Skip to main content
RunBook Academy

← All runbooks in Terraform

high riskdata loss risk~40 min

Runbook: Handle State Backend Size Limit

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 exact failing command is recorded: the working directory, the backend key, the workspace, the operator, and the wall-clock time it failed
  • · It is established which failure this is - a state WRITE the backend rejected, or a state that is only slow. The first is an incident, the second is capacity work, and they have opposite first moves
  • · terraform state pull > /tmp/state-remote.json succeeds and the result parses: jq -e . /tmp/state-remote.json. A backend that cannot be read at all is a different runbook
  • · The remote lineage and serial are written down before anything is pushed: jq -r ".lineage, .serial" /tmp/state-remote.json
  • · Backend version history is confirmed present, so a bad write has somewhere to roll back to: on S3, aws s3api get-bucket-versioning --bucket BUCKET --query Status --output text returns Enabled
  • · The CI apply job for this workspace is paused and the on-call channel has been told that nobody runs terraform apply against this key until the runbook closes

3 · Procedure

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

  1. 1Freeze first. Pause the CI apply job and announce the freeze. A second apply against a state that failed to persist is what turns a recoverable incident into a forked state
  2. 2Classify. Failed to persist state to backend, or an HTTP 413 on the state write, is Path A: the write was rejected and the record of a completed apply is sitting in a local file. Slow plans and rising lock waits with no failed write is Path B: capacity work, not a page
  3. 3**A1 - find errored.tfstate** in the working directory of the machine that ran the apply. If the apply ran in CI, retrieve it from the job artefacts before the runner workspace is reclaimed
  4. 4A2 - compare before pushing. jq -r ".lineage, .serial" errored.tfstate against the values recorded in the pre-checks. Lineage must match; the local serial must be higher than the remote one
  5. 5A3 - push. terraform state push errored.tfstate. Do not add -force; the lineage and serial checks it disables are the only two things standing between you and an overwrite
  6. 6A4 - verify, then unfreeze. terraform plan -detailed-exitcode must exit 0 before the apply job is re-enabled
  7. 7B1 - measure before deciding. Total bytes, resource count, instance count, and bytes per resource type. A split argued from a guess splits on the wrong line
  8. 8B2 - establish the actual limit. Read your backend documentation for a published per-object limit. Most object-store backends do not have one you will reach; the real ceiling is plan wall-clock and lock hold time
  9. 9B3 - choose the boundary from ownership and lifecycle, not from byte count. Two states that must always be applied together are one state with extra steps
  10. 10B4 - split under change management, not under a page: back up, create the destination key, move the addresses, push both states, confirm both plan empty, then replace the cross-state references with terraform_remote_state on published outputs
  11. 11Record the outcome: which path was taken, the serial before and after, and the boundary that was chosen and why

4 · Verification

Confirm the procedure actually fixed the problem.

  • ✓terraform plan -detailed-exitcode exits 0. Exit 2 means the recovered state does not match reality and the incident is still open
  • ✓The remote serial has advanced past the value recorded in the pre-checks: terraform state pull | jq -r .serial
  • ✓The lineage is unchanged. A changed lineage is a forked state, not a recovered one, and it invalidates every backup taken before the fork
  • ✓terraform state list | wc -l matches the instance count in errored.tfstate on Path A, or the sum of the two new states on Path B
  • ✓After a split: both states plan empty, and the consumer reads the producer through data.terraform_remote_state rather than a hard-coded ID
  • ✓The backend object has a new version and the pre-incident version is still restorable

5 · Rollback

If verification fails, undo the procedure in reverse order.

  • ↶Record the pre-push object version id BEFORE pushing. A push is reversible only through the backend version history, and that history is worth nothing if nobody noted which version was good
  • ↶To undo a bad push on S3: restore the previous version of the state object, then re-run terraform plan -detailed-exitcode and expect exit 0
  • ↶To roll back a split: push the pre-split state to the original key first, then delete the destination key. In that order - the reverse leaves a window where neither state owns the resources
  • ↶If a state mv moved the wrong address, use the backup file the subcommand writes automatically; every state-modifying subcommand writes one, and -backup controls where it lands
  • ↶Never resolve a failed push by running terraform apply. An apply against a state that is behind reality proposes to create resources that already exist

6 · Escalation

When the runbook isn't enough, contact:

  • · Escalate immediately if errored.tfstate cannot be found and the apply changed production. At that point the only record of what was created is the cloud API, and recovery is a re-import, not a push
  • · Escalate to the backend owner if terraform state push is itself rejected for size. That recovery needs either a raised limit or a smaller state, and neither is a page-time change
  • · Escalate to the team that owns the workspace before any split. A state boundary is a deployment boundary; drawing one is their call, not the on-call engineer's
  • · Escalate to security if the lineage changed with nobody able to account for it. A rewritten state is a tampering question until someone owns the answer
  • · Raise a planning ticket, not a page, if the 90th-percentile plan duration has been climbing for weeks. That is the signal a split is overdue

