Skip to content

Stack instances ​

A stack directory can be deployed several times, once per instance. Each instance runs the same code with its own state object, var files, environment variables and GitHub environment. A network component deployed to staging and production is one directory and two instances.

Every instance is a stack of its own wherever a stack appears: the graph, the affected set, waves, locks, runs, check runs, plan artifacts, drift and the UI. The CLI renders every setting on this page from the checkout it runs in. The server re-resolves the policy of an apply, its environment, allowed_teams and plan_output, from the default branch with the same rules.

Keys and names ​

An instance is keyed path:instance. The suffix names an instance of the directory; it is never, by itself, a Terraform workspace. The one exception is the CLI's --stack path:x on a directory without instances, where x selects the ad hoc workspace x; see Stack keys.

text
infra/network                 a directory with no instances
infra/network:production      the production instance of infra/network
infra/network:staging         the staging instance of the same directory
acme/infra//infra/dns:shared  an instance in another repository
RuleValue
CharactersA letter or digit, then letters, digits, ., _ and -
LengthAt most 64 characters
Reserveddefault, in any letter case, is never an instance name, since GitHub environment names ignore case

The same name is safe in check run names, comment commands, plan artifact names and GitHub environment names.

An instance selects a Terraform workspace only when its workspace says so. Most instances need none: they differ by their backend key. See Workspaces.

Declaring instances ​

There are four ways to declare the instances of a directory. The first that applies wins:

  1. The names in the stack's instances key.
  2. One instance per file that the root stacks.instances.from_var_files glob matches in the stack directory.
  3. The stack's workspace, as one instance of that name. A workspace of default, or one that renders empty, is no workspace, and one that renders to an invalid instance name is an error.
  4. Otherwise one instance with an empty name, keyed by the bare path.

From var files ​

A monorepo that keeps one var file per environment in each component can derive the instances from those files:

yaml
# stackorder.yaml
stacks:
  instances:
    from_var_files: "workspaces/*.tfvars.json"
text
infra/network/workspaces/production.tfvars.json   ->  infra/network:production
infra/network/workspaces/staging.tfvars.json      ->  infra/network:staging
  • The glob is relative to each stack directory.
  • An instance is named by the file's base name up to its first ., so production.tfvars.json gives production. Instances are sorted by name. Two files that derive the same name, such as production.tfvars and production.tfvars.json, are an error that names them.
  • The matched file is one of the instance's var files, after the root and stack var_files.
  • A stack directory with no matching file falls through to the next rule.
  • A derived name that is not a valid instance name, such as default from default.tfvars.json, is an error that names the file.

Adding a file adds an instance, which the pull request plans. Deleting one removes the instance from the graph; nothing is destroyed.

An explicit list ​

A stack's .stackorder.yaml can name its instances:

yaml
# infra/dns/.stackorder.yaml
instances: [shared]

A null item in the list, a - with nothing after it, is an error, and so is a name listed twice. The list replaces anything from_var_files would derive for the directory. A matched var file whose name equals a listed instance is still that instance's var file. A matched file whose name is not listed is not used, and the scan warns about it.

An empty list or map, instances: [] or instances: {}, declares nothing: the directory falls through to from_var_files and the later rules. To keep a directory with matching var files out of the graph, exclude it with stacks.exclude.

A map with overrides ​

The map form names the instances and overrides settings for some of them:

yaml
# infra/kyc/.stackorder.yaml
instances:
  staging: {}
  production:
    environment: infra-production
    apply:
      allowed_teams: [platform-prod]
    env:
      TF_VAR_replicas: "3"

An override may set environment, workspace, backend_config, var_files, env, plan_output, apply.allowed_teams, depends_on and ignore_inferred. The map is the instance set, like the list, so name every instance, even those with nothing to override. A map that lists only production drops staging from the directory.

In JSON, such as a stack's config in the API, instances is always a map.

A workspace stack without instances ​

A stack that sets workspace: blue and declares no instances is one instance named blue, keyed path:blue, whose Terraform workspace is blue.

Like any instance, it runs its applies under the environment of its own name, blue, unless something maps it elsewhere. Set environment: default in its .stackorder.yaml to apply under default. See GitHub environments.

