Code Beautifier
DevOps & Config

Dockerfile mistakes that bloat your image

Five habits behind a 900 MB image: the wrong base, no multi-stage build, cache-busting COPY order, leftover package caches, and secrets baked into layers.

A Node service that ships as a 900 MB image is not unusual. It is also not necessary: the same service, built carefully, is usually under 150 MB, pulls in a fraction of the time, and has a much smaller surface for vulnerability scanners to complain about. The gap is almost never one big mistake. It is five small habits, each of which looks reasonable on its own.

1. Starting from the wrong base

FROM node:20 gives you a full Debian install with compilers, documentation, and a package manager — around 1 GB before your code arrives. The same Node runtime on node:20-slim is about 200 MB; on node:20-alpine, about 130 MB.

The slim variants drop things you need only at build time. If your npm install compiles native modules, you may need the full image to build and the slim image to run — which is the next point.

Pick the smallest base that runs your code, and pin it: node:20.11-slim, not node:latest. An unpinned tag makes every build a different build.

2. Building and running in the same stage

Everything you install to build the application — compilers, dev dependencies, the source tree, the test suite — stays in the image unless you deliberately leave it behind. A multi-stage build is the mechanism for leaving it behind:

FROM node:20.11 AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build && npm prune --omit=dev

FROM node:20.11-slim
WORKDIR /app
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
USER node
CMD ["node", "dist/server.js"]

The final image contains the runtime, the production dependencies, and the compiled output. Nothing else. For compiled languages the effect is more dramatic still: a Go binary on a scratch or distroless base is a few megabytes.

3. COPY . . before installing dependencies

Docker caches each layer and reuses it if the instruction and its inputs are unchanged. COPY . . followed by RUN npm install means any change to any file — a README edit — invalidates the copy layer and forces a full reinstall on every build.

Copy the dependency manifest first, install, then copy the source. The install layer is reused until package.json or the lockfile actually changes, which turns a three-minute build into a twenty-second one. This ordering is the single biggest build-time win in most Dockerfiles, and it is free.

A .dockerignore file belongs alongside: node_modules, .git, test fixtures, and local env files should never be sent to the build context, let alone copied in.

4. Leaving package-manager caches in the layer

RUN apt-get update && apt-get install -y curl leaves the apt index and the downloaded package lists in the layer. Cleaning up in a later RUN does nothing, because the earlier layer is already written. The cleanup has to happen in the same instruction:

RUN apt-get update \
 && apt-get install -y --no-install-recommends curl ca-certificates \
 && rm -rf /var/lib/apt/lists/*

The same applies to npm cache, pip --no-cache-dir, and apk --no-cache. Each one is a few tens of megabytes; together, across several instructions, they add up to hundreds.

5. Secrets and files that should never be in a layer

COPY .env . puts your credentials into an image layer, where they remain readable with docker history even if a later instruction deletes the file. So does an ARG used for a token during npm install from a private registry. Layers are append-only; deletion is another layer on top.

Use build secrets (RUN --mount=type=secret) for anything needed during the build, inject runtime configuration as environment variables at docker run time, and let .dockerignore keep .env out of the context entirely.

Checking a Dockerfile before it builds

Most of the above is visible in the Dockerfile itself, before a single layer is written. Dockerfile Formatter normalises the file and flags the structural issues — an unpinned base, a COPY . . ahead of the dependency install, an apt-get without cleanup — so they get caught in review rather than in the registry's storage bill.

Once the image is built, the numbers tell the story. docker image ls for the total; docker history <image> for the size of each layer, which shows exactly which instruction contributed what. A layer that is unexpectedly large is a layer with something in it that should not be there.

When the same service also needs a Compose file for local development, Docker Run to Compose converts the docker run command you already have — ports, volumes, environment, restart policy — into a service definition, with the values quoted so YAML does not reinterpret them. The docker commands used throughout are on the Docker cheat sheet.