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.
You're building a multi-tenant SaaS from scratch. Every customer needs:
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 blueprint (examples/cattle-multi-stack-greenfield/terrantula/blueprint.yaml) declares:
AWSAccount, ArgoProject, ClusterBinding, Tenant. Each declares an applier: the Apply Action that fires automatically when an entity of that type is created.runs_in_aws_account, has_argo_project, placed_on_cluster, all from Tenant to its upstreams.ApplyAWSAccount, ApplyArgoProject, ApplyClusterBinding, ApplyTenant. Each opens a PR with one entity's Terraform.OnboardTenant, 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.
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.
In CI or a Makefile you can skip the saved login and drive it with env vars:
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.
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:
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.
Apply the catalog (entity types, relationship types, Actions) to your project. apply upserts by name, so it's safe to re-run:
Preview first with a server-side plan that writes nothing:
Expected:
You now have the schema for the multi-stack greenfield pattern — but no tenants yet.
One command onboards a tenant. Parameters are passed as a JSON object to --parameters:
Expected:
That single call kicks off the whole cascade. The next steps describe what Terrantula does on your behalf — you don't run them.
Tenant-acme entity in its initial state (pending).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.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:
Action.dependsOn gates ordering:
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.
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.
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.
The demo ships a runner that boots a local stack, applies the blueprint, fires OnboardTenant, and asserts the cascade graph:
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/:
This is the cattle thesis as architecture:
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.
apply, actions run, export, and the import variants.