Workspaces ​

An instance selects no Terraform workspace unless its workspace is set; only the CLI's ad hoc --stack path:x on a directory without instances selects one from the suffix alone. Instances then differ by their state key, which backend_config renders per instance.

To give each instance a workspace of its own name instead, set a template in the stack's .stackorder.yaml, or in an instance override:

yaml
workspace: "{{ .Instance }}"

The CLI then runs workspace select -or-create after init, and the state object is <workspace_key_prefix>/<workspace>/<key>, env:/production/network.tfstate for the key network.tfstate and the default prefix. Two instances with neither a workspace nor a key of their own share one state object, and the scan warns about it.

How settings merge ​

The settings of an instance come from three levels: the root stackorder.yaml, the stack's .stackorder.yaml, and the instance's override in instances.

SettingEffective value
backend_configRoot entries, then stack entries, then instance entries, in that order
var_filesRoot entries, then stack entries, then the instance's from_var_files file if any, then instance entries, in that order
envPer variable name: the instance's value, else the stack's, else the root's
depends_on, ignore_inferredThe stack's list followed by the instance's
environmentThe first of the instance's environment, the stack's and the best match in the root environments map that renders non-empty, else the instance name, else default
workspaceThe instance's, else the stack's, rendered; none when neither is set or it renders empty or default
plan_outputThe instance's, else the stack's, else the root's
tool, tool_versionThe stack's, else the root's
apply.allowed_teamsThe most specific non-empty list: the instance's, else the stack's, else the root's

workspace, plan_output, tool and tool_version take the most specific value that is set. workspace is then rendered, and an empty result does not fall back to a less specific level. plan_output, tool and tool_version are not templates. environment is rendered too, but an empty result falls through to the next source.

An env value is replaced as a whole. An instance that sets TF_VAR_role: admin replaces a stack's { plan: reader, apply: deployer }, for every mode.

The server reads environment, plan_output and apply.allowed_teams of an instance from the default branch when an apply starts, as it does for any stack. The CLI reads everything else from the checkout it runs in.

Templates ​

Values can refer to the stack and the instance they are rendered for:

yaml
backend_config:
  - 'key={{ trimPrefix "infra/" .Path }}/{{ .Instance }}.tfstate'
env:
  TF_VAR_environment: "{{ .Instance }}"

Templates are Go text/template. They render once per instance.

DataValue for infra/network:production
.Pathinfra/network
.Namenetwork, the last path segment
.Instanceproduction; empty for a stack with no instances
.Keyinfra/network:production
FunctionExampleResult
trimPrefix prefix strimPrefix "infra/" .Pathnetwork
trimSuffix suffix strimSuffix "/network" .Pathinfra
base sbase .Pathnetwork
dir sdir .Pathinfra
replace old new sreplace "/" "-" .Pathinfra-network
lower s, upper supper .InstancePRODUCTION

Templates also have the builtins and, or, not, eq, ne, lt, le, gt and ge, and the actions if and with:

yaml
workspace: '{{ if eq .Instance "legacy" }}default{{ else }}{{ .Instance }}{{ end }}'

