Kokil Thapa - Professional Web Developer in Nepal
Freelancer Web Developer in Nepal with 15+ Years of Experience

Kokil Thapa is an experienced full-stack web developer focused on building fast, secure, and scalable web applications. He helps businesses and individuals create SEO-friendly, user-focused digital platforms designed for long-term growth.

Terraform Remote State on S3 with Locking

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.

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.

Remote State ArchitectureEngineerterraform applyCI PipelineGitHub ActionsTerraform CLIinit, plan, applyS3 Bucketversioned stateDynamoDBLockID tableOne state file per workspace pathLock acquired before write, released after apply
Terraform Remote State on S3 with Locking: shared state storage plus a lock table serialises concurrent applies.

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.

Apply Lock Sequence1. Read S32. LockDynamoDB3. ApplyAWS API4. Write S3new version5. UnlockIf lock exists, apply waits or failsStale locks need manual force-unlockS3 versioning keeps prior state copiesNever disable locking in shared envs
Lock sequence during terraform apply: read state, acquire lock, change infrastructure, write state, release lock.

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 methodProsConsBest for
DynamoDB tableBattle-tested, visible lock IDs, works on Terraform 0.9+Extra table to manageTeams on established AWS setups
S3 use_lockfileNo DynamoDB cost, fewer resourcesNewer, requires Terraform 1.5+ and recent AWS providerGreenfield stacks in 2026
No lockSimplest configRace conditions under parallel applySolo 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.

Local vs Remote StateLocal stateSingle laptop copyNo lockingMerge conflicts in GitNo versioningSecrets in state fileCI cannot shareS3 + lockCentral durable storeSerialised appliesS3 version historySSE encryptionIAM-controlled accessTeam and CI readyMigrate once, benefit on every apply
Local Terraform state versus Terraform Remote State on S3 with Locking for team and CI workflows.

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.

  1. Copy terraform.tfstate to a timestamped backup path outside the repo.
  2. Add the backend "s3" block with correct bucket, key, and dynamodb_table.
  3. Run terraform init -migrate-state. Terraform prompts to copy local state to S3—confirm yes.
  4. Run terraform plan. Expect zero changes if the migration was clean.
  5. Delete the local terraform.tfstate and backup files from workstations after CI confirms remote access.
  6. Add *.tfstate* to .gitignore if 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.

Lock Error Decision TreeLock error?Active apply?WaitStale CI job?Cancel jobProcess dead?force-unlockRun terraform planConfirm zero surprise changesStill broken? Restore S3 version
Decision flow for resolving Terraform Remote State on S3 with Locking errors without corrupting state.

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_version and required_providers.
  • Run terraform plan on 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 key paths.
  • 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

Shared Terraform state stored in a versioned, encrypted Amazon S3 bucket, with concurrent applies serialized by a DynamoDB lock table or S3 native locking via use_lockfile on Terraform 1.5+.

Local terraform.tfstate fails under collaboration, CI/CD, and recovery. Two engineers or parallel pipeline jobs reading the same state version can both apply, and whichever write lands last wins—the other changes disappear from state even if AWS resources still exist. That drift is painful to untangle. S3 gives durable, shared storage with versioning as an undo button. Locking serialises applies so state cannot be corrupted mid-run. I've seen teams discover missing locks during their first parallel CI job, not during planning.

Usually under Rs 500 (~USD 4) monthly for small-team apply frequency on a pay-per-request table—far cheaper than recovering a corrupted VPC state file.

Provision both once in a separate bootstrap or foundations stack, never storing the bucket's own state inside itself on first creation. The S3 bucket needs encryption (SSE-S3 or SSE-KMS), versioning enabled, and all public access blocked. Create a DynamoDB table named for your org with billing_mode PAY_PER_REQUEST and a string partition key LockID. One table can serve many state files because each lock key is unique per backend path. Monthly DynamoDB cost at small-team frequency is typically under Rs 500 (~USD 4).

Add a backend s3 block to your root module with literal bucket, key, region, encrypt = true, and dynamodb_table values—backend config cannot use variables or locals. Use folder-like key prefixes per project and environment, for example prod/app/api/terraform.tfstate, and never reuse the same key across unrelated stacks. Many teams commit a backend.hcl template per environment and run terraform init -backend-config=backend.hcl in CI. Pin required_version to at least 1.5.0 if you plan to use S3 native locking later.

