Skip to content
intermediate Phase 5 · State Storage and Remote Backends

Configuring a Remote Backend

Moving state to shared storage with terraform init backend migration.

55m
0 problems
Topic Progress 0%

Declaring Where State Lives: The backend Block and Its Two Halves

Moving state off laptops takes one declaration inside the existing terraform block:

terraform {
  required_version = ">= 1.6.0"

  backend "s3" {
    bucket         = "acme-tfstate-prod"
    key            = "network/terraform.tfstate"
    region         = "eu-west-1"
    dynamodb_table = "tfstate-locks"
    encrypt        = true
  }
}

The block has two halves, and conflating them is the first conceptual stumble. The backend type — here s3, but also azurerm, gcs, http, remote, kubernetes, and others — selects the storage implementation and which provider plugin (if any) backs it. The arguments configure that implementation. This is not a provider configuration: the S3 backend talks to S3 with ambient credentials (environment variables, shared config, instance profile) and never reads your provider "aws" block. A team can therefore point state at a dedicated account or bucket with completely separate credentials from the ones managing resources — separation that most security reviews eventually demand.

A constraint that surprises everyone once: backend arguments must be literals. No variables, no locals, no data references, no expressions beyond literal values. The reason is bootstrapping — the backend is read before the configuration graph exists, so there is nothing to evaluate against. key = "${var.env}/terraform.tfstate" fails outright, and the error sends people to the partial-configuration pattern covered in Partial Backend Configuration. It also means the backend block is deliberately minimal: one block per environment's shape, with the varying values (bucket names, keys) supplied at init.

Each argument's role in the S3 case, because they recur in every backend discussion:

Argument Role
bucket The storage container for state objects
key The object path inside the bucket — your stack's identity
region Which S3 endpoint to talk to
dynamodb_table Optional but strongly advised: the lock table
encrypt Require server-side encryption on writes
kms_key_id Encrypt with your own KMS key instead of the S3 default

Two operational notes close the loop. The key is how stacks share a bucket safely — network/terraform.tfstate and app/terraform.tfstate are different objects in one bucket, so a whole estate can use a single well-governed bucket. And adding or changing a backend later is not an edit-and-forget operation: the next terraform init performs the migration, and understanding that flow — including what happens to the local file — is the next chapter's subject.

One asymmetry is worth internalizing before your first init against a real bucket: backend credentials are resolved independently of provider credentials, through the AWS SDK's own chain — environment variables, shared config and SSO profiles, container or instance roles — because the backend block is not provider configuration. A developer whose SSO grants ec2:* for resource work will find terraform init fail with access denied on the state bucket, while terraform plan would have succeeded had state already been reachable. In CI, the runner's role needs state-plane permissions — s3:GetObject/PutObject/DeleteObject on the bucket and, when locking is on, dynamodb:PutItem/GetItem/DeleteItem on the lock table — scoped independently from the deploy permissions. Two credential paths, two permission sets, one command sequence: this separation is the feature that lets state live in a different account, and the trap that produces the most confusing access-denied errors of any Terraform setup. (Recent releases also add S3-native locking via use_lockfile, removing the DynamoDB requirement; the DynamoDB pairing remains the widely deployed, well-documented default.)

The init That Moves Your State: Migration Prompts and What They Really Do

You add the backend "s3" block to a repository whose state currently lives in a local terraform.tfstate, and run terraform init. Before anything is downloaded or written, Terraform stops and asks:

Successfully configured the backend "s3"! Terraform will automatically
use this backend unless the backend configuration changes.

Do you want to copy existing state to the new backend?
  Enter yes or no.

Answer yes and the local state file is copied into the new backend, byte for byte, with its serial and lineage intact — no resources are touched, no plan runs, nothing is created or destroyed. A state migration is a storage operation, not an infrastructure operation, and that is exactly why it is safe to do during business hours. Answer no and the backend is configured but empty: the next plan sees an empty baseline and cheerfully proposes to create your entire estate — the ephemeral-CI failure from Limitations of Local State Files, on demand. "No" is correct only when you intend to import or start fresh.

On backends that support workspaces (S3 does), init also asks which workspace to start in — default unless you deliberately select or create another. Your existing state becomes the state of that workspace.

The same machinery handles every later backend change, so know the three flags by heart:

