Shrinking Docker Images from 1GB to 150MB for Faster Node.js Deployments
A deep-dive into multi-stage builds, layer caching, .dockerignore, Distroless base images, and dependency pruning — everything you need to shrink a bloated Node.js container and ship faster.
When you first containerize a Node.js app, Docker feels like magic. You write a Dockerfile, build an image, and your app runs the same way everywhere — local, staging, production. Clean.
Then you check the image size.
$ docker images my-api
REPOSITORY TAG IMAGE ID CREATED SIZE
my-api latest 3a2f1d9e8b4c 2 minutes ago 1.24GBAnd that magic starts to feel a bit less magical.
A 1.2GB Docker image is not just a disk space problem. It is a CI/CD speed problem, a cloud bandwidth cost problem, a cold-start latency problem, and — perhaps most critically — a security surface area problem. Research by Snyk consistently shows that base image bloat is one of the leading drivers of container CVEs (Common Vulnerabilities and Exposures) in production environments.
In this article, we are going to take that 1.2GB image down to approximately 150MB using a combination of techniques: multi-stage builds, optimised base images, proper .dockerignore configuration, layer caching, and dependency pruning. Everything with working code you can drop straight into a real project.
Why Your Node.js Docker Image is So Fat
Before we fix it, we need to understand why the default experience produces such massive images. A naive Dockerfile looks like this:
# ❌ The Naive Dockerfile
FROM node:18
WORKDIR /app
COPY . .
RUN npm install
RUN npm run build
CMD ["npm", "start"]This single-stage approach silently bundles four massive problems into one image:
1. A Full Debian OS as the Base
node:18 is built on top of Debian Bookworm, a full Linux distribution. Debian itself, before you install a single npm package, contributes roughly ~370MB to your final image. It includes the entire package registry, editors, networking utilities, and system libraries you will never touch in production.
You can verify this yourself:
$ docker pull node:18
$ docker image inspect node:18 --format='{{.Size}}' | numfmt --to=iec
~1.1GB # before your code is even added2. Native Build Toolchains
Packages like bcrypt, sharp, node-sass, and canvas include native C/C++ addons that must be compiled from source. The node:18 image ships with gcc, g++, python3, and make preinstalled specifically for this. These compilers are critical at build time, but are completely dead weight at runtime.
3. Your Entire Source Tree
COPY . . copies everything — TypeScript source files, test suites, local development configs, .env.local, README.md, and your entire .git directory — into the image. None of that is needed to run node dist/index.js in production.
4. devDependencies Nobody Asked For
npm install (without a flag) installs both dependencies and devDependencies. That means typescript, jest, eslint, prettier, ts-node, and every testing utility you have ever touched all ship into your production container. According to Snyk's 2023 container security report, the average Node.js production container carries 68% more packages than it actually needs.
The Fix: Multi-Stage Builds
The core architectural insight is this: the environment you need to build your app is completely different from the environment you need to run it.
Multi-stage builds act like a factory assembly line. Stage 1 is the heavy industrial floor — it has all the tools, compilers, and raw materials. Stage 2 is the clean shipping dock — it receives only the finished product and sends it out into the world.
# ✅ Stage 1: The Heavy Builder
FROM node:18-alpine AS builder
WORKDIR /app
# Copy only the manifest files first (for layer caching — more on this below)
COPY package*.json ./
RUN npm ci
# Copy source and compile
COPY . .
RUN npm run build
# ✅ Stage 2: The Lean Production Release
FROM node:18-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
# Re-install ONLY production dependencies
COPY package*.json ./
RUN npm ci --only=production
# Cherry-pick the compiled output from Stage 1
COPY --from=builder /app/dist ./dist
# Run as a non-root user for security
USER node
CMD ["node", "dist/index.js"]The key instruction is COPY --from=builder. It reaches back into Stage 1 — which Docker discards after the build — and pulls out only the compiled dist/ folder. The Debian/Alpine OS from Stage 1, the TypeScript compiler, the entire node_modules/.bin folder, your raw .ts files — none of it makes it into the final image.
Choosing the Right Base Image
Switching from node:18 to node:18-alpine alone is one of the highest-leverage changes you can make. Let's compare the common options:
| Base Image | OS | Compressed Size | Notes |
|---|---|---|---|
node:18 | Debian Bookworm | ~1.1 GB | Default. Full OS, all build tools. |
node:18-slim | Debian Bookworm | ~245 MB | Strips non-essential Debian packages. |
node:18-alpine | Alpine Linux | ~175 MB | Musl libc, minimal shell, small attack surface. |
gcr.io/distroless/nodejs18-debian12 | None | ~120 MB | No shell at all. Hardened for production. |
Alpine Linux uses musl libc instead of the standard glibc, which means it is dramatically smaller but can occasionally cause compatibility issues with native npm addons that link against glibc. If you are not using native addons, Alpine is almost always the right call. If you are, node:18-slim is a safe middle ground.
For the highest-security, lowest-footprint production deployments, Google's Distroless images are worth the extra configuration.
Going Further: Distroless Base Images
If Alpine is a stripped-down car, Distroless is a skeleton chassis. Google's Distroless images contain only the Node.js runtime and its direct dependencies — no shell (bash, sh), no package manager (apk, apt), no system utilities at all.
This has two serious benefits:
- Smaller image size — another ~50–80MB reduction over Alpine.
- Reduced attack surface — an attacker who compromises your container has no shell to run commands in, no
curlto exfiltrate data, nowgetto download additional payloads.
Here is how you integrate Distroless into a multi-stage build:
# Stage 1: Build with full Alpine toolchain
FROM node:18-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
RUN npm ci --only=production # Prune to production deps before extracting
# Stage 2: Copy into a zero-shell Distroless container
FROM gcr.io/distroless/nodejs18-debian12
WORKDIR /app
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
ENV NODE_ENV=production
CMD ["dist/index.js"]Note: Because Distroless has no shell,
CMD ["node", "dist/index.js"]will not work — there is nonodebinary in thePATHin the same way. Use the exec form and reference the entrypoint directly as shown above. The Distroless Node image internally maps execution to its bundled Node binary.
The .dockerignore File: The Most Underrated Optimisation
Before Docker even begins building your image, it sends a build context — a snapshot of your project directory — to the Docker daemon. If your .dockerignore is missing or incomplete, that context includes everything: node_modules (potentially hundreds of MBs), .git history, .env secrets, and compiled output from previous local builds.
A well-configured .dockerignore file stops all of that at the door.
# .dockerignore
# These are often massive and Docker should never see them
node_modules
npm-debug.log*
# Build artefacts from your local machine — not the build stage's output
dist
build
.next
# Git history has no place in a container
.git
.gitignore
.gitattributes
# Secrets and local environment files — critical for security
.env
.env.*
!.env.example
# Documentation and housekeeping
README.md
CHANGELOG.md
*.md
LICENSE
docs/
# Editor and OS files
.DS_Store
.vscode/
.idea/
*.swp
# Test infrastructure
__tests__/
*.test.ts
*.spec.ts
coverage/
jest.config.*
# Docker files themselves (optional but tidy)
Dockerfile*
docker-compose*The impact of this file is often larger than people expect. Here is a real-world example of what happens at build time:
# Before .dockerignore — Docker ships 420MB of context
Sending build context to Docker daemon 437.2MB
# After .dockerignore — dramatically reduced context
Sending build context to Docker daemon 1.824MBA smaller build context means:
- Faster
docker buildtimes (no unnecessary file hashing). - Lower memory usage in the Docker daemon.
- No risk of accidentally copying local
.envsecrets into a production image.
Layer Caching: Making Your CI/CD Pipeline Blazing Fast
Docker images are not monolithic blobs — they are a stack of read-only layers. Each instruction in your Dockerfile (COPY, RUN, ENV) creates a new layer. Docker is smart: if a layer has not changed since the last build, it reuses the cached version and skips re-running that command entirely.
The most impactful caching trick for Node.js is separating your package.json copy from your source code copy:
# ❌ Both npm install AND source changes trigger a full reinstall
COPY . .
RUN npm ci
# ✅ npm install is only re-run when package.json actually changes
COPY package*.json ./
RUN npm ci
COPY . .Here is what the layer invalidation chain looks like:
Layer 1: FROM node:18-alpine ← Almost never changes
Layer 2: COPY package*.json ./ ← Only changes when deps change
Layer 3: RUN npm ci ← Cache hit unless Layer 2 changed ✅
Layer 4: COPY . . ← Changes every code push
Layer 5: RUN npm run build ← Runs on every code push
In a mature CI environment, npm ci — which can take 30–90 seconds for a large project — becomes a cache hit on almost every run. Only dependency updates trigger a reinstall. This alone can cut your pipeline build time in half.
BuildKit: The Modern Docker Build Engine
If you are not using BuildKit, you are leaving significant performance on the table. BuildKit is Docker's next-generation build engine — it enables parallel stage execution, better cache management, and secret mounting.
Enable it by setting an environment variable:
export DOCKER_BUILDKIT=1
docker build -t my-api .Or specify it per-build:
DOCKER_BUILDKIT=1 docker build -t my-api .With BuildKit, you also gain access to cache mounts — a powerful feature that persists the npm cache between builds without embedding it in the final image:
# syntax=docker/dockerfile:1
FROM node:18-alpine AS builder
WORKDIR /app
COPY package*.json ./
# Mount the npm cache volume — dramatically speeds up repeated installs
RUN --mount=type=cache,target=/root/.npm \
npm ci
COPY . .
RUN npm run buildThe --mount=type=cache instruction tells BuildKit to keep the ~/.npm cache directory persistent across builds, acting like a local registry mirror for your packages. This is especially powerful in CI/CD environments like GitHub Actions with persistent caches.
Pruning Dependencies with npm prune
When your production code requires native addons that must be compiled during npm ci (e.g., bcrypt, sharp), you cannot skip installation in Stage 2 — you need the compiled binaries. A clean way to handle this is to install everything in Stage 1, compile the app, then prune the devDependencies before extracting:
FROM node:18-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Prune devDependencies in-place — what remains is only production deps
RUN npm prune --production
# ----
FROM node:18-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
# node_modules is already pruned — copy it directly
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
USER node
CMD ["node", "dist/index.js"]This avoids a second npm ci --only=production in Stage 2 (which would re-download packages from the registry), while ensuring no devDependencies land in the final container.
Scanning for Vulnerabilities with docker scout
Size reduction is only one half of the equation. The other half is knowing what is inside your image. Docker's built-in Docker Scout tool gives you a dependency breakdown and CVE report:
# Requires Docker Desktop or Docker Scout CLI
docker scout cves my-api:latestExample output:
✓ Provenance attestation found
✗ 3 vulnerabilities found in 2 packages
LOW 2 CVE-2023-26364, CVE-2023-45133
MEDIUM 1 CVE-2024-1234
nodejs 18.12.0
✗ CVE-2024-1234 MEDIUM 5.4
Fix: upgrade to 18.19.1
Package URL: pkg:apk/alpine/libssl1.1@1.1.1n-r0
✗ CVE-2023-26364 LOW 3.3
For teams using GitHub Actions, Docker Scout integrates directly into the pull request workflow:
# .github/workflows/docker-scan.yml
- name: Docker Scout CVE Scan
uses: docker/scout-action@v1
with:
command: cves
image: my-org/my-api:${{ github.sha }}
only-severities: critical,high
exit-code: true # Fail the pipeline on critical CVEsAlternatively, Trivy by Aqua Security is an excellent open-source alternative:
# Install via Homebrew
brew install aquasecurity/trivy/trivy
# Scan your image
trivy image my-api:latestPutting It All Together: The Production-Ready Dockerfile
Here is the complete, battle-tested Dockerfile combining everything we have covered — multi-stage builds, Alpine, cache mounts, non-root user, and dependency pruning:
# syntax=docker/dockerfile:1
# ─────────────────────────────────────────────
# Stage 1: Build
# ─────────────────────────────────────────────
FROM node:18-alpine AS builder
# Build-time args (e.g., SENTRY_DSN, PUBLIC_API_URL)
ARG BUILD_ENV=production
ENV NODE_ENV=${BUILD_ENV}
WORKDIR /app
# Install dependencies first (cache-friendly layer ordering)
COPY package*.json ./
RUN --mount=type=cache,target=/root/.npm \
npm ci
# Copy source and compile
COPY . .
RUN npm run build
# Prune devDependencies in-place
RUN npm prune --production
# ─────────────────────────────────────────────
# Stage 2: Production Release
# ─────────────────────────────────────────────
FROM node:18-alpine AS runner
LABEL maintainer="your-team@example.com"
LABEL version="1.0"
# Non-root group and user for security
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
WORKDIR /app
ENV NODE_ENV=production
ENV PORT=3000
# Pull only what we need from Stage 1
COPY --from=builder --chown=appuser:appgroup /app/node_modules ./node_modules
COPY --from=builder --chown=appuser:appgroup /app/dist ./dist
# Switch to non-root user before CMD
USER appuser
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD wget -qO- http://localhost:3000/health || exit 1
CMD ["node", "dist/index.js"]A few things worth calling out in this final version:
--chownonCOPY --from— ensures the files arrive in the runner stage already owned byappuser, so you do not need an extraRUN chownlayer.HEALTHCHECK— tells Docker (and orchestrators like ECS and Kubernetes) how to probe the container's health. Without this, a crashed app still shows as "running".ARG BUILD_ENV— allows passing build-time variables without hardcoding them, keeping theDockerfileenvironment-agnostic.EXPOSE 3000— purely documentation; it tells the operator which port the app listens on and is picked up by orchestration tools automatically.
Integrating Into a GitHub Actions CI/CD Pipeline
Here is a full GitHub Actions workflow that builds, scans, and pushes the optimised image:
# .github/workflows/docker.yml
name: Build and Push Docker Image
on:
push:
branches: [main]
pull_request:
branches: [main]
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx (enables BuildKit)
uses: docker/setup-buildx-action@v3
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=sha
type=ref,event=branch
type=semver,pattern={{version}}
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha # Pull cache from GitHub Actions cache
cache-to: type=gha,mode=max # Push cache back to GitHub Actions cache
build-args: |
BUILD_ENV=production
- name: Scan for CVEs with Trivy
uses: aquasecurity/trivy-action@master
with:
image-ref: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.meta.outputs.version }}
format: table
exit-code: 1
ignore-unfixed: true
severity: CRITICAL,HIGHThe cache-from: type=gha and cache-to: type=gha,mode=max lines are particularly important. They store BuildKit's layer cache in GitHub Actions' cache store, which means your npm ci layer stays cached between workflow runs even though the runner itself is ephemeral.
The Real-World Impact: Before and After
These numbers are representative of a mid-sized Express + TypeScript API with ~120 direct dependencies:
| Metric | Naive Dockerfile | Optimised Dockerfile |
|---|---|---|
| Final image size | 1.24 GB | ~148 MB |
| CI/CD build time | ~4 min 20 sec | ~1 min 35 sec |
| Docker pull time (EC2) | ~82 seconds | ~12 seconds |
| Container cold-start time | ~8 seconds | ~1.4 seconds |
| CVE count (Trivy scan) | 312 | 19 |
| Running as root | Yes | No |
The CVE reduction — from 312 down to 19 — is arguably more important than the size reduction in a production context. Fewer packages mean fewer known vulnerabilities, fewer patching cycles, and a smaller blast radius if something in your supply chain is compromised.
Summary and Checklist
Here is a quick reference of everything covered. Run through this whenever you are shipping a new Node.js service:
- Switch base image from
node:18→node:18-alpineornode:18-slim - Add a
.dockerignoreexcludingnode_modules/,.git/,.env*, test files, and docs - Split
package.jsoncopy from source copy to enable layer caching onnpm ci - Use
npm ci(notnpm install) for reproducible, lockfile-driven installs - Multi-stage build: compile in Stage 1, ship only
dist/and productionnode_modulesin Stage 2 - Enable BuildKit with
DOCKER_BUILDKIT=1and use--mount=type=cachefor npm - Run as a non-root user (
USER nodeor a customappuser) - Add a
HEALTHCHECKso orchestrators can detect crashed containers - Scan the final image with
docker scoutor Trivy for CVEs before pushing to production - Use remote layer caching in CI (
cache-from: type=ghain GitHub Actions)
Optimising Docker images is not a one-time task you do once and forget. It is a discipline. Base images release new versions with security patches monthly. Your dependency tree grows with every sprint. Building a lean, secure, and auditable container pipeline from day one means you are never scrambling to reduce a 3GB image at 2am on a Friday before a critical release.
Build small. Ship fast. Run securely.