DynamoDB is the battle-tested default: Terraform writes a lock record before mutating state and deletes it on completion, works on Terraform 0.9+, and lock IDs are visible in the table—but you manage an extra resource. S3 native locking via use_lockfile = true uses conditional writes, needs Terraform 1.5+ and a recent AWS provider, and removes DynamoDB cost. Pick one mechanism per stack and stay consistent across environments. Mixing lock systems invites confusion during troubleshooting and onboarding.

Scope a dedicated IAM policy to the state bucket ARN and lock table ARN—least privilege beats admin credentials on CI runners. Apply roles need s3:ListBucket on the bucket with prefix conditions, s3:GetObject, s3:PutObject, and s3:DeleteObject on bucket objects, plus dynamodb:GetItem, dynamodb:PutItem, and dynamodb:DeleteItem on the lock table. Plan-only human roles can get read-only S3 without DynamoDB write. On EC2-hosted GitLab runners I use alongside Deployer, the instance profile carries this policy. Scan policies with Checkov before merge—overly broad s3: on all buckets is a recurring audit finding.

Treat migration as a production cutover: announce a freeze, copy terraform.tfstate to a timestamped backup outside the repo, add the backend s3 block with correct bucket, key, and dynamodb_table, then run terraform init -migrate-state and confirm the copy prompt. Run terraform plan and expect zero changes on a clean migration. Delete local state files from workstations after CI confirms remote access, and add .tfstate* to .gitignore—state must never return to Git. If plan shows unexpected destroys, stop, download the latest S3 version, compare JSON, and restore the prior version if needed.

That error means locking is working—another apply holds the lock or a crashed run left a stale record. If a teammate is applying, wait. If CI is stuck, cancel the job first and confirm the lock row is stale before using terraform force-unlock with the displayed lock ID. Force-unlock deletes the DynamoDB record without verifying the holder is dead; if the original apply still runs, two writers can corrupt state. After clearing a stale CI lock, run plan immediately to reconcile drift. Capture TF_LOG in failed jobs so the lock holder identity is obvious.

Yes. Use distinct key paths per stack, such as 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.

Always. Set encrypt = true on the S3 backend for SSE-S3, or use a KMS key when compliance requires customer-managed keys and audit trails.

Terraform loses track of managed resources—the state map linking resource addresses to real cloud IDs is gone. The next plan may try to recreate live infrastructure or fail to destroy orphaned resources. Recovery requires restoring the bucket from S3 backup, cross-region replication, or an off-site copy. That is why versioning is non-negotiable and why critical stacks benefit from cross-region replication. Prevention beats recovery: block public access, restrict IAM tightly, and treat the bucket like a secrets store because state contains sensitive values even when outputs are marked sensitive.

The state bucket cannot store its own state inside itself on first creation. Provision backend resources once in a separate bootstrap stack or dedicated foundations repository using one-time local state or a minimal bootstrap apply. After the bucket and DynamoDB lock table exist, point every root module backend at unique key paths and migrate other stacks with terraform init -migrate-state. Never commit terraform.tfstate to Git. This mirrors how I treat production deploy hygiene: bootstrap foundations separately, then wire application and infrastructure stacks to shared, hardened resources.

No. Backend configuration cannot reference variables, locals, or module outputs—it must use literal values or values supplied via a -backend-config file at init time. That restriction is why teams commit a backend.hcl template without secrets and inject bucket names through CI secrets for public repositories. Validate JSON backend configs with a formatter before pasting into CI secrets; a trailing comma in a copied config has caused more outages than DynamoDB throttling. For stacks already on remote backends elsewhere, terraform init -migrate-state -backend-config moves state between buckets or keys without redefining resources.

Enable S3 versioning and consider a lifecycle rule expiring 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_version and required_providers. Run terraform plan on every pull request and apply only from protected branches. Never hand-edit state in the S3 console unless performing documented recovery; prefer terraform state mv, terraform state rm, and terraform import. Put shared VPC code in modules but give each deployment its own state key to keep boundaries clean.

Share this article

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.

Quick Contact Options
Choose how you want to connect me: