Skip to main content
RunBook Academy

← All runbooks in Terraform

high riskcluster affecting~90 min

Runbook: Coordinate a Cross-Team Terraform Change

1 · Prerequisites

Confirm every item is in place before any state change.

2 · Pre-checks

Read-only diagnostic commands. If any of these don't match expected output, stop and investigate further.

  • · The dependency direction is established from the code, not from memory. grep -rn terraform_remote_state across the stacks tells you which state reads which; the state whose outputs.tf changes is the producer.
  • · Every state in scope is already at a clean baseline. terraform plan -input=false -detailed-exitcode exits 0 in each one. Exit 2 means that state has an unapplied change that is not yours, and that is a blocker for the window rather than a footnote.
  • · The serial of every state in scope is recorded before anything is merged: terraform state pull | jq -r .serial. Every "did my change land" question afterwards is a comparison against these numbers.
  • · No state in scope is holding a lock. A lock held by a principal nobody in the window recognises is an unexplained concurrent operator; stop and find them.
  • · The apply job for each state has its own CI concurrency group, keyed on the state, so a second run of the same workflow cannot overlap the first.
  • · Every consumer team has confirmed they will not merge an unrelated change into their stack during the window, and the change has an agreed end time by which the estate is either fully forward or fully back.

3 · Procedure

Execute each step in order. Verify the expected output of a step before moving to the next.

  1. 1Ask whether the change needs coordinating at all. If the producer can publish the new output alongside the old one, this is three independent single-state changes (expand, migrate, contract) and this runbook does not apply. Spend the ten minutes here; it is the highest-value ten minutes in the window.
  2. 2Establish direction from the code and freeze the scope: every state, its owner, its apply credential, its position in the dependency order. Nothing enters the window that is not on the list, and nothing leaves it either.
  3. 3Record the baseline: plan exit code and state serial for every state in scope, captured before the first merge.
  4. 4Merge the reviewed PRs but do not let the merge trigger the applies. In a coordinated change the merge and the apply are two separate decisions, gated separately.
  5. 5Apply the producer first for an additive change. Reverse the order for a removal — every consumer must be off the old output before the producer removes it.
  6. 6Verify the producer by reading its published contract back with terraform output -json, not by reading its apply log. The consumers read the outputs; verify the thing they read.
  7. 7Re-plan every consumer against the applied producer. A consumer plan built before the producer applied carries the old output values and will not be refused for it.
  8. 8Apply the consumers one at a time in the recorded order, verifying each with a clean plan before starting the next.
  9. 9Verify the whole set, not the parts: re-run the baseline loop and confirm every state exits 0 and every serial advanced exactly once.
  10. 10Close the window explicitly. Record the serials, the applies, who decided what and when, and how long the estate was split across two contract versions.

4 · Verification

Confirm the procedure actually fixed the problem.

  • ✓terraform plan -input=false -detailed-exitcode exits 0 in every state in scope, run in the same order as the applies — not only in the states you touched.
  • ✓Each applied state serial has advanced exactly once from the step 3 baseline. Twice means something else applied inside your window; find out what before closing it.
  • ✓Each state you did not apply has the same serial it had at baseline.
  • ✓terraform output -json on the producer shows the new contract, and for an additive change still shows the old one for the duration of the transition.
  • ✓A grep of every consumer stack for the removed output name returns nothing before the producer removes it.
  • ✓The CI run history for each state shows exactly one apply inside the window, by the expected principal, against the expected backend.
  • ✓The service the change existed for is measured healthy. A green apply is evidence that the API accepted the calls, not that the service works.

5 · Rollback

If verification fails, undo the procedure in reverse order.

  • ↶Roll back in the reverse of the apply order: consumers first, then the producer. Reverting the producer while a consumer still references the new contract breaks the consumer instead of restoring it.
  • ↶A rollback is a fresh plan against the previous revision, reviewed and saved like any other. Do not unwind a coordinated change with terraform destroy or with -target; targeting cuts dependency edges and leaves the estate in a third state that nobody planned.
  • ↶Roll back only the states that actually applied. Reverting a state that never received the change moves it away from the intended state rather than back towards it — work from the recorded serials, not from the merge list.
  • ↶POINT OF NO RETURN: once the producer has removed an output, a consumer that has already applied against the new contract cannot be restored by reverting its code alone — the old output no longer exists to read. From that moment the decision for the whole set is forward-only.
  • ↶If any producer resource was replaced rather than updated in place, the rollback recreates it with a new identifier. Every consumer that recorded the old identifier must re-plan, and any external system holding it must be told.
  • ↶Stopping the rollback partway is worse than either end state. An estate split across two contract versions produces intermittent behaviour that nobody attributes to this change weeks later.

6 · Escalation

