Week 5: Container Registries & Image Scanning

Last week your docker-compose.yml ran an app, database and cache together on your own machine — useful, but nobody else can run what only exists on your laptop. A registry is the missing piece: a place to push a built image so any machine with credentials can pull and run the exact same bytes. This week you'll tag and push images to Docker Hub and AWS ECR, scan them for known vulnerabilities before they ship, and trim their size — the registry habits here are exactly what the CI pipeline you build in Week 6 will automate on every push, and the same ECR repository reappears the moment you provision real AWS infrastructure in Week 8.

Module 4 of 22 Week 5 of 26 ~3–4 Hours Hands-on Exercise Included

By the end of this week, you'll be able to

  • Tag images correctly and push/pull them to and from Docker Hub and AWS ECR
  • Authenticate a Docker client against a registry using tokens instead of a stored password
  • Scan an image for known vulnerabilities and reduce its size with a leaner base and multi-stage build

1. Tagging Conventions & Registries

An image name is really [registry-host/]repository:tag. Leave the registry host off and Docker assumes Docker Hub; leave the tag off and Docker assumes :latest. Neither default is something you want to rely on in a real pipeline — :latest tells you nothing about which commit produced the image, and omitting the registry host silently points you at the wrong place once you start using more than one registry.

terminal
docker.io/adalovelace/orders-api:1.4.2      # Docker Hub, explicit
123456789012.dkr.ecr.us-east-1.amazonaws.com/orders-api:1.4.2  # AWS ECR
ghcr.io/adalovelace/orders-api:1.4.2        # GitHub Container Registry

A common, durable tagging scheme combines a build identifier with a mutable "floating" tag so both precision and convenience are available:

terminal
# Tag the same built image twice: once immutable, once floating
docker build -t orders-api:git-a1b2c3d .
docker tag orders-api:git-a1b2c3d orders-api:latest

# Or bake the git SHA in directly from CI
docker build -t orders-api:$(git rev-parse --short HEAD) .

Docker Hub is the default public registry and the simplest place to start — free public repositories, one private repository on the free tier. AWS ECR (Elastic Container Registry) is what you'll actually deploy from once your workloads run on AWS starting Week 8: it's private by default, integrates with IAM for access control instead of a separate login, and keeps images in the same region as the ECS or EKS workloads that pull them, which avoids cross-region transfer time and cost. Most real projects use both — Docker Hub or GHCR for anything public, ECR for anything that runs in production.

Never deploy off :latest

If a bad deploy uses :latest, rolling back means "whatever the previous :latest happened to be" — which nobody can reconstruct with confidence. Deploy an immutable tag like a git SHA, and reserve :latest for local development convenience only.

2. Authenticating & Pushing/Pulling Images

Pushing to Docker Hub uses a personal access token instead of your account password — Docker Hub deprecated password-based CLI login for exactly the reason you'd expect: a token can be scoped and revoked without touching the account itself.

terminal — Docker Hub
# Generate a token at hub.docker.com > Account Settings > Security first
docker login -u adalovelace
Password: <paste access token, not your account password>

docker tag orders-api:1.4.2 adalovelace/orders-api:1.4.2
docker push adalovelace/orders-api:1.4.2
docker pull adalovelace/orders-api:1.4.2

ECR authentication works differently: there's no long-lived password at all. The AWS CLI mints a short-lived authorization token (valid 12 hours) that you pipe straight into docker login, which is the same mechanism your CI runner will use in Week 6 instead of storing a registry password as a secret.

terminal — AWS ECR
# Create the repository once
aws ecr create-repository --repository-name orders-api --region us-east-1

# Authenticate Docker against ECR using a short-lived token
aws ecr get-login-password --region us-east-1 \
  | docker login --username AWS --password-stdin \
    123456789012.dkr.ecr.us-east-1.amazonaws.com

# Tag with the full ECR repository URI and push
docker tag orders-api:1.4.2 \
  123456789012.dkr.ecr.us-east-1.amazonaws.com/orders-api:1.4.2
docker push 123456789012.dkr.ecr.us-east-1.amazonaws.com/orders-api:1.4.2

docker login writes credentials to ~/.docker/config.json — on a shared or CI machine, prefer a credential helper (docker-credential-ecr-login for ECR) over letting a plaintext token sit in that file longer than the current session needs it.

Why the token expires

An ECR auth token expiring after 12 hours means a leaked token from a compromised laptop or log file is useless the next day without any manual revocation. This is the same tradeoff you'll see again with Terraform's AWS provider and Kubernetes service account tokens — short-lived credentials minted on demand beat long-lived secrets sitting in a config file.

3. Image Vulnerability Scanning

Every base image bundles an operating system's worth of packages, and those packages accumulate published CVEs (Common Vulnerabilities and Exposures) over time. Scanning turns "we hope our base image is fine" into a specific, actionable list of known issues, ranked by severity, before that image ever reaches production. Two tools cover almost every workflow you'll meet: Docker Scout, built into the Docker CLI, and Trivy, an open-source scanner from Aqua Security that's easy to drop into any CI pipeline.

terminal — Docker Scout
docker scout quickview orders-api:1.4.2
docker scout cves orders-api:1.4.2

