The first terraform apply of this track creates almost nothing on AWS on purpose:
one S3 bucket, made by hand, that will hold the state of everything that follows — the VPC in
cloud-04, the ECR repository, the Fargate service. State is the file Terraform uses to remember what it
built; it is the one file two people must never write at the same time, and the one file you want a
history of. So the bucket gets versioning, encryption and public access blocked,
and Terraform gets use_lockfile = true — the S3-native lock that, since Terraform 1.11,
replaces the DynamoDB table every older tutorial tells you to create. You will be looking at one console
page (S3) and two terminals side by side. Everything on this page reads ~$0.00/mo; the scene
below is about which box would cost money, and which one you no longer need.
If you have written JPA, you know the version column: an update that carries a stale
version number is refused instead of silently overwriting a colleague's write. use_lockfile
is that idea applied to one S3 object — the lock is a create-only write that succeeds when no
.tflock exists yet, so the second apply is refused instead of clobbering the
first.
One box per thing, the AWS name on the first line and the Terraform type on the second. Solid boxes exist in the state you picked; dashed ones do not. Step through where the state file lives — your disk, then S3, then S3 with the lock — and the readout recomputes an approximate monthly line (verify on the pricing page today). The fourth state shows the box every pre-1.10 tutorial adds and this page does not: a DynamoDB table, which wears the red ring not because it bills much on-demand, but because it is one more thing to remember to delete.
terraform apply before that e-mail; it is the hard rule of the whole
cloud track.aws sts get-caller-identity works via SSO (AWS_PROFILE=lbv-admin from cloud-02)
and the Arn names an assumed role, not :root. If it prints an expired-token
error, aws sso login first.ap-south-1 (Mumbai) is the default in that profile — the bucket, the backend block
and the provider on this page all say so explicitly anyway.arm64 build)..tf files, say ~/lbv-cloud/03-backend/.
It becomes the root module every later cloud item adds to.0. Install Terraform from the HashiCorp tap and pin it. The tap installs the current release
(1.16.x at the time of writing); the pin in step 3 is what actually protects the state, because a
required_version that a colleague's binary does not satisfy stops before any state
is touched:
brew tap hashicorp/tap brew install hashicorp/tap/terraform terraform -version # -> Terraform v1.16.x on darwin_arm64 (the exact minor varies; anything >= 1.11 is what this page needs) # optional, if you want several versions side by side (the tap installs one): # brew install tfenv && tfenv install latest && tfenv use latest # either route is fine; the required_version pin below is what the state relies on, not the installer.
1. Prove the preconditions from the terminal — the alarm from cloud-02 and the identity you are about to write state as:
export AWS_PROFILE=lbv-admin aws sts get-caller-identity --query Arn --output text # -> arn:aws:sts::123456789012:assumed-role/AWSReservedSSO_AdministratorAccess_.../abhishek (not :root) aws budgets describe-budgets --region us-east-1 --account-id 123456789012 \ --query 'Budgets[].BudgetName' --output text # -> zero-spend (if this prints nothing, go back to cloud-02 -- no apply before the alarm)
2. The bucket — made ONCE, by the CLI, never by Terraform. This is the chicken-and-egg every
Terraform setup has: the backend bucket is where the state lives, so it cannot be a resource in
that state — the first apply would need the bucket to exist before it could write the state
that records the bucket. Create it by hand, write the name down, and never put an
aws_s3_bucket for it in any .tf file. Bucket names are global across every AWS
account, so change yourname-x7q2 to something of yours:
B=lbv-tfstate-yourname-x7q2 # outside us-east-1 the LocationConstraint is REQUIRED -- without it the call fails or lands in Virginia aws s3api create-bucket --bucket "$B" --region ap-south-1 \ --create-bucket-configuration LocationConstraint=ap-south-1 # versioning: every PutObject of the state keeps the previous object as an older version aws s3api put-bucket-versioning --bucket "$B" \ --versioning-configuration Status=Enabled # closed to the public four ways -- state contains every attribute of every resource, sometimes secrets aws s3api put-public-access-block --bucket "$B" \ --public-access-block-configuration BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true # SSE-S3 has been the DEFAULT for every bucket since January 2023, so this call changes nothing -- # run it anyway: it makes the choice explicit in the bucket's configuration and in CloudTrail aws s3api put-bucket-encryption --bucket "$B" \ --server-side-encryption-configuration '{"Rules": [{"ApplyServerSideEncryptionByDefault": {"SSEAlgorithm": "AES256"}}]}' # read all three back -- the proof lines are in Verify aws s3api get-bucket-versioning --bucket "$B" aws s3api get-public-access-block --bucket "$B" aws s3api get-bucket-encryption --bucket "$B"
What the backend needs to be allowed, for later — when cloud-02's temporary
AdministratorAccess is narrowed to a Terraform-only permission set, these are the S3 actions
the backend documentation lists, on exactly these ARNs (the third statement is the one
use_lockfile adds — the lock object needs s3:DeleteObject):
# tf-backend-policy.json -- reference only on this page; nothing here applies it
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": "s3:ListBucket",
"Resource": "arn:aws:s3:::lbv-tfstate-yourname-x7q2"
},
{
"Effect": "Allow",
"Action": ["s3:GetObject", "s3:PutObject"],
"Resource": "arn:aws:s3:::lbv-tfstate-yourname-x7q2/lbv-track/terraform.tfstate"
},
{
"Effect": "Allow",
"Action": ["s3:GetObject", "s3:PutObject", "s3:DeleteObject"],
"Resource": "arn:aws:s3:::lbv-tfstate-yourname-x7q2/lbv-track/terraform.tfstate.tflock"
}
]
}
3. versions.tf — the pins and the backend block. Complete; paste it as-is into the
empty folder and change only the bucket name. A backend block cannot read variables, so the name is a
literal. use_lockfile arrived in 1.10 as experimental and became generally available in
1.11, the same release that deprecated dynamodb_table — hence ~> 1.11
(any 1.x from 1.11 up), not ~> 1.10:
# versions.tf terraform { required_version = "~> 1.11" required_providers { aws = { source = "hashicorp/aws" version = "~> 6.0" } } backend "s3" { bucket = "lbv-tfstate-yourname-x7q2" key = "lbv-track/terraform.tfstate" region = "ap-south-1" use_lockfile = true encrypt = true } } # nothing on this page uses the AWS provider yet -- cloud-04 does. Pinning and configuring it now # means init downloads it once and .terraform.lock.hcl records the exact build you tested with. provider "aws" { region = "ap-south-1" }
Two lock files, and they are not related: .terraform.lock.hcl is the
dependency lock (provider versions and checksums — commit it, like a package-lock.json);
terraform.tfstate.tflock is the state lock this page is about — it lives in the
bucket, only while an operation runs, and never on your disk. Add .terraform/ and
*.tfstate* to .gitignore; commit the .hcl.
4. main.tf — the smallest resource that writes state. terraform_data is
the built-in resource type Terraform 1.4 added to replace null_resource: it does nothing on
AWS, needs no provider download, and stores whatever you give input in the state — which is
exactly what this page needs, a state write with no bill attached:
# main.tf
resource "terraform_data" "hello" {
input = "cloud-03: the first state write"
}
output "hello" {
value = terraform_data.hello.output
}
5. terraform init — this is the step that talks to the bucket for the first time:
cd ~/lbv-cloud/03-backend
terraform init
# reads like:
# Initializing the backend...
# Successfully configured the backend "s3"! Terraform will automatically
# use this backend unless the backend configuration changes.
# ...
# Terraform has been successfully initialized!
# (if it complains about the bucket, check the name in versions.tf against $B and re-run init;
# any change to the backend block needs terraform init again)
6. Plan, read the plan, apply — the first state object appears in the bucket at the end of this:
terraform plan -out=tfplan # Plan: 1 to add, 0 to change, 0 to destroy. terraform apply tfplan # Apply complete! Resources: 1 added, 0 changed, 0 destroyed. # # Outputs: # # hello = "cloud-03: the first state write" terraform state list # terraform_data.hello <- read from the bucket, not from any file in this folder ls -A # .terraform .terraform.lock.hcl main.tf tfplan versions.tf <- and NO terraform.tfstate on disk
7. The lock, demonstrated with two terminals. Terraform takes the state lock before it plans and holds it until the apply finishes — including the whole time it sits at the Enter a value: prompt. Change the input so there is something to apply, start an apply in terminal A and leave it at the prompt, then start a second one in terminal B:
# terminal A -- edit main.tf first: input = "cloud-03: the second state write" terraform apply # ... Plan: 0 to add, 1 to change, 0 to destroy. (input changed: an update in place; only triggers_replace forces a replace) # Do you want to perform these actions? # Enter a value: <- type NOTHING yet; the lock object now exists in the bucket # terminal B -- same folder, while A waits at the prompt terraform apply # reads like (the request-id lines and the Who/Created values are yours): # 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: 3fd6c1a2-.... # Path: lbv-tfstate-yourname-x7q2/lbv-track/terraform.tfstate # Operation: OperationTypeApply # Who: your-user at your-Mac # Version: 1.16.x # Created: 2026-.. UTC # # Terraform acquires a state lock to protect the state from being written # by multiple users at the same time. Please resolve the issue above and try # again. For most commands, you can disable locking with the "-lock=false" # flag, but this is not recommended. # terminal B again, this time told to WAIT for the lock instead of refusing at once terraform apply -lock-timeout=60s # (sits there retrying -- go back to terminal A) # terminal A: answer the prompt # Enter a value: yes # Apply complete! Resources: 0 added, 1 changed, 0 destroyed. <- the lock is released here # terminal B, within its 60 s: acquires the lock, plans against the NEW state, and reports # No changes. Your infrastructure matches the configuration. <- it saw A's write, it did not overwrite it
Never reach for -lock=false to "fix" that error, and use
terraform force-unlock <ID> only for a lock you know is stale (a killed process) — the
ID in the error is the nonce it wants. The lock object itself is gone the moment an operation ends:
aws s3api head-object --bucket "$B" --key lbv-track/terraform.tfstate.tflock answers
Not Found between runs, and exists only while one is in flight.
aws s3api list-object-versions --bucket "$B" --prefix lbv-track/terraform.tfstate --query 'Versions[].[Key,IsLatest,Size]' --output table
lists lbv-track/terraform.tfstate at least twice — IsLatest True once and
False for every earlier apply. Every write keeps the one before it; that is what versioning
bought you.Error acquiring the state
lock with a Lock Info: block whose Path: is your bucket and key — and
terminal A's apply finished normally. No DynamoDB table exists anywhere in the account:
aws dynamodb list-tables --region ap-south-1 prints "TableNames": [].aws s3api get-public-access-block --bucket "$B"
shows all four flags true; aws s3api get-bucket-versioning --bucket "$B" prints
"Status": "Enabled"; aws s3api get-bucket-encryption --bucket "$B" prints
"SSEAlgorithm": "AES256"..tflock object is listed while nothing runs.terraform destroy
# answer yes; wait for "Destroy complete! Resources: 1 destroyed."
# this writes ONE MORE version of the state (an empty one) -- list-object-versions now shows it too
Keep the bucket. It is the backend for cloud-04 onwards; every later page's
versions.tf points at it with a different key. Do not empty it, do not delete
it, do not manage it from Terraform.
What remains and what it costs, approx., verify on the pricing page today: one bucket holding a few KB of state versions — storage is billed per GB-month, so a few KB rounds to nothing; the handful of PUT/GET/DELETE requests an apply makes on the state and the lock object are priced per thousand, so a day of applies is a fraction of a cent; the bucket itself carries no charge (the first 2,000 general purpose buckets in an account are a free tier of their own), and an empty versioned bucket stores no bytes and is charged for none. Rounded: ~$0.00/mo. There is no DynamoDB table to forget.
This is self-attestation — the site cannot see your AWS account, so checking the box and pressing the button is you telling The Path the four Verify lines were true and the demo resource is gone.
use_lockfile = true, a .tflock beside the state, created only if absent) — the
DynamoDB table is deprecated, and a setup that still creates one has one more thing to delete and one more
place a stale lock can hide.