What is it? #
A Dockerfile is the recipe for an image: a list of instructions, each producing a layer.
Three things separate a good Dockerfile from a poor one. Instruction order that exploits the cache, multi-stage builds that keep build tools out of the final image, and running as a non-root user.
A .dockerignore file matters as much as the Dockerfile itself. Without one, the entire directory — including .git, node_modules and .env — is sent to the build and often copied into the image.
Getting these right typically turns a 1.2 GB image with three-minute rebuilds into a 180 MB image that rebuilds in seconds.
Think of it like this #
An assembly line where each station's output is saved. If nothing before a station changed, its saved output is reused.
Put the parts that change constantly at the end, and most of the line never has to run again.
Simple example #
A Python application with compiled dependencies. A build stage installs the compiler and builds wheels; the final stage copies only the results, so the shipped image contains no compiler at all.
Code #
# syntax=docker/dockerfile:1
# ---------- build stage ----------
FROM python:3.12-slim AS builder
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential libpq-dev \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY requirements.txt .
RUN pip wheel --no-cache-dir --wheel-dir /wheels -r requirements.txt
# ---------- final stage ----------
FROM python:3.12-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
libpq5 curl \
&& rm -rf /var/lib/apt/lists/* \
&& useradd --system --create-home --shell /usr/sbin/nologin appuser
WORKDIR /app
COPY --from=builder /wheels /wheels
COPY requirements.txt .
RUN pip install --no-cache-dir --no-index --find-links=/wheels -r requirements.txt \
&& rm -rf /wheels
COPY --chown=appuser:appuser . .
USER appuser
ENV PYTHONUNBUFFERED=1 PYTHONDONTWRITEBYTECODE=1
EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=3s --start-period=20s --retries=3 \
CMD curl -fsS http://localhost:8000/health || exit 1
CMD ["gunicorn", "app.main:app", "--bind", "0.0.0.0:8000", "--workers", "4"]
# .dockerignore — as important as the Dockerfile
.git
.gitignore
.venv
__pycache__
*.pyc
node_modules
.env
.env.*
*.log
tests/
docs/
.github/
Dockerfile
docker-compose.yml
# A Node equivalent, same principles
FROM node:22-slim AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci # cached unless package files change
COPY . .
RUN npm run build
FROM node:22-slim
WORKDIR /app
ENV NODE_ENV=production
COPY package*.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY --from=builder /app/dist ./dist
USER node # the node image already has this user
EXPOSE 3000
CMD ["node", "dist/server.js"]
docker build -t myapp:1.4.2 .
docker build --progress=plain --no-cache -t myapp:1.4.2 . # debug a build
docker images myapp # check the size
How it works #
The build stage installs compilers and builds wheels. The final stage starts from a clean base and copies only the built artefacts, so the compiler never ships. This is usually the single largest size reduction available.
Instruction order drives caching. COPY requirements.txt before COPY . . means editing source code does not invalidate the dependency install, turning minutes into seconds.
Combining apt-get update and install in one RUN matters because separate layers can produce a stale package list from cache. Deleting the apt lists in the same instruction keeps them out of the layer entirely — deleting them later would not.
useradd plus USER appuser means the process runs unprivileged. A container running as root gives a container escape far more to work with, and it is one line to fix.
COPY --chown sets ownership during the copy rather than requiring a separate RUN chown, which would duplicate the files in another layer.
HEALTHCHECK lets Docker and orchestrators know whether the application is actually serving, not merely running. --start-period avoids marking a slow-starting application as unhealthy during boot.
CMD in exec form runs the process directly as PID 1, so it receives SIGTERM properly. Shell form wraps it in /bin/sh -c, which swallows signals and breaks graceful shutdown.
The .dockerignore prevents the build context ballooning and stops .env and .git reaching the image — the latter being a genuine source of leaked secrets.
Real-world use #
Build times affect every developer and every CI run. Correct layer ordering is the highest-leverage change available and takes minutes to apply.
Image size affects pull times on every deployment and every scaled instance. Multi-stage builds routinely cut images by 80% or more.
Running as non-root is required by many container platforms and is a standard finding in security reviews when missing.
Secrets must never be baked into an image. They remain in the layer history even if deleted later, and anyone who can pull the image can extract them. Use build secrets or runtime environment variables.
Scanning images for vulnerabilities is standard in CI, and a minimal final stage produces a far shorter report simply because it contains less software.
Common mistakes #
- Copying source before installing dependencies, destroying the build cache.
- No .dockerignore, so .git, node_modules and .env end up in the context or image.
- Running as root because USER was never set.
- Baking secrets into the image, where they persist in layer history.
- Using shell-form CMD, so the process never receives SIGTERM.
Practice #
Write a multi-stage Dockerfile for an application, with a non-root user, a health check and a .dockerignore. Compare the final image size with a naive single-stage version, then change one source file and measure how much of the build is cached.