Quick Start — Advanced

This is the cattle wedge end-to-end. You'll build a fresh multi-tenant SaaS layout where one command onboards a tenant: a single Action creates four entities across four Terraform stacks, opens three pull requests in parallel for the upstream stacks, blocks the tenant stack until those merge and apply, then opens the tenant PR.

Crucially, Terrantula never runs terraform apply. Actions open PRs against your IaC repo; your existing GitHub Actions CI applies on merge and reports back. Terrantula owns the cascade graph, the apply ordering, and the per-entity lifecycle — not your runners, not your state.

Do the Simple tutorial first. This page assumes the CLI is installed and you've seen the local dashboard. It also assumes you're comfortable with entities, cells, relationships, and Actions — if not, read Core Concepts before continuing.

This walkthrough mirrors the canonical demo at examples/cattle-multi-stack-greenfield/ in the repo. Clone it to follow along; every file referenced below ships there.

The scenario

You're building a multi-tenant SaaS from scratch. Every customer needs:

  • A dedicated AWS sub-account (an isolation boundary).
  • A registered Argo CD project for their workloads.
  • A cluster + namespace binding placing them on a specific cluster.
  • The tenant control-plane resources (RDS, S3, IAM roles) — provisioned only after the three upstreams are real.

Each lives in its own Terraform stack with its own statefile, so a bad apply for tenant A can never block tenant B. The Tenant stack reads outputs from the three upstreams via terraform_remote_state, so it must apply after them.

Done by hand, this is a Slack thread and four manual terraform apply runs in the right order, hoping nothing fails halfway. With Terrantula it's one command and zero ad-hoc orchestration.

The shape: four entity types, one Action

The blueprint (examples/cattle-multi-stack-greenfield/terrantula/blueprint.yaml) declares:

  • 4 EntityTypesAWSAccount, ArgoProject, ClusterBinding, Tenant. Each declares an applier: the Apply Action that fires automatically when an entity of that type is created.
  • 3 RelationshipTypesruns_in_aws_account, has_argo_project, placed_on_cluster, all from Tenant to its upstreams.
  • 4 Apply ActionsApplyAWSAccount, ApplyArgoProject, ApplyClusterBinding, ApplyTenant. Each opens a PR with one entity's Terraform.
  • 1 entry ActionOnboardTenant, a create-entity Action with cascadeRules that create the three upstream entities plus the three relationships.

The key idea: apply order isn't hardcoded as imperative steps — it falls out of the entity graph. ApplyTenant declares dependsOn against the three upstream applies, so it waits for them automatically.

# blueprint.yaml (excerpt) — Tenant's applier waits on its three upstreams
kind: EntityType
metadata:
  name: Tenant
spec:
  applier: ApplyTenant
  properties:
    - { name: customer_id,       type: string, required: true }
    - { name: plan_tier,         type: string, enum: [basic, premium], required: true }
    - { name: region_preference, type: string, required: true }
---
kind: Action
metadata:
  name: ApplyTenant
spec:
  dependsOn:
    - ApplyAWSAccount
    - ApplyArgoProject
    - ApplyClusterBinding
  # ... opens a PR whose Terraform reads the three upstream stacks via
  #     terraform_remote_state, gated until all three have succeeded.
---
kind: Action
metadata:
  name: OnboardTenant
spec:
  parameters:
    - { name: customer_id,       type: string, required: true }
    - { name: plan_tier,         type: string, enum: [basic, premium], required: true }
    - { name: region_preference, type: string, required: true }
  operation:
    type: create-entity
    entityType: Tenant
    name: "Tenant-{{ parameters.customer_id }}"
    cascadeRules:
      # creates aws-<id>, argo-<id>, binding-<id> + the three relationships

Step 1 — Connect to a Terrantula server

