Blog/How to Deploy a React App with Nginx on Ubuntu (try_files for React Router, Caching, and API Proxy)

NginxReactViteFrontendDevOps··greencrew

How to Deploy a React App with Nginx on Ubuntu (try_files for React Router, Caching, and API Proxy)

To deploy a React app with Nginx, run npm run build, copy the build folder (dist for Vite) to /var/www/your-app, and add a server block with root pointing at it and try_files $uri $uri/ /index.html so React Router routes don't return 404. This guide covers the full config, caching hashed assets, proxying /api to a backend, subfolder deploys, and HTTPS.

Short answer: A React app is static files after you build it, so Nginx serves it directly with no Node.js process. Run npm run build, copy the output folder (dist/ for Vite, build/ for Create React App) to /var/www/myapp, and set root /var/www/myapp; in a server block. The line that matters is try_files $uri $uri/ /index.html;. It sends every URL that isn't a real file to index.html, so client-side routes like /dashboard work on refresh instead of returning 404 Not Found. Then cache hashed assets for a year, don't cache index.html, and add HTTPS with Certbot.

How Serving a React App with Nginx Works

When you run npm run build, your bundler turns JSX, CSS, and imports into plain HTML, JavaScript, and CSS files. The browser downloads them once, and from then on React Router changes pages in JavaScript without asking the server.

The catch: if a user refreshes on /settings/profile or opens it from a link, the browser asks Nginx for /settings/profile. That file doesn't exist on disk, so a default Nginx config returns 404. The fix is to fall back to index.html and let React Router render the right page.

Build toolBuild commandOutput folderHashed assets folder
Vite (npm create vite@latest)npm run builddist/dist/assets/
Create React App (deprecated)npm run buildbuild/build/static/
Next.js static export (output: 'export')npm run buildout/out/_next/static/

Create React App is deprecated, and the React docs recommend a framework or a build tool like Vite for new projects. This guide uses Vite's dist/, but the Nginx config is the same for any of them. Only the folder names change. (A Next.js app with server-side rendering or API routes is a Node.js server, not static files. Deploy it like any Node.js app with PM2 and Nginx.)

Prerequisites

  • Ubuntu server with Nginx installed (how to install Nginx on Ubuntu)
  • Your React project building locally or in CI
  • A domain pointed at the server for HTTPS

Step 1: Build the React App

Build on your machine or in CI, not on a small production server. Builds use a lot of memory, and the server only needs the output.

npm ci
npm run build
ls dist
# assets  favicon.svg  index.html

Set Environment Variables at Build Time

Variables are baked into the JavaScript bundle when you build. Changing them on the server does nothing until you rebuild.

ToolPrefixExampleRead in code
ViteVITE_VITE_API_URL=/apiimport.meta.env.VITE_API_URL
Create React AppREACT_APP_REACT_APP_API_URL=/apiprocess.env.REACT_APP_API_URL

Anything in the bundle is public. Never put API secrets in a React environment variable. Keep them in your backend.

Step 2: Copy the Build to the Server

sudo mkdir -p /var/www/myapp
sudo chown $USER:$USER /var/www/myapp
rsync -avz --delete dist/ user@your-server:/var/www/myapp/

The trailing slash on dist/ copies the folder's contents, and --delete removes files from old builds. Nginx's worker user (www-data on Ubuntu) needs read access, which the default permissions give it.

Step 3: Create the Nginx Server Block

Create /etc/nginx/sites-available/myapp:

server {
    listen 80;
    listen [::]:80;
    server_name example.com www.example.com;

    root /var/www/myapp;
    index index.html;

    # Single-page app fallback for React Router
    location / {
        try_files $uri $uri/ /index.html;
    }

    # Hashed build assets never change, so cache them for a year
    location /assets/ {
        try_files $uri =404;
        expires 1y;
        add_header Cache-Control "public, immutable";
        access_log off;
    }

    # Always revalidate the HTML entry point
    location = /index.html {
        add_header Cache-Control "no-cache";
    }

    gzip on;
    gzip_types text/css application/javascript application/json image/svg+xml;
}

Enable it and reload:

sudo ln -s /etc/nginx/sites-available/myapp /etc/nginx/sites-enabled/
sudo rm -f /etc/nginx/sites-enabled/default
sudo nginx -t && sudo systemctl reload nginx

Test a deep link, which is the request that breaks without the fallback:

curl -I http://example.com/settings/profile
# HTTP/1.1 200 OK
# Content-Type: text/html

What Each Directive Does

DirectivePurpose
root /var/www/myappFolder Nginx serves files from
try_files $uri $uri/ /index.htmlServe the file if it exists, otherwise hand the URL to React Router
location /assets/ + try_files $uri =404A missing JS/CSS file returns a real 404 instead of HTML (which would show up as a confusing "Unexpected token '<'" error)
expires 1y + immutableBrowsers keep hashed files for a year without revalidating
Cache-Control "no-cache" on index.htmlBrowsers check for a new index.html on each visit, so users get the new build right after a deploy
gzip onCompresses JS and CSS. See Gzip and Brotli in Nginx for Brotli

The caching split works because the bundler puts a content hash in each asset filename (for example index-B4x9kQ2a.js). A new build gets new filenames, and the fresh index.html points at them. The try_files directive is documented in the ngx_http_core_module reference.