# Compare against the currently deployed tag to see what a rebuild would fix
docker scout compare orders-api:1.4.2 --to orders-api:1.4.1
terminal — Trivy
# Install: https://aquasecurity.github.io/trivy — or run via the Trivy image itself
trivy image orders-api:1.4.2

# Fail the command (useful as a CI gate) only on HIGH/CRITICAL findings
trivy image --severity HIGH,CRITICAL --exit-code 1 orders-api:1.4.2

# Scan the Dockerfile itself for misconfigurations, separately from the built image
trivy config .

A scan report lists each vulnerable package, its installed version, the fixed version (if one exists), and a severity rating. Most findings resolve by rebuilding against a newer base image tag rather than patching packages by hand — which is why scanning a stale image weekly, not just at build time, matters: the code hasn't changed, but new CVEs get published against the packages already baked into it every day it sits in the registry.

Looking ahead

trivy image --exit-code 1 is designed to be dropped straight into a CI job as a pass/fail gate — that's exactly what you'll wire into the build-test-push pipeline next week, and again as one of the DevSecOps controls in Week 14.

4. Keeping Image Size Down

A smaller image pulls faster, scans faster, and carries a smaller attack surface — fewer installed packages means fewer possible CVEs. Three habits get you most of the way there, and you already met two of them building your Dockerfile in Week 3.

Dockerfile — slim base + multi-stage build
# Stage 1: build with full tooling
FROM node:20 AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

# Stage 2: ship only the runtime, on a slim base
FROM node:20-slim
WORKDIR /app
COPY --from=build /app/dist ./dist
COPY --from=build /app/node_modules ./node_modules
USER node
CMD ["node", "dist/server.js"]

node:20 carries a full Debian userland and build toolchain — fine for compiling, wasteful for running. node:20-slim (or -alpine for an even smaller, musl-based image) strips that down to roughly a tenth of the size. The multi-stage build lets stage 1 use the fat image for compiling while only stage 2's output — the slim image plus your built artifacts — actually gets pushed.

The third habit is a .dockerignore file, which keeps the build context — everything sent to the Docker daemon before a build even starts — from including files that bloat layers or leak secrets into the image:

.dockerignore
node_modules
.git
.env
*.log
dist
coverage
Dockerfile
.dockerignore

Check the real impact with docker images, and confirm a build didn't silently regress with docker history, which prints the size each instruction in the Dockerfile added:

terminal
docker images orders-api
docker history orders-api:1.4.2
Size and security are the same problem

Every package you don't install is a package that can never show up in a CVE scan. Treat "keep it small" and "keep it scanned clean" as one habit, not two separate chores — a slim, multi-stage image is usually most of the way to a clean scan before you even run one.

5. Hands-on Exercise

Hands-on

Push a scanned, slimmed image to two registries

Take last week's Compose app's main service image and put it through a real registry workflow, end to end.

Requirements:

  1. Rewrite the service's Dockerfile as a multi-stage build ending on a -slim or -alpine base image, and add a .dockerignore that excludes node_modules, .git and .env.
  2. Build it tagged with the current git short SHA: docker build -t orders-api:$(git rev-parse --short HEAD) ., then confirm the size drop with docker images against your Week 3 build.
  3. Create a free Docker Hub account (if you don't have one), generate an access token, and push the image as <your-username>/orders-api:<sha>.
  4. Create an AWS ECR repository named orders-api, authenticate with aws ecr get-login-password, and push the same image tagged with the full ECR repository URI.
  5. Run trivy image --severity HIGH,CRITICAL --exit-code 1 against the pushed image and fix at least one finding by bumping the base image tag.
  6. Write down, in a short paragraph, the size difference between your original and slimmed image and one CVE the scan found before the fix.
Hint

If you don't have an AWS account yet, create the free-tier account now — you'll need it again in Week 8 regardless, and ECR's free tier (500 MB/month private storage) is more than enough for this exercise.

6. Knowledge Check

Four quick questions. Expand each to check your answer.

Q1

Why is deploying off an image tagged :latest risky?

:latest is a mutable, floating pointer — it can be reassigned to a different build at any time, and by the time you need to roll back you often can't reconstruct what it previously pointed to. An immutable tag like a git SHA always refers to exactly one build, so rollback means "redeploy this specific known-good tag" instead of guessing.

Q2

Why doesn't ECR use a stored username/password like Docker Hub's older login flow?

ECR relies on aws ecr get-login-password to mint a short-lived token (expiring in 12 hours) tied to your IAM identity, piped directly into docker login. A leaked token is only useful for a few hours and never needs manual revocation, unlike a long-lived password that stays valid until someone notices and rotates it.

Q3

What would happen if you scanned an image once at build time and never again?

New CVEs get published against already-installed package versions constantly, so an image that scanned clean on day one can become vulnerable weeks later even though nothing in it changed. A stale image sitting in a registry needs periodic rescanning, not just a one-time check at build time, to stay trustworthy.

Q4

In a multi-stage Dockerfile, why does only the final stage end up in the pushed image?

Each FROM line starts a fresh, independent stage; earlier stages only persist into later ones through explicit COPY --from=<stage> instructions. So a heavy build stage's compiler and toolchain never get copied forward — only the specific build artifacts you name end up in the final image, which is what keeps it slim.