Skip to main content
RunBook Academy

← All break/fix scenarios in Terraform

intermediateterraform-state~25 min

State Refuses to Restore from Backup

Reported symptoms

  • ●The restore was carried out exactly as the runbook says, with aws s3api copy-object from the version before the incident, and the command returned a new VersionId with no error
  • ●Since the restore, every Terraform command against the workspace fails - plan, state list, state pull, even init -reconfigure - where before the restore at least the plan ran
  • ●The error says the state data in S3 does not have the expected content and suggests waiting a minute or two for S3 to settle; the team waits an hour and nothing changes
  • ●The same object downloaded with the AWS CLI parses cleanly under jq, with the expected lineage, the expected serial and all 143 resources present
  • ●A different workspace in the same bucket, using the same lock table, plans normally, so neither the bucket nor the table is broken
  • ●There is no lock item in the table for this key and -lock=false changes nothing, which is read as proof that locking is not involved
  • ●A second restore attempt, from an entirely different backup in a different account, produces a byte-identical error - which reads as evidence that every backup the team owns is bad

Evidence

  • · The full error text, which names a Digest value and asks for it to be written into the DynamoDB table
  • · md5sum of the restored object equals the Digest value the error message printed
  • · aws dynamodb get-item on the LockID formed from the bucket and key with -md5 appended returns a Digest that is a different value
  • · The pre-restore version of the object, fetched by VersionId and hashed, matches the Digest the table is holding
  • · aws s3api list-object-versions shows three versions - the good state at 08:52, the damaged state at 09:14, and the restored copy at 10:06 - with the restored copy current
  • · A scan of the lock table returns only -md5 digest items, one per state key, and no lock item for any key
  • · The healthy workspace in the same bucket has a digest item whose value matches the md5 of its current object
Diagnosis and resolutionclick to reveal

Root cause

An S3 backend with a DynamoDB lock table keeps two records of the state, not one. The first is the object in the bucket. The second is an item in the lock table whose LockID is the bucket and key with -md5 appended, holding the MD5 of the state the backend itself last wrote. On every read the backend hashes what it fetched from S3 and compares it against that item; a mismatch is reported as state data in S3 not having the expected content. The check exists to catch a transient condition - a read that has not yet caught up with a write the backend knows it performed - which is why the message advises waiting. Restoring the object out of band, through the console or the AWS CLI, changes the bucket without going through the backend, so the digest item is never updated and still describes the damaged state. From the backend's point of view the bucket now contains a state it did not write, which is precisely the situation the check was built to refuse. Nothing is corrupt. The backup is good, the copy succeeded, and the guard is working exactly as designed against a restore procedure that only ever touched half of what the backend stores. The second backup failed identically for the same reason, and that identical failure is the strongest available clue: two unrelated files producing the same error are saying something about the reader, not about the files.

Remediation

Stop before forcing anything, because every instinct at this point - state push -force, deleting the lock table, disabling locking - destroys evidence or a guard, and none of them is needed. Establish which record is authoritative. The object always is; the digest item is only a memory of the last write the backend performed, and a restore is by definition a write it did not perform. Prove the object is the restore point you intended before you make the backend accept it, because reconciling the digest makes the backend accept whatever is there - hash the object, confirm its lineage matches the workspace, confirm its serial is the one from before the damage, and count its resources. Note that the obvious fix is unavailable: terraform state push has to read the destination state before it can compare lineage and serial against it, and that read is the operation that is failing, so a push is a way to avoid this situation rather than a way out of it. Two routes remain and they should be chosen deliberately rather than stumbled into. The surgical one is to write the Digest attribute of the -md5 item to the value the error message printed; the message is reporting the hash of what it just read, so the fix is to make the table agree with the bucket. The other is to put the bucket back the way the backend remembers it by promoting the pre-restore version to current again, confirm Terraform can read state, and then perform the restore the supported way with terraform state push, which the backend executes and which therefore updates the object and the digest together. The second is the better shape and the first is the right answer when Terraform cannot run at all. If you push, read the objection before overriding it, because -force relaxes the lineage check as well as the serial check and lineage is the guard that stops another workspace's state landing on this key. Then plan, and read the result against the change log rather than against hope.

Verification

