Skip to main content
RunBook Academy

TerraformXII · State Recovery and BackupProduction Terraform

State Versioning and Retention

Intermediate⏱ ~12 minbash

What you'll learn

  • Enable bucket-level versioning on the storage backend that holds Terraform state
  • Configure lifecycle rules that expire non-current state versions and abort incomplete multipart uploads
  • Choose a per-environment retention policy that balances recovery need against storage cost
  • List object versions and identify the version before a known bad change
  • Restore a state file from a specific version ID and verify Terraform agrees with reality

Prerequisites

None — start here.

Verified against Terraform CLI 1.9.x · OpenTofu 1.7.x · HCL 2.0 · bpg/proxmox provider 0.66+ · hashicorp/local provider 2.5+ · hashicorp/null provider 3.2+ · hashicorp/random provider 3.6+ · hashicorp/http provider 3.4+ · Ubuntu 24.04 LTS · Debian 12 (Bookworm) · 2026-08-13

Not yet marked complete on this device.

State file versioning is the storage-layer mechanism that retains every write to a state file as a separate object version. It is the safety net for the cases where a destructive terraform apply, a state file pulled from a stale branch, or an engineer accidentally running terraform state rm against the wrong resource leaves the live state file unusable. Versioning at the bucket level gives you a per-object history that survives in place; you do not need a separate backup pipeline to make the recovery story work.

This lesson covers the storage-layer controls that make state recoverable: enabling versioning on the S3 bucket, GCS bucket, or Azure storage account that holds state; writing the bucket policy that turns versioning on and adds lifecycle rules to retire old versions; choosing a retention policy per environment; and the restore procedure using a specific version ID.

What versioning is, and what it is not

Versioning at the storage layer is a property of the bucket (S3), the bucket (GCS), or the storage account (Azure Blob). When versioning is enabled, every PUT to an object creates a new versioned object and leaves the previous version accessible. DELETE marks the current version as a delete marker but does not remove prior versions. The bucket stores every version until something deletes them, normally a lifecycle rule.

This is not the same thing as:

  • State backup tools that take a periodic snapshot and copy it to a different location. Those tools run on a schedule; versioning is a property of the storage system itself and records every write.
  • Git history of the state file in a repository. Git stores textual diffs. Terraform state is a JSON document; you can put it in Git, but the working directory rarely does, and the merge semantics are wrong (you should never edit two state files concurrently).
  • State locking. DynamoDB locks (S3 backend), a lock file (GCS), or blob leases (Azure) prevent concurrent writes. They do not give you a history.

How it works in each backend

The three managed backends expose versioning differently. The mechanics differ; the operational story is the same.

S3

S3 versioning is a bucket property. Once enabled, every version of every object is retained. Each version has its own version ID. The latest write becomes the current version; the prior versions remain accessible until a lifecycle rule removes them.

# READ-ONLY
aws s3api get-bucket-versioning \
  --bucket acme-tf-state-eu-west-1 \
  --query Status

A response of Enabled confirms versioning is on. A response of Suspended means prior versions are kept but new writes do not create new versions; this is a half-on state that should not be left in production.

GCS

GCS supports object versioning. Enabling it on a bucket means every object has a generation number; older generations remain until deleted or removed by a lifecycle rule.

# READ-ONLY
gsutil versioning get gs://acme-tf-state-eu

The output is Enabled or Suspended.

Azure Blob Storage

Azure Blob soft delete plus blob versioning gives the same operational story. Soft delete retains deleted blobs for a configurable period; versioning retains every prior version of an overwritten blob.

# READ-ONLY
az storage account show \
  --name acmetfstate \
  --resource-group acme-tf-state \
  --query "blobRestorePolicy.enabled"

The relevant properties are blobRestorePolicy.enabled, containerSoftDelete.enabled, and blobSoftDelete.enabled. All three should be on for a production state store.

The right bucket policy

Versioning alone is not enough. Without a lifecycle rule, the bucket accumulates every prior version of every state file until you run out of money or the bucket hits its object limit. The policy has three parts: versioning enabled, MFA delete optional, and a lifecycle rule that retires old versions.

S3

# Bucket policy - versioning on, lifecycle rule attached
resource "aws_s3_bucket_versioning" "tf_state" {
  bucket = aws_s3_bucket.tf_state.id

  versioning_configuration {
    status = "Enabled"
    # mfa_delete is optional. When enabled, deletion of any version
    # requires an MFA code. Production default: leave it off until
    # you have an MFA-protected break-glass procedure for restoring.
    # mfa_delete = "Enabled"
  }
}