When the runbook isn't enough, contact:

  • · Escalate to the named change owner before choosing forward, back or hold. That choice has service consequences for teams who are not in the room and is not the operator running the applies to make alone.
  • · Escalate immediately if a producer apply fails partway. Some consumers may already be planning against a contract that is now half-published, and the window has to stop before anyone applies one.
  • · Escalate to the owning team, not around them, when a state in scope will not reach a clean baseline. Applying someone else pending change to unblock your window makes their change yours.
  • · Escalate to the platform owner if a state in scope is locked by a principal nobody in the window recognises, or if a state serial moves without a corresponding CI run.
  • · Escalate if the window end time passes with the estate still split across two contract versions. A hold is legitimate; a hold with no end time is how a split lasts a quarter.

Terraform has no transaction across state boundaries. Each state has its own lock, its own plan, its own backend credential and its own apply. A change that spans four states is four applies, and between any two of them the estate is in a configuration that nobody designed and no plan describes.

Nothing in the tool will stop you there. The lock is per state, so two applies against two different states do not serialise — and that is deliberate, because the whole point of a state boundary is that one team’s cadence is not another team’s problem. When a change crosses the boundary, that property becomes the hazard: the ordering, the re-planning and the stop rule are supplied entirely by people, and they live in this runbook.

Step 0: does this change need coordinating at all?

A coordinated window is the most expensive way to ship a change. It needs several people awake at once, it blocks unrelated work in every stack it touches, and it has a failure mode — half-applied estate — that a single-state change does not have.

Most cross-state changes decompose into three independent, uncoordinated ones:

  1. Expand. The producer publishes the new output alongside the old one. Nothing else in the estate changes. The producer merges and applies on its own schedule.
  2. Migrate. Each consumer moves from the old output to the new one, at its own pace, in its own PR, with its own review.
  3. Contract. Once no consumer references the old output, the producer removes it. Again a single-state change.

The state-boundary lesson gives the versioned-key form of the same idea: a breaking change to a contract is a new key path, the producer writes both during a transition window, and consumers migrate on their own schedule.

Use this runbook when the change genuinely cannot be decomposed. The honest cases:

  • Old and new cannot coexist, because the thing being changed holds one value at a time — a route table association, a DNS record, an account-level setting.
  • A shared external identifier changes, because a producer resource is being replaced rather than updated, and every consumer that recorded the old identifier has to be re-planned.
  • A regulator, a maintenance window or a supplier cutover fixes the time, and the estate has to be on one side of it or the other.

It does not apply when several teams merely review one change to one state (that is an ordinary PR), when several states change with no dependency between them (that is N independent changes — run them whenever), or when one state moves through several environments (that is the promotion model: staging then production, each re-planned against its own backend).

Blast radius

Every resource in every state on the list — plus every state that reads one of those states’ outputs and is not on the list. That second set is the one that gets missed, and step 1 exists to find it.

Step 1: establish direction from the code

Read-only / Safewho reads whom
# Every cross-state reference in the repository.
grep -rn 'terraform_remote_state' stacks/ modules/

# Which stacks read this producer's state key?
PRODUCER_KEY=platform/networking/terraform.tfstate
grep -rln "$PRODUCER_KEY" stacks/

Direction is not a matter of opinion and it is not what the change ticket says. The producer is the state whose outputs.tf changes; the consumers are the states carrying a terraform_remote_state block that points at the producer’s key. Write the list down with the owning team taken from CODEOWNERS, because that is who has to be awake.

Step 2: freeze the scope and take the baseline

Read-only / Safebaseline: exit code and serial, per state
for d in stacks/platform-networking stacks/orders stacks/payments; do
(
  cd "$d" || exit 1
  terraform init -input=false -no-color >/dev/null
  terraform plan -input=false -no-color -detailed-exitcode >/dev/null 2>&1
  rc=$?
  serial=$(terraform state pull | jq -r '.serial')
  printf '%-32s plan_exit=%s serial=%s\n' "$d" "$rc" "$serial"
)
done
stacks/platform-networking       plan_exit=0 serial=311
stacks/orders                    plan_exit=0 serial=88
stacks/payments                  plan_exit=2 serial=140

Illustrative output

-detailed-exitcode makes the plan machine-readable: 0 is an empty diff, 2 is a non-empty diff, 1 is an error. The distinction between 0 and 2 is the whole point of the baseline.

plan_exit=2 on payments is a blocker, not a footnote. That stack has an unapplied change already, owned by someone else. If you apply it to clear the way, their change becomes yours; if you apply around it, you can never afterwards tell which of the two produced a given effect. Hand it back to its owner and either wait or drop the stack from the window.

The serials are the more important half of this step, because they are not recoverable later. Every “did my change land”, “did something else land”, “is the estate where I think it is” question downstream is a comparison against these three numbers.

Step 3: merge without applying

In a normal pipeline the merge to the default branch is the trigger: plan on the PR, apply on merge. For a coordinated change those are two decisions, and they need two gates.

