Skip to content

Terraform State and Git: What Should You Commit?

Lesson 6 of 10Intermediate15 min readGit for DevOps & Infrastructure · Infrastructure as CodeVerified: Terraform state and backend documentation, September 2026

State is the file people commit because it looks like a file.

It sits in the working directory next to main.tf. It is JSON. git status lists it as untracked. Everything about its presentation says “part of the project”, and it is the single most damaging thing you can commit to a Terraform repository — because it contains your infrastructure’s secrets in plaintext, and Git never forgets.

Terraform’s record of what it believes it manages.

When you write resource "aws_db_instance" "primary", that is a name in your configuration. The real database has an identifier the provider assigned. State is the mapping between them, plus everything Terraform needs to plan the next change without re-reading the entire world.

Three jobs it does:

Mapping. aws_db_instance.primary corresponds to the database with this identifier. Without it, Terraform cannot tell an existing resource from one it should create — and would create a second database alongside the first, because from its point of view nothing exists yet. This is the failure people hit when they lose state and re-run: not an error, a duplicate estate.

Metadata. Dependency ordering, provider versions, and the module structure at the time of the last apply. Some of this is not derivable from the configuration afterwards — the dependency graph as it was when resources were created is not necessarily the graph the current configuration implies — which is why state cannot simply be regenerated from the files.

Performance. State caches attribute values so a plan can diff against them rather than reading every resource on every run. On a large estate this is the difference between a plan taking seconds and taking many minutes, and it is why terraform plan -refresh=false exists as an escape hatch for when you are confident nothing has changed underneath you.

That third job is why state contains what it contains — and why it is dangerous.

Not a design flaw. A consequence of what state is for.

To diff a resource, Terraform must record its attributes. If a resource has an attribute that is a password, that password is in state. Concretely, state routinely contains:

  • Database master passwords, from resources that generate or accept them
  • Private keys, from key-pair resources
  • Generated secrets from random_password
  • API keys and tokens set as resource attributes
  • Connection strings, internal hostnames and IP addresses
  • Full network topology and security rules

sensitive = true does not encrypt anything. It redacts a value from CLI output and plan display. The value is stored in state in plaintext. This surprises people badly, and it is stated plainly in Terraform’s own documentation: sensitive values are stored as plain text in state files.

Five reasons, and the first is sufficient.

Git history is permanent and widely distributed. A committed state file is a plaintext credential in a repository, in every clone, in every fork, in CI caches, and on every laptop that has ever pulled. Removing it later does not un-distribute it.

Repository read access is broader than secret access should be. Most organisations grant repository read generously. Nobody intends that to include the production database password.

State changes on every apply, not on every commit. These are different clocks. Committed state means either a commit after every apply, which pipelines get wrong in both directions, or state that silently describes a world that no longer exists. The second is worse, because nothing signals it: the file is present, it parses, and it is confidently wrong.

Concurrent applies produce merge conflicts in state. Two engineers apply, both commit state, and Git offers a three-way merge on a JSON document describing real infrastructure. There is no correct manual resolution: both versions accurately describe part of reality, neither describes all of it, and choosing either loses track of resources that exist. The only real fix at that point is to rebuild state from the live infrastructure by hand.

There is no locking. Git cannot stop two applies running simultaneously. Losing that is losing the protection against state corruption.

