Multi-stage builds, slim bases, layer ordering and .dockerignore, the techniques that cut image size by 90% and speed up every deploy.
Why size matters more than it seems
Image size is not aesthetic. Every gigabyte is pulled on deploy, stored per host, kept per release for rollbacks, and pruned eventually by someone at 2 a.m. when the disk fills. Big images slow every deploy, stretch rollback windows, and inflate the attack surface (more packages, more CVEs in every scan). The good news: 90% reductions are routine with four techniques, none of which change your application code.
Technique 1: multi-stage builds
The heavy hitter. Build with the full toolchain image; copy only the artifacts into a minimal runtime stage. Compilers, dev dependencies and source never reach production:
FROM node:22 AS build # fat: toolchain, dev deps
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:22-slim # thin: runtime only
WORKDIR /app
ENV NODE_ENV=production
COPY package*.json ./
RUN npm ci --omit=dev
COPY --from=build /app/dist ./dist
CMD ["node", "dist/index.js"]
Technique 2: pick the right base
- node:22 (full Debian): ~1 GB, never for production
- node:22-slim: ~200 MB, the safe default; glibc, so native modules just work
- node:22-alpine: ~130 MB, smallest mainstream option; musl libc occasionally breaks native modules (sharp, canvas), test before committing
- distroless: ~20 MB base with no shell or package manager, excellent security posture, harder to debug in
- scratch/distroless-static: for compiled languages, Go and Rust binaries yield single-digit-MB images
Technique 3: layer ordering and .dockerignore
Docker caches layers top-down and invalidates everything below the first change. Order instructions least-to-most volatile: dependency manifests and installs before source code, so editing code never re-downloads dependencies. And always ship a .dockerignore, it shrinks the build context (faster uploads), keeps junk out of layers, and prevents secrets from leaking into image history:
.dockerignore
node_modules
.git
.next
dist
*.log
.env*
Technique 4: measure, don’t guess
docker history shows the size each instruction added, the offender is usually obvious. The dive tool goes deeper, showing files per layer and wasted space from files added then deleted in later layers (which does not reclaim size unless done in the same RUN). Combine cleanup with installation in one instruction: apt-get install with rm -rf /var/lib/apt/lists/* in the same RUN, or use --no-cache flags on alpine’s apk.
Expected results
Node/Next.js app: 1.1 GB naive to 130-180 MB (multi-stage + slim + standalone output)
Python/Django: 950 MB to ~180 MB (multi-stage + python:slim)
Go service: any size to 8-15 MB (static binary + distroless)
Operationally: deploys move seconds of data instead of minutes, rollbacks are instant, disks stop filling, and platform build caches (like Peon’s on-server layer cache) stay effective because only your app layer changes per push
This article was originally published by DEV Community and written by Peon Sh.
Read original article on DEV Community