The check that can fail is a read, so start there: terraform state pull must return the document, and it must return the document that was restored - the same lineage, the same resource count and the same serial as the file that was verified before anything was written. Then confirm the object and the digest agree, by hashing the current object version and reading the digest item back; on the push route they agree because Terraform wrote both, which is the outcome to prefer, and on the digest-edit route they agree because an operator typed 32 hex characters, which is why that route needs a second pair of eyes. Record the serial before and after every write performed during the recovery and account for each change; a serial nobody can explain means something wrote state during the incident that was not part of the recovery. Run a plan with refresh enabled and require no changes, which is the right expectation only because the restore point is one write back from the damage - a restore from the nightly pull would produce a non-empty plan that has to be reconciled line by line against the change log instead. Have a second operator run the same plan from a clean working directory, which rules out a local cache explaining the result. Finally, prove the corrected procedure end to end in a scratch workspace with its own key, because a restore path that has only ever been executed during an incident has not been tested.

Prevention

Restore through the backend by default. terraform state push is a write the backend performs, so every record the backend keeps is updated as part of it; a storage-layer copy updates one record and leaves the other describing the file it replaced, and that asymmetry is the whole incident. Where the runbook keeps the object-copy path, and there are good reasons to keep it since it works when Terraform itself will not run, the digest update belongs in the same numbered step rather than in a troubleshooting note further down the page. Know what your backend stores outside the state object before you need to know it: for S3 with a DynamoDB table that is a lock item and a digest item, and other backends keep other things. S3 native locking, configured with use_lockfile instead of a DynamoDB table, removes the second store entirely - a digest item cannot go stale when there is no digest item - and is worth evaluating for exactly that reason. Record the target VersionId in the change ticket before a risky change begins, so the restore point is one command away rather than a forensic exercise. Drill the restore quarterly against a scratch key and time it, because a runbook that has never been executed is a guess. And treat the conclusion that the backups are bad as a claim that needs evidence: when several unrelated backups fail in exactly the same way, suspect the thing they have in common.

Reported symptoms

At 09:14 an operator meant to clean up a retired module in staging and ran the removal against production instead. Thirty-eight addresses under module.networking came out of the production platform state. Nothing in the account changed - state rm only edits Terraform’s record - but the next plan proposed to create thirty-eight objects that already exist.

That part was diagnosed in four minutes and the response was correct: restore the state to the version from immediately before the removal. The bucket has versioning on, the version is right there, and the runbook has the procedure.

At 10:06 the restore was performed exactly as written:

# The values below are from this incident; yours will differ.
BUCKET=acme-tf-state-eu-west-1
KEY=prod/platform/terraform.tfstate
GOOD_VERSION=3sL9qGZ.mR1vN0hK8bYpXtA4cWfEuD7j

# DATA-LOSS-RISK: promote the pre-incident version to current.
aws s3api copy-object \
  --bucket "$BUCKET" \
  --key "$KEY" \
  --copy-source "$BUCKET/$KEY?versionId=$GOOD_VERSION" \
  --metadata-directive COPY

The command returned a new VersionId and no error.

Since that moment nothing works. Not the plan, not state list, not state pull, not init -reconfigure. Every one of them exits on the same message. Before the restore the workspace was damaged but usable; after the restore it is unusable, which everyone in the channel reads as the restore having made things worse.

The message itself is unhelpful in a specific way: it blames S3 and suggests waiting. The team waits two minutes, then ten, then an hour.

Three more observations arrive over the next twenty minutes and none of them fit together:

  • The restored object, pulled down with the AWS CLI and read with jq, is entirely intact. Correct lineage, serial 812, 143 resources. It is not a corrupt file.
  • Another workspace in the same bucket, behind the same lock table, plans normally. Neither the bucket nor the table is broken.
  • There is no lock item for this key at all, and -lock=false changes nothing. Since the word “lock” is nowhere in the error either, locking is written off.

At 10:52 someone restores a second time, from the nightly pull held in a separate account - a completely different file, produced by a different mechanism, eighteen hours older. It fails with a byte-identical error.

That is the moment the incident turns, because two unrelated backups failing identically looks like proof that the backups are worthless, and the proposals on the table become terraform state push -force, then deleting the lock table, then rebuilding the state by importing 143 resources by hand.

Evidence provided

The error, in full:

Error: Failed to load state: state data in S3 does not have the expected content.

