Skip to main content

🐳 Lesson 4: Building Images with Dockerfiles

A Dockerfile is the recipe for your image. In this lesson you'll write clean, modern Dockerfiles — understand every core instruction, exploit layer caching, shrink images with multi-stage builds, and lock them down with today's security best practices.

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Write a Dockerfile using the core instructions and explain the difference between CMD and ENTRYPOINT
  • Order instructions so Docker's layer cache accelerates rebuilds
  • Author a multi-stage build that produces a dramatically smaller production image
  • Apply current security practices — non-root users, pinned bases, no baked-in secrets, and scanning with docker scout

Estimated Time: 45–55 minutes

Prerequisite: Lessons 1–3 (you can already run containers and pull images).

In This Lesson

What a Dockerfile Is

So far you've run images that other people built. Now you'll build your own. A Dockerfile is a plain text file containing an ordered list of instructions that Docker follows to assemble an image — think of it as a recipe. Each instruction runs in sequence, and each one that changes the filesystem produces a new layer. Stack the layers and you have a complete, reproducible image.

You turn a Dockerfile into an image with docker build:

docker build -t myapp:1.0 .

Reading that command right to left: the . is the build context (the folder Docker sends to the builder), -t myapp:1.0 tags the resulting image with a name and version, and docker build is the command itself. Here's typical output from a first build:

Output:

[+] Building 12.3s (10/10) FINISHED                     docker:default
 => [internal] load build definition from Dockerfile        0.0s
 => [internal] load metadata for docker.io/library/node    0.6s
 => [1/5] FROM docker.io/library/node:20-alpine            2.1s
 => [internal] load build context                          0.1s
 => [2/5] WORKDIR /app                                      0.0s
 => [3/5] COPY package*.json ./                            0.0s
 => [4/5] RUN npm ci --omit=dev                            8.7s
 => [5/5] COPY . .                                          0.2s
 => exporting to image                                      0.4s
 => => naming to docker.io/library/myapp:1.0               0.0s

The build context and .dockerignore

The build context is everything in the directory you point at. Docker packages it up and hands it to the builder, so a bloated context (a 500 MB node_modules, your .git history, log files) makes every build slower and can accidentally leak secrets into the image.

The fix is a .dockerignore file — a packing list of what not to send, using the same glob syntax as .gitignore:

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

📖 Definition

Layer: a read-only filesystem diff produced by an instruction. Images are stacks of layers; layers are cached and shared between images, which is what makes Docker fast and space-efficient.

💡 Tip: New project? Run docker init in your app folder. Docker's scaffolder detects your language and generates a sensible Dockerfile, a .dockerignore, and a compose.yaml for you — a great starting point you then refine.

Core Instructions

You'll do 90% of your work with a dozen instructions. Here's each one, what it does, and when to reach for it.

InstructionWhat it does
FROMSets the base image every build starts from. Must be the first instruction (after optional ARGs).
WORKDIRSets the working directory for later instructions (and creates it). Use it instead of RUN cd.
COPYCopies files from the build context into the image. Prefer this over ADD.
ADDLike COPY but also auto-extracts tarballs and fetches URLs — surprising behavior, so avoid unless you need it.
RUNExecutes a command at build time (install packages, compile code). Each RUN is a new layer.
ENVSets an environment variable that persists into the running container.
ARGDeclares a build-time variable (passed with --build-arg). Not available at runtime.
EXPOSEDocuments which port the app listens on. Informational — it does not publish the port.
USERSwitches to a non-root user for subsequent instructions and at runtime.
HEALTHCHECKTells Docker how to test that the container is still healthy.
CMD / ENTRYPOINTDefine what runs when the container starts (see below).

COPY vs ADD

Both copy files in, but ADD has extra magic: it can unpack local tar archives and download remote URLs. That magic causes surprises and cache misses, so the rule of thumb is simple: use COPY for everything, and only reach for ADD when you specifically want tar auto-extraction.

ARG vs ENV

ARG exists only during the build — perfect for choosing a version or toggling a build flavor. ENV is baked into the image and available while the container runs. Never put secrets in either — both are visible in the image's build history.

ARG NODE_VERSION=20            # build-time only
FROM node:${NODE_VERSION}-alpine

ENV NODE_ENV=production        # persists at runtime
ENV PORT=3000

