Home / Blog / Infrastructure as Code

Terraform on AWS: Production-Ready Remote State with S3 Native Locking

Set up a hardened S3 backend for Terraform with versioning, encryption, TLS-only access and the S3 native lock file, with no DynamoDB table needed. Includes the bootstrap process, least-privilege IAM and migrating existing state.

TerraformAWSS3IaC

Every Terraform project on AWS eventually hits the same wall: the state file. Keep it on your laptop and the first teammate who runs terraform apply from their own machine will happily try to recreate everything you already built. This guide walks through the setup I use on real projects: a hardened S3 bucket for state, native S3 state locking, and a clean bootstrap process that avoids the classic chicken-and-egg problem.

What you need

  • Terraform 1.11 or newer (native S3 locking is generally available from 1.11)
  • The AWS CLI, signed in to the account you are deploying to (aws sts get-caller-identity should return your identity)
  • Permission to create an S3 bucket and, later, IAM roles

Why remote state matters

Terraform's state file maps the resources in your code to the real resources in AWS. It also often contains sensitive values such as database passwords and generated keys. A good state backend gives you four things:

  • One source of truth that the whole team and your CI pipeline share
  • Locking, so two apply runs can never modify the same state at the same time
  • History, so a bad change to the state can be rolled back
  • Encryption and access control, because state can contain secrets

S3 covers all four. The bucket stores the state, versioning gives you history, and since Terraform 1.10 the S3 backend can lock state with a lock file in the same bucket. You no longer need a separate DynamoDB table; the dynamodb_table setting is now deprecated.

Step 1: Bootstrap the state bucket

The bucket that holds your state has to exist before Terraform can use it. I keep it in a small, separate bootstrap configuration that is applied once with local state.

# bootstrap/main.tf
terraform {
  required_version = ">= 1.11"
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 6.0"
    }
  }
}

provider "aws" {
  region = "ca-central-1"
}

data "aws_caller_identity" "current" {}

resource "aws_s3_bucket" "tfstate" {
  # Bucket names are global, so include the account ID to keep it unique
  bucket = "tfstate-${data.aws_caller_identity.current.account_id}-ca-central-1"

  lifecycle {
    prevent_destroy = true
  }
}

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"
    }
    bucket_key_enabled = true
  }
}

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
}

# Refuse any request that is not made over TLS
resource "aws_s3_bucket_policy" "tfstate" {
  bucket = aws_s3_bucket.tfstate.id
  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Sid       = "DenyInsecureTransport"
      Effect    = "Deny"
      Principal = "*"
      Action    = "s3:*"
      Resource = [
        aws_s3_bucket.tfstate.arn,
        "${aws_s3_bucket.tfstate.arn}/*",
      ]
      Condition = { Bool = { "aws:SecureTransport" = "false" } }
    }]
  })
}

# Keep old state versions for 90 days, then clean them up
resource "aws_s3_bucket_lifecycle_configuration" "tfstate" {
  bucket = aws_s3_bucket.tfstate.id
  rule {
    id     = "expire-old-state-versions"
    status = "Enabled"
    filter {}
    noncurrent_version_expiration {
      noncurrent_days = 90
    }
  }
}

output "state_bucket" {
  value = aws_s3_bucket.tfstate.bucket
}

Apply it once:

cd bootstrap
terraform init
terraform apply

A few choices worth explaining:

  • prevent_destroy stops an accidental terraform destroy from wiping out every project's state.
  • Versioning is your undo button. If the state ever gets corrupted, you can restore an earlier version from the S3 console.
  • SSE-KMS with a bucket key encrypts state at rest without adding noticeable KMS cost. If you need tighter control, create a customer-managed KMS key and pass its ARN as kms_master_key_id.
  • New S3 buckets already disable ACLs and block public access by default. Declaring the public access block anyway makes that intent explicit and catches drift.
If you want the bootstrap configuration itself in remote state, add the backend block from Step 2 to it after the first apply and run terraform init -migrate-state. Terraform copies the local state into the bucket it just created.

Step 2: Point your projects at the bucket

In each real project, add an S3 backend block. Give each project and environment its own key so their state files never collide.

# infra/networking/backend.tf
terraform {
  required_version = ">= 1.11"

  backend "s3" {
    bucket       = "tfstate-123456789012-ca-central-1"
    key          = "networking/prod/terraform.tfstate"
    region       = "ca-central-1"
    encrypt      = true
    use_lockfile = true
  }

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 6.0"
    }
  }
}

use_lockfile = true is the important line. During every plan and apply, Terraform writes a networking/prod/terraform.tfstate.tflock object next to the state, using S3 conditional writes so only one run can hold the lock. If a second run starts, it waits or fails with a lock error instead of corrupting your state.

Backend blocks cannot use variables. To reuse one configuration across environments, leave out the environment-specific values and pass them at init time:

terraform init -backend-config="key=networking/dev/terraform.tfstate"

Step 3: Give people and pipelines least-privilege access

Whoever runs Terraform needs access to the state objects and the lock file, and nothing else in the bucket:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": "s3:ListBucket",
      "Resource": "arn:aws:s3:::tfstate-123456789012-ca-central-1",
      "Condition": { "StringLike": { "s3:prefix": "networking/*" } }
    },
    {
      "Effect": "Allow",
      "Action": ["s3:GetObject", "s3:PutObject", "s3:DeleteObject"],
      "Resource": [
        "arn:aws:s3:::tfstate-123456789012-ca-central-1/networking/*"
      ]
    }
  ]
}

s3:DeleteObject is needed because Terraform deletes the .tflock file when it releases the lock. Scoping the policy to a prefix like networking/* means a pipeline for one project can't read the state of another, which matters because state can contain secrets.

Step 4: Migrate an existing project

If a project already has local state, migration takes one command. Add the backend block, then run:

terraform init -migrate-state
terraform plan   # should report: No changes.

A clean plan proves the state was copied correctly. Once it is, delete the local terraform.tfstate and terraform.tfstate.backup files and make sure your .gitignore covers them:

.terraform/
*.tfstate
*.tfstate.*
*.tfplan
crash.log

Troubleshooting

  • "Error acquiring the state lock": another run is in progress, or a crashed run left a lock behind. Check your CI first. If you are certain nothing else is running, release it with terraform force-unlock LOCK_ID, using the ID from the error message.
  • **AccessDenied on the .tflock object**: the IAM policy is missing s3:PutObject or s3:DeleteObject on the lock file path.
  • Moving off DynamoDB locking: you can set use_lockfile = true alongside dynamodb_table during the transition. Terraform then takes both locks. Once every pipeline uses the new configuration, remove dynamodb_table and delete the table.

Wrapping up

With an S3 bucket that is versioned, encrypted and TLS-only, plus use_lockfile, you get a production-grade Terraform backend on AWS with no extra infrastructure to run. In the next articles I set up the same foundation on Azure, then connect both clouds to a CI/CD pipeline that doesn't need any stored cloud credentials.

Bassel Aasi
Bassel Aasi

Cloud engineer and PMP, certified as an Azure Solutions Architect Expert, AWS SysOps Administrator and Terraform Associate. I help businesses build secure, automated infrastructure on AWS and Azure.

Let's talk about your infrastructure

Tell me where you are and where you want to be. I'll reply within 24 hours with practical next steps.