A representative Dockerfile
FROM node:20-slim
WORKDIR /app
ENV NODE_ENV=production
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY . .
EXPOSE 3000
USER node
CMD ["node", "server.js"]
Instruction by instruction
FROM— every Dockerfile starts with a base image. This sets the starting filesystem layers and OS/runtime foundation everything else builds on.RUN— executes a command at build time and commits its filesystem changes as a new layer. Used for installing packages, compiling code, or other build-time setup.COPY— copies files/directories from the build context (the directory you rundocker buildfrom) into the image's filesystem.WORKDIR— sets the working directory for all followingRUN,CMD,COPY, etc. Similar to runningcd, but it persists across instructions and creates the directory if it doesn't exist.ENV— sets an environment variable that persists into the running container, visible to the application at runtime. This is different fromARG, which only exists during the build.EXPOSE— purely documentation. It tells anyone reading the Dockerfile, and tools likedocker network, which ports the app listens on. It doesn't actually publish or open that port — you still need-pondocker runfor that.USER— sets which user later instructions run as, and which user the container's main process runs as by default. Important for not running as root.CMDandENTRYPOINT— both define what runs when a container starts, with an important behavioral difference between the two.
Additional instructions worth knowing
ARG BUILD_VERSION=dev # build-time-only variable (see the ARG vs ENV question)
LABEL maintainer="team@example.com" # arbitrary metadata attached to the image
VOLUME /data # documents/declares a mount point (see the storage topic)
HEALTHCHECK CMD curl -f http://localhost/health || exit 1 # see the container lifecycle topic
Why instruction order matters beyond just readability
Each instruction that touches the filesystem — RUN, COPY, ADD — creates a new cached layer. Docker's build cache invalidates from the first changed instruction onward, and every instruction after that must re-run, even if its own inputs didn't change. A Dockerfile is really executable documentation of how to build and run the application. That's why it deserves the same deliberate structure as any other piece of code, not just whatever order felt natural while developing.
Related Resources
COPY — simple, explicit, predictable
COPY package.json package-lock.json ./
COPY src/ ./src/
Copies files or directories from the build context straight into the image's filesystem, with no extra behavior. What you see is exactly what happens.
ADD — COPY, plus automatic extraction and remote URL fetching
# ADD automatically extracts a LOCAL tar archive into the destination
ADD myapp.tar.gz /app/
# ADD can fetch directly from a URL
ADD https://example.com/config.json /app/config.json
The first example is the behavior that most often surprises people. If the source is a recognized local archive format — .tar, .tar.gz, .tar.bz2 — ADD automatically unpacks it into the destination. COPY would just copy the compressed archive file itself, unextracted. Docker's own documentation calls out this "maybe it extracts, maybe it doesn't, depending on file type" behavior as a source of confusion.
Why COPY is the recommended default
- Predictability.
COPYdoes one simple thing, with no hidden conditional logic based on file type. ADD's remote-URL fetching is generally discouraged. Fetching a remote file directly in a Dockerfile instruction means the build result depends on a URL you don't control at build time — worse for reproducibility. The fetched file also isn't automatically cleaned up if it's only needed transiently. ARUN curl ... && ...in the same layer, or a multi-stage build, gives more explicit control.ADD's auto-extraction is useful in only one scenario: unpacking a local tarball as part of assembling the image. That's legitimate, but narrow enough that you should reach forADDdeliberately for that purpose, not out of habit. A remote URL fetch in particular is better done as an explicitRUN curl/wget, or a multi-stage build step that verifies the artifact, than viaADD's implicit behavior.
Related Resources
CMD alone — a default command, easily overridden
CMD ["node", "server.js"]
docker run myapp # runs: node server.js
docker run myapp node debug.js # OVERRIDES the entire CMD -- runs: node debug.js instead
Any arguments given after the image name on docker run completely replace the CMD. This makes CMD alone a good fit when the image should be flexible about what it runs — a general-purpose base image, or a dev image where you might want to run a shell or a different script for debugging.
ENTRYPOINT alone — a fixed command that always runs
ENTRYPOINT ["node", "server.js"]
docker run myapp # runs: node server.js
docker run myapp --port=9000 # runs: node server.js --port=9000 (appended as ARGS, not a replacement)
Arguments given at docker run are appended to the ENTRYPOINT, not used to replace it. Use ENTRYPOINT when the image should always run one specific thing, no matter what — it makes the container behave like a fixed, dedicated executable.
Combining both — the standard, recommended pattern
ENTRYPOINT ["node"]
CMD ["server.js"]
docker run myapp # runs: node server.js (CMD's default argument used)
docker run myapp debug.js # runs: node debug.js (CMD's default OVERRIDDEN, but still passed to ENTRYPOINT)
This gives you the best of both: ENTRYPOINT fixes what program runs — always node — while CMD provides a sensible default argument. That default is still easy to override for a one-off, without needing to override the entire command.
Exec form vs. shell form — a critical, easy-to-miss distinction
# Exec form (recommended): runs the command DIRECTLY, no shell involved
CMD ["node", "server.js"]
# Shell form: runs the command wrapped in "/bin/sh -c ..."
CMD node server.js
The exec form, using JSON array syntax, runs the program directly as PID 1 inside the container. Signals like SIGTERM, sent by docker stop, go straight to it, so it can shut down gracefully. The shell form instead runs /bin/sh -c "node server.js". The shell itself becomes PID 1, and it's the shell's job to forward signals to the actual application — a job it doesn't always do correctly. This is a common, subtle cause of containers that don't shut down gracefully: they ignore SIGTERM and only stop when docker stop's timeout forces a SIGKILL.
| Scenario | Recommended setup |
|---|---|
| Fixed, purpose-built application container | ENTRYPOINT + CMD (overridable default args) |
| General-purpose or dev image, command often replaced entirely | CMD alone |
| Either form, always | Exec (JSON array) syntax, for correct signal handling |
Related Resources
How the cache decides whether to reuse a layer
FROM node:20-slim # Layer A
WORKDIR /app # Layer B
COPY package.json ./ # Layer C -- cache key includes package.json's actual content
RUN npm install # Layer D -- cache key includes the PRECEDING layer + this instruction's text
COPY . . # Layer E -- cache key includes the content of every copied file
For each instruction, Docker computes a cache key from the preceding layer plus that instruction's own inputs. For RUN, that's the literal command text. For COPY/ADD, it's the actual content of the files being copied, not just their names. So even a single-character change in package.json invalidates Layer C — and, since caching is sequential, everything after it too.
Why this makes rebuilds fast — when structured well
# First build: everything builds from scratch
docker build -t myapp .
# ... (30 seconds, say, mostly spent on `npm install`)
# Change only application code (not package.json), rebuild:
docker build -t myapp .
# Layer A, B, C, D all CACHE HIT (package.json unchanged, so npm install's inputs are identical)
# Only Layer E (COPY . .) and anything after it actually re-executes
# ... (2 seconds)
npm install is often one of the slowest steps. Because it sits before the COPY . . that brings in frequently-changing application code, changing that code alone doesn't invalidate the expensive dependency-installation layer. This is the single most impactful Dockerfile optimization technique.
Why cache invalidation cascades forward, never backward
FROM node:20-slim
COPY package.json ./ # Layer C
RUN npm install # Layer D
COPY . . # Layer E <- if THIS changes, only E (and anything after) rebuilds
# C and D are unaffected, since their own inputs didn't change
If package.json changes instead, Layer C invalidates, and every layer from C onward — D and E — must rebuild too, even if Layer E's own inputs (the application code) didn't change. This "cascades forward from the first change" rule is why placing rarely-changing, expensive instructions like dependency installation before frequently-changing ones like application code is so valuable. It maximizes how often the expensive early layers reuse the cache.
Sharing cache and layers across images, not just across builds of the same image
Layers are content-addressed and stored once on a given host. Two entirely different images that happen to share an identical layer genuinely share that stored layer on disk — literally the same data, not just conceptually. This can happen when both images are built FROM node:20-slim with no differences up to some point. It saves both disk space and pull time when a machine already has one image with a shared base layer and pulls another.
Cache-busting techniques when you deliberately want to skip the cache
docker build --no-cache -t myapp . # ignore the cache entirely for this build
This is occasionally necessary when a RUN instruction's effects depend on something outside its literal text or copied files. For example, in RUN apt-get update && apt-get install -y curl, the actual packages fetched can change over time even though the instruction's text never does. This is a common, subtle source of "why did my rebuild not pick up the latest security patches" confusion — the cache has no way to know an identical-looking instruction might now behave differently against a changed remote package repository.
Related Resources
The anti-pattern: copying everything before installing dependencies
# BAD ORDERING
FROM node:20-slim
WORKDIR /app
COPY . . # copies EVERYTHING, including source code that changes constantly
RUN npm install # this layer's cache key now depends on the ENTIRE copied tree
CMD ["node", "server.js"]
With this ordering, changing any file in the project invalidates the COPY . . layer — even a single comment in a source file that has nothing to do with dependencies. That invalidates the npm install layer right after it, since its cache key depends on the preceding layer. Every build then re-runs the full dependency installation from scratch, even though package.json hasn't changed at all. That's a slow, entirely avoidable rebuild on every code change.
The fix: copy the dependency manifest first, install, then copy the rest
# GOOD ORDERING
FROM node:20-slim
WORKDIR /app
COPY package.json package-lock.json ./ # only the dependency manifest -- changes rarely
RUN npm ci # cached, as long as the manifest hasn't changed
COPY . . # application code -- changes constantly, but
# this is now the LAST filesystem-changing step
CMD ["node", "server.js"]
Now, changing application code only invalidates the final COPY . . layer. The npm ci layer, often much slower, stays cached as long as package.json/package-lock.json haven't changed — the common case for most day-to-day commits.
The general principle, stated once
Order instructions from least-likely-to-change to most-likely-to-change. System package installation and dependency installation, driven by a lockfile that changes rarely, belong early. Application source code, which changes on nearly every commit, belongs as late as possible.
FROM python:3.12-slim
RUN apt-get update && apt-get install -y libpq-dev # rarely changes
COPY requirements.txt . # changes occasionally
RUN pip install -r requirements.txt # cached unless requirements.txt changes
COPY . . # changes on every commit -- last
CMD ["python", "app.py"]
Combining related RUN instructions to control layer granularity
# Creates two separate layers, and (more importantly) leaves package-manager
# cache/lists behind in the FIRST layer even after the second layer "removes" them,
# since removal in a later layer doesn't shrink an earlier, already-committed layer
RUN apt-get update
RUN apt-get install -y curl && rm -rf /var/lib/apt/lists/*
# Better: combine into ONE layer so cleanup actually reduces that layer's size
RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/*
Each layer is immutable once committed, so "deleting" a file in a later layer doesn't reclaim the space it used in an earlier layer — it just hides that file from the merged view. Combining install-then-cleanup into a single RUN instruction makes the cleanup actually shrink that one resulting layer, instead of leaving bloat in an earlier layer that a later layer merely masks.
A quick self-check for any Dockerfile: change one line of application code, rebuild, and ask what the minimum set of layers should have needed to re-execute. If the rebuild touches an expensive dependency-installation step unrelated to that change, the ordering has room to improve. That's often the difference between a multi-minute rebuild and one that takes a couple of seconds.