IAnswerable

Cut Your Docker Image Size in Half

12 Nov 2025

A Node or Python app that should ship as a 100-150MB image regularly ships as 1GB+ because the default Dockerfile people copy-paste never got revisited. Smaller images deploy faster, pull faster on a fresh server, and have a smaller attack surface. Here’s where the bloat actually comes from and how to cut it.

1. Multi-stage builds

The single biggest win. Your build stage needs a full toolchain — compilers, dev dependencies, source maps. Your runtime doesn’t need any of that, just the compiled output and production dependencies.

# Build stage
FROM node:20 AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

# Runtime stage
FROM node:20-slim
WORKDIR /app
COPY --from=build /app/dist ./dist
COPY --from=build /app/node_modules ./node_modules
COPY package*.json ./
CMD ["node", "dist/index.js"]

The build stage’s layers — the full node_modules with devDependencies, the TypeScript compiler, source files — never make it into the final image. Only what you explicitly COPY --from=build does. This alone commonly cuts an image by 60-80%.

2. Use a slim or alpine base — but know the tradeoff

node:20 is built on a full Debian image. node:20-slim strips out most non-essential packages. node:20-alpine goes further, using musl libc instead of glibc, which shrinks it dramatically further.

Alpine’s catch: some native npm packages compiled against glibc fail or behave differently on musl. If your dependency tree includes native bindings (image processing libraries, certain database drivers), test on alpine before committing to it in production — the failure mode is usually a build error or a runtime segfault, not a subtle bug, so you’ll find out quickly, but better to find out locally than after a deploy.

slim is the safer default when you’re not sure. It’s still meaningfully smaller than the full image without alpine’s native-module risk.

3. Order layers for cache efficiency, not just size

This doesn’t shrink the image but it shrinks build time, which matters just as much on a small VPS with limited CPU:

COPY package*.json ./
RUN npm ci
COPY . .

Copying package.json and installing dependencies before copying the rest of your source means Docker only re-runs npm ci when dependencies actually change — not on every code edit. Get this order backwards (copy everything, then install) and every single code change invalidates the dependency layer, forcing a full reinstall on every build.

4. Clean up in the same layer you create the mess

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

If the apt-get install and the rm -rf are in separate RUN layers, the cleanup doesn’t actually shrink anything — Docker layers are additive, and a file deleted in a later layer still exists in the earlier layer’s data, just hidden. The image on disk keeps every byte from every layer regardless of what a later layer deletes. Clean up in the same RUN command that created the mess, or it’s dead weight you’re carrying for nothing.

5. Use a .dockerignore

Missing this means your build context — and sometimes your image, if a broad COPY . . runs before you’ve excluded anything — includes .git, node_modules from your host machine, test fixtures, and local .env files.

.git
node_modules
*.md
.env
dist
coverage

Beyond size, this closes a real risk: without it, a stray COPY . . can bake your local .env file — with real secrets — straight into an image layer. Deleting it in a later step doesn’t remove it from the layer history; anyone who pulls the image can extract it.

Measure before and after

docker images your-app --format "{{.Size}}"
docker history your-app --human --format "{{.Size}}\t{{.CreatedBy}}"

docker history shows size per layer, which tells you exactly which instruction is adding the most weight — don’t guess, look.

The takeaway

Multi-stage builds and a .dockerignore file get you most of the reduction with almost no risk. Alpine gets you the rest, but test your specific dependencies against it first — a 30MB image that crashes on a native module isn’t a win over a 150MB image that works.