DevOps · 35 Days · Week 4 Day 17 — Writing Dockerfiles
1 / 22
Week 4 · Day 17

Writing Dockerfiles

From a slow, bloated, insecure Dockerfile to a lean, cached, hardened production image — layer by layer. The golden rule, .dockerignore, non-root users, distroless, and docker scout.

⏱ Duration 60 min
📖 Theory 25 min
🔧 Lab 30 min
❓ Quiz 5 min
❌ Before: 1.1 GB · slow · root user ✅ After: 80 MB · cached · non-root
Session Overview

What we cover today

01
The Bad Dockerfile — what's wrong
COPY . . before npm install, node:latest, root user, no .dockerignore. Every code change = full reinstall.
02
The Golden Rule — layer cache order
Put least-changing instructions first. Copy package.json → install deps → copy code. Cache the expensive layer.
03
.dockerignore — what not to send
Exclude node_modules, .git, .env, coverage/. Speeds up build context. Prevents secrets leaking into images.
04
Security hardening
Non-root user. --chown. Read-only filesystem. No new privileges. These are AKS production requirements.
05
Base image choices
ubuntu vs debian vs alpine vs distroless. Size and attack surface comparison.
06
docker scout — vulnerability scanning
Scan your image for CVEs. See which base image has fewest vulnerabilities. Fix before pushing.
07
🔧 Lab — Bad → Good Dockerfile
Build the bad Dockerfile. Measure time + size. Optimize step by step. Compare results.
Part 1 — What goes wrong

The Bad Dockerfile — every mistake in one file

❌ BAD — don't do this
# Every problem annotated

FROM node:20                 ← Problem 1: full image (1.1 GB!)
                                 should be node:20-alpine (170 MB)

FROM node:latest             ← Problem 2: floating tag
                                 breaks builds unpredictably

WORKDIR /app

COPY . .                     ← Problem 3: copies EVERYTHING first
                                 including node_modules, .git, .env
                                 invalidates ALL layers on code change

RUN npm install              ← Problem 4: not npm ci
                                 non-deterministic, slower
                                 re-runs on EVERY code change
                                 because COPY . . is above it!

EXPOSE 3000

  # No USER instruction → runs as root
                             ← Problem 5: root user in production
                                 container breakout = host root

  # No .dockerignore → sends everything
                             ← Problem 6: node_modules (200 MB!)
                                 sent as build context every time
                                 .env secrets included in image!

CMD ["node", "src/index.js"]  ← Problem 7: shell form CMD
CMD node src/index.js           SIGTERM ignored → 10s delay on stop
The cost of every mistake
Image size1.1 GB
Build time (cold)90+ seconds
Build time (code change)90+ seconds (no cache!)
SecurityRunning as root
Secrets.env baked into image
Build context200 MB (inc. node_modules)
What we'll achieve by end of lab
Image size~80 MB
Build time (cold)30 seconds
Build time (code change)3 seconds (cache hit!)
SecurityNon-root user
SecretsNever baked in
Build context~5 KB
Part 2 — Layer Cache

The Golden Rule — least-changing instructions first

❌ WRONG ORDER — cache busted every time
FROM node:20-alpine
WORKDIR /app
COPY . .               ← copies ALL files (src/ + package.json)
RUN npm ci             ← below COPY . . → invalidated when ANY file changes
CMD ["node", "src/index.js"]

# You change ONE line in src/index.js:
# → COPY . . layer invalid
# → npm ci runs again (45 seconds!)
# → Every. Single. Build.
✅ CORRECT ORDER — cache preserved
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./  ← ONLY package files (changes rarely)
RUN npm ci             ← directly below package.json COPY
                           cached unless package.json changes!
COPY src/ ./src/       ← source code (changes often) goes LAST
CMD ["node", "src/index.js"]

