Multi-stack golden path

Goal: onboard a tenant whose infrastructure spans several Terraform stacks (its own state per stack), with one command that opens the right pull requests in the right order — and never has Terrantula run apply itself.

This is the lookup recipe for the pattern. For a full hands-on walkthrough, do the Advanced Quick Start; for a runnable repo, see the examples gallery.

When to reach for the golden path

Use it when onboarding a tenant means creating more than one entity — a tenant record, an AWS account, an Argo project, a cluster binding — each in its own directory with its own statefile, so a bad apply for one never blocks the others. For the simplest single-stack case, a plain Onboard Actionis enough.

The shape

Five conventions and primitives carry the pattern. Each is a field on the catalog kinds you already know:

PieceWhere it livesWhat it does
One directory per entityYour IaC repoEach entity owns infra/<type>/<name>/, its own statefile, keyed by path.
One Apply Action per entity typeActionApply<Type> opens a PR with that entity's Terraform; one Action covers every instance of the type.
applierEntityTypeNames the Apply Action that auto-fires when an entity of this type is created.
dependsOnActionDeclares apply order by following relationship types — blocks a leaf apply until its upstreams succeed.
postMergeDispatchpull-request triggerOn merge, fires a repository_dispatch to one entity-generic workflow that runs terraform apply and calls back.

The entry point is a single create-entity Action (OnboardTenant) whose cascadeRules seed the upstream entities; from there each applier fires and dependsOn sequences the applies.

Step 1 — Model one stack per entity

Give every entity type that owns infrastructure an Apply Action and wire its applier. The Apply Action's trigger is a pull-request that commits the entity's Terraform into its own directory and dispatches the apply workflow on merge:

kind: EntityType
name: AWSAccount
applier: ApplyAWSAccount        # auto-fires when an AWSAccount is created
---
kind: Action
name: ApplyAWSAccount
associatedWith: { entityType: AWSAccount, scope: instance }
operation: { type: transition-entity, onTrigger: applying, onSuccess: active, onFailure: failed }
trigger:
  type: pull-request
  repo: "{{ project.iac_repo }}"
  title: "Apply AWSAccount {{ entity.name }}"
  head: "terrantula/apply-aws-account-{{ entity.name }}"
  base: main
  files:
    - path: "infra/aws-account/{{ entity.name }}/main.tf"
      content: |
        module "aws_account" {
          source      = "../_modules/aws-account"
          customer_id = "{{ entity.properties.customer_id }}"
        }
  postMergeDispatch:
    workflow: terrantula-entity-apply.yml
    eventType: terrantula-entity-apply
    payload:
      entity_id: "{{ entity.id }}"
      directory: "infra/aws-account/{{ entity.name }}"

Step 2 — Declare apply order withdependsOn

A leaf entity (the tenant) usually needs its upstreams applied first. Declare each dependency as a relationship to follow plus the action that must have succeeded for the linked entity:

kind: Action
name: ApplyTenant
associatedWith: { entityType: Tenant, scope: instance }
dependsOn:
  - { relationship: runs_in_aws_account, appliedAction: ApplyAWSAccount }
  - { relationship: has_argo_project,     appliedAction: ApplyArgoProject }
  - { relationship: placed_on_cluster,    appliedAction: ApplyClusterBinding }
operation: { type: transition-entity, onTrigger: applying, onSuccess: active, onFailure: failed }
# ... pull-request trigger as above, committing infra/tenant/{{ entity.name }}/ ...

ApplyTenant fires the moment the Tenant is created, sees its deps aren't ready, moves to blocked, then wakes and fires when the last upstream Apply succeeds. The dependency graph is validated as acyclic at apply time (DEPENDS_ON_CYCLE if you introduce a loop).

Step 3 — Seed the upstreams from one entry Action

OnboardTenant creates only the Tenant; its createEntity cascade rules create the upstream entities and the linking relationships as a side effect:

kind: Action
name: OnboardTenant
associatedWith: { entityType: Tenant, scope: collection }
parameters:
  - { name: customer_id, type: string, required: true }
operation:
  type: create-entity
  entityType: Tenant
  onTrigger: pending
  properties: { customer_id: "{{ parameters.customer_id }}" }
  cascadeRules:
    - phase: on-trigger
      createEntity:
        type: AWSAccount
        name: "aws-{{ parameters.customer_id }}"
        properties: { customer_id: "{{ parameters.customer_id }}" }
        relationship: { type: runs_in_aws_account, direction: from }
    - phase: on-trigger
      createEntity:
        type: ArgoProject
        name: "argo-{{ parameters.customer_id }}"
        relationship: { type: has_argo_project, direction: from }
    - phase: on-trigger
      createEntity:
        type: ClusterBinding
        name: "binding-{{ parameters.customer_id }}"
        relationship: { type: placed_on_cluster, direction: from }

Step 4 — Apply the catalog, connect the repo

apply upserts by name, so it's safe to re-run. Connect the IaC repo via the GitHub App so PRs can be opened and merge callbacks received, and ship the three entity-generic workflows (terrantula-entity-apply.yml, …-destroy.yml, …-drift.yml) — copy them from the examples customer-iac-repo/ starter.

terrantula apply --file blueprint.yaml

Step 5 — Fire one Action; watch the cascade

OnboardTenant is collection-scope, so it runs at the type level:

terrantula actions run --action-name OnboardTenant \
  --parameters '{"customer_id": "acme"}'

What happens:

  1. The Tenant is created pending; the cascade creates the three upstream entities plus their relationships.
  2. Each entity type's applier auto-fires. The three upstream applies have no deps, so they fire immediately and each opens a PR in its own directory. ApplyTenant fires, finds its deps unmet, and blocks.
  3. A reviewer merges each upstream PR. postMergeDispatch fires the repository_dispatch; the entity-generic workflow runs terraform apply in that directory and POSTs success back to Terrantula's callback URL. The entity transitions applying → active.
  4. When the last upstream succeeds, Terrantula unblocks ApplyTenant, which opens the tenant PR. Merge it; your CI applies; the tenant goes active.

Track progress at any point:

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

Result

One command produced four stacks across four PRs, applied in dependency order, each with isolated state — and Terrantula never ran terraform apply. Every step is a reviewable PR your CI applied.

Operating a live fleet

The same primitives give you surgical, per-entity operations — none require re-running OnboardTenant:

  • Re-apply one stack (drift correction). Fire the entity's Apply Action again: terrantula entities trigger --id <id> --action-name ApplyAWSAccount.
  • Deprovision one entity. Fire Deprovision<Type> — same dispatch shape, opposite semantics; the destroy runs before the directory is removed. The dependency graph is walked in reverse so a leaf tears down before its upstreams. See Deprovision a tenant.
  • Detect drift. A scheduled terraform plan -detailed-exitcode per directory reports drift back as an audit event; correcting it is just re-firing the Apply Action. See Cross-source drift.

Caveats

State backend config lives in each Apply Action, not the project

There is intentionally no project-level state-backend default — a single bucket breaks multi-account and multi-env fleets. Each Apply Action declares its own backend (backend.tf) in its filesblock, keyed by the entity's directory path. The demo uses one bucket; swap in your own per Action for multi-account.

Cross-entity data flows through Terraform, not Terrantula

When the tenant stack needs an upstream's output (an account ID, a cluster ARN), read it with Terraform's terraform_remote_state data source. Terrantula orchestrates order; it does not pass outputs between stacks.

The PR is the approval gate

Each apply is a reviewable PR. Terrantula opens it and dispatches the apply on merge; your CI runs Terraform. Don't auto-merge if review is your gate — the ordering still holds, but you lose the human checkpoint.