Deploying a React or Next.js app on a VPS: complete guide
12 min read · 15-May-2025
villagehosting.in team
15 May 2025
Vercel and Netlify are convenient, but if you want full control — or your project requires backend functionality that needs a server — deploying on a VPS is the right choice. Here is how to do it properly.
Next.js App Router requires Node.js 18.17+ on your server
Next.js 14+ with the App Router (server components, server actions) requires Node.js 18.17 or higher. If your VPS is running Ubuntu 20.04, the default Node.js from apt is version 10 or 12 — far too old. Always install Node.js via nvm to get the correct version regardless of your OS.
React vs Next.js: different deployment approaches
React (Create React App / Vite):
A React-only frontend is a static site after npm run build. You get a build/ or dist/ folder with HTML, CSS, and JavaScript files. This can be served by NGINX directly — no Node.js process needed.
Next.js:
Next.js with server components, API routes, or server-side rendering needs a Node.js process running. You cannot serve it statically unless you use next export (only for fully static sites).
This guide covers both cases.
Serving a React (Vite/CRA) build with NGINX
Build your app:
npm run build
# Output: dist/ (Vite) or build/ (CRA)
Upload the build folder to your VPS:
rsync -avz dist/ user@yourserver:~/myapp/public/
Configure NGINX:
sudo nano /etc/nginx/sites-available/myapp
server {
listen 80;
server_name yourdomain.com www.yourdomain.com;
root /home/user/myapp/public;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
# Cache static assets
location ~* \.(js|css|png|jpg|gif|svg|ico|woff2)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}
The try_files $uri $uri/ /index.html line handles client-side routing (React Router) — all 404s fall back to index.html where React takes over.
Enable and reload:
sudo ln -s /etc/nginx/sites-available/myapp /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx
Add SSL:
sudo certbot --nginx -d yourdomain.com -d www.yourdomain.com
Deploying Next.js with PM2
Next.js requires a running Node.js process. Steps:
On the VPS, clone and build:
git clone https://github.com/yourusername/your-nextjs-app.git ~/nextapp
cd ~/nextapp
npm install
npm run build
Create .env.local on the server:
nano ~/nextapp/.env.local
NEXT_PUBLIC_API_URL=https://api.yourdomain.com
DATABASE_URL=mysql://user:password@localhost/dbname
NEXTAUTH_SECRET=your-secret
NEXTAUTH_URL=https://yourdomain.com
Start with PM2:
pm2 start npm --name "nextapp" -- start
pm2 save
pm2 startup
This runs npm start (which calls next start) as a managed process.
Configure NGINX reverse proxy:
server {
listen 80;
server_name yourdomain.com www.yourdomain.com;
location / {
proxy_pass http://localhost:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
}
}
Add SSL via Certbot as before.
Environment variables in production
Next.js distinguishes between:
NEXT_PUBLIC_*— included in the browser bundle (visible to users)- All others — server-only (safe for secrets)
Never put secrets in NEXT_PUBLIC_* variables. API keys, database passwords, and session secrets must be server-only.
Set production env variables in .env.local (not committed to Git). For CI/CD pipelines, use GitHub Secrets or your server's environment.
Setting up GitHub Actions for automated deployment
Create .github/workflows/deploy.yml in your repository:
name: Deploy
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Deploy to VPS
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.VPS_HOST }}
username: ${{ secrets.VPS_USER }}
key: ${{ secrets.VPS_SSH_KEY }}
script: |
cd ~/nextapp
git pull origin main
npm install
npm run build
pm2 reload nextapp --update-env
In your GitHub repository:
- Settings → Secrets → add
VPS_HOST,VPS_USER,VPS_SSH_KEY
Every push to main now triggers an automatic deployment.
Handling multiple environments
For staging + production on the same VPS:
Staging:
- Clone to
~/nextapp-staging - Run on port 3001
- NGINX serves
staging.yourdomain.com→ port 3001 - PM2 name:
nextapp-staging
Production:
- Clone to
~/nextapp - Run on port 3000
- NGINX serves
yourdomain.com→ port 3000 - PM2 name:
nextapp
Deploy to staging first, test, then pull to production.
NGINX caching for Next.js static assets
Next.js outputs static files to _next/static/. These are content-addressed (filename includes a hash) and can be cached forever:
location /_next/static/ {
proxy_pass http://localhost:3000;
expires 1y;
add_header Cache-Control "public, immutable";
}
For dynamic pages, do not add cache headers — let Next.js handle its own caching with Cache-Control headers from your route handlers.
Monitoring your Next.js app
# View logs
pm2 logs nextapp
# Monitor CPU and memory
pm2 monit
# Check if the app is responding
curl http://localhost:3000/api/health
Set up UptimeRobot (free) to ping your domain every 5 minutes and alert you on Slack or WhatsApp if it goes down.
Common issues
Build fails with "out of memory": Node.js default heap is 512MB. On a small VPS, increase it:
NODE_OPTIONS="--max-old-space-size=1024" npm run build
App crashes randomly: PM2 restarts it, but you need to investigate the crash reason via pm2 logs nextapp --err. Common causes: unhandled promise rejections, memory leaks, or missing environment variables in production.
NGINX 502 Bad Gateway: The Node.js app is not running or not on port 3000. Check pm2 status and pm2 logs nextapp.
Environment variables not loading: Make sure .env.local is on the server (not in Git) and restart PM2 with pm2 reload nextapp --update-env.