# State — never commit
*.tfstate
*.tfstate.*
*.tfstate.backup
# Local Terraform directory
**/.terraform/*
# Crash logs can contain state fragments
crash.log
crash.*.log
# Variable files — default-deny, allow examples back in
*.tfvars
*.tfvars.json
!*.tfvars.example
!example.tfvars
# Plan files contain the same sensitive attributes as state
*.tfplan
*.plan
# CLI configuration can contain credentials
.terraformrc
terraform.rc
# Override files are for local experimentation
override.tf
override.tf.json
*_override.tf
*_override.tf.json

Two deliberate choices.

*.tfvars is denied wholesale, then examples are re-allowed. A new production-secrets.tfvars is ignored automatically. The alternative — listing files to ignore — depends on somebody remembering, and that is the mechanism by which secrets get committed.

.terraform.lock.hcl is not in the list. The dependency lock file should be committed. It pins provider versions so every machine and every CI run resolves identically. Ignoring it is a common and quiet mistake, and the symptom is a plan that differs by machine.

The alternative state lives in, and each of its properties answers one of the problems above.

Shared access. The team and CI read the same state. No copies to reconcile.

Locking. The backend refuses a second concurrent operation. This is the property Git cannot provide at all.

Encryption at rest, and separate access control from the repository.

Versioning, where the storage supports it — which is the recovery path when state is damaged.

No plaintext on laptops, at least not persistently.

A minimal backend configuration:

terraform {
backend "s3" {
bucket = "example-terraform-state"
key = "environments/production/terraform.tfstate"
region = "eu-west-1"
encrypt = true
use_lockfile = true
}
}

encrypt = true enables server-side encryption. use_lockfile = true is the current S3-native locking mechanism, using a lock object in the same bucket.

The older pattern used a separate DynamoDB table via dynamodb_table. That argument is deprecated and slated for removal in a future minor version. You will still see it in existing repositories and in a great many tutorials, and both arguments can be set simultaneously to support a migration — but new configuration should use use_lockfile and existing configuration should plan to move. This is a good example of why infrastructure guidance needs re-checking against primary documentation rather than recalled from a blog post.

Enable versioning on the bucket. It is the difference between a corrupted state being an inconvenience and being an incident, and HashiCorp’s own backend documentation describes it as highly recommended for exactly that reason.

Note what is not in that block: credentials. Backend authentication comes from the environment — OIDC in CI, a profile locally. A backend block with an access key in it is a committed credential.

Backend configuration cannot use variables

Section titled “Backend configuration cannot use variables”

A limitation that catches everyone, once.

terraform {
backend "s3" {
bucket = var.state_bucket # Error — not allowed
}
}

Backend configuration is read before variables are evaluated, so it cannot interpolate. The standard workaround is partial configuration: leave values out of the block and supply them at init time.

terraform {
backend "s3" {}
}
Terminal window
terraform init \
-backend-config="bucket=example-terraform-state" \
-backend-config="key=environments/production/terraform.tfstate" \
-backend-config="region=eu-west-1"

Or a committed backend-production.hcl file — non-secret values only — passed with -backend-config=backend-production.hcl.

This is one place OpenTofu diverges: its early variable evaluation permits variables in backend configuration, which removes the workaround entirely. It is a genuine functional difference rather than a cosmetic one.

The reasoning is the same everywhere; the mechanics differ.

Azure Storage (azurerm) uses blob leases for locking, which is native and needs no extra resource. Enable blob versioning and soft delete for the same reason you enable S3 versioning.

Google Cloud Storage (gcs) locks natively. Enable object versioning.

HTTP backend implements a generic protocol — useful for a self-hosted state service, and locking depends on whether the server implements the lock endpoints. Verify rather than assume.

Kubernetes backend stores state in a Secret. Workable for small footprints, and worth remembering that a Kubernetes Secret is base64-encoded rather than encrypted at rest by default — the same misconception that causes trouble elsewhere applies here.

Local backend is the default when no backend is configured. Fine for a throwaway experiment, unusable for a team: no locking, no sharing, and the file sits in the working directory waiting to be committed.

The properties to check for any backend you are considering: does it lock, does it encrypt at rest, can it be versioned for recovery, and can its access control be set separately from the repository’s? If the answer to any of those is no, know which protection you are giving up.

Rare, unpleasant, and much easier if you prepared.

Restore from a previous version. With bucket versioning enabled, this is the answer almost every time: fetch the last known-good state object and put it back. Terraform also writes terraform.tfstate.backup locally as the immediately previous version.

terraform force-unlock <LOCK_ID> clears a lock left behind by a killed process. Confirm nothing is actually running first — the lock exists for a reason, and clearing it while a real operation is in progress is how you get the corruption it was preventing.

terraform import brings a real resource that exists but is missing from state back under management. This is the repair for “the resource was created but the state write failed”, and it is manual.

terraform state rm removes a resource from state without destroying it. The correct tool when Terraform is tracking something it should not — and dangerous in the other direction, because a resource removed from state is now unmanaged and invisible.

Never hand-edit a state file. The temptation is strong when you can see the wrong value in JSON. The serial number, lineage and dependency metadata all have to stay consistent, and terraform state subcommands maintain them. If you must, take a copy first and be aware you are performing surgery.

The preparation that makes all of this routine rather than frightening: versioning on the state storage, and separate state per environment, so a bad recovery in one place cannot spread.

One state file per root module, and environments must not share one.

s3://example-terraform-state/
├── environments/dev/terraform.tfstate
├── environments/staging/terraform.tfstate
└── environments/production/terraform.tfstate

Shared state means a mistake in dev locks or corrupts production’s record. Separate state means a dev apply cannot touch production’s, and access to the production state key can be granted separately from the others.

The stronger version separates the storage as well — a different bucket, in a different account, for production. That way read access to the dev state does not imply read access to production’s secrets, which is exactly the property you want given what state contains.

It happens. What matters is doing the steps in the right order.

  1. Treat every secret in that state as compromised. This is the whole point. Not “potentially exposed” — exposed. Anyone with repository access at any time since the commit could have read it.

  2. Rotate those credentials now, before anything else. Database passwords, keys, tokens. Rotating exposed credentials covers doing this without an outage. Rotation comes before history cleanup, always, because history cleanup takes time and the credential is valid the whole while.

  3. Add the .gitignore entries so it cannot recur.

  4. Move state to a remote backend. Add the backend block and run terraform init; Terraform offers to migrate the existing state.

  5. Remove it from the working tree and index: git rm --cached terraform.tfstate, then commit.

  6. Then consider history. Removing secrets from Git history covers the rewrite. It requires everybody to re-clone, and it does not reach existing forks, clones or caches — which is why step 2 is the one that actually fixes the problem.

  7. Enable push protection so the next attempt is blocked at push time.

Access control on the state backend deserves the same thought as access control on a production database, and it usually gets less.

The applying identity needs read and write. The CI role that runs apply, and nothing else in the pipeline.

The planning identity needs read and lock. A plan reads state and takes the lock while refreshing. It does not need write in the ordinary sense, though the lock mechanism itself involves writing a lock object — so “read-only” in the S3 sense means read on state objects plus the ability to create and delete the lock.

Engineers usually need read, sometimes. For debugging. Grant it deliberately and consider whether production is included, given what production state contains.

Nobody needs read on every environment. This is the most common over-grant. A single bucket with one policy, read by everybody who works on infrastructure, means read access to production database passwords for everyone who has ever touched dev.

Log the access. Bucket access logs answer “who read the production state, and when”, which is a question you will be asked after an incident and cannot answer retroactively.

The structural version of all of this is separate storage per environment, in separate accounts. That converts a policy decision — which is easy to get wrong and easy to change accidentally — into an account boundary, which is not.

A reasonable question, given everything above.

Server-side encryption at rest is what encrypt = true gives you, and it protects against somebody obtaining the storage medium. It does not protect against somebody with read access to the bucket, because the storage decrypts transparently for them. This is worth having and is not the protection people often assume it is.

Client-side encryption — where the state is encrypted before it leaves the machine — protects against exactly that case. Terraform does not offer this natively.

OpenTofu does. Client-side state and plan encryption, with key providers including PBKDF2, AWS KMS, GCP KMS and OpenBao. This is one of the substantive functional differences between the two tools, and if the sensitivity of state is a live concern for your organisation it is a genuine reason to evaluate OpenTofu rather than a cosmetic one.

Even with encryption, the operational rules do not change. State still does not go in Git, still needs locking, still needs versioning, and its key material still needs managing. Encryption changes who can read the file; it does not turn state into a source artifact.

Sometimes you legitimately need what is in state.

terraform state list shows managed resource addresses. Safe — no attribute values.

terraform state show <address> displays one resource’s attributes. This prints secrets to your terminal, and therefore into your shell history and any terminal recording.

terraform output prints outputs, redacting those marked sensitive; -json includes sensitive values in full.

Never cat terraform.tfstate in CI. A state file printed into a workflow log is a state file in a log that is retained and readable by anybody with repository access.

Never paste state into an issue, a chat or an AI prompt when debugging. This is a common and understandable impulse — the file is confusing and you want help reading it — and it distributes every credential it contains to wherever that text goes. If you need help interpreting state, describe the structure or share a redacted extract; the useful part of the answer rarely depends on the values.

Detecting a committed state file before it is a problem

Section titled “Detecting a committed state file before it is a problem”

Prevention is cheaper than rotation, and there are three layers of it.

A pre-commit hook that refuses to stage anything matching *.tfstate. Local, fast, and bypassable — which is fine, because its job is catching the accident rather than stopping the determined.

Push protection blocks pushes containing recognised credential patterns. A state file full of provider secrets frequently trips it, which is a happy accident rather than a designed defence — push protection matches credential formats, not state files as such.

A CI check that fails if any tracked file matches the state patterns:

Terminal window
if git ls-files | grep -qE '\.tfstate(\.[^.]+)?$'; then
echo "Terraform state is tracked in this repository." >&2
git ls-files | grep -E '\.tfstate(\.[^.]+)?$' >&2
exit 1
fi

That runs in a second, has no dependencies, and catches the case where somebody force-added a file past .gitignore with git add -f.

An audit of what is already there. Worth running once against every infrastructure repository you own, including history:

Terminal window
git log --all --diff-filter=A --name-only --format= | sort -u | grep -E '\.tfstate|\.tfvars$'

Run against a repository where state was committed and later deleted, that reports the file even though it is no longer in the working tree — which is the point. git rm removes a file from the current tree; the blob remains reachable from history, and so does every secret in it.

If it returns anything, you have a rotation task rather than a cleanup task, and the ordering in the section above applies.

Committing terraform.tfstate. The subject of this lesson.

Assuming sensitive = true protects stored values. It affects display only.

Ignoring .terraform.lock.hcl. It should be committed; ignoring it makes plans machine-dependent.

One state file for every environment. Dev mistakes reach production’s record.

No bucket versioning. No recovery path.

Credentials in the backend block. A committed credential.

Rewriting history before rotating. The credential stays valid throughout.

-lock=false to get past a lock error. Removes the protection against concurrent corruption.

Printing state in CI logs. Retained and broadly readable.

Pasting state into a chat to ask what it means. Distributes everything it contains.

State is an operational database that happens to be stored as a file. It records what Terraform manages, it contains the credentials of everything it manages, and it belongs where operational data belongs — not in source control.

Every rule follows. It is not committed because credentials are not committed. It is locked because databases are locked. It is versioned because operational data needs recovery. It is access-controlled separately from the repository because it is more sensitive than the repository.

  • State maps configuration to real resources, holds metadata, and caches attributes for planning
  • Attribute caching is why state contains passwords, keys and connection strings in plaintext
  • sensitive = true redacts display; it does not encrypt stored values
  • Committed state is a permanently distributed credential, with no locking and unmergeable conflicts
  • .terraform.lock.hcl is committed; state, plans and real .tfvars are not
  • Backend blocks cannot interpolate variables — use partial configuration
  • Separate state per environment, ideally in separate storage
  • If state was committed: rotate first, then clean history

Use a disposable repository and local state only. No cloud credentials.

  1. Create a configuration with a random_password resource and a local_file that writes something unrelated. Run terraform apply.

  2. Open terraform.tfstate and find the generated password. Predict: is it plaintext?

  3. Mark the password output sensitive = true. Apply again. Check the CLI output, then check state. Predict: which one changed?

  4. Add the .gitignore above. Run git status. Confirm state is ignored and .terraform.lock.hcl is not.

  5. Run terraform state list, then terraform state show on the password resource. Note which one exposes the value.

  6. Create terraform.tfvars and terraform.tfvars.example. Predict: which is ignored?

  7. Delete the repository and the local state.

Lab: ship an infrastructure change through a pull requestPractise the review discipline that makes an infrastructure change reviewable before it is applied.

The infrastructure-as-code repository template and deployment PR checklist are in the Professional Toolkit.