SaveMyCert
Log in
5 of 5 free questions left today·for 10 a day
Terraform state management

Terraform State: Backends, Locking, Remote State, and Drift

17 min readTerraform 004 · Terraform state managementUpdated

Terraform state is the record that maps your configuration to real infrastructure, and a backend is where that record lives. By default Terraform uses the local backend, writing a terraform.tfstate file to your working directory - fine for solo experiments, dangerous for teams. Remote backends such as s3, gcs, azurerm, and kubernetes move state to shared, durable storage and lock it across machines, which stops two concurrent runs from corrupting it. This lesson covers exam objective 6 end to end: how the local backend behaves, which operations lock state and how to recover a stuck lock, how to configure and migrate remote state with the backend block and partial configuration, how to share outputs between configurations with the terraform_remote_state data source (and when not to), and how a plan detects drift and how -refresh-only and -refresh=false change that. Every command here reflects current Terraform 1.x behavior.

What you’ll learn
  • Describe how the default local backend stores state, what .terraform/ holds, and why local state cannot be shared
  • Explain which operations take the state lock, how -lock-timeout and -lock=false behave, and when force-unlock is safe
  • Compare how the local, s3, gcs, and azurerm backends implement locking, including the s3 use_lockfile migration
  • Configure remote state with the backend block, keeping every argument a literal or supplying it at init with -backend-config
  • Migrate state between backends with terraform init -migrate-state, -force-copy, or -reconfigure
  • Share root module outputs with terraform_remote_state and choose a least-privilege alternative when needed
  • Predict how a normal plan, a -refresh-only plan, and a -refresh=false plan each treat drifted and deleted resources

The local backend is Terraform's default

The local backend is what Terraform uses when your configuration declares no backend or cloud block: it stores state as a terraform.tfstate file in the current working directory and keeps the previous version in terraform.tfstate.backup. You never have to configure it - run terraform init and terraform apply in a fresh directory and the local backend is already in effect.

You can also declare it explicitly, which is mainly useful for pointing state at a non-default path:

terraform {
  backend "local" {
    path = "state/terraform.tfstate"
  }
}

Do not confuse the state file with the .terraform/ directory. .terraform/ holds the downloaded providers, installed modules, and a cached copy of the backend settings from the last terraform init; it does not hold your local state, which sits beside it in the working directory.

The local backend does support state locking - Terraform takes a lock on the state file on disk, so two terminals on the same machine cannot write it simultaneously. What it cannot do is share state. The file exists only on one machine, and it should not be committed to version control (it holds secrets in plain text, and Git gives neither locking nor safe merging). So a teammate who clones the repository gets no state at all: their terraform plan believes nothing exists yet and proposes creating every resource again. That is exactly why a team or production workflow needs a remote backend.

State locking prevents concurrent writes from corrupting state

State locking makes Terraform acquire a lock before any operation that could write state and hold it until that operation finishes. Without it, two people running terraform apply against the same state at the same time could interleave writes and leave the file corrupted or missing resources. Locking happens automatically whenever the backend supports it; there is no configuration setting that enables it for the run.

Which commands lock? apply, destroy, import, the state-modifying terraform state subcommands - and plan, which holds the lock for its whole run, with or without -refresh=false. Reading another configuration's outputs with the terraform_remote_state data source is a read-only fetch and does not take that state's lock.

When another run already holds the lock, what happens depends on two per-command flags:

OptionBehavior
(none)-lock-timeout defaults to 0s: the command fails immediately with a lock error showing the lock ID and who holds it
-lock-timeout=5mKeeps retrying to acquire the lock for up to 5 minutes, then fails. It only governs waiting - it never breaks or expires someone else's lock
-lock=falseThis command skips locking entirely. It does not release the other run's lock; both runs can now write state at once, which is the corruption risk locking exists to prevent

A lock left behind by a crashed run never expires on its own. The recovery tool is terraform force-unlock LOCK_ID:

