Scientyfic World

How to Dockerize a React App (Vite, Multi-Stage Build and nginx)

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...

Share:

Get an AI summary of this article

Dockerize a react app banner image with docker logo

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

FilePurpose
DockerfileMulti-stage build: Node builds the app, nginx serves it
nginx.confServes the files, supports client-side routing, sets cache headers
.dockerignoreKeeps node_modules, .git and secrets out of the build
compose.yamlRuns the production image with one command
Dockerfile.dev and compose.dev.yamlA 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, then cd my-app && npm install. The lockfile (package-lock.json) matters because the Dockerfile uses npm 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.html is what makes client-side routing work. If someone opens /dashboard/settings directly or refreshes the page, there is no such file on disk, so nginx returns index.html and React Router takes over. Without this, deep links return 404.
  • The /assets/ location returns a real 404 for missing files instead of falling back to index.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-cache for index.html. Because file names change whenever content changes, assets can be cached for a year; index.html must be revalidated so users pick up new releases.
  • The headers are repeated inside each location block on purpose. In nginx, if a location defines any add_header, it stops inheriting the ones from the server level.
  • listen 8080 because we will use an unprivileged image that does not run as root.
  • The /healthz endpoint 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.json and the lockfile before the rest of the code lets Docker reuse the cached npm ci layer when only your source changes. That makes rebuilds much faster.
  • npm ci installs 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-unprivileged is 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 during npm run build (see Vite’s env variables guide). Changing one means rebuilding the image. Never put secrets in a VITE_ 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.conf is 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.html that points to deleted assets, or your server returns index.html for missing JavaScript files. The config above avoids both.
  • npm ci fails in the build. The lockfile is missing from the build context or is out of sync with package.json; run npm install locally 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 with ARG in the Dockerfile.
  • “Permission denied” when starting nginx. You replaced the main nginx.conf of the unprivileged image and removed its writable temp paths; replace only conf.d/default.conf as shown.
  • Container exits immediately. Run docker logs <container>; a syntax error in your nginx config is the usual cause. You can check a config with nginx -t.

Security and Production Notes

  • Run as non-root (done) and avoid latest tags.
  • Scan your images for known vulnerabilities (for example with docker scout or 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.

Snehasish Konger
Developed @scientyficworld.org | Technical writer @Nected | Content Developer
Connect with Snehasish Konger

On This page

Take a Pause with Intervals

A Sunday letter on building, writing, and thinking deeper as a developer — short, honest, and worth your time.

Snehasish Konger profile photo

"Hey there — I'm Snehasish. Hope this post saved you some head-scratching time! I've spent years turning technical chaos into clarity, and I'm here to be your guide through the maze of modern tech. Stick around for more lightbulb moments — we're just getting started."

Related Posts