DevOps Tip Featured

Cutting a Docker image from 1 GB to 100 MB

A 1 GB Node image is not a performance problem you can wish away with a bigger registry. It slows every CI run, every pull on every node, every cold start on a serverless platform, and it carries hundreds of megabytes of compilers and headers your production container will never execute. Getting from 1 GB to roughly 100 MB is mostly a matter of five decisions, and none of them require changing your application code.

Measure before you optimise

Guessing which layer is fat is how people spend a day shaving 30 MB off the wrong thing. Start with the layer breakdown:

Advertisement
docker history --no-trunc --format "{{.Size}}\t{{.CreatedBy}}" myapp:before

docker history shows how much size each instruction contributed. A layer that a later RUN rm appears to delete is not gone: removing files in a subsequent layer only hides them, and their bytes still ship in the image. The only way to reclaim space is to avoid creating the layer, or to delete and recreate within a single RUN.

When a layer still looks suspicious, open a shell in the image and inspect the filesystem directly; the largest directory is rarely the one you would guess. For a layer-by-layer view of what is recoverable, run dive:

dive myapp:before

Pick a base image on purpose

The default tags are the biggest single line item. For a Node service, roughly:

Base imageApproximate sizeTrade-off
node:20~1.1 GBFull Debian toolchain; convenient, never needed at runtime.
node:20-slim~250 MBglibc, minimal packages, prebuilt native modules work.
node:20-alpine~135 MBmusl libc; native modules may need rebuilding.
gcr.io/distroless/nodejs20-debian12~120 MBNo shell, no package manager, non-root by default.

Alpine is not automatically the right answer. musl-linked binaries and packages that download prebuilt glibc artifacts — image processing, database drivers, some ORMs — produce the classic "works locally, crashes in production". If your dependency tree is pure JavaScript, Alpine is a safe 100 MB win. If it is not, slim with glibc costs you 100 MB and saves you a week.

Distroless is the smallest and the most secure default: no shell, no package manager, and a non-root user already configured. The cost is debuggability — keep a -slim debug target for the days you need to poke around.

Multi-stage builds do the heavy lifting

A single-stage build keeps the build toolchain in the final image forever. A multi-stage build separates "how the artifact is made" from "what runs it", so only compiled output and production dependencies reach the runtime stage.

FROM node:20-bookworm-slim AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build \
 && npm prune --omit=dev \
 && npm cache clean --force

FROM gcr.io/distroless/nodejs20-debian12
WORKDIR /app
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
COPY --from=build /app/package.json .
USER nonroot
CMD ["dist/server.js"]

TypeScript, ESLint, Jest, and their transitive dependency trees never reach the runtime image, because npm prune --omit=dev strips them before the final stage copies node_modules. That alone is typically 400–600 MB.

Layer order, COPY placement, and cache hygiene

Each instruction creates a layer, and Docker caches layers until an instruction's inputs change. Copying the whole project before installing dependencies means every source edit invalidates the dependency install and re-downloads the world.

  • Copy package.json and the lockfile first, run the install, then copy the source. Code changes then only bust the cheap COPY layer.
  • Put the least volatile instructions earliest: base image, system packages, dependency install, then source.
  • Never COPY . . without a .dockerignore.

A .dockerignore is not optional. At minimum:

node_modules
.git
.github
.env*
dist
coverage
*.log

Without it, your local node_modules overwrites the container's, the build context can be hundreds of megabytes to transfer per build, and secrets in .env end up baked into a layer.

Combine RUN steps and clean in the same layer

RUN apt-get update followed by RUN apt-get install followed by RUN rm -rf /var/lib/apt/lists/* produces three layers where the package lists are still present in the second. Cleaning in a later layer reclaims nothing.

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

The same rule applies to npm, pip, and composer caches: clean them in the same RUN that created them. Split commands only when you want a cache boundary.

Run as non-root and add a health check

Root inside the container is a real risk if anything escapes, and some platforms refuse to run privileged containers at all. The Node images ship a node user, and distroless ships nonroot.

USER node
HEALTHCHECK --interval=30s --timeout=3s \
  CMD node -e "require('http').get('http://127.0.0.1:3000/healthz',r=>process.exit(r.statusCode===200?0:1)).on('error',()=>process.exit(1))"

Distroless has no wget or curl, so the health check has to be a Node one-liner like the above. Keep the endpoint cheap: it should touch the process, not the database, or your orchestrator will restart healthy containers during a database blip.

Before and after, in numbers

The naive version most projects start with:

FROM node:20
WORKDIR /app
COPY . .
RUN npm install
RUN npm run build
CMD ["node", "dist/server.js"]

That image carries the full Debian toolchain, all dev dependencies, your local node_modules, and your .git history. On a typical Express or Next.js API it lands around 1.0–1.2 GB.

The multi-stage version above, built on node:20-bookworm-slim and finishing on distroless, typically lands between 95 and 130 MB — an 85–90% reduction with no application changes. Switching only the base image to slim gets you to roughly 350 MB; adding multi-stage builds gets you to ~150 MB; distroless plus a lean lockfile gets you under 130 MB.

When not to do this

  • If your app genuinely needs a compiler at runtime, or a system package that distroless lacks, stop at slim. Fighting for the last 30 MB is not worth an unsupported platform.
  • Do not move to Alpine solely for size if any dependency is native. Rebuilding native modules on musl is a recurring maintenance cost.
  • If image size is not on your critical path, spend the effort on startup time and layer caching instead.
  • Pin versions. node:20-slim drifts; node:20.11.1-bookworm-slim does not.

Measure again afterwards: docker images shows the uncompressed size, while the registry count your CI downloads is compressed.

Order of operations that pays off fastest: add a .dockerignore, split into stages, switch the runtime base to slim or distroless, combine and clean your RUN steps, then set a non-root user and a health check. Verify each step with docker history rather than assuming the number went down.

Advertisement
khallaf

Writing about programming, AI and the tools that make engineering teams faster. Published by A1 Systems.

Last updated 19 Sep 2026

// Keep reading

Related articles

Tools & Tricks 5 min read

Regex you will actually use

The small set of regex constructs that cover everyday work, the patterns worth keeping in a snippet file, and how to avoid catastrophic backtracking.

khallaf Tip