This may be caused by unusually long delays in S3 processing a previous state
update. Please wait for a minute or two and try again. If this problem
persists, and neither S3 nor DynamoDB are experiencing an outage, you may need
to manually verify the remote state and update the Digest value stored in the
DynamoDB table to the following value: 8f2c41d0a97b3e6511c0d4a7e9b25f83

The wrapper varies by command - it is whichever operation was trying to load state - so the part to match on is the sentence about the expected content and the Digest value at the end. That value is the useful one: it is the hash of what the backend just read out of S3.

The restored object, read directly:

# READ-ONLY: fetch the current object and inspect it outside Terraform.
aws s3 cp "s3://$BUCKET/$KEY" /tmp/state-current.json

jq '{lineage, serial, resources: (.resources | length)}' /tmp/state-current.json
md5sum /tmp/state-current.json
{
  "lineage": "b1f0c6d2-9a44-4c71-8e2b-7d3f5a90c118",
  "serial": 812,
  "resources": 143
}
8f2c41d0a97b3e6511c0d4a7e9b25f83  /tmp/state-current.json

Serial 812 is the state from before the removal, lineage is the workspace’s own, and 143 resources is the expected count. The hash is the same value the error message asked for.

What the lock table is holding for this key:

# READ-ONLY: the digest item, not the lock item. The LockID is the
# state's bucket and key with -md5 appended.
TABLE=acme-tflock-prod
KEY_JSON=$(printf '{"LockID": {"S": "%s/%s-md5"}}' "$BUCKET" "$KEY")

aws dynamodb get-item --table-name "$TABLE" --key "$KEY_JSON"
{
  "Item": {
    "LockID": { "S": "acme-tf-state-eu-west-1/prod/platform/terraform.tfstate-md5" },
    "Digest": { "S": "c31b7ae5f04d29886b1e5c07a4d3ff10" }
  }
}

A different value. And the decisive test - hash the version that was current before the restore:

# READ-ONLY: the damaged state, fetched by VersionId.
DAMAGED_VERSION=nQ2xVbT5oJ8aH.dP6yMzKrC1fSgLwE9i

aws s3api get-object \
  --bucket "$BUCKET" \
  --key "$KEY" \
  --version-id "$DAMAGED_VERSION" \
  /tmp/state-damaged.json

jq '{serial, resources: (.resources | length)}' /tmp/state-damaged.json
md5sum /tmp/state-damaged.json
{
  "serial": 813,
  "resources": 105
}
c31b7ae5f04d29886b1e5c07a4d3ff10  /tmp/state-damaged.json

Serial 813, 105 resources - 143 minus the 38 that were removed - and its hash is exactly the Digest the table is holding.

The object history, for the timeline:

# READ-ONLY
aws s3api list-object-versions \
  --bucket "$BUCKET" --prefix "$KEY" \
  --query 'sort_by(Versions,&LastModified)[*].[LastModified,VersionId,IsLatest]' \
  --output table

And the shape of a healthy pair, from the workspace that still plans: its digest item matches the md5 of its current object. There is nothing exotic about this key.

Work the evidence before reading on

The two hashes are the whole puzzle. Everything else is noise generated by people trying to explain them.

  1. The error names a Digest and a DynamoDB table. What does the backend store in that table besides locks, and where would that value have come from?
  2. The value the table holds is the hash of the state as it was before the restore. What operation would normally have changed it, and did any such operation happen at 10:06?
  3. -lock=false had no effect and there is no lock item. Does that rule the lock table out, or only the lock?
  4. The second restore, from a different file in a different account, failed identically. What can two unrelated files possibly have in common?

Before continuing: the restore wrote to one place. Name every place the backend expects a state write to touch, and work out which of them the procedure missed.

Root cause

1. An S3 backend keeps two records, not one

The state object in the bucket is the obvious one. The second is an item in the DynamoDB table, and the table is not only for locking. It holds two kinds of item per state key:

  • The lock item. LockID is the bucket and key. It exists only while a run holds the lock, and it carries the Info block naming the operation, the holder and the acquisition time. This is the item force-unlock removes.
  • The digest item. LockID is the same bucket and key with -md5 appended, and it carries a Digest attribute: the MD5 of the state the backend last wrote to that key. It is permanent, it is rewritten on every state write, and no ordinary operation ever deletes it.

