Key Takeaways
- React = static files after build:
npm run buildproducesdist/— just HTML, CSS, JS. Nginx serves it. No server process to manage. try_files $uri /index.htmlis mandatory: Client-side routing requires all routes to serveindex.html. Without this, page refresh on any route returns 404. See Nginx Reverse Proxy Tutorial for detailed configuration.VITE_prefix for env vars: Only variables prefixedVITE_are embedded in the build. Others are stripped. Bake public config at build time.- Aggressive caching for assets: Vite hashes filenames (
main.a1b2c3.js) — cache forever.index.htmlmust never be cached. For API backend, see Build a REST API with Fastify or Nginx Reverse Proxy Tutorial.
Introduction
Direct Answer: How do I host a React or Vite app on my own VPS without Vercel in 2026?
Build the app: npm run build — this produces a dist/ folder with static files. On your server, install Nginx and create a virtual host pointing root at a directory where you’ll copy the dist folder: root /var/www/myapp. Add try_files $uri $uri/ /index.html inside the location / block for client-side routing. Copy the build: rsync -avz dist/ user@server:/var/www/myapp/. Enable Let’s Encrypt SSL with sudo certbot --nginx -d yourdomain.com. Automate with GitHub Actions: a workflow that runs npm run build on push and rsyncs dist/ to the server. Total setup: ~20 minutes. Monthly cost: ~€4 on Hetzner CX22 versus $20+/month on Vercel Pro.
Part 1: Build the React App
# Create a Vite React project (or use your existing one)
npm create vite@latest myapp -- --template react-ts
cd myapp
npm install
# Set environment variables (Vite-specific prefix required)
cat > .env.production << 'EOF'
VITE_API_URL=https://api.example.com
VITE_APP_NAME=MyApp
# Note: Variables without VITE_ prefix are NOT included in the build
EOF
# Build for production
npm run build
ls -lh dist/
Expected output:
dist/
├── assets/
│ ├── index-BkT9a7pL.css (18.4 KB gzipped)
│ ├── index-CvD3eF8g.js (142.8 KB gzipped)
│ └── vendor-Dh2iJ5kL.js (89.3 KB gzipped)
└── index.html (0.5 KB)
Build time: 3.2s
Hashed filenames (BkT9a7pL) enable permanent browser caching — every build produces new unique filenames.
Part 2: Server Setup
# On the Ubuntu 24.04 VPS
sudo apt-get install -y nginx
sudo mkdir -p /var/www/myapp
sudo chown -R www-data:www-data /var/www/myapp
# Create Nginx virtual host
sudo tee /etc/nginx/sites-available/myapp << 'EOF'
server {
listen 80;
server_name myapp.example.com;
root /var/www/myapp;
index index.html;
# ── React SPA routing ──────────────────────────────────────────────────
# Without this, /about returns 404 on refresh (file doesn't exist on disk)
location / {
try_files $uri $uri/ /index.html;
}
# ── Aggressive caching for hashed assets ──────────────────────────────
# Vite hashes filenames — safe to cache forever
location /assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
gzip_static on; # Serve pre-compressed files if available
}
# ── Never cache index.html ────────────────────────────────────────────
location = /index.html {
expires -1;
add_header Cache-Control "no-cache, no-store, must-revalidate";
}
# ── Compression ───────────────────────────────────────────────────────
gzip on;
gzip_types text/plain text/css application/json application/javascript
text/xml application/xml text/javascript;
gzip_min_length 1000;
# ── Security headers ──────────────────────────────────────────────────
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
}
EOF
sudo ln -sf /etc/nginx/sites-available/myapp /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx
Part 3: Deploy the Build
# From your local machine — copy build to server
rsync -avz --delete \
dist/ \
[email protected]:/var/www/myapp/
# Verify
curl -sI http://myapp.example.com | head -5
curl -s http://myapp.example.com/about | grep -c "index.html\|root\|app"
Expected output:
HTTP/1.1 200 OK
Server: nginx
Content-Type: text/html; charset=utf-8
1 ← index.html served for /about route (SPA routing working)
Part 4: HTTPS with Let’s Encrypt
sudo apt-get install -y certbot python3-certbot-nginx
sudo certbot --nginx -d myapp.example.com --redirect \
--non-interactive --agree-tos --email [email protected]
# Verify HTTPS
curl -sI https://myapp.example.com | head -3
Expected output:
HTTP/2 200
server: nginx
content-type: text/html; charset=utf-8
Part 5: GitHub Actions CI/CD
# .github/workflows/deploy.yml
name: Deploy React App
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "22"
cache: "npm"
- name: Install dependencies
run: npm ci
- name: Build
run: npm run build
env:
VITE_API_URL: ${{ vars.VITE_API_URL }}
VITE_APP_NAME: MyApp
- name: Deploy via rsync
env:
SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
DEPLOY_USER: ${{ secrets.DEPLOY_USER }}
run: |
echo "$SSH_KEY" > /tmp/deploy_key
chmod 600 /tmp/deploy_key
rsync -avz --delete \
-e "ssh -i /tmp/deploy_key -o StrictHostKeyChecking=no" \
dist/ \
${DEPLOY_USER}@${DEPLOY_HOST}:/var/www/myapp/
- name: Verify deployment
run: |
sleep 3
STATUS=$(curl -sI https://myapp.example.com | head -1 | awk '{print $2}')
echo "HTTP Status: $STATUS"
[ "$STATUS" = "200" ] || exit 1
Required GitHub secrets:
DEPLOY_SSH_KEY— private key for the deploy userDEPLOY_HOST— your server’s IP or hostnameDEPLOY_USER— SSH username
Required GitHub variables:
VITE_API_URL— your API URL (baked into the build)
Part 6: Environment Variables Strategy
# .env files hierarchy (Vite):
.env # Defaults — committed to Git
.env.production # Production overrides — committed
.env.local # Local overrides — NOT committed (in .gitignore)
.env.production.local # Local production overrides — NOT committed
// In your React code — access Vite env vars
const apiUrl = import.meta.env.VITE_API_URL // "https://api.example.com"
const isDev = import.meta.env.DEV // true in dev, false in build
const appName = import.meta.env.VITE_APP_NAME // "MyApp"
// Type-safe env vars (src/env.d.ts)
interface ImportMetaEnv {
readonly VITE_API_URL: string
readonly VITE_APP_NAME: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}
Troubleshooting
Page refresh on any route returns 404
Cause: Missing try_files $uri $uri/ /index.html in Nginx config.
Fix: Add the try_files line to the location / block and sudo systemctl reload nginx.
Build environment variables showing as undefined
Cause: Missing VITE_ prefix. API_URL=... is stripped from the build.
Fix: Rename to VITE_API_URL=... and access as import.meta.env.VITE_API_URL.
Assets returning stale versions after deploy
Cause: Browser cached the old index.html which referenced old hashed asset filenames.
Fix: The Cache-Control: no-cache on index.html prevents this — verify the nginx config includes the location = /index.html block with no-cache headers.
Conclusion
A React/Vite app is running sovereignly: Nginx serves the static build, try_files handles client-side routing, hashed assets are cached aggressively, and GitHub Actions deploys on every push to main. Total monthly cost: ~€4 on Hetzner versus $20+ on Vercel Pro.
See React + Vite Frontend 2026 for SSR/full-stack alternatives, and Nginx Reverse Proxy Tutorial 2026 when the React app needs to proxy API requests.
People Also Ask
When should I use Vercel vs self-hosting for a React app?
Self-hosting wins on: cost (€4/month vs $20+), data sovereignty (no traffic going through Vercel’s servers), full control over infrastructure, and no vendor lock-in. Vercel wins on: zero-config deployment, global CDN (faster for international users), automatic preview deployments per PR, and built-in analytics. For internal tools, B2B apps, or privacy-sensitive applications: self-host. For consumer-facing apps with global users where setup speed matters more than control: Vercel is reasonable.
Can I self-host a React app with server-side rendering?
Yes, but it requires a Node.js server process (not just Nginx + static files). For SSR/ISR, see React + Vite Frontend 2026 which covers Docker + Nginx for a full deployment. For Remix or React Router v7 SSR, the pattern is similar: build produces a server bundle, run it with Node.js, proxy with Nginx.
Related Vucense Guides
- Nginx Reverse Proxy & Static File Serving — reverse proxy and caching for React apps
- Docker Compose Full Stack Setup 2026 — containerize this React app with backend services
- GitHub Actions CI/CD Tutorial 2026 — extend this workflow with Docker builds and deployment
Further Reading
Vucense Guides
- React + Vite Frontend 2026 — SSR/full-stack alternative to plain Vite
- Nginx Reverse Proxy Tutorial 2026 — proxy API requests from the React app
- Apache SSL with Let’s Encrypt 2026 — alternative to Nginx reverse proxy
- GitHub Actions Tutorial 2026 — automate builds and deployments
- Docker Compose Tutorial 2026 — containerise React + API deployment
Official Documentation
- Vite Official Documentation — fast build tool for React; v6.x
- React Official Documentation — UI framework reference
- Nginx Official Documentation — reverse proxy and static server
- Node.js Official Documentation — JavaScript runtime; v22 LTS
- npm Documentation — JavaScript package manager
- GitHub Actions Documentation — CI/CD automation
Deployment & Hosting Tools
- Certbot for Nginx — Let’s Encrypt automation
- Systemd Service Files — persistent service management
- PM2 Process Manager — alternative Node.js process supervisor
- systemd Timer Units — scheduled restart/update jobs
Performance & Monitoring
- Lighthouse Page Speed Audit — React app performance testing
- Nginx Status Page — monitor reverse proxy metrics
- PM2 Monitoring Dashboard — real-time app performance tracking
Tested on: Ubuntu 24.04 LTS (Hetzner CX22). Nginx 1.27.3, Node.js 22.11.0, Vite 6.0.7. Last verified: May 16, 2026.