When you are the only one running Terraform, local state is fine. The moment a second engineer runs apply, you are one race condition away from a broken environment. And when you are running dozens of environments across teams, the questions get harder: who creates the state bucket, how do you split state so one bad apply cannot take down everything, and how do you give a team of twenty engineers safe access without handing everyone the keys to production?
This post covers the full picture: choosing a backend, the bootstrapping problem nobody warns you about, state layout strategy, locking, surgery when things go wrong, and the team workflow that holds it together.
Why local state breaks
terraform.tfstate on your laptop is a single point of failure. It cannot be shared. It cannot be locked. If your laptop dies, your state dies with it. Worse, if two people apply at the same time, the last write wins and the loser’s changes vanish from state while still existing in the cloud. That gap between state and reality is called drift, and it is painful to fix.
Remote state solves three problems: shared access, locking, and durability. But setting it up raises a question most tutorials skip.
The bootstrapping problem
Here is the chicken-and-egg problem: Terraform needs a backend to store state, but the backend itself (an S3 bucket, a DynamoDB table, a storage account) is infrastructure. Who creates it?
You have three options, and only one of them is right for teams.
Option 1: ClickOps. Someone creates the bucket by hand in the console. This works exactly once. Then nobody remembers the settings, the bucket has no versioning, no encryption, no lifecycle policy, and six months later someone asks why state history only goes back two weeks.
Option 2: A separate bootstrap Terraform project with local state. This is the standard answer. You keep one tiny Terraform project whose only job is to create the backend infrastructure. Its state stays local (or in a second-level backend), because it has to exist before everything else.
Option 3: Terraform managing its own backend. Do not do this. A Terraform project cannot reliably create the bucket that holds its own state. The init sequence makes this circular and fragile.
Here is what a bootstrap project looks like on AWS:
# bootstrap/main.tf - run once per account, state stays local
terraform {
required_version = ">= 1.5"
}
provider "aws" {
region = "us-east-1"
}
resource "aws_s3_bucket" "tfstate" {
bucket = "myorg-terraform-state-prod"
}
resource "aws_s3_bucket_versioning" "tfstate" {
bucket = aws_s3_bucket.tfstate.id
versioning_configuration {
status = "Enabled"
}
}
resource "aws_s3_bucket_server_side_encryption_configuration" "tfstate" {
bucket = aws_s3_bucket.tfstate.id
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "aws:kms"
}
}
}
resource "aws_s3_bucket_public_access_block" "tfstate" {
bucket = aws_s3_bucket.tfstate.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
resource "aws_dynamodb_table" "tflocks" {
name = "terraform-state-locks"
billing_mode = "PAY_PER_REQUEST"
hash_key = "LockID"
attribute {
name = "LockID"
type = "S"
}
}
Versioning matters more than people think. If a state file gets corrupted, versioning lets you roll back to the last good version. Without it, you are reconstructing state from terraform import, resource by resource, which is exactly as fun as it sounds.
Run this once per account. Commit it to git. Then every other project points at the backend it created.
Picking a backend
Every major cloud has a native backend. The pattern is the same everywhere: an object store for the state file plus a locking mechanism.
AWS (S3 + DynamoDB):
terraform {
backend "s3" {
bucket = "myorg-terraform-state-prod"
key = "network/terraform.tfstate"
region = "us-east-1"
dynamodb_table = "terraform-state-locks"
encrypt = true
}
}
Azure (Blob Storage):
terraform {
backend "azurerm" {
resource_group_name = "tfstate-rg"
storage_account_name = "tfstatestore"
container_name = "tfstate"
key = "network.terraform.tfstate"
}
}
Azure handles locking natively through blob leases. No separate lock table to manage. One less moving part, and one less thing to misconfigure.
GCP (GCS):
terraform {
backend "gcs" {
bucket = "myorg-terraform-state"
prefix = "network"
}
}
GCS locking is native as well.
State layout: one file per blast radius
Do not put your entire infrastructure in one state file. Split by environment and by component. A layout that works:
prod/
network/terraform.tfstate
compute/terraform.tfstate
data/terraform.tfstate
dns/terraform.tfstate
staging/
network/terraform.tfstate
compute/terraform.tfstate
Three reasons for this:
- Blast radius. If the network state gets corrupted, compute and data are untouched. A bad apply can only destroy what is in its state file.
- Speed. A plan against a 5,000-resource state file is slow. A plan against 200 resources is fast. Engineers stop running plan when it takes ten minutes. Then they apply blind.
- Access control. Not everyone needs access to every state file. The data team does not need write access to network state. Smaller state files map cleanly to IAM boundaries.
The tradeoff is cross-component references. When compute needs the VPC ID from network state, use terraform_remote_state data sources or, better, pass values through a shared outputs pattern. It adds a little friction. The blast radius protection is worth it.
State locking in practice
Locking is automatic once the backend supports it. When you run apply, Terraform acquires the lock. Anyone else who tries gets an error instead of corrupting your state.
Two rules I follow:
- Never use
-lock=falsein a shared workflow. It exists for emergencies, like a stuck lock after a crashed apply. If you use it, tell the team first. - If a lock gets stuck,
terraform force-unlock <lock-id>clears it. Check with the team before you do. Someone might actually be mid-apply, and force-unlocking under them recreates the exact race condition locking was built to prevent.
State surgery: when you need to move resources
Sometimes the layout needs to change. You split a monolith state into components, or you move a resource from one team’s state to another’s. Terraform has tools for this, and they all operate on state without touching real infrastructure.
Move a resource between modules or addresses:
terraform state mv 'aws_instance.web' 'module.compute.aws_instance.web'
This rewrites the state file. The EC2 instance keeps running. Nothing is destroyed. But get the address wrong and Terraform will plan to destroy the old address and create the new one. Always run terraform plan immediately after a state mv. If the plan shows creates or destroys, you got the address wrong. Fix it before anyone applies.
Pull state down, fix it, push it back:
terraform state pull > /tmp/state.json
# edit carefully
terraform state push /tmp/state.json
This is the escape hatch for corrupted state. It is also the fastest way to make things worse if you edit the JSON by hand without understanding the schema. Use it as a last resort, and keep a backup of the original pull.
Remove something from state without destroying it:
terraform state rm 'aws_s3_bucket.legacy'
The bucket keeps existing in AWS. Terraform just stops managing it. Useful when decommissioning Terraform management of a resource, or when handing a resource to another team’s state file (rm here, import there).
Migrating from local to remote state
If you already have local state and need to move it, the process is straightforward:
- Add the backend block to your configuration.
- Run
terraform init. Terraform detects the migration and asks: "Do you want to copy existing state to the new backend?" Say yes. - Verify with
terraform plan. It should show no changes. - Delete the local
terraform.tfstateand the backup. Commit the backend config.
The init migration is one of the few genuinely smooth operations in Terraform. It just works.
Keep secrets out of hardcoded config
Do not hardcode bucket names or credentials in your backend block. Use partial backend configuration and pass values at init time:
terraform {
backend "s3" {}
}
terraform init \
-backend-config="bucket=myorg-terraform-state-prod" \
-backend-config="key=network/terraform.tfstate" \
-backend-config="region=us-east-1" \
-backend-config="dynamodb_table=terraform-state-locks"
This keeps environment-specific values out of version control and lets the same code work across dev, staging, and prod. In CI/CD, these values come from pipeline variables, not from the repo.
State files contain secrets
This one catches people. Terraform state stores every attribute of every resource, including passwords, keys, and connection strings, in plain text. Treat your state bucket like a secrets store:
- Enable encryption at rest (the
encrypt = trueabove, or the KMS config in the bootstrap). - Restrict IAM access to the state bucket to the CI/CD role and break-glass admins only. Engineers get read access for planning, not write.
- Never commit a state file to git. Add
*.tfstateand*.tfstate.backupto.gitignoreon day one. - Enable bucket versioning so a corrupted state can be rolled back.
If you are already using Vault or a secrets manager for application secrets, that does not cover Terraform state. State is a separate secrets surface. Treat it as one.
The team workflow
The setup that works for most teams:
- Engineers run
terraform planlocally against the remote state. Plans are safe. They change nothing. Read access to state is enough. - Pull requests include the plan output. Reviewers check what will change before it changes.
- Only CI/CD runs
terraform apply, after the plan is approved. The pipeline uses a dedicated IAM role with write access to state. - Applies run serially per state file. The lock enforces this mechanically, but the pipeline should also avoid queuing parallel applies to the same state.
Local plan, reviewed apply, locked state. That is the whole discipline. It scales from two engineers to two hundred without changing shape.
Pre-flight checklist
Before you call your remote state setup done:
- [ ] Backend infrastructure created via a bootstrap project, not by hand
- [ ] Versioning enabled on the state bucket
- [ ] Encryption at rest enabled
- [ ] Public access blocked on the state bucket
- [ ] Locking configured and tested (run two applies, confirm the second waits or fails cleanly)
- [ ] State split by environment and component, not one giant file
- [ ]
.gitignorecovers*.tfstateand*.tfstate.backup - [ ] IAM: engineers read, CI/CD writes, break-glass admins only
- [ ] Backend values passed via
-backend-config, not hardcoded - [ ] Team knows the
force-unlockprocedure and when not to use it
Closing
Remote state is not exciting work. Nobody demos a backend block at a conference. But every production Terraform setup stands on it, and every Terraform horror story I have heard starts with someone skipping one of the items on that checklist.
Get the backend right, split your state by blast radius, lock everything, and keep secrets out of git. The rest of your Terraform practice gets easier from there.
Next week: reusable Terraform modules for API gateway provisioning across Azure and AWS, with versioned module registries and composition patterns that survive team growth.
Top comments (0)