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.
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.
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:
| Thing | Name / value | Introduced |
|---|---|---|
| The application | parcel-api — a small shipment-tracking HTTP service | Part 1 |
| Source repo | parcel-api on GitHub, trunk-based branching | Part 1 |
| Branch protection | main requires the build-and-test status check to pass | Part 1 |
| CI pipeline | .github/workflows/ci.yml — lint, test, build | Part 1 |
| Container image | parcel-api:<commit-sha>, immutable per commit | Part 1 (built) → Part 2 (pushed today) |
| Container registry | Amazon ECR repo parcel-api, immutable tags, scan-on-push | Part 2 — this page |
| Infra directory | infra/ — a new top-level folder in the same parcel-api repo, alongside src/ | Part 2 — this page |
| State backend | S3 bucket acme-terraform-state + DynamoDB table acme-terraform-locks | Part 2 — this page |
| Reusable module | infra/modules/parcel-service — security groups, ALB, target group, launch template, ASG | Part 2 — this page |
| Hosting target | AWS: one ALB + Auto Scaling Group per environment, built from the module above | Part 2 — this page |
| Environment sizing | dev: 1× t3.micro · staging: 2× t3.small · prod: 3–6× m5.large (autoscaled) | Part 2 — this page |
| Deployment strategy | not yet chosen | Part 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
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 }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 forPromoting 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
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 existNow 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 infrastructureDone 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.
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:
| Part | What it does with today's infrastructure |
|---|---|
| 3 — Deployment Strategy | Adds a second target group to today's ALB and shifts weighted traffic between them for a canary rollout |
| 4 — Observability | Scrapes and dashboards the exact ASG instances this module creates |
| 5 — Incident Response | Breaks prod on purpose, in a blast radius today's separate state keys made possible in the first place |
| 6 — Security Hardening | Tightens the security groups and IAM this part deliberately left loose, and hardens the ECR push path |
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.
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
- 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.
- 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.
- The ALB's DNS name changed to a genuinely different address rather than the original surviving, and
/healthzanswering200against that new address only happens if a real Auto Scaling Group booted a real instance runningparcel-apiagain — both are only true if resources were actually destroyed and recreated, not left alone. - 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.
infra/bootstrapterraform output ecr_repository_url prints a real ECR URL.parcel-api image to ECRaws ecr get-login-password, docker build, docker tag, docker push, exactly as shown above.aws ecr list-images) shows a13f0c9 in the parcel-api repo.infra/modules/parcel-serviceterraform validate inside a test env root that calls the module passes clean.infra/envs/devterraform init, plan -out=tfplan -var-file=dev.tfvars, apply tfplan.curl "$(terraform output -raw parcel_url)/healthz" returns 200.infra/envs/stagingt3.small sizing, own staging.tfvars./healthz also returns 200, independently of dev's.infra/envs/prodm5.large sizing 3–6, own prod.tfvars — review the plan carefully before applying.terraform state list in each of the three env directories and note the resource count for each.200 on /healthz, and each state file's resource count matches only its own module call.terraform plan -destroy -out=tfplan.destroy -var-file=dev.tfvars — do not apply yet.terraform apply tfplan.destroy, then curl the old ALB URL directly.curl fails to resolve the old hostname at all.rm -rf .terraform .terraform.tfstate.d, terraform init, plan -out=tfplan -var-file=dev.tfvars, apply tfplan.parcel_url is a genuinely different ALB DNS name than the one you destroyed.terraform state list | wc -l in staging and prod and compare against milestone 7's counts.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.