TechEarl

How to Dockerize a Static Site (HTML/CSS/JS Built Output)

Multi-stage Docker for a static site: build with Node, ship with Nginx. The result is a ~25 MB image that serves HTML, handles SPA fallbacks, and sets cache headers correctly.

Ishan KarunaratneIshan Karunaratne⏱️ 6 min readUpdated
Share thisCopied

A static site (React build output, a Vue app, an Astro site, an exported Next.js, a Jekyll/Hugo build) is the easiest thing to dockerize correctly: a Node build stage produces dist/, an Nginx final stage serves it, the image is around 25 MB, and Nginx handles HTTP/2 and gzip automatically.

A working Dockerfile

dockerfile
# Build stage
FROM node:22-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build

# Runtime stage
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY ./nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80

With a matching nginx.conf:

nginx
server {
    listen 80;
    server_name _;
    root /usr/share/nginx/html;
    index index.html;

    # SPA fallback — every unknown path serves index.html so the client router can handle it
    location / {
        try_files $uri $uri/ /index.html;
    }

    # Cache hashed asset files aggressively
    location ~* \.(?:js|css|woff2?|webp|svg|png|jpg|jpeg|gif|ico)$ {
        expires 1y;
        add_header Cache-Control "public, immutable";
        try_files $uri =404;
    }

    # Don't cache index.html (so deploys reach users immediately)
    location = /index.html {
        add_header Cache-Control "no-cache, no-store, must-revalidate";
        expires off;
    }
}

Build and run:

bash
docker build -t my-site .
docker run -d --name my-site -p 8080:80 my-site

Image size lands around 25 MB. The Nginx base is about 25 MB; your static files add a few hundred KB to a few MB depending on size.

Build output directories vary

The dist/ path is the convention; some frameworks use different names:

FrameworkBuild output directory
Vite (React, Vue, Svelte)dist/
Create React Appbuild/
Next.js (next export / output: 'export')out/
Astrodist/
SvelteKit (adapter-static)build/
Vue CLIdist/
Angulardist/<project-name>/

Adjust the COPY --from=build /app/dist line to match.

Pick a Node version

Match what your build uses locally — usually the current LTS. node:22-alpine is the default for the build stage. The runtime stage is Nginx, so Node disappears from the final image after multi-stage.

SPA fallback

For client-side routed apps (React Router, Vue Router, etc.), the user visits /about — your Nginx looks for a file about or about/index.html, doesn't find one, and 404s. The try_files $uri $uri/ /index.html; directive says: try the URL as a file, then as a directory, otherwise serve index.html. The JS bundle then loads, the router reads the URL, renders the right page.

For Next.js (static export), the routes ARE pre-rendered as HTML files, so the fallback is rarely needed. For React/Vue SPAs, it's mandatory.

Cache headers

The two-rule pattern:

  1. Hashed assets (/static/js/main.abc123.js, /static/css/main.def456.css) get Cache-Control: public, immutable and a one-year expiry. The hash in the filename changes on every build, so the cache invalidates correctly.
  2. index.html gets Cache-Control: no-cache so each visitor checks for the latest version on every load.

Without this, your CSS update from yesterday is still cached in users' browsers for a year.

Compose

yaml
services:
  site:
    build: .
    ports:
      - "8080:80"
    restart: unless-stopped

For a deployment that serves multiple static sites behind a reverse proxy:

yaml
services:
  site-a:
    build: ./site-a
    expose:
      - "80"
  site-b:
    build: ./site-b
    expose:
      - "80"
  proxy:
    image: nginx:alpine
    ports:
      - "80:80"
    volumes:
      - ./proxy.conf:/etc/nginx/conf.d/default.conf:ro
    depends_on:
      - site-a
      - site-b

Where proxy.conf routes by hostname:

nginx
server {
    listen 80;
    server_name site-a.example.com;
    location / { proxy_pass http://site-a/; }
}
server {
    listen 80;
    server_name site-b.example.com;
    location / { proxy_pass http://site-b/; }
}

Build at deploy time vs build at image-build time

The recipe above bakes the build into the image — every code change requires a new docker build. The alternative is to build outside Docker (in CI or locally) and copy the resulting dist/ into a thin Nginx image:

dockerfile
FROM nginx:alpine
COPY ./dist /usr/share/nginx/html
COPY ./nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80

That's faster to build (no Node install, no npm ci) and gives more control over the build environment in CI. Pick based on where your build pipeline lives — Docker-only or CI-driven.

Practical usage: deploying a Vite React build

package.json:

json
{
  "scripts": {
    "build": "vite build"
  }
}

Dockerfile (the one at the top of this article).

nginx.conf (the one at the top of this article).

bash
docker build -t my-react-app .
docker run -d --name my-react-app -p 8080:80 my-react-app
open http://localhost:8080

The build runs Vite, produces dist/, gets copied into Nginx, served on port 8080. Total time: ~30 seconds on first build, 10-15 seconds on rebuilds (cached layers).

Common pitfalls

  • Wrong build output directory. build/ vs dist/ vs out/. Match the COPY --from=build /app/PATH line to your framework.
  • No SPA fallback. Refresh /about and you get 404. Add try_files $uri $uri/ /index.html;.
  • Aggressive caching of index.html. Update deploys but users don't see them. Add Cache-Control: no-cache for index.html specifically.
  • Shipping node_modules or .git into the build. Add a .dockerignore so COPY . . doesn't pull them in.
  • Missing build environment variables. Some frameworks bake env vars into the bundle at build time. Pass them with --build-arg or set them in the build stage's ENV.

What to do next

FAQ

Sources

Authoritative references this article was fact-checked against.

TagsDockerStatic SiteNginxReactVueAstroDevOps

Found this useful? Pass it on.

Copied
Ishan Karunaratne

Ishan Karunaratne

Tech Architect · Software Engineer · AI/DevOps

Tech architect and software engineer with 20+ years building software, Linux systems, and DevOps infrastructure, and lately working AI into the stack. Currently Chief Technology Officer at a healthcare tech startup, which is where most of these field notes come from.

Keep reading

Related posts

H

How to Dockerize a Node.js App

A Dockerfile for a real Node.js app: multi-stage build, npm ci for deterministic dependencies, the node_modules-volume trick that makes bind-mounted source fast on Mac, and the non-root user that most tutorials skip.