Cloud 03 — Terraform, and a state backend with no DynamoDB table

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.

~45 min~$0.00/mo (approx.) Terraform ≥ 1.11 + AWS CLI v2the bucket stays cloud-03

What this creates

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.

Preconditions

Do it

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.

Verify

Teardown

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.

Takeaways: the state file is the one artefact Terraform cannot recreate, so it gets the three things a file like that needs — a history (versioning), a lock (one writer at a time) and a locked door (public access blocked, encrypted at rest). The bucket that holds the state cannot be a resource in that state, so it is the one AWS thing on this track made by hand and never destroyed. And the interview-grade fact: since Terraform 1.11 the lock is an object in the same bucket (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.