Putting a React app in a Docker container means anyone (or any server) can run the same build with one command, with no “works on my machine” surprises. The pattern that works best for a single-page app is simple: build the static files in one stage, then serve only those files from a small web server image. This guide shows that setup, plus a development container, and every config here was tested against a real build.
Updated October 2026: I rewrote this 2024 post. The original Dockerfile used node:14-alpine (Node 14 has been end-of-life for years) and served the app from a Node container, which is larger and slower than it needs to be. It also used the version: key in Compose (now obsolete) and assumed Create React App; the React team has announced the sunsetting of Create React App, so this version uses Vite, which is the common default today. The steps for a Create React App project are almost identical, except that the build output folder is build instead of dist.
The Plan
| File | Purpose |
|---|---|
Dockerfile | Multi-stage build: Node builds the app, nginx serves it |
nginx.conf | Serves the files, supports client-side routing, sets cache headers |
.dockerignore | Keeps node_modules, .git and secrets out of the build |
compose.yaml | Runs the production image with one command |
Dockerfile.dev and compose.dev.yaml | A development container with live reload |
Prerequisites
- Docker (Docker Desktop on Mac or Windows, or Docker Engine on Linux) with Compose and BuildKit, which are included in current releases.
- Node.js 22 or newer if you also want to run the app outside Docker. Vite’s current release needs Node 20.19+ or 22.12+. Check the Node.js release schedule and pick a supported LTS line for your image; this guide uses Node 24.
- A React app with a lockfile. If you do not have one, run
npm create vite@latest my-app -- --template react-ts, thencd my-app && npm install. The lockfile (package-lock.json) matters because the Dockerfile usesnpm ci, which fails without it.
Step 1: Check the Production Build
Before involving Docker, confirm the app builds:
npm run build
For a Vite project this creates a dist folder containing index.html and an assets folder of JavaScript, CSS and images. The file names in assets include a content hash (for example index-BRDr3nmD.js), which is what lets us cache them safely for a long time. The result is plain static files. Per Vite’s static deploy guide, any static web server can serve them, so the container only needs a web server and no Node.js at runtime.
Step 2: Write the nginx Configuration
Create nginx.conf. This file is a server block that will replace the image’s default one:
server {
listen 8080;
server_name _;
root /usr/share/nginx/html;
index index.html;
gzip on;
gzip_vary on;
gzip_min_length 256;
gzip_types text/css application/javascript application/json image/svg+xml;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# Fingerprinted build files never change, so browsers can cache them for a year.
location /assets/ {
try_files $uri =404;
add_header Cache-Control "public, max-age=31536000, immutable" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
}
# The HTML entry point must always be revalidated so users get new releases.
location = /index.html {
add_header Cache-Control "no-cache" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
}
# Client-side routing: unknown paths return index.html and React Router takes over.
location / {
try_files $uri /index.html;
}
location = /healthz {
access_log off;
default_type text/plain;
return 200 "ok\n";
}
}
What the important parts do:
try_files $uri /index.htmlis what makes client-side routing work. If someone opens/dashboard/settingsdirectly or refreshes the page, there is no such file on disk, so nginx returnsindex.htmland React Router takes over. Without this, deep links return 404.- The
/assets/location returns a real 404 for missing files instead of falling back toindex.html. Otherwise a missing JavaScript file would come back as an HTML page, which causes confusing “unexpected token” errors after a deploy. - Long-term caching for hashed assets, and
no-cacheforindex.html. Because file names change whenever content changes, assets can be cached for a year;index.htmlmust be revalidated so users pick up new releases. - The headers are repeated inside each
locationblock on purpose. In nginx, if a location defines anyadd_header, it stops inheriting the ones from the server level. listen 8080because we will use an unprivileged image that does not run as root.- The
/healthzendpoint gives Docker (and load balancers) something cheap to check.
I tested this exact file with a locally installed nginx against a real Vite production build. Results: the root page returned 200 with Cache-Control: no-cache; /dashboard/settings returned 200 with HTML; a hashed asset returned the one-year immutable cache header; /assets/nope.js returned 404; gzip compression was applied; and /healthz returned ok. Add further security headers such as a Content-Security-Policy to suit your app, since the right policy depends on which scripts and APIs it uses.
Step 3: Write the Dockerfile
# syntax=docker/dockerfile:1
# ---- Stage 1: build the static files ----
FROM node:24-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
# Vite bakes VITE_* variables into the bundle at build time
ARG VITE_API_URL
ENV VITE_API_URL=$VITE_API_URL
RUN npm run build
# ---- Stage 2: serve them with nginx ----
FROM nginxinc/nginx-unprivileged:1.30-alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=3s --retries=3 \
CMD wget -q -O /dev/null http://127.0.0.1:8080/healthz || exit 1
This is a multi-stage build: the first stage has Node and all your development dependencies, and only the compiled dist folder is copied into the final image. The final image contains nginx and static files, with no Node.js, no source code and no node_modules, so it is small and has a small attack surface.
- Copying
package.jsonand the lockfile before the rest of the code lets Docker reuse the cachednpm cilayer when only your source changes. That makes rebuilds much faster. npm ciinstalls exactly what the lockfile says, so builds are reproducible.- The base image tags are pinned to a major or minor line (
node:24-alpine,nginx-unprivileged:1.30-alpine), which I confirmed exist on Docker Hub in October 2026. For stricter reproducibility, pin by digest and update on a schedule. nginxinc/nginx-unprivilegedis the nginx project’s variant that runs as a non-root user and listens on port 8080 by default, per its Docker Hub page. Running as root in a container is an avoidable risk.- Environment variables are baked in at build time. A Vite app is static files, so
VITE_*variables are replaced in the JavaScript duringnpm run build(see Vite’s env variables guide). Changing one means rebuilding the image. Never put secrets in aVITE_variable: everything in them is visible to anyone who opens the browser’s developer tools.
Add a .dockerignore
node_modules
dist
.git
.gitignore
.env*
*.log
Dockerfile*
compose*.yaml
README.md
The build context is everything Docker sends to the builder. Excluding node_modules speeds up builds and stops your local (possibly macOS-built) dependencies overwriting the ones installed in the image. Excluding .env* keeps local secrets out of the image history.
Step 4: Build and Run
docker build -t my-react-app --build-arg VITE_API_URL=https://api.example.com .
docker run --rm -p 8080:8080 my-react-app
Open http://localhost:8080. -p 8080:8080 maps your machine’s port 8080 to the container’s port 8080 (the left number is the host port, which you can change). Check the container’s health:
docker ps # the STATUS column shows "healthy" after the first check
curl -i http://localhost:8080/healthz
What I tested and what I did not: I could not run Docker on the machine where I prepared this post. I did run the build stage’s exact steps (npm ci then npm run build, from a clean copy of the project) and the nginx configuration (above), and I checked the Compose files parse correctly. The docker build and docker run steps themselves follow the standard pattern in the Dockerfile reference but were not executed here.
Step 5: Run It With Docker Compose
services:
web:
build:
context: .
args:
VITE_API_URL: https://api.example.com
ports:
- "8080:8080"
restart: unless-stopped
docker compose up --build -d
docker compose logs -f web
docker compose down
Notice there is no version: line. The Compose Specification no longer needs it, and current Compose versions warn that it is obsolete. Compose becomes more useful as you add services, for example an API container, but for a static front end alone, docker run is just as good.
A Development Container With Live Reload
Do not use the production image for development; you want the Vite dev server with hot reload. Create Dockerfile.dev:
FROM node:24-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
EXPOSE 5173
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]
and compose.dev.yaml, which uses Compose’s file-watch feature to copy your edits into the running container (see the Compose Watch documentation):
services:
web:
build:
context: .
dockerfile: Dockerfile.dev
ports:
- "5173:5173"
develop:
watch:
- action: sync
path: ./src
target: /app/src
- action: sync
path: ./public
target: /app/public
- action: rebuild
path: package.json
docker compose -f compose.dev.yaml up --watch
Open http://localhost:5173. Edits in src and public are synced into the container and Vite reloads the page, and a change to package.json triggers a rebuild of the image so new dependencies get installed. The --host 0.0.0.0 flag is essential: by default Vite only listens on the container’s loopback address, which your browser cannot reach. If hot reload does not trigger on your platform, set server.watch.usePolling to true in vite.config.ts as a fallback.
Troubleshooting
- Refreshing a page shows 404 or an nginx error. The SPA fallback is missing: check that
nginx.confis actually copied into/etc/nginx/conf.d/default.conf. - The page is blank after a deploy and the console shows “Unexpected token ‘<‘”. The browser cached an old
index.htmlthat points to deleted assets, or your server returnsindex.htmlfor missing JavaScript files. The config above avoids both. npm cifails in the build. The lockfile is missing from the build context or is out of sync withpackage.json; runnpm installlocally and commit the result.- Environment variable is undefined in the browser. It must be prefixed
VITE_, provided at build time with--build-arg, and declared withARGin the Dockerfile. - “Permission denied” when starting nginx. You replaced the main
nginx.confof the unprivileged image and removed its writable temp paths; replace onlyconf.d/default.confas shown. - Container exits immediately. Run
docker logs <container>; a syntax error in your nginx config is the usual cause. You can check a config withnginx -t.
Security and Production Notes
- Run as non-root (done) and avoid
latesttags. - Scan your images for known vulnerabilities (for example with
docker scoutor Trivy) and rebuild regularly to pick up base-image fixes. - Terminate HTTPS in front of the container with a reverse proxy, load balancer or CDN, and add a Content-Security-Policy suited to your app.
- Keep secrets out of the image and out of
VITE_variables. A front-end bundle is public. - Deploy the image to any container platform. If you are on AWS, our guide to choosing a service and deploying a web app on AWS walks through a container deployment.
Conclusion
A Dockerized React app is a two-stage build, one nginx config and a .dockerignore. The details that matter are the single-page-app fallback, cache headers for hashed assets, running as a non-root user and understanding that front-end environment variables are fixed at build time. Copy the files above, replace the API URL with yours, and you have a reproducible image that runs the same on your laptop, in CI and in production.
Why use nginx instead of running Node in the container?
A built React app is just static files, so a small web server image is smaller, faster and has fewer vulnerabilities than a Node.js runtime. Node is only needed in the build stage.
Does this work with Create React App?
Yes, with two changes: the build output folder is build instead of dist, and environment variables use the REACT_APP_ prefix. However, the React team has announced the sunsetting of Create React App, so new projects should use Vite or a framework.
How do I pass environment variables to a React app in Docker?
For Vite, VITE_ variables are baked into the JavaScript at build time, so pass them with –build-arg and rebuild to change them. If you need runtime configuration, load a config file or call an endpoint when the app starts. Never put secrets in front-end variables.
Why does refreshing a page return 404?
Your web server does not know that routes like /dashboard are handled by React Router in the browser. Use try_files $uri /index.html in nginx so unknown paths return the app shell.
How can I make my React Docker image smaller and more secure?
Use a multi-stage build so the final image has only static files, use an alpine nginx image that runs as a non-root user, add a .dockerignore, pin base image versions, and scan and rebuild images regularly.