Dockerfiles
Turn a recipe into a reproducible image.
Creating Dockerfiles
So far you have been using images written by other people. Now it’s time to build your own, and this is where Docker stops being a utility and becomes a way of working: the Dockerfile is the document where your application’s runtime environment goes from tribal knowledge to source code, reviewed and versioned along with everything else.
At first, the temptation is to treat it like an installation script that you’ve simply moved into a file. You stack up the commands that worked on your machine, build, it works, and you move on. The result runs. It also weighs 1.4 GB for a three-file Node application, rebuilds in four minutes every time you change a comma, runs as root, and carries in one of its layers the private access token you thought you had deleted on the next line.
None of these four flaws is inevitable, and none requires advanced knowledge to fix. They all come from the same misunderstanding: a Dockerfile is not a script executed top to bottom in a single environment. It is a series of transformations, each of which produces an immutable layer, cached and stacked on top of the previous one. Understanding this mechanism gets you images ten times lighter and builds ten times faster almost for free.
This lesson starts from a naive but working Dockerfile, then fixes it flaw by flaw, explaining at each step what the build engine actually does.
A First Dockerfile, Line by Line
Let’s take a small Node application. At the root of the project, a file named exactly Dockerfile, with no extension:
# Starting image: every Dockerfile begins with FROM.
# The version is pinned: neither "latest" nor plain "node".
FROM node:22-slim
# Working directory for the following instructions.
# WORKDIR creates the folder if it doesn't exist.
WORKDIR /app
# Copy the dependency manifests ONLY, before the code.
# The reason is the cache: it is detailed below.
COPY package.json package-lock.json ./
# "npm ci" strictly respects the lock file, unlike "npm install".
RUN npm ci --omit=dev
# The rest of the application code
COPY . .
# EXPOSE publishes NOTHING: it is documentary metadata.
# Publishing is done with -p at launch time.
EXPOSE 3000
# Command executed when the container starts
CMD ["node", "server.js"]
# -t tags the image (name:tag). The final "." is the build
# CONTEXT: the folder sent to the daemon, not the Dockerfile's location.
docker build -t mon-api:1.0 .
docker images mon-api
docker run --rm -p 3000:3000 mon-api:1.0
This Dockerfile works, and it already gets one important thing right: the order of the COPY instructions. Before going further, we need to clear up that last point about the final dot.
The build context is the set of files the client sends to the daemon before the build begins. The daemon has no access to your disk: it only sees that package. Two consequences follow, and everyone runs into them at least once. A COPY ../secrets/config.json fails with path outside of build context, because the path falls outside the package. And if your local node_modules weighs 800 MB, those 800 MB are transferred on every build even if they are never copied — hence the .dockerignore, further down.
The context and the location of the Dockerfile are, incidentally, independent: docker build -f docker/Dockerfile.prod -t mon-api:prod . reads the recipe from a subfolder while keeping the current folder as the context.
The Essential Instructions
FROM defines the base image and can appear several times (see multi-stage builds). FROM scratch denotes a completely empty image.
RUN executes a command during the build and freezes its result into a layer. Each RUN creates a layer, so chain them.
# Bad: three layers, and the apt cache stays in the image
RUN apt-get update
RUN apt-get install -y curl
RUN apt-get install -y git
# Good: one layer, cleanup in the SAME instruction
RUN apt-get update && apt-get install -y --no-install-recommends \
curl \
git \
&& rm -rf /var/lib/apt/lists/*
The rm -rf must be in the same RUN. Placed in a later RUN, it would hide the files without reducing the image size, since layers are additive. The --no-install-recommends flag avoids dozens of suggested packages you don’t need.
COPY copies from the context into the image. ADD does the same thing but adds two implicit behaviors — extracting a local archive and downloading a URL — that are often surprising. The accepted rule is COPY by default, ADD only to extract an archive.
COPY ./src /app/src
COPY --chown=node:node ./src /app/src # copy while setting the owner
ADD app-v2.tar.gz /opt/ # extracts automatically
ENV defines a variable that persists in the image and in containers; ARG defines a variable available only during the build, which can be overridden on the command line.
ARG NODE_VERSION=22
FROM node:${NODE_VERSION}-slim
ARG APP_ENV=production
ENV NODE_ENV=${APP_ENV}
docker build --build-arg NODE_VERSION=20 --build-arg APP_ENV=staging -t mon-api:test .
# An ARG is NOT a secret: its value remains readable here
docker history --no-trunc mon-api:test
WORKDIR sets the current directory: always prefer it to RUN cd /app, which has no lasting effect since each RUN starts a new process. EXPOSE documents a port and enables docker run -P, which automatically publishes the declared ports. Finally, LABEL org.opencontainers.image.source="..." adds traceability metadata that tooling knows how to use.
Layer Caching: Order Determines Build Time
This is the point that yields the most spectacular gains, and it comes down to one rule.
For each instruction, the engine computes a cache key. If the instruction and its inputs are unchanged, the layer is reused as is. But as soon as one layer is invalidated, all the following ones are too. The cache is a chain: a break at link 3 breaks links 4 through 12. For RUN, the key is the text of the command; for COPY, it is the content of the copied files.
Hence the classic counter-example:
FROM node:22-slim
WORKDIR /app
COPY . . # invalidated as soon as a SINGLE source file changes
RUN npm ci --omit=dev # ...so it reinstalls everything, on every comma changed
CMD ["node", "server.js"]
And the correct version, which takes advantage of the relative stability of dependencies:
FROM node:22-slim
WORKDIR /app
# The manifests only: they rarely change
COPY package.json package-lock.json ./
# Expensive layer, but kept as long as the manifests are identical
RUN npm ci --omit=dev
# The code last: what changes most often, and costs the least
COPY . .
CMD ["node", "server.js"]
The general rule, valid for every language: from most stable to most volatile. System installation, then dependencies, then code. Only the file names change: requirements.txt for Python (pip install --no-cache-dir -r), go.mod and go.sum for Go, pom.xml for Maven, Cargo.toml for Rust.
During a build, CACHED lines indicate reused layers. Two options let you bypass the cache: --no-cache forces a complete rebuild, which verifies that the Dockerfile still works from scratch, and --pull fetches an up-to-date base image instead of the one in the local cache.
BuildKit, the default build engine since Docker 23, also offers cache mounts that survive invalidations. They require the syntax directive on the first line:
# syntax=docker/dockerfile:1
FROM node:22-slim
WORKDIR /app
COPY package.json package-lock.json ./
# The npm cache is mounted during the RUN and is NOT
# included in the layer: fast, without bloating the image.
RUN --mount=type=cache,target=/root/.npm npm ci --omit=dev
COPY . .
CMD ["node", "server.js"]
.dockerignore: Essential, Never Optional
Next to the Dockerfile, this file excludes paths from the build context. Its syntax follows that of .gitignore.
# Dependencies: reinstalled in the image, and the host's
# may have been compiled for a different OS
node_modules
__pycache__
vendor
.git # often hundreds of MB, never useful
# Secrets and local configuration: the most serious risk
.env
.env.*
*.pem
*.key
# Artifacts, coverage, editor files
dist
build
coverage
.vscode
.idea
Dockerfile*
docker-compose*.yml
It works on three levels at once. It speeds things up: the transferred context can drop from several hundred megabytes to a few hundred kilobytes. It protects: without it, a COPY . . bundles your .env and private keys into an image layer, where they remain extractable. And it stabilizes the cache: a log file that changes on every run will no longer invalidate your layers.
The node_modules case deserves emphasis: copying it from the host is not only useless, it’s risky, because native modules are compiled for the architecture and OS of the machine where they were installed.
CMD vs. ENTRYPOINT
CMD provides a default command, fully replaceable by the arguments of docker run. ENTRYPOINT fixes the executable, and the arguments of docker run are appended to it.
With CMD ["echo", "bonjour"] alone, docker run mon-image prints “bonjour” and docker run mon-image ls / lists the root directory: the default command was simply replaced. The ENTRYPOINT + CMD pair behaves very differently, and it is the recommended pattern for an application.
FROM alpine:3.20
ENTRYPOINT ["ping"]
CMD ["-c", "3", "localhost"]
docker run --rm mon-image # ping -c 3 localhost
docker run --rm mon-image -c 5 8.8.8.8 # ping -c 5 8.8.8.8
# ENTRYPOINT can only be replaced explicitly
docker run --rm --entrypoint sh -it mon-image
One more subtle distinction remains, with very real consequences: the exec form versus the shell form.
# Exec form (JSON array): the process is PID 1 directly
CMD ["node", "server.js"]
# Shell form (string): Docker runs /bin/sh -c "node server.js"
CMD node server.js
In the shell form, PID 1 is sh, and sh does not forward SIGTERM to its child. Your application therefore never receives the stop signal: docker stop waits ten seconds and then kills it abruptly. Always use the exec form for a long-running process. Environment variables are not interpreted in it; if you need them, say so explicitly:
CMD ["sh", "-c", "node server.js --port=$PORT"]
Multi-Stage: Build in One Image, Ship in Another
This is the biggest lever available for image size. The idea: the tools needed to build an application — compiler, headers, development dependencies — have no place in the image that runs it.
A Dockerfile can contain several FROM instructions. Each one opens a stage, and the final stage copies files from a previous stage with COPY --from. Anything that isn’t copied is discarded.
# syntax=docker/dockerfile:1
# ---------- Stage 1: build ----------
FROM node:22 AS builder
WORKDIR /app
COPY package.json package-lock.json ./
# Here we install EVERYTHING: TypeScript, bundlers, test tools
RUN npm ci
COPY . .
RUN npm run build # produces /app/dist
# ---------- Stage 2: runtime ----------
FROM node:22-slim AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY package.json package-lock.json ./
RUN npm ci --omit=dev && npm cache clean --force
# ONLY the compiled artifact. The compiler, the sources
# and the devDependencies stay behind.
COPY --from=builder /app/dist ./dist
USER node
EXPOSE 3000
CMD ["node", "dist/server.js"]
The effect is even more striking with a compiled language, where the final image can do without any runtime environment:
FROM golang:1.23 AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
# CGO disabled: static binary, no dependency on libc
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /bin/api ./cmd/api
# scratch is literally empty: no shell, no libc, no package manager
FROM scratch
# Root certificates are required for any outgoing HTTPS call
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=builder /bin/api /api
EXPOSE 8080
ENTRYPOINT ["/api"]
You go from about 1 GB to a few megabytes, and the attack surface shrinks in the same proportion: with no shell or utilities, an attacker who gained code execution would have almost nothing to work with. The downside is that you can no longer troubleshoot with docker exec ... sh. Google’s distroless images are a common compromise: no shell, but a complete runtime environment.
Stages can also be used separately, which makes the same file usable in continuous integration:
docker build --target builder -t mon-api:build .
docker run --rm mon-api:build npm test
Never Run as root
By default, a container’s process runs as root. It is not the host’s root, but combining that elevation with a kernel vulnerability or a volume mounted from the host is enough to turn an application vulnerability into a compromise of the machine. The fix takes two instructions.
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
# System user, with no password or login shell
RUN groupadd --system --gid 1001 appgroup \
&& useradd --system --uid 1001 --gid appgroup --no-create-home appuser \
&& chown -R appuser:appgroup /app
# Everything that follows runs as this user
USER appuser
EXPOSE 8000
CMD ["python", "-m", "app"]
On an Alpine base, the tools differ: addgroup -S -g 1001 appgroup && adduser -S -u 1001 -G appgroup -H appuser. And some official images already provide an unprivileged account: node images have a node user, so you just write USER node.
Two points to watch. Order matters: USER comes after the installations that require privileges, and chown comes before the user change, otherwise the application won’t be able to write to its own directory. And an unprivileged user cannot bind to a port below 1024: have your application listen on 8080, then publish it wherever you want with -p 80:8080.
Verification takes one command: docker run --rm mon-api:1.0 id should answer uid=1001(appuser), not uid=0(root).
Layers Are Additive: Secrets Don’t Get Erased
A mistake that looks harmless and is anything but:
# DON'T DO THIS
COPY .npmrc /root/.npmrc
RUN npm ci
RUN rm /root/.npmrc # illusory: the file stays in the COPY layer
The rm creates a layer that hides the file; it does not remove it from the image. Anyone with access to the image can extract the earlier layer and recover the token. The same reasoning applies to an ARG TOKEN, which is visible in docker history.
BuildKit provides the clean solution: a secret mounted for the duration of a single RUN, which doesn’t belong to any layer.
# syntax=docker/dockerfile:1
FROM node:22-slim
WORKDIR /app
COPY package.json package-lock.json ./
# The file exists only for the duration of the RUN, then disappears without a trace
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc npm ci --omit=dev
COPY . .
CMD ["node", "server.js"]
docker build --secret id=npmrc,src=$HOME/.npmrc -t mon-api:1.0 .
Build, Tag, Verify
The name you give an image will tell you, six months from now, which commit is running in production. So tag at build time, and check three things before considering the image finished.
# Multiple tags for a single build, including one derived from the Git commit
docker build -t mon-api:1.2.3 -t mon-api:"$(git rev-parse --short HEAD)" .
# Tag for a private registry, required before the push
docker tag mon-api:1.2.3 registre.exemple.com/equipe/mon-api:1.2.3
docker images mon-api # is the size reasonable?
docker run --rm mon-api:1.2.3 id # does it run without privileges?
docker history mon-api:1.2.3 # where did the weight go?
Key Takeaways
- A
Dockerfileproduces a series of immutable layers, not a linear script. Order instructions from most stable to most volatile: dependencies before code, otherwise you pay for a full reinstall every time you change a single character. - An invalidated layer invalidates all the ones after it. The cache is a chain, and a
COPY . .placed too early breaks it every time. - The
.dockerignorefile is not optional: it speeds up the build, stabilizes the cache, and keeps your.envfiles and private keys from ending up in a publishable layer. ENTRYPOINTsets the executable, whileCMDprovides default arguments that can be overridden. Use the exec form so your process runs as PID 1 and receivesSIGTERM.- Multi-stage builds are the biggest lever available: build tools stay in the
builderstage, and only the artifact is copied over. Typical gains are a factor of 10 in both size and attack surface. - Always add a non-privileged
USER, after installations and after thechown, and have the application listen on a port above 1024. - A file deleted in a later layer can still be extracted. For build tokens, use
RUN --mount=type=secret, neverCOPYfollowed byrm, and neverARG.
Tip: after each change, run
docker historyon your image. An abnormally large layer almost always points to a forgotten package cache or an overly broadCOPY.
Comments
Comments are powered by GitHub Discussions
Sign in with GitHub to join the discussion