EponwebPractical guides to web development and technology
DevOps & Cloud

Terraform State Files: Why Remote Backend Isn't Optional for Teams

Prevent infrastructure drift and collaboration conflicts by migrating Terraform state to a secure AWS S3 remote backend with DynamoDB locking.

Rafael Mendes
Rafael MendesSecurity Researcher & Emerging Tech Analyst8 min read
Editorial image illustrating Terraform State Files: Why Remote Backend Isn't Optional for Teams

I witnessed a critical failure at a fintech startup back in late 2024 that perfectly illustrates the danger of local state management. Two engineers, Sarah and Mike, were working on the same AWS VPC configuration from their respective laptops. Sarah added a new subnet, committed her code, and ran apply. Simultaneously, Mike was removing a deprecated security group. He committed his code and ran apply. Because both were using a local terraform.tfstate file stored on their machines, Terraform assumed each environment was static and pristine.

Sarah's apply executed first, updating the cloud resources. A minute later, Mike's apply ran. His local state file still contained the security group Sarah's changes relied on implicitly for routing. Terraform happily executed Mike's plan, tearing down resources that were active in the cloud but missing from his local map. The result was a partial outage that took three hours to untangle. The root cause was not a lack of skill, but a reliance on a single-user artifact in a multi-user environment. When you transition from a solo developer to a team, local state becomes a ticking time bomb.

The only robust solution is a remote backend that centralizes state and enforces locking. This ensures that only one person can modify the infrastructure at a time, and that everyone sees the exact same map of resources.

The Mechanics of the Race Condition

To understand why remote backends are non-negotiable, you must understand how Terraform reads state. When Terraform runs, it loads the state file into memory to compare the "desired state" (your code) against the "current state" (the file). If the state file exists locally on your machine, it is blind to changes made by others.

Consider a scenario where a team is managing a fleet of EC2 instances. Developer A modifies the instance type to t3.large and applies the change. The cloud updates, and A's local state file is updated. Developer B, unaware of A's change, decides to add a tag to these instances. B's local state file still thinks the instances are t3.medium. When B applies, Terraform might perform a refresh step, but if the configuration relies on specific attributes that have drifted, the plan output could be misleading, or worse, Terraform might attempt to "fix" the instance type back to t3.medium, causing an unintended replacement or reconfiguration.

Furthermore, without locking, there is nothing stopping Developer C from running an apply exactly when Developer B is halfway through theirs. This creates a race condition where two processes try to update the same state file simultaneously. The last writer wins, but the cloud reality is a messy hybrid of both actions.

Remote backends solve this by decoupling the state storage from the workstation. The state lives in a highly available object store, and a mechanism exists to lock that state during an operation. If you try to run terraform apply while a colleague is doing the same, the process halts immediately with a clear error message: "Error acquiring the state lock."

1. Isolate Storage with an Encrypted S3 Bucket

We will use AWS as the provider for this walkthrough, but the logic applies to Azure, GCP, or Terraform Cloud. The first step is building the vault where your state will live. This must be an S3 bucket configured with specific security controls. Do not use a public bucket. Do not reuse a bucket storing logs or frontend assets. The state file contains sensitive data in plain text by default, including resource names and potentially sensitive variables if you aren't using outputs correctly.

Create a new directory named remote-state-setup and add a file named backend.tf.

resource "aws_s3_bucket" "terraform_state" {
  bucket = "my-secure-terraform-state-2026-prod"
}

resource "aws_s3_bucket_versioning" "terraform_state_versioning" {
  bucket = aws_s3_bucket.terraform_state.id
  versioning_configuration {
    status = "Enabled"
  }
}

resource "aws_s3_bucket_server_side_encryption_configuration" "encryption" {
  bucket = aws_s3_bucket.terraform_state.id

  rule {
    apply_server_side_encryption_by_default {
      sse_algorithm = "AES256"
    }
  }
}

resource "aws_s3_bucket_public_access_block" "public_access" {
  bucket = aws_s3_bucket.terraform_state.id

  block_public_acls       = true
  block_public_policy     = true
  ignore_public_acls      = true
  restrict_public_buckets = true
}

Apply this configuration immediately. Note that we enabled versioning. This is your safety net. If a state file is corrupted or someone accidentally deletes critical resources, you can roll back the state file to a previous version. This acts as a time machine for your infrastructure topology.

Photographic detail related to Terraform State Files: Why Remote Backend Isn't Optional for Teams

2. Configure a Locking Table in DynamoDB

Storing the file remotely solves the sharing problem, but it does not solve the concurrent modification problem. We need a mutex—a mutual exclusion lock. In AWS, DynamoDB is the standard service for this. We will create a table that Terraform uses to "check out" the state file.

