Terraform Basics #9 Remote State and Collaboration: S3 Backend, Native Locking, and Protecting Sensitive Data

6 min read

The conclusion of #5 was that a local disk is the wrong home for state. In this final part of the basics series, we settle that homework: move state to S3 and add locking, turning a Terraform setup used by one person into one a team can share. Finish this post and you are ready to build out full infrastructure in the practice series.

The three problems with local state #

When terraform.tfstate exists only on your laptop, a team runs into three problems.

  1. It cannot be shared: when a teammate pulls the same code and runs apply, Terraform — working from their empty state — tries to create every resource all over again. Code without its state is only half the picture.
  2. Concurrent runs cannot be prevented: if two people apply against the same infrastructure at the same time, each calculation proceeds unaware of the other, and state drifts apart or resources get duplicated.
  3. It is easy to lose: a lost laptop, a failed disk, an accidental delete. As we saw in #5, losing state leaves you with the manual work of importing everything.

The thing that solves all three at once is a remote backend. Put state in storage the team shares, and make Terraform read and write it there on every run. In an AWS environment, the standard choice is S3.

Preparing the state bucket #

First we need a bucket to hold the state. To avoid the chicken-and-egg problem (if Terraform creates the bucket that holds state, where does that state go?), this one bucket is usually created via the console or the CLI.

Create bucket
aws s3api create-bucket --bucket my-team-tfstate \
  --region ap-northeast-2 \
  --create-bucket-configuration LocationConstraint=ap-northeast-2
aws s3api put-bucket-versioning --bucket my-team-tfstate \
  --versioning-configuration Status=Enabled

Versioning is not optional — it is mandatory. If state gets corrupted or overwritten by a bad operation, versioning lets you roll back to a previous version. It is the last line of insurance against state accidents. S3 default encryption (SSE-S3) is applied automatically to buckets these days, but depending on your organization’s policy you can step up to KMS key encryption.

The backend block and migration #

The backend is declared inside the terraform block.

main.tf
terraform {
  required_version = ">= 1.15.0"

  backend "s3" {
    bucket       = "my-team-tfstate"
    key          = "myapp/prod/terraform.tfstate"
    region       = "ap-northeast-2"
    use_lockfile = true
  }
}

key is the path within the bucket where this project’s state will live. It is common to keep the state of multiple projects and environments in one bucket, separated by key, so it pays to settle on a convention like project/environment/terraform.tfstate from the start. Add the block, run terraform init, and Terraform discovers the existing local state and asks.

Run result
Initializing the backend...
Do you want to copy existing state to the new backend?
  Enter a value: yes

Answer yes and the local state is copied to S3, and every plan and apply from then on reads and writes the state in S3. The local terraform.tfstate is no longer used, so once you have verified the migration you can delete it. From this point, a teammate only needs to run init on the same code to see the same state — problems 1 and 3 are solved.

use_lockfile: locking out concurrent runs #

What remains is concurrent execution. The use_lockfile = true in the configuration above is the answer. When an apply starts, Terraform creates a .tflock object next to the state file to mark the lock, and deletes it when the run finishes. While the lock is held, anyone else who runs Terraform is rejected like this.

Example output
Error: Error acquiring the state lock
Lock Info:
  Who:       bob@laptop
  Created:   2026-08-30 09:12:44 UTC

This S3 native locking went GA in Terraform 1.11. Before that, the standard approach was to create a dedicated DynamoDB table for locking and wire it up with the dynamodb_table argument, and search results are still full of that setup. The DynamoDB approach is deprecated, and for new configurations use_lockfile alone is enough. The only condition to check is that everyone on the team runs Terraform 1.11 or later.

You should also know the principle for handling a lock error. Most of the time it is a normal signal that a teammate’s apply is in progress, so waiting is the correct answer. Occasionally an apply gets killed and leaves a stale lock behind; only then do you release it with terraform force-unlock <LOCK_ID>. It is a last resort you use after checking Who in the Lock Info and confirming nobody is actually running — not a command to fire off reflexively the moment the error appears.

Access control for the state bucket #

The moment state lands in team-shared storage, the plaintext sensitive data problem we saw in #5 becomes a team problem. Anyone who can read the state bucket can also read the database passwords inside it. So the state bucket gets the principle of least access. Beyond blocking public access, narrow it down with an IAM policy that allows read and write on that key path only to the role that runs Terraform. Designs that keep sensitive data out of state entirely (referencing Secrets Manager, and so on) will be covered in the secrets part of the practice series.

Closing out the basics series #

Summing up the nine parts: we wrote infrastructure as declarations (#1), expressed it in HCL (#2), extracted values into variables (#3), read in existing resources (#4), understood state as the center of it all (#5), handled dependencies and repetition (#6), controlled resource lifecycles (#7), structured code with modules (#8), and today built the foundation for team collaboration. The syntax and the principles are in place; what remains is the experience of standing up real infrastructure.

Here is what we covered in this post.

  • Local state has three problems — no sharing, concurrent-run conflicts, and risk of loss — and a remote backend solves them all at once
  • Create the state bucket outside Terraform and always enable versioning. Restoring a previous version is the last insurance when accidents happen
  • After adding the backend "s3" block, init guides the migration of existing state. Settle on a key path convention from the start
  • use_lockfile = true is the current standard for locking (GA in 1.11). The DynamoDB table approach is deprecated, and force-unlock is a last resort used only after confirming nobody is running
  • The state bucket holds plaintext sensitive data, so minimize access with IAM

Next up is the Terraform in Practice series. From the VPC network to the domain and HTTPS, we will build one web service’s infrastructure in code from start to finish across ten parts.

X