Chapter 48 — Writing a good Dockerfile
In one sentence
A Dockerfile is a recipe for building an image, layer by layer (Chapter 46), and a genuinely GOOD one is small, cacheable, and — critically — doesn't run its main process as root by default.
Why this matters
A careless Dockerfile produces a bloated, slow-to-build, slow-to-deploy image that also runs with more privilege than it needs — real, common, and avoidable technical debt from day one.
How it actually works
# A genuinely good, real-world-shaped example
FROM python:3.12-slim # a SMALL base image, not the
# full, much larger default
WORKDIR /app
COPY requirements.txt . # dependencies FIRST (Chapter
# 46's caching lesson) --
# changes rarely
RUN pip install --no-cache-dir -r requirements.txt
COPY . . # application code LAST --
# changes often
RUN useradd --create-home appuser # create a NON-ROOT user
USER appuser # switch to it -- the
# container's main process now
# runs as appuser, NOT root
EXPOSE 8000 # documentation -- doesn't
# actually publish the port
# (Chapter 50 does that)
CMD ["python3", "app.py"]
The habits that separate a good Dockerfile from a careless one:
[ ] a SMALL base image (a `-slim`/`-alpine` variant, or a distroless
image) -- smaller attack surface, faster pulls, less disk.
[ ] dependencies installed BEFORE application code (Chapter 46).
[ ] a `.dockerignore` file (works like `.gitignore`) -- excludes
things like `.git/`, local virtual environments, and secrets
from ever being COPY-ed into the image at all.
[ ] a NON-ROOT user for the actual running process.
[ ] PINNED versions (a specific base image tag, pinned dependency
versions) rather than `latest`/unpinned -- reproducible builds,
exactly this wiki's advanced security guide's supply-chain
chapter's lesson, applied here.
[ ] MULTI-STAGE builds for compiled languages -- build in one stage
(with all the compilers/build tools), copy ONLY the final
artifact into a clean, minimal final stage, so build tooling
never ships in your production image at all.
Real-world example
"Don't run your container's process as root" is one of the most
consistently, widely repeated pieces of real container-security
guidance in the industry -- present in Docker's own official
documentation, in every major container-security scanning tool's
default findings, and covered in operational depth in this wiki's
security guides' container-hardening material. The concrete, real
reasoning: a container running as root, if COMPROMISED (through an
application vulnerability, a malicious dependency, or any other real
attack path), gives an attacker ROOT WITHIN the container's namespace
-- and root, combined with ANY of the real, documented container-
escape techniques (a misconfigured volume mount reaching sensitive
host paths, a kernel vulnerability, an overly-permissive capability
set), is a materially WORSE starting position for the attacker than
a non-root user would have been. This is precisely why "USER
appuser" (or the equivalent) is treated as close to a non-negotiable
default in serious Dockerfile guidance, not an optional hardening
step reserved for especially sensitive workloads -- the cost of
adding it is a few lines; the benefit, in a real compromise, is
substantial and well documented across container-security research.
Try it yourself
cd ~/lab/dockerdemo
# See the root-vs-non-root difference directly
cat > Dockerfile.root << 'EOF'
FROM alpine:latest
CMD ["whoami"]
EOF
docker build -t rootdemo -f Dockerfile.root .
docker run --rm rootdemo # prints "root"
cat > Dockerfile.nonroot << 'EOF'
FROM alpine:latest
RUN adduser -D appuser
USER appuser
CMD ["whoami"]
EOF
docker build -t nonrootdemo -f Dockerfile.nonroot .
docker run --rm nonrootdemo # prints "appuser" -- NOT root
# Compare image sizes: slim base vs full
docker pull python:3.12-slim
docker pull python:3.12
docker images | grep python
rm -f Dockerfile.root Dockerfile.nonroot
Build it in Go
Go is unusually well suited to small, safe images: a static binary
(CGO_ENABLED=0) needs no libc, so the final stage can be distroless/static
or even scratch. Chapter 82 has a tested multi-stage
Dockerfile that produced a 14.8 MB image that runs as non-root, read-only,
with all capabilities dropped, and with no shell to exploit.
Common mistakes
- Using
FROM ubuntu:latest(or any:latesttag) in production. Not reproducible — what "latest" points to changes over time, silently, out from under you. Pin a specific version. COPY . .before installing dependencies. Defeats Chapter 46's layer caching on every single code change — order matters.- Never creating a
.dockerignore. Without one, your build context (and potentially your final image) can include.githistory, local secrets, or virtual environment junk you never intended to ship.
Check yourself
- Why does dependency installation belong BEFORE application code in a Dockerfile?
- What real security benefit does running as a non-root user provide, specifically?
- What does
.dockerignoredo, and why does it matter?
Further reading
- Docs: Docker's own official "Best practices for writing Dockerfiles."
- Guide: this wiki's security guides' container-hardening chapters cover the full "harden a container" checklist (capabilities, read-only filesystems, and more) in operational depth.
- Tool:
hadolint— a Dockerfile linter that catches many of these mistakes automatically; worth running in CI.