What You'll Build
You will write a production-grade Dockerfile that applies everything from this module: a multi-stage build for a small final image, cache-friendly instruction ordering, a .dockerignore, a non-root user, a pinned slim base, and correct CMD/ENTRYPOINT — then build, run, and verify it. The result is a lean, secure, reproducible image of the kind you would actually ship, built the right way rather than the naive way.
Using a typical Node.js web service as the example, you will first see a naive single-stage Dockerfile and its problems, then build the optimised production version step by step, measuring the difference in image size and observing the caching behaviour. The skills transfer directly to any language — the principles are universal.
Prerequisites
- Completion of lessons 07–11, or equivalent familiarity with Dockerfiles, multi-stage builds, caching, networking, and volumes.
- Docker installed and working (docker build and docker run).
- A small sample application to containerise (the example uses a Node.js service; any app works).
- A terminal and basic command-line comfort.
- Understanding of layer caching and the CMD/ENTRYPOINT distinction from this module.
Setup & Project Structure
Start with a minimal Node.js web service (a package.json, a server file, and a build step) — or substitute your own app. First, look at a naive single-stage Dockerfile to understand its problems: it copies everything, installs all dependencies including build tools, runs as root, and produces a large image. You will then build the production version that fixes all of this.
Create a .dockerignore alongside the Dockerfile to keep the build context clean, and plan the production Dockerfile: a builder stage that installs dependencies (cache-friendly) and builds, and a final slim stage that copies only the runtime artifacts and runs as a non-root user. Each module concept maps to a part of the Dockerfile.
# Project layout
# .
# ├── package.json
# ├── package-lock.json
# ├── src/ ... # application source
# ├── .dockerignore
# └── Dockerfile
# .dockerignore — keep the build context small and exclude junk/secrets
node_modules
.git
*.log
.env
dist
# NAIVE single-stage Dockerfile (problems: large, build tools shipped, runs as root)
# FROM node:20
# COPY . . # copies everything, busts cache on any change
# RUN npm install # all deps incl. dev/build tools, reinstalled every change
# CMD npm start # shell form; runs as rootStep 1 — Builder Stage with Cache-Friendly Ordering
Write the first stage: a builder using a full node:20 base. Copy only the dependency manifests first and install, then copy the source and run the build. This ordering means editing source code does not reinstall dependencies — the expensive npm ci layer stays cached, applying the caching lesson directly. The builder has the full toolchain because it needs to compile/bundle.
This stage produces the built artifacts (e.g. dist/) and the dependencies. It is deliberately heavy — that is fine, because nothing from it ships except what you explicitly copy in the next stage. The cache-friendly order makes rebuilds after code changes fast, which matters greatly in development and CI.
# ---- Stage 1: builder (full toolchain, cache-friendly order) ----
FROM node:20 AS builder
WORKDIR /app
# Dependency manifests first -> npm ci layer cached unless deps change
COPY package.json package-lock.json ./
RUN npm ci # all deps (incl. build tools)
# Source after deps -> editing code doesn't reinstall dependencies
COPY . .
RUN npm run build && npm prune --omit=dev # build, then drop dev deps for runtimeStep 2 — Lean Final Stage with Non-Root User
Write the final stage from a slim base (node:20-alpine), copying only the runtime artifacts from the builder with COPY --from=builder — the built dist and the production-only node_modules — so none of the build tools, dev dependencies, or source come along. Set a non-root USER, EXPOSE the app's port, and use the exec form for ENTRYPOINT/CMD so signals work.
This stage is the image you ship: a tiny runtime base plus just what is needed to run. Running as the non-root node user improves security, the exec-form command ensures graceful shutdown on docker stop, and EXPOSE documents the port. The contrast with the naive single-stage image — in size and security — is dramatic.
# ---- Stage 2: final runtime (slim, non-root, only what's needed) ----
FROM node:20-alpine
WORKDIR /app
ENV NODE_ENV=production
# Copy ONLY runtime artifacts from the builder (no build tools/dev deps/source)
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./
USER node # non-root for security
EXPOSE 3000 # documents the port (publish with -p at run)
ENTRYPOINT ["node"] # exec form -> receives SIGTERM for graceful stop
CMD ["dist/server.js"] # overridable default argumentStep 3 — Build, Run, and Persist Data
Build the image with a version tag, then run it: published on a port, named, with a restart policy, and — if it has persistent data — a named volume, applying the networking and volumes lessons. Compare the production image's size to a naive build to see the multi-stage payoff. Confirm the service is reachable and that a code change rebuilds quickly thanks to caching.
Verify the security and behaviour: the container runs as non-root, docker stop shuts it down gracefully (exec form), and any data written to the mounted volume persists across container recreation. This brings together image building (multi-stage, caching, security) with running concerns (ports, restart policy, volumes) into a complete production-style workflow.
# Build with a version tag and check the size (compare to a naive single-stage build)
docker build -t myapp:1.0 .
docker images myapp # production image is far smaller than a single-stage one
# Run: published port, named, restart policy, and a volume for persistent data
docker run -d -p 3000:3000 --restart unless-stopped \
--name app -v appdata:/app/data myapp:1.0
curl -s -o /dev/null -w '%{http_code}\n' http://localhost:3000 # expect 200
docker exec app whoami # confirm it runs as non-root (node)
docker stop app # graceful (exec form receives SIGTERM)
# Edit source, rebuild -> fast, because npm ci stays cached (caching lesson)
docker build -t myapp:1.1 .Step 4 — Testing & Verification
Confirm the production image meets the bar: it is small (multi-stage), built with cache-friendly ordering (a code change rebuilds in seconds without reinstalling dependencies), runs as a non-root user, shuts down gracefully on docker stop, and persists data in its mounted volume across recreation. Compare its size against a naive single-stage build to quantify the improvement, and verify the .dockerignore kept junk out of the context.
# Verification checklist
docker images myapp # small image (vs a single-stage equivalent)
docker history myapp:1.0 # inspect the layers (only runtime artifacts)
docker exec app whoami # 'node' (non-root) ✓
docker inspect -f '{{.Config.User}}' app # confirms non-root user
# Caching: rebuild after a source-only change is fast (deps not reinstalled)
touch src/index.js && time docker build -t myapp:test . # npm ci layer = CACHED
# Persistence: data in the volume survives container recreation
docker rm -f app
docker run -d -p 3000:3000 --name app -v appdata:/app/data myapp:1.0 # data intactWarning: A frequent mistake is copying too much from the builder stage (e.g. COPY --from=builder /app ./) which drags source, caches, and dev dependencies into the final image, defeating the multi-stage size and security benefits. Copy only the specific runtime artifacts (dist, production node_modules, package.json). Equally, don't forget the .dockerignore — without it, node_modules and .git can bloat the build context and even sneak into the image.
Extension Challenge: Add a HEALTHCHECK instruction so Docker reports the container's health, and reduce the image further by trying a smaller base (or distroless) for the final stage. Then scan your image for vulnerabilities (docker scout or trivy — previewing the security module), add build metadata with LABEL, and use BuildKit cache mounts (RUN --mount=type=cache) to speed dependency installs further. Compare the final image size against your first naive attempt to quantify the total improvement.
- Use a multi-stage build: a heavy builder stage (full toolchain) and a lean final stage with only runtime artifacts via COPY --from.
- Order for caching: copy dependency manifests and install before copying source, so code changes don't reinstall dependencies.
- Add a .dockerignore (node_modules, .git, .env) to keep the build context small and exclude secrets/junk.
- Run as a non-root USER and use the exec form for ENTRYPOINT/CMD so the process receives SIGTERM for graceful stops.
- Pin a small base (alpine/slim) and copy only what the runtime needs; the production image is far smaller than a naive single-stage build.
- Run with a published port, restart policy, and a named volume for persistent data — combining image building with running concerns.