Code Safari

Chapter 120·Intermediate·9 min read

Writing a Dockerfile

A plain-English guide to writing a Dockerfile — the recipe that builds an image. The key instructions (FROM, WORKDIR, COPY, RUN, EXPOSE, CMD), how each becomes a cached layer, why instruction order makes builds fast or slow, and .dockerignore. Package your own app into an image.

July 18, 2026

So far you've run images that other people built. This chapter is where you package your own application into an image — and the tool for that is a Dockerfile: a short recipe of instructions that Docker follows to assemble your app and its environment into a shippable image. It's one of the highest-leverage files in a modern codebase, and writing one well (not just correctly) is a real skill. Everything here builds on the last chapter's layers idea — because in a Dockerfile, every line is a layer, and that fact shapes how you write it.

The Dockerfile is a recipe

A Dockerfile is a plain text file, named exactly Dockerfile, that lists the steps to build an image — in order, top to bottom. When you run docker build, Docker reads the file and executes each instruction in sequence, and the result is a finished image. It's a recipe in the most literal sense: start with these base ingredients, add these things, run these steps, and here's the command to serve it.

Here's a complete, realistic Dockerfile for a small Node.js web app. Don't worry about every line yet — we'll take it apart instruction by instruction:

FROM node:20
 
WORKDIR /app
 
COPY package.json package-lock.json ./
RUN npm install
 
COPY . .
 
EXPOSE 3000
 
CMD ["node", "server.js"]

Eight lines, and they turn a folder of source code into a portable image that runs identically anywhere. Let's understand each verb.

FROM: stand on someone else's shoulders

Every Dockerfile begins with FROM, which names the base image you build on top of:

FROM node:20

This says: start from the official node:20 image — a ready-made environment that already has Node.js 20 and its tooling installed on a minimal Linux base. You're not building an operating system from scratch; you're inheriting a working one and adding your app to it. There are base images for every language and tool — python:3.11, golang:1.22, nginx, or a bare-bones ubuntu or alpine if you want to start minimal. Choosing a good base is the first decision of any Dockerfile, and it's usually "the official image for my language, at the version I need."

WORKDIR, COPY, and RUN: building it up

WORKDIR sets the working directory inside the image — the folder that subsequent instructions operate in, and where the container starts. It's the container's equivalent of cd:

WORKDIR /app

From here on, everything happens in /app inside the image.

COPY brings files from your project (the "build context" — the folder you run the build from) into the image:

COPY package.json package-lock.json ./

This copies those two files from your machine into the image's current directory (/app). The source is on your host; the destination is inside the image.

RUN executes a command during the build, and whatever it produces becomes part of the image:

RUN npm install

This runs npm install at build time, downloading the app's dependencies into the image. The distinction that trips people up: RUN happens once, while building the image; it's baked into the result. (Contrast that with CMD, coming next, which runs each time a container starts.) So RUN is for setup that should be permanent — installing packages, compiling code — and its output is captured into a layer.

EXPOSE and CMD: declaring how it runs

EXPOSE documents which port the app listens on inside the container:

EXPOSE 3000

An honest note: EXPOSE is mostly documentation — it signals intent but doesn't actually publish the port. You still map ports at run time with docker run -p (from the last chapter), which is what truly connects the container to the host. Think of EXPOSE as a label telling readers "this app uses port 3000."

CMD sets the default command that runs when a container starts from this image:

CMD ["node", "server.js"]

This is the one that brings the app to life. When someone does docker run your-image, Docker launches node server.js inside the container. The critical contrast to lock in: RUN executes at build time and bakes into the image; CMD executes at run time, each time a container starts. RUN npm install happens once when you build; CMD ["node", "server.js"] happens every time the app boots. Mixing these two up is the single most common Dockerfile confusion.

docker build reads the Dockerfile
FROM / COPY / RUN — build the image (once)
Image is finished and frozen
docker run — CMD starts the app (every time)
Build time vs run time

Every instruction is a layer — and this is everything

Now the idea that separates a Dockerfile that builds in seconds from one that builds in minutes. Recall from the last chapter that images are stacks of cached layers. In a Dockerfile, each instruction creates one layer, and Docker caches them: on a rebuild, it reuses cached layers for instructions that haven't changed, and only rebuilds from the first changed instruction downward.

That "downward" is the key. If you change an instruction near the top, every layer below it must rebuild too, because they might depend on it. Change something near the bottom, and only that last bit rebuilds. So the order of your instructions directly controls how much work each rebuild costs.

Look again at why our example copies things in two steps instead of one:

COPY package.json package-lock.json ./   # 1. just the dependency list
RUN npm install                          # 2. install dependencies (slow!)
COPY . .                                 # 3. now the rest of the code

Here's the payoff. Your dependency list (package.json) changes rarely; your source code changes constantly. By copying just the dependency files first and running the slow npm install before copying your code, you arrange it so that editing your code only invalidates the cache from step 3 onward. The expensive npm install layer stays cached, because nothing above it changed. Rebuilds after a code edit take seconds.

Do it the naive way — COPY . . (everything) then RUN npm install — and every single code edit changes a layer above the install step, forcing npm install to run again from scratch, every time. Same final image, wildly worse build times.

.dockerignore: don't ship the junk

One companion file earns its place. When you run docker build, Docker sends the whole project folder (the "build context") to the build — and you almost never want all of it. Your node_modules, your .git history, local logs, secrets — none of that belongs in the image, and sending it makes builds slower and images bigger.

A .dockerignore file, which works just like .gitignore, lists what to leave out:

node_modules
.git
*.log
.env

This keeps the build fast (less to send), the image small (less copied in), and — importantly — keeps secrets like a .env file from accidentally being baked into an image you might share. It's a small file that quietly prevents real problems.

Building and running your image

With the Dockerfile written, two commands take you from source code to a running container:

$ docker build -t myapp .          # build an image named 'myapp' from here
$ docker run -p 3000:3000 myapp    # run it, mapping the port

The -t myapp tags (names) the image, and the . tells Docker to build using the current directory as the context. Then docker run starts a container from it, and — thanks to -p 3000:3000 — your browser can reach the app at localhost:3000. Your application is now a portable image: hand it to a teammate, push it to a server, run it in a pipeline, and it behaves identically everywhere, exactly as the first chapter promised.

From here

You can now package any application into an image and run it. But there's a loose thread from chapter two: containers are disposable, and their writable layer vanishes when they're removed. That's perfect for the app itself — but what about a database's data, or files a user uploaded? You can't have those evaporate every time a container restarts. The next chapter, on volumes, is how Docker lets data outlive the container that created it — the final piece of the local-development picture before we move, tomorrow, into networking, Compose, and shipping to production.

Writing a Dockerfile | Code Safari