
September 10, 2026
13 min read
By Kokil Thapa | Last reviewed: September 2026
Terraform Remote State on S3 with Locking is the baseline pattern for any team running Terraform beyond a solo laptop. Local terraform.tfstate files break the moment two engineers run apply at once, and they vanish when a disk fails. Storing state in Amazon S3 gives you durability, versioning, and a shared location. Pairing S3 with a lock—traditionally DynamoDB, or native S3 conditional writes in newer Terraform releases—stops concurrent runs from corrupting the same state file. If you already manage production servers with Linux system administration workflows, this setup fits cleanly into the same AWS account hygiene you use for backups and deploy pipelines.
LockID, and a backend block pointing at both. Run terraform init -migrate-state to move local state. Locks release automatically when apply finishes.Why do you need Terraform Remote State on S3 with Locking?
State is Terraform's memory of what it created. It maps resource addresses to real cloud IDs. Lose it or corrupt it, and the next plan may try to recreate live infrastructure or fail to destroy orphaned resources.
Local state fails three predictable tests. First, collaboration: two people cannot safely share one file without Git merge pain. Second, CI/CD: your pipeline runner needs a remote location, not a checked-in secret-filled JSON blob. Third, recovery: a laptop theft or accidental rm should not erase your infrastructure map.
Remote state on S3 solves storage. Locking solves concurrency. Without a lock, Engineer A and Pipeline B can both read version N, apply different changes, and whichever writes last wins—the other changes are lost from state even if resources still exist in AWS. That drift is expensive to untangle.
For background on why state design matters long term, see the companion guide on Terraform state management and remote backends. Teams that skip locking often discover the problem during their first parallel CI job—not during planning.
How do you create the S3 bucket and DynamoDB lock table?
Provision the backend resources once, ideally in a separate bootstrap stack or a dedicated "foundations" repository. Never store the state bucket's own state inside itself on first creation—that chicken-and-egg problem needs a one-time local state or a minimal bootstrap apply.
S3 bucket requirements
Your state bucket needs encryption, versioning, and public access blocked. Versioning is non-negotiable: it is your undo button when a bad write lands. Enable server-side encryption with SSE-S3 or SSE-KMS. For most teams SSE-S3 is enough; regulated workloads may require a CMK.
# bootstrap/s3.tf — one-time foundation stack
resource "aws_s3_bucket" "terraform_state" {
bucket = "myorg-terraform-state-prod"
}
resource "aws_s3_bucket_versioning" "terraform_state" {
bucket = aws_s3_bucket.terraform_state.id
versioning_configuration { status = "Enabled" }
}
resource "aws_s3_bucket_server_side_encryption_configuration" "terraform_state" {
bucket = aws_s3_bucket.terraform_state.id
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "AES256"
}
}
}
resource "aws_s3_bucket_public_access_block" "terraform_state" {
bucket = aws_s3_bucket.terraform_state.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
} DynamoDB table for locking
Create a pay-per-request DynamoDB table with a string partition key named LockID. Terraform writes a lock record before mutating state and deletes it when the operation completes. The table can be shared across many state files—each lock key is unique per backend path.
# bootstrap/dynamodb.tf
resource "aws_dynamodb_table" "terraform_locks" {
name = "myorg-terraform-locks"
billing_mode = "PAY_PER_REQUEST"
hash_key = "LockID"
attribute {
name = "LockID"
type = "S"
}
} Monthly cost for this table is usually under Rs 500 (~USD 4) at small-team apply frequency. That is cheaper than one hour recovering from a corrupted VPC state file.
How do you configure the S3 backend in Terraform?
Add a backend block to your root module. The backend configuration cannot use variables or locals—it must be literal values or supplied via a -backend-config file. Many teams commit a backend.hcl per environment and pass it at init time.
# versions.tf
terraform {
required_version = ">= 1.5.0"
backend "s3" {
bucket = "myorg-terraform-state-prod"
key = "networking/vpc/terraform.tfstate"
region = "ap-southeast-1"
encrypt = true
dynamodb_table = "myorg-terraform-locks"
}
} The key path is how you separate stacks. Use a folder-like prefix per project and environment, for example prod/app/api/terraform.tfstate. Do not reuse the same key across unrelated stacks. Workspaces append a env:/ prefix automatically when enabled—document that behaviour before mixing workspaces and custom keys. Read Terraform workspaces and environments before combining both patterns.
Partial backend config for CI
Keep bucket names out of public repos when possible. Commit a template and inject secrets in CI:
# backend.hcl (not committed with secrets in public repos)
bucket = "myorg-terraform-state-prod"
key = "prod/ec2/web/terraform.tfstate"
region = "ap-southeast-1"
encrypt = true
dynamodb_table = "myorg-terraform-locks" terraform init -backend-config=backend.hcl Official reference: the HashiCorp S3 backend documentation lists every supported argument, including role assumption and KMS keys.
S3 native locking (Terraform 1.5+)
HashiCorp added optional S3-native locking via conditional writes. You can set use_lockfile = true and omit DynamoDB on newer provider versions. DynamoDB remains the widely documented default and works across older Terraform versions. Pick one mechanism per stack and stay consistent—mixing lock systems across environments invites confusion.
| Lock method | Pros | Cons | Best for |
|---|---|---|---|
| DynamoDB table | Battle-tested, visible lock IDs, works on Terraform 0.9+ | Extra table to manage | Teams on established AWS setups |
S3 use_lockfile | No DynamoDB cost, fewer resources | Newer, requires Terraform 1.5+ and recent AWS provider | Greenfield stacks in 2026 |
| No lock | Simplest config | Race conditions under parallel apply | Solo local experiments only |
What IAM permissions does Terraform need for S3 state and DynamoDB locks?
Least privilege beats admin credentials on CI runners. Create a dedicated IAM policy attached to the role your pipeline or engineers assume. Scope the S3 bucket ARN and DynamoDB table ARN tightly.
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["s3:ListBucket"],
"Resource": "arn:aws:s3:::myorg-terraform-state-prod",
"Condition": {
"StringLike": { "s3:prefix": ["networking/*", "prod/*"] }
}
},
{
"Effect": "Allow",
"Action": [
"s3:GetObject",
"s3:PutObject",
"s3:DeleteObject"
],
"Resource": "arn:aws:s3:::myorg-terraform-state-prod/*"
},
{
"Effect": "Allow",
"Action": [
"dynamodb:GetItem",
"dynamodb:PutItem",
"dynamodb:DeleteItem"
],
"Resource": "arn:aws:dynamodb:ap-southeast-1:123456789012:table/myorg-terraform-locks"
}
]
} Human admins who only plan can get read-only S3 plus no DynamoDB write if you split roles. Apply roles need full lock permissions. Scan your policies with Checkov for Terraform misconfigurations before merging IAM changes—overly broad s3:* on * buckets is a recurring audit finding.
On EC2-hosted GitLab runners—the pattern I use alongside Deployer for application releases—the instance profile should carry this policy. The same runner often executes Terraform CI/CD pipelines after a merge to main.
How do you migrate existing local state to S3 safely?
Migration is a one-time cutover per stack. Treat it like a production deploy: announce a freeze window, back up the local file, and verify the remote object after init.
- Copy
terraform.tfstateto a timestamped backup path outside the repo. - Add the
backend "s3"block with correctbucket,key, anddynamodb_table. - Run
terraform init -migrate-state. Terraform prompts to copy local state to S3—confirm yes. - Run
terraform plan. Expect zero changes if the migration was clean. - Delete the local
terraform.tfstateand backup files from workstations after CI confirms remote access. - Add
*.tfstate*to.gitignoreif not already present. State must never return to Git.
If plan shows unexpected destroys, stop. Download the latest S3 version, compare JSON structure, and restore the prior version if needed. AWS documents object version restoration in the S3 versioning guide.
For stacks already using remote backends elsewhere, use terraform init -migrate-state -backend-config=... to move between buckets or keys. The same lock rules apply—do not migrate during an active apply.
Broader migration patterns—including multi-cloud moves—are covered in how to manage Terraform state safely and multi-cloud state with Terraform.
How do you troubleshoot Terraform state lock errors?
The most common error is Error acquiring the state lock with a lock ID and metadata about who holds it. That is working as designed—someone else's apply is running, or a previous run crashed before release.
When to wait versus force-unlock
If a teammate is applying, wait. If CI shows a stuck job, cancel the job first and confirm the lock row is stale. Only then use force-unlock.
terraform force-unlock 9f3a1b2c-d4e5-6789-abcd-ef0123456789 Force-unlock deletes the DynamoDB lock record without verifying the holder. If the original apply is still running, two writers can corrupt state. Always confirm the process is dead.
Stale locks from crashed CI
CI runners killed mid-apply often leave locks behind. Fix the pipeline timeout, then force-unlock. Run plan immediately after to reconcile drift. On projects where I maintain shared EC2 infrastructure, I set explicit TF_LOG capture in failed jobs so the lock holder identity is obvious in logs.
State drift and manual S3 edits
Never hand-edit state in the S3 console unless you are performing a documented recovery. Prefer terraform state mv, terraform state rm, and terraform import. If you must restore an older S3 version, pause all applies, restore, then run plan across every dependent stack.
Validate JSON backend configs with a JSON formatter before pasting into CI secrets— a trailing comma in a copied IAM policy has caused more outages than DynamoDB throttling ever has.
Operational habits that prevent pain
- Enable S3 versioning and a lifecycle rule to expire noncurrent versions after 90 days if storage cost matters.
- Replicate the state bucket cross-region for disaster recovery on critical stacks.
- Use separate state keys per environment—never share prod and staging keys.
- Pin Terraform and provider versions in
required_versionandrequired_providers. - Run
terraform planon every pull request; apply only from protected branches.
These habits mirror how I treat application deploys: immutable artefacts, protected production paths, and automated checks before merge. See infrastructure as code with Terraform for the full workflow picture.
S3 state complements other AWS patterns on the same account. Teams hosting Laravel on EC2 often use S3 for uploads and Terraform for the surrounding network—see hosting Laravel on AWS EC2 with S3 and S3 file storage for Laravel. Off-site backup strategies overlap too: automating off-site backups to S3 uses similar bucket hardening.
Module reuse keeps state boundaries clean. Put shared VPC code in a module, but give each deployment its own state key. Read Terraform modules for reusable infrastructure before splitting monolithic state files.
If you evaluate forks and alternatives, Terraform vs Pulumi vs OpenTofu compares state models. OpenTofu supports the same S3 backend syntax in most cases.
For a delivered example of AWS-backed production infrastructure, the Adventure Third Pole Trek booking platform runs on Laravel with managed cloud resources—state discipline matters equally for application and infra repos.
Ongoing ops after setup map to support and maintenance services when your team needs help hardening CI or recovery runbooks.
Key Takeaways
- Store Terraform state in a versioned, encrypted, private S3 bucket—never in Git or local disks for team projects.
- Enable DynamoDB locking (or S3 native
use_lockfile) so parallel applies cannot overwrite each other. - Bootstrap bucket and lock table once with a separate stack, then point every root module backend at unique
keypaths. - Grant CI and humans least-privilege IAM scoped to the state bucket prefix and lock table only.
- Migrate with
terraform init -migrate-state, verify a clean plan, then delete local state copies. - Treat force-unlock as a break-glass action—confirm the holding process is dead before running it.
People Also Ask
Can multiple Terraform projects share one S3 bucket?
Yes. Use distinct key paths per stack, for example prod/network/terraform.tfstate and prod/app/terraform.tfstate. One DynamoDB lock table serves all keys because each lock ID includes the bucket and key path. Apply bucket IAM prefix conditions so projects cannot read each other's state.
Should Terraform state files be encrypted?
Always. Set encrypt = true on the S3 backend for SSE-S3. Use a KMS key when your compliance regime requires customer-managed keys and audit trails. State contains sensitive values even when outputs are marked sensitive—treat the bucket like a secrets store.
What happens if someone deletes the state bucket?
Terraform loses track of managed resources. Recovery requires restoring S3 from backup or replication, or manually importing every resource into fresh state. Enable versioning, MFA delete for production buckets, and cross-region replication on critical accounts. Prevention beats import marathons.
Is DynamoDB still required for S3 backend locking in 2026?
Not strictly. Terraform 1.5+ supports S3-native locking with use_lockfile = true. Many teams still use DynamoDB because it is well documented, visible in the AWS console, and compatible with older Terraform versions. Either option is valid—do not run both on the same stack.
Build shared Terraform state the right way from day one
Terraform Remote State on S3 with Locking is not optional once more than one person touches infrastructure. The setup takes an afternoon: bootstrap bucket and lock table, wire the backend block, migrate, and lock down IAM. Every apply after that inherits versioning, encryption, and serialised writes without merge conflicts in JSON.
Start with one non-production stack, prove plan parity after migration, then roll the pattern across environments. Need help wiring Terraform into your AWS or GitLab CI setup? Contact us or explore the home page for related S3 architecture guides and DevOps resources.
Frequently Asked Questions
0 Comments
Leave a comment
Your email is not published. Comments appear once they have been read. Sign in to have your details filled in.

