Skip to content

5. The Dockerfile

This is the most important chapter in this guide. Almost every Docker problem people actually hit — slow builds, huge images, leaked secrets, containers that will not stop, permission errors on volumes — is written into a Dockerfile, usually in one line, usually copied from somewhere.

A Dockerfile is a sequence of instructions. Each one is applied to the result of the previous one. That is the whole model. The difficulty is entirely in the details, so this chapter is a detailed reference you can come back to.

Here is a Dockerfile that does most things right. The rest of the chapter explains every line.

# syntax=docker/dockerfile:1
FROM node:20-slim AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:20-slim
ENV NODE_ENV=production
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY --from=build /app/dist ./dist
USER node
EXPOSE 3000
ENTRYPOINT ["node", "dist/server.js"]

Two stages, dependencies copied before source, a non-root user, an exec-form entrypoint. Under 15 lines and it beats most Dockerfiles in production.

This is the first thing to get straight, because it drives image size and cache behaviour.

CREATE A LAYER METADATA ONLY (free, no layer)
RUN FROM (starts from existing layers)
COPY ENV
ADD ARG
WORKDIR
USER
EXPOSE
LABEL
CMD
ENTRYPOINT
VOLUME
HEALTHCHECK
STOPSIGNAL
SHELL

Only three instructions add bytes. Everything else writes a line into the config blob from chapter 3. So ENV, WORKDIR and LABEL are free — use them freely — and every RUN, COPY and ADD is a decision.

FROM debian:12-slim
FROM python:3.12-slim AS build ← named stage (chapter 7)
FROM gcr.io/distroless/static:nonroot ← no shell, no package manager
FROM scratch ← literally empty

The base image choice determines most of your final size and most of your CVE count. In rough order:

scratch 0 MB nothing at all. For static Go/Rust binaries.
distroless ~2-20 MB the runtime and its libs, no shell, no apt.
alpine ~7 MB musl libc, busybox, apk.
*-slim ~30-80MB Debian without docs, man pages, extras.
debian/ubuntu ~75-120 the full thing.

A warning about Alpine. It is tempting because it is tiny, and it is genuinely great for Go and static binaries. But it uses musl rather than glibc, and that is a real behavioural difference, not just a size one:

  • Python — many wheels are built for glibc (manylinux). On Alpine, pip install falls back to compiling from source, so your build goes from 40 seconds to 12 minutes and needs a compiler toolchain in the image.
  • Performance — musl’s memory allocator is measurably slower than glibc’s for allocation-heavy workloads. There are well-documented cases of Python and Node services running noticeably slower on Alpine for no other reason.
  • DNS — musl’s resolver historically did not read search domains and options the same way, and did not fall back from UDP to TCP for large responses. This has improved but still produces confusing intermittent resolution failures.

For compiled static binaries, use scratch or distroless. For interpreted languages, -slim is usually the better default than Alpine, and it is very often smaller in the end because you do not need a build toolchain.

Pin the base image, and pin it well:

