# 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, orubuntu) - 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:latestFor 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 image | Approx. size | Notes |
|---|---|---|
node:20 | ~1.1 GB | Full Debian, build tools included |
node:20-slim | ~200 MB | Debian minus extras |
node:20-alpine | ~130 MB | musl libc, tiny |
gcr.io/distroless/nodejs20 | ~110 MB | No 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
.vscodeThis 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 ciThe 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
diveordocker 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).