Our order API's container image was 1.1 GB. Nobody had decided that; it had accumulated. The cost was real: each deploy pulled the image to every node, autoscaling new tasks took over a minute before the container even started, and the registry bill grew with every tag. A security scan listed 400 vulnerabilities, most of them in packages the API never used.

This post walks through how we reduced it to 96 MB, in the order we did it, with measurements at each step. The application is Node.js, but the same approach works for Python, Go, Java and PHP.

Step 0: measure before changing anything

Start by finding where the size comes from. docker history shows each layer and the instruction that created it:

docker history --format 'table {{.Size}}\t{{.CreatedBy}}' orders-api:before | head -15
SIZE      CREATED BY
48.2MB    CMD ["node" "dist/server.js"]  ...  COPY . .
412MB     RUN /bin/sh -c npm install
210MB     RUN /bin/sh -c apt-get update && apt-get install -y python3 make g++ ...
1.2kB     WORKDIR /app
...
342MB     (base image layers: node:22)

The original Dockerfile:

FROM node:22
WORKDIR /app
RUN apt-get update && apt-get install -y python3 make g++ git
COPY . .
RUN npm install
RUN npm run build
CMD ["node", "dist/server.js"]

Four sources of size were visible: a full Debian-based Node image, compilers installed for one native dependency, all development dependencies, and the whole repository copied in, including tests and the local .git directory. For deeper analysis, the dive tool shows file-level contents of each layer.

Step 1: a proper .dockerignore

COPY . . copies the build context, which is whatever the .dockerignore file does not exclude. Ours did not exist.

# .dockerignore
.git
node_modules
dist
coverage
*.log
.env*
Dockerfile*
docker-compose*.yml
tests
docs

Result: 1.1 GB to 1.04 GB, and the build context sent to the daemon dropped from 380 MB to 4 MB, which also made builds faster. Excluding .env* also stopped local secrets from being baked into the image.

Step 2: multi-stage build

A multi-stage build uses one stage with all the tools to build the application, and a second, clean stage that receives only the result. Everything not copied into the final stage is discarded.

# syntax=docker/dockerfile:1

FROM node:22-bookworm AS build
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends python3 make g++ \
    && rm -rf /var/lib/apt/lists/*
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build && npm prune --omit=dev

FROM node:22-bookworm-slim AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
COPY package.json ./
USER node
CMD ["node", "dist/server.js"]

What changed:

  • Compilers exist only in the build stage.
  • npm ci installs exactly the lockfile; npm prune --omit=dev removes development dependencies after the build, so only runtime packages are copied.
  • Copying package.json and the lockfile before the source keeps the dependency layer cached when only code changes.
  • The final stage starts from node:22-bookworm-slim.
  • The container runs as the unprivileged node user included in the official image.

Result: 1.04 GB to 214 MB.

Step 3: choose the base image deliberately

The base image is now most of the size. The realistic options for Node.js:

Base Approx. size C library Shell and package manager Notes
node:22 ~1 GB glibc Yes Full Debian with build tools; for building, not running
node:22-bookworm-slim ~200 MB glibc Yes Good default; works with most native modules
node:22-alpine ~130 MB musl Yes (apk) Smaller; native modules must be built for musl
Distroless Node.js ~120-160 MB glibc No No shell, fewer packages to patch; harder to debug

We tested Alpine first. Our native dependency, a hashing library, had prebuilt binaries only for glibc, so it would have to be compiled for musl in the build stage, which also meant the build stage had to be Alpine. It worked, but we chose distroless, because it kept glibc compatibility and removed the shell and package manager from production entirely:

FROM gcr.io/distroless/nodejs22-debian12:nonroot AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
COPY package.json ./
CMD ["dist/server.js"]

The distroless Node.js image's entrypoint is already node, so CMD contains only the script.

Result: 214 MB to 158 MB.

Step 4: trim dependencies

With the base fixed, node_modules was the largest remaining part. Two findings:

  • A full cloud SDK v2 package was a dependency of one script; replacing it with the specific v3 client modules saved about 40 MB.
  • Source maps and TypeScript declaration files in some packages were not needed at runtime.

We also switched the build output to a bundled file for the API code, which made runtime dependencies smaller still.

Result: 158 MB to 96 MB.

Results

Step Image size Vulnerabilities reported by scanner
Original 1.1 GB 412
.dockerignore 1.04 GB 412
Multi-stage, slim runtime 214 MB 61
Distroless runtime 158 MB 9
Dependency trim 96 MB 6

Pull time on a new ECS Fargate task went from about 48 seconds to 9 seconds, which cut the time to scale out during traffic spikes noticeably.

Keeping it small

Image size tends to creep back. Two cheap guards stop it:

  • CI prints the image size for every build and fails if it grows more than 15% compared with the main branch, which forces a conversation about new dependencies.
  • The weekly base image rebuild runs the vulnerability scan, so new findings in the base appear without waiting for an application change.

Debugging without a shell

The trade-off of distroless is that docker exec -it ... sh no longer works. Options we use:

  • Ephemeral debug containers in Kubernetes (kubectl debug) that share the pod's process namespace.
  • A :debug variant of the distroless image, used only in non-production environments.
  • Good logs and metrics, which reduce the need to log in to containers at all.

Common mistakes

Mistake Effect Do this instead
No .dockerignore .git, node_modules, secrets in the image Exclude everything not needed to build
rm in a later RUN Size stays, because the earlier layer still holds the files Delete in the same RUN, or use multi-stage
npm install in images Non-reproducible dependency versions npm ci with a lockfile
Running as root Larger blast radius if compromised USER node or :nonroot images
Switching to Alpine blindly Native module crashes from musl differences Test, or use slim or distroless glibc images

Key takeaways

  • Measure layers with docker history (and dive) before optimising.
  • A .dockerignore is the cheapest fix and also keeps secrets out.
  • Multi-stage builds keep compilers and dev dependencies out of the runtime image.
  • Choose the base image by C library compatibility, debuggability and patch surface, not size alone.
  • Smaller images pull faster, scale out faster and have fewer vulnerabilities to patch.

References