A Dockerfile that works and a Dockerfile that's actually good for production are different things — a 1.2GB image running as root with dev dependencies baked in works fine right up until it's slow to deploy or the thing you shipped has a permission it never needed.
4 min read
.dockerignoreBefore anything else: docker build sends the entire build context (every file in the directory, by default) to the Docker daemon before it even reads the Dockerfile. Without a .dockerignore, that includes node_modules, .git, local .env files, and build artifacts — slowing every build and risking a genuine secret leak if a .env file ever gets COPY .'d into an image layer.
# .dockerignore
node_modules
.git
.env
.env.local
*.log
dist
coverage
This mirrors .gitignore in spirit: keep things that don't belong in the image out of the build context entirely, rather than trusting every COPY instruction to be written carefully.
| Base image | Approximate size | Trade-off |
|---|---|---|
node:20 (full Debian) | ~1.1 GB | Every tool available; slow to pull, large attack surface |
node:20-slim | ~250 MB | Debian-minimal; missing some build tools |
node:20-alpine | ~180 MB | Much smaller; uses musl libc, which occasionally breaks native npm packages |
gcr.io/distroless/nodejs20 | ~120 MB | No shell, no package manager, nothing but the runtime — smallest attack surface, hardest to debug interactively |
There's no universally "right" answer — alpine is the common default for size, but a native dependency that only builds against glibc can turn "smaller image" into "broken build," so test the actual base image against your dependency tree rather than assuming.
Every RUN, COPY, and ADD instruction creates a layer, and Docker reuses cached layers only up to the first instruction whose input changed. The practical rule: order instructions from least likely to change to most likely to change.
# GOOD — dependency layer is cached across every code change
FROM node:20-slim
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
CMD ["node", "dist/index.js"]# BAD — every code change reinstalls every dependency from scratch
FROM node:20-slim
WORKDIR /app
COPY . .
RUN npm ci --omit=dev
CMD ["node", "dist/index.js"]The only difference is instruction order, but the second version turns a 2-second rebuild into a 45-second one on every single code change, because COPY . . runs first and its changed content invalidates every layer after it — including the expensive npm ci.
RUN instructions where it actually mattersEach RUN is a layer, and each layer adds a small amount of image size even for things later deleted in a different layer:
# BAD — the apt cache added in layer 1 is still in the image,
# even though layer 2 "removes" it — removal in a later layer
# doesn't shrink an earlier layer, it just hides the files
RUN apt-get update && apt-get install -y curl
RUN rm -rf /var/lib/apt/lists/*# GOOD — install and cleanup happen in the same layer,
# so the cache never becomes part of the final image
RUN apt-get update && apt-get install -y curl \
&& rm -rf /var/lib/apt/lists/*This only matters for instructions that add and then remove data in the same conceptual step (package manager caches, temp build files) — don't over-apply it to instructions that have nothing to clean up, since collapsing everything into one giant RUN just kills readability and cache granularity for no benefit.
By default, a container's process runs as root inside the container. If an attacker finds a remote code execution vulnerability in the app, root inside the container is a meaningfully worse starting point than an unprivileged user — especially combined with any container-escape vulnerability, where root-in-container can become root-on-host.
FROM node:20-slim
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
RUN addgroup --system app && adduser --system --ingroup app app
USER app
CMD ["node", "dist/index.js"]Official images increasingly ship a pre-made unprivileged user for exactly this (node on the node images, for example) — check the base image's documentation before hand-rolling one.
A secret passed via ARG or ENV and used in a RUN command is not removed by a later RUN rm — it's already baked into an earlier, still-present layer, extractable with docker history or by anyone who can pull the image.
# WRONG — this API key is permanently in the image's layer history
ARG NPM_TOKEN
RUN echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" > .npmrc && npm ci# RIGHT — BuildKit secret mounts never get written to a layer at all
RUN --mount=type=secret,id=npm_token \
NPM_TOKEN=$(cat /run/secrets/npm_token) npm cidocker build --secret id=npm_token,src=./npm_token.txt .The secret exists only for the duration of that one RUN instruction's execution and is never committed to any layer — a fundamentally different guarantee than "delete it in a later step."
Check your understanding
A quick comprehension check — not tracked, not graded, just for you.
1. Why does 'docker build' need a .dockerignore file even before any Dockerfile instructions run?
2. Why doesn't a later 'RUN rm -rf /var/lib/apt/lists/*' shrink the image if it's in a separate RUN from the install?
3. Why run a container process as a non-root user?
4. Why does deleting a secret file in a later RUN step not actually remove it from the image?
Docker & Containers