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.
- The joke domain that outlived Gatsby: why I moved a long-running static site off Gatsby onto Next.js, and what that says about picking a generator you can still build later.
FAQ
So the final image doesn't contain Node, the entire node_modules tree, and your source files. With multi-stage, the build happens in the first stage (Node + everything needed to compile) and the final stage is just Nginx + the built output. The Nginx image is ~25 MB; the Node image is ~150 MB; without multi-stage you'd be shipping the bigger one.
Nginx is the default. Caddy is a strong alternative if you want automatic HTTPS, drop in caddy instead of nginx and write a one-line Caddyfile. Apache works too but is heavier than needed for static files. busybox httpd can ship sub-5 MB images but lacks features like proper cache headers and SPA routing.
Static sites bake env vars at build time. Pass them with docker build --build-arg VITE_API_URL=https://... and reference in the Dockerfile's build stage. Once built, the values are inlined into the JS bundle; you can't change them without rebuilding. For runtime config, use a small JSON file the app fetches on load, or a server-rendered alternative like Next.js.
Yes, serve (from npm) or a tiny Go static server can do it in a few MB. For dev or trivial deploys that's fine; for production, Nginx's gzip, cache headers, HTTP/2, and TLS support make it worth the ~25 MB. The difference rarely matters.
Two paths. Either terminate TLS at a reverse proxy in front of this container (Traefik, Caddy, or nginx-proxy-acme with Let's Encrypt): most common in production. Or mount certs into this Nginx and configure TLS in nginx.conf directly, fine for local dev with self-signed certs.
Sources
Authoritative references this article was fact-checked against.
- Official Nginx image, Docker Hubhub.docker.com
- Official Node.js image, Docker Hubhub.docker.com