FROM python:3 ← changes minor versions under you
FROM python:3.12 ← changes patch versions
FROM python:3.12.7-slim ← good
FROM python:3.12.7-slim@sha256:… ← reproducible (chapter 3)
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates curl \
&& rm -rf /var/lib/apt/lists/*

Every part of that line is doing work:

update and install must be in the same RUN. Split across two, the cached update layer goes stale while the install layer re-runs, and you install packages from a months-old index — the classic “cache busting” bug that produces 404 Not Found from apt on an otherwise unchanged Dockerfile.

--no-install-recommends typically halves what apt drags in.

rm -rf /var/lib/apt/lists/* in the same RUN. In a later RUN, chapter 3’s whiteout rule applies and the ~40 MB package index is still in the image.

Chain related commands; do not chain unrelated ones. Each RUN is a cache checkpoint, so this is a genuine trade-off:

too many layers too few layers
RUN apt-get update RUN apt-get update && \
RUN apt-get install -y curl apt-get install -y curl && \
RUN rm -rf /var/lib/apt/lists rm -rf /var/lib/apt/lists && \
RUN pip install -r req.txt pip install -r req.txt
3 broken caches, bloated image any change re-runs everything,
including a 4-minute pip install

The rule that works: one RUN per logical unit of work — system packages in one, language dependencies in another, build steps in a third.

RUN apt-get update SHELL form: runs /bin/sh -c "…"
RUN ["apt-get", "update"] EXEC form: runs the binary directly

For RUN this rarely matters (shell form is standard, and you want shell features like && and *). For CMD and ENTRYPOINT it matters enormously, which is the next section.

This is the most confused pair in Docker, so here is the actual rule:

ENTRYPOINT is the command. CMD is the default arguments to it. Anything you type after docker run image replaces CMD, never ENTRYPOINT.

Dockerfile docker run img runs
CMD ["nginx","-g","daemon off;"] (nothing) nginx -g daemon off;
CMD ["nginx","-g","daemon off;"] bash bash
ENTRYPOINT ["nginx"] (nothing) nginx
ENTRYPOINT ["nginx"] -v nginx -v
ENTRYPOINT ["nginx"] CMD ["-g","…"] (nothing) nginx -g …
ENTRYPOINT ["nginx"] CMD ["-g","…"] -v nginx -v
ENTRYPOINT ["nginx"] bash nginx bash ← error

That last row is the one that surprises people: with an ENTRYPOINT set, docker run img bash does not give you a shell — it passes bash as an argument to nginx. You need docker run --entrypoint bash img.

Which to use:

  • A tool (a CLI, a linter, a migration runner) — use ENTRYPOINT, so docker run mytool --help works naturally.
  • A service — either works. ENTRYPOINT is slightly safer because arguments cannot accidentally replace the command.
  • A base image others will extend — prefer CMD, so they can override cleanly.
ENTRYPOINT node server.js ← SHELL form. Do not use.
ENTRYPOINT ["node", "server.js"] ← EXEC form. Use this.

The difference is not style. Shell form becomes:

/bin/sh -c "node server.js"

so /bin/sh is PID 1, and your application is a child of it. Now recall chapter 2: docker stop sends SIGTERM to PID 1. That is sh. And sh does not forward signals to its children.

docker stop → SIGTERM to /bin/sh → sh ignores it
→ 10 seconds pass
→ SIGKILL to everything
→ your app is killed mid-request, connections cut,
no graceful shutdown, exit code 137

Every deploy takes ten seconds longer than it should and drops in-flight requests, and nothing anywhere says why. Use exec form for ENTRYPOINT and CMD, always. Chapter 8 covers the remaining cases where you genuinely need a shell — the answer is exec "$@" in an entrypoint script.

One consequence of exec form: there is no shell, so $VARIABLE is not expanded.

CMD ["node", "server.js", "--port=$PORT"] ← passes the literal "$PORT"

If you need variable expansion, use an entrypoint script that ends with exec, or let the application read the environment itself — which it should anyway.

COPY src dest copies files. That is all it does.
ADD src dest copies files, AND auto-extracts local tarballs,
AND can fetch URLs.

Use COPY. ADD’s extra behaviours are surprises: a file named something.tar.gz is silently unpacked instead of copied, and URL fetching produces an un-cacheable, unverified download inside your image. The one legitimate ADD use is unpacking a local tarball you control, and even then people write ADD when they meant COPY far more often than the reverse.

Useful flags:

COPY --chown=node:node . /app set ownership without a RUN chown
COPY --from=build /app/dist ./dist from another stage (chapter 7)
COPY --chmod=755 entrypoint.sh / set the mode inline

--chown matters more than it looks. Without it, files land as root, and a container running as a non-root USER cannot write to its own directories. Doing it with RUN chown -R afterwards duplicates every file into a new layer — a 200 MB COPY followed by RUN chown -R produces a 400 MB image.

Everything about build speed reduces to one sentence:

Order instructions from least likely to change to most likely to change.

Chapter 6 traces exactly why, but the mechanism in short: the cache is checked instruction by instruction from the top, and the first miss invalidates every instruction after it.

WRONG RIGHT
COPY . . COPY package*.json ./
RUN npm ci RUN npm ci
COPY . . COPY . .
any source change changes a source change misses only
COPY . ., so npm ci re-runs the last COPY. npm ci is cached.
→ 4 minutes, every build → 8 seconds

This one pattern — copy the dependency manifest, install, then copy the source — applies to every ecosystem: package.json for Node, requirements.txt or pyproject.toml for Python, go.mod and go.sum for Go, Cargo.toml for Rust, pom.xml for Maven, Gemfile for Ruby. It is the single highest-value change most Dockerfiles can make.

They look similar and behave completely differently.

ARG build-time only. NOT present in the running container.
Set with --build-arg. Scoped: an ARG
before the first FROM is only visible
to FROM lines unless re-declared.
ENV build AND run. Baked into the image config. Visible to
the process, to `docker inspect`, to
anyone who pulls the image.
ARG NODE_VERSION=20 ← before FROM: usable in FROM
FROM node:${NODE_VERSION}-slim
ARG BUILD_ID ← re-declared: now usable in RUN
ENV NODE_ENV=production ← present at runtime
RUN echo "built from ${BUILD_ID}" > /app/build-info

Neither is safe for secrets. ARG is not in the runtime environment, which fools people — but docker history shows every build arg, and any layer created while the value was in the environment records it. If a token is passed as --build-arg, treat it as published. Chapter 7’s RUN --mount=type=secret is the correct mechanism.

WORKDIR /app creates the dir if needed, and sets cwd for every
following RUN/CMD/ENTRYPOINT/COPY. Use it instead
of `RUN cd /app` — which does nothing, because each
RUN is a separate shell.
USER node every following instruction, and the container
itself, runs as this user. Put it AFTER the
installs that need root. The user must exist —
create it with a RUN adduser if the base has none.
EXPOSE 3000 DOCUMENTATION ONLY. It publishes nothing and
opens nothing. Chapter 10. It is read by
`docker run -P` and by some tooling.
VOLUME /data declares a mount point. Anonymous volumes get
created automatically for it — including on every
`docker run`, which quietly accumulates gigabytes.
Prefer documenting it and letting users mount
explicitly.
HEALTHCHECK CMD curl -f http://localhost:3000/health || exit 1
Docker runs this periodically and marks the
container healthy/unhealthy. Compose's
depends_on: condition: service_healthy uses it.
Kubernetes IGNORES it entirely — it has its own
probes.
STOPSIGNAL SIGQUIT which signal `docker stop` sends. nginx wants
SIGQUIT for graceful shutdown, not SIGTERM.
LABEL org.opencontainers.image.source=https://github.com/org/repo
metadata. The `image.source` label is what links
a package to its repo on GitHub Container Registry.

USER deserves emphasis: containers run as root by default, and that is the wrong default for anything reachable from a network. Chapter 11 covers what it buys you and what it does not.

Chapter 4 explained that the CLI tars the whole context directory and sends it to the daemon. .dockerignore controls what goes into that tar.

.git
node_modules
__pycache__
*.pyc
.env
.venv
dist
build
target
coverage
*.log
Dockerfile
.dockerignore
README.md

Three separate reasons this matters:

Speed. A repository with a 900 MB .git and a 400 MB node_modules sends 1.3 GB over a socket on every build, before a single instruction runs.

Cache. With COPY . ., any change inside the context — including a log file your test run wrote, or a .git object from a fetch — changes the layer’s hash and invalidates everything after it. Builds that “randomly” miss cache are almost always this.

Security. COPY . . with a .env file present copies your credentials into the image. Permanently, per chapter 3.

Excluding node_modules also prevents the subtler bug where a host node_modules built on macOS/arm64 is copied into a Linux/amd64 image, overwriting the correct one from npm ci, and native modules fail at runtime with an error that mentions nothing about architecture.

  • Only RUN, COPY and ADD create layers. Everything else is free metadata — use ENV, WORKDIR and LABEL liberally.
  • Order from least-changing to most-changing. Copy the dependency manifest, install, then copy source. The first cache miss invalidates everything after it, so this one pattern is usually the difference between a 4-minute and an 8-second build.
  • ENTRYPOINT is the command; CMD is its default arguments; docker run arguments replace CMD only. With an ENTRYPOINT set, docker run img bash passes bash as an argument — use --entrypoint.
  • Always use exec form (["node","server.js"]). Shell form makes /bin/sh PID 1, and sh does not forward SIGTERM, so every stop takes 10 seconds and kills your app mid-request. The cost is no $VAR expansion — handle that in an entrypoint script or in the app.
  • Use COPY, not ADD. ADD silently untars and silently downloads. Use COPY --chown rather than a following RUN chown -R, which duplicates every file into a new layer.
  • apt-get update and install must share one RUN, and the cleanup must be in that same RUN or the bytes stay in the image.
  • ARG is build-time, ENV is both — and neither is safe for secrets. docker history shows build args.
  • Alpine is not automatically the right choice. musl breaks Python wheels, is slower for allocation-heavy work, and has had DNS quirks. Prefer -slim for interpreted languages, distroless/scratch for static binaries.
  • EXPOSE publishes nothing, VOLUME creates anonymous volumes you will forget about, and HEALTHCHECK is ignored by Kubernetes.
  • .dockerignore is not optional. It controls build speed, cache correctness, and whether your .env ends up in the image.

Next: one build, traced from docker build . to a manifest on disk — including exactly how the cache decides to hit or miss.

Written by Sepuri Sai Krishna

© 2026CC BY 4.0Source