Writing a good Dockerfile
To containerise your own application, you write a Dockerfile — a recipe that tells Docker how to build an image of your app. It is one of the most important files in a modern project, and writing a good one (not just one that works) is a real skill: the difference is build speed, image size, and security. This lesson builds the Dockerfile for the "greetings" web service and explains each instruction, so you can write your own.
What a Dockerfile is
A Dockerfile is a plain text file of instructions, each building on the last, that produce an image.
docker build reads it and creates the image; then docker run starts a container from that image. Here is a
first, simple Dockerfile for the greetings Node app:
FROM node:20-alpine # start from an official Node 20 image (small "alpine" variant)
WORKDIR /app # set the working directory inside the image
COPY package.json package-lock.json* ./ # copy the dependency manifests
RUN npm ci --omit=dev # install production dependencies
COPY src ./src # copy the application source
EXPOSE 3000 # document that the app listens on port 3000
CMD ["node", "src/server.js"] # the command to run when a container starts
Build and run it:
docker build -t greetings:1.0 . # build an image tagged greetings:1.0 from the current directory
docker run -p 3000:3000 greetings:1.0
Every instruction below is one of those lines, explained.
The core instructions
FROM— the base image you build on.node:20-alpinegives you Node 20 on a tiny Alpine Linux. Always start from an official, specific base (node:20-alpine, notnode:latest— pinning the version keeps builds reproducible, the same reason lockfiles exist).WORKDIR— sets the directory inside the image where subsequent commands run and where your app lives. Use it instead ofcd.COPY— copies files from your project into the image (COPY src ./src).ADDis similar but does more (and surprising things); preferCOPY.RUN— runs a command at build time, e.g. installing dependencies. Its result is baked into the image.EXPOSE— documents which port the app listens on. It does not publish the port (that is-pat run time); it is documentation for humans and tools.ENV— sets an environment variable in the image (e.g.ENV NODE_ENV=production).CMD— the command that runs when a container starts. There is oneCMD; it is the app's entrypoint. (ENTRYPOINTis a related instruction for wrapping the command.)
RUN happens when you build; CMD happens when you run. Confusing the two is the most common beginner
mistake — RUN npm ci installs deps into the image at build time; CMD ["node", ...] starts the app each
time a container launches.
Layers and caching: why the order matters
Every instruction creates a layer, and Docker caches layers: on a rebuild, it reuses a layer unless that instruction or the files it uses changed. Getting the order right makes builds fast, and it is the single most impactful Dockerfile skill.
Look again at the greetings Dockerfile: it copies package.json and runs npm ci before copying the
source:
COPY package.json package-lock.json* ./
RUN npm ci --omit=dev # this layer is cached...
COPY src ./src # ...and only THIS layer rebuilds when you edit source
Why this order? Dependencies change rarely; source changes constantly. By copying the manifests and installing
deps first, the expensive npm ci layer is cached and reused on every build where dependencies did not
change — so editing your code rebuilds only the fast final COPY, not the whole dependency install. If you
instead did COPY . . before npm ci, every source edit would invalidate the cache and re-install everything,
making builds slow. Order instructions from least-frequently-changing to most-frequently-changing. This one
rule saves enormous build time.
.dockerignore: don't copy junk
Alongside the Dockerfile, a .dockerignore file lists things Docker should not copy into the image —
exactly like .gitignore:
node_modules
.git
.env
*.md
This matters for three reasons: it keeps the image small (no node_modules from your host, no .git
history), it keeps builds fast (less to copy), and it is a security must — never copy .env files or
secrets into an image (anyone with the image can read them). The greetings project ignores node_modules
(so the container uses the ones installed inside it, for the right platform), .git, and .env. Always add a
.dockerignore; forgetting it is how a 5 MB app becomes a 500 MB image full of the host's junk, or how a
secret leaks into a shared image.
Habits of a good Dockerfile
Pulling it together, a good Dockerfile:
- Starts from a specific, minimal, official base (
node:20-alpine, pinned — notlatest). - Orders instructions for caching (dependencies before source).
- Has a
.dockerignoreso only what is needed is copied, and no secrets. - Installs only what it needs (
--omit=devfor production dependencies). - Runs as a non-root user and keeps the image small (the next lesson goes deep on both).
These are not style preferences; each has a concrete payoff in speed, size or security. Writing Dockerfiles that are merely correct is easy; writing ones that are fast, small and safe is what separates a professional image from a naive one — and it is exactly what the image-size lesson builds on next.
Check your work
A Dockerfile is a recipe of instructions that docker build turns into an image (then docker run starts
a container). Core instructions: FROM (specific official base — node:20-alpine, not latest),
WORKDIR, COPY (prefer over ADD), RUN (command at build time), EXPOSE (documents the
port — does not publish it), ENV, CMD (command at run time — the one entrypoint). RUN = build
time, CMD = run time (the classic confusion).
Layers & caching: each instruction is a cached layer, reused unless it or its files changed. Copy manifests and install deps before copying source, so the expensive install layer stays cached when only code changes. Order least- to most-frequently-changing — the highest-impact rule.
.dockerignore (like .gitignore) excludes junk: keeps the image small and the build fast, and never
copy .env/secrets into an image. Always add one.
Good Dockerfile: specific/minimal/official pinned base, cache-friendly order, a .dockerignore, install
only what's needed (--omit=dev), non-root + small image (next lesson). Each habit pays off in speed, size or
security.
Practice
- Write a Dockerfile for the greetings app;
docker build -t greetings:1.0 .and run it. - Explain each instruction (
FROM,WORKDIR,COPY,RUN,EXPOSE,CMD) in your Dockerfile. - Explain the difference between
RUNandCMDwith an example of each from your file. - Edit only a source file and rebuild; confirm the
npm cilayer was cached. Then reorder soCOPY . .comes first and show the cache breaks. - Add a
.dockerignoreand explain what each entry excludes and why (including why.envmust be excluded). - Explain why pinning
node:20-alpineinstead ofnode:latestmatters for reproducible builds.
Official documentation
- Docker — Dockerfile reference — Every instruction, in detail.
- Docker — Building best practices — Layer caching, ordering and
.dockerignore. - Docker — Layer caching — How the build cache works and how to use it.
Next: multi-stage builds and keeping images small.
Stuck on this lesson?
Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.
About the internship