A bloated container image is a slow deploy, a slow autoscale event, and a bigger attack surface all at once. The fix is rarely one trick — it's a handful of habits that compound: a smaller base image, a multi-stage build that throws away everything the runtime doesn't need, and a layer order that actually uses Docker's build cache instead of fighting it. None of this is exotic; it's mostly about being deliberate with what ends up in the final image.
Start with the base image
The single biggest lever is usually the base image. node:20 is over 1GB; node:20-slim drops the build tooling Debian ships by default and lands closer to 200MB; node:20-alpine is smaller still, in the tens of megabytes, because Alpine uses musl libc and busybox instead of a full Debian userland. Distroless images (Google's gcr.io/distroless family) go further — no shell, no package manager, nothing but your app and its runtime dependencies. That's excellent for a final production image and painful for debugging, since you can't docker exec into a shell that doesn't exist. Alpine is usually the pragmatic middle ground unless you have a specific reason to go distroless.
Alpine's musl libc is not a drop-in replacement for glibc. Native Node modules, some Python packages with C extensions, and certain Go binaries built assuming glibc can fail or behave differently on Alpine. Test on the actual base image you'll ship, not just locally on Debian or macOS.
Multi-stage builds
A multi-stage Dockerfile lets you use a full-featured image to compile or install, then copy only the output into a minimal runtime image. The build stage's compilers, dev dependencies, and intermediate artifacts never make it into what ships.
FROM node:20-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:20-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY --from=build /app/dist ./dist
USER node
CMD ["node", "dist/server.js"]
Layer order and cache invalidation
Docker caches each layer and invalidates everything after the first changed layer. Copying package.json and running npm ci before copying the rest of the source, as in the example above, means a source-only change doesn't force a full dependency reinstall on every build. Get this ordering backwards — copy everything, then install — and every code change busts the dependency-install cache, which is often the slowest step in the build. This is a five-minute fix with a large effect on CI build time.
What actually ends up in the image
A .dockerignore file matters as much as the Dockerfile itself. Without one, COPY . . pulls in node_modules, .git, test fixtures, local .env files, and build caches — bloating the image and occasionally leaking secrets into a layer that's now baked into the image history. Exclude anything the runtime doesn't need to execute.
docker history <image> shows the size each layer adds. dive (a small open-source CLI) gives a better interactive breakdown of what's taking up space in each layer. Look at the actual numbers before assuming which step is the culprit — it's often not the one you expect.
Squashing and layer count
Combining related RUN commands with && reduces layer count and avoids leaving stale files from an earlier step sitting in an intermediate layer even after a later step deletes them — deleted files in Docker layers still take up space in the image until the layers are squashed or combined, because each layer is immutable. A common pattern is installing packages, using them, and cleaning up apt caches all in a single RUN so the cleanup actually shrinks the layer instead of just hiding files from the final filesystem view.
| Change | Typical size impact |
|---|---|
| Debian slim/full base → Alpine or distroless | Large — often 5-10x smaller base |
| Multi-stage build separating compile from runtime | Large — removes compilers and dev deps entirely |
.dockerignore excluding node_modules/.git | Medium to large, depends on repo |
| Combining RUN commands with cleanup inline | Small to medium — avoids stale layer bloat |
| Correct layer ordering for cache reuse | No size change — affects build speed, not image size |
Wrapping up
None of these changes are individually dramatic, but together they routinely take an image from over a gigabyte down to well under 200MB. Start with the base image swap since it's the least invasive change, add the multi-stage split next, and only reach for distroless once you're confident you won't need a shell in production for debugging. Smaller images pull faster, scale faster, and give an attacker less to work with if a container is ever compromised.
Independent software engineer in Nairobi specialising in Acumatica customisations, Laravel backends, and tax fiscalisation integrations across East and Southern Africa.