terraform force-unlock 6f2b1c3e-1a2b-4c5d-8e9f-0123456789ab
terraform force-unlock -force 6f2b1c3e-1a2b-4c5d-8e9f-0123456789ab   # skip the confirmation prompt
  • It takes the lock ID from the error message and asks for confirmation unless you pass -force.
  • It removes the lock for the current configuration and nothing else: it performs no check that a run is still active, and it does not repair state that a crashed apply left half-written.
  • Local state locks cannot be unlocked by another process - on the local backend, stop the process that holds the lock instead.

Use it only when you are certain no operation is still running; force-unlocking a live run reopens the door to corruption.

How the common backends implement locking

Most widely used backends lock natively, but each does it with its own storage primitive:

BackendHow it locks
localA lock on the state file on disk (one machine only)
s3Only when configured: use_lockfile = true writes a <key>.tflock object next to the state in the same bucket. The older dynamodb_table lock is deprecated
gcsNatively, with a lock file in the bucket - no extra arguments
azurermNatively, with a lease on the state blob in Blob Storage
kubernetes, consul, pg, HCP TerraformNatively, through the platform (a Lease, a Consul lock, an advisory lock, the workspace lock)

The s3 backend is the one with traps. use_lockfile defaults to false, so deleting dynamodb_table without adding use_lockfile = true leaves the backend with no locking at all. The lock object needs s3:GetObject, s3:PutObject, and s3:DeleteObject permission on <key>.tflock in addition to the permissions on the state object. During a migration you may set dynamodb_table and use_lockfile = true together: Terraform then acquires both locks, and you remove the DynamoDB setting once every user and pipeline runs a version that supports the S3 lock file.

terraform {
  backend "s3" {
    bucket         = "acme-tf-state-prod"
    key            = "network/terraform.tfstate"
    region         = "us-east-1"
    encrypt        = true
    use_lockfile   = true
    dynamodb_table = "tf-locks"   # transitional; deprecated - remove after migrating
  }
}

Configuring remote state with the backend block

You configure remote state by adding a backend block inside the top-level terraform block, naming a backend type and its settings. A configuration can have at most one backend, and cannot combine a backend block with a cloud block (the HCP Terraform integration). Built-in backends include s3, gcs, azurerm, kubernetes, consul, pg, http, oss, cos, and remote.

terraform {
  backend "s3" {
    bucket       = "acme-tf-state-prod"
    key          = "network/terraform.tfstate"
    region       = "us-east-1"
    encrypt      = true
    use_lockfile = true
  }
}

A Google Cloud equivalent uses the gcs backend, which locks natively with no extra settings:

terraform {
  backend "gcs" {
    bucket = "acme-tf-state"
    prefix = "prod/network"
  }
}

Two rules are heavily tested. First, the backend is initialised before the rest of the configuration is evaluated, so backend arguments cannot reference any named value - var., local., data source attributes, and even terraform.workspace are all rejected. Every argument must be a literal, or be supplied at init time (next section). For the same reason, TF_VAR_ environment variables and .tfvars files cannot feed a backend: they set input variables, which the backend block cannot read. Second, any change to the backend block only takes effect after you re-run terraform init.

Remote state gives the team one shared source of truth with cross-machine locking and durable storage - but the state still contains sensitive values. Marking an output or variable sensitive = true only redacts it from CLI output; the value is still written to state in plain text. Protect state where it lives: tight access control on the bucket or container, and encryption at rest (for example encrypt = true on s3). The one way to keep a secret out of state entirely is a write-only argument fed an ephemeral value (Terraform 1.11+), where the provider supports it.

Partial configuration and migrating between backends

Partial configuration means leaving some backend arguments out of the backend block and supplying them when you run terraform init. Because the block cannot use variables, this is the supported way to vary settings per environment or keep them out of the committed .tf files. You pass the missing values with -backend-config, either as a file or as individual key/value pairs. HashiCorp recommends naming the files *.backendname.tfbackend:

terraform init -backend-config=prod.s3.tfbackend

terraform init \
  -backend-config="bucket=acme-tf-state-prod" \
  -backend-config="key=network/terraform.tfstate" \
  -backend-config="region=us-east-1"

