RizTech Academy logo
RizTech Academy
ContainersLesson 3 of 640 min

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-alpine gives you Node 20 on a tiny Alpine Linux. Always start from an official, specific base (node:20-alpine, not node: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 of cd.
  • COPY — copies files from your project into the image (COPY src ./src). ADD is similar but does more (and surprising things); prefer COPY.
  • 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 -p at 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 one CMD; it is the app's entrypoint. (ENTRYPOINT is 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 — not latest).
  • Orders instructions for caching (dependencies before source).
  • Has a .dockerignore so only what is needed is copied, and no secrets.
  • Installs only what it needs (--omit=dev for 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

  1. Write a Dockerfile for the greetings app; docker build -t greetings:1.0 . and run it.
  2. Explain each instruction (FROM, WORKDIR, COPY, RUN, EXPOSE, CMD) in your Dockerfile.
  3. Explain the difference between RUN and CMD with an example of each from your file.
  4. Edit only a source file and rebuild; confirm the npm ci layer was cached. Then reorder so COPY . . comes first and show the cache breaks.
  5. Add a .dockerignore and explain what each entry excludes and why (including why .env must be excluded).
  6. Explain why pinning node:20-alpine instead of node:latest matters for reproducible builds.

Official documentation

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