A Dockerfile builder for static React sites that emits a multi-stage Dockerfile:
one stage builds your app with Node, and a second tiny stage serves the compiled output
with Nginx. It also generates a matching nginx.conf with sensible caching headers and
optional single-page-app routing.
How it works
The build stage uses node:<version>-alpine. It copies package.json and your
lockfile first and runs a clean install (npm ci, yarn install --frozen-lockfile, or
pnpm install --frozen-lockfile) so dependencies are cached independently of your source.
It then copies the rest of the project and runs your build script, producing static assets
in dist, build, or out.
The runtime stage starts from nginx:alpine, copies only the built assets into
/usr/share/nginx/html, and copies the generated nginx.conf over the default config.
That config sets a one-year immutable cache on hashed assets and, when SPA fallback is on,
uses try_files $uri $uri/ /index.html; so any unmatched route is handed to your
client-side router instead of returning a 404.
The generated files
Dockerfile:
FROM node:22-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:1.27-alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
HEALTHCHECK --interval=30s CMD wget -qO- http://localhost/ || exit 1
nginx.conf (with SPA fallback):
server {
listen 80;
root /usr/share/nginx/html;
index index.html;
# Cache hashed assets for one year
location ~* \.(js|css|png|jpg|svg|woff2)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# Never cache index.html
location / {
try_files $uri $uri/ /index.html;
add_header Cache-Control "no-cache";
}
}
The immutable directive tells browsers the file will never change (because the filename includes a hash), so they do not even send a conditional request. index.html is served without caching so users always get the latest version pointing to those hashed assets.
SPA routing in detail
When a user navigates directly to /dashboard/settings in a React Router app, Nginx receives a request for that path. Without the try_files … /index.html fallback, it looks for a file at that path, finds nothing, and returns a 404. The fallback tells Nginx to serve index.html for any unmatched path, handing routing control to React. Disable this for multi-page sites where each path corresponds to a real file.
Tips and notes
- Add a
.dockerignorewithnode_modules,.git, anddistso the build context is small and the dependency cache is not invalidated by stale local builds. - If your app reads runtime configuration (API base URL, feature flags), remember the bundle is built at image build time — environment variables baked in then cannot change without rebuilding. One pattern: serve a
config.jsfrom Nginx that the entrypoint script writes at container start, and load it via a<script>tag inindex.html. - Hashed asset filenames (
app.4f3c1.js) are safe to cache for a year because a new build produces a new filename — never long-cacheindex.htmlitself or you will break deployments for returning users.