Docker for Backend Engineers: Images, Layers, and Small Containers
Docker stops being magic once you understand three things: what an image really is, how the layer cache decides your build speed, and why multi-stage builds turn a 1.2GB image into 80MB. A practical tour.
"Works on my machine" is the problem Docker was built to kill. It packages your app and its entire environment — runtime, libraries, system deps — into an image that runs identically on your laptop, in CI, and in production. But most people copy a Dockerfile off the internet and never learn the three ideas that make the difference between a fast, 80MB image and a slow, 1.2GB one.
Image vs container
The distinction that everything else builds on: an image is the immutable blueprint — a snapshot of a filesystem plus a command to run. A container is a running instance of that image. One image, many containers, like a class and its objects. You build an image once and run it anywhere, as many times as you want.
Images are stacks of layers
A Docker image isn't one blob — it's a stack of layers, and each instruction in your Dockerfile creates one. This is the single most important thing to understand, because layers are cached, and the cache decides your build speed.
FROM node:22-slim # layer: base image
WORKDIR /app
COPY package.json pnpm-lock.yaml ./ # layer: dependency manifest
RUN pnpm install # layer: installed dependencies
COPY . . # layer: your source code
RUN pnpm build # layer: build output
CMD ["node", "server.js"]When you rebuild, Docker reuses cached layers up to the first one that changed, then rebuilds everything after it. That's why the order above matters enormously.
Order your Dockerfile by change frequency
Here's the payoff. Dependencies change rarely; source code changes constantly. If you COPY . . before installing dependencies, then every one-line code change invalidates the cache and reinstalls every dependency from scratch — a 90-second build for a typo fix.
By copying the lockfile and installing first, then copying source, the expensive install layer stays cached across every code change. Only the cheap "copy source + build" layers rerun.
Multi-stage builds: ship only what runs
Your build needs compilers, dev dependencies, and the full toolchain. Your runtime needs almost none of that. A multi-stage build uses one stage to build and a clean, minimal stage to run — copying only the finished artifact across.
# Stage 1: build (has the full toolchain)
FROM node:22 AS build
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN pnpm install
COPY . .
RUN pnpm build
# Stage 2: runtime (clean and small)
FROM node:22-slim
WORKDIR /app
COPY --from=build /app/dist ./dist
COPY --from=build /app/node_modules ./node_modules
CMD ["node", "dist/server.js"]The final image contains only the runtime and the build output — none of the source, dev dependencies, or build tools. This routinely cuts image size by an order of magnitude, which means faster deploys, faster cold starts, and a smaller attack surface.
A few habits that pay off
- Use a
.dockerignore. Excludenode_modules,.git, and local env files so they don't get copied into the image (bloating it) or bust the cache. - Pin base image versions.
node:22-slim, notnode:latest. "Latest" means your build changes under you without warning. - Prefer slim/alpine bases for runtime — smaller and less to patch — but test, since alpine's different C library occasionally trips up native modules.
- Run as a non-root user in the runtime stage. A container breakout as root is far worse than as an unprivileged user.
The mental model
Docker is just "your app plus its environment, as a portable, layered, immutable artifact." Understand that images are cached layers (so order by change frequency), and that build-time and run-time needs are different (so use multi-stage), and you've got the 20% that delivers 80% of the value. Everything else — networking, volumes, compose — is detail you layer on once these fundamentals are solid.