Images and Dockerfiles
A complete multi-stage Dockerfile, layer caching, and building images with docker build.
Images vs. containers
An image is a read-only template — a set of filesystem layers plus metadata (default command, exposed ports, environment) describing how to run an application. A container is a running (or stopped) instance created from an image, with its own writable layer on top. The relationship is the same as a class and an object: one image can spawn any number of independent containers.
A Dockerfile for a real Node.js app
A Dockerfile is the recipe used to build an image, line by line. Here's a complete, production-realistic example for a small Node.js API, using a multi-stage build — one stage to install dependencies and compile TypeScript, and a second, much smaller stage that only contains what's needed to actually run the app:
# ---- Stage 1: build ----
FROM node:20-alpine AS build
WORKDIR /app
# Copy only the dependency manifests first, so this layer is cached
# unless package.json/package-lock.json actually change.
COPY package.json package-lock.json ./
RUN npm ci
# Now copy the rest of the source and compile TypeScript -> JavaScript.
COPY . .
RUN npm run build
# ---- Stage 2: runtime ----
FROM node:20-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production
# Only install production dependencies — no dev tools, no TypeScript compiler.
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
# Copy just the compiled output from the build stage, not its node_modules
# or source files.
COPY --from=build /app/dist ./dist
# Run as a non-root user for defense in depth.
USER node
EXPOSE 3000
CMD ["node", "dist/server.js"]
Breaking down what's happening:
FROM node:20-alpine AS buildstarts from an official Node.js base image (alpineis a minimal Linux distribution, keeping image size small) and names this stagebuildso it can be referenced later.COPY package.json package-lock.json ./beforeCOPY . .is a deliberate ordering, not an accident — explained in the caching section below.RUN npm ciinstalls exact dependency versions from the lockfile (ciis faster and stricter thannpm installfor reproducible builds).COPY --from=build /app/dist ./distpulls only the compiled JavaScript output from the first stage into the second — none of the first stage'snode_modules, TypeScript compiler, or source.tsfiles end up in the final image at all.USER nodeswitches to a non-root user (the official Node.js image ships one) for the rest of the Dockerfile — running production containers as root is unnecessary risk.CMDis the default command run when a container starts from this image; unlikeRUN(executed during the build),CMDonly runs when the container actually starts.
Layer caching
Every instruction in a Dockerfile (FROM, COPY, RUN, ...) creates a new, cached layer. When you rebuild an image, Docker reuses cached layers for any instruction whose inputs haven't changed, and only re-executes from the first changed instruction onward.
This is exactly why the Dockerfile above copies package.json/package-lock.json and runs npm ci before copying the rest of the source code:
COPY package.json package-lock.json ./
RUN npm ci # <- cached unless package*.json changed
COPY . . # <- source changes constantly
RUN npm run build
If you'd written COPY . . first, then RUN npm ci, any source file change — even a one-line comment fix — would invalidate the cache for COPY . ., which in turn forces npm ci to rerun from scratch, reinstalling every dependency on every single build. Ordering instructions from "changes rarely" to "changes often" is one of the highest-leverage habits in writing a fast-building Dockerfile.
Building and inspecting images
# Build an image from the Dockerfile in the current directory, tagging it
$ docker build -t my-app:1.0 .
[+] Building 24.3s (14/14) FINISHED
=> [build 1/5] FROM docker.io/library/node:20-alpine
=> [build 2/5] WORKDIR /app
=> [build 3/5] COPY package.json package-lock.json ./
=> [build 4/5] RUN npm ci
=> [build 5/5] COPY . .
=> [runtime 3/5] COPY package.json package-lock.json ./
=> [runtime 4/5] RUN npm ci --omit=dev
=> [runtime 5/5] COPY --from=build /app/dist ./dist
=> exporting to image
=> naming to docker.io/library/my-app:1.0
# List local images
$ docker images
REPOSITORY TAG IMAGE ID CREATED SIZE
my-app 1.0 a1b2c3d4e5f6 2 minutes ago 187MB
# Run a container from it, mapping container port 3000 to host port 3000
$ docker run -p 3000:3000 my-app:1.0
# Remove an image no longer needed
$ docker rmi my-app:1.0
Rebuilding after only changing application source (not dependencies) is dramatically faster than the first build, because every layer up through RUN npm ci in the build stage is reused straight from cache:
$ docker build -t my-app:1.1 .
[+] Building 3.1s (14/14) FINISHED
=> CACHED [build 3/5] COPY package.json package-lock.json ./
=> CACHED [build 4/5] RUN npm ci
=> [build 5/5] COPY . .
Common mistakes
- Copying the entire project (
COPY . .) before installing dependencies — this invalidates the dependency-install layer's cache on every source change, making every build as slow as the first. - Skipping multi-stage builds and shipping the compiler, dev dependencies, and full source tree in the final runtime image — larger image, larger attack surface, slower deploys.
- Not adding a
.dockerignorefile (works like.gitignore) — without one,COPY . .can accidentally includenode_modules,.git, or.envfiles from the host into the build context and image. - Running the container's main process as
rootwhen a non-root user is available and sufficient, unnecessarily widening the blast radius of a container-escape vulnerability.
Interview questions
Q: What is a multi-stage Dockerfile, and why use one?
It's a Dockerfile with more than one FROM instruction, where each FROM starts a new build stage, and a later stage can selectively copy artifacts from an earlier one via COPY --from=<stage>. It lets you use a heavier stage with full build tooling (compilers, dev dependencies) to produce the artifact, while the final shipped image only contains that artifact and a minimal runtime — much smaller and with a much smaller attack surface than shipping the build tools too.
Q: How does Docker's layer caching work, and why does instruction order in a Dockerfile matter? Each Dockerfile instruction produces a cached layer, and Docker reuses a layer unchanged if that instruction's inputs (the instruction itself and any files it copies) haven't changed since the last build; the moment one instruction's cache is invalidated, every instruction after it must also rerun. Ordering instructions from least-frequently-changing (installing dependencies) to most-frequently-changing (copying application source) maximizes how often the cache is reused, which is why dependency manifests are typically copied and installed before the rest of the source.
Q: What's the difference between RUN and CMD in a Dockerfile?
RUN executes a command during the image build itself, and its result (e.g., installed packages, compiled files) is baked into the resulting image layer. CMD specifies the default command that runs only when a container is started from the image — it doesn't execute at build time and can be overridden by whoever runs the container.