# .github/workflows/apply-orders.yml
apply-orders:
  needs: plan-orders
  if: github.ref == 'refs/heads/main'
  environment: apply-production-orders     # manual approval gate
  concurrency: terraform-apply-production-orders

Two properties matter. The environment gate means the merge queues the apply rather than performing it, so the order is yours to choose. The concurrency group is keyed on the state, so a second run of the same workflow cannot overlap the first.

Be clear about what the concurrency group does not do: it constrains one workflow in one repository. It does not stop a different workflow, a laptop, or another repository from applying the same state. The backend lock is what stops those, and only if every path genuinely uses the same backend.

Step 4: apply in the direction the dependency runs

For an additive change — a new output, a new resource that consumers will reference — the producer applies first. A consumer applied first would plan against an output that does not exist yet and fail. That is a safe failure, but it burns window.

For a removal the order reverses. Every consumer must stop referencing the old output before the producer removes it.

Service impact possibleproducer apply, from a reviewed saved plan
cd stacks/platform-networking

terraform plan -input=false -no-color -lock-timeout=300s -out=tfplan
terraform show -no-color tfplan

# Apply exactly what was reviewed: the saved plan as the argument.
# No -auto-approve, and no second plan.
terraform apply -input=false -no-color tfplan | tee apply-networking.log

The saved plan is what makes this step reversible in the sense that matters: it is the record of what was proposed, and the apply executed that file rather than recomputing something else at apply time. -lock-timeout=300s is there so transient contention waits instead of failing the window; it is not a licence to proceed past a lock that is genuinely held.

Step 5: verify the published contract, not the apply log

Read-only / Saferead back what the consumers will read
terraform output -json | jq 'keys'

# During an additive transition BOTH names must be present.
terraform output -json | jq '{
old: has("public_subnet_ids"),
new: has("public_subnet_ids_by_az")
}'

Consumers do not read your plan and they do not read your log. They read your outputs. The output map is the contract, so the output map is the thing to verify before anyone plans against it.

Step 6: re-plan every consumer

Do not carry a consumer plan across the producer’s apply.

A saved plan is bound to the state it was built from. If that state has changed, the apply is refused:

Error: Saved plan is no longer up to date

The given plan is no longer up to date. The plan was created against a
state that has since been modified, so it cannot be applied without
potentially affecting the wrong resources.

Step 7: apply consumers one at a time

Service impact possibleone consumer, then verify, then the next
cd stacks/orders

terraform plan -input=false -no-color -lock-timeout=300s -out=tfplan
terraform show -no-color tfplan
terraform apply -input=false -no-color tfplan | tee apply-orders.log

# Verify this one before starting the next. Expect exit 0.
terraform plan -input=false -no-color -detailed-exitcode
echo "plan exit: $?"

Serially, in the recorded order. Not because Terraform requires it — the locks are independent and the applies would run in parallel quite happily — but because parallel failures give you several simultaneous unknowns and no way to attribute any of them. The window is bounded by how fast you can understand it, not by how fast the API responds.

Step 8: verify the whole set

Re-run the step 2 loop unchanged. Every state in scope exits 0. Every state you applied has a serial exactly one higher than its baseline. Every state you did not apply has the serial it started with.

A serial that advanced twice on a state you applied once means a second apply landed inside your window. Find it before you close the window; afterwards, the evidence is a diff against a number you no longer have.

Forward, back, or hold

One decision, for the whole set, made with the change owner.

DecisionWhen it is rightWhat it requires
ForwardThe failure is understood and specific to the state that failedConfidence the remaining consumers will not hit the same cause
BackThe change is wrong, or the cause is unknownReverse order, and no consumer past the point of no return
HoldThe estate is split but every state is in a valid configurationAn additive change, a stated end time, and a named owner

Common patterns

SymptomLikely causeResolution
Consumer plan errors on a missing outputProducer has not applied, or a removal ran before the consumers migratedCheck terraform output -json on the producer; fix the order, not the consumer
Consumer apply refused as no longer up to dateThe consumer’s own state changed since its planRe-plan, review, re-apply — never force
Consumer applies cleanly but takes the old valueIts plan was computed before the producer appliedRe-plan and apply again; add the re-plan to the checklist
A state will not reach a clean baselineA pending change owned by another teamBlocker for the window; hand it back rather than applying it
A stack breaks days after a clean windowIt read the producer’s key from outside the repository you greppedAudit reads of the key, not greps of one repo
Producer rolled back, consumers notRollback executed in apply order instead of reverseRoll consumers back first, then the producer
Estate stays split for weeksA hold with no end timeEvery hold needs an end time and a named owner
Two applies land on one state in the windowNo per-state concurrency group; the backend lock is the last line, not the firstAdd the concurrency group keyed on the state

References

  1. The terraform_remote_state data source
  2. State locking
  3. terraform plan -out=FILE (saved plans)
  4. terraform plan -detailed-exitcode
  5. terraform apply command
  6. terraform output command