How to Deploy a Next.js Full-Stack App on a VPS with PostgreSQL and PM2
Next.js has evolved from a React framework into a complete full-stack platform β API routes, server components, middleware, and database access all in one codebase. Deploying it on a VPS gives you more control than Vercel (no cold starts, no function timeout limits, no surprise egress bills) while delivering the same full-stack capability.
This guide deploys a full-stack Next.js application on Ubuntu VPS: PostgreSQL for the database, Prisma as the ORM, PM2 for process management, Nginx as the reverse proxy, and Let’s Encrypt for SSL.
The Full-Stack Setup
| Component | Role |
|---|---|
| Next.js | Full-stack framework (frontend + API routes + SSR) |
| PostgreSQL | Primary database |
| Prisma | Type-safe ORM for database access |
| PM2 | Process manager β keeps Next.js running 24/7 |
| Nginx | Reverse proxy β SSL termination, static file serving |
π‘ VPS.DO Tip: Next.js with PostgreSQL runs well on 2 vCPU / 4 GB RAM. VPS.DO’s USA VPS 500SSD plan is a solid production foundation for most Next.js apps. View Plans β
Step 1: Server Setup
sudo apt update && sudo apt upgrade -y
# Install Node.js 22 LTS
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
sudo apt install -y nodejs
# Install PM2 globally
sudo npm install -g pm2
# Install Nginx and Certbot
sudo apt install -y nginx certbot python3-certbot-nginx
# Install PostgreSQL
sudo apt install -y postgresql postgresql-contrib
sudo systemctl enable postgresql nginx
sudo ufw allow 'Nginx Full'
sudo ufw allow OpenSSH
sudo ufw enable
Step 2: Set Up PostgreSQL
sudo -u postgres psql
CREATE DATABASE myapp_production;
CREATE USER myapp_user WITH PASSWORD 'SecureDBPassword123!';
GRANT ALL PRIVILEGES ON DATABASE myapp_production TO myapp_user;
\c myapp_production
GRANT ALL ON SCHEMA public TO myapp_user;
\q
# Test connection
psql postgresql://myapp_user:SecureDBPassword123!@localhost/myapp_production
Step 3: Deploy Your Next.js Application
Option A: Clone from Git
sudo apt install -y git
sudo mkdir -p /var/www/myapp
sudo chown -R $USER:$USER /var/www/myapp
cd /var/www/myapp
git clone https://github.com/yourusername/your-nextjs-app.git .
npm ci
Option B: Create a sample Next.js app (for testing)
cd /var/www
npx create-next-app@latest myapp --typescript --tailwind --app
cd myapp
npm install @prisma/client prisma
npx prisma init
Step 4: Configure Environment Variables
nano /var/www/myapp/.env.production
# Database
DATABASE_URL="postgresql://myapp_user:SecureDBPassword123!@localhost:5432/myapp_production"
# Next.js
NEXTAUTH_URL="https://yourdomain.com"
NEXTAUTH_SECRET="your-32-char-random-secret-here"
# Application
NODE_ENV=production
NEXT_PUBLIC_APP_URL="https://yourdomain.com"
# Add your app-specific vars here
API_KEY="your-api-key"
SMTP_HOST="smtp.youremail.com"
chmod 600 /var/www/myapp/.env.production
Step 5: Configure Prisma and Run Migrations
cd /var/www/myapp
# If using Prisma, generate client and run migrations
npx prisma generate
npx prisma migrate deploy # Apply all pending migrations
# Seed database if needed
npx prisma db seed
Step 6: Build the Next.js Application
cd /var/www/myapp
NODE_ENV=production npm run build
The build output goes to .next/. The next start command serves this built output.
Step 7: Configure PM2
nano /var/www/myapp/ecosystem.config.js
module.exports = {
apps: [
{
name: 'myapp',
script: 'node_modules/.bin/next',
args: 'start',
cwd: '/var/www/myapp',
instances: 'max', // One per CPU core (cluster mode)
exec_mode: 'cluster',
watch: false,
max_memory_restart: '1G',
env_production: {
NODE_ENV: 'production',
PORT: 3000,
},
// Load from .env.production
node_args: '--env-file=.env.production',
error_file: '/var/log/myapp/error.log',
out_file: '/var/log/myapp/out.log',
log_date_format: 'YYYY-MM-DD HH:mm:ss',
},
],
};
sudo mkdir -p /var/log/myapp
sudo chown $USER:$USER /var/log/myapp
# Start with PM2
pm2 start ecosystem.config.js --env production
# Verify it's running
pm2 status
pm2 logs myapp --lines 20
# Test locally
curl http://localhost:3000
Enable auto-start on reboot
pm2 startup
# Run the command it outputs
pm2 save
Step 8: Configure Nginx
sudo nano /etc/nginx/sites-available/myapp
server {
listen 80;
server_name yourdomain.com www.yourdomain.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
server_name yourdomain.com www.yourdomain.com;
ssl_certificate /etc/letsencrypt/live/yourdomain.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/yourdomain.com/privkey.pem;
# Security headers
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
server_tokens off;
# Serve Next.js static files directly (faster than proxying)
location /_next/static/ {
alias /var/www/myapp/.next/static/;
expires 1y;
add_header Cache-Control "public, immutable";
access_log off;
}
# Serve public folder directly
location /public/ {
alias /var/www/myapp/public/;
expires 30d;
add_header Cache-Control "public, no-transform";
}
# Favicon and other root static files
location = /favicon.ico {
alias /var/www/myapp/public/favicon.ico;
access_log off;
}
# Proxy everything else to Next.js
location / {
proxy_pass http://127.0.0.1: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;
# Increase timeout for slow API routes
proxy_read_timeout 60s;
}
access_log /var/log/nginx/myapp.access.log;
error_log /var/log/nginx/myapp.error.log;
}
sudo ln -s /etc/nginx/sites-available/myapp /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx
sudo certbot --nginx -d yourdomain.com -d www.yourdomain.com
Your Next.js app is now live at https://yourdomain.com. β
Step 9: Zero-Downtime Deployment Script
nano ~/deploy.sh
#!/bin/bash
set -e
APP_DIR="/var/www/myapp"
APP_NAME="myapp"
echo "π Starting deployment at $(date)"
cd $APP_DIR
# Pull latest code
echo "π₯ Pulling latest code..."
git pull origin main
# Install dependencies (only if package.json changed)
echo "π¦ Installing dependencies..."
npm ci --only=production
# Generate Prisma client (if schema changed)
echo "ποΈ Generating Prisma client..."
npx prisma generate
# Run database migrations
echo "π Running migrations..."
npx prisma migrate deploy
# Build Next.js
echo "ποΈ Building Next.js..."
NODE_ENV=production npm run build
# Zero-downtime reload with PM2
echo "β»οΈ Reloading PM2 cluster..."
pm2 reload $APP_NAME
# Verify deployment
sleep 5
if pm2 show $APP_NAME | grep -q "online"; then
echo "β
Deployment successful!"
pm2 status
else
echo "β Deployment failed β check pm2 logs"
exit 1
fi
chmod +x ~/deploy.sh
Run ~/deploy.sh for every future deployment. PM2’s cluster reload replaces workers one at a time β zero downtime.
Step 10: Database Backups
nano ~/backup-db.sh
#!/bin/bash
DATE=$(date +%Y-%m-%d_%H-%M)
BACKUP_DIR="/var/backups/myapp"
mkdir -p $BACKUP_DIR
pg_dump -U myapp_user -h localhost myapp_production \
| gzip > $BACKUP_DIR/db-$DATE.sql.gz
find $BACKUP_DIR -name "*.sql.gz" -mtime +14 -delete
echo "Database backup complete: $DATE"
chmod +x ~/backup-db.sh
crontab -e
# 0 2 * * * /bin/bash /root/backup-db.sh
Next.js Specific Optimizations
Enable standalone output (smaller deployment)
// next.config.js
module.exports = {
output: 'standalone', // Creates a minimal production bundle
};
With standalone mode, the .next/standalone directory contains everything needed to run the app β no node_modules required in production:
# Update PM2 to run standalone
# In ecosystem.config.js:
script: '.next/standalone/server.js'
Configure Next.js for your domain
// next.config.js
module.exports = {
// Allow images from specific domains
images: {
domains: ['yourdomain.com', 'cdn.yourdomain.com'],
},
// Set base URL for all environments
env: {
NEXT_PUBLIC_BASE_URL: process.env.NEXTAUTH_URL,
},
};
Add Redis caching for API routes
npm install ioredis
// lib/redis.js
import Redis from 'ioredis';
const redis = new Redis(process.env.REDIS_URL || 'redis://localhost:6379');
export default redis;
// pages/api/products.js
import redis from '@/lib/redis';
export default async function handler(req, res) {
const cached = await redis.get('products');
if (cached) return res.json(JSON.parse(cached));
const products = await fetchFromDatabase();
await redis.setex('products', 300, JSON.stringify(products)); // Cache 5 min
res.json(products);
}
Monitoring Your Next.js App
# Real-time logs
pm2 logs myapp
# CPU and memory dashboard
pm2 monit
# Request metrics (from Nginx logs)
sudo tail -f /var/log/nginx/myapp.access.log | \
awk '{print $7, $9}' | sort | uniq -c | sort -rn | head -20
# Database connections
sudo -u postgres psql -c "SELECT count(*) FROM pg_stat_activity WHERE datname='myapp_production';"
Final Thoughts
Deploying Next.js on a VPS gives you a production environment with no function timeout limits, no cold starts, predictable costs, and complete control over your infrastructure. The PM2 cluster mode uses all available CPU cores, Nginx caches static assets directly, and PostgreSQL runs on the same machine for sub-millisecond database queries.
For most Next.js applications serving under 10,000 daily users, a $20/month VPS.DO plan with this configuration performs comparably to significantly more expensive managed platforms β while giving you full visibility and control.