Command Palette

Search for a command to run...

Hectal

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.

35 min Cents — ECR storage for a ~50 MB image 6 steps 2 break-it drills

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 jsonencode instead 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.

Who does whatdiagram
Rendering diagram…

Part 2

Your project after this mission · 6 files change

shoplite/
  • 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. 1

    Write the ShopLite API

    No dependencies, just Node's built-in http module. Two things matter for infrastructure: /healthz is what the load balancer checks, and the port and version come from environment variables that Terraform sets. The app handles SIGTERM so 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. 2

    Package it

    "type": "module" enables import syntax. The Dockerfile runs as the unprivileged node user. .dockerignore keeps 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. 3

    Declare the repository

    force_delete is computed from the environment: in dev, terraform destroy may 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. 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. 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/arm64 makes 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. 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/ contains server.js, package.json, Dockerfile, and .dockerignore.
  • ✓ECR repository shoplite/api exists 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.

terminal
$ docker buildx build --platform linux/arm64 -t "$REPO:1.0.0" --push ../app
── what you'll see ──
ERROR: failed to push 123456789012.dkr.ecr.ap-south-1.amazonaws.com/shoplite/api:1.0.0:
tag invalid: The image tag '1.0.0' already exists in the 'shoplite/api'
repository and cannot be overwritten because the repository is immutable.

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.

terminal
$ terraform destroy -target=aws_ecr_repository.api
── what you'll see ──
aws_ecr_repository.api: Destroying... [id=shoplite/api]
╷
│ Error: ECR Repository (shoplite/api) not empty, consider using force_delete:
│ operation error ECR: DeleteRepository, https response error StatusCode: 400,
│ RepositoryNotEmptyException: The repository with name 'shoplite/api' in
│ registry with id '123456789012' cannot be deleted because it still contains
│ images
╵

Part 5

Interview questions from this mission

01

Should Terraform build and push your application's Docker images?

02

Why use jsonencode for policies and container definitions?

03

What do immutable image tags protect against?

0/4 · 0%