Frontend Labs
⋯

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 :latest tag) 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 .git history, local secrets, or virtual environment junk you never intended to ship.

Check yourself

  1. Why does dependency installation belong BEFORE application code in a Dockerfile?
  2. What real security benefit does running as a non-root user provide, specifically?
  3. What does .dockerignore do, 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.

Generated from real-life-os-guide.md · 8 Oct 2026 16:36