Two different incidents arrive wearing this label, and they call for opposite reflexes.

In the first, a backend refused a state write. An apply finished changing the world and then could not record what it did. The record now exists in exactly one place: a file on the machine that ran the apply. This is a page, the clock is running, and the single most damaging thing anyone can do is run terraform apply again to “see what happens”.

In the second, nothing has failed. Plans are getting slower, lock waits are getting longer, and someone has noticed the state file is large. This is capacity work with a deadline measured in weeks.

Step 2 of the procedure is the whole runbook. Get the classification wrong in the calm direction and you leave a forked state behind. Get it wrong in the panicked direction and you split a production state at 3am on a boundary nobody reviewed.

When this runbook applies

  • An apply ended with Error: Failed to persist state to backend.
  • A state write returned HTTP 413, RequestBodyTooLarge, or a documented per-object limit from the backend.
  • Plan duration or lock-wait time has been growing week over week and someone has proposed splitting the state.

When it does not

  • The state cannot be read. That is a backend availability or credentials problem, and pushing anything into it makes the diagnosis harder.
  • The write failed with AccessDenied or ExpiredToken. The size is a red herring; fix the credential and push. Steps A2 to A4 still apply, but nothing in Path B does.
  • A single apply is slow. One slow apply is a graph or a provider problem. A trend is a size problem.

Path A: the write was rejected

The error block Terraform prints is precise, and it is easy to miss because it arrives underneath the error that caused it:

╷
│ Error: Failed to persist state to backend
│
│ The error shown above has prevented Terraform from writing the updated
│ state to the configured backend. To allow for recovery, the state has
│ been written to the file "errored.tfstate" in the current working
│ directory.
│
│ Running "terraform apply" again at this point will create a forked
│ state, making it harder to recover.
│
│ To retry writing this state, use the following command:
│     terraform state push errored.tfstate
╵

Read the third paragraph literally. errored.tfstate is not a log; it is the only complete record of what the apply created. The cloud has the resources, and this file has the mapping from those resources back to configuration addresses. Lose the file and the recovery is a resource-by-resource re-import.

A1: freeze, then find the file

Read-only / Safethe file is the incident
ls -l errored.tfstate
jq -e . errored.tfstate > /dev/null && echo 'parses'
jq -r '.lineage, .serial, (.resources | length)' errored.tfstate

If the apply ran on a CI runner, this is the moment the runner workspace matters. A pipeline that does not upload errored.tfstate as a job artefact on failure has a recovery gap that only shows up here, once, expensively. Fix that in the postmortem; right now, get the file off the runner before the workspace is reclaimed.

A2: compare before you push

terraform state push performs two checks, and they are the reason it is safe to run at all:

CheckWhat it prevents
Lineage must matchPushing a state that belongs to a different configuration
Remote serial must not be higherOverwriting a newer state with an older one
Read-only / Safethe two numbers that decide this
terraform state pull > /tmp/state-remote.json
jq -r '"remote lineage=\(.lineage) serial=\(.serial)"' /tmp/state-remote.json
jq -r '"local  lineage=\(.lineage) serial=\(.serial)"' errored.tfstate

Lineage equal and the local serial higher is the expected shape: the remote is the state before the apply, the local is the state after. Anything else stops the runbook.

A higher remote serial means somebody already wrote to this key after your apply failed. Almost always that is the re-run the freeze was supposed to prevent. Do not reach for -force; the state is now forked and the reconciliation is a diff of two state files, not a push.

A3: push, verify, unfreeze

Data-loss riskwrites the state; recoverable only through backend versioning
# Note the current object version FIRST. This is the rollback target.
aws s3api list-object-versions \
--bucket acme-tfstate-prod \
--prefix production/terraform.tfstate \
--query 'Versions[0].VersionId' --output text

terraform state push errored.tfstate
Read-only / Safeexit 0 is the only clean result
terraform plan -detailed-exitcode
echo "exit=$?"

-detailed-exitcode is the point of this step. Exit 0 means empty diff, 1 means error, 2 means changes present. A human reading “No changes” in a terminal is a human who can be fibbed to by a -target or a scrolled-off warning; the exit code cannot.

Exit 2 here does not automatically mean the push was wrong. It means the recovered state and reality disagree, and the incident is still open until you know which one is right.

Path B: nothing failed, the state is just getting heavy

B1: measure before deciding anything

Read-only / Safefour numbers, then an opinion
terraform state pull > /tmp/state-audit.json

wc -c < /tmp/state-audit.json
jq '.resources | length' /tmp/state-audit.json
jq '[.resources[].instances | length] | add' /tmp/state-audit.json

# Bytes per resource type, heaviest first.
jq -r '
.resources
| group_by(.type)
| map({type: .[0].type, bytes: (map(tostring | length) | add)})
| sort_by(-.bytes)[]
| "\(.bytes)\t\(.type)"
' /tmp/state-audit.json | head -20

