Hands-On Labs · The Capstone · Part 2 of 6

Capstone Part 2 — Infrastructure as Code

This is the second of six parts building one continuous project: shipping parcel-api, the shipment-tracking service Part 1 gave a real branching strategy and a gated CI pipeline. That pipeline builds and tests a parcel-api:<commit-sha> image on every push — and stops there, because there's nowhere for the image to go. Today it gets somewhere: a real container registry, and one reusable Terraform module that stands up dev, staging, and prod as three isolated environments, each behind its own load balancer, with state locked in a shared remote backend so two applies can never race. By the end of this page you will have destroyed dev's infrastructure completely and rebuilt it from nothing but the files in Git — the only way to actually prove this is infrastructure as code, and not just tidy-looking YAML.

☺ Explain it like I'm 10

Think of the reusable module as a cookie cutter, not a single cookie. Part 1 already mixed a batch of dough — the parcel-api image, tested and ready but still sitting in the bowl. Today you build one cutter shaped like "a parcel-api environment" and press it into the dough three times: once small for dev, once a bit bigger for staging, once full-size for the batch everyone actually eats from, prod. Same cutter, three different amounts of dough — you never hand-carve a new cutter for each size, and if the cutter itself has a flaw, you fix the cutter once and every future cookie inherits the fix.

🤖Your host for this part: Recon the Robot — the same reconciler from infrastructure as code and Terraform. Benny's pipeline from Part 1 hands off a tested image; today Recon gives it somewhere real to run.
⚠ Where you are arriving from, and where you're headed

Arriving: a parcel-api repo on GitHub with branch protection on main, a Jest-tested Express service, and a .github/workflows/ci.yml that lints, tests, and builds a parcel-api:<commit-sha> Docker image on every push and pull request — but never pushes that image anywhere. Leaving this page: a real container registry holding that image; three AWS environments — dev, staging, prod — provisioned from one reusable Terraform module, each running parcel-api behind its own Application Load Balancer and Auto Scaling Group; remote state locked under its own key in a shared S3 + DynamoDB backend; and dev fully destroyed and rebuilt from nothing with your own hands. Part 3 picks up exactly here and turns today's single target group into a canary rollout.

What this part assumes, and what it produces

☺ Like you're 10: An AWS sandbox and Terraform — everything else is already sitting in the repo Part 1 left you.

