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

Partial Backend Configuration

Supplying environment-specific backend settings at init without editing shared files.

50m
0 problems
Topic Progress 0%

A Backend Block That Deliberately Says Less

Here is the tension partial configuration resolves. The backend block accepts only literals — no variables, no expressions, nothing dynamic, as Configuring a Remote Backend established. But one repository serves many environments, and dev and prod must never write to the same state object. You do not want engineers editing the committed block to point at their own stack; you want the block to describe how the team stores state and something else to describe which state this run uses. The answer is to under-specify the block on purpose and complete it at init time:

terraform {
  backend "s3" {
    # bucket, key, and dynamodb_table omitted on purpose
    region  = "eu-west-1"
    encrypt = true
  }
}

This is partial backend configuration: the arguments that never vary for your team are committed, and the ones that vary are left out. Run terraform init against it and Terraform discovers the missing required settings, prompting one per line:

backend.s3.bucket:
backend.s3.key:

The prompt is Terraform admitting the configuration is incomplete — fine for a one-off experiment, unacceptable as a workflow. In CI there is no terminal: init blocks against a closed stdin or aborts, and a pipeline whose state location depends on interactive input is one nobody can reproduce. A related flag matters here: terraform init -input=false turns every prompt into a hard failure, which is exactly what you want in automation — the job should refuse to guess a bucket name rather than hang for the full runner timeout. Two completion mechanisms exist, and teams usually arrive at them in order: first a wrapper script assembling flags, then — once the flag list outgrows one line and appears in three repositories — the per-environment file the wrapper simply points at.

Partial configuration composes with workspaces in a way worth deciding early. Workspaces multiply environments inside one backend configuration: the S3 backend automatically prefixes non-default workspace state as env:/<workspace>/<key> inside the object key, so terraform workspace select staging against the same init produces a distinct state path with zero extra configuration. That pattern is attractive for ephemeral preview stacks and dangerous for anything long-lived, because a typo'd workspace name creates a new state rather than failing loudly, and the surprise appears as an empty plan, not an error.

The first mechanism is the -backend-config flag, taking key=value pairs:

terraform init \
  -backend-config="bucket=acme-tfstate-prod" \
  -backend-config="key=prod/network/terraform.tfstate" \
  -backend-config="dynamodb_table=tfstate-locks"

Every argument on the command line is merged over whatever the block contained, producing the effective backend configuration for that init. One consequence surprises people who have only run full configuration: with a partial block, init is doing double duty as configuration time. Terraform persists the merged result in .terraform/terraform.tfstate — the file holding this directory's backend connection metadata, including any credentials you passed. That is a primary reason the .terraform directory must never be committed. Saved plan files capture the same backend configuration at plan time, so applying a saved plan writes to the backend the plan recorded — a deliberate safeguard, and a reason a saved plan can outlive time-limited credentials.

All of which argues for making the invocation boring and file-driven rather than a wall of flags. That is the next chapter's subject: the config file, and how it keeps both secrets and sanity out of the committed block.

One Committed Block, One Config File per Environment

-backend-config accepts a file path as well as key=value pairs, and the file contains nothing but backend arguments in HCL:

# env/prod/backend.hcl — arguments only, no terraform block
bucket         = "acme-tfstate-prod"
key            = "prod/network/terraform.tfstate"
dynamodb_table = "tfstate-locks"
kms_key_id     = "arn:aws:kms:eu-west-1:111122223333:key/abcd-1234"
terraform init -backend-config=env/prod/backend.hcl

The division of labor is now clean. The committed backend block says how the team stores state (S3, region, encryption on) — code review's job. The per-environment file says which state — the environment owner's job. If the file holds nothing more sensitive than a bucket name and a key path, it can be committed too; many teams version the boring files (env/dev, env/staging) and gitignore only the ones carrying account-specific secrets, such as a kms_key_id for a shared-services account or any argument naming infrastructure a compromised repository could reach.

CI wiring becomes declarative and reviewable in the same pull request as the configuration it serves:

- name: Init against the prod backend
  run: terraform init -backend-config=env/${ENVIRONMENT}/backend.hcl
  env:
    ENVIRONMENT: prod

No prompting, no long command lines, and the file the pipeline used is a commit — reviewing a change to prod's state location looks exactly like reviewing any other change.

