100% Free Forever
AI-Powered Learning
Industry Expert Content
Certificates & Badges
Learn At Your Own Pace
Docker & Containers
55 minbeginner

Image Building Practice — Production Dockerfile

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.

Analogy🏏Cricket
🏏 Think of it like cricket: Just as a player's first practice session puts the fundamental skills together in sequence — take guard, play some shots, run between wickets, review — rather than in isolation, this exercise puts the Docker fundamentals together in sequence: pull, run, manage, inspect, clean up. The insight is that fluency comes from rehearsing the basics as a connected flow: the practice session links the skills into real play, exactly as this exercise links the Docker commands into a real workflow.

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.

Analogy🏏Cricket
🏏 Think of it like cricket: starting this practice with official nginx and alpine images is like turning up to nets with the ground's standard-issue kit already laid out — you need install nothing of your own. Just as a coach first confirms the nets are booked and the bowling machine is switched on before a session begins, you verify Docker is working before anything else. Just as a player draws the standard bat and pads from the club store rather than crafting gear from scratch, you pull ready-made images from Docker Hub — nginx as your web-server 'all-rounder', alpine as a tiny, nimble twelfth man. Then, just as a session moves methodically from knocking-in to full-pace deliveries to fitness cool-down, you will pull images, run containers, and manage them through their full lifecycle. The payoff: a friction-free, command-line-only foundation session where every fundamental container move gets rehearsed cleanly.

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.

bash
# 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 root

Step 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.

Analogy🏏Cricket
🏏 Think of it like cricket: Just as the training facility is fully equipped for preparation, with the fitness foundation established before the frequently-changing tactical work, the builder stage is fully equipped, installing stable dependencies before copying the frequently-changing source. The insight is that the preparation environment is heavy and ordered stable-first: the training ground has all the gear and builds fitness before tactics, exactly as the builder stage holds the full toolchain and installs deps before copying source.
dockerfile
# ---- 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 runtime

Step 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.

Analogy🏏Cricket
🏏 Think of it like cricket: Just as the match-day squad carries only honed skills and essential kit onto the field, with proper safety gear and clear roles — nothing from the training ground's heavy equipment, the final stage carries only runtime artifacts, runs as a safe non-root user, and has a clear entrypoint. The insight is that what takes the field is lean, safe, and purposeful: the match squad is stripped to essentials with proper protection, exactly as the final image holds only runtime needs, runs non-root, and starts cleanly.
dockerfile
# ---- 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 argument

Step 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.

Analogy🏏Cricket
🏏 Think of it like cricket: Just as the final test of preparation is the team actually taking the field and performing — fit, safe, and effective under real conditions, the final test of the image is running it: lean, secure, reachable, with its data persisting. The insight is that the build is proven by the run: the prepared team must perform in the match, exactly as the production image must build small, run securely, and persist its data when actually deployed.
bash
# 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.

Analogy🏏Cricket
🏏 Think of it like cricket: this verification step is the post-match review that confirms every part of the game went to plan. Just as a captain checks the scorecard to confirm the total was reached, the wickets fell as expected, and every fielder did his job, you confirm nginx is reachable at localhost:8080, that you observed it via logs, inspect and exec, and that you stopped and restarted it cleanly. Just as a rolling substitution is checked to have swapped a fresh player in without stopping play, you verify the restart-policy version took over and the self-cleaning interactive container left no trace. And just as a diligent groundsman clears the pitch and confirms nothing is left behind before locking up, you stop and remove the web container, prune leftover stopped ones, and check with `docker ps -a` that the field is truly empty. The payoff: proof the full lifecycle worked and the host is left spotless.
bash
# 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 intact

Warning: 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.
Lesson 12 of 35
0% complete