Reducing Docker Image Size for Frontend Containers

A naively built React or Next.js image routinely exceeds 1 GB because the build toolchain, dev dependencies, and unfiltered source tree all end up in the final layer. This page shows the exact Dockerfile and CI configuration that cuts that to under 30 MB by isolating the build toolchain from the runtime artifact.

When to use this pattern

  • Your frontend image is served by a static web server (Nginx, Caddy) with no Node.js runtime in production.
  • Registry pull times or egress costs are affecting deployment velocity.
  • You are enforcing a hard image-size gate in your CI/CD pipeline and the current image fails it.

Prerequisites

Complete working example

The three files below form a self-contained unit. Drop them into the repository root and the CI step produces a final image under 30 MB for a typical Vite or Create React App project.

Dockerfile

# ── Stage 1: build ───────────────────────────────────────────────────────────
FROM node:22-alpine AS builder
WORKDIR /app

# Copy manifests BEFORE source so this layer is only invalidated on dep changes.
# This is the core principle behind effective Docker layer caching.
COPY package.json package-lock.json ./

# --ignore-scripts prevents lifecycle hooks from running arbitrary code.
# --frozen-lockfile ensures the install is deterministic.
RUN npm ci --ignore-scripts

# Source copied last; changes here do not bust the dependency install layer.
COPY . .

RUN npm run build

# ── Stage 2: minimal runtime ──────────────────────────────────────────────────
# nginx:alpine is ~8 MB; the builder stage (node:22-alpine + node_modules) never
# appears in the final image layers.
FROM nginx:alpine AS runtime