The rules behind it:

  • Only init takes backend settings. plan and apply have no -backend-config option; they use what init cached in .terraform/.
  • Order matters. -backend-config options are processed in the order given, so a later one wins over an earlier one, and all of them override values in the block.
  • Missing values prompt - unless input is off. Terraform asks interactively for required arguments that are still missing; with -input=false (typical in CI) init fails instead.
  • Not a secret store. Values passed with -backend-config (or hardcoded in the block) are written in plain text to .terraform/terraform.tfstate and into saved plan files. Supply credentials through environment variables or credential files the backend reads directly (for s3, the standard AWS credential chain) instead.

When you change the backend - say, moving from local to s3 - terraform init detects the change and will not proceed until you choose what to do with the existing state:

CommandEffect
terraform init -migrate-stateCopies the existing state into the new backend, asking for confirmation
terraform init -force-copySame migration, but answers "yes" to every copy prompt (implies -migrate-state) - the non-interactive choice for pipelines
terraform init -reconfigureAdopts the new backend configuration and ignores the old one, copying nothing
terraform init -backend=falseSkips backend initialisation, reusing the backend already initialised in this working directory (some other init steps still need it)

There is no -auto-approve flag on init. Migration is the normal path: it is how you promote a laptop-only project to shared remote state without losing the mapping to existing infrastructure.

Sharing outputs across configurations with terraform_remote_state

The terraform_remote_state data source lets one configuration read another configuration's latest state snapshot and consume its root module outputs. It is how a separately managed application stack can reference, for example, the subnet IDs that a network stack created, without hard-coding them:

data "terraform_remote_state" "network" {
  backend = "s3"
  config = {
    bucket = "acme-tf-state-prod"
    key    = "network/terraform.tfstate"
    region = "us-east-1"
  }
}

resource "aws_instance" "app" {
  ami           = "ami-0abcd1234example"
  instance_type = "t3.micro"
  subnet_id     = data.terraform_remote_state.network.outputs.subnet_id
}

Values are always reached through data.terraform_remote_state.NAME.outputs.OUTPUT. Only values the producing configuration declares as output blocks in its root module are visible - resources, and child-module outputs that the root did not re-export, are not reachable. The read does not lock the producer's state.

The catch is access. Although only outputs are exposed, the consumer must be able to read the entire state snapshot, which can include sensitive values. The optional defaults and workspace arguments do not narrow that, and storage permissions cannot either: S3 grants access to whole objects, so neither a bucket policy nor reading the object another way can expose just the outputs. When the consumer must not read the producer's state, HashiCorp recommends publishing the values explicitly instead:

  • Have the producer write them to a separate store - for example AWS SSM Parameter Store, Consul, or a DNS record - and read them with that provider's ordinary data source.
  • On HCP Terraform, use the tfe_outputs data source, which reads a workspace's outputs without access to its full state.

Use terraform_remote_state when both sides are Terraform, you control both, and the consumer is trusted with the producer's state.

Detecting and reconciling drift

Drift is when real infrastructure no longer matches what Terraform state recorded because something changed it outside Terraform - someone resized an instance in the console, deleted a security rule by hand, or an external process changed a tag. Terraform detects drift by refreshing: reading each remote object recorded in state through its provider and comparing it with state. Refresh only reads objects that are already in state, so a resource created by hand that Terraform never managed is not drift and is never reported; bringing it under management is a job for import (objective 7).

A normal terraform plan refreshes first, then diffs the refreshed picture against your configuration. The refreshed values live in memory for that plan only - plan does not persist them to the state file. How drift shows up depends on what changed:

Out-of-band changeWhat a normal plan proposes
An attribute that can be updated in place was changed (a tag, a rule)An in-place update back to the configured value - not a replacement
An attribute that forces a new resource was changedA replacement (destroy and create)
The object was deleted, and its block is still in configurationRefresh drops it from the in-memory state, so the plan proposes creating it

Three planning modes treat drift differently:

CommandRefreshes?Result
terraform planYes, in memoryProposes changes that bring infrastructure back to the configuration
terraform plan -refresh-onlyYesProposes no infrastructure changes; shows only how state would be updated to match reality. apply -refresh-only writes those updates to state
terraform plan -refresh=falseNoDiffs configuration against stored state as-is. Faster on large configurations, but blind to drift not yet refreshed - a deleted object still looks present and shows no change. It still takes the state lock

