← Back to all topics
$ docker run -d devops-foundations

Docker & Containers
Instructor Guide

Build, ship, and run containers — the modern foundation of every DevOps deployment

01
Why Docker? Containers vs VMs
The shipping-container analogy and why every cloud-native company runs on Docker

How to explain to students

Open with the question: "Have you ever shipped your laptop's code to a server and watched it break?" That's the problem Docker solves. A container packages the app plus its OS-level dependencies into a single portable unit that runs identically on your laptop, your colleague's, and on AWS.

Use the analogy: "VMs are houses — full kitchen, plumbing, electricity. Containers are apartments — they share the building's plumbing (the kernel) but each has its own walls." A VM boots a full OS in minutes; a container starts in milliseconds and uses ~10× less RAM.

bash — first-container
# Your first container — runs nginx in 2 seconds
$ docker run -d -p 8080:80 --name web nginx
Unable to find image 'nginx:latest' locally
latest: Pulling from library/nginx ... Pull complete
a3f2e8c91d7a4b5c8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d

$ curl http://localhost:8080
<h1>Welcome to nginx!</h1>

# Inspect what's running
$ docker ps
CONTAINER ID IMAGE STATUS PORTS NAMES
a3f2e8c91d7a nginx Up 12 seconds 0.0.0.0:8080->80/tcp web
📦
Portable
Build once, run on any Linux host. No "works on my machine" excuses.
Fast boot
Containers start in milliseconds vs minutes for VMs. Critical for autoscaling.
🪶
Lightweight
No guest OS. A container image is typically 50–500 MB; a VM image is gigabytes.
🔁
Reproducible
Same image runs identically in dev, staging, and prod. No drift.

🎯 Practice Questions

Q1.
In one sentence each, list three real-world problems Docker solves that you can't easily fix with plain SSH and bash scripts.
Q2.
A container shares the host's kernel but a VM ships its own. Name one capability VMs have that containers don't, and one DevOps scenario where that matters.
Show Answer
VMs can run a different OS kernel than the host (e.g. Windows VM on a Linux server, or Linux 4.x VM on a Linux 6.x host). Containers must use the host's kernel.

Where it matters: running legacy software that requires a specific kernel version, kernel-module testing, or hosting Windows workloads on a Linux fleet. For those, VMs (or hybrids like Firecracker) are still the right tool — Docker is not a universal replacement.
Q3.
Run docker run -d -p 8080:80 nginx on your machine. Open localhost:8080 in a browser. What does the -d flag do, and what would change if you removed it?
Q4.
Your team currently deploys via "git pull on the server then restart pm2". List three concrete things that get easier the day you switch to Docker.
💡 Think about rollback, dependency drift, and onboarding new engineers.
02
Docker Architecture, CLI & Dockerfile Best Practices
Engine, daemon, client — and writing Dockerfiles that build fast and stay small

How to explain to students

There are three pieces: the Docker daemon (long-running background process), the Docker client (the docker CLI you type into), and a registry (like Docker Hub or AWS ECR) that stores images. The CLI talks to the daemon over a socket; the daemon does the actual work of pulling, building, and running.

A Dockerfile is a recipe — each instruction creates a layer, and Docker caches layers aggressively. If you understand "order your Dockerfile from least-changing to most-changing", you've already mastered 80% of build performance.

bash — Dockerfile + build
# A production-ready Node.js Dockerfile
FROM node:20-alpine  # pinned + slim base = small + secure

WORKDIR /app

# Copy ONLY package.json first → cache the npm install layer
COPY package*.json ./
RUN npm ci --omit=dev  # reproducible install

# THEN copy source — changes here don't bust the npm cache
COPY . .

USER node  # 🔒 never run as root in prod
EXPOSE 3000
CMD ["node", "server.js"]

# Build it
$ docker build -t myapp:1.0 .
[+] Building 12.4s (10/10) FINISHED
=> [internal] load build context 0.0s
=> CACHED [2/6] WORKDIR /app 0.0s
=> CACHED [3/6] COPY package*.json ./ 0.0s
=> [4/6] RUN npm ci --omit=dev 8.2s
=> [5/6] COPY . . 0.1s
=> exporting layers 0.4s
🥡
Pin your base image
Use node:20-alpine, never node:latest. Reproducibility > convenience.
📚
Order layers by churn
Stable steps (deps) first, volatile steps (source) last. Massive build-time wins.
🚫
Use .dockerignore
Exclude node_modules, .git, .env — never ship them into the image.
🪪
CMD vs ENTRYPOINT
CMD is the default args (overridable). ENTRYPOINT is the fixed binary.