# You change ONE line in src/index.js:
# → COPY package*.json layer: CACHED ✓
# → RUN npm ci layer: CACHED ✓ (skipped!)
# → COPY src/ layer: rebuilt (tiny, 2 sec)
# Total: ~3 seconds instead of 45 seconds
The Golden Rule
Put instructions that change LESS FREQUENTLY higher in the Dockerfile. Put instructions that change MORE FREQUENTLY lower.

Docker rebuilds every layer from the first changed layer downward. A change at Layer 2 invalidates Layers 3, 4, 5... A change at Layer 4 only rebuilds Layer 4 onward.
Change frequency pyramid
FROM node:20-alpine    ← never   (base image)
WORKDIR /app           ← never   (metadata)
COPY package*.json ./  ← rarely  (dep changes)
RUN npm ci             ← rarely  (same)
COPY src/ ./src/       ← always  (code changes)
CMD [...]              ← rarely

Invert this order and you get the worst possible performance — every code change triggers a full npm install.

💡 The 10× speedup calculation
45 sec (npm ci) saved per build.
10 devs × 20 pushes/day = 200 builds/day.
200 × 45 sec = 150 minutes saved daily.
One Dockerfile ordering decision. Huge ROI.
Part 3 — Build Context

.dockerignore — what not to send to the daemon

What is the build context?
When you run docker build ., the . means "send the entire current directory to the Docker daemon." This is called the build context.

The daemon receives this as a tar archive before it even looks at the Dockerfile. Every file in that directory — including node_modules, .git, .env, coverage/ — gets sent.

Without .dockerignore: sending 200 MB+ on every build.
With .dockerignore: sending 5 KB.
.dockerignore
# Dependencies — never copy from host
node_modules           ← 200 MB, rebuilt inside image anyway

# Version control
.git                   ← entire git history, large
.gitignore

# Secrets — CRITICAL: never bake into image
.env                   ← DB passwords, API keys
.env.*                 ← .env.local, .env.production
*.pem                  ← SSL certificates
secrets/

# Test + CI artifacts
coverage/              ← test coverage reports
*.test.js              ← test files (not needed in prod)
.github/               ← CI workflow files
jest.config.js         ← test config

# Build artifacts
dist/                  ← if building inside Docker
build/

# Documentation
*.md
docs/

# OS files
.DS_Store              ← macOS metadata
Thumbs.db              ← Windows
*.log
⚠ The .env in image = security disaster

What happens when you forget .dockerignore and COPY . .?

# Your .env file:
DATABASE_URL=postgres://admin:S3cr3tP@ss@prod-db
STRIPE_SECRET_KEY=sk_live_abc123
JWT_SECRET=my-super-secret-key

# After docker build + docker push:
docker run myapp env
# DATABASE_URL=postgres://admin:S3cr3tP@ss@prod-db ← exposed!
# STRIPE_SECRET_KEY=sk_live_abc123 ← exposed!

# Anyone who pulls your image gets your secrets
docker history myapp:latest
# Layer: COPY . .  ← .env is in there

This happens in real companies. Secrets committed to images, pushed to public registries. Attackers scan Docker Hub for this.

💡 Verify your build context size
docker build . 2>&1 | head -5
Output shows: Sending build context to Docker daemon 5.12kB

If you see MB — your .dockerignore is missing or incomplete.
Part 4 — Security

Security hardening — production-ready containers

✅ Hardened Dockerfile
# Stage 1: Install dependencies
FROM node:20-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production   ← prod deps only, no jest/eslint

# Stage 2: Runtime — minimal and secure
FROM node:20-alpine AS runtime
WORKDIR /app

# === SECURITY: Create non-root user ===
RUN addgroup -S appgroup && \
    adduser  -S appuser -G appgroup
# -S = system user (no password, no home dir)
# -G = add to group

# Copy files with correct ownership
COPY --from=deps \
     --chown=appuser:appgroup \
     /app/node_modules ./node_modules

COPY --chown=appuser:appgroup src/ ./src/

# Switch to non-root user BEFORE CMD
USER appuser

EXPOSE 3000