CMD vs ENTRYPOINT — the classic confusion

This trips up nearly everyone, so let's make it crystal clear.

  • ENTRYPOINT sets the executable that always runs — the fixed part of the command.
  • CMD supplies the default arguments — the part a user can easily override at docker run.

If you use only CMD, the whole thing is the default command and anything you type after docker run image replaces it entirely:

CMD ["node", "server.js"]
# docker run myapp            -> node server.js
# docker run myapp npm test   -> npm test   (CMD fully replaced)

Combine ENTRYPOINT + CMD when you have a fixed program with tweakable defaults. Extra docker run arguments are appended after the entrypoint:

ENTRYPOINT ["ping"]
CMD ["localhost"]
# docker run myapp             -> ping localhost
# docker run myapp example.com -> ping example.com   (CMD overridden, ping stays)
⚠️ Note: Always use the exec form — a JSON array like ["node", "server.js"] — not the shell form node server.js. The exec form makes your app PID 1 so it receives SIGTERM and shuts down cleanly on docker stop. The shell form wraps it in /bin/sh -c, which swallows those signals.

HEALTHCHECK

Let Docker know whether your app is actually working, not just running:

HEALTHCHECK --interval=30s --timeout=3s --retries=3 \
  CMD wget --quiet --tries=1 --spider http://localhost:3000/health || exit 1

A container then reports healthy or unhealthy in docker ps, and orchestrators can restart it automatically.

Layer Caching & Order

Every instruction that changes the filesystem becomes a cached layer. On a rebuild, Docker walks the Dockerfile top to bottom and reuses a layer as long as nothing it depends on has changed. The moment one layer's inputs change, that layer and every layer after it must be rebuilt. This single rule drives the most important Dockerfile skill: ordering.

Each Dockerfile instruction produces one stacked, cached image layer One instruction → one cached layer Dockerfile FROM node:20-alpine WORKDIR /app COPY package*.json ./ RUN npm ci --omit=dev COPY . . CMD ["node","server.js"] Image = stack of layers base OS + node runtime /app workdir manifests dependencies app source metadata (CMD)
Figure 1: Each instruction adds one read-only layer. Docker caches every layer and reuses it on the next build until something changes.

⭐ Order matters: copy manifests before source

Your dependencies change rarely; your source code changes constantly. So install dependencies before copying your code. That way an edit to server.js only busts the cheap final layers — the expensive npm ci stays cached. Here is the pattern for Node:

FROM node:20-alpine
WORKDIR /app

# 1. Copy ONLY the dependency manifests first
COPY package*.json ./

# 2. Install deps — this heavy layer is cached until package*.json changes
RUN npm ci --omit=dev

# 3. Copy the source LAST, so code edits don't bust the deps cache
COPY . .

CMD ["node", "server.js"]

The same idea in Python — copy requirements.txt and install before copying the app:

FROM python:3.12-slim
WORKDIR /app

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY . .

CMD ["gunicorn", "--bind", "0.0.0.0:5000", "app:app"]

Watch what caching does on the second build after a code-only change:

Output (rebuild after editing app.py):

[+] Building 0.8s (10/10) FINISHED
 => [1/5] FROM python:3.12-slim            CACHED
 => [2/5] WORKDIR /app                     CACHED
 => [3/5] COPY requirements.txt .          CACHED
 => [4/5] RUN pip install ...              CACHED   <- the 45s step, skipped!
 => [5/5] COPY . .                         0.2s
 => exporting to image                     0.3s

The dependency install — often the slowest step — was reused from cache. Get the ordering backwards (COPY . . before installing) and every code change re-runs the full install.

Shrinking Image Size

Smaller images pull faster, deploy faster, cost less to store, and expose a smaller attack surface. Three habits do most of the work.

1. Choose a lean base image

The base you pick sets your floor. Same app, very different sizes:

Base imageApprox. sizeWhen to use
node:20~1.1 GBRarely — full Debian with build tools
node:20-slim~200 MBGood default; Debian minus the extras
node:20-alpine~130 MBTiny; musl libc (watch native modules)
gcr.io/distroless/nodejs20~110 MBNo shell, no package manager — hardened prod

2. Combine RUN steps and clean caches

