React apps feel almost magical to build — hot reload, instant feedback, everything just works. Then you try to put one online and realize you have no idea what the actual deployment process looks like.
The key insight that took me a while to grasp: in production, a standard React app doesn't need Node.js running at all. Running npm run build spits out a dist or build folder full of plain HTML, CSS, and JavaScript. Nginx can serve those files directly — fast, simple, and cheap.
The tricky part is client-side routing. If your app uses React Router, navigating directly to a URL like /profile/123 on a server that doesn't know about those routes will return a 404. I'll show you the two-line Nginx fix for that.
This guide covers building, deploying, handling routing, and HTTPS.
I deploy this on Tencent Cloud Lighthouse running Ubuntu 22.04. For React apps without a backend, the Starter plan (2 GB RAM) handles significant traffic since Nginx is extremely efficient at serving static files — it's mostly idle between requests. For global audiences, I add Tencent Cloud EdgeOne as a CDN layer, which caches assets at edge nodes worldwide and reduces latency for international visitors significantly. Lighthouse's predictable bandwidth pricing (fixed monthly traffic allowance) means no surprise bills even if a post goes viral.
Key Takeaways
- React production build is static files — Nginx serves them directly, no Node.js needed
- Add
try_files $uri $uri/ /index.html;to Nginx config to fix React Router 404s- Environment variables are baked into the bundle at build time (
REACT_APP_orVITE_prefix)- Use GitHub Actions to automate build and deploy on every push to main
- For global audiences, add EdgeOne CDN in front for edge caching of static assets
React builds compile your code into static files:
npm run build
→ /build (CRA) or /dist (Vite)
├── index.html
├── static/css/main.xxx.css
└── static/js/main.xxx.js
Nginx serves these files directly. No Node.js process runs in production. This makes React apps cheap to host and easy to scale.
The only complexity: client-side routing. React Router intercepts navigation in the browser, but if someone directly visits /dashboard or refreshes a page, the server needs to return index.html for every path — React Router then takes over. Setting this up in Nginx is the key configuration step.
| Requirement | Notes |
|---|---|
| Node.js installed locally | For building the app |
| Cloud server | Tencent Cloud Lighthouse Ubuntu 22.04 |
| Nginx installed on server | See Nginx setup guide |
| A React project | Create React App, Vite, or similar |
On your local machine:
# Build for production
npm run build
# The build output is in the /build directory
ls build/
# index.html asset-manifest.json static/
npm run build
# Output is in /dist
ls dist/
# index.html assets/
The build process minifies and optimizes everything automatically. The build/ or dist/ directory contains everything that needs to go on the server.
SSH into your server and create a directory for your app:
ssh ubuntu@YOUR_SERVER_IP
sudo mkdir -p /var/www/myreactapp
sudo chown ubuntu:www-data /var/www/myreactapp
sudo chmod 755 /var/www/myreactapp
# On your local machine
scp -r build/* ubuntu@YOUR_SERVER_IP:/var/www/myreactapp/
# For Vite: scp -r dist/* ubuntu@YOUR_SERVER_IP:/var/www/myreactapp/
If your repo includes the build or you build on the server:
# On the server
cd ~
git clone https://github.com/your-username/your-react-app.git
cd your-react-app
# Install Node.js if not present
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
sudo apt install -y nodejs
# Install dependencies and build
npm install
npm run build
# Copy build output to web root
cp -r build/* /var/www/myreactapp/
# For Vite: cp -r dist/* /var/www/myreactapp/
sudo nano /etc/nginx/sites-available/myreactapp
server {
listen 80;
server_name yourdomain.com www.yourdomain.com;
root /var/www/myreactapp;
index index.html;
# Gzip compression for faster loading
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml;
gzip_vary on;
# Cache static assets aggressively (they have content hashes in filenames)
location /static/ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# All routes fall back to index.html (required for React Router)
location / {
try_files $uri $uri/ /index.html;
}
}
sudo ln -s /etc/nginx/sites-available/myreactapp /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx
Visit http://YOUR_SERVER_IP — your React app should load.
This is the configuration that makes React Router work:
location / {
try_files $uri $uri/ /index.html;
}
What this does:
$uri (an actual file like /static/js/main.js)$uri/ (a directory)/index.htmlThis means any route (/dashboard, /profile/123, /settings) that doesn't match an actual file gets served index.html, and React Router handles the routing from there.
Without this, a hard refresh or direct URL visit to any route other than / returns a 404.
sudo apt install -y certbot python3-certbot-nginx
sudo certbot --nginx -d yourdomain.com -d www.yourdomain.com
Test auto-renewal:
sudo certbot renew --dry-run
Manually uploading files gets tedious. Set up GitHub Actions to build and deploy automatically on every push to main:
Create .github/workflows/deploy.yml in your repo:
name: Deploy React App
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install and build
run: |
npm ci
npm run build
- name: Deploy to server
uses: appleboy/scp-action@v0.1.7
with:
host: ${{ secrets.SERVER_IP }}
username: ubuntu
key: ${{ secrets.SSH_PRIVATE_KEY }}
source: "build/"
target: "/var/www/myreactapp"
strip_components: 1
Add secrets in your GitHub repo settings:
SERVER_IP = your server's public IPSSH_PRIVATE_KEY = your private SSH keyEvery push to main now builds and deploys automatically.
You navigate to /dashboard in React — looks great. You refresh the page — blank page or 404.
This is the React Router + static server problem. The fix is the try_files $uri $uri/ /index.html; line in Nginx. If you're seeing a blank page on refresh, check:
# Verify your Nginx config has the try_files line
grep -A 3 "location /" /etc/nginx/sites-enabled/myreactapp
If the line is there but it's still not working, check the browser console for JavaScript errors — it might be a different issue (wrong base URL, missing env variables, etc.).
Also common: if your app is deployed at a subdirectory (e.g., yourdomain.com/app/), you need to set homepage in package.json:
{
"homepage": "/app"
}
React environment variables are embedded at build time — they're not available at runtime like Node.js env vars. Create a .env.production file before building:
# .env.production
REACT_APP_API_URL=https://api.yourdomain.com
REACT_APP_ANALYTICS_ID=your-analytics-id
In your code:
const apiUrl = process.env.REACT_APP_API_URL;
Important: Only variables prefixed with REACT_APP_ are included in the build. All REACT_APP_ variables are visible in the browser — never put secrets in them.
For Vite, variables are prefixed with VITE_:
# .env.production
VITE_API_URL=https://api.yourdomain.com
const apiUrl = import.meta.env.VITE_API_URL;
| Issue | Likely Cause | Fix |
|---|---|---|
| Connection refused | Service not running or wrong port | Check systemctl status SERVICE and verify firewall rules |
| Permission denied | Wrong file ownership or permissions | Check file ownership with ls -la and use chown/chmod to fix |
| 502 Bad Gateway | Backend service not running | Restart the backend service; check logs with journalctl -u SERVICE |
| SSL certificate error | Certificate expired or domain mismatch | Run sudo certbot renew and verify domain DNS points to server IP |
| Service not starting | Config error or missing dependency | Check logs with journalctl -u SERVICE -n 50 for specific error |
| Out of disk space | Logs or data accumulation | Run df -h to identify usage; clean logs or attach CBS storage |
| High memory usage | Too many processes or memory leak | Check with htop; consider upgrading instance plan if consistently high |
| Firewall blocking traffic | Port not open in UFW or Lighthouse console | Open port in Lighthouse console firewall AND sudo ufw allow PORT |
Do I need Node.js running in production for a React app?
Not for a standard Create React App or Vite build. npm run build generates static HTML, CSS, and JavaScript files that Nginx serves directly — no Node.js process needed in production.
What causes 404 errors when refreshing a React page directly?
React Router handles navigation in the browser, but the server doesn't know about those routes. When you refresh /profile/123, Nginx tries to find that file and fails. Fix: add try_files $uri $uri/ /index.html; to your Nginx config.
How do I deploy React app updates?
Run npm run build locally, then upload the new build/ or dist/ folder to your server (or automate with GitHub Actions). The deployment is just replacing static files.
What's the difference between npm run build and npm start?
npm start runs the development server with hot reload — not for production. npm run build creates an optimized, minified production bundle in the build/ or dist/ directory.
.env.production in your project root. Variables must start with REACT_APP_ (CRA) or VITE_ (Vite) to be included in the build. They're baked into the bundle at build time.Deploy your React app today:
👉 Tencent Cloud Lighthouse — Nginx-ready Ubuntu VPS
👉 View current pricing and promotions
👉 Explore all active deals and offers