Practical: Multi-stage builds shrink images dramatically

A naive Node image ships the whole toolchain (gcc, npm, build tools). A multi-stage build compiles in a "builder" stage, then copies only the runtime artefacts to a tiny final stage.

Dockerfile — multi-stage
# ── Stage 1: build ────────────────────────────
FROM node:20 AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

# ── Stage 2: runtime (tiny) ───────────────────
FROM node:20-alpine
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/package*.json ./
RUN npm ci --omit=dev
USER node
CMD ["node", "dist/server.js"]

# Result: 1.2 GB → 180 MB (8× smaller)
FROM COPY RUN CMD ENTRYPOINT USER multi-stage .dockerignore

🎯 Practice Questions

Q1.
Take a Dockerfile that does COPY . . as its first instruction. Rewrite it so changing a single source file doesn't bust the npm install cache.
Q2.
What's the difference between CMD ["npm","start"] and ENTRYPOINT ["npm","start"]? Give a scenario where each is the right choice.
Show Answer
CMD sets default arguments that the user can override at docker run time. docker run myapp test would replace npm start with test.

ENTRYPOINT sets the fixed executable. Anything passed to docker run becomes arguments to ENTRYPOINT, not a replacement.

Use CMD when the image is a generic runtime someone might invoke differently (e.g. a CLI tool image). Use ENTRYPOINT when the image must always run as the same process — typical for application containers. The combo ENTRYPOINT ["node"] CMD ["server.js"] is a clean idiom.
Q3.
Why is node:20-alpine typically a better choice than node:20 for production images? What's one scenario where Alpine causes problems?
💡 Alpine uses musl libc instead of glibc.
Q4.
Add a .dockerignore for a Node.js project that excludes everything that should never end up in the image. List at least six entries.
Q5.
Your team's image is 1.4 GB. Sketch the multi-stage Dockerfile that would bring it under 300 MB while still running a TypeScript Node.js app.
03
Building, Tagging & Managing Images (Docker Hub + AWS ECR)
Image versioning discipline and pushing to public + private registries

How to explain to students

An image is the blueprint; a container is a running instance of that blueprint. You can run 100 containers from one image. Tags are how you version images — and :latest is a trap, not a version. Use semver (:1.4.2) or git SHAs (:abc123f) so you always know exactly what's running.

A registry is the warehouse — Docker Hub for public images, AWS ECR for private images you don't want the world to pull. You log in once, then docker push uploads your image and docker pull downloads it on the server.

bash — push to Docker Hub
# Build with two tags — version + latest pointer
$ docker build -t myapp:1.0.0 -t myapp:latest .

# Re-tag for Docker Hub (must be username/repo:tag)
$ docker tag myapp:1.0.0 muzammil/myapp:1.0.0
$ docker tag myapp:latest muzammil/myapp:latest

# Authenticate, then push
$ docker login
Username: muzammil
Password: ********
Login Succeeded

$ docker push muzammil/myapp:1.0.0
The push refers to repository [docker.io/muzammil/myapp]
a3f2e8c9: Pushed
1.0.0: digest: sha256:9f4e2... size: 1782

Practical: Pushing to AWS ECR (private registry)

ECR is AWS's private registry. The flow has one extra step — get a temporary token from AWS, hand it to docker login. After that, it's just docker push like any registry.

bash — push to AWS ECR
# 1. Create the ECR repo (one time)
$ aws ecr create-repository --repository-name myapp --region eu-west-1

# 2. Authenticate Docker against ECR (token expires in 12h)
$ aws ecr get-login-password --region eu-west-1 | \
  docker login --username AWS --password-stdin \
  123456789012.dkr.ecr.eu-west-1.amazonaws.com
Login Succeeded

# 3. Tag with the full ECR URI + push
$ docker tag myapp:1.0.0 \
  123456789012.dkr.ecr.eu-west-1.amazonaws.com/myapp:1.0.0
$ docker push \
  123456789012.dkr.ecr.eu-west-1.amazonaws.com/myapp:1.0.0
1.0.0: digest: sha256:9f4e2... size: 1782 ✓