# === EXEC FORM — proper signal handling ===
CMD ["node", "src/index.js"]
# NOT: CMD node src/index.js (shell form)
# Shell form: node runs as child of /bin/sh
# SIGTERM goes to sh, not node → 10s wait
# Exec form: node IS PID 1 → SIGTERM works

# === Runtime security (docker run flags) ===
# docker run --read-only \            # read-only fs
#            --security-opt no-new-privileges \
#            --cap-drop ALL \           # drop all capabilities
#            myapp:latest
Why non-root user matters

Scenario: your Node.js app has an RCE (Remote Code Execution) vulnerability. An attacker exploits it and gets a shell inside your container.

Without USER instruction (root):

  • Attacker has root inside container
  • Can read /etc/shadow
  • Can install attack tools
  • If container escape → root on host

With USER appuser:

  • Attacker has limited UID 1001 access
  • Cannot install system packages
  • Cannot read sensitive files
  • Blast radius is contained
CMD exec form vs shell form
❌ Shell form:
CMD node src/index.js
# Runs as: /bin/sh -c "node src/index.js"
# PID 1 = sh, node is a child
# docker stop → SIGTERM → sh
# sh doesn't forward to node
# waits 10 sec → SIGKILL

✅ Exec form:
CMD ["node", "src/index.js"]
# Runs as: node src/index.js (directly)
# PID 1 = node
# docker stop → SIGTERM → node handles it
# graceful shutdown immediately
Part 5 — Base Images

Choosing a base image — size vs security trade-offs

FROM node:20
Full Debian Bullseye + Node
~1.1 GB
~900 CVEs
FROM node:20-slim
Minimal Debian + Node. No gcc, build tools
~250 MB
~300 CVEs
FROM node:20-alpine ⭐
Alpine Linux (musl libc) + Node. Most popular.
~170 MB
~10 CVEs
FROM gcr.io/distroless/nodejs20 ⭐⭐
No shell. No apt. No package manager. Node only.
~110 MB
~0 CVEs
FROM scratch (Go binaries)
Empty. Statically compiled apps only. Not for Node.
0 MB base
0 CVEs
What is distroless?
Distroless images (by Google) contain only the runtime — Node.js, the CA certs, and your app. Nothing else.

