All posts
7 min read303 views

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.

G

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:

  1. node:22 is Debian with a full toolchain — compilers you needed for ten seconds at build time and ship forever.
  2. COPY . . before npm install busts the dependency cache on every source change.
  3. Dev dependencies, build caches, and .git all 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
StageSizeWhat changed
node:22 naive1.14 GBBaseline
node:22-alpine410 MBSmaller base
Multi-stage180 MBBuild 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.

Thoughts on this one? Send me an email.

Keep reading

Built with Next.js & Tailwind.
© 2026 Gabriel Okemwa