The team looked for a lock item, found none, and concluded the table was not involved. The table was involved through the other item, which is why -lock=false made no difference: that flag governs whether a lock is acquired. It says nothing about the digest, which is read on the way to loading state whether or not a lock was taken.

2. The check compares the bucket against the backend’s own memory

Every time the backend loads state it hashes what S3 returned and compares that hash against the digest item. Equal means the object is the one the backend last wrote. Unequal means it is not, and the backend refuses to load it.

This is a consistency guard, and the wording of the message tells you what it was built for: a read that has not yet caught up with a write the backend knows it performed. Under that hypothesis waiting is the right advice, because the condition clears itself.

It is the wrong hypothesis here, and the message has no way to know that. The object is not stale. It is a different object, deliberately put there, by a procedure the backend was never told about.

3. The restore updated the bucket and nothing else

aws s3api copy-object is a storage-layer operation. It promotes an old version to current and it is completely correct at what it does. What it cannot do is update a record that lives in another service, because it has no idea that record exists.

So at 10:06 the bucket held serial 812 and the table still described serial 813. From the backend’s point of view the bucket now contained a state it did not write - which is exactly the situation the check exists to refuse.

Nothing was corrupt at any point. The backup was good, the copy succeeded, the guard worked as designed, and the restore procedure only ever touched half of what an S3 backend stores.

The second restore failed for the same reason and could not have failed any other way. It used the same procedure against the same key, so it also left the digest item describing serial 813. Two unrelated files, one common factor: the method used to install them.

Resolution

  1. Stop restoring. A third attempt through the same procedure will fail in the same way, and each one buries the version history a little deeper. Say out loud that the file is not the suspect until the file has been examined.

  2. Hash the object and read the digest item. Two read-only commands. If the object’s md5 equals the value the error message printed, and the digest item holds something else, the diagnosis is settled and no further investigation is needed.

  3. Confirm what the digest item is actually describing. Fetch the version that was current before the restore, hash it, and compare. When it matches, you have proved the backend is comparing against the damaged state rather than against anything unknown - which also proves nothing has written state since 09:14.

  4. Verify the restore point before making the backend accept it. This is the step it is tempting to skip, and skipping it is how a good recovery becomes a bad one, because reconciling the digest makes the backend accept whatever is sitting in the bucket. Confirm the lineage matches the workspace, the serial is the one from before the damage, and the resource count is what you expect. A lineage that does not match means you are holding another workspace’s state, and no amount of digest work will make that safe.

  5. Rule out the obvious fix, on the mechanism rather than by trying it. terraform state push is the right way to install a state file and it cannot help you here. Push has to read the destination state before it can compare its lineage and serial against the file being pushed, and that read is the failing operation. Push is what you use instead of a bucket copy, not what you use after one.

  6. Choose one of the two remaining routes and write down which. They end in the same place and they leave very different audit trails.

    Route A - correct the digest. One write, no state write, and the workspace reads immediately afterwards. This is the right route when Terraform cannot run at all: no credentials for the workspace, a provider that will not install, a version mismatch on the runner.

    # DATA-LOSS-RISK: makes the backend accept the object now in the bucket.
    # Two-person review: this value is typed, and nothing validates it.
    DIGEST=8f2c41d0a97b3e6511c0d4a7e9b25f83
    ITEM_JSON=$(printf '{"LockID": {"S": "%s/%s-md5"}, "Digest": {"S": "%s"}}' \
      "$BUCKET" "$KEY" "$DIGEST")
    
    aws dynamodb put-item --table-name "$TABLE" --item "$ITEM_JSON"

    Route B - return to the supported path. Put the bucket back the way the backend remembers it by promoting the damaged version to current again. The two records now agree, on a state you do not want but can read, and Terraform works. Then perform the restore properly, so that the backend executes the write and maintains both records itself:

    # The version that was current before the out-of-band restore, taken from
    # the object history and confirmed by its hash matching the digest item.
    DAMAGED_VERSION=nQ2xVbT5oJ8aH.dP6yMzKrC1fSgLwE9i
    
    # DATA-LOSS-RISK: re-promote the damaged version, restoring consistency.
    aws s3api copy-object \
      --bucket "$BUCKET" --key "$KEY" \
      --copy-source "$BUCKET/$KEY?versionId=$DAMAGED_VERSION" \
      --metadata-directive COPY
    
    # READ-ONLY: confirm Terraform can read state again before writing anything.
    terraform state pull > /dev/null
    
    # DATA-LOSS-RISK: overwrites the live state. Two-person review.
    terraform state push /tmp/state-current.json

    Route B costs two more object writes and a longer window in which the workspace holds a state nobody wants, and it buys an audit trail in which Terraform is the only thing that ever wrote state. Route A is faster and leaves an object in the bucket that Terraform never wrote and a hash that an operator typed. Prefer B when Terraform runs; take A when it does not.

  7. On route B, read push’s objection before overriding it. Push compares lineage and serial against the state it just read and refuses on either. Here the object is serial 813 and the file is 812, so the objection will be about the serial, and overriding that specific objection is the deliberate act this restore consists of. -force also removes the lineage check, which is the one thing standing between /tmp and a production key.

  8. Do not force-unlock. There is no lock item and there never was one. A force-unlock here removes nothing and adds a misleading line to the incident timeline.

  9. Plan, and read it. With the restore point one write back from the damage, the plan should be empty. Anything else is a new fact and the investigation is not over.

