Shrinking a container image from 1GB to 180MB
Multi-stage builds, layer ordering, and a .dockerignore that earns its keep. Same features, a sixth of the size, and a CI pipeline that stops being the bottleneck.
Gabriel Okemwa
Software engineer working across backend, DevOps, and applied AI.
On this page
A Node image that starts at 1.1GB and ends at 180MB, without deleting a single feature. The technique is the same for Python, Go, or Java — only the copied artefacts change.
Start honest
Here's the naive Dockerfile, the one that ships in most tutorials:
FROM node:22
WORKDIR /app
COPY . .
RUN npm install
RUN npm run build
CMD ["npm", "start"]Three problems, all of them expensive:
node:22is Debian with a full toolchain — compilers you needed for ten seconds at build time and ship forever.COPY . .beforenpm installbusts the dependency cache on every source change.- Dev dependencies, build caches, and
.gitall end up in the final layer.
Order layers by how often they change
FROM node:22-alpine AS deps
WORKDIR /app
# Only the lockfile — this layer survives every source edit.
COPY package.json pnpm-lock.yaml ./
RUN corepack enable && pnpm install --frozen-lockfile
FROM node:22-alpine AS build
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN corepack enable && pnpm build && pnpm prune --prod
FROM node:22-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production
# A non-root user costs one line and closes a whole class of container escapes.
RUN addgroup -S app && adduser -S app -G app
COPY --from=build --chown=app:app /app/node_modules ./node_modules
COPY --from=build --chown=app:app /app/dist ./dist
COPY --from=build --chown=app:app /app/package.json ./
USER app
EXPOSE 3000
CMD ["node", "dist/server.js"]Nothing from the build stage travels except the two directories that actually run.
The .dockerignore does more than you think
node_modules
.git
.next
dist
coverage
*.log
.env*Without this, COPY . . drags your local node_modules into the build context — often the single largest thing in it — and can leak a .env file into a layer that someone else can docker history right back out.
Measure, don't guess
docker build -t api:slim .
docker images api:slim --format '{{.Size}}'
# Where did the weight actually go?
docker history api:slim --human --format '{{.Size}}\t{{.CreatedBy}}' | head -20| Stage | Size | What changed |
|---|---|---|
node:22 naive | 1.14 GB | Baseline |
node:22-alpine | 410 MB | Smaller base |
| Multi-stage | 180 MB | Build tools and dev deps dropped |
The part people skip
Smaller images aren't the real win — faster, more cacheable builds are. A CI pipeline where only the source layer rebuilds turns a six-minute deploy into a forty-second one, and that changes how often you're willing to ship.
That's the actual outcome: not a number in docker images, but a team that deploys on a Friday without flinching.