# Pro tip: Use the git SHA as the tag in CI for traceability
$ SHA=$(git rev-parse --short HEAD)
$ docker build -t myapp:$SHA . && docker push ...
docker tag docker push docker pull Docker Hub AWS ECR ⚠️ avoid :latest in prod

🎯 Practice Questions

Q1.
Why is deploying :latest to production a recipe for incident? Describe a concrete failure mode.
Show Answer
:latest is just a pointer; it gets overwritten every time someone pushes. Two problems follow:

1. You can't reproduce yesterday's deploy. If :latest on the server points to image SHA abc but the registry now has :latest pointing to def, a re-pull silently changes what's running.
2. You can't roll back. "Deploy the previous :latest" is meaningless — the previous image may be unreachable.

Fix: tag with semver (:1.4.2) or the git SHA. Use :latest only as a convenience pointer for local dev.
Q2.
Re-tag a local image myapp:1.0.0 for both Docker Hub (under your username) and an ECR URI. Write the two docker tag commands.
Q3.
Your aws ecr get-login-password | docker login command worked yesterday but fails today with "no basic auth credentials". Why?
💡 ECR auth tokens have a TTL.
Q4.
In a CI pipeline, what's the advantage of tagging images with the git SHA over a sequential build number?
Q5.
Compare Docker Hub free tier vs AWS ECR for a private startup project: cost, security, integration. Which would you pick and why?
04
Docker Networks & Volumes
How containers talk to each other and how data survives restarts

How to explain to students

By default, every container gets an isolated network. Containers on the same custom bridge network can find each other by name — no IP gymnastics. This is how a web container reaches a database container: postgres://db:5432, where db is just the container name.

Containers are ephemeral — when one dies, its filesystem dies too. Volumes survive restarts and rebuilds. Use a named volume for database data, and a bind mount only for local development (mapping host code into the container for live reload).

bash — networks + volumes
# Create a custom network so containers can find each other by name
$ docker network create app-net

# Create a named volume for postgres data
$ docker volume create pgdata

# Run postgres — attach the volume + network
$ docker run -d \
  --name db \
  --network app-net \
  -v pgdata:/var/lib/postgresql/data \
  -e POSTGRES_PASSWORD=secret \
  postgres:16-alpine

# Run web app on the SAME network — connect by container name
$ docker run -d \
  --name web \
  --network app-net \
  -e DB_URL=postgres://postgres:secret@db:5432/postgres \
  -p 3000:3000 \
  myapp:1.0.0

# Bind mount for local dev — host code → container, live reload
$ docker run --rm -it \
  -v $(pwd):/app \
  -w /app \
  node:20-alpine npm run dev

# Inspect: where do volumes actually live on the host?
$ docker volume inspect pgdata
"Mountpoint": "/var/lib/docker/volumes/pgdata/_data"
🌐
Custom bridge > default
Default bridge has no DNS. Custom bridges resolve container names automatically.
💾
Named volumes for data
Postgres, Redis, anything stateful. Survives docker rm.
🔗
Bind mounts for dev
Map your source folder into the container. Edit on host, see changes inside.
⚠️
Anonymous volumes pile up
Run docker volume prune periodically — they leak disk over time.

🎯 Practice Questions

Q1.
Two containers on the default bridge can reach each other only by IP, not by name. Explain why, and what one command fixes it.
Show Answer
The default bridge network (bridge) is the legacy network and does not ship Docker's embedded DNS. Custom bridge networks do.