Each RUN is its own layer, and files deleted in a later layer still weigh down the earlier one. So chain related commands with && and delete the package cache in the same layer that created it:

# ❌ Three layers; the apt cache is baked in forever
RUN apt-get update
RUN apt-get install -y curl git
# (cache never removed)

# ✅ One layer; cache cleaned before the layer closes
RUN apt-get update && apt-get install -y --no-install-recommends \
        curl git \
    && rm -rf /var/lib/apt/lists/*

3. Speed builds with BuildKit cache mounts

BuildKit is the default builder in modern Docker — the same engine behind docker buildx build. One of its best features is a persistent cache mount: keep a package manager's download cache between builds without baking it into the final image.

# syntax=docker/dockerfile:1
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN --mount=type=cache,target=/root/.cache/pip \
    pip install -r requirements.txt
COPY . .

The pip cache survives across builds (faster installs) but never lands in the image (smaller result). The buildx builder is invoked the same way you already know:

docker buildx build -t myapp:1.0 .
💡 Tip: The combine RUN and lean base tricks help, but the single biggest size win comes from the next section — multi-stage builds.

⭐ Multi-Stage Builds

Here's the big one. Most apps need a pile of tools to build (compilers, dev dependencies, headers) that they don't need to run. Shipping those tools bloats your image and widens its attack surface. Multi-stage builds solve this elegantly: you build in one stage, then copy only the finished artifact into a clean, minimal final stage. It's like cooking in a messy kitchen but serving on a spotless plate.

graph LR subgraph S1["Stage 1: builder (heavy)"] A["FROM node:20 AS builder"] --> B["npm ci (all deps)"] B --> C["npm run build"] C --> D["/app/dist"] end subgraph S2["Stage 2: final (tiny)"] E["FROM nginx:alpine"] --> F["COPY --from=builder /app/dist"] F --> G["Small production image"] end D -->|"COPY --from=builder"| F style A fill:#fecaca,color:#1e293b style E fill:#bbf7d0,color:#1e293b style G fill:#bfdbfe,color:#1e293b

A full worked example — build a React app with Node, then serve the static files from a tiny Nginx image. The node toolchain never reaches production:

# syntax=docker/dockerfile:1

# ---- Stage 1: build ----
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build          # produces /app/dist

# ---- Stage 2: final ----
FROM nginx:1.27-alpine
COPY --from=builder /app/dist /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

The key line is COPY --from=builder: it reaches into the first stage and lifts out only the compiled dist folder. Everything else in the builder — node_modules, source, npm cache — is discarded. The size difference is dramatic:

Multi-stage builds shrink a single-stage 1.1 GB image down to about 40 MB Single-stage vs multi-stage image size Single-stage node:20 + build tools + node_modules + source ≈ 1.1 GB Multi-stage nginx:alpine + static files ≈ 40 MB ~96% smaller — and no compiler shipped to production 🎉
Figure 2: Copy only the built artifact into a minimal final stage and the production image shrinks by an order of magnitude.

✅ Bonus: named stages are targetable

Because stages have names, you can build just one for debugging: docker build --target builder -t myapp-debug . stops at the build stage so you can shell in and inspect it.

Security Best Practices

A container is only as safe as the image behind it. These practices are cheap to adopt and dramatically reduce risk.

graph TD A["Dockerfile security"] --> B["Pin base image tags/digests"] A --> C["Run as a non-root USER"] A --> D["Minimal base (slim / alpine / distroless)"] A --> E["Never bake in secrets"] A --> F["Scan with docker scout cves"] style A fill:#fecaca,color:#1e293b style B fill:#fde68a,color:#1e293b style C fill:#bbf7d0,color:#1e293b style D fill:#bfdbfe,color:#1e293b style E fill:#c7d2fe,color:#1e293b style F fill:#cbd5e1,color:#1e293b

Pin your base image

A bare :latest tag means your build can silently change from one day to the next. For production, pin a specific version — or, for full reproducibility, pin the digest:

# ❌ Unpredictable — don't ship this to prod
FROM node:latest

# ✅ Pinned version
FROM node:20.11-alpine

# ✅✅ Pinned by immutable digest — byte-for-byte reproducible
FROM node:20.11-alpine@sha256:2b3...c9f

Run as a non-root user

By default a container runs as root. If an attacker escapes the app, they're root inside the container. Create an unprivileged user and switch to it with USER:

FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .

# create and switch to a non-root user
RUN addgroup -S app && adduser -S app -G app
USER app

EXPOSE 3000
CMD ["node", "server.js"]

Keep secrets out of layers

Anyone with the image can run docker history and read every build argument and ENV. Never put API keys, tokens, or passwords in the Dockerfile. Pass them at runtime with -e / --env-file, or use BuildKit's --mount=type=secret for build-time secrets that never persist in a layer.

Scan for vulnerabilities with Docker Scout

The old docker scan command has been removed. The current tool is Docker Scout:

docker scout cves myapp:1.0

Output:

    ✓ Image stored for indexing
    ✓ Indexed 214 packages
    ✗ Detected 3 vulnerable packages with 5 vulnerabilities

  ## Overview
                        │        Analyzed Image
  ──────────────────┼──────────────────────────
    Target          │  myapp:1.0
      digest        │  9c2b1e4a7f0d
    vulnerabilities │    0C     1H     3M     1L

  What's next:
    View base image update recommendations → docker scout recommendations myapp:1.0

Inspect layers with docker image history

To see exactly how big each layer is and which instruction created it — invaluable for hunting bloat:

docker image history myapp:1.0

Tagging

A single image can carry several tags. Add one with docker tag — commonly to mark a release as latest or to prepare it for a registry:

docker tag myapp:1.0 myapp:latest
docker tag myapp:1.0 registry.example.com/team/myapp:1.0

Try It Yourself

Time to put it together. You'll write a multi-stage Dockerfile, build it, and measure the payoff.

🏋️ Exercise: A lean multi-stage image

Objective: Build a small Node app two ways and compare the sizes.

Instructions:

  1. Create a folder with a trivial package.json and a server.js that runs a build step (or use any small React/Vite app).
  2. Write a multi-stage Dockerfile: a node:20-alpine AS builder stage that installs deps and runs npm run build, then a minimal final stage that copies only the built output.
  3. Add a .dockerignore that excludes node_modules and .git.
  4. Build it: docker build -t exercise:multi .
  5. Check the size with docker images exercise, then inspect layers with docker image history exercise:multi.
💡 Hint

Remember the caching rule: COPY package*.json ./ and install before COPY . .. In the final stage, use COPY --from=builder to pull only the artifact directory across.

✅ Worked solution

A static-site example serving a built dist from Nginx:

# syntax=docker/dockerfile:1

# ---- build stage ----
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build          # writes to /app/dist

# ---- final stage ----
FROM nginx:1.27-alpine
COPY --from=builder /app/dist /usr/share/nginx/html
# run as the non-root nginx user that ships with the image
USER nginx
EXPOSE 80
HEALTHCHECK --interval=30s --timeout=3s \
  CMD wget -q --spider http://localhost/ || exit 1
CMD ["nginx", "-g", "daemon off;"]

Matching .dockerignore:

node_modules
.git
dist
*.log
.env

What you should observe: docker images shows a final image in the tens of megabytes, not hundreds — because the node toolchain and node_modules stayed in the discarded builder stage. Running docker image history confirms the final image contains only the Nginx base plus your static files.

Quick Quiz

Question 1: Why do you copy package.json and install dependencies before copying your application source?

Question 2: What is the main benefit of a multi-stage build?

Question 3: Which command scans an image for known vulnerabilities in current Docker?

Summary

🎉 Key Takeaways

  • A Dockerfile is a recipe — each instruction adds a cached layer, and docker build -t name:tag . assembles them into an image.
  • Order for caching: copy dependency manifests and install before copying source, so code edits don't bust the dependency layer.
  • Prefer COPY over ADD, and know that ENTRYPOINT is the fixed command while CMD is the overridable default.
  • Multi-stage builds keep build tools out of production and can shrink images by ~10×.
  • Ship securely: pin bases, run as non-root via USER, keep secrets out of layers, and scan with docker scout cves.

📚 Additional Resources

🚀 What's Next?

You can now build lean, secure custom images. Real applications, though, are rarely a single container — a web app needs a database, a cache, maybe a worker. Next you'll orchestrate multiple containers as one stack with Docker Compose and a compose.yaml file.

🎉 Lesson 4 complete!

Your images are small, fast, and safe. Time to make them work together.