Vucense

Deploy React Apps Without Vercel or Netlify: Self-Hosted VPS Guide 2026

🟢Beginner

Host React and Vite apps on your own Ubuntu VPS. Covers static build deployment, Nginx serving, GitHub Actions CI/CD, environment variables, and sovereign CDN-free asset delivery.

Anju Kushwaha

Author

Anju Kushwaha

Founder & Editorial Director

Published

Duration

Reading

15 min

Build

25 min

Deploy React Apps Without Vercel or Netlify: Self-Hosted VPS Guide 2026
Article Roadmap

Key Takeaways

  • React = static files after build: npm run build produces dist/ — just HTML, CSS, JS. Nginx serves it. No server process to manage.
  • try_files $uri /index.html is mandatory: Client-side routing requires all routes to serve index.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 prefixed VITE_ 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.html must 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 user
  • DEPLOY_HOST — your server’s IP or hostname
  • DEPLOY_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.



Further Reading

Vucense Guides

Official Documentation

Deployment & Hosting Tools

Performance & Monitoring

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.

Further Reading

All Dev Corner

Comments