The scale economics explain why files win over flags once a team passes about three environments. A wrapper carrying six flags per environment needs three code paths; a directory of files needs three files and no code. The failure mode shifts from "the script branched wrong" to "the file says the wrong bucket" — and the second failure is visible in a diff, greppable across repositories, and diffable between environments (diff env/staging/backend.hcl env/prod/backend.hcl tells you in one line whether staging and prod differ only in their key prefix, or also in encryption settings). That last property — environment parity as a reviewable artifact — is the quiet reason platform teams adopt this pattern before they have any compliance requirement pushing them toward it. When an auditor later asks "how do you know prod's state is encrypted and staging's is not?", the answer is a file comparison, not a tribal recollection.

The rules that keep the pattern healthy:

  • Files contain backend arguments only — not a terraform { block, not provider config, not interpolated expressions. The literal-only rule still applies inside them; they are values, not code.
  • One file per state location, named by environment, not by person. env/dev/backend.hcl, not alice-sandbox.hcl; the directory layout is the documentation of which states exist.
  • Never mix files between stacks. Pointing the app stack's init at the network stack's file makes the app plan against the network's state, and nothing validates that the file matches the directory you are standing in.

Workspaces interact with S3 in one specific way worth knowing: the backend automatically prefixes non-default workspace state under env:/<workspace>/ inside the object key. One backend configuration therefore serves every workspace of one stack — useful for ephemeral preview environments. Long-lived environments (dev/staging/prod) are better served by explicit files, because explicitness is a safety property when the thing being varied is production's address.

When Settings Change: init Again, -reconfigure or -migrate-state

Partial configuration did not relax an earlier rule: changing backend settings requires re-running terraform init, and init may offer to migrate state. What partial configuration changed is how easily those settings vary — a new bucket per environment, a lock-table rename — which means how often this situation arises. The decision table is small and worth memorizing:

Situation Command
Settings changed; state already lives in the new location terraform init -reconfigure
Settings changed; state must be copied to the new location terraform init -migrate-state
Settings changed; no state access needed (validate-only job) terraform init -backend=false

-reconfigure says "accept these settings and keep going": it overwrites the stored backend configuration without attempting any migration. That is correct after a colleague already migrated, or when repointing at a store that was populated another way. -migrate-state forces the copy flow even if init would not have prompted — the right choice the first time a stack moves buckets. Each wrong choice fails memorably. -reconfigure when a copy was needed leaves the new backend empty, and the next plan proposes creating the entire estate over live infrastructure. -migrate-state when a copy was not wanted writes a second copy of state to a new location, leaving two stores that each believe they are authoritative until a human reconciles them — the infrastructure analog of a split-brain database.

-backend=false deserves its own line because it solves a real CI pattern: lint and validate jobs that need the provider plugin directory but should never touch remote state. It skips backend initialization entirely, so the job cannot lock, unlock, or read a state it was never meant to see.

One more scenario sits on this table: migrating away from partial configuration entirely — a team that hard-coded its per-environment files after standardizing on one account per environment. The move is the same ceremony in reverse: replace the partial block with a full one, run init -reconfigure (the state is already in place under the same bucket and key; only the completion mechanism changed), and delete the files. The reason -reconfigure and not a plain init is worth stating plainly: init sees different backend settings than last time and will offer migration, and accepting a migration prompt you do not need is how two state copies come into existence. Any time the state's physical location is unchanged, -reconfigure is the honest answer to init's question.

Every backend change ends with the same verification sequence — this is the habit that turns backend surgery from a white-knuckle operation into a checklist:

terraform init -backend-config=env/prod/backend.hcl   # accept any prompt deliberately
terraform state list | head -20                       # populated? the resources you expect?
terraform plan                                        # no surprise diff, above all no mass-create

If state list comes back empty against a stack you know is running, stop and re-run init with the other flag — you are looking at the wrong state, and apply is the last thing to run. With partial configuration in place you can store state deliberately and complete backend configuration without editing shared files. The next topic begins the state-operations half of this phase: the read-only commands that make every later mutation safer, starting with Listing and Showing State Entries.

Quiz

1. A committed backend block omits bucket and key, and an engineer runs terraform init with no flags in a terminal. What happens?

Question 1 options

2. What belongs in a per-environment backend.hcl file passed via -backend-config, and what stays in the committed backend block?

Question 2 options

3. You change the backend to a new S3 bucket that is currently empty, and the state must be copied there. Which command is correct?

Question 3 options

4. Why must the .terraform directory be excluded from version control when using partial backend configuration?

Question 4 options

Flashcards

Question

What is partial backend configuration, and why can't you fill the gaps with variables?

Answer

It is the practice of omitting varying backend arguments (bucket, key, lock table) from the committed backend block and supplying them at `terraform init` via -backend-config flags or a config file. Variables and expressions are illegal inside the backend block — the backend must be resolved before Terraform can load configuration, so only literals or init-time values work.

Question

What are the two -backend-config completion mechanisms, and which do teams standardize on?

Answer

Inline `key=value` flags (fine for one-off use, unwieldy in scripts) and a per-environment HCL file of backend arguments (env/prod/backend.hcl). Teams standardize on the file: it is declarative, reviewable in the same PR as the configuration, and avoids long copy-paste-prone command lines in CI.

Question

Where does Terraform record the resolved backend configuration after init, and what is the security consequence?

Answer

In .terraform/terraform.tfstate — the directory's backend connection metadata, including any credentials passed via -backend-config. Consequence: the .terraform directory must be gitignored, or state location and secrets land in version control.

Question

terraform init -reconfigure vs -migrate-state: what does each do, and how does each fail when chosen wrongly?

Answer

-reconfigure accepts the new settings without migrating — wrong when a copy was needed, leaving the new backend empty (next plan proposes mass create). -migrate-state forces the copy — wrong when a copy was not wanted, leaving two state stores that both think they are authoritative. Choose by whether the state must physically move.

Question

How do S3 workspaces interact with backend keys, and when should you prefer explicit per-environment files?

Answer

The S3 backend prefixes non-default workspace state under env:/<workspace>/ in the object key, so one backend configuration serves all workspaces of a stack — good for ephemeral previews. Prefer explicit env/dev|staging|prod backend files for long-lived environments, where making production's state address explicit is a safety property, not redundancy.

Revision Notes

Key Takeaways

  • 1. The backend block accepts literals only, so per-environment variation is supplied at init time via -backend-config — flags or, in practice, one HCL file per environment
  • 2. A bare `terraform init` against a partial block prompts for each missing argument; CI must always pass -backend-config explicitly or init blocks on a closed stdin
  • 3. The committed block documents how the team stores state, the per-environment file documents which state — keeping prod's address a reviewed diff instead of an ad-hoc command line
  • 4. init persists the merged backend configuration, credentials included, in .terraform/terraform.tfstate — the reason .terraform is always gitignored
  • 5. Settings changes require init again: -reconfigure when state is already in place, -migrate-state when it must be copied; each wrong choice produces a distinct, expensive failure

Interview Tips

  • • "How do you manage one Terraform codebase across many environments?" — partial backend configuration: committed block for constants, per-environment backend.hcl files (or -backend-config flags in CI) for bucket/key; add that workspaces cover ephemeral environments while dev/staging/prod use explicit files
  • • "Why can't the backend block use variables?" — the backend must be resolved before configuration is loaded, so only literals or init-time values are legal; this is why init, not plan, is where backend configuration happens
  • • "What happens to state when you switch buckets?" — init -migrate-state copies it; -reconfigure does not, and the failure mode (empty backend, plan proposing mass create) is worth naming unprompted
  • • "Where are backend credentials stored after init, and why does that matter?" — .terraform/terraform.tfstate holds the resolved backend config including passed credentials; hence .terraform is gitignored and backend secrets belong in the credential chain, not the file

Cheat Sheet

Partial Backend Configuration

Committed block = constants; init supplies the variables:

terraform {
  backend "s3" {
    region  = "eu-west-1"
    encrypt = true
    # bucket/key/dynamodb_table omitted — supplied at init
  }
}

Completion at init:

terraform init -backend-config="bucket=acme" -backend-config="key=prod/terraform.tfstate"
terraform init -backend-config=env/prod/backend.hcl   # preferred: file per environment

backend.hcl file (arguments only — no terraform block, no expressions):

bucket         = "acme-tfstate-prod"
key            = "prod/network/terraform.tfstate"
dynamodb_table = "tfstate-locks"

Changing settings:

Situation Command
state already in new location terraform init -reconfigure
state must be copied terraform init -migrate-state
no state access needed terraform init -backend=false

Gotchas: bare init prompts interactively (fatal in CI); resolved backend config (with any credentials) is stored in .terraform/terraform.tfstate — gitignore .terraform; saved plans apply against the backend recorded at plan time.

Continue Learning