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
# 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 80With a matching nginx.conf:
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:
docker build -t my-site .
docker run -d --name my-site -p 8080:80 my-siteImage 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:
| Framework | Build output directory |
|---|---|
| Vite (React, Vue, Svelte) | dist/ |
| Create React App | build/ |
Next.js (next export / output: 'export') | out/ |
| Astro | dist/ |
| SvelteKit (adapter-static) | build/ |
| Vue CLI | dist/ |
| Angular | dist/<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:
- Hashed assets (
/static/js/main.abc123.js,/static/css/main.def456.css) getCache-Control: public, immutableand a one-year expiry. The hash in the filename changes on every build, so the cache invalidates correctly. index.htmlgetsCache-Control: no-cacheso 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
services:
site:
build: .
ports:
- "8080:80"
restart: unless-stoppedFor a deployment that serves multiple static sites behind a reverse proxy:
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-bWhere proxy.conf routes by hostname:
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:
FROM nginx:alpine
COPY ./dist /usr/share/nginx/html
COPY ./nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80That'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:
{
"scripts": {
"build": "vite build"
}
}Dockerfile (the one at the top of this article).
nginx.conf (the one at the top of this article).
docker build -t my-react-app .
docker run -d --name my-react-app -p 8080:80 my-react-app
open http://localhost:8080The 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/vsdist/vsout/. Match theCOPY --from=build /app/PATHline to your framework. - No SPA fallback. Refresh
/aboutand you get 404. Addtry_files $uri $uri/ /index.html;. - Aggressive caching of
index.html. Update deploys but users don't see them. AddCache-Control: no-cacheforindex.htmlspecifically. - Shipping
node_modulesor.gitinto the build. Add a.dockerignoresoCOPY . .doesn't pull them in. - Missing build environment variables. Some frameworks bake env vars into the bundle at build time. Pass them with
--build-argor set them in the build stage'sENV.
What to do next
- How to Run Nginx in Docker — Nginx-specific details (TLS, reverse proxy, etc.).
- How to Dockerize a Next.js App — for the Next.js standalone-output recipe (server-rendered) and a brief on static export.
- Docker Image Size Optimization — multi-stage and Alpine strategies.
FAQ
Sources
Authoritative references this article was fact-checked against.
- Official Nginx image — Docker Hubhub.docker.com
- Official Node.js image — Docker Hubhub.docker.com