The standalone terraform refresh command still exists but is deprecated: it behaves like terraform apply -refresh-only -auto-approve, silently rewriting state with no chance to review.

Once drift is visible you have two honest options. To undo it, run a normal terraform apply to push infrastructure back to the configuration. To keep it, change the configuration to match and, if you want state updated first, run terraform apply -refresh-only. Accepting drift into state is not enough on its own: a refresh-only apply updates state but not the configuration, so with the configuration unchanged the next normal plan proposes reverting the value again.

State surgery: the terraform state commands

The terraform state subcommands (plus a few related top-level commands) inspect and rewrite the state's mapping without touching infrastructure. They belong mainly to objective 7 (using the CLI to inspect state, covered in the lesson on import, state inspection, and debug logging); this summary shows where they fit next to backends and drift:

CommandWhat it does
terraform state listLists the addresses of all resources tracked in state
terraform state show ADDRESSPrints every recorded attribute of one resource
terraform state mv SOURCE DESTMoves/renames a resource address in state without destroying it
terraform state rm ADDRESSRemoves a resource from state; the real object survives, unmanaged
terraform state pullDownloads the raw state and writes it to stdout
terraform state pushUploads a local state file to the backend (dangerous; rarely needed)
terraform taint ADDRESSDeprecated: marks a resource for recreation; use -replace instead
terraform apply -replace=ADDRESSPlans and applies the destroy-and-recreate of one resource
terraform force-unlock LOCK_IDReleases a stuck state lock (last resort)

The deprecation pattern recurs: terraform taint gave way to the plannable terraform apply -replace="aws_instance.web", just as terraform refresh gave way to -refresh-only. Configuration-driven alternatives exist for state surgery too - a moved block records a rename in configuration instead of a one-off state mv, and a removed block (Terraform 1.7+) forgets a resource in a reviewed plan instead of state rm.

terraform state mv aws_instance.app aws_instance.web
terraform apply -replace="aws_instance.web"

Remember the boundary: state mv and state rm edit only Terraform's records. state rm never destroys anything - the resource keeps running, Terraform just stops managing it (and a later plan may propose creating a duplicate if the configuration still declares it).

Tip. Expect scenario questions on what the local backend stores and why a fresh clone has no state, which commands take the state lock, how -lock-timeout (default 0s) and -lock=false behave, and what force-unlock does and does not do. You will be asked how s3 (use_lockfile, the deprecated dynamodb_table), gcs, and azurerm lock state; backend block rules (no named values, no TF_VAR_ or tfvars, one backend, re-init after changes); partial configuration with -backend-config, its ordering and plain-text storage; and choosing -migrate-state, -force-copy, or -reconfigure. Remote-state questions cover the outputs.NAME reference, root-only outputs, whole-state read access, and least-privilege alternatives. Drift questions hinge on what a normal plan proposes for changed versus deleted objects, -refresh-only versus the deprecated terraform refresh, the -refresh=false trade-off, and why accepted drift reverts unless the configuration changes.

Key takeaways
  • The local backend is the default: state lives in terraform.tfstate in the working directory (previous version in terraform.tfstate.backup), not in .terraform/ - and a fresh clone without the uncommitted state file has no state at all.
  • plan, apply, destroy, import and state-writing commands take the lock automatically; terraform_remote_state reads do not.
  • -lock-timeout defaults to 0s (fail at once) and only controls waiting; -lock=false skips locking for that one command without releasing anyone else's lock.
  • force-unlock LOCK_ID releases an orphaned lock after a confirmation prompt (-force skips it), checks nothing and repairs nothing; another process cannot unlock local state.
  • s3 locks only with use_lockfile = true (default false) via a <key>.tflock object; dynamodb_table is deprecated and may coexist with use_lockfile while migrating. gcs locks natively; azurerm uses a blob lease.
  • Backend arguments must be literals or come from terraform init -backend-config: var., local., terraform.workspace, TF_VAR_ and tfvars cannot feed a backend, and later -backend-config options win.
  • -backend-config values are stored in plain text in .terraform/ and plan files; keep credentials in environment variables or credential files, and protect state with access control and encryption, since sensitive = true only redacts output.
  • Changing backends: init -migrate-state copies state, -force-copy does so without prompts, -reconfigure copies nothing.
  • terraform_remote_state exposes only root module outputs yet needs read access to the whole state; publish values to a separate store or use tfe_outputs for least privilege.
  • A normal plan refreshes in memory: changed updatable attributes become in-place updates, deleted objects are planned for creation. -refresh-only accepts drift into state only; -refresh=false skips refresh and misses unrefreshed drift.
  • State surgery: terraform taint is deprecated in favour of plan/apply -replace=ADDRESS, and terraform state rm makes Terraform forget a resource without destroying the real object.