Add the following to your backend.tf file:

resource "aws_dynamodb_table" "terraform_locks" {
  name         = "terraform-locks"
  billing_mode = "PAY_PER_REQUEST"
  hash_key     = "LockID"

  attribute {
    name = "LockID"
    type = "S"
  }
}

Apply this configuration. The table does not need to store much data. It simply needs to be able to write a record with the LockID (usually the path to the state file) when a terraform apply or terraform plan starts, and delete that record when the command finishes or crashes. If the record exists, subsequent commands are rejected.

This locking mechanism is essential for maintaining data integrity, much like how SameSite cookies are used to enforce state integrity in web sessions. Without it, you cannot guarantee that the plan matches the reality of the infrastructure at the moment of execution.

3. Redefine the Terraform Backend Block

Now that the infrastructure exists, we must point Terraform to it. Navigate to your actual project directory (the one managing your application infrastructure). You need to modify the terraform block in your main.tf or create a dedicated backend.tf file.

You will define the backend "s3" block. Crucially, you cannot use variables inside the backend block because Terraform needs to initialize the backend before it can load variables to parse the rest of the code. The values must be hardcoded strings.

terraform {
  backend "s3" {
    bucket         = "my-secure-terraform-state-2026-prod"
    key            = "global/s3/terraform.tfstate"
    region         = "us-east-1"
    encrypt        = true
    dynamodb_table = "terraform-locks"
  }
}

The key argument acts like a file path inside the bucket. It allows you to store multiple state files in the same bucket by using different keys for different environments or components. For example, prod/network/terraform.tfstate and prod/db/terraform.tfstate can coexist.

4. Migrate Existing State without Downtime

This is the moment solo developers fear most. You already have resources running, and a local state file describing them. You need to push that state to S3 without causing Terraform to think it needs to delete and recreate everything.

Run the following command in your project directory:

terraform init

Terraform detects the change from local to remote backend. It will prompt you: Do you want to copy existing state to the new backend?

Type yes. Terraform will take your local terraform.tfstate, upload it to the specified S3 bucket, and create a lock entry in DynamoDB to verify connectivity. It will then rename your local state file to terraform.tfstate.backup (just in case).

Verify the migration by checking the S3 console. You should see the object in the specified path.

5. Validate Locking Mechanisms Under Concurrency

To ensure this setup works, simulate the conflict that previously broke your infrastructure. Open two separate terminal windows pointed at the same project directory.

In Terminal A, run:

terraform plan -out=tfplan
terraform apply tfplan

Immediately, while that is running (or while it is waiting for approval), switch to Terminal B and run:

terraform plan

Terminal B should fail almost instantly with a message resembling: Error acquiring the state lock: Error: Error acquiring the state lock: resource already locked

This is the desired behavior. The process in Terminal A holds the lock. Terminal B is politely told to wait. Once Terminal A finishes, the lock is released, and Terminal B can proceed.

For those working with complex deployment strategies, this remote state configuration allows for safer Blue-Green Deployments on AWS because the state management is no longer a single point of failure bound to a specific developer's machine.

Security Considerations for State at Rest

Security is not an afterthought; it is the foundation of this architecture. While we enabled server-side encryption on the S3 bucket, you must also consider the sensitivity of the data within the state file. Terraform state files output all attributes of your resources, including database passwords, IAM secret keys, and private certificates in plain text unless you use the sensitive flag or external secret managers.

Relying solely on S3 encryption protects against data theft at the disk level, but anyone with read access to the bucket can download the state file and read your secrets. Therefore, strict IAM policies are mandatory.

  1. Principle of Least Privilege: Only the CI/CD pipeline roles and specific DevOps leads should have GetObject and PutObject access to the state bucket.
  2. Separate State: Consider isolating highly sensitive workloads into separate state files so that only a subset of engineers can access those secrets.
  3. Audit Logging: Enable AWS CloudTrail on the S3 bucket to log every API call. You need to know who accessed the state file and when.

While reading and writing separation is a common pattern in high-scale application architecture, as detailed in discussions on CQRS, Terraform state requires a unified source of truth. However, the access to that truth should be rigorously controlled.

Moving to a remote backend transforms Terraform from a personal scripting tool into an enterprise-grade orchestration engine. It prevents the silent corruption of infrastructure maps and enforces the coordination required when human and automated actors share the same cloud environment. The resistance often comes from the perceived complexity of the setup, but as we have seen, the steps are mechanical and finite. The cost of not doing them is the unpredictable degradation of the very infrastructure you are trying to manage.

Read next