A React app built with Vite (or the older Create React App) compiles to plain static files: an index.html, JavaScript bundles and assets. Nginx serves those faster than any Node server, using almost no resources. The only special requirement is client-side routing — handled with one line of config.
1. Build the app
On your machine or in CI:
npm ci
npm run build
Vite outputs to dist/; Create React App to build/. Environment variables are baked in at build time (VITE_* or REACT_APP_*), so set production values before building. Never put secrets in them — everything in the bundle is public.
2. Upload to the server
rsync -avz --delete dist/ [email protected]:/var/www/myapp/
--delete removes old files that are no longer part of the build. See rsync examples.
On the server, make sure Nginx can read the files:
sudo chown -R deploy:www-data /var/www/myapp
sudo find /var/www/myapp -type d -exec chmod 755 {} +
sudo find /var/www/myapp -type f -exec chmod 644 {} +
3. Nginx config
/etc/nginx/sites-available/myapp:
server {
listen 80;
server_name app.example.com;
root /var/www/myapp;
index index.html;
# Hashed build assets: cache for a year
location /assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
try_files $uri =404;
}
# index.html must always be revalidated so users get new deploys
location = /index.html {
add_header Cache-Control "no-cache";
}
# SPA routing: unknown paths fall back to index.html
location / {
try_files $uri $uri/ /index.html;
}
}
For Create React App, hashed files live under /static/ instead of /assets/ — adjust the location.
sudo ln -s /etc/nginx/sites-available/myapp /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx
Why try_files matters
With React Router, a URL like /dashboard/settings exists only in JavaScript. When a user refreshes that page or opens a shared link, the browser asks the server for /dashboard/settings — and without the fallback, Nginx returns 404. try_files $uri $uri/ /index.html serves the file if it exists, otherwise the app shell, and React Router renders the right view.
A side effect: truly missing pages return 200 with the app shell. Handle “not found” inside the app with a catch-all route.
4. Caching strategy
Build tools put a content hash in asset file names (index-3f9a2c1b.js). A new build produces new names, so those files can be cached “forever”. index.html keeps its name and points to the current hashed files, so it must not be cached long — hence no-cache (the browser revalidates each time, cheaply with ETags).
Get this wrong and users see a broken mix of old and new code after a deploy.
5. Compression
Enable gzip (and Brotli if available) for JS, CSS and JSON — see gzip and Brotli in Nginx. Vite plugins can also produce .gz/.br files at build time for gzip_static.
6. HTTPS
sudo certbot --nginx -d app.example.com
7. Proxy the API (avoid CORS)
If your backend runs on the same server, serve it under the same domain so the browser doesn’t need CORS:
location /api/ {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
The React app calls /api/... with relative URLs. Place this block before the catch-all location /.
8. Automate deploys
A GitHub Actions job can build and rsync on every push to main. Upload to a new directory and switch a symlink so visitors never see a half-uploaded build:
RELEASE=/var/www/releases/$(date +%Y%m%d%H%M%S)
rsync -az dist/ deploy@server:$RELEASE/
ssh deploy@server "ln -sfn $RELEASE /var/www/myapp && ls -1dt /var/www/releases/* | tail -n +6 | xargs rm -rf"
The second command keeps the five most recent releases for quick rollback. See GitHub Actions deploy to a VPS.
Security headers
Static apps benefit from a few headers:
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header X-Frame-Options "DENY" always;
A Content Security Policy is the strongest protection against XSS — see Content Security Policy.
Troubleshooting
- 404 on refresh — missing
try_files … /index.html. - Blank page, console shows 404 for JS files — the app was built for a sub-path; set Vite’s
base(or CRA’shomepage) to match where it’s served. - Old version after deploy —
index.htmlis cached; check itsCache-Control. - API calls fail with CORS errors — proxy the API under the same domain, or configure CORS on the backend.
The same setup works for Vue, Svelte, Angular and Astro static builds. Static sites need very little — even the smallest VPS plan serves them at high traffic.