resource "aws_s3_bucket_lifecycle_configuration" "tf_state" {
  bucket = aws_s3_bucket.tf_state.id

  rule {
    id     = "expire-old-state-versions"
    status = "Enabled"

    filter {
      # Apply to every state object. The live version is never
      # affected because NoncurrentVersionExpiration only targets
      # versions that are not the current one.
      prefix = ""
    }

    noncurrent_version_expiration {
      noncurrent_days = 90
    }

    abort_incomplete_multipart_upload {
      days_after_initiation = 7
    }
  }
}

The noncurrent_days = 90 is the retention. Old versions become eligible for deletion after 90 days; the actual deletion happens when the lifecycle rule next runs.

GCS

# CONFIGURATION - lifecycle config for a state bucket
cat > tfstate-lifecycle.json <<'EOF'
{
  "lifecycle": {
    "rule": [
      {
        "action": {"type": "Delete"},
        "condition": {
          "numNewerVersions": 50,
          "isLive": false,
          "matchesStorageClass": ["STANDARD", "NEARLINE"]
        }
      }
    ]
  }
}
EOF

gsutil lifecycle set tfstate-lifecycle.json gs://acme-tf-state-eu
gsutil versioning set on gs://acme-tf-state-eu

The numNewerVersions clause is the retention: keep the last 50 versions of every object. Tune to your environment.

Azure Blob

# CONFIGURATION
az storage account blob-service-properties update \
  --account-name acmetfstate \
  --resource-group acme-tf-state \
  --enable-versioning true \
  --enable-container-soft-delete true \
  --container-soft-delete-days 30 \
  --enable-blob-soft-delete true \
  --blob-soft-delete-days 30

The retention numbers are conservative defaults; tighten them for production by aligning them with the change rate of the environment.

The cost of old versions

Versioning is not free. S3, GCS, and Azure all charge for stored versions by storage class. A state file that is 1 MB and is rewritten 20 times a day produces 7,300 versions per year; small per object, but multiplied across 200 workspaces and a 90-day lifecycle, it is a non-trivial line item.

The cost levers:

  • Storage class. Old versions of state files do not need millisecond retrieval. A lifecycle rule that transitions non-current versions to Glacier Instant Retrieval (S3), Coldline (GCS), or Archive (Azure) drops storage cost to roughly 20% of standard.
  • Non-current version expiry. A lifecycle rule that deletes non-current versions after 30, 90, or 365 days is the primary lever. The right number depends on the rate of change in the environment.
  • Multipart upload aborts. A failed terraform apply leaves an incomplete multipart upload behind. A lifecycle rule to abort those after seven days stops the bucket filling with junk.

Per-environment retention

Different environments have different change rates and different recovery needs. The retention policy should match.

EnvironmentChange rateNon-current retentionNotes
ProductionMultiple per day90 daysHigh change rate, frequent recovery need
StagingA few per week30 daysDrift sandbox; retention is shorter
DevelopmentHourly7 daysCost-sensitive; retention is short
DR replican/a365 daysCold storage; survives region failure

A single lifecycle rule for every bucket is the wrong answer. Either tag the buckets and write per-bucket rules, or write a per-environment Terraform module that sets the lifecycle at creation time.

The audit trail

Versioning is not a substitute for an audit trail. The version ID gives you “what was in the bucket at time T.” The audit trail tells you who changed what, when, and why. You need both.

For production:

  • S3 server access logging. Enable server access logging on the state bucket to a separate log bucket. The log records who issued which API call against which object version.
  • S3 CloudTrail data events. CloudTrail data events on the state bucket record the same information with tighter attribution. Cost scales with object count and PUT rate.
  • Object Lock for compliance. S3 Object Lock in compliance mode makes versions immutable until a retention period expires. This is the right answer for environments under regulatory retention.

The minimum production control is server access logging. CloudTrail data events are the better answer; Object Lock is the right answer when the regulator requires it.

Restoring a state file from a version ID

The recovery story for “I just destroyed the wrong resource” or “I just rewrote state with the wrong backend” is to go back to a known-good version of the state file.

Identify the version

# READ-ONLY - list the versions of the state object
aws s3api list-object-versions \
  --bucket acme-tf-state-eu-west-1 \
  --prefix prod/networking/terraform.tfstate

The output includes a VersionId for each version, a LastModified timestamp, and the IsLatest flag. The version you want is the most recent one before the bad change; pair the LastModified against the incident timeline.

Download the version

# VERSION_ID: the VersionId you picked out of the listing above.
VERSION_ID=KGf8Pd_ISVJhCcFTGKA2VJcLBOe.gLQK