# Remove default welcome page to avoid leaking server version info.
RUN rm -rf /usr/share/nginx/html/*

# --chown avoids a separate RUN chown layer; both ops happen in one layer.
COPY --from=builder --chown=nginx:nginx /app/dist /usr/share/nginx/html

# Custom Nginx config: gzip on, try_files for SPA routing, no server_tokens.
COPY nginx.conf /etc/nginx/conf.d/default.conf

EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

.dockerignore

node_modules
.git
*.md
.env*
coverage
.next/cache
dist
*.log
.DS_Store
.vscode

nginx.conf

server {
    listen 80;
    server_tokens off;

    root /usr/share/nginx/html;
    index index.html;

    # Serve pre-compressed brotli/gzip assets when the client accepts them.
    gzip_static on;

    # SPA fallback: unknown paths return index.html for client-side routing.
    location / {
        try_files $uri $uri/ /index.html;
    }

    # Long-lived cache for hashed static assets.
    location ~* \.(js|css|woff2|png|svg|webp)$ {
        expires 1y;
        add_header Cache-Control "public, immutable";
    }
}

CI pipeline (GitHub Actions)

name: Build frontend container

on:
  push:
    branches: [main]
  pull_request:

jobs:
  build:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write

    steps:
      - uses: actions/checkout@v4

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: Log in to GHCR
        uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Build and push
        uses: docker/build-push-action@v5
        with:
          context: .
          target: runtime
          # registry cache mode=max stores all intermediate layers, not just the
          # final stage, so the builder node_modules layer is also cached.
          cache-from: type=registry,ref=ghcr.io/${{ github.repository }}:cache
          cache-to: type=registry,ref=ghcr.io/${{ github.repository }}:cache,mode=max
          tags: ghcr.io/${{ github.repository }}:${{ github.sha }}
          push: ${{ github.ref == 'refs/heads/main' }}

      - name: Assert image size under 50 MB
        run: |
          SIZE=$(docker image inspect ghcr.io/${{ github.repository }}:${{ github.sha }} \
            --format '{{.Size}}')
          echo "Image size: $((SIZE / 1024 / 1024)) MB"
          [ "$SIZE" -lt 52428800 ] || { echo "Image exceeds 50 MB limit"; exit 1; }

Step-by-step walkthrough

Why copy manifests before source

The dependency install layer (npm ci) is the most expensive step — often 60–90 seconds on a cold runner. Copying only package.json and the lockfile first means that layer is only invalidated when dependencies actually change. Source-only edits reuse the cached install layer, which is the foundational rule in Docker layer caching for full-stack applications.

Why the final stage starts from nginx:alpine, not node:alpine

The Node.js runtime is not needed to serve pre-compiled static assets. Starting from nginx:alpine (~8 MB) rather than node:22-alpine (~180 MB) eliminates the single largest contributor to image size. The COPY --from=builder instruction pulls only the dist/ directory across stage boundaries — the entire node_modules tree, TypeScript compiler, and build toolchain are discarded automatically.

The .dockerignore gates your build context

Docker sends the entire directory tree to the BuildKit daemon before it reads a single FROM instruction. Without .dockerignore, node_modules (often 300–500 MB) is transmitted on every build. The .dockerignore above also excludes dist/ so the host’s local build output cannot shadow the in-container build, preserving determinism.

Registry cache mode=max vs inline

type=inline embeds cache metadata inside the image manifest, but it only stores the final stage’s layers. type=registry,mode=max stores every intermediate stage, meaning the builder stage with its installed dependencies is cached in the registry and reused on subsequent runs — this is the difference between a 90-second and a 15-second pipeline on a cache hit.

Nginx config for SPAs

The try_files $uri $uri/ /index.html directive is mandatory for single-page applications: without it, direct navigation to any client-side route returns a 404 from Nginx. The server_tokens off directive removes the Nginx version string from response headers, which is a minor hardening step with no performance cost.

Visual overview

Two-stage Docker build flow for frontend containers Builder stage installs dependencies and compiles source into dist/. Runtime stage copies only dist/ into nginx:alpine, discarding node_modules and build toolchain. Stage 1 · builder node:22-alpine base COPY package*.json + npm ci COPY . . → npm run build dist/ ✓ dist/ only Stage 2 · runtime nginx:alpine base (~8 MB) COPY --from=builder dist/ COPY nginx.conf Final image ~25 MB node_modules, TypeScript, build tools — discarded

Verification

After the CI job completes, run these checks locally or in a subsequent pipeline step:

# Pull the just-built image and inspect its compressed size
docker pull ghcr.io/<org>/<repo>:<sha>
docker image inspect ghcr.io/<org>/<repo>:<sha> --format '{{.Size}}' | \
  awk '{printf "%.1f MB\n", $1/1024/1024}'

# Confirm node_modules is absent from the final image filesystem
docker run --rm --entrypoint sh ghcr.io/<org>/<repo>:<sha> \
  -c "[ ! -d /app/node_modules ] && echo 'clean' || echo 'LEAK DETECTED'"

# Verify the web server responds correctly
docker run -d --name frontend-test -p 8080:80 ghcr.io/<org>/<repo>:<sha>
curl -sf http://localhost:8080/ | grep -q '<div id="root">' && echo "OK"
docker rm -f frontend-test

# Audit layer breakdown with dive (install: go install github.com/wagoodman/dive@latest)
dive ghcr.io/<org>/<repo>:<sha>

Expected output: image size between 20–35 MB depending on asset count; dive efficiency score above 95%; no node_modules directory visible in the runtime filesystem.

Common pitfalls

Image exceeds 800 MB despite multi-stage Dockerfile

The final FROM stage is not nginx:alpine — it may accidentally point to node:22-alpine. Verify the --target runtime flag is passed to docker buildx build in CI; without it, BuildKit builds all stages but the default target is the last FROM instruction, which may be the builder stage on some configurations.

Fix: explicitly name the runtime stage and always pass --target runtime.

FROM nginx:alpine AS runtime   # <-- name this stage
docker buildx build --target runtime ...

CI cache invalidated on every commit

The most common cause is copying . before the npm ci step, so any source change busts the dependency layer. The fix is always to copy manifests first:

COPY package.json package-lock.json ./   # manifests first
RUN npm ci --ignore-scripts              # install locked to this layer
COPY . .                                 # source last

Pair this with remote build caching via Turborepo for workspace-level deduplication in monorepos.

Native Node modules crash in Alpine

Modules such as sharp, canvas, or bcrypt use precompiled C binaries built against glibc. Alpine uses musl libc, causing immediate crashes. Options:

  1. Switch the builder base to node:22-slim (Debian) and compile the native module there; keep nginx:alpine for the runtime stage — the compiled native module is irrelevant since it never ships to production in a static build.
  2. If you need Node in production (SSR), use node:22-slim as the runtime base, not Alpine.

← Back to Docker Layer Caching for Full-Stack Applications