Lab 1 · The Ledger & The Loop#
Terraform is a reconcile loop — and the state file is its memory — Terraform Inside Out#
David Chan, Claude Opus 4.8 AI-Symbiosis Research · July 2026
Here is the one idea this entire series hangs on: Terraform is a reconcile loop. There are three moving parts, and if you’ve met Kubernetes or GitOps you already know the shape:
- Desired state — what you declare in
.tffiles (HCL). - Actual state — what really exists in the cloud.
- The state file (
terraform.tfstate) — Terraform’s ledger: its private memory of everything it built and what it believes is out there.
The loop: plan diffs desired against actual → apply makes actual match desired and updates the ledger. Most tutorials never mention that ledger, and that omission is why Terraform later feels unpredictable. So we’re going to watch it the whole time.
The rig: a free local AWS#
You don’t need an AWS account (or a bill) to do this. LocalStack runs a fake AWS on your machine in a Docker container, and two thin wrappers point Terraform and the AWS CLI at it:
# free community image — no account required
docker run -d --name localstack-tf -p 4566:4566 \
-v /var/run/docker.sock:/var/run/docker.sock \
localstack/localstack:3.8.1
pip install terraform-local awscli-local # gives us `tflocal` and `awslocal`tflocal is just terraform with the AWS endpoints pointed at LocalStack; awslocal is the same for the AWS CLI. Everything you write is real aws_* Terraform — identical to what you’d run against real AWS.
Here’s our entire starting config — one bucket:
# main.tf
provider "aws" {
region = "us-east-1"
access_key = "test"
secret_key = "test"
skip_credentials_validation = true
skip_requesting_account_id = true
skip_metadata_api_check = true
s3_use_path_style = true
}
resource "aws_s3_bucket" "ledger_demo" {
bucket = "my-first-bucket"
}Sidebar — a real gotcha, diagnosed live. The first time I ran this,
applyhung forever. The cause: the AWS provider does a preflightsts:GetCallerIdentitycall to figure out who you are, and I’d started LocalStack with a restricted service list that left STS out — so the call retried into the void. Two fixes, both in the config above: don’t restrict LocalStack’s services (so STS is available), and add theskip_*flags that tell the provider “don’t do the cloud-credential preflight.” That five-minute hang is the lesson — the provider is a program making API calls, and when one silently retries, you get a hang, not an error.
init — download the tools, touch nothing#
tflocal init # → Terraform has been successfully initialized!Now look at what it actually did:
$ ls -la
-rw-r--r-- main.tf
drwxr-xr-x .terraform
-rw-r--r-- .terraform.lock.hcl
Notice what’s missing: there is no terraform.tfstate. That’s the whole lesson of init in one ls:
.terraform/— the downloaded AWS provider plugin (the “driver” that knows how to speak S3)..terraform.lock.hcl— pins the exact provider version, so your infra is reproducible (like apackage-lock.json).- No state file, and no bucket in the cloud.
initmade zero contact with the cloud. It’s pure local prep.
The ledger isn’t born at init. It’s born at the first apply.
plan — the read-only diff#
Desired = one bucket. Actual = nothing. Ledger = empty. So plan should propose to create:
$ tflocal plan
# aws_s3_bucket.ledger_demo will be created
+ resource "aws_s3_bucket" "ledger_demo" {
+ arn = (known after apply)
+ bucket = "my-first-bucket"
+ id = (known after apply)
+ region = "us-east-1"
...
}
Plan: 1 to add, 0 to change, 0 to destroy.
Terraform’s symbols are worth memorizing: + create, - destroy, ~ change in place. Every (known after apply) is a value only the cloud can assign — the ARN, the domain name — so Terraform honestly says “I’ll know once I build it.”
And the crucial part: plan is read-only. It’s a dry run. It created nothing, wrote no state. Run awslocal s3 ls right after and it’s still empty.
apply — the moment the ledger is born#
$ tflocal apply # (shows the same plan, then asks) → yes
aws_s3_bucket.ledger_demo: Creating...
aws_s3_bucket.ledger_demo: Creation complete after 0s [id=my-first-bucket]
Apply complete! Resources: 1 added, 0 changed, 0 destroyed.
Now check both places:
$ ls -la
... terraform.tfstate ← this file just appeared
$ awslocal s3 ls
2026-07-13 11:31:40 my-first-bucket ← and the bucket exists
This is the heart of Terraform: apply changed both the cloud and the ledger, together. The bucket now exists, and terraform.tfstate was born to record it. tflocal show reveals what the ledger captured — every previously-(known after apply) value now real:
resource "aws_s3_bucket" "ledger_demo" {
arn = "arn:aws:s3:::my-first-bucket"
bucket = "my-first-bucket"
hosted_zone_id = "Z3AQBSTGFYJSTF"
id = "my-first-bucket"
region = "us-east-1"
...
}The ledger now holds a mapping: “my declared aws_s3_bucket.ledger_demo ⇄ the real bucket my-first-bucket.” That mapping is everything — it’s how Terraform remembers.
Equilibrium — why re-running is safe#
Run plan again. The bucket exists, the ledger knows it, the config is unchanged:
$ tflocal plan
aws_s3_bucket.ledger_demo: Refreshing state... [id=my-first-bucket]
No changes. Your infrastructure matches the configuration.
Notice what Terraform didn’t do: it didn’t try to recreate the bucket, and it didn’t error. A naive script (awslocal s3 mb ...) would have blown up with “bucket already exists.” Terraform is idempotent precisely because of the ledger — it remembers the mapping, so re-running is a no-op.
And don’t skim that first line — it’s the key to everything that follows:
aws_s3_bucket.ledger_demo: Refreshing state... [id=my-first-bucket]Before diffing, Terraform refreshed — it phoned the cloud and asked “does this still exist, and does it still match my ledger?” So plan is not a two-way compare. It’s a three-way one:
your HCL ⇄ the ledger (tfstate) ⇄ the real cloud (refresh)
(desired) (what TF thinks) (actual reality)That refresh is what makes the next thing possible.
Drift — break it behind Terraform’s back#
What happens when reality changes and Terraform wasn’t the one who did it? Let’s delete the bucket directly, bypassing Terraform entirely:
$ awslocal s3 rb s3://my-first-bucket # delete it behind TF's back
$ awslocal s3 ls # (empty — it's gone)
$ tflocal plan
Note: Objects have changed outside of Terraform
# aws_s3_bucket.ledger_demo has been deleted
Plan: 1 to add, 0 to change, 0 to destroy.
There it is — changed outside of Terraform. The refresh caught reality diverging from the ledger. Your HCL still says “1 bucket,” the ledger still expected it to exist, but reality now says “nothing” — and Terraform’s plan is to reconcile back to what you declared. This is the single most valuable thing Terraform does in production: it doesn’t just build once, it continuously detects when the real world drifts from your declared intent.
Heal it:
$ tflocal apply # → yes
aws_s3_bucket.ledger_demo: Creating...
Apply complete! Resources: 1 added, 0 changed, 0 destroyed.
$ awslocal s3 ls
2026-07-13 11:38:07 my-first-bucket ← restored
The whole loop, on one page#
Everything above is this diagram, and every “weird” Terraform behavior you will ever hit traces back to it — usually to the ledger being out of sync with reality:
declare (HCL) ← desired state, what you want
│ init ← download provider, prep workspace (no cloud, no ledger)
│ plan ← 3-way diff: HCL ⇄ ledger ⇄ real cloud (read-only)
│ apply ← make cloud match desired + write the ledger (together)
▼
equilibrium ← "No changes" — idempotent, because the ledger remembers
│
(someone changes reality behind TF's back)
│ plan ← refresh catches it: "changed outside of Terraform" = DRIFT
│ apply ← reconcile back to desired (heals the deleted bucket)
▼
equilibrium againTwo things to carry forward: apply always writes the cloud and the ledger together, and plan refreshes reality before it diffs. Those two facts explain idempotency, drift detection, and most of the state-file confusion you’ll ever meet — which we’ll go deeper on next.
Next — Lab 2 · Dependencies & Modules: we add a second resource that depends on the first, watch Terraform build the dependency graph and order the work itself, then package it all into a reusable module.