Skip to main content

How to Reduce Your Docker Image Size by 80 Percent

A 1.2GB Node.js Docker image became 180MB with three changes. Here is exactly what changed, why it worked, and the 2026 benchmark numbers for other languages.

A Swiggy backend engineer pushed a Node.js service to production. The Docker image was 1.2GB. ECR storage costs were climbing. Every new EKS node took 90 seconds to pull the image before a pod could start. Deployments during traffic spikes were visibly slow.

Three changes later, the image was 180MB. The same application. The same code. Deployments went from 90-second pull times to under 12 seconds.

This is what changed and why — plus what a realistic reduction target looks like depending on your language, based on 2026 production benchmarks.

Why Docker Images Get Large

Before fixing anything, understand what is actually inside a large image. Most engineers assume the application code is the problem. It is almost never the application code.

Bash
## Inspect what is taking up space in an image
docker run --rm \
node:18 \
du -sh /usr/local/lib/node_modules /usr/local/bin \
/usr/lib /usr/share

A default node:18 base image contains the full Node.js runtime, npm, yarn, the Debian package manager, curl, git, a C compiler, Python, and hundreds of system libraries. Almost none of this belongs in a production container. The application code is typically 5-50MB. The base image is 900-1100MB.

Bash
Image size breakdown (typical Node.js app):
+------------------------------------------+
| Base image (node:18-debian) ~950MB |
| Build tools (npm ci, dev deps) ~180MB |
| Node.js runtime (needed) ~120MB |
| Application code ~10MB |
| Total ~1.2GB |
+------------------------------------------+

The fix is not smaller application code. The fix is building a minimal image that contains only what the running application needs.

What Reduction Is Realistic for Your Language

Multi-stage builds do not save the same percentage across every stack. 2026 production benchmarks put the realistic range at:

Language Typical Reduction
Go (static binary) 90-99%
Rust (musl static) 95-99%
Node.js 70-90%
Java (JRE-slim) 80-87%
Python 50-70%

Go and Rust compile to static binaries, so their runtime stage can start from scratch or distroless with almost nothing else. Python and Node.js still need an interpreter and standard library in the runtime image, which caps how far they can shrink.

Fix 1: Multi-Stage Builds

A multi-stage build uses separate Docker stages for building and running. The build stage installs all build tools and compiles the application. The runtime stage starts fresh and copies only the built output. Build tools, dev dependencies, test frameworks — none of it reaches the final image.

Dockerfile
## WRONG: Single stage — everything ends up in the image
FROM node:18
WORKDIR /app
COPY package*.json ./
## npm ci here installs ALL deps including devDependencies
RUN npm ci
COPY . .
RUN npm run build
CMD ["node", "dist/index.js"]
## Result: ~1.1GB image
Dockerfile
## CORRECT: Multi-stage build
## Stage 1: Builder — installs deps and compiles
FROM node:18 AS builder
WORKDIR /app
COPY package*.json ./
## Installs all deps needed to build
RUN npm ci
COPY . .
RUN npm run build ## compile TypeScript, bundle, etc.
## Stage 2: Runtime — only what is needed to run
FROM node:18-alpine AS runtime
WORKDIR /app
## Copy only production dependencies
COPY package*.json ./
## Production deps only, no devDependencies
RUN npm ci --omit=dev
## Copy only the built output from the builder stage
COPY --from=builder /app/dist ./dist
CMD ["node", "dist/index.js"]
## Result: ~180MB image

The COPY --from=builder instruction copies files from the builder stage into the runtime stage. The builder stage is discarded entirely. Every npm package in devDependencies, every TypeScript compiler file, every test framework — gone.

Parameter Breakdown:

  • AS builder: names the stage so it can be referenced in later stages
  • --from=builder: copies files from the named stage, not from the filesystem
  • --omit=dev: npm flag to skip devDependencies in the production stage
  • npm ci: installs exactly from package-lock.json — faster and deterministic

Fix 2: Choosing Between Alpine, Slim, and Distroless

Node.js has official Alpine variants. Alpine Linux uses musl-libc instead of glibc and busybox instead of GNU coreutils, resulting in a base image of about 5MB versus 175MB for the Debian base.

Dockerfile
## Base image size comparison
## node:18 ~960MB (Debian Bullseye)
## node:18-slim ~240MB (Debian, stripped)
## node:18-alpine ~130MB (Alpine with Node.js runtime)
## node:18-alpine + app ~ 30MB typical production image
FROM node:18-alpine AS runtime

When Alpine causes problems:

Some npm packages with native bindings use pre-compiled binaries for glibc. They will fail to start under Alpine's musl-libc with an error like invalid ELF header or exec format error.

Bash
## Test Alpine compatibility before committing to it
docker build -t payment-api:alpine-test .
docker run --rm payment-api:alpine-test node -e "require('./dist/index')"
## If this fails, try node:18-slim instead of full Alpine
## node:18-slim is Debian-based but strips most system packages

The packages most commonly affected are database drivers (node-gyp compiled binaries), image processing libraries (sharp, canvas), and cryptography modules with native implementations. For the Swiggy order API — a pure TypeScript service with PostgreSQL — Alpine worked without issues.

Distroless as a third option: for teams prioritizing security surface over absolute minimum size, Google's distroless images sit between Alpine and scratch — no shell, no package manager, but still includes CA certificates for outbound TLS, which scratch lacks. This matters most for SOC 2 or compliance-driven environments where "no shell in the runtime image" is itself a control, not just a size optimization.

Fix 3: .dockerignore File

Without a .dockerignore, the COPY . . instruction sends the entire project directory to the Docker build context, including node_modules, test fixtures, .git, documentation, and local environment files. These inflate the build context and end up in the image if not explicitly excluded.

