Back to Blog
Performance10 min read2026-07-08

How to Optimize Docker Image Size

Bloated Docker images slow deploys, waste registry storage, and widen your attack surface. Here are the practical techniques that consistently cut image size, from base image choice to multi-stage builds.

Ajay Kumar
Ajay Kumar
Founder & DevOps, PandaStack

# How to Optimize Docker Image Size

A 1.2 GB image isn't just an aesthetic problem. It means slower pushes and pulls, longer cold starts, more registry storage cost, and a bigger attack surface. I've seen Node images balloon past a gigabyte when the same app fits comfortably in under 150 MB. The good news is that image-size optimization is mostly mechanical once you understand where the bytes come from.

Where the bytes actually go

Every image is a stack of layers. Each instruction in your Dockerfile (RUN, COPY, ADD) creates a layer, and layers are additive — deleting a file in a later layer does not reclaim the space it occupied in an earlier one. The four usual offenders are:

  • A heavy base image (full node, python, or ubuntu)
  • Build-time toolchains (compilers, dev headers) shipped into the runtime image
  • Package manager caches left behind
  • Source code, .git, tests, and other files that don't belong at runtime

Start by measuring. docker history shows you the per-layer breakdown:

docker history --no-trunc --format '{{.Size}}\t{{.CreatedBy}}' myapp:latest

For a deeper view, [dive](https://github.com/wagoodman/dive) lets you inspect each layer interactively and flags wasted space.

Pick a smaller base image

The single biggest lever is the base image. Compare common Node base images:

Base imageApprox. sizeNotes
node:20~1.1 GBFull Debian, build tools included
node:20-slim~200 MBDebian minus extras
node:20-alpine~130 MBmusl libc, tiny
gcr.io/distroless/nodejs20~110 MBNo shell, no package manager

Alpine is small but uses musl instead of glibc, which occasionally breaks native modules (sharp, some Prisma binaries). Test before committing. [Distroless](https://github.com/GoogleContainerTools/distroless) images go further by removing the shell and package manager entirely — great for security, but you lose exec-into-container debugging.

Use multi-stage builds

This is the technique that matters most. Build with a fat image, then copy only the artifacts into a clean runtime image. The build tools never make it into the final layer.

# ---- 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-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY package*.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY --from=build /app/dist ./dist
USER node
CMD ["node", "dist/server.js"]

For compiled languages the win is even larger. A Go binary needs nothing but itself:

FROM golang:1.22 AS build
WORKDIR /src
COPY go.* ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /app ./cmd/server

FROM gcr.io/distroless/static-debian12
COPY --from=build /app /app
ENTRYPOINT ["/app"]

That final image can be under 15 MB.

Clean up in the same layer

Because layers are immutable, cleanup must happen in the same RUN that created the mess:

# Bad: cache persists in an earlier layer
RUN apt-get update && apt-get install -y curl
RUN rm -rf /var/lib/apt/lists/*

# Good: install and clean in one layer
RUN apt-get update \
 && apt-get install -y --no-install-recommends curl \
 && rm -rf /var/lib/apt/lists/*

For pip, use --no-cache-dir. For apk, --no-cache. Each saves tens of megabytes.

Write a real .dockerignore

If you COPY . . without a .dockerignore, you ship node_modules, .git, logs, and local env files into the build context and often into the image. A minimal one:

.git
node_modules
npm-debug.log
*.md
.env
coverage
dist
.vscode

This also speeds up builds because the daemon transfers a smaller context.

Order layers for cache reuse

Copy dependency manifests before source code so dependency installation is cached across builds when only your code changes:

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

This doesn't shrink the final image, but it dramatically speeds up rebuilds — a related and worthwhile win.

Advanced: BuildKit cache mounts

With [BuildKit](https://docs.docker.com/build/buildkit/), you can mount a cache that persists across builds without ending up in the image:

# syntax=docker/dockerfile:1
RUN --mount=type=cache,target=/root/.npm npm ci

The download cache lives outside the layer entirely, so it accelerates builds without adding bytes.

A quick checklist

  • [ ] Switch to a slim/alpine/distroless base
  • [ ] Use multi-stage builds; copy only artifacts
  • [ ] Combine install + cleanup in one RUN
  • [ ] Add a .dockerignore
  • [ ] Run as a non-root USER
  • [ ] Measure with dive or docker history

Let the platform handle it

If you'd rather not hand-tune Dockerfiles for every service, PandaStack builds your images with rootless BuildKit in ephemeral Kubernetes Job pods (no host Docker socket) and pushes to a managed registry. Buildpack auto-detection produces sensible, layered images for Node, Python, Go and more — and if you bring your own Dockerfile, the same caching and multi-stage best practices apply automatically.

References

  • [Docker: Building best practices](https://docs.docker.com/build/building/best-practices/)
  • [Docker BuildKit documentation](https://docs.docker.com/build/buildkit/)
  • [Distroless container images (GoogleContainerTools)](https://github.com/GoogleContainerTools/distroless)
  • [dive — explore Docker image layers](https://github.com/wagoodman/dive)
  • [Alpine Linux](https://alpinelinux.org/)

Want smaller images without the Dockerfile archaeology? Spin up a service on PandaStack's free tier — it builds, optimizes, and deploys from a Git push. [Start at dashboard.pandastack.io](https://dashboard.pandastack.io).

Ready to deploy?

Start free on PandaStack.

Start free on PandaStack

More in Performance

Browse all Performance articles →

See also