Remote State on S3 With use_lockfile
Part I closed with a terraform.tfstate file sitting right in the working directory on your machine. That's fine when learning alone, but it breaks the moment a second person shows up, or when your machine dies. Part II opens by getting state off your personal machine and onto a shared, safe place: S3, with locking so two people don't overwrite each other.
The problem with local state
Local state has three fatal flaws. It can't be shared: your colleague doesn't have the file, so they can't apply the same infrastructure. It isn't safe: the file holds sensitive values in plaintext (Article 4), sits right there on a laptop, and if it ever gets committed to git it leaks. And it can't prevent concurrent writes: two people apply at the same time, the two state versions overwrite each other, infrastructure and state drift apart, and fixing it is painful.
Remote state solves all three. State lives in a shared backend; everyone reads the same copy; the backend encrypts and controls access; and a locking mechanism guarantees only one person can write at a time.
What a backend is, and the chicken-and-egg problem
A backend is where Terraform stores state. The default is local (a file on disk). We'll switch to the s3 backend. But there's a snag: to use S3 as a backend, the bucket has to exist first, yet we want to use Terraform to create the bucket — chicken and egg.
The common way out is to split it in two. A small bootstrap configuration, using local state, just to create the bucket that holds state. It runs once, rarely changes, and its own local state doesn't matter much because the contents are easy to rebuild. Then every other configuration points its backend at that bucket.
Bootstrap: the bucket that holds state
resource "aws_s3_bucket" "state" {
bucket_prefix = "tf-series-state-"
force_destroy = true # only to clean up the lab; do NOT enable in production
}
resource "aws_s3_bucket_versioning" "state" {
bucket = aws_s3_bucket.state.id
versioning_configuration {
status = "Enabled"
}
}
resource "aws_s3_bucket_server_side_encryption_configuration" "state" {
bucket = aws_s3_bucket.state.id
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "AES256"
}
}
}
resource "aws_s3_bucket_public_access_block" "state" {
bucket = aws_s3_bucket.state.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
Each supporting resource here has its own reason, and they all use the "separate resource" technique seen in Article 5 (versioning is a separate resource referencing the bucket). Versioning keeps every old state version — if an apply corrupts state, you can restore the previous one. Server-side encryption encrypts state at rest, since it holds secrets. Public access block blocks any accidental configuration that would open the bucket to the Internet. These three are the minimum for a bucket holding state.
Apply the bootstrap with local state:
$ terraform apply -auto-approve
aws_s3_bucket.state: Creation complete after 3s [id=tf-series-state-20260525030320917300000001]
...
Apply complete! Resources: 4 added, 0 changed, 0 destroyed.
Configuring the s3 backend
Now in a different directory (the "app" configuration), declare a backend pointing at the bucket you just created:
terraform {
required_version = ">= 1.10"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 6.0"
}
}
backend "s3" {
bucket = "tf-series-state-20260525030320917300000001"
key = "app/terraform.tfstate"
region = "ap-southeast-1"
encrypt = true
use_lockfile = true
}
}
The backend needs three required things: bucket, key (the path of the state file within the bucket), and region. encrypt = true requires encryption in transit and at rest. One thing to remember: the backend block can't use variables — it's read very early, before Terraform processes variables, so every value must be static. That's why we hardcode the bucket name (Part VII will show how to pull this value out into a file via -backend-config).
The most notable line is use_lockfile = true. This is the point where many old docs online will steer you wrong. Previously, to lock state you had to stand up an extra DynamoDB table and declare dynamodb_table. That approach is now deprecated: the current docs state plainly that "DynamoDB-based locking is deprecated and will be removed in a future minor release." Instead, use_lockfile enables native locking right on S3. You no longer need to stand up DynamoDB, and this article doesn't.
init: switching to the backend
$ terraform init
Initializing the backend...
Successfully configured the backend "s3"! Terraform will automatically
use this backend unless the backend configuration changes.
...
Terraform has been successfully initialized!
init reads the backend block and configures S3 as the place to hold state. (If the directory already had local state, init will ask whether you want to migrate the local state up to the new backend — answer yes and it copies it up.) Apply a test resource:
$ terraform apply -auto-approve
aws_s3_bucket.app: Creation complete after 3s [id=tf-series-bai6-app-2026...]
Apply complete! Resources: 1 added, 0 changed, 0 destroyed.
Confirm state is no longer local but lives on S3:
$ ls terraform.tfstate
ls: terraform.tfstate: No such file or directory
$ aws s3 ls s3://tf-series-state-20260525030320917300000001/app/
2026-05-25 10:04:51 3218 terraform.tfstate
State now lives on S3 at exactly the key declared. Anyone with access to the bucket uses this same copy.
💰 Cost
State is just a few KB. Storing it on S3 is nearly free, and use_lockfile costs nothing extra (just a small object created and deleted around each operation). This is another difference from DynamoDB: you drop a service you'd otherwise pay for and have to manage.
How use_lockfile works
The mechanism is worth dissecting because it explains why locking works without a separate service. When a write operation begins (plan/apply/destroy), Terraform tries to create a lock object named <key>.tflock (here, app/terraform.tfstate.tflock) using a conditional write that says "only create if the object doesn't already exist." S3 supports conditional writes at the API level. If nobody holds the lock, the object is created, Terraform holds it through the operation, then deletes it when done. If someone already holds it, the conditional write fails, and Terraform knows the state is locked.
Create a conflict to see it firsthand: let one apply hold the lock (waiting for confirmation), and meanwhile run another command against the same state:
$ terraform plan
Error: Error acquiring the state lock
Error message: operation error S3: PutObject, https response error
StatusCode: 412, ... api error PreconditionFailed: At least one of the
pre-conditions you specified did not hold
Lock Info:
ID: ce1c8cbb-5211-21f5-7878-9b867e6246cb
Path: tf-series-state-2026.../app/terraform.tfstate
Operation: OperationTypeApply
Who: ops@workstation.local
Version: 1.15.4
Created: 2026-05-25 03:05:04 UTC
StatusCode: 412 PreconditionFailed is exactly the conditional write being rejected: the .tflock object already exists, so S3 won't let you overwrite it. This isn't an error, it's the lock doing its job. The Lock Info block tells you who holds the lock, what operation, and since when — enough to go ask your colleague instead of clobbering state. When the other process finishes, it deletes .tflock, and your command can run.
If a lock gets stuck (a process died mid-run without deleting the lock), there's terraform force-unlock <ID> to remove it manually — but only use it when you're certain nobody else is running, because removing the wrong lock opens the door to overwrites.
🧹 Cleanup
Clean up in reverse order of creation: destroy "app" first (its state lives in the bootstrap bucket), then tear down the bootstrap.
$ cd app && terraform destroy -auto-approve
Destroy complete! Resources: 1 destroyed.
$ cd ../0-bootstrap
$ aws s3 rm s3://tf-series-state-2026... --recursive # the bucket has versioning, clear objects first
$ terraform destroy -auto-approve
Destroy complete! Resources: 4 destroyed.
Wrap-up
Local state can't be shared, isn't safe, and doesn't prevent concurrent writes. Moving it to S3 solves all three: bootstrap a bucket with versioning, encryption, and public-access blocking using a one-time local configuration, then point the s3 backend of every other configuration at it. use_lockfile = true enables native locking on S3 via a conditional write that creates a .tflock object; conflicts surface as a 412 PreconditionFailed error along with who holds the lock. This is the current approach — DynamoDB for locking is deprecated, no longer needed.
State is now safe, but working with state day to day needs more operations: renaming a resource in state, removing a resource from management without deleting it, and bringing existing infrastructure (built by hand earlier) into Terraform. Article 7 walks through those state operations, with the highlight being the import block — a way to declare that Terraform should take over an existing resource without typing the manual import command.