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.
# 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
| Image size | 1.1 GB |
| Build time (cold) | 90+ seconds |
| Build time (code change) | 90+ seconds (no cache!) |
| Security | Running as root |
| Secrets | .env baked into image |
| Build context | 200 MB (inc. node_modules) |
| Image size | ~80 MB |
| Build time (cold) | 30 seconds |
| Build time (code change) | 3 seconds (cache hit!) |
| Security | Non-root user |
| Secrets | Never baked in |
| Build context | ~5 KB |
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.
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
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.
docker build ., the . means "send the entire current directory to the Docker daemon." This is called the build context.node_modules, .git, .env, coverage/ — gets sent..dockerignore: sending 200 MB+ on every build..dockerignore: sending 5 KB.
# 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
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.
docker build . 2>&1 | head -5Sending build context to Docker daemon 5.12kB# 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
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):
/etc/shadowWith USER appuser:
❌ 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
/bin/sh doesn't exist).apt or apk doesn't exist).ls, ps, curl don't exist).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).
node:20-alpine (easy debugging)node:20-alpine or distrolessdistrolessscratch
# 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
| CRITICAL | CVSS 9.0–10.0. Remote exploit, no auth. Block deploy immediately. |
| HIGH | CVSS 7.0–8.9. Serious risk. Fix within days. |
| MEDIUM | CVSS 4.0–6.9. Fix when possible. |
| LOW | CVSS 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.
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.node:20 to node:20-alpine often eliminates 90% of CVEs instantly.
trivy image --exit-code 1 --severity CRITICAL myapp:latest
Build the bad version first. Measure time and size. Fix step by step. See the difference with your own eyes.
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.COPY package*.json before RUN npm ci, then COPY src/ last. Build, change code, build again. npm ci must show CACHED..dockerignore with node_modules, .git, .env, coverage. Compare "Sending build context" line before/after: 200 MB → 5 KB.FROM node:20 to FROM node:20-alpine. Build. docker images — compare: 1.1 GB → 170 MB.addgroup + adduser + USER appuser. Build and run. docker exec container whoami must return appuser, not root.docker scout cves app:bad vs docker scout cves app:good. See CVE count drop dramatically with alpine base.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
node_modules .git .env .env.* coverage *.test.js .github jest.config.js *.md .DS_Store *.log
# 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
3 questions · 5 minutes · layer cache, .dockerignore, base images
COPY package*.json ./ before COPY . . in a Node.js Dockerfile?.dockerignore file do?FROM ubuntu:latestFROM node:20FROM node:20-alpine or gcr.io/distroless/nodejs20FROM centos:7FROM 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", ...]docker: optimise Dockerfile for cache and security
# ════════════════════════════════════════ # 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"]
node:20-alpine — specific tag (not latest), alpine (small)AS deps / AS runtime — multi-stage keeps build tools out of prodCOPY package*.json ./ before code — cache the install layernpm ci --only=production — exact versions, no devDepsnpm cache clean --force — no leftover cache in layerLABEL — traceability, links image back to sourceaddgroup + adduser — non-root user--chown=appuser:appgroup — files owned by app userUSER appuser — switch before CMDHEALTHCHECK — Docker/K8s knows if app is actually readyCMD ["node", ...] — exec form, SIGTERM works| Dockerfile instruction | Changes when? | Cache scenario: code change | Cache scenario: dep change |
|---|---|---|---|
| FROM node:20-alpine | When you update Node.js version | ✅ CACHED | ✅ CACHED |
| WORKDIR /app | Almost never | ✅ CACHED | ✅ CACHED |
| COPY package*.json ./ | When deps change | ✅ CACHED | ❌ REBUILDS |
| RUN npm ci | When 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 |
docker build -t app:test .[+] Building: CACHED — layer reused[+] Building: Done — layer rebuilt
docker build --no-cache -t app:latest .# 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
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!)
docker history can reveal it. Use BuildKit secrets for sensitive build-time data:RUN --mount=type=secret,id=npmrc \ npm ci
| Day | Topic | Key Skills | Status |
|---|---|---|---|
| Day 16 | Docker Fundamentals | Namespaces, cgroups, OverlayFS, architecture | ✅ |
| Day 17 ← TODAY | Writing Dockerfiles | Layer cache, .dockerignore, non-root, distroless | ✅ |
| Day 18 | Docker Compose | Multi-container stacks, health checks, networking | Tomorrow |
| Day 19 | Container Networking | Bridge, host, overlay, DNS resolution | Thu |
| Day 20 | Container Security | Trivy, rootless, signing, Seccomp | Fri |
| Image size | 1.1 GB | → 80 MB |
| Rebuild on code change | 90 seconds | → 3 seconds |
| Build context | 200 MB | → 5 KB |
| Running user | root | → appuser |
| CVE count | ~900 | → ~10 |
| Problem | Cause | Fix |
|---|---|---|
| npm ci always re-runs (no cache) | COPY . . is above COPY package*.json | Move COPY package*.json ./ and RUN npm ci BEFORE COPY . . |
| "Permission denied" when app writes files | Files owned by root, container runs as appuser | Use COPY --chown=appuser:appgroup or RUN chown -R appuser /app |
| Container won't stop (10 sec delay) | Shell form CMD — SIGTERM not reaching app | Change to exec form: CMD ["node", "app.js"] |
| Image is unexpectedly large | node_modules in build context or wrong base | Add node_modules to .dockerignore. Use alpine. Check docker history to see which layer is large. |
| Build context is huge (200 MB+) | .dockerignore missing | Create .dockerignore with node_modules, .git, coverage. Check context size in first build output line. |
| whoami shows root instead of appuser | USER instruction missing or after CMD | Add USER appuser BEFORE CMD. Verify with docker exec container whoami. |
| Module not found in production container | Used --only=production but dep is in devDependencies | Move the dependency from devDependencies to dependencies in package.json. |
| docker scout not installed | Older Docker version | Update Docker Desktop to 4.17+. Alternative: trivy image myapp:latest |
# 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
# 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
docker: optimise Dockerfile — alpine, non-root, cache