Verification

  1. terraform state pull returns the document. This is the check that can fail, and it is the whole point: the failure was a read, so the proof is a read. Confirm the document it returns is the one that was verified in step 4

    • same lineage, same resource count - and not merely that the command exited zero.
  2. The object and the digest agree, and you know why. Hash the current object version and read the digest item back. On route B they match because Terraform wrote both, which is the outcome to prefer. On route A they match because somebody typed the hash, which is why that route carries a second-pair-of-eyes step and a line in the incident record naming who typed it.

  3. Every serial change is accounted for. Record the serial before and after each write the recovery performs, and check the list adds up. This is worth doing explicitly rather than asserting a number, because a serial that moved when nothing in the recovery moved it means something else wrote state during the incident - which is a larger problem than the one being closed.

  4. The plan is empty with refresh enabled. No creates, no replacements, and in particular none of the thirty-eight module.networking addresses that the damaged state proposed to build. This expectation is correct only because the restore point is one write back; a restore from the nightly pull would produce a non-empty plan whose every line has to be classified against the change log before anything is applied.

  5. A second operator reproduces the plan from a clean directory. A fresh clone, a fresh init, no .terraform inherited from the incident. This rules out a local cache being the reason anything works.

  6. The corrected procedure is proven, not asserted. Copy the state to a scratch key with its own digest item, damage it, restore it with the procedure as now written, and confirm the workspace plans. A restore path that has only ever been executed during an incident has not been tested.

Prevention

  • Restore through the backend by default, and do it first. terraform state push is a write the backend performs, so every record the backend keeps is updated as part of the same operation. The object-copy path updates one record and leaves the other describing the file it replaced. That asymmetry is the entire incident, and the ordering matters as much as the choice: once the object has been copied, push is the command that can no longer run.
  • Keep the object-copy path, but complete it. It has a real use - it works when Terraform will not run at all, which is a case the push path cannot cover - so the answer is not to delete it from the runbook. The answer is that the digest update is a numbered step in the same procedure, not a troubleshooting note two pages down that nobody reads at 10:06.
  • Write down what the backend stores outside the state object. For S3 with a DynamoDB table that is a lock item and a digest item. Other backends keep other things. An operator who knows the inventory diagnoses this in two minutes; one who thinks the bucket is the whole story cannot diagnose it at all.
  • Evaluate S3 native locking. Configuring the backend with use_lockfile rather than a DynamoDB table keeps the lock beside the state object and removes the second store. A digest item cannot go stale when there is no digest item, and this failure mode disappears with it.
  • Record the target VersionId in the change ticket before the change. The restore point is then one command away instead of a forensic exercise performed under pressure by whoever has console access.
  • Drill the restore quarterly, against a scratch key, and time it. The drill is what turns the runbook from a document into a procedure. A drill that takes three hours is a runbook that will not survive the real thing.
  • Treat “the backups are bad” as a claim requiring evidence. It was reached here in forty-six minutes on the strength of two failures, and it was wrong. When several unrelated inputs fail in exactly the same way, the common factor is not the inputs.