On this page
- Your first Dockerfile works, and it's terrible
- The mental model: an image is a stack of layers
- The bad Dockerfile
- Fix 1, pin a slim base image
- Fix 2, order layers for caching
- Fix 3, add a .dockerignore
- Fix 4, multi-stage build + non-root user
- The payoff, measured
- Common mistakes that cost hours
- Takeaways
- Where to go next
Your first Dockerfile works, and it's terrible
Almost everyone's first Dockerfile follows the same pattern: start from a full OS image, copy everything in, install dependencies, run the app. It works, the container starts and serves traffic. Then reality arrives: the image is over a gigabyte, every tiny code change triggers a five-minute rebuild, a security scan lights up red, and it runs as root. None of that shows up in a tutorial; all of it shows up in production.
The good news: fixing it comes down to about five techniques, and once you understand *why* each works, you'll write good Dockerfiles by reflex. This article takes one bad Dockerfile and fixes it into a good one, explaining every change.
Who this is for
Anyone who can write a basic Dockerfile and run `docker build` but whose images are big, slow, or flagged by security tooling. We use a Node.js app as the example; the principles (caching, multi-stage, non-root, slim base) apply to Python, Go, Java, any language.
The mental model: an image is a stack of layers
A Docker image is a stack of read-only layers, one per instruction in your Dockerfile. Docker caches each layer and rebuilds a layer (and everything above it) only when that instruction's inputs change.
This one fact explains almost every best practice. Each RUN, COPY, and FROM creates a layer. On a rebuild, Docker walks down your Dockerfile reusing cached layers until it hits the first instruction whose inputs changed, then it rebuilds that layer and every layer below it. So the *order* of your instructions directly controls how much gets rebuilt.
The bad Dockerfile
Here's the version almost everyone writes first. It runs, and it does almost everything wrong. Read it, then we'll diagnose each problem.
FROM node # untagged โ unpredictable, and huge
WORKDIR /app
COPY . . # copies EVERYTHING, before installing deps
RUN npm install # cache busts on any file change
EXPOSE 3000
CMD npm start # runs as root- `FROM node`, untagged, so "node" means whatever the latest happens to be today. Builds aren't reproducible, and the full
nodeimage is ~1 GB of OS and tooling you don't need at runtime. - `COPY . .` before `npm install`, copies your entire source first, so *any* code change invalidates the cache for the install step. You reinstall every dependency on every one-line edit.
- No `.dockerignore`,
COPY . .drags innode_modules,.git, logs, and secrets, bloating the image and risking leaks. - No multi-stage build, the final image carries the full toolchain, dev dependencies, and source instead of just what's needed to run.
- Runs as root, the default. If the app is compromised, the attacker is root inside the container, which is a real escalation risk.
Fix 1, pin a slim base image
Two changes, big payoff. Pin an explicit version so builds are reproducible, and pick a slim variant so you're not shipping a gigabyte of unused OS. node:20-slim is a Debian-slim base; node:20-alpine is even smaller (Alpine Linux). Alpine is tiny but uses musl libc, which occasionally trips up native modules, slim is the safe default; reach for alpine when you've verified your deps work on it.
# Pinned + slim: reproducible and ~10x smaller than `node`
FROM node:20-slimNever ship `latest`
`FROM node` (or `FROM node:latest`) means your build depends on whatever Docker Hub serves that day. A new major version can break your build with zero changes on your side. Pin to a specific version, ideally a digest (`node:20-slim@sha256:...`) for fully immutable builds.
Fix 2, order layers for caching
This is the single highest-impact change. Your dependencies change rarely; your source code changes constantly. So copy the dependency manifest and install *first*, then copy the source. Now an edit to your code reuses the cached dependency layer, installs become instant.
WORKDIR /app
# 1. Copy ONLY the manifest first, changes rarely
COPY package.json package-lock.json ./
# 2. Install, this layer is cached until the manifest changes
RUN npm ci
# 3. NOW copy source, changes often, but doesn't bust the install
COPY . .Read the comments as a rule you can apply in any language: copy the thing that changes least, first. In Python it's requirements.txt before your code; in Go it's go.mod/go.sum. Same principle, same dramatic speedup on every rebuild after the first.
Fix 3, add a .dockerignore
Even with good ordering, COPY . . will happily copy node_modules, your .git history, local env files, and logs into the image. A .dockerignore (same syntax as .gitignore) keeps them out, smaller image, faster builds, and no accidentally-baked-in secrets.
node_modules
npm-debug.log
.git
.gitignore
.env
.env.*
Dockerfile
.dockerignore
coverage
dist
*.mdPro tip
Always ignore `node_modules` (you reinstall it inside the image anyway), `.git`, and any `.env` files. Copying a local `.env` into an image is one of the most common ways secrets accidentally end up published in a registry.
Fix 4, multi-stage build + non-root user
The big two. A multi-stage build uses one stage to build (with all the dev tooling) and a second, clean stage that copies in *only* the finished artifact, so the toolchain never ships. And switching to a non-root user means a compromised app isn't a root shell. Here's the full, good Dockerfile:
# ---- Stage 1: build ----
FROM node:20-slim AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci # includes dev deps for the build
COPY . .
RUN npm run build # produce ./dist
# ---- Stage 2: runtime ----
FROM node:20-slim AS runtime
WORKDIR /app
ENV NODE_ENV=production
# Install ONLY production deps
COPY package.json package-lock.json ./
RUN npm ci --omit=dev && npm cache clean --force
# Copy just the built output from the build stage
COPY --from=build /app/dist ./dist
# Run as a non-root user (the node image ships one called 'node')
USER node
EXPOSE 3000
CMD ["node", "dist/server.js"]Trace what the final image actually contains: the slim base, production dependencies only, and the built ./dist, nothing else. The compiler, dev dependencies, and source code all stayed in the build stage and were thrown away. The USER node line drops privileges. And CMD uses the array form (["node", "dist/server.js"]), which runs the process directly instead of wrapping it in a shell, so signals like graceful-shutdown reach your app correctly.
Pro tip
The official `node` images already include a non-root user named `node`. For other bases, create one: `RUN useradd -m appuser && USER appuser`. The rule is simply: never let the default (root) run your app.
The payoff, measured
These aren't abstract improvements. Here's the before/after for a typical Node service. Your exact numbers vary, but the shape is always this dramatic.
| Bad Dockerfile | Good Dockerfile | |
|---|---|---|
| Final image size | ~1.1 GB | ~180 MB |
| Rebuild after a code edit | ~4โ5 min (reinstalls deps) | ~10 sec (cache hit) |
| Ships build toolchain? | Yes | No (multi-stage) |
| Runs as | root | non-root (node) |
| Reproducible build? | No (untagged base) | Yes (pinned base) |
Smaller images push and pull faster (cheaper CI, faster deploys), the cache turns multi-minute rebuilds into seconds, and the non-root + slim base shrinks your attack surface. Every one of these compounds across a team shipping dozens of times a day.
Common mistakes that cost hours
- Untagged base images.
FROM nodeis a time bomb, pin a version so a new release can't silently break your build. - `COPY . .` before installing deps. This single ordering mistake makes every code change reinstall every dependency. Copy the manifest and install first.
- No `.dockerignore`. Without it you bloat the image and risk copying
.envfiles and.gitstraight into a published image. - Shipping the build toolchain. If your runtime image contains a compiler and dev dependencies, you skipped multi-stage. Build in one stage, copy the artifact into a clean one.
- Running as root. The default, and a real risk. Add a
USERline so a compromised app isn't a privileged one. - Shell-form CMD.
CMD npm startwraps your app in a shell that can swallow shutdown signals. Use the array form so signals reach your process.
Takeaways
Good Dockerfiles in seven lines
- An image is a stack of cached layers, order instructions least-changed first.
- Pin a slim base (node:20-slim), never `latest` or untagged.
- Copy the dependency manifest and install BEFORE copying source, to keep the cache.
- Add a .dockerignore to keep node_modules, .git, and .env out of the image.
- Use a multi-stage build so the toolchain never ships in the final image.
- Add a USER line, never run your app as root.
- Use array-form CMD so shutdown signals reach your process.
Where to go next
You can build a small, fast, secure image. Next, see where containers fit in the bigger picture, practice the commands hands-on, and wire your image build into CI.
- Docker vs Kubernetes: When to Use Each, once you have great images, where do they run?
- Practice in the Docker lab, build, tag, and inspect images in an in-browser terminal.
- Your First CI Pipeline with GitHub Actions, build and push this image automatically on every commit.
Want to go deeper?
This article covers concepts taught hands-on in the Cloud Engineer and DevOps career paths, with real terminal labs, production scenarios, and structured lessons.