The cascade needs the worker and trigger machinery, so this flow runs against a Terrantula API server rather than the purely-local dashboard. Self-host it (it's Apache 2.0, end-to-end) or use a hosted project — either works.

terrantula login --base-url https://terrantula.your-domain.com --token terr_xxxxxxxx
terrantula use <project-id>

In CI or a Makefile you can skip the saved login and drive it with env vars:

export TERRANTULA_BASE_URL=https://terrantula.your-domain.com
export TERRANTULA_TOKEN=terr_xxxxxxxx
export TERRANTULA_ORG_ID=<your-org-slug>
export TERRANTULA_PROJECT_ID=<your-project-id>

Self-hosting? See Install → Self-host the backend. The cattle wedge runs with zero SaaS dependencies — Terrantula OSS, your own Postgres, the pg-boss queue, and your own GitHub Actions for the apply.

Step 2 — Wire your IaC repo

Terrantula opens PRs against your repo; your CI applies them. The demo's customer-iac-repo/ shows the layout you ship once and never edit per tenant:

customer-iac-repo/ ├── .github/workflows/ │ ├── terrantula-entity-apply.yml # entity-generic `terraform apply` │ ├── terrantula-entity-destroy.yml # entity-generic `terraform destroy` │ └── terrantula-entity-drift.yml # daily drift scan └── infra/_modules/ # one reusable module per entity type ├── aws-account/ ├── argo-project/ ├── cluster-binding/ └── tenant/

The three workflows are entity-generic: the per-entity directory (e.g. infra/tenant/Tenant-acme/) comes from the repository_dispatch payload, so new entity types never require workflow edits. Copy these from examples/cattle-multi-stack-greenfield/customer-iac-repo/ into your repo, then connect the repo to Terrantula via the GitHub App so PRs can be opened and merge callbacks received.

Step 3 — Apply the blueprint

Apply the catalog (entity types, relationship types, Actions) to your project. apply upserts by name, so it's safe to re-run:

terrantula apply --file ./terrantula/blueprint.yaml

Preview first with a server-side plan that writes nothing:

terrantula apply --file ./terrantula/blueprint.yaml --dry-run

Expected:

✓ blueprint.yaml applied: 18 items succeeded.

You now have the schema for the multi-stack greenfield pattern — but no tenants yet.

Step 4 — Fire the cascade

One command onboards a tenant. Parameters are passed as a JSON object to --parameters:

terrantula actions run \
  --action-name OnboardTenant \
  --parameters '{"customer_id":"acme","plan_tier":"premium","region_preference":"us-east-1"}'

Expected:

✓ OnboardTenant ActionRun created: <run-id>

That single call kicks off the whole cascade. The next steps describe what Terrantula does on your behalf — you don't run them.

Step 5 — The cascade builds the graph

  1. The entry Action runs. Terrantula creates the Tenant-acme entity in its initial state (pending).
  2. Cascade createEntity rules fire. OnboardTenant's cascadeRules create three more entities — aws-acme, argo-acme, binding-acme — plus the three relationships from Tenant-acme to each. After this step the graph has 4 entities and 3 relationships.
  3. Each entity type's applier auto-fires. Because every EntityType declares an applier, Terrantula enqueues four ActionRuns: ApplyAWSAccount, ApplyArgoProject, ApplyClusterBinding, and ApplyTenant.

You can watch this in the dashboard (read-only) or from the CLI:

terrantula entities list --entity-type Tenant
terrantula action-runs list

Step 6 — Three PRs open, the fourth blocks

Action.dependsOn gates ordering:

  • The three upstream applies have no dependencies, so they fire immediately. Each opens a pull request containing one entity's Terraform (infra/<type>/<name>/main.tf + backend.tf).
  • ApplyTenant declares dependsOn against all three. It inspects the relationship graph, sees the upstreams haven't succeeded yet, and transitions to blocked — it never tries to open its PR early.

At this point: three open PRs in your repo, and ApplyTenant parked in blocked.

Terrantula opened PRs — it did not apply anything. Your infrastructure is unchanged until a human (or your automation) reviews and merges. This is the binding guardrail: Terrantula proposes change via PRs; your CI is the only thing that runs terraform apply.

Step 7 — Merge the upstreams; your CI applies

A reviewer merges each upstream PR. On merge, the pull-request trigger's postMergeDispatch fires a repository_dispatch event in your repo. The entity-generic terrantula-entity-apply.yml workflow runs terraform apply in that entity's directory and POSTs success back to Terrantula's callback URL. The entity transitions pending → applying → active.

Each time an upstream apply succeeds, Terrantula re-evaluates every blocked ActionRun whose dependsOn includes the just-succeeded entity (block-and-resume). After the third upstream succeeds, ApplyTenant's dependencies are all satisfied.

Step 8 — The tenant PR opens and applies

ApplyTenant leaves blocked and opens its PR. The Tenant Terraform reads the three upstream stacks' outputs via terraform_remote_state and composes the tenant control-plane resources. Merge → dispatch → apply → callback → Tenant-acme transitions to active.

Net effect: one command, the right four PRs in the right order, no human orchestration. Your GitHub review workflow, CI runner, state backend, and cloud credentials are all unchanged.

Run it locally end-to-end

The demo ships a runner that boots a local stack, applies the blueprint, fires OnboardTenant, and asserts the cascade graph:

bash examples/cattle-multi-stack-greenfield/run-demo.sh

Without a real GITHUB_TOKEN the PR-open calls fail (401) but the cattle-thesis assertions still pass — the cascade graph is built (4 entities + 3 relationships), the applier auto-fire happened (4 ActionRuns enqueued), and dependsOn ordering is respected (ApplyTenant reaches blocked, never firing its PR).

To open real PRs, point it at a fork of the demo's customer-iac-repo/:

export GITHUB_TOKEN=<your-pat-with-repo-scope>
export GITHUB_TARGET_REPO=<your-org>/<your-iac-repo-fork>
bash examples/cattle-multi-stack-greenfield/run-demo.sh

Why this is the golden path

This is the cattle thesis as architecture:

  1. One stack per entity. A bad apply for one tenant can't corrupt another's state. Re-applies are surgical.
  2. One Apply Action per entity type, not per entity. Adding the 5,000th tenant adds zero schema.
  3. One entity-generic workflow. You drop apply/destroy/drift into your repo once and never edit them per entity.
  4. Relationship-driven ordering. Apply order is a consequence of the graph (dependsOn over relationships), not a hand-written playbook. Grow the graph, and ordering follows.

The operator names the fleet, not the pet: every entity is interchangeable within its type, the graph carries the structure, and the apply lifecycle is a deterministic consequence of declarative dependencies.

Next steps