Writing Dockerfiles
Writing Dockerfiles
A Dockerfile is the single source of truth for how your container image is assembled. Every production image at a serious company — the Node API, the Python ML worker, the Go microservice — starts here. Writing a Dockerfile badly means slow CI builds, bloated images, unpredictable runtime behaviour, and security surface you can't explain. Writing it well means 10-second cache hits, 20 MB final images, and builds that are reproducible across every developer's laptop and every CI runner.
This lesson walks through every instruction that matters, explains why layer ordering is a first-class concern, and shows you what cache-friendly Dockerfiles actually look like in production.
The Core Instructions
FROM — Choosing Your Base
FROM is always the first instruction. It sets the base image your image builds on top of. Every subsequent instruction adds a new layer on top of that base.
At big tech, three rules govern FROM:
- Always pin an exact digest or at minimum an exact tag — never
FROM node:latest. Floating tags break reproducibility silently when upstream publishes a new image. - Prefer official minimal bases:
node:22-alpine,python:3.12-slim,golang:1.22-bookworm. Alpine and slim variants are dramatically smaller than the default Debian-full images. - For statically-compiled binaries (Go, Rust), prefer
FROM scratch— the final stage gets zero OS surface area.
COPY — Bringing Files In
COPY <src> <dest> copies files from the build context (your local filesystem) into the image layer. ADD also exists but should be avoided unless you specifically need its tar-auto-extraction or remote-URL fetch behaviour — both are footguns. Use COPY by default.
Key flags you will actually use in production:
--chown=user:group— set ownership in a single step rather than a separateRUN chowninstruction (which would create an extra layer).--from=builder— copy from another stage in a multi-stage build (covered in a later lesson).
COPY . . sends your entire working directory into the build context — including .git/, node_modules/, test fixtures, and local .env files. A well-crafted .dockerignore cuts build-context transfer from gigabytes to kilobytes.RUN — Executing Build Steps
RUN executes a shell command during the build and commits the result as a new layer. Every RUN instruction is a cache key. The most important production rule: chain related commands into a single RUN instruction, especially when they install, modify, and then clean up packages — because if you split them across multiple RUN instructions, intermediate files are frozen in earlier layers even after you delete them.
Shell form vs exec form: RUN apt-get update is shell form (runs via /bin/sh -c). You can also use exec form: RUN ["apt-get", "update"]. Shell form is more readable for chained commands; exec form avoids shell interpretation and is preferred in CMD and ENTRYPOINT.
CMD and ENTRYPOINT — Defining Runtime Behaviour
These two instructions are a source of constant confusion. The rule is simple once you internalise it:
ENTRYPOINT— the fixed executable that always runs. It defines what the container is.CMD— default arguments that are passed to ENTRYPOINT (or, if there is no ENTRYPOINT, the default command to run). It defines sensible defaults that can be overridden atdocker runtime.
Both accept shell form and exec form. Always use exec form (["executable", "arg1"]) for CMD and ENTRYPOINT. Shell form wraps your process in /bin/sh -c, making it PID 2 instead of PID 1 — which means signals (SIGTERM, SIGINT) from Docker or Kubernetes are never delivered to your process, causing unclean shutdowns and slow rolling deploys.
docker run time, your entire CMD is replaced — not merged. Design your argument surface accordingly.Layer Ordering and Cache-Friendly Dockerfiles
Docker's build cache is keyed on the instruction text and the contents of any files referenced. Once a layer is invalidated, every subsequent layer must be rebuilt. This means layer order is a performance-critical decision, not an aesthetic one.
The golden rule: order instructions from least-frequently-changing to most-frequently-changing.
A Production-Grade Dockerfile (Node.js API)
Here is a complete, real-world Dockerfile incorporating every principle above. Study the order and the comments:
RUN adduser + USER is a two-line security win.Other Useful Instructions
WORKDIR /app— sets the working directory for subsequent instructions. Prefer this overRUN cd /appwhich does not persist.ENV KEY=value— sets environment variables available at both build time and runtime. Use forNODE_ENV=production,PYTHONUNBUFFERED=1, etc. Do not use ENV for secrets — they are baked into the image and visible viadocker history.ARG— build-time-only variable, not persisted into the image. Safe for things like version numbers:ARG APP_VERSION=1.0.0.EXPOSE 3000— documents which port the container listens on. It does not publish the port; that happens atdocker run -por in Docker Compose. Treat it as metadata for operators.LABEL— attach key-value metadata (maintainer, version, git SHA). Useful fordocker inspectand automated inventory systems.
--cache-from / --cache-to flags or GitHub Actions' cache-to=type=gha to persist layer caches between pipeline runs — this is often the single biggest CI speed-up available.Summary
A production-quality Dockerfile is defined by four habits: pin your base image, chain RUN commands to collapse layers, order instructions with least-changing first, and always use exec form for ENTRYPOINT and CMD. Every deviation has a concrete cost — either in image size, build time, or runtime reliability. Make these the default, not the exception.