The last two parts of this series ran images someone else built: hello-world, ubuntu, nginx. That covers a lot of ground, but sooner or later you need to package your own application. That is what a Dockerfile is for.

A Dockerfile is a recipe, not a script

A Dockerfile is a plain-text list of instructions for assembling an image. It reads top to bottom, and each instruction produces a new, cached layer stacked on the one before it. That is different from a shell script: a script just runs commands. A Dockerfile builds an artifact, one immutable layer at a time, and Docker can reuse a layer instead of rebuilding it if nothing that would affect it has changed.

That caching behavior is the single most important thing to understand about writing a good Dockerfile, and it shapes almost every recommendation below.

The instructions that cover almost everything

A short list of directives handles the overwhelming majority of real Dockerfiles:

  • FROM <image> — the base image everything else builds on. Every Dockerfile starts here.
  • WORKDIR <path> — sets the working directory for every instruction after it, and creates the directory if it does not exist.
  • COPY <src> <dest> — copies files from your build context (your project, on disk) into the image.
  • RUN <command> — executes a command while building the image and bakes the result into a layer.
  • CMD [<command>] — the default command a container runs when it starts, unless overridden.
  • EXPOSE <port> — documents which port the application listens on. It does not publish anything by itself; you still need -p at docker run time.

RUN and CMD are the pair people mix up first. RUN happens once, at build time, and changes the image. CMD happens every time a container starts, and does not change the image at all.

A real example

Here is a small, complete Dockerfile for a Node.js application:

FROM node:20-alpine

WORKDIR /app

COPY package.json package-lock.json ./
RUN npm ci --omit=dev

COPY . .

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

Read it the way Docker does: start from node:20-alpine, a small Linux image with Node.js preinstalled; set the working directory; copy just the dependency manifests; install dependencies; copy the rest of the source; run the app when a container starts.

Every instruction is a layer

Each FROM, COPY, and RUN produces its own layer, stacked on the ones before it:

FROM node:20-alpineWORKDIR /appCOPY package.jsonpackage-lock.jsonRUN npm ciCOPY . .CMD node server.js

Docker caches each layer. When you rebuild, it walks down the stack and reuses every layer whose inputs have not changed—stopping and rebuilding fresh from the first layer that has changed, and rebuilding everything below it too.

Order matters: put what changes least at the top

That caching behavior is exactly why the example above copies package.json and installs dependencies before copying the rest of the source code, instead of one simple COPY . . followed by RUN npm ci.

Source code changes constantly. Dependency manifests change rarely. If you copy everything at once, every source change invalidates the dependency-install layer too, and every build reinstalls every package from scratch. Split it, and a source-only change reuses the cached install layer—turning a multi-minute rebuild into a few seconds.

The general rule: order instructions from least-frequently-changing to most-frequently-changing.

Keep the build context clean: .dockerignore

When you run docker build, Docker sends the entire directory you point it at—the build context—to the engine, before a single instruction runs. If that directory contains node_modules, .git, or build artifacts, you are uploading megabytes or gigabytes of files that will never be used, on every single build.

A .dockerignore file fixes this, using the same pattern syntax as .gitignore:

node_modules
.git
.env
*.log
dist

Anything listed there never reaches the build context at all, which makes builds faster and keeps secrets like .env from ever having a chance of being copied into an image by an unrelated COPY . ..

Building and running your image

Build it, then run it like any other image:

docker build -t my-app:latest .
docker images
docker run -d -p 3000:3000 my-app:latest

-t my-app:latest tags the resulting image with a name and version so you can refer to it later. The trailing . tells Docker where the build context is—almost always the current directory.

Common mistakes

  • Copying everything before installing dependencies. COPY . . followed by RUN npm ci busts the dependency cache on every source change. Copy manifests first.
  • No .dockerignore. Without one, .git, node_modules, and local secrets can end up inside the build context—and sometimes inside the image itself.
  • Treating latest as a version. latest is just a tag, not a guarantee of what is actually inside. Pin real versions for anything that matters.
  • One giant RUN for everything. A single sprawling RUN with dozens of chained commands is hard to read and hard to cache selectively. Group logically related steps; separate steps that change at different rates.

Practical checklist

  • My Dockerfile copies dependency manifests and installs dependencies before copying the rest of the source.
  • A .dockerignore file excludes node_modules, .git, and any local secrets from the build context.
  • I understand the difference between RUN (build time) and CMD (container start time).
  • I have built an image with docker build -t <name>:<tag> . and run a container from it.
  • I am not relying on the latest tag for anything I need to reproduce reliably.

Next in the series: Volumes, Networking, and Environment Variables. We will cover what happens to data and configuration once your own image is running.