No shell (/bin/sh doesn't exist).
No package manager (apt or apk doesn't exist).
No utilities (ls, ps, curl don't exist).

If an attacker gets RCE — they have a Node.js process with no tools to escalate. They can't install malware, they can't look around the filesystem, they can't do anything useful.
⚠ Distroless debugging
You cannot docker exec -it container sh into a distroless container — there's no shell. Use the :debug variant for troubleshooting: gcr.io/distroless/nodejs20:debug (has a minimal busybox shell).
💡 Recommendation by use case
Dev/test: node:20-alpine (easy debugging)
Production: node:20-alpine or distroless
Security-critical: distroless
Go/Rust static binary: scratch
Part 6 — Vulnerability Scanning

docker scout — scan your image for CVEs before pushing

docker scout commands
# Scan your local image for vulnerabilities
docker scout cves my-devops-app:local

# Example output:
## Overview
#    Analyzed image: my-devops-app:local
#       Digest: sha256:abc123...
#    Packages: 67
# Vulnerabilities:
#    0C  0H  2M  5L
#    ↑   ↑   ↑   ↑
#  Crit High Med Low
#
# Vulnerability details:
# ✗ MEDIUM CVE-2023-12345
#   openssl 3.1.1
#   Fixed version: 3.1.2
#   Upgrade base image to fix

# Quick summary (just the numbers)
docker scout quickview my-devops-app:local

# Compare your image vs a newer base image
docker scout compare \
  --to node:20-alpine \
  my-devops-app:local

# Get fix recommendations
docker scout recommendations my-devops-app:local

# Scan a remote image (from registry)
docker scout cves ghcr.io/user/myapp:latest

# === Alternative: Trivy (open source) ===
trivy image my-devops-app:local
trivy image --severity HIGH,CRITICAL my-devops-app:local
trivy image --exit-code 1 --severity HIGH my-devops-app:local
# exit-code 1 = fail CI if HIGH vulns found
CVE severity levels
CRITICALCVSS 9.0–10.0. Remote exploit, no auth. Block deploy immediately.
HIGHCVSS 7.0–8.9. Serious risk. Fix within days.
MEDIUMCVSS 4.0–6.9. Fix when possible.
LOWCVSS 0.1–3.9. Low risk, track and fix.

CI gate: fail on CRITICAL and HIGH. Don't block on MEDIUM/LOW — too noisy, progress not ship.

Why base image choice = CVE count
Most CVEs in your container come from the base image's OS packages — not your code. node:20 includes the full Debian OS (hundreds of packages). node:20-alpine includes Alpine (fewer packages, musl libc). distroless has no OS utilities at all.

Switching from node:20 to node:20-alpine often eliminates 90% of CVEs instantly.
💡 Add Trivy to your CI pipeline
After docker build, before docker push — scan and fail if CRITICAL:
trivy image --exit-code 1 --severity CRITICAL myapp:latest
Hands-On Lab

🔧 Bad → Good Dockerfile

Build the bad version first. Measure time and size. Fix step by step. See the difference with your own eyes.

⏱ 30 minutes
Docker Desktop running ✓
my-devops-app ✓
🔧 Lab — Steps

Optimize a Dockerfile from scratch

1
Build the bad Dockerfile — measure the baseline
Create Dockerfile.bad with all the mistakes. Run time docker build -t app:bad -f Dockerfile.bad . Record the time and size. Change one line of code. Build again — see the cache fail.
2
Fix layer order — measure the cache win
Move COPY package*.json before RUN npm ci, then COPY src/ last. Build, change code, build again. npm ci must show CACHED.
3
Add .dockerignore — measure context size
Create .dockerignore with node_modules, .git, .env, coverage. Compare "Sending build context" line before/after: 200 MB → 5 KB.
4
Switch to alpine — measure size reduction
Change FROM node:20 to FROM node:20-alpine. Build. docker images — compare: 1.1 GB → 170 MB.
5
Add non-root user — verify with docker exec
Add addgroup + adduser + USER appuser. Build and run. docker exec container whoami must return appuser, not root.
6
Scan with docker scout — compare CVEs
docker scout cves app:bad vs docker scout cves app:good. See CVE count drop dramatically with alpine base.
🔧 Lab — Complete Code

Bad vs Good Dockerfile

❌ Dockerfile.bad
FROM node:20
WORKDIR /app
COPY . .
RUN npm install
EXPOSE 3000
CMD node src/index.js

# Build and measure:
# time docker build -t app:bad -f Dockerfile.bad .
# docker images app:bad
# → SIZE: ~1.1 GB
# → Build time: ~90 seconds
#
# Change src/index.js, rebuild:
# → npm install runs AGAIN (no cache!)
# → Still 90 seconds
.dockerignore
node_modules
.git
.env
.env.*
coverage
*.test.js
.github
jest.config.js
*.md
.DS_Store
*.log
✅ Dockerfile.good
# Stage 1: Install dependencies
FROM node:20-alpine AS deps
WORKDIR /app
COPY package*.json ./          ← step 1: package files
RUN npm ci --only=production   ← step 2: install (cached!)

# Stage 2: Lean runtime image
FROM node:20-alpine AS runtime
WORKDIR /app

# Non-root user
RUN addgroup -S appgroup && \
    adduser  -S appuser -G appgroup

# Copy from builder stage with ownership
COPY --from=deps \
     --chown=appuser:appgroup \
     /app/node_modules ./node_modules

COPY --chown=appuser:appgroup src/ ./src/

USER appuser
EXPOSE 3000
CMD ["node", "src/index.js"]     ← exec form!

# Build and measure:
# time docker build -t app:good -f Dockerfile.good .
# docker images app:good
# → SIZE: ~80 MB (14× smaller!)
# → Build time cold: ~30 seconds
#
# Change src/index.js, rebuild:
# → COPY package*.json: CACHED ✓
# → RUN npm ci: CACHED ✓
# → COPY src/: rebuilds (2 sec)
# → Total: ~3 seconds!
#
# Verify non-root:
# docker run -d --name test app:good
# docker exec test whoami → appuser
Knowledge Check

Quiz Time

3 questions · 5 minutes · layer cache, .dockerignore, base images

QUESTION 1 OF 3
Why should you COPY package*.json ./ before COPY . . in a Node.js Dockerfile?
A
It is required by npm to find the package file
B
It creates a separate cached layer for dependencies — npm ci only re-runs when package.json changes, not on every code change
C
It makes the final image smaller
D
It speeds up npm install itself by pre-loading the registry
QUESTION 2 OF 3
What does a .dockerignore file do?
A
Ignores Docker-related warnings during build
B
Excludes files from the build context sent to the Docker daemon — preventing large or secret files from being included
C
Hides secrets from docker inspect output
D
Prevents the container from accessing certain files at runtime
QUESTION 3 OF 3
Which base image provides the smallest and most secure production container for a Node.js app?
A
FROM ubuntu:latest
B
FROM node:20
C
FROM node:20-alpine or gcr.io/distroless/nodejs20
D
FROM centos:7
Day 17 — Complete

What you learned today

Layer Cache
Copy package.json first, code last. Cache the expensive npm install layer. 90s → 3s.
🚫
.dockerignore
Exclude node_modules, .git, .env. 200 MB context → 5 KB. Secrets never baked in.
🔒
Non-root
addgroup + adduser + USER. docker exec whoami → appuser not root. Blast radius contained.
🏋
Alpine / Distroless
node:20 (1.1 GB, 900 CVEs) → alpine (170 MB, 10 CVEs) → distroless (110 MB, 0 CVEs).
Day 17 — The complete optimised Dockerfile
FROM node:20-alpine AS deps → COPY package*.json → RUN npm ci --only=production → FROM node:20-alpine AS runtime → adduser/addgroup → COPY --from=deps --chown → USER appuser → CMD ["node", ...]

Commit: docker: optimise Dockerfile for cache and security
Tomorrow — Day 18
Docker Compose

One command spins up your entire stack: Node.js app + PostgreSQL + Redis. Service discovery by name. Health checks. Volumes for data persistence.
docker-compose.yml depends_on healthcheck service discovery
📌 Reference

The production-ready Dockerfile — every best practice applied

✅ Production Dockerfile — complete
# ════════════════════════════════════════
# Stage 1: Install production dependencies
# ════════════════════════════════════════
FROM node:20-alpine AS deps
WORKDIR /app

# Copy ONLY package files (cache this layer)
COPY package*.json ./

# Install prod deps only — no jest, eslint, etc.
RUN npm ci --only=production && \
    npm cache clean --force     ← clean cache inside layer

# ════════════════════════════════════════
# Stage 2: Lean, secure runtime image
# ════════════════════════════════════════
FROM node:20-alpine AS runtime

# Metadata
LABEL org.opencontainers.image.source="https://github.com/user/repo"
LABEL org.opencontainers.image.version="1.0.0"

WORKDIR /app

# Security: create non-root user
RUN addgroup -S appgroup && \
    adduser  -S appuser -G appgroup

# Copy deps from stage 1 with correct ownership
COPY --from=deps \
     --chown=appuser:appgroup \
     /app/node_modules ./node_modules

# Copy source code with correct ownership
COPY --chown=appuser:appgroup src/ ./src/

# Switch to non-root BEFORE everything runtime
USER appuser

EXPOSE 3000

# Health check — Kubernetes uses this
HEALTHCHECK --interval=30s \
            --timeout=5s \
            --start-period=10s \
            --retries=3 \
  CMD wget -qO- http://localhost:3000/health || exit 1

# Exec form — proper signal handling
CMD ["node", "src/index.js"]
Every decision explained
  • node:20-alpine — specific tag (not latest), alpine (small)
  • AS deps / AS runtime — multi-stage keeps build tools out of prod
  • COPY package*.json ./ before code — cache the install layer
  • npm ci --only=production — exact versions, no devDeps
  • npm cache clean --force — no leftover cache in layer
  • LABEL — traceability, links image back to source
  • addgroup + adduser — non-root user
  • --chown=appuser:appgroup — files owned by app user
  • USER appuser — switch before CMD
  • HEALTHCHECK — Docker/K8s knows if app is actually ready
  • CMD ["node", ...] — exec form, SIGTERM works
📌 Understanding cache

Layer cache — visualising what rebuilds when

Dockerfile instruction Changes when? Cache scenario: code change Cache scenario: dep change
FROM node:20-alpineWhen you update Node.js version✅ CACHED✅ CACHED
WORKDIR /appAlmost never✅ CACHED✅ CACHED
COPY package*.json ./When deps change✅ CACHED❌ REBUILDS
RUN npm ciWhen layer above changes✅ CACHED (45s saved!)❌ REBUILDS (expected)
COPY src/ ./src/Every code change❌ REBUILDS (tiny, 1s)❌ REBUILDS
CMD [...]Only if CMD changes✅ CACHED✅ CACHED
💡 Watch the cache live
docker build -t app:test .
You'll see each step marked as either:
[+] Building: CACHED — layer reused
[+] Building: Done — layer rebuilt
⚠ Force a full rebuild when needed
docker build --no-cache -t app:latest .
Use this when: base image has security updates, you suspect stale cache, debugging build issues. Not for normal development — defeats the purpose of caching.
📌 Common Mistakes

Dockerfile anti-patterns — what to avoid

❌ Anti-patterns with fixes
# 1. Floating base image tag
FROM node:latest             ← breaks unexpectedly
FROM node:20.11.0-alpine     ← pin exact version

# 2. Secrets in ENV
ENV DB_PASSWORD=s3cr3t       ← baked into image!
# Pass at runtime: docker run -e DB_PASSWORD=s3cr3t

# 3. Multiple RUN commands = multiple layers
RUN apt-get update
RUN apt-get install -y curl
RUN apt-get install -y wget
RUN rm -rf /var/lib/apt/lists/*
← 4 layers, 3 have leftover cache
RUN apt-get update && \
    apt-get install -y curl wget && \
    rm -rf /var/lib/apt/lists/*
← 1 layer, cache cleaned in same RUN

# 4. COPY . . at wrong position
COPY . .
RUN npm ci                   ← kills cache on every change
COPY package*.json ./
RUN npm ci
COPY . .                    ← npm ci cached separately

# 5. Running as root
# (no USER instruction)
RUN adduser -S appuser
USER appuser

# 6. Shell form CMD
CMD node app.js
CMD ["node", "app.js"]

# 7. COPY * instead of specific paths
COPY * /app/                 ← copies everything
COPY src/ /app/src/          ← only what's needed
The RUN layer consolidation rule

Each RUN command = one layer. If you clean up in a separate RUN, the original layer still contains the uncleaned data.

RUN apt-get update            ← Layer A: 30 MB
RUN apt-get install -y build-essential ← Layer B: 100 MB
RUN rm -rf /var/lib/apt/lists/* ← Layer C: 0 MB
Total image size: 130 MB (Layer A+B never shrinks!)

RUN apt-get update && \
    apt-get install -y build-essential && \
    rm -rf /var/lib/apt/lists/*  ← Single layer: 70 MB
Total: 70 MB (cleanup in same layer!)
💡 Never store secrets in layers
Even if you delete a secret in a later RUN command, docker history can reveal it. Use BuildKit secrets for sensitive build-time data:
RUN --mount=type=secret,id=npmrc \
npm ci
Week 4 Progress

Week 4 — 2 of 5 days complete

Day Topic Key Skills Status
Day 16Docker FundamentalsNamespaces, cgroups, OverlayFS, architecture
Day 17 ← TODAYWriting DockerfilesLayer cache, .dockerignore, non-root, distroless
Day 18Docker ComposeMulti-container stacks, health checks, networkingTomorrow
Day 19Container NetworkingBridge, host, overlay, DNS resolutionThu
Day 20Container SecurityTrivy, rootless, signing, SeccompFri
The before/after summary of your Dockerfile
Image size1.1 GB→ 80 MB
Rebuild on code change90 seconds→ 3 seconds
Build context200 MB→ 5 KB
Running userroot→ appuser
CVE count~900→ ~10
Best practices checklist
  • ✅ Pinned base image tag (not latest)
  • ✅ Alpine base image
  • ✅ package.json COPY before code COPY
  • ✅ npm ci --only=production
  • ✅ .dockerignore present
  • ✅ Non-root USER before CMD
  • ✅ --chown on COPY instructions
  • ✅ CMD in exec form ["node", ...]
  • ✅ HEALTHCHECK defined
  • ✅ docker scout cves passing
📌 Troubleshooting

Common Dockerfile issues & fixes

Problem Cause Fix
npm ci always re-runs (no cache)COPY . . is above COPY package*.jsonMove COPY package*.json ./ and RUN npm ci BEFORE COPY . .
"Permission denied" when app writes filesFiles owned by root, container runs as appuserUse COPY --chown=appuser:appgroup or RUN chown -R appuser /app
Container won't stop (10 sec delay)Shell form CMD — SIGTERM not reaching appChange to exec form: CMD ["node", "app.js"]
Image is unexpectedly largenode_modules in build context or wrong baseAdd node_modules to .dockerignore. Use alpine. Check docker history to see which layer is large.
Build context is huge (200 MB+).dockerignore missingCreate .dockerignore with node_modules, .git, coverage. Check context size in first build output line.
whoami shows root instead of appuserUSER instruction missing or after CMDAdd USER appuser BEFORE CMD. Verify with docker exec container whoami.
Module not found in production containerUsed --only=production but dep is in devDependenciesMove the dependency from devDependencies to dependencies in package.json.
docker scout not installedOlder Docker versionUpdate Docker Desktop to 4.17+. Alternative: trivy image myapp:latest
📌 Day 17 Quick Reference

Dockerfile best practices — at a glance

✅ The 10 rules
# 1. Pin base image tag — never :latest
FROM node:20-alpine

# 2. Copy package files first (cache!)
COPY package*.json ./
RUN npm ci --only=production

# 3. Copy source code after deps
COPY src/ ./src/

# 4. Add .dockerignore (node_modules/.git/.env)

# 5. Create non-root user
RUN addgroup -S app && adduser -S app -G app

# 6. chown files when copying
COPY --chown=app:app src/ ./src/

# 7. Switch user before CMD
USER app

# 8. Use exec form CMD
CMD ["node", "src/index.js"]

# 9. Add HEALTHCHECK
HEALTHCHECK CMD wget -qO- localhost:3000/health

# 10. Scan before pushing
docker scout cves myapp:latest
Useful build commands
# Build and tag
docker build -t app:v1.0 .
docker build -f Dockerfile.prod -t app:prod .

# Check image size
docker images app
docker system df

# See all layers + sizes
docker history app:v1.0
docker history --no-trunc app:v1.0

# Measure build time
time docker build -t app:test .

# Force rebuild (no cache)
docker build --no-cache -t app:fresh .

# Check running user
docker run app:v1.0 whoami
docker exec container whoami

# Verify build context size
# First line of docker build output:
# [+] Building → "Sending build context..."

# Scan for vulnerabilities
docker scout cves app:v1.0
docker scout quickview app:v1.0
trivy image app:v1.0
💡 Day 17 commit message
docker: optimise Dockerfile — alpine, non-root, cache