Mission 3.1 · Stage 3 — Compute
Write ShopLite and Push It to ECR
Goal: The ShopLite API as a container image, version 1.0.0, stored in an immutable, scanned ECR repository that Terraform manages.
By the end of this mission
- Draw the line between what Terraform manages and what a build pipeline does
- Create an ECR repository with immutable tags, scanning, and a lifecycle policy
- Embed JSON documents with
jsonencodeinstead of heredoc strings - Build a multi-arch-safe image and push it using Terraform outputs
Part 1
Understand it first
Terraform builds the kitchen, CI cooks the food
Terraform should own long-lived infrastructure: the image REPOSITORY, the cluster, the load balancer. Building and pushing an image is an application build step: it happens on every commit, produces an artifact, and belongs in CI (Stage 7). Terraform only needs to know WHICH image tag to run, which is a variable.
Teams that build images inside Terraform (with null_resource + local-exec + docker build) end up with slow plans, builds that only run when Terraform thinks something changed, and applies that fail because Docker isn't installed on the runner. Keep the boundary clean.
jsonencode: JSON without string-typing
Many AWS resources take JSON documents: lifecycle policies, container definitions, IAM policies. You could write them as heredoc strings, but then a missing comma is only discovered by AWS at apply time, and you can't use references or expressions. jsonencode({...}) takes a normal HCL object, so you get syntax checking, references like var.app_port, conditionals, and correctly escaped JSON.
Watch one subtlety: jsonencode keeps HCL types, so 8080 becomes a JSON number and "8080" a string. AWS APIs are picky, and ECS environment variable values must be strings, hence tostring() in Mission 3.3.
Immutable tags
With mutable tags, api:1.0.0 can be silently overwritten by a later push, so the same tag might mean different code in dev and prod, and a rollback to 1.0.0 might not roll anything back. With IMMUTABLE, ECR rejects any push to an existing tag. Every build gets a new tag (a version or git SHA), and a tag always means exactly one image.
Part 2
Your project after this mission · 6 files change
- app/
- .dockerignorenew
- Dockerfilenew
- package.jsonnew
- server.jsnew
- infra/
- backend.tf
- ecr.tfnew
- locals.tf
- logs.tf
- network.tf
- outputs.tfmodified
- probe.tf
- providers.tf
- refactors.tf
- security.tf
- storage.tf
- terraform.tfvars
- variables.tf
- versions.tf
Part 3
Build it, step by step
- 1
Write the ShopLite API
No dependencies, just Node's built-in
httpmodule. Two things matter for infrastructure:/healthzis what the load balancer checks, and the port and version come from environment variables that Terraform sets. The app handlesSIGTERMso ECS can stop it gracefully during deploys.app/server.jswhole filejs import http from "node:http"; const port = Number(process.env.PORT ?? 8080); const version = process.env.APP_VERSION ?? "dev"; const products = [ { id: 1, name: "Masala Chai", price: 120 }, { id: 2, name: "Filter Coffee", price: 90 }, { id: 3, name: "Mango Lassi", price: 150 }, ]; function json(res, status, body) { res.writeHead(status, { "content-type": "application/json" }); res.end(JSON.stringify(body)); } const server = http.createServer((req, res) => { if (req.url === "/healthz") return json(res, 200, { status: "ok" }); if (req.url === "/products") return json(res, 200, products); return json(res, 200, { service: "shoplite-api", version }); }); server.listen(port, () => console.log(`shoplite-api ${version} listening on ${port}`)); process.on("SIGTERM", () => { console.log("SIGTERM received, draining"); server.close(() => process.exit(0)); }); - 2
Package it
"type": "module"enablesimportsyntax. The Dockerfile runs as the unprivilegednodeuser..dockerignorekeeps local junk out of the image.app/Dockerfilewhole filedocker # app/package.json: { "name": "shoplite-api", "version": "1.0.0", "type": "module" } # app/.dockerignore: node_modules .git *.log FROM node:22-alpine WORKDIR /app COPY package.json server.js ./ USER node EXPOSE 8080 CMD ["node", "server.js"] - 3
Declare the repository
force_deleteis computed from the environment: in dev,terraform destroymay delete a repository with images in it, which is convenient when tearing down between sessions; in prod it may not. The lifecycle policy keeps the newest 20 images so storage doesn't grow forever.infra/ecr.tfwhole filehcl resource "aws_ecr_repository" "api" { name = "${var.project}/api" image_tag_mutability = "IMMUTABLE" force_delete = var.environment != "prod" image_scanning_configuration { scan_on_push = true } } resource "aws_ecr_lifecycle_policy" "api" { repository = aws_ecr_repository.api.name policy = jsonencode({ rules = [{ rulePriority = 1 description = "Keep the newest 20 images" selection = { tagStatus = "any" countType = "imageCountMoreThan" countNumber = 20 } action = { type = "expire" } }] }) } - 4
Output the repository URL and apply
infra/outputs.tfadd to filehcl output "ecr_repository_url" { value = aws_ecr_repository.api.repository_url }terminal$ cd infra && terraform apply── expected output ──Plan: 2 to add, 0 to change, 0 to destroy....Outputs:...ecr_repository_url = "123456789012.dkr.ecr.ap-south-1.amazonaws.com/shoplite/api" - 5
Build for ARM and push
ShopLite runs on Graviton (ARM) Fargate, which is cheaper per unit of compute. On an Intel/AMD machine,
--platform linux/arm64makes buildx cross-build; on Apple Silicon it's native. The registry host is the part of the URL before the first/.terminal$ REPO=$(terraform output -raw ecr_repository_url)aws ecr get-login-password | docker login --username AWS --password-stdin "${REPO%%/*}"docker buildx build --platform linux/arm64 -t "$REPO:1.0.0" --push ../app── expected output ──Login Succeeded[+] Building 14.2s (8/8) FINISHED=> [1/3] FROM docker.io/library/node:22-alpine=> [2/3] WORKDIR /app=> [3/3] COPY package.json server.js ./=> exporting to image=> pushing 123456789012.dkr.ecr.ap-south-1.amazonaws.com/shoplite/api:1.0.0 - 6
Confirm the image and its scan
Scan-on-push runs automatically. Check for findings before the image goes anywhere near production; Stage 7 makes this a pipeline gate.
terminal$ aws ecr describe-images --repository-name shoplite/api \--query 'imageDetails[].[imageTags[0],imageSizeInBytes,imageScanStatus.status]' --output table── expected output ──------------------------------------------| DescribeImages |+--------+------------+------------------+| 1.0.0 | 47811234 | COMPLETE |+--------+------------+------------------+
Checkpoint — you should now have
- ✓
app/containsserver.js,package.json,Dockerfile, and.dockerignore. - ✓ECR repository
shoplite/apiexists with IMMUTABLE tags, scan-on-push, and a lifecycle policy. - ✓Image
shoplite/api:1.0.0(linux/arm64) is in the repository with a completed scan. - ✓No image build happens inside Terraform.
Part 4
Break it on purpose
Make each change, run the command, and read the error before revealing the diagnosis. Recognising these messages on sight is what makes you fast on a real team. Undo the change afterwards.
Break #1
Push the same tag twice
Change the product list in server.js, rebuild, and push again as 1.0.0.
Break #2
Protect a prod repository, then try to destroy it
Set force_delete = false on the repository (as it would be in prod), apply, then run terraform destroy -target=aws_ecr_lifecycle_policy.api -target=aws_ecr_repository.api.
Part 5
Interview questions from this mission
Should Terraform build and push your application's Docker images?
Why use jsonencode for policies and container definitions?
What do immutable image tags protect against?