# DATA-LOSS-RISK - copy the chosen version to a working file
aws s3api get-object \
  --bucket acme-tf-state-eu-west-1 \
  --key prod/networking/terraform.tfstate \
  --version-id "$VERSION_ID" \
  /tmp/terraform.tfstate.recovered

# Verify integrity
sha256sum /tmp/terraform.tfstate.recovered

Verify the recovered state

# READ-ONLY
terraform state pull > /tmp/current.tfstate
diff -u /tmp/terraform.tfstate.recovered /tmp/current.tfstate

A short diff (one or two resource changes) is the expected outcome of a recovery. A diff that touches every resource means you have the wrong version.

Promote the version to current

The right way to make a recovered version current is a copy back to the same key without specifying --version-id. That PUT creates a new current version whose contents are the recovered state. The old current version becomes a non-current version that the lifecycle rule will eventually retire.

# DATA-LOSS-RISK - this rewrites the live state
aws s3 cp /tmp/terraform.tfstate.recovered \
  s3://acme-tf-state-eu-west-1/prod/networking/terraform.tfstate

Verify Terraform agrees

# READ-ONLY
terraform plan

A plan that touches resources you did not intend to touch is a red flag. Stop. The recovered state and reality do not agree; the next step is forensic, not another apply.

For GCS:

# GENERATION: the generation number from the `gsutil ls -a` output below.
GENERATION=1734015296482714

# READ-ONLY - list generations
gsutil ls -a gs://acme-tf-state-eu/prod/networking/terraform.tfstate

# DATA-LOSS-RISK - copy a specific generation to current
gsutil cp \
  "gs://acme-tf-state-eu/prod/networking/terraform.tfstate#$GENERATION" \
  gs://acme-tf-state-eu/prod/networking/terraform.tfstate

For Azure:

# SNAPSHOT: the `snapshot` value of the blob version you want, from the
# listing below.
SNAPSHOT=2026-08-13T22:14:03.0000000Z

# READ-ONLY - list blob snapshots
az storage blob list \
  --account-name acmetfstate \
  --container-name tfstate \
  --prefix prod/networking/terraform.tfstate \
  --query "[].{name:name, snapshot:snapshot}"

# DATA-LOSS-RISK - copy a snapshot back over the live blob
az storage blob copy start \
  --account-name acmetfstate \
  --destination-blob prod/networking/terraform.tfstate \
  --destination-container tfstate \
  --source-blob prod/networking/terraform.tfstate \
  --source-container tfstate \
  --source-snapshot "$SNAPSHOT"

Verification

# READ-ONLY - confirm versioning is on
aws s3api get-bucket-versioning \
  --bucket acme-tf-state-eu-west-1 \
  --query Status
# Expected: "Enabled"

# READ-ONLY - confirm the lifecycle rule is present
aws s3api get-bucket-lifecycle-configuration \
  --bucket acme-tf-state-eu-west-1
# Expected: at least one NoncurrentVersionExpiration rule with
# NoncurrentDays set, plus an AbortIncompleteMultipartUpload block

# READ-ONLY - list versions of the prod networking state
aws s3api list-object-versions \
  --bucket acme-tf-state-eu-west-1 \
  --prefix prod/networking/terraform.tfstate \
  --max-items 5
# Expected: multiple VersionId entries, one marked IsLatest=true

# READ-ONLY - confirm access logging is on
aws s3api get-bucket-logging \
  --bucket acme-tf-state-eu-west-1
# Expected: a LoggingEnabled block with a TargetBucket

# READ-ONLY - Terraform agrees with the recovered state
terraform plan
# Expected: "No changes. Your infrastructure matches the configuration."

Knowledge check · 7 questions

  1. Q1. What does enabling S3 bucket versioning give you that a periodic snapshot backup does not?

  2. Q2. Versioning on an S3 bucket retains every prior version indefinitely, and expiring them is the job of a separate lifecycle rule.

  3. Q3. Which lifecycle action stops a failed `terraform apply` from leaving incomplete multipart uploads behind?

  4. Q4. Which of the following are required for a production state bucket policy? (Select all that apply.)

  5. Q5. A production state bucket sees 20 applies per day and you want three months of recoverable history without paying for it forever. What is the right retention?

  6. Q6. An engineer runs `terraform state rm` against the wrong resource in production. The state file is current; the resource still exists in the cloud. What is the right recovery procedure?

  7. Q7. After restoring a state file from a prior version, `terraform plan` proposes changes to every resource. What does that mean?

Passing score: 75%. Answers are checked in this browser.