Self-Hosting a React SPA with Nginx: Build, Serve, and Cache Correctly
Build a React SPA once, serve dist/ with Nginx, and set cache headers so hashed assets live for a year while index.html revalidates on every load.
Netbay Engineering
Netbay Engineering
On this page
A React single-page app stops being React the moment npm run build finishes. What ships to the VPS is a folder of static files: one index.html that bootstraps everything, plus hashed JavaScript and CSS bundles. Serving it well is not a Node problem, it is an Nginx problem. Get the server block and the cache headers right and the app feels instant, deploys cleanly, and never serves a stale bundle. Get them wrong and users see week-old UI or a blank page after every release.
This walkthrough assumes Ubuntu 24.04 on a small VPS, a domain pointed at the server, and a React app built with Vite. The same shape works for Create React App or any bundler that emits an index.html plus an assets directory.
Build once, build reproducibly
The build step belongs in the pipeline, not in your shell history. Two commands do the whole job: npm ci installs exactly what the lockfile pins, and npm run build emits the production bundle into dist/.
cd /srv/app-source
git pull --ff-only origin main
npm ci
npm run build
sudo rsync -a --delete dist/ /var/www/app/html/npm ci, not npm install: ci deletes node_modules first and fails when package.json and the lockfile disagree, which is exactly the behavior you want on a server. rsync -a --delete makes /var/www/app/html an exact mirror of dist/. One honest caveat: during the copy there is a brief window where the new index.html and the old hashed assets coexist, so a request landing mid-deploy can 404 on an asset. For low-traffic sites it is invisible; for a proper fix, see the atomic symlink release pattern.
A server block that does four jobs
The whole Nginx configuration for a production SPA fits on one screen.
server {
listen 80;
server_name app.example.com;
root /var/www/app/html;
index index.html;
gzip on;
gzip_types text/css application/javascript application/json image/svg+xml;
location /assets/ {
try_files $uri =404;
add_header Cache-Control "public, max-age=31536000, immutable";
}
location = /index.html {
add_header Cache-Control "no-cache";
}
location / {
try_files $uri $uri/ /index.html;
}
}Four things happen here. root points at the build output. gzip shrinks the JavaScript, which is the dominant payload cost on mobile connections. The /assets/ location serves hashed files with a one-year lifetime and refuses to invent a fallback, so a genuinely missing bundle is a loud 404 instead of HTML served with the wrong MIME type. The final location sends every other path to index.html, so client-side routing survives refresh and deep links.
The cache contract, in two rules
Vite rewrites every asset filename to include a content hash, so app.9f31c2ab.js changes name whenever its contents change. That hash is a cache-busting contract, and the headers above are how you honor it:
- Hashed assets: max-age=31536000 with immutable. Browsers skip revalidation for a year, and immutable stops Chrome from rechecking on every reload.
- index.html: no-cache. The file is still stored, but the browser revalidates it on each navigation with a cheap If-None-Match request, so a new deploy is picked up on the next load.
no-cache is not no-store. no-store would refetch the full HTML every time and throw away the 304 shortcut. The pair is deliberately asymmetric: a few hundred bytes revalidated often, megabytes never revalidated.
Verify from outside the server
curl -sI https://app.example.com/ | grep -i cache-control
curl -sI https://app.example.com/assets/app.9f31c2ab.js | grep -i cache-controlThe first command must print no-cache and the second the year-long immutable policy. If both print nothing, your add_header directives are not matching, and the browser will guess, usually badly. Point a staging hostname at the box and check the headers before the first real deploy; cache mistakes are far cheaper to fix before users have them cached too.
The takeaway: a React SPA on your own VPS is one build command, one Nginx server block, and two cache rules. Everything else is details you now have answers for.
You can spin up an Ubuntu 24.04 instance on Netbay in under 60 seconds and follow along — netbayhosts.in.
Keep reading
Follow along on a real VPS
Deploy Linux in under 60 seconds
These guides are written against Ubuntu, Debian, and RHEL-family images — the same ones on NetBay.
Deploy an instance