Why Self-Host?
Vercel is the easiest way to deploy Next.js — it's built by the same team, and for most projects it's the right choice. But there are real reasons to self-host:
- Cost: A small VPS ($6–$20/month) handles significant traffic. Vercel's pricing can grow fast at scale.
- Compliance: Some clients require data to stay on specific infrastructure.
- Control: Full control over the runtime environment, dependencies, and configuration.
This guide covers a production-grade setup on Ubuntu using Nginx as a reverse proxy, PM2 for process management, and GitHub Actions for CI/CD.
Server Setup
Start with a fresh Ubuntu 22.04 VPS. I use DigitalOcean Droplets, but Hetzner (better price/performance in Europe) or any VPS provider works.
# Update system
sudo apt update && sudo apt upgrade -y
# Install Node.js (use NodeSource for latest LTS)
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
sudo apt install -y nodejs
# Install pnpm
npm install -g pnpm
# Install PM2
npm install -g pm2
# Install Nginx
sudo apt install -y nginx
# Set up firewall
sudo ufw allow OpenSSH
sudo ufw allow 'Nginx Full'
sudo ufw enableNginx Configuration
Nginx acts as a reverse proxy, forwarding traffic to the Next.js server running on port 3000:
# /etc/nginx/sites-available/myapp.com
server {
listen 80;
server_name myapp.com www.myapp.com;
# Security headers
add_header X-Frame-Options "SAMEORIGIN";
add_header X-Content-Type-Options "nosniff";
add_header Referrer-Policy "strict-origin-when-cross-origin";
# Gzip compression
gzip on;
gzip_types text/plain text/css application/json application/javascript;
# Static assets — served directly by Nginx, bypassing Node.js
location /_next/static/ {
alias /var/www/myapp/.next/static/;
expires 1y;
add_header Cache-Control "public, immutable";
}
location /public/ {
alias /var/www/myapp/public/;
expires 30d;
}
# Everything else goes to Next.js
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;
}
}# Enable the site
sudo ln -s /etc/nginx/sites-available/myapp.com /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx
# Set up SSL with Certbot (free Let's Encrypt certificates)
sudo apt install -y certbot python3-certbot-nginx
sudo certbot --nginx -d myapp.com -d www.myapp.comCertbot automatically edits your Nginx config to add HTTPS and sets up auto-renewal.
PM2 Process Configuration
Create an ecosystem.config.js in your project root:
module.exports = {
apps: [
{
name: "myapp",
script: "node_modules/.bin/next",
args: "start",
cwd: "/var/www/myapp",
instances: "max", // One process per CPU core
exec_mode: "cluster", // Load balance across processes
env_production: {
NODE_ENV: "production",
PORT: 3000,
},
max_memory_restart: "500M",
error_file: "/var/log/pm2/myapp-error.log",
out_file: "/var/log/pm2/myapp-out.log",
},
],
};# Start the app
pm2 start ecosystem.config.js --env production
# Save process list (survives reboots)
pm2 save
# Auto-start on server reboot
pm2 startupGitHub Actions CI/CD
Create .github/workflows/deploy.yml:
name: Deploy to Production
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: "20"
- name: Install pnpm
uses: pnpm/action-setup@v3
with:
version: 9
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build
run: pnpm build
env:
# Pass build-time env vars as GitHub Secrets
NEXT_PUBLIC_API_URL: ${{ secrets.NEXT_PUBLIC_API_URL }}
- name: Deploy to server
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.SERVER_HOST }}
username: deploy
key: ${{ secrets.SSH_PRIVATE_KEY }}
script: |
cd /var/www/myapp
git pull origin main
pnpm install --frozen-lockfile
pnpm build
pm2 reload ecosystem.config.js --env productionAdd SERVER_HOST, SSH_PRIVATE_KEY, and any other secrets in your GitHub repository settings → Secrets.
Zero-Downtime Deploys
pm2 reload (not restart) performs a rolling reload in cluster mode — new processes start before old ones stop. Users never see a downtime window during deploys.
For database migrations, run them before the pm2 reload step:
npx prisma migrate deploy # or your migration command
pm2 reload ecosystem.config.js --env productionMonitoring
pm2 monit # Real-time CPU/memory per process
pm2 logs myapp # Tail application logs
pm2 status # Process status overviewFor production monitoring, I add:
- Better Uptime (free tier) — pings every minute, emails on downtime.
- Sentry — error tracking and alerting.
- Netdata or Grafana — server metrics (CPU, memory, disk, network).
The Result
A production Next.js deployment that:
- Serves static assets directly from Nginx (fast, no Node.js overhead).
- Runs across all CPU cores with load balancing.
- Deploys automatically on every push to
main. - Renews SSL certificates automatically.
- Restarts automatically if the process crashes or the server reboots.
The total server cost for a mid-traffic project: $6–$20/month.
