Here’s the threat nobody models: a file called terraform.tfstate on your laptop. It’s the only record of which cloud resources your team owns. It holds every value Terraform ever saw, in plaintext. It has no backups, no lock and no access log. And it’s sitting in a Downloads-adjacent folder next to final_FINAL_v3.zip.
SEVERITY: your coworker's laptop. ATTACK VECTOR: the lid closing at 5:01pm.
If that laptop dies the same week its owner goes on vacation, an entire production environment ends up with nothing tracking it: a Kubernetes cluster nobody can describe, sitting in a VPC forever like a lighthouse with no keeper, billing quietly to a credit card that belongs to someone who left in 2023. A foreign intelligence service would need a spy to find out what you’re running. So would you.
Back to Earth: the fix is moving state into a shared, locked, versioned S3 bucket, a remote backend. The it-journey.dev System Engineer quest report is where this one came up. There’s a catch that every tutorial covers in one sentence and every engineer discovers in forty minutes: the backend cannot create the bucket it lives in. Below is the bootstrap that gets around it, then the two ways the “finished” setup still loses your state, then my three mitigations, ranked.
Everything here I ran: Terraform 1.17.1 (the hashicorp/terraform image) against LocalStack in Docker, so no real AWS account was harmed or billed. The output is pasted, trimmed only of blank lines and the wall of boilerplate Terraform prints on success ([...] marks a cut). I ran each command inside a container with LocalStack’s --endpoint-url attached. The transcripts show the bare terraform/aws command you’d type against real AWS.
The part where it broke before it started
First, the lab. I pulled localstack/localstack:latest so I wouldn’t have to pay for real AWS, and it refused to start:
License activation failed! 🔑❌
Reason: No credentials were found in the environment. Please set the LOCALSTACK_AUTH_TOKEN variable to a valid auth token.
The current latest (2026.10.4 when I ran it) wants an account and an auth token. Pinning the older community tag localstack/localstack:4.9 started without one. If you’re following along, either pin that tag or sign up and set the token. Which convenience you sacrifice is up to you.
Now the actual bug. This is the backend block every tutorial hands you:
# app/backend.tf
terraform {
backend "s3" {
bucket = "acme-tfstate"
key = "app/terraform.tfstate"
region = "us-east-1"
use_lockfile = true
}
}
(My lab copy also carries endpoints, use_path_style, skip_credentials_validation, skip_requesting_account_id and dummy keys so it talks to LocalStack. Delete those for real AWS.)
Run terraform init while the bucket doesn’t exist:
$ terraform init
Initializing the backend...
Error: Failed to get existing workspaces: S3 bucket "acme-tfstate" does not exist.
The referenced S3 bucket must have been previously created. If the S3 bucket
was created within the last minute, please wait for a minute or two and try
again.
Error: operation error S3: ListObjectsV2, https response error StatusCode: 404, [...] NoSuchBucket: The specified bucket does not exist
Then the obvious fix: add an aws_s3_bucket "acme-tfstate" resource to the same config so Terraform builds its own home. That gives you the exact same error, word for word. init sets up the backend before it reads a single resource, so the bucket resource never gets a chance to run. Waiting “a minute or two”, as the error suggests, doesn’t help either. I waited.
The fix: a separate bootstrap config with local state
Create the bucket from a second, tiny Terraform config that has no backend block, so its own state stays local. Once it has run, point the real config at the bucket it made.
infra/
├── bootstrap/main.tf # makes the bucket; local state, rarely touched
└── app/backend.tf # everything else; state lives in the bucket
# bootstrap/main.tf — no backend block on purpose: this config keeps LOCAL state.
terraform {
required_providers {
aws = { source = "hashicorp/aws", version = "~> 6.0" }
}
}
provider "aws" {
region = "us-east-1"
}
resource "aws_s3_bucket" "state" {
bucket = "acme-tfstate"
lifecycle {
prevent_destroy = true
}
}
resource "aws_s3_bucket_versioning" "state" {
bucket = aws_s3_bucket.state.id
versioning_configuration {
status = "Enabled"
}
}
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
}
Bootstrap first, then the app:
$ cd bootstrap && terraform init && terraform apply -auto-approve
[...]
aws_s3_bucket.state: Creating...
aws_s3_bucket.state: Creation complete after 0s [id=acme-tfstate]
aws_s3_bucket_versioning.state: Creating...
aws_s3_bucket_public_access_block.state: Creating...
aws_s3_bucket_public_access_block.state: Creation complete after 0s [id=acme-tfstate]
aws_s3_bucket_versioning.state: Creation complete after 1s [id=acme-tfstate]
Apply complete! Resources: 3 added, 0 changed, 0 destroyed.
$ cd ../app && terraform init
Successfully configured the backend "s3"! Terraform will automatically
[...]
Terraform has been successfully initialized!
$ terraform apply -auto-approve
[...]
Apply complete! Resources: 1 added, 0 changed, 0 destroyed.
You’ll know it worked when app/ has a .terraform/ directory but no terraform.tfstate, and the state turns up in the bucket instead:
$ aws s3 ls s3://acme-tfstate --recursive
2026-10-28 22:45:30 906 app/terraform.tfstate
The bootstrap’s own terraform.tfstate is still a local file. That’s fine: it describes one bucket and two settings, and you’ll touch it about once a year. Commit it, or keep it somewhere boring. Just don’t let it be the only thing anyone knows about the bucket.
About the lock table: use_lockfile = true makes the S3 backend lock with a .tflock object in the bucket itself. That arrived in Terraform 1.10, and 1.11 deprecated the old dynamodb_table setting. If you’re stuck on an older release, add an aws_dynamodb_table with a LockID string hash key to the bootstrap config and set dynamodb_table in the backend. I didn’t run that older path for this post, so the only thing I’m vouching for here is the lockfile.
Failure one: two applies, no lock, one survivor
Remote state without a lock is a shared Google Doc where everyone types blindfolded. To prove it, I gave two configs the same state key and turned the lock off. Alice’s config adds terraform_data.alice, Bob’s adds terraform_data.bob, and each one has a local-exec that sleeps 8 seconds so the applies overlap. I started Alice, then Bob two seconds later:
==alice
terraform_data.alice: Creation complete after 8s [id=149f125b-6ab6-b37d-59c2-93ffbae5c614]
Apply complete! Resources: 1 added, 0 changed, 0 destroyed.
==bob
terraform_data.bob: Creation complete after 8s [id=0c41ea8e-9bb6-df74-3b9d-aaf81d1f2dc4]
Apply complete! Resources: 1 added, 0 changed, 0 destroyed.
$ terraform state list
terraform_data.alice
Both applies said “complete”, but only one resource made it into state. Both runs read the same empty state, and each wrote back its own version. The last write won, and Bob’s resource now exists without being tracked. Here’s what Bob’s next plan wants to do about it:
$ terraform plan # in Bob's directory
# terraform_data.bob will be created
Plan: 1 to add, 0 to change, 1 to destroy.
It wants to create a second copy of the thing he already built, and destroy the one Alice built. Each of them did nothing wrong, and together they’re one -auto-approve away from an outage.
With use_lockfile = true I ran the same race, and Bob was turned away at the door:
Error: Error acquiring the state lock
Error message: operation error S3: PutObject, https response error
[...] api error PreconditionFailed: At least one of the pre-conditions you
specified did not hold
Lock Info:
ID: 80fcfc78-b1e1-46b5-e14d-150a3c8a0760
Path: acme-tfstate/app/terraform.tfstate
Operation: OperationTypeApply
Who: root@b6e7e3f73907
Version: 1.17.1
That’s a conditional PutObject on the lock file, refused because Alice’s lock already exists. Bob waits, re-plans, and nothing gets orphaned. The error message also suggests -lock=false as a way around it. Read that as “here’s how to bring the race condition back.”
Failure two: destroying the bootstrap
The bootstrap config is a normal Terraform config, and normal Terraform configs get terraform destroyed. That might be a cleanup script, a well-meaning intern, or you at 2am thinking it’s the scratch directory. With prevent_destroy in place:
$ cd bootstrap && terraform destroy -auto-approve
Error: Instance cannot be destroyed
on main.tf line 25:
25: resource "aws_s3_bucket" "state" {
Resource aws_s3_bucket.state has lifecycle.prevent_destroy set, but the plan
calls for this resource to be destroyed.
Good. Then I removed prevent_destroy and ran it again. That’s where it gets subtle:
aws_s3_bucket_versioning.state: Destruction complete after 0s
aws_s3_bucket_public_access_block.state: Destruction complete after 0s
aws_s3_bucket.state: Destroying... [id=acme-tfstate]
Error: deleting S3 Bucket (acme-tfstate): [...] api error BucketNotEmpty: The bucket you tried to delete is not empty. You must delete all versions in the bucket.
$ aws s3api get-bucket-versioning --bucket acme-tfstate
{
"Status": "Suspended"
}
S3 refused to delete a non-empty bucket, so the “failed” destroy looks harmless. It wasn’t. It had already removed the two child resources, which means versioning is now suspended and the public-access block is gone. The bucket survived, but its seatbelt and its locks didn’t. After any aborted destroy, run terraform plan on the bootstrap, then apply to restore what it took.
Finally I added force_destroy = true, the setting people add specifically to make that BucketNotEmpty error stop:
$ terraform apply -auto-approve
Apply complete! Resources: 2 added, 1 changed, 0 destroyed.
$ terraform destroy -auto-approve
Destroy complete! Resources: 3 destroyed.
$ cd ../app && terraform plan
Error: S3 bucket "acme-tfstate" does not exist.
Three resources destroyed, and every state file in the bucket went with them, every version included, because emptying the bucket is what force_destroy does. The infrastructure those files described is still running and billing. Terraform has just forgotten all of it. We’re back at the lighthouse.
The three mitigations, ranked
- Turn the lock on:
use_lockfile = truein the backend block. It’s the cheapest of the three, and without it two simultaneous applies silently lose a resource while both report success (tested above). Never pass-lock=false. If a lock really is stale because a run died, find who holds it from theLock Infoblock and useterraform force-unlock <ID>once you’ve confirmed that run is dead. Don’t just disable locking. (I didn’t needforce-unlockin this lab, so that part is from the docs, not from a run.) - Make the bucket hard to delete:
prevent_destroy = trueon the bucket, and neverforce_destroy. I tested this above:prevent_destroystopped the destroy cold, andforce_destroyerased every state version in one command. Keep the bootstrap in its own directory, out of any wrapper script that loopsterraform destroyover folders. After a destroy aborts halfway, runterraform planthere to see what it already removed, thenterraform applyto put the versioning and access block back. - Version the bucket and block public access. State is plaintext. My test value turned up verbatim in the state object (
"value": "pretend-this-is-a-database"), and so would your database passwords. Versioning means a bad write is recoverable: after two applies,aws s3api list-object-versions --bucket acme-tfstate --prefix app/terraform.tfstatelisted four versions (the latest at 920 bytes, the one before at 906). The public-access block means the file stays off the open internet. I confirmed both withaws s3api get-bucket-versioning("Status": "Enabled") andget-public-access-block(all four flagstrue).
Not on the list, but true: whoever can read this bucket can read every secret Terraform touches. Grant bucket access like it’s the production database password, because some of what’s in there is exactly that.
When this goes wrong
initsays the bucket doesn’t exist, but you just made it. Check that the backend’sregionmatches the bucket’s region, and that the backendbucketname matches the bootstrap’s name exactly. Waiting “a minute or two” won’t fix a typo.- You already have local state and you’re adding the backend now. Run
terraform init -migrate-statein that directory and answeryesto copy the existing state up. Don’t delete the local file untilterraform state listagainst the backend shows everything. I didn’t migrate existing state in this lab; that’s the documented path, not a captured one. - You lost the bootstrap’s local state. The bucket and the app state in it are fine. You’re only missing the record of the bucket itself, which you’d re-adopt with an
importblock. I didn’t run that path here, so treat it as a pointer to read about, not a tested step.