You need an AWS account (a disposable sandbox is safest — everything here fits comfortably inside most free-tier limits, but an Auto Scaling Group and a load balancer are not literally free, so tear down what you're not using), the AWS CLI configured with credentials, and Terraform 1.9 or newer — the same version floor the Terraform page assumes. You also need the parcel-api repo exactly where Part 1 left it: main protected, build-and-test green, and at least one local docker build -t parcel-api:<sha> . you can push once a registry exists to receive it.

The world model, updated

Part 1's table gets two rows filled in today, and several new ones added — here's the full picture as it stands after this page:

ThingName / valueIntroduced
The applicationparcel-api — a small shipment-tracking HTTP servicePart 1
Source repoparcel-api on GitHub, trunk-based branchingPart 1
Branch protectionmain requires the build-and-test status check to passPart 1
CI pipeline.github/workflows/ci.yml — lint, test, buildPart 1
Container imageparcel-api:<commit-sha>, immutable per commitPart 1 (built) → Part 2 (pushed today)
Container registryAmazon ECR repo parcel-api, immutable tags, scan-on-pushPart 2 — this page
Infra directoryinfra/ — a new top-level folder in the same parcel-api repo, alongside src/Part 2 — this page
State backendS3 bucket acme-terraform-state + DynamoDB table acme-terraform-locksPart 2 — this page
Reusable moduleinfra/modules/parcel-service — security groups, ALB, target group, launch template, ASGPart 2 — this page
Hosting targetAWS: one ALB + Auto Scaling Group per environment, built from the module abovePart 2 — this page
Environment sizingdev: 1× t3.micro · staging: 2× t3.small · prod: 3–6× m5.large (autoscaled)Part 2 — this page
Deployment strategynot yet chosenPart 3

Keep that table in mind across the rest of this capstone: Part 3 adds a second target group to the exact ALB you provision today, Part 4 wires monitoring straight into the ASG this module creates, and Part 6 comes back to tighten the security groups and IAM this part deliberately leaves loose.

Bootstrapping the state backend and the registry, once

☺ Like you're 10: Before Recon can keep a logbook everyone shares, someone has to build the shelf the logbook sits on — and set up the loading dock the image gets dropped off at — and neither step can be written down in the logbook itself.

Infrastructure as code and Terraform both assume a remote backend already exists. It doesn't yet — creating one is a genuine chicken-and-egg problem: a Terraform config's backend block tells it where to store its own state, so a config whose job is to create that exact storage location cannot point its own backend at the thing it hasn't built yet. A small, separate configuration runs once, with state kept local, and is never touched again — and since a registry is just as much a one-time, account-level thing as the state backend, it lives right here too:

infra/
├── bootstrap/                  # run once, by hand, local state — see the warning below
│   └── main.tf                 # state backend + ECR repo
├── modules/
│   └── parcel-service/
│       ├── main.tf
│       ├── variables.tf
│       └── outputs.tf
└── envs/
    ├── dev/       { main.tf, dev.tfvars }
    ├── staging/   { main.tf, staging.tfvars }
    └── prod/      { main.tf, prod.tfvars }
## infra/bootstrap/main.tf — deliberately has no backend block

terraform {
  required_version = ">= 1.9.0"
  required_providers {
    aws = { source = "hashicorp/aws", version = "~> 5.60" }
  }
}

provider "aws" {
  region = "us-east-1"
}

resource "aws_s3_bucket" "tf_state" {
  bucket = "acme-terraform-state"
  lifecycle {
    prevent_destroy = true   # the one bucket in this capstone you never want a bad plan to delete
  }
}

resource "aws_s3_bucket_versioning" "tf_state" {
  bucket = aws_s3_bucket.tf_state.id
  versioning_configuration { status = "Enabled" }
}

resource "aws_s3_bucket_server_side_encryption_configuration" "tf_state" {
  bucket = aws_s3_bucket.tf_state.id
  rule { apply_server_side_encryption_by_default { sse_algorithm = "AES256" } }
}

resource "aws_dynamodb_table" "tf_locks" {
  name         = "acme-terraform-locks"
  billing_mode = "PAY_PER_REQUEST"
  hash_key     = "LockID"
  attribute { name = "LockID", type = "S" }
}

resource "aws_ecr_repository" "parcel_api" {
  name                 = "parcel-api"
  image_tag_mutability = "IMMUTABLE"           # same discipline Part 1 already used: one tag, one set of bytes, forever
  image_scanning_configuration { scan_on_push = true }
}

output "ecr_repository_url" { value = aws_ecr_repository.parcel_api.repository_url }
cd infra/bootstrap
terraform init
terraform plan -out=tfplan
terraform apply tfplan
# acme-terraform-state, acme-terraform-locks, and the parcel-api ECR repo now exist
⚠ This one directory breaks the "everything is remote" rule on purpose

infra/bootstrap's own terraform.tfstate stays local — it describes the bucket, table, and registry that don't exist until this config runs, so nothing else can hold it. Keep it out of Git (add it to .gitignore) and store a copy somewhere durable and access-controlled instead, the same way you'd protect any secret; re-running apply here casually is also the one operation in this capstone that could delete the shelf every other environment's logbook sits on, which is exactly what prevent_destroy above is guarding against.

Seeding the registry with today's image

☺ Like you're 10: The loading dock is built — now carry the batch of dough Part 1 already baked over to it, once, by hand.

Part 1's ci.yml builds parcel-api:${{ github.sha }} in every run but never pushes it — there was nothing to push to. Push today's image once by hand to prove the registry actually works, using the exact commit SHA the pipeline already tagged:

ECR_URL=$(terraform -chdir=infra/bootstrap output -raw ecr_repository_url)

aws ecr get-login-password --region us-east-1 \
  | docker login --username AWS --password-stdin "${ECR_URL%%/*}"

docker build -t parcel-api:a13f0c9 .          # the same build ci.yml already runs
docker tag parcel-api:a13f0c9 "$ECR_URL:a13f0c9"
docker push "$ECR_URL:a13f0c9"

From here forward, every real merge should push automatically — add one step to the ci.yml Part 1 already committed, after aws-actions/configure-aws-credentials and aws-actions/amazon-ecr-login:

      - name: Push to ECR
        run: |
          docker tag parcel-api:${{ github.sha }} $ECR_URL:${{ github.sha }}
          docker push $ECR_URL:${{ github.sha }}

That's an edit you make to your own ci.yml, not something this page rewrites for you — Part 1 already gated that file behind branch protection, so it goes through the exact same PR-and-green-check path as everything else in this capstone, not a shortcut around it.

The reusable parcel-service module

☺ Like you're 10: One blueprint, filled in with different numbers for dev, staging, and prod — not three separate blueprints that happen to look alike.

The module takes an environment name, an image tag, a registry URL, and a size, and produces one complete, working slice of infrastructure: a security group for the load balancer, a security group for the instances that only the load balancer can reach, an Application Load Balancer with a listener and a target group, a launch template whose user data pulls and runs parcel-api, and an Auto Scaling Group wired to that target group. Notice what it does not contain: a var.environment == "prod" ? "m5.large" : "t3.micro" ternary like the Terraform page's own worked example used. That ternary was a fine illustration for one variable in one file — a real paved-road module takes sizing as an input, not a rule baked into its own source, so a platform team can change dev's instance type without touching a single line that staging or prod depend on.

## infra/modules/parcel-service/variables.tf

variable "environment" {
  type        = string
  description = "dev, staging, or prod — never anything else"
  validation {
    condition     = contains(["dev", "staging", "prod"], var.environment)
    error_message = "environment must be one of: dev, staging, prod."
  }
}
variable "ecr_repository_url" { type = string }              # from infra/bootstrap's output
variable "image_tag"          { type = string }               # e.g. "a13f0c9"
variable "instance_type"      { type = string }
variable "min_size"           { type = number }
variable "max_size"           { type = number }
variable "desired_capacity"   { type = number }
variable "container_port"     { type = number, default = 8080 }
## infra/modules/parcel-service/main.tf (trimmed to the shape that matters)

data "aws_vpc" "default" { default = true }
data "aws_subnets" "default" {
  filter { name = "vpc-id", values = [data.aws_vpc.default.id] }
}
data "aws_ami" "al2023" {
  most_recent = true
  owners      = ["amazon"]
  filter { name = "name", values = ["al2023-ami-*-x86_64"] }
}
locals { name = "parcel-${var.environment}" }

resource "aws_security_group" "alb" {
  name   = "${local.name}-alb"
  vpc_id = data.aws_vpc.default.id
  ingress { from_port = 80, to_port = 80, protocol = "tcp", cidr_blocks = ["0.0.0.0/0"] }
  egress  { from_port = 0,  to_port = 0,  protocol = "-1",  cidr_blocks = ["0.0.0.0/0"] }
}

resource "aws_security_group" "app" {
  name   = "${local.name}-app"
  vpc_id = data.aws_vpc.default.id
  ingress {
    from_port       = var.container_port
    to_port         = var.container_port
    protocol        = "tcp"
    security_groups = [aws_security_group.alb.id]   # only the ALB can reach the app — never 0.0.0.0/0
  }
  egress { from_port = 0, to_port = 0, protocol = "-1", cidr_blocks = ["0.0.0.0/0"] }
}

resource "aws_lb" "parcel" {
  name               = local.name
  load_balancer_type = "application"
  security_groups    = [aws_security_group.alb.id]
  subnets            = data.aws_subnets.default.ids
}

resource "aws_lb_target_group" "parcel" {
  name     = local.name
  port     = var.container_port
  protocol = "HTTP"
  vpc_id   = data.aws_vpc.default.id
  health_check { path = "/healthz", healthy_threshold = 2, unhealthy_threshold = 3, interval = 15 }
}

resource "aws_lb_listener" "parcel" {
  load_balancer_arn = aws_lb.parcel.arn
  port              = 80
  protocol          = "HTTP"
  default_action { type = "forward", target_group_arn = aws_lb_target_group.parcel.arn }
}

resource "aws_launch_template" "parcel" {
  name_prefix             = "${local.name}-"
  image_id                = data.aws_ami.al2023.id
  instance_type           = var.instance_type
  vpc_security_group_ids  = [aws_security_group.app.id]
  user_data = base64encode(<<-EOT
    #!/bin/bash
    dnf install -y docker && systemctl enable --now docker
    aws ecr get-login-password --region us-east-1 \
      | docker login --username AWS --password-stdin ${split("/", var.ecr_repository_url)[0]}
    docker run -d --restart unless-stopped -p ${var.container_port}:${var.container_port} \
      ${var.ecr_repository_url}:${var.image_tag}
  EOT
  )
  tag_specifications {
    resource_type = "instance"
    tags          = { Name = local.name, Environment = var.environment }
  }
}

resource "aws_autoscaling_group" "parcel" {
  name                = local.name
  vpc_zone_identifier = data.aws_subnets.default.ids
  target_group_arns   = [aws_lb_target_group.parcel.arn]
  health_check_type   = "ELB"
  min_size            = var.min_size
  max_size            = var.max_size
  desired_capacity    = var.desired_capacity
  launch_template { id = aws_launch_template.parcel.id, version = "$Latest" }
  tag { key = "Name", value = local.name, propagate_at_launch = true }
}
## infra/modules/parcel-service/outputs.tf

output "alb_dns_name"     { value = aws_lb.parcel.dns_name }
output "target_group_arn" { value = aws_lb_target_group.parcel.arn }
output "asg_name"         { value = aws_autoscaling_group.parcel.name }
◆ Key idea

This is the paved-road pattern the module ecosystem exists for: one team writes modules/parcel-service once, and dev, staging, and prod each consume it with a handful of variables instead of three copies of the same eighty lines of HCL. Fix a bug in the health check, tighten a security group, bump the AMI filter — do it once in the module, and every environment picks up the fix the next time someone runs plan. Write one of these yourself, on a smaller and more forgiving example, in Drill — Write a Reusable IaC Module — the muscle memory transfers directly to the module above.

Provisioning dev

☺ Like you're 10: Point the smallest amount of dough at the cutter, and press.

Each environment gets its own root configuration — its own backend key, its own tfvars, its own review gate — never a Terraform workspace pretending to be one. The Terraform page is explicit about why: every workspace shares the same backend credentials and provider config, so a workspace name is not a real wall between a laptop and production. envs/dev/main.tf reads the ECR URL straight out of bootstrap's own state and calls the module with dev's numbers:

## infra/envs/dev/main.tf

terraform {
  required_version = ">= 1.9.0"
  required_providers { aws = { source = "hashicorp/aws", version = "~> 5.60" } }
  backend "s3" {
    bucket         = "acme-terraform-state"
    key            = "parcel/dev/terraform.tfstate"
    region         = "us-east-1"
    dynamodb_table = "acme-terraform-locks"
    encrypt        = true
  }
}

provider "aws" { region = "us-east-1" }

# reads bootstrap's LOCAL state directly — only safe because that state sits right
# next to this config, the same intentional exception the bootstrap warning named
data "terraform_remote_state" "bootstrap" {
  backend = "local"
  config  = { path = "${path.module}/../../bootstrap/terraform.tfstate" }
}

variable "parcel_image_tag" { type = string }

module "parcel" {
  source              = "../../modules/parcel-service"
  environment         = "dev"
  ecr_repository_url  = data.terraform_remote_state.bootstrap.outputs.ecr_repository_url
  image_tag           = var.parcel_image_tag
  instance_type       = "t3.micro"
  min_size            = 1
  max_size            = 1
  desired_capacity    = 1
}

output "parcel_url" { value = "http://${module.parcel.alb_dns_name}" }
# infra/envs/dev/dev.tfvars
parcel_image_tag = "a13f0c9"
cd infra/envs/dev
terraform init
terraform plan -out=tfplan -var-file=dev.tfvars
# review every line: 2 security groups, 1 ALB, 1 listener, 1 target group, 1 launch template, 1 ASG — 7 to add
terraform apply tfplan

terraform output parcel_url
# http://parcel-dev-123456789.us-east-1.elb.amazonaws.com

curl -s -o /dev/null -w "%{http_code}\n" "$(terraform output -raw parcel_url)/healthz"
# 200 — parcel-api:a13f0c9 is live in dev, reachable through an ALB you wrote no click for

Promoting the same module to staging and prod

☺ Like you're 10: Same cutter, bigger batch of dough — the file barely changes.

Staging and prod's main.tf files are near-identical to dev's — different key, different module arguments, nothing else. This is the infrastructure equivalent of build once, promote everywhere: you don't rewrite the module per environment any more than Part 1's pipeline rebuilds parcel-api per environment — you carry the same declared shape forward and change only the inputs.

## infra/envs/staging/main.tf — differs from dev only in key and module args
  backend "s3" { ... key = "parcel/staging/terraform.tfstate" ... }
  ...
module "parcel" {
  source = "../../modules/parcel-service"
  environment         = "staging"
  ecr_repository_url  = data.terraform_remote_state.bootstrap.outputs.ecr_repository_url
  image_tag           = var.parcel_image_tag
  instance_type       = "t3.small"
  min_size            = 2
  max_size            = 2
  desired_capacity    = 2
}

## infra/envs/prod/main.tf — differs from dev only in key and module args
  backend "s3" { ... key = "parcel/prod/terraform.tfstate" ... }
  ...
module "parcel" {
  source = "../../modules/parcel-service"
  environment         = "prod"
  ecr_repository_url  = data.terraform_remote_state.bootstrap.outputs.ecr_repository_url
  image_tag           = var.parcel_image_tag
  instance_type       = "m5.large"
  min_size            = 3
  max_size            = 6
  desired_capacity    = 3
}
cd infra/envs/staging
terraform init && terraform plan -out=tfplan -var-file=staging.tfvars && terraform apply tfplan

cd ../prod
terraform init && terraform plan -out=tfplan -var-file=prod.tfvars
# in a real pipeline: this plan posts as a PR comment, apply runs only after a human approves it —
# the same manual gate Part 1's build-and-test check enforced on code, now guarding infrastructure too
terraform apply tfplan
modules/parcel-service one reusable module envs/dev t3.micro · 1/1/1 own ALB + ASG envs/staging t3.small · 2/2/2 own ALB + ASG envs/prod m5.large · 3–6 own ALB + ASG S3 acme-terraform-state + DynamoDB acme-terraform-locks one shared backend, one state key + lock per environment parcel/dev/… parcel/staging/… parcel/prod/…

Proving it's really declarative: destroy dev, then rebuild it

☺ Like you're 10: Knock the whole cookie back into crumbs, then press the exact same cutter into fresh dough and check you get the same cookie back.

A config that "looks declarative" and one that actually is declarative only differ in one respect: whether it can rebuild the identical thing from nothing but its own files. This is the single most important thing to prove with your own hands before this capstone moves on — and it's the "done when" for this entire part. Note dev's current ALB address, then destroy every resource dev owns:

cd infra/envs/dev
terraform output parcel_url
# http://parcel-dev-123456789.us-east-1.elb.amazonaws.com   <- write this down

terraform plan -destroy -out=tfplan.destroy -var-file=dev.tfvars
# review the -/- lines: ASG, launch template, listener, target group, ALB, both security groups — 7 to destroy
terraform apply tfplan.destroy

curl -s -o /dev/null -w "%{http_code}\n" "http://parcel-dev-123456789.us-east-1.elb.amazonaws.com/healthz"
# curl: (6) Could not resolve host — the ALB, and its DNS name, genuinely no longer exist

Now rebuild it from nothing but what's committed — no state left over locally, no half-applied resources to lean on:

rm -rf .terraform .terraform.tfstate.d   # simulate a fresh clone; dev.tfvars and main.tf are all that's left
terraform init
terraform plan -out=tfplan -var-file=dev.tfvars
terraform apply tfplan

terraform output parcel_url
# http://parcel-dev-987654321.us-east-1.elb.amazonaws.com   <- a DIFFERENT ALB: proof this wasn't a no-op

curl -s -o /dev/null -w "%{http_code}\n" "$(terraform output -raw parcel_url)/healthz"
# 200 — parcel-api:a13f0c9 is back, rebuilt from the files in Git and nothing else

cd ../staging && terraform state list | wc -l
cd ../prod    && terraform state list | wc -l
# both counts identical to before dev's destroy — separate backend keys means dev's teardown
# never touched staging's or prod's state, locks, or live infrastructure

Done when: the second parcel_url is provably a different ALB than the first, /healthz answers 200 against it within a few minutes of apply, and terraform state list in staging and prod shows exactly the resource count it showed before you touched dev — proof, not assumption, that a real teardown-and-rebuild of one environment left the other two completely alone.

⚠ The lock is what makes this safe to do while CI is also running

If a CI job happened to be mid-apply against dev's own state at the exact moment you ran terraform destroy above, the DynamoDB lock — not politeness, not timing — is what would have stopped the two from racing: whichever operation asked for the lock second blocks or fails fast instead of computing a plan against a snapshot that's already stale. This is state locking doing its actual job, not a theoretical concern from an earlier lesson.

What "done" looks like for Part 2

☺ Like you're 10: A registry, three real environments, one module, and a rebuild you watched happen with your own eyes.

At the end of this part: parcel-api:a13f0c9 lives in a real Amazon ECR repository and is running behind its own ALB and ASG in dev, staging, and prod, all three provisioned from the same modules/parcel-service module with nothing but variables differing between them; state for all three lives in acme-terraform-state, locked per-environment via acme-terraform-locks; and you have destroyed and rebuilt dev end to end, watching the ALB's identity change and staging/prod's state stay untouched throughout. Nothing here gets thrown away — each later part reads today's infrastructure directly:

PartWhat it does with today's infrastructure
3 — Deployment StrategyAdds a second target group to today's ALB and shifts weighted traffic between them for a canary rollout
4 — ObservabilityScrapes and dashboards the exact ASG instances this module creates
5 — Incident ResponseBreaks prod on purpose, in a blast radius today's separate state keys made possible in the first place
6 — Security HardeningTightens the security groups and IAM this part deliberately left loose, and hardens the ECR push path
🎬 At the Ship-It Guild
🦫

Benny the Beaver: Pipeline's still green — a13f0c9 passed every stage. It's just sitting in docker images on my laptop, though. Feels wrong.

🤖

Recon: Not for long. Same discipline you used building it, Benny — one module, promoted through variables, never rebuilt per environment.

🦊

Foxy: Why not just make three Terraform workspaces instead of three whole directories? Feels like less typing.

🤖

Recon: Because a workspace shares its backend credentials and its provider config with every other workspace. One wrong workspace select plus one unreviewed apply, and you've landed on prod by accident.

👺

Gizmo: Or — hot take — skip the whole destroy-and-rebuild exercise. It plans clean, obviously it'd rebuild fine. 🤑

🐢

Timmy: "Obviously" is exactly the word that got Benny's direct push rejected in Part 1. A clean plan proves the diff is empty right now — it says nothing about whether the whole thing comes back from zero. Prove it, or it didn't happen.

🦫

Benny: ...fine. Tearing dev down for real. Give me the DynamoDB table's blessing first, though — I'd rather the lock stop me than a race condition.

✓ Checkpoint

1. Why must the S3 bucket, DynamoDB table, and ECR repo created in infra/bootstrap never be managed by a Terraform config whose own backend points at that same bucket? 2. Why does dev/staging/prod each get its own root configuration and backend key instead of three Terraform workspaces sharing one? 3. What two independent observations in the destroy-and-rebuild exercise prove dev was actually torn down and recreated, rather than apply just no-oping against unchanged state? 4. What stops a person from running terraform apply against prod's directory at the exact moment CI is applying a different change to prod?

Check your answers
  1. That config's own state would have to describe a bucket, table, and registry it is simultaneously trying to create — a bootstrapping paradox, since the backend has to exist before Terraform can write state into it. It runs with local state instead, kept safe outside Git, specifically so it can execute before the shared backend exists at all.
  2. Every Terraform workspace shares the same backend credentials, provider configuration, and often the same variable defaults — a workspace name alone doesn't stop an unreviewed local apply from landing on production. Separate root configurations with separate backend keys give each environment, especially prod, its own independent review gate and its own blast radius.
  3. The ALB's DNS name changed to a genuinely different address rather than the original surviving, and /healthz answering 200 against that new address only happens if a real Auto Scaling Group booted a real instance running parcel-api again — both are only true if resources were actually destroyed and recreated, not left alone.
  4. State locking. Acquiring the DynamoDB lock is required before any state-touching plan or apply proceeds; whichever operation asks for the lock second blocks or fails immediately until the first one releases it, so the two can never race and corrupt state or issue conflicting API calls against the same resources.

Milestones

☺ Like you're 10: Tick each box only once you've actually watched it happen on your own screen, not because the step "sounds right."

Work these in order — each depends on the state left by the one before. Progress saves in this browser.

0 / 12 milestones complete
1Write and apply infra/bootstrap
Create the S3 bucket, DynamoDB table, and ECR repo exactly as shown above, with local state.
Done when: terraform output ecr_repository_url prints a real ECR URL.
2Push today's parcel-api image to ECR
aws ecr get-login-password, docker build, docker tag, docker push, exactly as shown above.
Done when: the AWS console (or aws ecr list-images) shows a13f0c9 in the parcel-api repo.
3Write infra/modules/parcel-service
The security groups, ALB, target group, listener, launch template, and ASG, exactly as shown above.
Done when: terraform validate inside a test env root that calls the module passes clean.
4Write and apply infra/envs/dev
terraform init, plan -out=tfplan -var-file=dev.tfvars, apply tfplan.
Done when: curl "$(terraform output -raw parcel_url)/healthz" returns 200.
5Write and apply infra/envs/staging
Same shape as dev, own backend key, t3.small sizing, own staging.tfvars.
Done when: staging's /healthz also returns 200, independently of dev's.
6Write and apply infra/envs/prod
Same shape again, own backend key, m5.large sizing 3–6, own prod.tfvars — review the plan carefully before applying.
Done when: prod's ASG shows 3 healthy targets in its target group.
7Confirm all three environments are healthy and isolated
Run terraform state list in each of the three env directories and note the resource count for each.
Done when: all three ALBs answer 200 on /healthz, and each state file's resource count matches only its own module call.
8Plan dev's destruction and read every line
terraform plan -destroy -out=tfplan.destroy -var-file=dev.tfvars — do not apply yet.
Done when: you can name all 7 resources the plan proposes to destroy, out loud, without looking.
9Apply the destroy, and prove dev is actually gone
terraform apply tfplan.destroy, then curl the old ALB URL directly.
Done when: the curl fails to resolve the old hostname at all.
Concept: this page's proving section
10Rebuild dev from nothing but committed files
rm -rf .terraform .terraform.tfstate.d, terraform init, plan -out=tfplan -var-file=dev.tfvars, apply tfplan.
Done when: the new parcel_url is a genuinely different ALB DNS name than the one you destroyed.
Concept: Idempotency
11Confirm staging and prod were never touched
Re-run terraform state list | wc -l in staging and prod and compare against milestone 7's counts.
Done when: both counts match exactly, and both ALBs still answer with their original hostnames.
12Say out loud what state you're leaving for Part 3
Confirm: registry seeded, three environments up from one module, dev's rebuild proven, staging/prod provably untouched by it.
Done when: you can describe this state without looking anything up — it's the exact starting point Part 3 assumes.

Part 2 gave parcel-api somewhere real to run, in triplicate, provisioned from one module and provably rebuildable from nothing. Continue to Capstone Part 3 — Deployment Strategy, where today's single target group becomes a canary rollout. Or revisit infrastructure as code and Terraform for the concepts behind what you just built, step back to Ship It — Start Here for how this capstone's six parts fit together, or get a faster, standalone rep of writing a module with Drill — Write a Reusable IaC Module.