Frequently asked questions

What backend does Terraform use if I never configure one?

The local backend. Terraform writes state to a terraform.tfstate file in the working directory and keeps the prior version in terraform.tfstate.backup. It supports locking on that one machine, but the state cannot be shared with teammates, which is why remote backends are recommended for any collaborative work.

Can I use variables inside a backend block?

No. The backend is initialised before the rest of the configuration is evaluated, so its arguments cannot reference any named value - input variables, locals, data sources, or terraform.workspace. TF_VAR_ environment variables and .tfvars files cannot help either, because they only set input variables. To vary settings per environment, use partial configuration and pass the missing arguments at terraform init with -backend-config, as key=value pairs or a .tfbackend file.

When is it safe to run terraform force-unlock?

Only when a lock is genuinely orphaned - typically because a Terraform process crashed or lost connectivity before releasing it - and you have confirmed no run is still in progress. The command takes the lock ID from the error message and asks for confirmation unless you add -force. It performs no check that a run is active and does not repair partially written state, so force-unlocking a live run reopens the door to corruption. Local state locks cannot be released by another process at all.

Does the s3 backend lock state by default?

No. The s3 backend locks only when you set use_lockfile = true, which stores a <key>.tflock object next to the state file in the same bucket and needs s3:GetObject, s3:PutObject and s3:DeleteObject on it. The older dynamodb_table locking is deprecated. While migrating you can set both; removing dynamodb_table without enabling use_lockfile leaves the backend with no locking.

What is the difference between terraform refresh and terraform plan -refresh-only?

terraform refresh is deprecated: it immediately rewrites state to match real infrastructure with no review step, equivalent to terraform apply -refresh-only -auto-approve. The modern workflow is terraform plan -refresh-only to preview exactly what would change in state, then terraform apply -refresh-only to accept it. Neither mode ever changes real infrastructure.

Does terraform_remote_state let me read another configuration's resources directly?

No. It exposes only the values the other configuration declared as outputs in its root module, read through data.terraform_remote_state.NAME.outputs.OUTPUT. Resources, and outputs of nested modules that were not re-exported from the root, are not reachable. The consumer still needs permission to read the entire state snapshot, so when that access is not acceptable, publish the values to a separate store such as SSM Parameter Store or Consul, or use tfe_outputs on HCP Terraform.

What happens to drift after terraform apply -refresh-only if I do not change the configuration?

The state now records the drifted value, but the configuration still declares the old one, so the next normal terraform plan proposes changing the real resource back to match the configuration. A refresh-only apply accepts drift into state; to keep a drifted value permanently, you must also update the configuration.

Does terraform state rm destroy the resource?

No. It only removes the resource from Terraform's state, so Terraform stops tracking it while the real object keeps running. If the resource is still declared in configuration, the next plan will propose creating a new one. To actually destroy a resource, remove it from configuration and apply, or use terraform destroy with targeting.

Source

This lesson covers the "Terraform state management" domain of the official Terraform 004 exam guide. Vendors revise their guides — check the source for the current version.

Test yourself on this topic
Terraform 004 practice questions, with full explanations.
Practice now

Sign up free to mark lessons complete, bookmark topics and track your exam readiness.

Spotted a mistake in this lesson?