Templates apply to environments values, environment, workspace, backend_config, var_files, env values, depends_on and ignore_inferred. A string without {{ is used as it is.

  • Quoting. A YAML value that starts with { is read as a map, so quote every template. Use single quotes around a template that contains double quotes: 'key={{ trimPrefix "infra/" .Path }}.tfstate'.
  • Errors. A name that does not exist, such as .Instanse, is an error, not an empty string. A template that fails to parse or render is a validation error of the file that holds it.
  • Limits. Templates are restricted so that a repository's configuration cannot stall the server, which renders them too. range, define, block, template, break, continue and every other builtin, such as printf, index or len, are refused. A template, or one function call in it, whose output exceeds 4096 bytes is an error.
  • Empty instance. .Instance is empty for a stack with no instances, so {{ .Instance }}.tfstate renders as .tfstate there. Give such stacks an instance, or keep instance templates out of the settings they read.

backend_config ​

backend_config is a list of -backend-config values for init. It exists at the root, in a stack's .stackorder.yaml and in an instance override, and the lists concatenate in that order.

yaml
backend_config:
  - infra/state.s3.tfbackend                                     # a file
  - 'key={{ trimPrefix "infra/" .Path }}/{{ .Instance }}.tfstate' # one attribute
  • A value that contains = is name=value. Anything else is a file, relative to the repository root.
  • The CLI passes files as absolute paths, then the values of STACKORDER_BACKEND_CONFIG. Terraform takes the last value of an attribute, so STACKORDER_BACKEND_CONFIG can override the configuration.
  • When backend_config is not empty, init also gets -reconfigure, so two instances of one directory can run one after the other in the same checkout.
  • A root backend_config applies to every stack. See Where to put it.

The stack still declares its backend in code, usually as an empty block:

hcl
terraform {
  backend "s3" {}
}

Where to put it ​

A root backend_config is concatenated into every stack and overlays each stack's backend block at init, exactly as if every terraform init of the repository got those -backend-config values. A key= entry therefore changes the state key of a stack that sets key in its block, and init -reconfigure then points that stack at a new, empty state object.

  • Use a root backend_config only in a repository where every discovered stack takes its backend from it: every stack has a partial backend "s3" {} block.
  • In a repository that mixes partial and full backend blocks, put backend_config in the .stackorder.yaml of each stack that needs it.
  • Keep directories with a full backend block, or with none, out of a root-configured repository with stacks.exclude.

The effective backend ​

The scanner computes each instance's S3 backend without running Terraform. It starts from the literal attributes of the backend "s3" block and applies the rendered backend_config entries in order: a file is parsed as HCL attributes, and name=value sets one attribute. It reads bucket, key, region, dynamodb_table, use_lockfile and workspace_key_prefix, and ignores the rest. A backend_config file that does not exist fails the scan.

That backend is what the graph records for the instance. Inferred reads_state edges and the warning about two stacks sharing one state object therefore work per instance, and catch two instances whose templates render the same key.

var_files ​

var_files is a list of -var-file values. Paths are relative to the stack directory, whichever file declares them.

yaml
# stackorder.yaml
var_files:
  - ../common.tfvars
# infra/kyc/.stackorder.yaml
var_files:
  - "regions/{{ .Instance }}.tfvars"

An instance's var files are, in order: the root entries, the stack entries, its from_var_files file, then the instance entries. Terraform takes the last value of a variable, so a later, more specific file wins: the environment's file beats shared defaults, and the instance's own entries beat the environment's file. Every var file wins over a TF_VAR_ variable from env. terraform.tfvars and *.auto.tfvars in the stack directory still load as usual, before the var files.

CommandVar files
stackorder planPassed to plan
stackorder apply from a saved planNot passed: the values are in the plan file
stackorder apply when it re-plansPassed to the re-plan
stackorder driftPassed to plan

A listed var file that does not exist is a warning in the scan, and an error at plan time that names the file.

env ​

env sets environment variables for the Terraform or OpenTofu process and for the hooks. A value is a string, or an object with a value per mode.

yaml
env:
  TF_VAR_environment: "{{ .Instance }}"
  TF_VAR_role:
    plan: reader
    apply: deployer
ModeUsed by
planstackorder plan
applystackorder apply, including its re-plan when the plan file is missing or apply.from_plan is false
driftstackorder drift; without a drift value the variable takes the plan value

A mode the object leaves out, other than drift, leaves the variable as the job already has it: Stackorder neither sets nor removes it.

  • Merging. Variables merge by name: the instance's value, else the stack's, else the root's.
  • Order. The variables are set on top of the job's environment and the automation variables. Then the CLI sets STACKORDER_STACK (the key), STACKORDER_STACK_PATH and STACKORDER_INSTANCE, which env cannot override.
  • Names. A name matches [A-Za-z_][A-Za-z0-9_]*. It may not start with STACKORDER_, GITHUB_, ACTIONS_ or RUNNER_, nor be PATH or HOME, in any letter case: github_token and Path are refused too.
  • Values. A value may not be null: TF_VAR_x: with nothing after it is an error. Write "" for an empty string.
  • Redaction. A value whose variable name looks like a secret, such as DB_PASSWORD, is masked in everything the CLI reports, under the same rule as the process environment (see Secrets). The values are still in the repository: never put a secret in env.
  • Where it applies. env reaches only what the CLI runs. The workflow steps before it, such as the AWS credentials step, never see it.

Plan-time values and ephemeral variables ​

A saved plan freezes the value of every input variable it was planned with. stackorder apply applies the saved plan, so the apply value of a TF_VAR_ variable has no effect on an ordinary variable: the plan's value is used.

Only an ephemeral variable takes its value at apply time. Declare a variable ephemeral when its apply value must differ from its plan value, such as the role a provider assumes:

hcl
variable "role" {
  type      = string
  ephemeral = true
}

Ephemeral input variables need Terraform 1.10 or later, or an OpenTofu release that supports them. An ephemeral variable can be used in provider configuration, but not stored in a resource. The bootstrap role model depends on this.

Change detection ​

A changed file in a stack directory affects every instance of that directory. Instances cannot be told apart by path: infra/network/main.tf is code they all run.

A stack also reads files outside its directory: backend_config files and var files such as ../common.tfvars. These are the stack's watch paths. A changed path equal to a watch path affects that stack with the reason watch_path, so a change to infra/state.s3.tfbackend plans every stack that reads it. The API lists them as the stack's watch_paths.

The graph's tree hash covers *.tfvars, *.tfvars.json and *.tfbackend files as well as Terraform and Stackorder configuration, so changing a var file never reuses a cached graph.

Dependencies between instances ​

A depends_on entry may name an instance, and may use a template:

yaml
# infra/kyc/.stackorder.yaml
depends_on:
  - infra/network                 # the same instance: infra/network:production for infra/kyc:production
  - infra/dns:shared              # one named instance
  - "infra/iam:{{ .Instance }}"   # the same instance, and never falls back to the target's only stack

A rendered entry without a suffix that names a directory with instances resolves:

  1. to the instance of the same name as the depending instance, when the target has one;
  2. else to the target's only stack, when it has exactly one;
  3. else it is kept as written, with a warning, like any unknown target.

The stack's list and the instance's list concatenate, so an instance can add a dependency the others do not have. ignore_inferred works the same way.

A cross-repository entry, owner/repo//path:instance, names the instance explicitly: the scanner cannot look inside another repository.

GitHub environments ​

The apply job of an instance runs under one GitHub environment, chosen in this order:

  1. the instance override's environment;
  2. the stack's environment;
  3. the best match in the root environments map;
  4. the instance name;
  5. default.

The first of 1 to 3 that renders non-empty wins, so a template such as '{{ if eq .Instance "production" }}prod{{ end }}' that renders empty for other instances falls through to the next source.

An instance is therefore protected by the GitHub environment of its own name unless it is mapped elsewhere. infra/network:production applies under production with no configuration at all.

environments keys take three forms:

KeyMatches
infra/Every stack under infra/
infra/:productionThe production instance of every stack under infra/
:productionThe production instance in any directory

Prefixes match whole path segments, and an empty prefix matches every path. The most specific key wins: a key with an instance part before one without, then the longest prefix. Values are templates.

  • An empty prefix needs an instance part: :production is valid, and a key "", / or ./ alone is rejected.
  • A prefix may not contain :, and the instance part must be a valid instance name.
  • Two keys whose prefixes name the same path, such as infra, infra/ and ./infra, with the same instance part are an error.

To map instances to GitHub environments with other names, use one of:

yaml
environments:
  "infra/": "infra-{{ .Instance }}"   # infra/network:production -> infra-production
  ":production": prod-apply           # any production instance -> prod-apply

or an override in the stack's instances map. A stack with no instance name that matches nothing still runs under default.

The environment of an apply comes from the default branch, but a pull request can add an instance, or a directory, that the default branch does not declare. It then runs under whatever the default branch's rules give its name, which for an unmapped instance is an environment of that name that GitHub creates unprotected. See Per instance for what stops such an apply and how to close the gap.

Only applies run under the instance's environment. Pull request plans have no environment, and the plans and drift checks the server dispatches run under default, as for every stack. See Which environment and role a job gets.

AWS roles ​

The reusable workflows assume one AWS role per job, before the CLI starts. For apply jobs, aws-role-arn-map selects it from the stack key:

KeyMatches
infra/Every stack under infra/
infra/network:productionThat instance only
:productionThe production instance in any directory

The first match wins, in this order: the exact key, then :instance, then the longest prefix, then aws-role-arn. A key that contains : never acts as a prefix. Unlike environments there is no prefix:instance form; an exact key covers that case. Pull request plan jobs in plan.yml select the same way. Plan and drift jobs that the server dispatches ignore the map and keep the single plan role, aws-plan-role-arn.

yaml
aws-role-arn-map: '{":production": "arn:aws:iam::123456789012:role/stackorder-apply-production", ":staging": "arn:aws:iam::123456789012:role/stackorder-apply-staging"}'

aws-role-session-name names the AWS session, which shows in CloudTrail. It is a name, or a JSON object with a name per mode; drift falls back to plan, and plan.yml always uses plan:

yaml
aws-role-session-name: '{"plan": "stackorder-plan", "drift": "stackorder-drift", "apply": "stackorder-apply"}'

Every character outside [A-Za-z0-9_+=,.@-] becomes -, and the name is cut at 64 characters. Nothing is added to it, so every stack of a job's mode gets the same name. Empty, or an object with no value for the mode, keeps the credentials action's default, GitHubActions. A session name is chosen by the workflow, so it is a label, not a security boundary.

See Reusable workflow inputs.

One bootstrap role and a provider role per account ​

Many organisations keep state in one account and resources in several. The jobs then assume one bootstrap role through OIDC, which can read and write state, and the AWS provider assumes a role in the target account. The provider's role differs between plan and apply, and Stackorder chooses it with env.

The example below is for the fictional organisation acme, with the repository acme/infra:

AccountIdHolds
shared123456789012The state bucket, the two bootstrap roles, and shared resources with their provider roles
staging345678901234Staging resources and its provider roles
production210987654321Production resources and its provider roles

The roles ​

RoleAccountTrusted byPermissions
stackorder-plansharedOIDC: repo:acme/infra:pull_request and repo:acme/infra:environment:defaultRead state, write the state lock, sts:AssumeRole on arn:aws:iam::*:role/stackorder-reader
stackorder-applysharedOIDC: repo:acme/infra:environment:<name> for shared, staging and productionRead and write state, sts:AssumeRole on arn:aws:iam::*:role/stackorder-deployer
stackorder-readereach target accountarn:aws:iam::123456789012:role/stackorder-planThe read-only permissions the providers need to plan
stackorder-deployereach target accountarn:aws:iam::123456789012:role/stackorder-apply onlyThe write permissions the stacks need

The S3 backend uses the bootstrap credentials, not the provider's, so the bootstrap roles need state access and the provider roles need none.

The provider ​

Each stack takes its environment and its provider role as variables. The role is ephemeral, so an apply of a saved plan uses the apply value instead of the one frozen at plan time.

hcl
variable "environment" {
  type = string
}

variable "role" {
  type      = string
  ephemeral = true
}

locals {
  account_id = {
    shared     = "123456789012"
    staging    = "345678901234"
    production = "210987654321"
  }[var.environment]
}

provider "aws" {
  region = "us-east-1"

  assume_role {
    role_arn     = "arn:aws:iam::${local.account_id}:role/${var.role}"
    session_name = "stackorder-${var.environment}"
  }
}

Without ephemeral = true, the apply would reuse the plan's stackorder-reader and fail on its first write.

The configuration ​

yaml
# stackorder.yaml
env:
  TF_VAR_environment: "{{ .Instance }}"
  TF_VAR_role:
    plan: stackorder-reader     # drift takes this value too
    apply: stackorder-deployer

The workflows ​

stackorder-plan.yml passes the plan bootstrap role:

yaml
jobs:
  plan:
    permissions:
      id-token: write
      contents: read
      actions: read
      checks: write
      pull-requests: read
    uses: stackorder/actions/.github/workflows/plan.yml@v1
    with:
      server-url: ${{ vars.STACKORDER_SERVER_URL }}
      aws-role-arn: arn:aws:iam::123456789012:role/stackorder-plan
      aws-role-session-name: stackorder-plan
    secrets: inherit

stackorder-run.yml passes both, with a session name per mode:

yaml
jobs:
  run:
    permissions:
      id-token: write
      contents: read
      actions: read
      checks: write
    uses: stackorder/actions/.github/workflows/run.yml@v1
    with:
      server-url: ${{ vars.STACKORDER_SERVER_URL }}
      run-id: ${{ inputs.run_id }}
      mode: ${{ inputs.mode }}
      wave: ${{ inputs.wave }}
      sha: ${{ inputs.sha }}
      stacks: ${{ inputs.stacks }}
      aws-plan-role-arn: arn:aws:iam::123456789012:role/stackorder-plan
      aws-role-arn: arn:aws:iam::123456789012:role/stackorder-apply
      aws-role-session-name: '{"plan": "stackorder-plan", "drift": "stackorder-plan", "apply": "stackorder-apply"}'
    secrets: inherit

The rest of stackorder-run.yml, its run-name and its dispatch inputs, is on Workflows.

What the trust policies still enforce ​

  • Plan jobs cannot deploy. A pull request controls its own .stackorder.yaml and can set TF_VAR_role to stackorder-deployer for its plans. The plan bootstrap role may not assume it, because each deployer role trusts only stackorder-apply. Keep that trust narrow: it is the boundary.
  • The environment gate decides who gets stackorder-apply. Its trust policy lists the subject of every environment an instance applies under, and nothing else. Never add environment:default.
  • AWS no longer tells staging from production. Once a job holds stackorder-apply, it can assume the deployer role of every account. A staging apply runs the pull request's code, and that code can name the production account. To keep the accounts apart, create one apply bootstrap role per environment, each trusting only its own environment's subject, select it with aws-role-arn-map keys such as :production, and let each account's deployer role trust only its own bootstrap role.
  • Session names are labels. The workflow chooses them, so a trust condition on sts:RoleSessionName adds nothing.

Limits ​

  • One hour. A session obtained with another role's session is role chaining, and AWS limits a chained session to one hour whatever the role's maximum session duration. The bootstrap session from configure-aws-credentials also lasts one hour by default. An apply that runs longer fails with expired credentials; split stacks that take that long.
  • Plans cannot use environment secrets. Pull request plans have no GitHub environment and dispatched plans run under default, so the provider role, like everything else a plan needs, must come from configuration or the repository's own secrets.

Migrating from Terrateam ​

.terrateam/config.ymlStackorder
dirs with a list of workspacesStack discovery with stacks.discover or stacks.include, and instances from stacks.instances.from_var_files or a stack's instances list. Terrateam is opt-in: nothing runs until a directory has its own dirs entry. stacks.discover is opt-out: every matching directory with a backend "s3" block is a stack. To keep opt-in behaviour while you migrate, use a narrow discover plus stacks.include for the enabled directories, or list the directories not yet migrated in stacks.exclude
create_and_select_workspace: falseThe default: an instance selects no workspace and differs by its backend key
create_and_select_workspace: trueworkspace: "{{ .Instance }}" at the stack or in an instance override
when_modified file patternsNot needed: a stack is affected by its directory, the local modules it uses at any depth, and its watch paths. stacks.ignore removes files; there is no way to add arbitrary patterns
Directories that must never runstacks.exclude
apply_requirements approvedapply.require_approvals, and apply.require_codeowner_review for code owners
apply_requirements merge_conflictsAlways on: a pull request whose merge state is dirty is refused
apply_requirements status_checksNo equivalent for arbitrary status checks; a named check from a hook gates the apply
access_controlapply.allowed_teams, per stack or per instance, and the GitHub environment of each instance
An env step setting TF_VAR_*env, with a plan and an apply value where they differ
An env step computing the state keyA key= entry in backend_config with a template
init extra_args: ["-backend-config=…"]backend_config: at the root only when every stack has a partial backend block, otherwise in each stack's .stackorder.yaml
TF_CLI_ARGS_plan="-var-file=…"var_files, or from_var_files for one file per instance
An oidc step with a role per modeaws-plan-role-arn and aws-role-arn on the workflows, and aws-role-session-name
TERRATEAM_DIR, TERRATEAM_WORKSPACE in scriptsSTACKORDER_STACK_PATH and STACKORDER_INSTANCE in hooks, .Path and .Instance in templates
Hooks and custom workflow steps.stackorder/hooks/pre-plan.sh and the other hooks
default_branch_overridesApply policy (apply, environments, allowed_teams, plan_output) always comes from the default branch; env, backend_config and var_files come from the commit being planned or applied
engine and its versiontool and tool_version

The last row matters for review. A pull request can change the env, backend_config and var_files its own apply uses, as it can change the code. Put stackorder.yaml and every .stackorder.yaml under a code owner you trust with production.

Complete example ​

A monorepo with one directory per component, one var file per environment, and one shared backend file:

text
infra/
  state.s3.tfbackend
  modules/
    vpc/
  network/
    backend.tf                  terraform { backend "s3" {} }
    main.tf
    workspaces/
      production.tfvars.json
      staging.tfvars.json
  kyc/
    .stackorder.yaml
    backend.tf
    main.tf
    workspaces/
      production.tfvars.json
      staging.tfvars.json
  dns/
    .stackorder.yaml
    backend.tf
    main.tf
  state-backend/
    main.tf                     creates the state bucket; local state, applied by hand
  bootstrap/
    backend.tf                  a full backend "s3" block with its own bucket and key
    main.tf                     the OIDC bootstrap roles; applied by hand

Every stack that Stackorder runs has a partial backend "s3" {} block, so the backend configuration can live at the root. infra/bootstrap sets its own key and must not get the root's, so it is excluded with infra/state-backend; in a repository where such stacks must run too, move backend_config into the .stackorder.yaml of each stack that needs it.

infra/state.s3.tfbackend holds everything but the key:

hcl
bucket       = "acme-terraform-state"
region       = "us-east-1"
encrypt      = true
use_lockfile = true

stackorder.yaml:

yaml
version: 1

stacks:
  discover: ["infra/**"]
  exclude: ["infra/state-backend", "infra/bootstrap"]
  instances:
    from_var_files: "workspaces/*.tfvars.json"

modules:
  paths: ["infra/modules/**"]

tool: terraform
tool_version: "1.13.0"

backend_config:
  - infra/state.s3.tfbackend
  - 'key={{ trimPrefix "infra/" .Path }}/{{ .Instance }}.tfstate'

env:
  TF_VAR_environment: "{{ .Instance }}"
  TF_VAR_role:
    plan: stackorder-reader
    apply: stackorder-deployer

environments:
  "infra/": "infra-{{ .Instance }}"

apply:
  mode: before_merge
  require_approvals: 1
  require_codeowner_review: true
  four_eyes: true

drift:
  schedule: "0 6 * * 1-5"
  open_issue: true

infra/dns/.stackorder.yaml has no var files, so it names its one instance:

yaml
instances: [shared]

infra/kyc/.stackorder.yaml depends on the network of its own environment and restricts production applies:

yaml
depends_on:
  - infra/network
instances:
  staging: {}
  production:
    apply:
      allowed_teams: [platform-prod]

The graph then holds five stacks:

KeyState objectGitHub environment
infra/network:productionnetwork/production.tfstateinfra-production
infra/network:stagingnetwork/staging.tfstateinfra-staging
infra/kyc:productionkyc/production.tfstateinfra-production
infra/kyc:stagingkyc/staging.tfstateinfra-staging
infra/dns:shareddns/shared.tfstateinfra-shared

A pull request that edits infra/network/main.tf plans both network instances in wave 0 and both KYC instances in wave 1. One that edits infra/state.s3.tfbackend plans all five. Create the environments infra-production, infra-staging and infra-shared in the repository settings, and let the apply bootstrap role trust each of their subjects.