Fix: create a custom network and attach both containers to it.
docker network create app-net → then run each container with --network app-net. They can now address each other by container name (http://web:3000, postgres://db:5432).
Q2.
A new engineer runs docker rm postgres and the database is gone. What should the original setup have done to prevent this from destroying the data?
Q3.
Compare bind mounts and named volumes for: (a) running Postgres in production, (b) developing a Node app locally with hot reload. Which fits which?
Q4.
Show the docker run flag combination that mounts your current working directory into /app inside a Node container read-only. Why might read-only matter?
💡 The :ro suffix on a volume mount.
05
Docker Compose for Multi-Container Apps
One YAML file describes your whole stack — web, database, cache, all wired together

How to explain to students

Running 4 docker run commands by hand to spin up app + db + cache + queue is fragile and unreproducible. Docker Compose describes the whole stack in one compose.yaml file — services, networks, volumes — and brings it up with docker compose up.

Compose is the standard for local development and small production deploys. For larger scale you graduate to Kubernetes, but every K8s engineer started with Compose.

compose.yaml — full-stack web app
services:

  web:
    build: .
    ports: ["3000:3000"]
    environment:
      DB_URL: postgres://postgres:secret@db:5432/app
      REDIS_URL: redis://cache:6379
    depends_on: [db, cache]
    restart: unless-stopped

  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_PASSWORD: secret
      POSTGRES_DB: app
    volumes: [pgdata:/var/lib/postgresql/data]
    healthcheck:
      test: ["CMD", "pg_isready", "-U", "postgres"]
      interval: 5s

  cache:
    image: redis:7-alpine

volumes:
  pgdata:

# Bring it all up — networks are auto-created
$ docker compose up -d
[+] Running 4/4
✔ Network app_default Created
✔ Container app-db-1 Started
✔ Container app-cache-1 Started
✔ Container app-web-1 Started

$ docker compose logs -f web
web-1 | Server listening on port 3000
compose up compose down compose logs depends_on healthcheck profiles

🎯 Practice Questions

Q1.
Write a minimal compose.yaml for a Node web app that depends on a Postgres database. Use environment variables for DB credentials and a named volume for Postgres data.
Q2.
What's the difference between depends_on and a healthcheck? Why is depends_on alone often not enough?
Show Answer
depends_on only waits for the dependency container to start — not for the service inside to be ready. Postgres takes a few seconds to accept connections after the container starts. The web container can crash trying to connect during that window.

Fix: add a healthcheck on the dependency, then use depends_on with a condition:
depends_on: { db: { condition: service_healthy } }

Now the web container only starts once pg_isready succeeds.
Q3.
You ran docker compose down and your database data is gone. What did Compose do, and what flag would have saved the data?
Q4.
Add a fourth service — a one-off "migration runner" — that runs DB migrations and then exits. How do you keep it from running on every up?
💡 Look up Compose profiles.
Q5.
A teammate asks: "Why do we use Compose locally but Kubernetes in prod? Can't we just use Compose everywhere?" Give a 3-bullet answer.
06
Container Security Basics
Non-root containers, image scanning, and the practical security checklist

How to explain to students

A container that runs as root is a one-line bug away from being root on the host. The single biggest container-security win is to add a USER directive and run as a non-privileged user. Most "Docker is insecure" headlines trace back to this one mistake.

After non-root, the next pillars are: scan images for known vulnerabilities (Trivy, Grype, Snyk), use minimal base images (alpine, distroless), never bake secrets into images, and pin your dependencies.

bash — security checklist
# 1. Non-root user in Dockerfile
FROM node:20-alpine
RUN addgroup -S app && adduser -S app -G app
USER app

# 2. Read-only root filesystem at runtime
$ docker run --read-only --tmpfs /tmp myapp:1.0.0

# 3. Scan images for CVEs (Trivy is open source + free)
$ trivy image myapp:1.0.0
myapp:1.0.0 (alpine 3.19)
============================
Total: 3 (UNKNOWN: 0, LOW: 1, MEDIUM: 0, HIGH: 2, CRITICAL: 0)
┌─────────┬───────────────┬──────────┬─────────────┐
│ Library │ Vulnerability │ Severity │ Fixed Version │
├─────────┼───────────────┼──────────┼─────────────┤
│ openssl │ CVE-2024-2511 │ HIGH │ 3.1.5-r0 │
└─────────┴───────────────┴──────────┴─────────────┘

# 4. Never bake secrets — pass at runtime
# ❌ NEVER do this:
ENV API_KEY=sk-live-abc123
# ✅ Pass at runtime instead:
$ docker run -e API_KEY="$API_KEY" myapp

# 5. Drop unnecessary Linux capabilities
$ docker run --cap-drop=ALL --cap-add=NET_BIND_SERVICE myapp
👤
USER non-root
Single biggest win. Add it to every Dockerfile.
🔍
Scan in CI
Run Trivy on every PR. Fail the build on HIGH/CRITICAL.
🤐
No secrets in images
Use --env-file, Docker secrets, or AWS Secrets Manager at runtime.
📐
Minimal base images
Alpine, distroless, or scratch. Less surface = fewer CVEs.

🎯 Practice Questions

Q1.
A Dockerfile ends with CMD ["node", "server.js"] and no USER directive. Explain in one sentence what the running container's UID is, and why that's a security problem.
Show Answer
Without a USER directive, the container runs as UID 0 (root) by default. If the Node process is exploited (RCE in a dependency, mounted volume escape), the attacker gets root inside the container — and depending on host setup, can pivot to the host (e.g. via mounted Docker socket, host-shared volumes, or a kernel exploit).

Fix: add RUN addgroup -S app && adduser -S app -G app then USER app in the Dockerfile.
Q2.
Add a Trivy step to a CI pipeline that fails the build on any HIGH or CRITICAL vulnerability in the freshly-built image. Sketch the command.
Q3.
An engineer puts ENV STRIPE_KEY=sk_live_… in the Dockerfile so deploys are simpler. Three things now go wrong — list them.
💡 Think about layer history, image registry, and rotation.
Q4.
What does docker run --read-only --tmpfs /tmp myapp actually prevent? Name one app type where this isn't realistic.
07
Deploying Containerised Apps to AWS
From local Compose to ECR + ECS Fargate (or Elastic Beanstalk for the easy path)

How to explain to students

Once your image is in ECR, AWS gives you several ways to run it: ECS Fargate (serverless containers, you don't manage servers), ECS on EC2 (you manage the EC2 fleet), or Elastic Beanstalk (the simplest — paste your image URI and click deploy). For first projects, Beanstalk's "Docker single-container" platform is the lowest-friction path.

Whichever you pick, the core flow is the same: (1) push image to ECR, (2) describe how to run it (task definition / Beanstalk config), (3) tell AWS to deploy it. Logs go to CloudWatch by default; you'll need an IAM role attached to the running task.

bash — deploy to ECS Fargate
# task-definition.json — tells ECS how to run the container
{
  "family": "myapp",
  "networkMode": "awsvpc",
  "requiresCompatibilities": ["FARGATE"],
  "cpu": "256", "memory": "512",
  "executionRoleArn": "arn:aws:iam::123:role/ecsTaskExecutionRole",
  "containerDefinitions": [{
    "name": "web",
    "image": "123.dkr.ecr.eu-west-1.amazonaws.com/myapp:1.0.0",
    "portMappings": [{ "containerPort": 3000 }],
    "logConfiguration": {
      "logDriver": "awslogs",
      "options": { "awslogs-group": "/ecs/myapp" }
    }
  }]
}

$ aws ecs register-task-definition --cli-input-json file://task-definition.json
$ aws ecs update-service --cluster prod --service myapp --task-definition myapp:5
"deployments": [{ "status": "PRIMARY", "rolloutState": "IN_PROGRESS" }]

Practical: The lazy-but-fine path — Elastic Beanstalk

For a first deploy, Beanstalk's Docker platform is hard to beat. You provide a Dockerrun.aws.json pointing to your ECR image; Beanstalk handles the EC2, load balancer, autoscaling, and CloudWatch logs.

bash — deploy via EB CLI
// Dockerrun.aws.json
{
  "AWSEBDockerrunVersion": "1",
  "Image": {
    "Name": "123.dkr.ecr.eu-west-1.amazonaws.com/myapp:1.0.0",
    "Update": "true"
  },
  "Ports": [{ "ContainerPort": 3000 }]
}

$ eb init -p docker myapp --region eu-west-1
$ eb create myapp-prod --instance-type t3.small
$ eb deploy
Environment update completed successfully.
$ eb open  # opens https://myapp-prod.eu-west-1.elasticbeanstalk.com
ECR ECS Fargate Elastic Beanstalk CloudWatch logs awslogs driver IAM task role

🎯 Practice Questions

Q1.
Compare ECS Fargate vs Elastic Beanstalk for a 2-engineer startup that just learned Docker. Which would you recommend and why?
Show Answer
Beanstalk for the first deploy. It abstracts the EC2, load balancer, and autoscaling away — the team gets HTTPS, logs, and rolling deploys with three CLI commands.

Move to ECS Fargate when you need: multiple containers per service, finer task-role IAM, custom networking, or autoscaling on container metrics. The mental model is heavier (clusters, services, task definitions) but the control is worth it at scale.

Skip self-managed ECS-on-EC2 unless you have specific GPU/CPU constraints — Fargate's per-vCPU pricing is fine for early-stage workloads.
Q2.
Your Fargate task starts, runs for 10 seconds, then ECS keeps replacing it. Where do you look first to debug?
💡 The awslogs driver writes container stdout/stderr where?
Q3.
Why does an ECS task definition specify two IAM roles (executionRoleArn + taskRoleArn)? What's each used for?
Q4.
Your image works locally with docker compose up but crashes in Fargate. List four common reasons (hint: networking, env vars, ports, IAM).
08
Using AI to Write & Debug Dockerfiles
AI as a force multiplier — and the verification workflow that keeps it safe

How to explain to students

Dockerfiles are the perfect task for AI assistance: small files, well-defined patterns, lots of training data. But the same is true for AI's failure modes — it'll happily generate a Dockerfile that runs as root, exposes secrets, or pulls a vulnerable base image. The skill is in the prompt and the verification.

Treat AI output like a pull request from a smart-but-junior engineer: read it line by line, build it, scan it, then ship it.

bash — AI prompt examples
# ❌ Weak prompt → generic, insecure result
"Write a Dockerfile for my Node app"

# ✅ Strong prompt → production-ready
"Write a multi-stage Dockerfile for a TypeScript Node 20 API:
- Build stage compiles src/ to dist/
- Final stage uses node:20-alpine, runs as non-root user
- Only production deps in final image (npm ci --omit=dev)
- HEALTHCHECK that hits /healthz every 30s
- Final image must be under 200 MB
- Include a .dockerignore for node_modules, .git, .env
After the Dockerfile, list what could still go wrong in production."

# Debug-by-AI: paste the build error with full context
ERROR [4/6] RUN npm ci -- exit code: 1
npm ERR! Missing: typescript@5.0.0 from lock file
→ AI prompt: "What does this npm ci error mean inside Docker,
and what's the most common Dockerfile-level cause?"

# Verify the AI's output before merging:
$ docker build -t test . && docker images test --format "{{.Size}}"
$ trivy image test  # scan for CVEs
$ docker run --rm test id  # check it's not root
ChatGPT Claude GitHub Copilot Always scan AI output Verify before shipping

🎯 Practice Questions

Q1.
Take the prompt "write a Dockerfile for my Python app" and rewrite it as a 6-bullet detailed prompt that produces a production-ready, non-root, slim, multi-stage Dockerfile.
Q2.
An AI generates a Dockerfile that uses FROM ubuntu:latest and ends with CMD bash. List three concrete red flags before you run it.
Show Answer
1. :latest tag — non-reproducible. Should be ubuntu:22.04 or similar pinned version.
2. Ubuntu base — heavy (~70 MB before any app). Alpine, distroless, or ubuntu:22.04-slim are slimmer + smaller attack surface.
3. No USER directive — runs as root. No HEALTHCHECK, no .dockerignore mentioned, and CMD bash isn't an application — it's an interactive shell, useless as a service entrypoint.

Fix: pin the base, switch to slim/distroless, add a non-root user, replace the CMD with the actual app's entrypoint.
Q3.
Write a 3-step verification routine you'll run on every AI-generated Dockerfile before merging the PR.
💡 Build, scan, run.
09
Project: Dockerise a Full-Stack App with Compose, Push to ECR
Real-world capstone — Node API + Postgres + Redis, containerised end-to-end

How to explain to students

This pulls the whole module together. Walk through it line by line on screen first, then ask students to recreate it from memory. The artefacts they leave with — a working multi-stage Dockerfile, a Compose stack, an ECR push — are exactly what a junior DevOps role expects.

project — file layout
myapp/
├── Dockerfile ← multi-stage, non-root
├── .dockerignore
├── compose.yaml ← web + db + cache
├── package.json
├── src/
│ └── server.ts
└── deploy.sh ← build + tag + push to ECR
deploy.sh — build + push pipeline
#!/bin/bash
set -euo pipefail

REGION="eu-west-1"
ACCOUNT="123456789012"
REPO="myapp"
TAG=$(git rev-parse --short HEAD)
ECR="$ACCOUNT.dkr.ecr.$REGION.amazonaws.com/$REPO"

echo "→ Building $REPO:$TAG"
docker build -t "$REPO:$TAG" -t "$REPO:latest" .

echo "→ Scanning for HIGH/CRITICAL CVEs"
trivy image --severity HIGH,CRITICAL --exit-code 1 "$REPO:$TAG"

echo "→ Authenticating to ECR"
aws ecr get-login-password --region "$REGION" | \
  docker login --username AWS --password-stdin "$ECR"

docker tag "$REPO:$TAG" "$ECR:$TAG"
docker tag "$REPO:latest" "$ECR:latest"
docker push "$ECR:$TAG"
docker push "$ECR:latest"

echo "✅ Pushed $ECR:$TAG"
🧱
Multi-stage build
Builder + runtime stages — final image under 200 MB.
🩺
Healthcheck inside
Add HEALTHCHECK in the Dockerfile so orchestrators know when it's ready.
🔐
Trivy in the script
Pipeline fails the build if HIGH/CRITICAL CVEs found.
🏷️
Tag with git SHA
Every deploy traceable to a specific commit.
10
Quiz: Docker Concepts + Compose Syntax
5 MCQs + 2 fill-in-the-command questions

Sample quiz questions (interactive)

Q1. Which Dockerfile instruction sets the default command but allows it to be overridden at docker run time?
A
ENTRYPOINT
B
CMD
C
RUN
D
EXEC
Q2. You change a single source file. What's the fastest Dockerfile design that doesn't re-run npm install?
A
COPY . . then RUN npm install
B
COPY package*.json ./ → RUN npm ci → COPY . .
C
Always rebuild from scratch
D
Use --no-cache
Q3. Two Compose services need to talk to each other by name. What's required?
A
Add links: entries
B
Nothing — Compose creates a default network with DNS
C
Hard-code the IPs
D
Use --link on docker run
Q4. Which command pushes a tagged image to AWS ECR?
A
docker upload <ecr-uri>
B
aws ecr push myapp:1.0
C
docker push <account>.dkr.ecr.<region>.amazonaws.com/myapp:1.0
D
aws s3 cp myapp.tar s3://ecr/...
Q5. The single biggest container-security improvement for a typical Node.js Dockerfile is:
A
Use --cap-drop=ALL
B
Add a USER directive (run as non-root)
C
Use a private registry
D
Encrypt the image

Fill-in-the-command

Fill 1: What command builds an image and tags it as myapp:1.0?
Fill 2: What single command brings up a Compose stack in detached mode?
11
Assignment: Optimise an Existing Dockerfile (≥ 50% Smaller)
Take a bloated 1+ GB image and shrink it to under 300 MB without breaking functionality

How to explain to students

Frame as a real on-call task: "Our nightly ECS deploys are slow because our images are 1.4 GB. Cut them in half by Friday — without changing app behaviour." This forces students to apply multi-stage builds, slim base images, layer ordering, and .dockerignore all at once.

📋 Assignment Requirements

  • Start from the provided "naive" Dockerfile (single stage, node:20, COPY . . first)
  • Refactor to a multi-stage build (builder + runtime)
  • Switch to a slim base image (alpine, slim, or distroless) where viable
  • Add a .dockerignore excluding node_modules, .git, .env, tests, and docs
  • Add a USER directive — final container must NOT run as root
  • Add a HEALTHCHECK hitting /healthz
  • Result must be ≥ 50% smaller than the starting image (proven with docker images)
  • Functional test: curl localhost:3000/healthz still returns 200
  • Bonus: Add a Trivy scan to your local build script that fails on HIGH/CRITICAL
  • Bonus: Push the optimised image to a free-tier Docker Hub account, share the link
bash — expected output when grading
$ docker images myapp
REPOSITORY TAG SIZE
myapp before 1.42GB
myapp after 187MB ← 87% reduction ✓

$ docker run --rm myapp:after id
uid=1001(app) gid=1001(app) groups=1001(app)  # non-root ✓

$ docker run --rm -d -p 3000:3000 myapp:after && sleep 2 && curl -s localhost:3000/healthz
{"status":"ok"}  # app still works ✓
📊
Grading rubric
Size reduction: 30pts. Multi-stage: 20pts. Non-root: 15pts. Healthcheck: 10pts. .dockerignore: 10pts. Bonus Trivy: +15.
🎯
Common mistakes
Forgot to copy --from=builder, missed --omit=dev, ran as root, broke healthcheck path.
💡
Stretch challenge
Get the image under 100 MB with distroless or scratch + static binary.