Back to Blog
DevOps15 min readJun 2026

Dockerfile Best Practices: Small, Fast, Secure Images

A naive Dockerfile produces a 1.2 GB image that rebuilds from scratch on every change and runs as root. A good one is 80 MB, rebuilds in seconds, and runs unprivileged. This is the practical guide: layer caching, multi-stage builds, .dockerignore, pinned base images, and a non-root user, shown as a bad Dockerfile fixed into a good one.

DockerContainersDevOpsBest Practices
SB

Sri Balaji

Founder ยท TheSimplifiedTech

On this page

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.

๐Ÿ“„ A stack of tracing sheetsThe image's layers
โœ๏ธ Redraw one sheet โ†’ redraw all above itChange one layer โ†’ rebuild all later layers
๐Ÿ—‚๏ธ Put rarely-changed sheets at the bottomPut rarely-changed steps early in the Dockerfile
๐Ÿšช Leave the building tools outside the houseMulti-stage: don't ship the build toolchain
Think of layers like a stack of transparent sheets, change one sheet and you redraw it and everything stacked on top.

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.

Dockerfile (bad)
Dockerfile
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 node image 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 in node_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.

Dockerfile (excerpt)
Dockerfile
# Pinned + slim: reproducible and ~10x smaller than `node`
FROM node:20-slim

Never 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.

Dockerfile (excerpt)
Dockerfile
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.

.dockerignore
bash
node_modules
npm-debug.log
.git
.gitignore
.env
.env.*
Dockerfile
.dockerignore
coverage
dist
*.md

Pro 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:

Dockerfile (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 DockerfileGood 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?YesNo (multi-stage)
Runs asrootnon-root (node)
Reproducible build?No (untagged base)Yes (pinned base)
Same app, bad Dockerfile vs. good. The rebuild and security wins matter on every single commit.

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

  1. Untagged base images. FROM node is a time bomb, pin a version so a new release can't silently break your build.
  2. `COPY . .` before installing deps. This single ordering mistake makes every code change reinstall every dependency. Copy the manifest and install first.
  3. No `.dockerignore`. Without it you bloat the image and risk copying .env files and .git straight into a published image.
  4. 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.
  5. Running as root. The default, and a real risk. Add a USER line so a compromised app isn't a privileged one.
  6. Shell-form CMD. CMD npm start wraps 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.

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.