The two counts are different questions and both matter. .resources | length counts resource blocks; the instance sum counts what count and for_each actually expanded to. A configuration with 40 resource blocks and 4,000 instances has a for_each problem, not a partitioning problem, and splitting it will not help.

The byte ranking is the part that changes minds. State size is not evenly distributed across resource types: a handful of types whose attributes carry embedded documents dominate, and the rest are noise. If the top three types are 80% of the bytes, the useful question is whether those resources belong in this state at all - not where to draw a line through the other 200.

B2: is there actually a limit?

The ceiling most teams actually hit has no error message. It is plan wall-clock, and the signal is the one the observability lesson names: a 90th-percentile plan duration that grows week over week. That number crosses a threshold long before any byte count does, because the threshold is “developers stop running plan locally”, and once they stop, review quality goes with it. Around 500 resources per state is the practical upper bound the course uses for one team’s state.

B3: draw the boundary on ownership, not on size

A split is a deployment boundary. Two states that must be applied in lockstep are one state with a coordination tax:

BoundaryHolds up because
One team, one stateThe people who review the plan are the people who own the resources
One lifecycle, one stateThings that change weekly do not sit behind things that change yearly
One blast radius, one stateA bad apply is bounded by the state it ran against
“Roughly half the bytes”It does not. It produces two states with a dependency in both directions

The mechanical test: after the split, can each state be applied on its own, in either order, without the other being applied first? If the answer is no in both directions, the boundary is wrong and the split will be reverted within a quarter.

Splitting a state without opening a window

The dangerous part of a split is not the commands. It is the interval in which the resources have been released by the old state and not yet adopted by the new one. During that interval, an apply against the old configuration proposes to create resources that already exist, because a resource that is in configuration and not in state is a resource Terraform believes is missing.

That is state rm semantics, and it is why the order below never leaves that window open: the destination is loaded first, and the source releases only after the destination has been verified.

Data-loss riskoperate on local copies; push only after both plan clean
# 1. Back up. This file is the rollback target for the whole operation.
terraform state pull > /tmp/split-source-before.json
cp /tmp/split-source-before.json /tmp/split-source-work.json

# 2. Move addresses into a destination snapshot. -state-out writes it,
#    creating the file if it does not exist yet.
terraform state mv \
-state=/tmp/split-source-work.json \
-state-out=/tmp/split-target-work.json \
'module.data' 'module.data'

# 3. Inspect both results before either is pushed.
jq -r '.resources[] | "\(.type).\(.name)"' /tmp/split-target-work.json
jq -r '.resources[] | "\(.type).\(.name)"' /tmp/split-source-work.json

-state and -state-out are legacy options that operate on local state files rather than the configured backend. That restriction is what makes them the right tool here: the whole operation happens on copies, and nothing reaches a backend until you have read the result.

Push the destination first, confirm it plans empty in the new working directory, and only then push the trimmed source. Reverse that order and you have created the window.

The reviewable alternative

Terraform has a declarative form of the same move, and where the timeline allows it, it is the better one because it lands in a pull request instead of a shell history. In the destination configuration, import blocks adopt the resources by cloud ID. In the source configuration, a removed block releases them:

# Source configuration - release without destroying.
removed {
  from = module.data.aws_db_instance.primary

  lifecycle {
    destroy = false
  }
}
# Destination configuration - adopt by cloud ID.
import {
  to = module.data.aws_db_instance.primary
  id = "acme-prod-primary"
}

lifecycle { destroy = false } is the entire safety of the source side. Without it the removed block destroys the real resource, which is the same keystroke count and a very different outcome. Review that line specifically, every time.

Reconnecting the two states

Whatever moved, something on the other side of the boundary referenced it. The replacement is a published output on the producer and a terraform_remote_state data source on the consumer - never a hard-coded ID, which silently stops tracking the producer, and never a direct resource reference across states, which Terraform will resolve in both states and fight over.

Common patterns

SymptomLikely causeFirst move
Failed to persist state to backend, errored.tfstate presentThe apply completed; the write did notFreeze, then A2
Same error, errored.tfstate missingApply ran in CI and the workspace was reclaimedEscalate; recovery is re-import
Push rejected: lineage mismatchPushing into the wrong backend keyCheck the key, not the flag
Push rejected: remote serial higherSomebody re-ran applyStop. Two states now exist; reconcile before writing
Plan slow, state small, few resourcesProvider or graph, not sizeProfile the graph; a split will not help
40 resource blocks, 4,000 instancesfor_each over a large collectionFix the collection, not the partitioning
Two “split” states always applied togetherThe boundary was drawn on bytesRedraw on ownership; merge back if needed

References

  1. State
  2. terraform state push command
  3. terraform state mv command
  4. terraform state (backups and remote state)
  5. The terraform_remote_state Data Source
  6. The removed block
  7. S3 backend