Note on add_header: Nginx only inherits add_header lines from the parent level when a block defines none of its own. If you set security headers at the server level, repeat them inside any location that has its own add_header, or move them into a shared include file.

Step 4: Proxy API Requests to Your Backend

Serving the frontend and API from the same domain avoids CORS entirely. Have React call relative paths like /api/users, and let Nginx forward them:

location /api/ {
    proxy_pass http://127.0.0.1:3000;
    proxy_http_version 1.1;
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;
}

With proxy_pass http://127.0.0.1:3000; (no path after the port), the backend receives the full /api/users path. If you write proxy_pass http://127.0.0.1:3000/; with a trailing slash, Nginx strips the /api/ prefix and the backend receives /users. Pick the one your backend expects. More options are in how to set up Nginx as a reverse proxy.

Step 5: Deploy React to a Subfolder (Optional)

To serve the app at example.com/app/ instead of the root, three things have to agree:

  1. Vite: set base: '/app/' in vite.config.js (CRA uses "homepage": "/app" in package.json).
  2. React Router: pass the base path, for example <BrowserRouter basename="/app"> or createBrowserRouter(routes, { basename: '/app' }).
  3. Nginx:
location /app/ {
    alias /var/www/myapp/;
    try_files $uri $uri/ /app/index.html;
}

If any one of the three is missing, you'll get a blank page with 404s for /assets/... in the browser console.

Step 6: Add HTTPS

sudo apt install certbot python3-certbot-nginx
sudo certbot --nginx -d example.com -d www.example.com

Certbot edits the server block to listen on 443 and redirect HTTP to HTTPS. See how to install Let's Encrypt SSL on Nginx with Certbot for renewal checks.

Zero-Downtime Deploys with Release Folders

rsync --delete into the live folder can briefly serve a new index.html before its assets have arrived. To swap builds atomically, upload each build to its own folder and switch a symlink:

RELEASE=/var/www/myapp-releases/$(date +%Y%m%d%H%M%S)
rsync -az dist/ user@your-server:$RELEASE/
ssh user@your-server "ln -sfn $RELEASE /var/www/myapp-new && mv -Tf /var/www/myapp-new /var/www/myapp"

mv -T replaces the symlink in one step. For this to work, /var/www/myapp has to be a symlink rather than a real directory, so set that up once before the first release. Keep the last few releases so rolling back is just pointing the symlink back. Old asset files from previous builds stay available to users who still have the old index.html open.

Troubleshooting React on Nginx

SymptomCauseFix
404 on refresh or deep links like /dashboardNo SPA fallbackAdd try_files $uri $uri/ /index.html; to location /
Blank white page, 404s for /assets/*.js in the consoleWrong base/homepage or wrong rootMatch the bundler base path, Router basename, and Nginx location
Uncaught SyntaxError: Unexpected token '<'A JS request got index.html backAdd location /assets/ { try_files $uri =404; } and check the file was uploaded
Users still see the old version after a deployindex.html cached by the browser or a CDNSend Cache-Control: no-cache for index.html, purge the CDN
403 ForbiddenNginx can't read the folder, or index.html is missingls -la /var/www/myapp, fix permissions, check you copied the build output
API calls return 404 or HTMLNo /api/ location, or the prefix is stripped incorrectlyAdd the proxy block and check the trailing slash on proxy_pass
CORS errorsFrontend calls the API on a different originProxy /api/ through the same domain, or set CORS headers on the API
.env change has no effectVariables are baked in at build timeRebuild and redeploy
Default "Welcome to nginx!" page showsDefault site still enabled or server_name mismatchRemove sites-enabled/default, check server_name

Monitor Your React Deployment

A static site rarely "crashes," but it still breaks: a deploy uploads an index.html pointing at missing assets, a certificate fails to renew, or the /api/ backend goes down while the HTML shell keeps loading fine.

GreenCrew runs HTTP uptime checks against your site from outside your network and sends alerts to Discord, Slack, or Telegram when something fails. It also gives you a public status page to share with users. Monitor the homepage and an API endpoint like https://example.com/api/health, since the frontend can look healthy while the backend it depends on is down. Setup steps are in how to get website downtime alerts on Discord, Slack, and Telegram.

Frequently Asked Questions

Why does my React app show 404 when I refresh the page?

Nginx looks for a file matching the URL path, and client-side routes like /about don't exist on disk. Add try_files $uri $uri/ /index.html; so Nginx returns index.html and React Router renders the route.

Do I need Node.js on the server to run a React app?

No, not for a client-side React app. After npm run build it's plain static files, and Nginx serves them on its own. You only need Node.js on the server for server-side rendering (for example Next.js without static export) or for your API.

Should I use HashRouter to avoid the 404 problem?

You can, but URLs like /#/dashboard look worse and are weaker for SEO. The try_files fallback fixes the problem on the server side and lets you keep clean URLs with BrowserRouter.

How do I serve multiple React apps on one server?

Give each app its own server block with its own server_name and root, or serve them from subfolders using the base + basename + alias combination from Step 5.

Conclusion

Deploying a React app with Nginx takes three steps: build it, copy the output folder to the server, and serve it with a try_files $uri $uri/ /index.html fallback so client-side routes survive a refresh. Add long-lived caching for hashed assets, no-cache for index.html, an /api/ proxy to skip CORS, and HTTPS. Then add an external uptime check with GreenCrew so a broken deploy or a dead backend reaches you as an alert before users notice.