Terraform Backends and Remote State
Why local state breaks teams, what a backend is, and the S3 plus DynamoDB pattern for remote state - with locking, encryption, migration, and remote_state outputs.
Terraform is only as good as the state file behind it, and the moment more than one person touches an environment, where that file lives stops being a detail and starts being the whole ballgame. If you have read the state guide, you know state is the map Terraform keeps between your configuration and the real resources it created. This guide is about where that map should live once you have a team: not on a laptop, but in a shared, locked, encrypted backend. We will cover why local state falls apart, what a backend actually is, the canonical S3 setup on AWS, state locking, encryption and access control, other backend options, how to migrate an existing project, and how one configuration reads another's outputs.
Why local state breaks teams
By default, Terraform writes state to a file called terraform.tfstate in your working directory. That is fine for a solo experiment and a disaster for a team, for three concrete reasons.
No sharing. State on your laptop is invisible to everyone else. Your teammate runs terraform apply against the same infrastructure with their own empty or stale state, Terraform thinks nothing exists yet, and it tries to create resources that are already there - or worse, it does not know about resources you created and cannot manage them at all. Two people with two local state files are managing two different, drifting pictures of the same real infrastructure.
No locking. Even if you email the state file around (please do not), nothing stops two people from running apply at the same time. Terraform reads state, plans changes, and writes new state. If two applies interleave, the second write clobbers the first, and now your state file describes neither of the two runs correctly. A corrupted state file is one of the worst places to be in Terraform - the fix is manual, error-prone terraform state surgery or importing resources one by one.
The laptop is a single point of failure. The state file is the only record of what Terraform manages and how resources map to your config. Lose it - a wiped disk, a lost laptop, an accidental rm - and Terraform no longer knows that your production database, load balancer, and VPC belong to it. The resources keep running, but Terraform is now blind to them. Local state also means no history, no backups, and no audit of who changed what.
All three problems have the same shape: state needs to live somewhere shared, protected, and durable. That somewhere is a backend.
What a backend is
A backend is where Terraform stores state and, in most cases, where it coordinates locking. Instead of a file next to your .tf code, state lives in a remote store - an S3 bucket, an Azure storage account, a GCS bucket, Terraform Cloud, and so on. You configure it once in a backend block inside terraform { ... }, and from then on every plan and apply reads and writes state there.
The key mental shift: the backend is infrastructure too, and it is shared. Everyone on the team, and your CI system, points at the same backend. There is one source of truth for state, it is not on anyone's machine, and (with a proper backend) only one apply can hold the lock at a time. Terraform still keeps a temporary local copy while it works, but the authoritative copy is remote.
A minimal backend block looks like this:
terraform {
backend "s3" {
bucket = "acme-terraform-state"
key = "prod/network/terraform.tfstate"
region = "us-east-1"
}
}
The key is the path to this configuration's state file within the bucket. Give every distinct configuration and environment its own key (prod/network/..., prod/app/..., staging/app/...) so their states never collide.
The S3 backend with DynamoDB locking
On AWS, the canonical setup is an S3 bucket for the state file plus a DynamoDB table for the lock. S3 gives you a durable, versioned, encrypted store; DynamoDB gives you the lock that prevents two applies from stomping on each other. This pairing has been the standard AWS backend for years, and it is what most teams run.
First, the backend that stores state and uses the table for locking:
terraform {
backend "s3" {
bucket = "acme-terraform-state"
key = "prod/network/terraform.tfstate"
region = "us-east-1"
dynamodb_table = "terraform-locks"
encrypt = true
}
}
The bucket and table have to exist before Terraform can use them. This is the classic chicken-and-egg: you cannot store the state of the state bucket in the state bucket. The standard answer is a small, separate "bootstrap" configuration that creates them with local state (a tiny, low-churn state that is easy to keep safe), or you create them by hand once. A bootstrap config looks like:
resource "aws_s3_bucket" "state" {
bucket = "acme-terraform-state"
}
# Keep every version of state, so you can recover from a bad apply.
resource "aws_s3_bucket_versioning" "state" {
bucket = aws_s3_bucket.state.id
versioning_configuration {
status = "Enabled"
}
}
# Encrypt at rest by default.
resource "aws_s3_bucket_server_side_encryption_configuration" "state" {
bucket = aws_s3_bucket.state.id
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "aws:kms"
}
}
}
# Block all public access - state contains secrets.
resource "aws_s3_bucket_public_access_block" "state" {
bucket = aws_s3_bucket.state.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
resource "aws_dynamodb_table" "locks" {
name = "terraform-locks"
billing_mode = "PAY_PER_REQUEST"
hash_key = "LockID"
attribute {
name = "LockID"
type = "S"
}
}
That DynamoDB table needs exactly one attribute, LockID of type string - Terraform writes a lock item there while it holds the lock and deletes it when done.
One note on versions: recent Terraform (1.10 and later) supports S3-native state locking via a use_lockfile = true option, which puts the lock in S3 itself and lets you drop the DynamoDB table entirely. It is newer, so plenty of real-world setups still use DynamoDB. If you are starting fresh on current Terraform, S3-native locking is one less resource to manage; if you are joining an existing project, expect to see the DynamoDB table.
State locking and why it matters
Locking is what prevents two applies from corrupting state. When you run apply (or plan with a write, or state commands), Terraform first acquires the lock. While one run holds it, a second run that tries to start gets refused:
Error: Error acquiring the state lock
Lock Info:
ID: 3f9a1c22-...
Operation: OperationTypeApply
Who: jsmith@laptop
Created: 2026-07-07 14:02:11 UTC
That message is doing exactly its job: it is telling you someone (here, jsmith) is mid-apply, so you should wait rather than run a conflicting change. Without a lock, both applies read the same starting state, each plans against it, and whichever writes last overwrites the other's result - leaving state that matches neither run. Locking serializes writes so the state file always reflects a complete, consistent apply.
If a run dies badly (network drop, killed process) the lock can be left dangling. terraform force-unlock <LOCK_ID> releases it, but treat that as a last resort - only force-unlock when you are certain no apply is actually still running, or you reintroduce exactly the corruption locking exists to prevent.
Encryption and access control
State files routinely contain secrets in plaintext: database passwords, generated private keys, access tokens, the full detail of every resource. Anyone who can read your state can read those. So the backend is a security boundary, and you protect it on two fronts.
Encryption. Encrypt state at rest and in transit. With the S3 backend, encrypt = true ensures the object is written encrypted, and enabling default SSE (ideally with a KMS key) on the bucket covers it at the storage layer. S3 also encrypts in transit over TLS. If you use a KMS key, you can scope who is allowed to decrypt, adding a second gate on top of bucket permissions.
Access control. Lock the bucket down. Block all public access (as in the bootstrap above), and grant read/write only to the humans and CI roles that actually need it via IAM. A useful pattern is separating who can read state from who can apply: broad read for people who need outputs, tight write for the roles that run applies in CI. And because state is your crown jewels, keep versioning on so a bad apply or accidental overwrite can be rolled back to a previous state object.
The rule of thumb: treat the state backend with the same care as a production secrets store, because functionally that is what it is.
Terraform Cloud, Enterprise, and other backends
S3 plus DynamoDB is the do-it-yourself route. There are managed and alternative options depending on your platform.
- Terraform Cloud / HCP Terraform and Terraform Enterprise - HashiCorp's managed offering. It stores state for you (encrypted, versioned, locked) and adds remote runs, a plan/apply approval workflow, run history, policy enforcement, and a secrets story for variables. Configured with a
cloudblock rather than abackendblock. Good when you want the coordination and governance features and not to run the plumbing yourself. - azurerm - state in an Azure Storage Account blob container, with blob leases providing locking. The Azure equivalent of the S3 pattern.
- gcs - state in a Google Cloud Storage bucket, with locking handled natively. The GCP equivalent.
- Consul, Postgres, and others - additional backends exist for specific setups. Less common for typical cloud teams, but available.
Whichever you pick, the concepts are identical: a shared remote store, locking to serialize writes, encryption and access control around it. Only the provider-specific configuration changes.
Migrating from local to remote state
You do not have to start remote. Plenty of projects begin with local state and move once a second person shows up. The migration is built in and safe if you follow the steps.
Add the backend block to your terraform { ... } configuration (having created the bucket and lock table first), then run:
terraform init -migrate-state
Terraform detects that state currently lives locally and a new backend is configured, and it prompts you:
Initializing the backend...
Do you want to copy existing state to the new backend?
Pre-existing state was found while migrating the previous "local" backend
to the newly configured "s3" backend. Do you want to copy this state?
Enter "yes" to copy and "no" to start with an empty state.
Answer yes and Terraform copies your existing terraform.tfstate into the remote backend. From then on, every command reads and writes remotely. Do a terraform plan immediately afterward and confirm it shows no changes - that proves the migration copied everything and Terraform still sees the same resources. Once you are confident, delete the leftover local terraform.tfstate (and its backup) so nobody accidentally uses it.
The same command handles moving between backends, not just local-to-remote. A related flag, terraform init -reconfigure, ignores existing state and reconfigures the backend from scratch - useful when you are intentionally pointing at a fresh, empty backend, but do not reach for it during a real migration or you will start with empty state.
Reading another configuration's outputs
Once state is remote and shared, one configuration can read another's outputs through the terraform_remote_state data source. This is how you split infrastructure into separate, independently applied configurations - a network layer, a database layer, an app layer - while still passing values between them.
Say your network configuration exports the VPC and subnet IDs:
# In the network configuration
output "vpc_id" {
value = aws_vpc.main.id
}
output "private_subnet_ids" {
value = aws_subnet.private[*].id
}
Your app configuration reads that state and uses the values:
data "terraform_remote_state" "network" {
backend = "s3"
config = {
bucket = "acme-terraform-state"
key = "prod/network/terraform.tfstate"
region = "us-east-1"
}
}
resource "aws_instance" "app" {
# ...
subnet_id = data.terraform_remote_state.network.outputs.private_subnet_ids[0]
}
The app config never manages the VPC - it just reads the network config's published outputs. This keeps each configuration small and independently applyable, and it makes the dependency explicit: the app layer depends on the network layer's outputs, nothing else. A caveat worth knowing: terraform_remote_state exposes only what the other configuration declares as output, but it reads the whole state to do so, so the reading role needs read access to that state - and only expose values through outputs that you are comfortable other configs consuming.
The shape of it
Local state is a solo tool: no sharing, no locking, and a laptop that is a single point of failure for your entire infrastructure. A backend moves state somewhere shared, durable, and protected. On AWS the canonical setup is an S3 bucket (versioned and encrypted) for the state file plus DynamoDB (or newer S3-native locking) for the lock that serializes applies so two runs cannot corrupt state. Because state holds secrets in plaintext, you encrypt it and lock down access with IAM. Managed options like Terraform Cloud package all of this, and the azurerm/gcs backends mirror the pattern on other clouds. Moving an existing project over is one command - terraform init -migrate-state - and once state is shared, terraform_remote_state lets one configuration read another's outputs so you can split infrastructure into clean, independent layers. Get the backend right and Terraform goes from a personal script to something a team can safely run.