Bash
## .dockerignore — exclude everything that should not be in the image
node_modules/
dist/
.git/
.gitignore
*.md
*.log
.env
.env.*
coverage/
__tests__/
test/
.nyc_output/
.DS_Store
docker-compose*.yml
Makefile

Why this matters beyond image size:

A missing .dockerignore also causes cache invalidation problems. If the build context contains files that change frequently (logs, coverage reports), Docker invalidates the cache for every COPY . . layer on every build — even when the source code has not changed. With a proper .dockerignore, only meaningful source changes invalidate the cache.

Fix 4: Non-Root User and Production NODE_ENV

These two changes are security and performance improvements that also reduce image size slightly.

Dockerfile
FROM node:18-alpine AS runtime
WORKDIR /app
## Set production mode before npm ci
## This skips devDependencies even without --omit=dev
ENV NODE_ENV=production
COPY package*.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY --from=builder /app/dist ./dist
## Run as non-root user
## node:18-alpine includes a 'node' user for exactly this purpose
USER node
## Health check so Kubernetes knows when the container is ready
HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \
CMD wget -qO- http://localhost:4000/health/live || exit 1
EXPOSE 4000
CMD ["node", "dist/index.js"]

npm cache clean --force after npm ci removes the npm package cache from the image layer. It is not needed at runtime and can add 50-200MB depending on the dependency tree.

Before and After

Bash
## Measure the impact of each fix
docker images | grep payment-api
## REPOSITORY TAG SIZE
## payment-api single-stage 1.18GB
## payment-api multi-stage 380MB
## payment-api alpine 182MB
## payment-api final 175MB

Size reduction breakdown:

Change Size reduction Reason
Multi-stage build 1.18GB → 380MB Removes build tools and devDependencies
Alpine base image 380MB → 182MB Smaller base OS and runtime
.dockerignore Negligible on size Prevents cache invalidation
npm cache clean 182MB → 175MB Removes post-install cache

Layer Caching in CI/CD

In a CI/CD pipeline, layer caching determines whether your pipeline takes 3 minutes or 12 minutes. Dockerfile layer order is critical — most-stable layers first, most-frequently-changing layers last.

Dockerfile
FROM node:18-alpine AS builder
WORKDIR /app
## COPY package files FIRST — only changes when dependencies change
## Docker caches this layer until package.json or package-lock.json changes
COPY package*.json ./
## Cached unless deps changed
RUN npm ci
## COPY source code LAST — changes on every commit
## Only this layer and below are invalidated on code changes
COPY . .
RUN npm run build

If you reverse the order (COPY . . before COPY package*.json ./), every single commit invalidates the npm ci cache and re-downloads all packages. A 3-minute build becomes a 12-minute build.

In GitHub Actions with the docker/build-push-action:

YAML
## Use GitHub Actions cache for Docker layers
steps:
- uses: docker/setup-buildx-action@v3
- uses: docker/build-push-action@v5
with:
context: .
push: true
tags: 123456789.dkr.ecr.ap-south-1.amazonaws.com/payment-api:${{ github.sha }}
cache-from: type=gha ## read from GitHub Actions cache
cache-to: type=gha,mode=max ## write layers to cache

With layer caching correctly configured, rebuilds that change only application code (not dependencies) complete in 45-90 seconds instead of 4-8 minutes.

Production Implementation Guidelines

Run docker scan or trivy image on every image before pushing to production. Alpine images often have fewer CVEs than Debian images because they ship fewer packages, but this is not guaranteed — verify with scanning.

Bash
## Scan for vulnerabilities before pushing
trivy image \
--exit-code 1 \
--severity HIGH,CRITICAL \
payment-api:latest
## Check actual image contents
docker run --rm payment-api:latest sh -c "apk list --installed"
docker history payment-api:latest --no-trunc

For teams on AWS ap-south-1, smaller images also reduce ECR cross-AZ transfer costs. A 175MB image pulled 100 times per day across three availability zones costs approximately $1.50/month in data transfer. The same workload with a 1.2GB image costs approximately $10.50/month — a difference that compounds with scale.

Note

References and Further Reading

Frequently Asked Questions

Should every service target the smallest possible image, even scratch?

No — scratch has no shell, no package manager, and no CA certificates, so if your app makes outbound HTTPS calls you must manually copy the CA bundle from the builder stage, and you lose the ability to exec into the container for debugging. Distroless or Alpine is the better default for most production services; reserve scratch for compiled static binaries (Go, Rust) where debugging happens outside the container.

Why does my Alpine-based image fail with "exec format error" when the same Dockerfile works on Debian?

Alpine uses musl-libc instead of glibc. Some npm/pip packages with native C bindings ship pre-compiled binaries built against glibc, which musl cannot load. Rebuild the native dependency inside the Alpine build stage, or fall back to a -slim (Debian-based) variant for that specific service.

Is a smaller image always faster to deploy?

Usually, but not purely linearly — pull time depends on layer caching at the node level too. A node that already has your base image layers cached only needs to pull the changed application layer, so consistent base-image versions across services compound the benefit beyond the raw size number.

Why does reversing the COPY order in a Dockerfile turn a 3-minute build into a 12-minute build?

Docker caches layers in order — if `COPY . .` runs before `COPY package*.json ./`, every commit invalidates the dependency-install layer's cache and forces a full re-download of packages. Copying package files first and installing dependencies before copying the rest of the source code keeps that layer cached across commits that don't touch dependencies.

What reduction should you actually expect from multi-stage builds, and does it depend on language?

Yes, significantly — Go and Rust compile to static binaries and can shrink 90-99% since the runtime stage needs almost nothing else, while Python and Node.js still need an interpreter and standard library in the runtime image, capping realistic reduction closer to 50-90% depending on the stack.

Discussion0