terraform init -reconfigure       # accept new backend settings WITHOUT migrating state
terraform init -migrate-state     # force the migration flow (prompt, then copy)
terraform init -backend=false     # init providers/modules only; do not touch the backend

-reconfigure is the everyday one: you changed a lock table name or a key prefix and want Terraform to use the existing state already in that backend, not to copy anything. The subtle trap is the default behavior: with changed backend settings and no flag, init prompts to migrate, and a hurried yes at 6 p.m. can copy the current state to a new location while the old copy remains — after which two stores each hold "the" state. Confirm which store your next plan actually reads (terraform init prints the configured backend; terraform state list works against it) before declaring the migration done.

Verification belongs in the same sitting as the migration. After init reports success, run terraform state list — a populated list proves the backend now holds your state — and a bare terraform plan to confirm no unexpected changes appear. Two working-copy files change meaning in the process, and both are worth knowing before an incident: .terraform/terraform.tfstate is not your state — it holds the directory's backend connection metadata, including any credentials supplied at init, which is one reason the whole .terraform directory stays out of version control — and the root terraform.tfstate is no longer consulted once the backend is configured. Harmless as leftovers, both are confusing during a 2 a.m. debug, so archive the root file after a successful migration.

One safety property worth stating plainly for incident contexts: none of this changes infrastructure. Init configures and migrates storage; only plan and apply touch real objects. Teams that fear backend migrations keep avoiding them and stay exposed to the far worse failures of local state — an avoidable fear, given what init actually does.

Where the migrated file lands — and the guarantees it gets there — depends on the storage service behind the backend type, which is the honest way to choose one.

What the Platform Guarantees vs. What You Own: Encryption, Access, and Backend Choice

A remote backend is a division of labor, and knowing which side of the line each guarantee falls on is what turns configuration into operations.

The storage service guarantees durability and usually encryption at rest. S3 objects are stored redundantly across devices with a durability design measured in nines; GCS and Azure Blob make equivalent promises. Encryption at rest is on by default in all three — with platform-managed keys — and every serious backend lets you bring your own: kms_key_id in the S3 backend, encryption_key in azurerm, CMEK in GCS. Bring-your-own-key matters when compliance says the platform operator must not be able to read your data, and it also gives you an off-switch: destroy the key, and the state becomes unreadable to everyone, including a compromised account with bucket access. The key's policy then joins the access model — the principal writing state needs encrypt and decrypt rights on that key — so an over-tight key policy surfaces as backend failures that bucket-policy debugging will never explain.

On locking, calibrate the promise precisely: "this backend supports locking" means the store rejects a second conditional write while a lock is held, and the guarantee is only as strong as the primitive behind it. That is why the lock configuration deserves the same review attention as the bucket policy; a backend without real locking is acceptable only for single-operator stacks where you accept the race.

You own access control, locking configuration, and secret hygiene. The bucket's policy, who may GetObject and PutObject, whether the lock table is writable by the whole org or only the platform team, whether CI assumes a scoped role — none of that arrives with the backend block. This is the partition interviewers probe: "isn't S3 state secure?" — durable and encrypted at rest, yes; access-controlled exactly as much as its IAM policy says, which is your design task.

Backend types roughly sort by who operates the storage:

Backend State stored in Operated by
s3 S3 bucket (+ DynamoDB locks) Your AWS account
azurerm / gcs Blob / GCS object Your cloud account
http Any HTTP service you run You, entirely
remote (HCP Terraform) HashiCorp's managed service HashiCorp, with policy controls
kubernetes Secret in a cluster Your cluster

The remote option deserves one note: HCP Terraform is a managed state-and-run service — state storage, locking, remote operations, and run history come as a product, at the price of putting your state in a vendor's custody and paying for the privilege. Plenty of teams run s3 for state and keep CI doing the executing; others want the audit trail a managed service provides. Neither is the default answer — the default answer is "who is responsible when this breaks at 2 a.m.?"

Whatever you pick, the workflow after setup is identical: init once per checkout, then plan and apply as before — except now every teammate and every CI run reads and writes the same locked store, which was the entire point. S3 paired with DynamoDB locking is the most common AWS answer by a wide margin, and it has enough specific detail — versioning, keys, the lock table's exact schema — to deserve its own topic: S3 Bucket and DynamoDB Locking Backend.

Quiz

1. You write `key = "${var.environment}/terraform.tfstate"` in a backend block. What happens?

Question 1 options

