Back to Thoughts

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.24GB

And 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 added

2. 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 ImageOSCompressed SizeNotes
node:18Debian Bookworm~1.1 GBDefault. Full OS, all build tools.
node:18-slimDebian Bookworm~245 MBStrips non-essential Debian packages.
node:18-alpineAlpine Linux~175 MBMusl libc, minimal shell, small attack surface.
gcr.io/distroless/nodejs18-debian12None~120 MBNo 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:

  1. Smaller image size — another ~50–80MB reduction over Alpine.
  2. Reduced attack surface — an attacker who compromises your container has no shell to run commands in, no curl to exfiltrate data, no wget to 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 no node binary in the PATH in 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.824MB

A smaller build context means:

  • Faster docker build times (no unnecessary file hashing).
  • Lower memory usage in the Docker daemon.
  • No risk of accidentally copying local .env secrets 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 build

The --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:latest

Example 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 CVEs

Alternatively, 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:latest

Putting 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:

  • --chown on COPY --from — ensures the files arrive in the runner stage already owned by appuser, so you do not need an extra RUN chown layer.
  • 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 the Dockerfile environment-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,HIGH

The 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:

MetricNaive DockerfileOptimised Dockerfile
Final image size1.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)31219
Running as rootYesNo

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:18node:18-alpine or node:18-slim
  • Add a .dockerignore excluding node_modules/, .git/, .env*, test files, and docs
  • Split package.json copy from source copy to enable layer caching on npm ci
  • Use npm ci (not npm install) for reproducible, lockfile-driven installs
  • Multi-stage build: compile in Stage 1, ship only dist/ and production node_modules in Stage 2
  • Enable BuildKit with DOCKER_BUILDKIT=1 and use --mount=type=cache for npm
  • Run as a non-root user (USER node or a custom appuser)
  • Add a HEALTHCHECK so orchestrators can detect crashed containers
  • Scan the final image with docker scout or Trivy for CVEs before pushing to production
  • Use remote layer caching in CI (cache-from: type=gha in 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.


© 2026 Daniel Dallas Okoye

The best code is no code at all.