The fastest way to break Terraform for a whole team is to keep terraform.tfstate on someone's laptop. Two people apply at the same time, one state file wins, and the other half of your infrastructure is now “unknown” to Terraform. A remote backend with locking fixes this.
1. The state bucket
Create the bucket once (by hand or in a tiny bootstrap Terraform project with local state):
- Versioning on – every state write is recoverable.
- Block all public access and default encryption (SSE-S3 or SSE-KMS).
- A bucket policy that only allows your CI role and a small admin group.
2. The backend block
terraform {
required_version = ">= 1.10"
backend "s3" {
bucket = "shailapps-tfstate"
key = "envs/dev/network/terraform.tfstate"
region = "us-east-1"
encrypt = true
use_lockfile = true
}
}
use_lockfile = true uses S3's native conditional writes to create a .tflock object next to the state, available from Terraform 1.10. On older versions you need a DynamoDB table with a LockID string key and dynamodb_table = "..." in the backend instead; that option is now deprecated, so plan the upgrade.
3. Move existing state
terraform init -migrate-state
Terraform copies the local state into S3. Check the object exists, then delete the local file so nobody applies from it by accident.
4. Split state by blast radius
envs/
dev/
network/ -> key = envs/dev/network/terraform.tfstate
eks/ -> key = envs/dev/eks/terraform.tfstate
apps/
prod/
network/
eks/
One giant state means every plan touches everything and a mistake can destroy unrelated resources. Separate states per environment and per layer keep plans fast and failures small. Read outputs across layers with the terraform_remote_state data source or, better, by looking resources up with data sources.
5. Running it from CI
- Authenticate with an IAM role (OIDC from your CI system), never long-lived keys in variables.
- Run
terraform plan -out=tfplanon the pull request andterraform apply tfplanonly after approval, so what you reviewed is exactly what gets applied. - If a run dies and leaves a lock behind, use
terraform force-unlock LOCK_IDonly after you are sure no other apply is running.
With this in place, concurrent applies fail fast with a clear lock error instead of silently corrupting state.