2. You add a backend block to a repo with existing local state and run terraform init. What does answering "yes" to the copy prompt do?

Question 2 options

3. You changed a backend argument (the lock table name) and want Terraform to keep using the state already in that backend. Which init flag?

Question 3 options

4. Which statement about a remote backend's security posture is accurate?

Question 4 options

Flashcards

Question

What are the two halves of a backend block, and how do backend credentials relate to provider credentials?

Answer

The backend type (s3, azurerm, gcs, http, remote, kubernetes...) selects the storage implementation; the arguments configure it. Backend auth is independent of the provider block — the S3 backend uses ambient credentials (env vars, profile, instance profile), so state storage can live in a separate account with its own permissions.

Question

Why must backend arguments be literals, and how is per-environment variation handled instead?

Answer

The backend is read before the configuration graph exists, so variables, locals, and expressions cannot be evaluated. Variation is handled by partial configuration: omit the arguments from the committed block and supply them at init via -backend-config flags or a separate (often uncommitted) file.

Question

What happens when terraform init runs with a new backend and existing local state?

Answer

It reports the backend was configured and asks whether to copy the existing state. Yes: byte-for-byte copy into the backend (serial/lineage intact, no infrastructure touched). No: empty backend, and the next plan proposes creating the whole estate. Workspaces-capable backends also ask which workspace to start in.

Question

Difference between -reconfigure, -migrate-state, and -backend=false?

Answer

-reconfigure: accept changed backend settings without migrating (state already in place). -migrate-state: force the migration flow. -backend=false: initialize providers and modules while leaving the backend untouched — used when state access isn't needed (e.g. validate-only jobs) or credentials are unavailable.

Question

Which backend guarantees come from the platform and which stay with you?

Answer

Platform: durability (redundant object storage) and encryption at rest, optionally with a customer-managed key (kms_key_id / CMEK). Yours: IAM and bucket policies, lock configuration, who can assume what, and the plaintext secrets inside state — remote storage changes custody, not content.

Revision Notes

Key Takeaways

  • 1. A backend block names the storage implementation and its settings; it authenticates separately from provider configuration, enabling dedicated state accounts
  • 2. Backend arguments must be literals because the backend is read before the graph exists — variation arrives via partial configuration at init
  • 3. init migrates state as a storage operation: yes copies bytes into the backend, no leaves an empty baseline that plans a full rebuild
  • 4. -reconfigure, -migrate-state, and -backend=false are the three flags that make backend changes deliberate instead of accidental
  • 5. The platform guarantees durability and at-rest encryption; access control, locking, and secrets-in-state remain yours

Interview Tips

  • • "What does terraform init do?" — beyond downloading providers: it configures the backend, offers to migrate existing state, and selects the workspace; emphasize that migration is a storage operation touching no infrastructure
  • • "Can the backend block use variables?" — no; explain why (evaluated before the graph exists) and give the partial-configuration alternative — this separates people who have read the docs from people who have clicked through the prompt
  • • "How do you move state to S3 without downtime?" — add backend block, init, answer yes; serial/lineage preserved; verify with terraform state list against the new backend; mention -reconfigure for later config-only changes
  • • "Is remote state secure?" — split the answer: durable and encrypted at rest by the platform (CMK optional), but content is plaintext and access is exactly as tight as the IAM/bucket policy you write

Cheat Sheet

Remote Backend Setup

Declaration (in the terraform block):

backend "s3" {
  bucket         = "acme-tfstate-prod"   # literal only — no vars/locals/expressions
  key            = "network/terraform.tfstate"
  region         = "eu-west-1"
  dynamodb_table = "tfstate-locks"
  encrypt        = true
  # kms_key_id  = "arn:..."              # your own key
}

Backend ≠ provider: separate credentials path (ambient), separate account possible.
One bucket, per-stack key prefixes → whole estate in one governed bucket.

init behavior:

Situation Behavior
New backend + local state exists prompts: copy state? (yes = storage copy, no = empty baseline)
Changed backend settings prompts to migrate — use -reconfigure when state is already in place
Force migration terraform init -migrate-state
Providers only, no state access terraform init -backend=false

Responsibility split: platform = durability + encryption at rest (CMK optional) · you = IAM/bucket policy, locking setup, secret hygiene (state still plaintext).

Backend types: s3 · azurerm · gcs · http · remote (HCP Terraform, managed) · kubernetes.

Continue Learning