Terraform Professional: Remote Backends and Remote State
A Terraform remote backend stores a configuration's state in a shared service such as an S3 bucket or HCP Terraform, and handles state locking so a team can run Terraform safely against one source of truth; remote state is that stored state, which other configurations can read for their outputs. This lesson covers objective 2a of the Terraform Infrastructure Engineer Professional (002) exam: writing and varying backend configuration, keeping credentials out of it, migrating state between backends, locking and protecting state in S3, workspace key layout, the HCP Terraform cloud block, and sharing outputs with terraform_remote_state and tfe_outputs.
On this page10 sections
- What a remote backend is and why its block only takes literals
- Partial configuration with -backend-config
- Where backend credentials end up — and how to keep them out
- Changing a backend: -migrate-state, -reconfigure and -force-copy
- State locking on the S3 backend: use_lockfile and the DynamoDB migration
- Protecting state in S3: versioning, encryption and access control
- Workspaces on the S3 backend: how state keys are laid out
- Connecting to HCP Terraform with the cloud block
- Migrating existing state into HCP Terraform
- Reading another configuration's outputs: terraform_remote_state and tfe_outputs
- Write backend blocks that respect the literals-only rule and vary per environment with partial configuration and -backend-config
- Explain where backend configuration values persist and keep credentials out of them
- Choose between init -migrate-state, -reconfigure and -force-copy when a backend changes, including in non-interactive jobs
- Configure S3 backend locking with use_lockfile, its permissions, and a safe migration away from DynamoDB locking
- Protect S3 state with versioning, KMS encryption and access control, and predict state and lock object keys for each workspace
- Connect a configuration to HCP Terraform with the cloud block and migrate existing state into it
- Read another configuration's outputs with terraform_remote_state or tfe_outputs and control who may read them
What a remote backend is and why its block only takes literals
A backend is the part of Terraform that decides where state is stored and how it is locked; a remote backend keeps that state in a shared service (an S3 bucket, an Azure storage container, a GCS bucket, HCP Terraform) instead of a terraform.tfstate file on one engineer's disk. You declare it once, inside the terraform block:
terraform {
backend "s3" {
bucket = "acme-state"
key = "network/terraform.tfstate"
region = "eu-west-1"
use_lockfile = true
}
}
Three rules shape every backend question:
- Literals only. Terraform initializes the backend before it evaluates the rest of the configuration, so the block cannot refer to any named value: no
var., nolocal., no data source attributes and noterraform.workspace. A reference is an error atterraform init— a variable's default value or aterraform.tfvarsfile does not rescue it. - One backend per configuration. A root module has at most one
backendblock, and acloudblock (HCP Terraform) cannot appear alongside it. - Init owns it. Adding, removing or changing any backend argument means running
terraform initagain; other commands refuse to run until you do.
Values that genuinely differ between environments therefore come from partial configuration at init time (next section), and per-workspace separation comes from the backend's own workspace layout, never from an expression in the block.
Partial configuration with -backend-config
Partial configuration means leaving some backend arguments out of the committed block and supplying them when you run terraform init. This is HashiCorp's answer to "one configuration, several environments, different buckets or accounts" without duplicating Terraform files. The missing values can come from:
- a file:
terraform init -backend-config=prod.s3.tfbackend— HashiCorp recommends the*.backendname.tfbackendnaming; the file holds top-levelkey = valuelines; - a key/value pair:
terraform init -backend-config="bucket=acme-prod-state"; - an interactive prompt for required arguments still missing (only when a terminal is attached).
Merge order: Terraform starts from the block, then applies each -backend-config option in the order given. Command-line options override the block, and when two options set the same argument the later one wins. So -backend-config=qa.s3.tfbackend -backend-config="region=eu-central-1" ends with eu-central-1, whatever the file or the block said. The merged result is saved in the .terraform directory.
-backend-config is an init-only option; plan and apply do not accept it. A pipeline that cannot edit its command line can inject it through TF_CLI_ARGS_init. Terraform inserts that value directly after the init subcommand and before any flags typed on the command line, so under the later-wins rule a -backend-config typed on the command line still overrides one injected through the variable.
Why the alternatives fall short follows from the same mechanism. Backend values are fixed at init, before any expression is evaluated, so anything that relies on evaluation (variables, locals, terraform.workspace) is rejected, and commands after init have no backend options to take. Keeping a separate backend_override.tf per environment does change the backend mechanically, but it trades one committed block for a set of files that have to be swapped in before each init.
Where backend credentials end up — and how to keep them out
Every value you put into backend configuration — in the block, in a .tfbackend file or as a -backend-config pair — is written in plain text to .terraform/terraform.tfstate in the working directory and captured in every saved plan file (terraform plan -out=...). That includes access keys and secrets.
It is not written to the remote state object, to the dependency lock file .terraform.lock.hcl (which records provider versions and checksums) or to terraform.tfstate.backup. Those hold different data.
HashiCorp's guidance is therefore to supply backend credentials through environment variables (for S3, the standard AWS_* variables or, better, a role or instance profile the runner already has), never through backend arguments, and never to commit the .terraform directory or saved plans. An uncommitted .tfbackend file still lands in .terraform/terraform.tfstate, so "keep the file out of git" does not solve the problem.
Changing a backend: -migrate-state, -reconfigure and -force-copy
When an initialized working directory's backend configuration changes — a new bucket, a new key, a different backend type — terraform init must be told what to do with the existing state, and you pick one of two flags:
| Option | What it does | Result for the next plan |
|---|---|---|
-migrate-state | Copies the state from the old backend to the new one (prompting to confirm) | Same resources tracked; no changes if nothing else moved |
-reconfigure | Forgets the old backend and starts against the new one without copying anything | If the new location is empty, Terraform proposes creating every resource |
-force-copy | Answers "yes" to migration prompts; on its own it also turns on -migrate-state | Same as a confirmed migration, with no prompt |
Non-interactive jobs: migration asks for confirmation, and with -input=false (or no terminal) Terraform aborts rather than guess. The CI form is terraform init -migrate-state -force-copy -input=false (or just -force-copy -input=false). -reconfigure -force-copy still copies nothing, because -reconfigure means "ignore the old state".
Recovering from a mistaken -reconfigure: it does not delete anything. The state object at the old location is untouched, so you point the configuration back at it, re-initialize, and then perform the move properly with -migrate-state. A plan that suddenly wants to create everything after a backend edit almost always means state was not migrated, not that resources drifted.
Two other init flags are often confused with these: -upgrade refreshes provider and module versions within their constraints, and -backend=false skips backend initialization entirely. Neither moves state.
State locking on the S3 backend: use_lockfile and the DynamoDB migration
State locking stops two runs from writing the same state at once: a run that changes state takes the lock first, and a second run that cannot get it fails with "Error acquiring the state lock". On the S3 backend, locking is configured with use_lockfile = true (generally available since Terraform 1.11). It is off by default, so you must opt in.
- The lock is an S3 object at the state key with
.tflockappended — forprod/app.tfstateit isprod/app.tfstate.tflock. No extra AWS resource is needed. - The role needs
s3:GetObject,s3:PutObjectands3:DeleteObjecton that lock object. A policy scoped tightly to the state object fails as soon as locking is enabled, and one withoutDeleteObjectcould take a lock it can never release. - If
kms_key_idis set, the lock file is encrypted with the same key.
The older dynamodb_table locking still works but is deprecated. The S3 backend documents that both may be configured at the same time to support migration: runs with both settings acquire both locks, so they still conflict with older runs that only check DynamoDB. The safe cutover is therefore: add use_lockfile while keeping dynamodb_table; once no job (including rollbacks) can run the old configuration, remove dynamodb_table and delete the table. Switching in one step leaves a window where old and new runs check different locks and can write concurrently.
Waiting instead of failing: by default a run fails immediately when the lock is held. -lock-timeout=10m makes it retry for up to that duration, so overlapping pipeline runs queue rather than fail. -lock=false removes the protection and is never the answer for shared state; breaking a stale lock (terraform force-unlock) is covered in the lesson on inspecting state and remediating drift.
Protecting state in S3: versioning, encryption and access control
State often contains secrets in plain text (database passwords, private keys, outputs marked sensitive), and it is the only record of what Terraform manages, so it needs both recoverability and confidentiality. Each is a different control:
| Control | What it protects against | What it does not do |
|---|---|---|
| S3 bucket versioning | Accidental delete or overwrite — earlier state snapshots can be restored (HashiCorp highly recommends it) | Hide anything from readers |
encrypt = true | Data at rest on the storage medium (S3 has applied SSE-S3 to new objects by default since January 2023, so this matters most together with kms_key_id) | Stop a principal with s3:GetObject from reading plaintext |
kms_key_id (customer managed key) | Ties reading to KMS permissions on that key; encrypts state and lock file | Work unless the role has kms:Encrypt, kms:Decrypt and kms:GenerateDataKey |
| Bucket policy with an explicit deny | Other roles reading the state, even if they hold broad S3 read access | Recover deleted objects |
use_lockfile | Concurrent writes | Recovery or confidentiality |
sensitive = true on an output | Redaction in CLI output | Remove the value from state |
With a customer managed key, reading state needs kms:Decrypt and writing it needs kms:GenerateDataKey (with kms:Encrypt), which is why a role that can read the state at init can still fail at the first plan or apply that writes state or a lock object (with use_lockfile, even a plan writes the encrypted lock). An explicit deny in a bucket policy overrides any Allow elsewhere, which is what makes "only the pipeline role may read" achievable in an account where other roles have wide S3 permissions; a KMS key policy limiting kms:Decrypt is the alternative route. Audit features such as logging are detective controls: they tell you who read the state afterwards, but neither restore it nor prevent the read.
Workspaces on the S3 backend: how state keys are laid out
On the S3 backend, the default workspace stores state at the key exactly as written, and every other CLI workspace stores it at <workspace_key_prefix>/<workspace>/<key>, where workspace_key_prefix defaults to env:. With key = "network/terraform.tfstate":
| Workspace | State object | Lock object (use_lockfile) |
|---|---|---|
| default | network/terraform.tfstate | network/terraform.tfstate.tflock |
| staging | env:/staging/network/terraform.tfstate | env:/staging/network/terraform.tfstate.tflock |
staging, with workspace_key_prefix = "network" | network/staging/network/terraform.tfstate | same path + .tflock |
Consequences you should be able to reason about:
- Separate state per workspace needs no expression. A literal
keyalready gives every workspace its own object, so engineers move between environments withterraform workspace selectalone. Partial configuration or override files would also separate keys, but each switch would then need a newinit. - IAM scoped to a prefix breaks for non-default workspaces. A role allowed only under
network/works for the default workspace and fails with AccessDenied instaging, because that state sits underenv:/. Settingworkspace_key_prefixto a path inside the allowed prefix fixes it without moving the default workspace's state. - Lock permissions follow the workspace path too: grants written for the default workspace's
.tflockdo not cover another workspace's lock.
Connecting to HCP Terraform with the cloud block
The cloud block, nested in the terraform block, is HashiCorp's recommended way to connect a configuration to HCP Terraform (or Terraform Enterprise) for state storage and CLI-driven runs. It replaces a backend: HCP Terraform manages state itself, so no backend block may sit beside it. The older backend "remote" is still supported but is not the recommended path.
terraform {
cloud {
organization = "acme"
workspaces {
tags = { app = "billing" }
}
}
}
namevstags:workspacesmaps the directory either to one named workspace (name) or to every workspace carrying the given tags (tags, as a key/value map or a list of key-only tags). They are mutually exclusive — setting both is an init error. To target one workspace from a tags-based block, useterraform workspace select(after init) orTF_WORKSPACE.- Environment variables:
TF_CLOUD_ORGANIZATION,TF_CLOUD_HOSTNAME,TF_CLOUD_PROJECTandTF_WORKSPACEare read only when the matching attribute is omitted; if both are set, the configuration wins. When every value comes from the environment you still commit an emptycloud {}block — without it there is no HCP Terraform integration at all. TF_WORKSPACEwith tags: allowed, as long as it names an existing workspace that matches the tags. It is how a job with no terminal picks one workspace at init while the committed block keeps serving all of them.TF_CLOUD_PROJECTscopes the project, not the workspace.- Remote execution: new workspaces default to remote execution, so a CLI
planuploads the configuration and runs on HCP Terraform workers. Your shell's environment variables are not sent; provider credentials must be workspace environment variables, a variable set or dynamic provider credentials — or the workspace is switched to local execution.
Migrating existing state into HCP Terraform
To move state from a backend into HCP Terraform with the CLI, replace the backend block with a cloud block and run a normal terraform init. Init detects the change and offers to copy the existing state into the HCP Terraform workspace.
The migration works because init compares the newly configured target with the backend recorded in .terraform and copies state only when both are known and different. That explains the common failures: a configuration cannot declare the old backend and the cloud block at once, an init option that tells Terraform to forget the recorded backend leaves nothing to copy, and the TF_CLOUD_* variables only fill in attributes of a cloud block that exists, so they cannot switch a configuration away from its backend on their own.
Several CLI workspaces at once: a cloud block with name maps the directory to a single workspace, so it can receive only one state. With tags, init offers to migrate each local or backend workspace into its own HCP Terraform workspace (optionally renaming them with a pattern), and the same committed block can target any of them afterwards. Migrating dev, staging and prod in one init therefore calls for a tags-based block.
Reading another configuration's outputs: terraform_remote_state and tfe_outputs
The terraform_remote_state data source reads the root-module outputs of another configuration's latest state snapshot, using that configuration's backend settings:
data "terraform_remote_state" "network" {
backend = "s3"
workspace = terraform.workspace
config = {
bucket = "acme-state"
key = "network/terraform.tfstate"
region = "eu-west-1"
}
defaults = {
log_bucket = "acme-logs-placeholder"
}
}
# data.terraform_remote_state.network.outputs.vpc_id
- Root outputs only. Values appear under the
outputsattribute. A child module's output is invisible until the producer re-exports it with a root-leveloutput(for examplevalue = module.vpc.vpc_id); there is nooutputs.module...path. workspacedefaults todefault, regardless of the workspace the consumer runs in. If producer and consumer both use workspaces per environment, setworkspace = terraform.workspaceor a non-default consumer silently reads the default workspace's values. (Hard-coding the key toenv:/prod/...would read prod's state, but it is a brittle fix that pins one environment and ignores the consumer's workspace; the data source already applies the backend's own workspace layout.)defaultssupplies values for outputs that are missing or when the remote state is empty, so a plan in a fresh environment succeeds without editing every reference — the fallback lives in one place, beside the data source, rather than at each reference.- Full state access. The reader needs access to the entire snapshot, which often contains secrets — marking an output
sensitivedoes not change that.
In HCP Terraform, access to a workspace's state by other workspaces' runs is controlled by remote state sharing, configured on the producer (the workspace being read). New workspaces share with nobody by default. The producer can share with all workspaces in the organization, all workspaces in its project (a workspace in another project is outside that share) or specific workspaces; choose the narrowest that works. Changing settings on the consumer grants nothing.
tfe_outputs (from the tfe provider) reads only a workspace's outputs, so HashiCorp recommends it over terraform_remote_state when the producer's state holds sensitive data. It calls the API with the provider's token, so it is governed by that token's permissions, not by remote state sharing — restricting sharing alone does not stop a token-based reader. The underlying principle is to give a consumer the narrowest view that works: outputs rather than whole state, and one producer rather than an organization-wide share.
Tip. Expect production scenarios in which a backend or remote-state setup fails or must change, often with a short backend, cloud or data-source block in the stem. Items ask what init does with a named value in a backend block, how to supply per-environment values with one committed block, which -backend-config value wins, where backend credentials persist, and which init options copy state in a pipeline without a terminal — or why a plan suddenly wants to recreate everything. S3 items cover use_lockfile permissions and lock object paths, a safe cutover from DynamoDB locking, versioning versus encryption versus access control, KMS permissions and the key layout of non-default workspaces under a prefix-scoped IAM role. HCP Terraform items cover name versus tags in the cloud block, environment-variable overrides, TF_WORKSPACE in non-interactive jobs, remote execution and credentials, migrating several workspaces, remote state sharing scopes and tfe_outputs. Scenarios typically include an operational requirement, so the skill tested is choosing the mechanism that meets it with the least change and the least exposure, not just one that works.
- Backend blocks accept literals only — no variables, locals, data sources or terraform.workspace — and a configuration has one backend, never alongside a cloud block.
- Vary backend values per environment with -backend-config files or KEY=VALUE pairs at init; later options override earlier ones and the block. Only init accepts -backend-config.
- Backend configuration values, credentials included, persist in .terraform/terraform.tfstate and in saved plans, so supply credentials through environment variables or roles.
- After a backend change, -migrate-state copies state and -reconfigure starts empty; -force-copy answers the prompts (and implies -migrate-state), and -input=false without it aborts.
- S3 locking is use_lockfile (off by default): a .tflock object at the state key that needs Get, Put and Delete permissions. Keep dynamodb_table alongside it until every job runs the new config.
- Versioning recovers state, a customer managed KMS key needs Encrypt, Decrypt and GenerateDataKey, and only an access control such as a bucket-policy deny keeps secrets in state from other readers.
- Non-default S3 workspaces live at <workspace_key_prefix>/<workspace>/<key> (prefix env:), and so do their lock files.
- The cloud block maps to one workspace by name or many by tags (never both); TF_CLOUD_* and TF_WORKSPACE fill only omitted attributes, and an empty cloud {} is still required.
- terraform_remote_state reads root outputs only, defaults to the default workspace and needs full state access; HCP sharing is set on the producer, while tfe_outputs is governed by its token.
Frequently asked questions
Can a Terraform backend block use variables?
No. Terraform configures the backend before it evaluates the rest of the configuration, so a backend block may contain only literal values. References to input variables, locals, data sources or terraform.workspace cause an error at terraform init, even if the variable has a default. Supply environment-specific values with partial configuration: leave the argument out of the block and pass it at init with -backend-config, as a .tfbackend file or a KEY=VALUE pair.
What is the difference between terraform init -migrate-state and -reconfigure?
Both are used after the backend configuration changes. -migrate-state copies the existing state from the old backend to the new one, so the next plan still tracks every resource. -reconfigure ignores the old backend and initializes the new one without copying anything; if the new location is empty, the next plan proposes creating all resources. -reconfigure does not delete the old state, so you can recover by pointing back at it and migrating.
How do I migrate Terraform state in a CI pipeline with no terminal?
Run terraform init -migrate-state -force-copy -input=false. State migration normally asks for confirmation, and with -input=false Terraform aborts instead of guessing. The -force-copy option answers yes to those prompts and on its own also enables -migrate-state. Combining -force-copy with -reconfigure still copies nothing, because -reconfigure discards the old state location.
Does the Terraform S3 backend still need a DynamoDB table for locking?
No. Since Terraform 1.11 the S3 backend can lock with use_lockfile = true, which writes a lock object at the state key plus .tflock and needs s3:GetObject, s3:PutObject and s3:DeleteObject on it. It is off by default. DynamoDB locking via dynamodb_table is deprecated; both can be set at once during a migration so new runs still conflict with old runs that only check DynamoDB.
Where are backend credentials stored if I pass them with -backend-config?
Terraform writes every backend configuration value, including credentials passed in the block, a .tfbackend file or -backend-config pairs, to .terraform/terraform.tfstate in the working directory, and saved plan files capture them too. They are not written to the remote state or the .terraform.lock.hcl file. HashiCorp recommends supplying backend credentials through environment variables or an assumed role instead.
Where does the S3 backend store state for a non-default workspace?
The default workspace uses the configured key as written. Every other workspace is stored at <workspace_key_prefix>/<workspace name>/<key>, and workspace_key_prefix defaults to env:. With key = "network/terraform.tfstate", the staging workspace's state is env:/staging/network/terraform.tfstate, and with use_lockfile its lock is the same path with .tflock appended.
Why does terraform_remote_state return the wrong environment's values?
The data source's workspace argument defaults to the default workspace, regardless of which workspace the consuming configuration is running in. If the producer keeps each environment in its own workspace, set workspace = terraform.workspace on the data source so the consumer reads the matching environment. Also remember it exposes only root-module outputs, under the outputs attribute.
Should I use terraform_remote_state or tfe_outputs in HCP Terraform?
HashiCorp recommends tfe_outputs when the producer's state may hold sensitive values, because it reads only outputs, whereas terraform_remote_state needs access to the entire state snapshot. Access differs too: terraform_remote_state is governed by remote state sharing set on the producer workspace, while tfe_outputs uses the tfe provider's API token and is governed by that token's permissions.
Source
This lesson covers the "Terraform state and backend operations" domain of the official Terraform Pro 002 exam guide. Vendors revise their guides — check the source for the current version.
Sign up free to mark lessons complete, bookmark topics and track your exam readiness.