Vercel is the easiest way to deploy Next.js, but it is not the only way — and once your traffic grows, it is rarely the cheapest. Deploying to your own VPS gives you predictable costs, full control over caching and middleware, and no vendor lock-in.
This guide covers a production-ready Next.js deployment on a Linux VPS using the standalone output mode, PM2 for process management, Nginx for reverse proxying, and DeployHQ for automated deployments. The result is a pipeline where every git push builds your app, ships it to your server, and restarts it — no SSH required.
Why deploy Next.js on a VPS?
| Vercel / Netlify | VPS (this guide) | |
|---|---|---|
| Cost at scale | Usage-based — can spike unpredictably | Fixed monthly cost, unmetered bandwidth |
| Control | Limited to platform constraints | Full root access, any Node version, any middleware |
| Cold starts | Serverless functions can have latency | Always-on process, no cold starts |
| Vendor lock-in | Vercel-specific features (Edge Middleware, ISR) | Standard Node.js server, portable anywhere |
| Regions | Limited to provider's edge network | Deploy wherever your users are |
A $10–25/month VPS handles most Next.js applications comfortably. For high-traffic sites, that is a fraction of what serverless platforms charge.
Architecture overview
flowchart LR
Browser["Browser"]
Nginx["Nginx\n(TLS + reverse proxy\n+ static files)"]
Next["Next.js\n(standalone server\nvia PM2)"]
DeployHQ["DeployHQ"]
Git["Git Repo"]
Browser -->|HTTPS :443| Nginx
Nginx -->|HTTP :3000| Next
Git -->|push| DeployHQ
DeployHQ -->|build + deploy| Next
Nginx handles TLS termination and serves static assets directly (faster than proxying them through Node). PM2 keeps the Next.js process alive, restarts it on crashes, and manages zero-downtime reloads.
Prerequisites
- A VPS with at least 1 GB RAM and 1 vCPU (Ubuntu 22.04 or 24.04)
- A domain name pointed at your VPS IP
- A Next.js application in a Git repository
- A DeployHQ account
Step 1: Install Node.js 22 and PM2
Connect to your VPS:
ssh deploy@your-server-ip
Install Node.js 22 (current LTS) using the official NodeSource repository:
sudo apt update && sudo apt upgrade -y
sudo apt install -y curl nginx
# Install Node.js 22 LTS
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
sudo apt install -y nodejs
# Verify
node --version # v22.x.x
npm --version # 10.x.x
# Install PM2 globally
sudo npm install -g pm2
Step 2: Enable standalone output in your Next.js app
The standalone output mode is the key to clean VPS deployments. It traces your application's dependencies and produces a self-contained build with only the files needed to run — no node_modules install required on the server.
In your next.config.ts (or next.config.js):
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
output: 'standalone',
};
export default nextConfig;
After running npm run build, this produces:
.next/
standalone/ # Self-contained server + minimal node_modules
server.js # Entry point (replaces `next start`)
node_modules/ # Only what's needed at runtime
package.json
static/ # Static assets (JS, CSS, images)
Important: The standalone output does not include the public/ folder or .next/static/ — those need to be copied into the standalone directory or served directly by Nginx (which is faster anyway).
Step 3: Create the deployment directory
On your VPS:
sudo mkdir -p /var/www/myapp
sudo chown -R deploy:deploy /var/www/myapp
Step 4: Create a PM2 ecosystem file
Create ecosystem.config.cjs in your repository root:
module.exports = {
apps: [
{
name: 'myapp',
script: '.next/standalone/server.js',
cwd: '/var/www/myapp',
instances: 1,
exec_mode: 'fork',
env: {
NODE_ENV: 'production',
PORT: 3000,
HOSTNAME: '127.0.0.1',
},
max_memory_restart: '512M',
log_date_format: 'YYYY-MM-DD HH:mm:ss',
},
],
};
Key settings:
HOSTNAME: '127.0.0.1'binds Next.js to localhost only — Nginx handles external trafficmax_memory_restartprevents memory leaks from taking down the serverscriptpoints to the standaloneserver.js, notnext start
Step 5: Configure Nginx
Create /etc/nginx/sites-available/myapp:
upstream nextjs {
server 127.0.0.1:3000;
}
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;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Content-Type-Options nosniff always;
add_header X-Frame-Options SAMEORIGIN always;
# Serve static assets directly from Nginx (faster than proxying through Node)
location /_next/static/ {
alias /var/www/myapp/.next/static/;
expires 365d;
access_log off;
add_header Cache-Control "public, immutable";
}
location /public/ {
alias /var/www/myapp/public/;
expires 30d;
access_log off;
}
# Proxy everything else to Next.js
location / {
proxy_pass http://nextjs;
proxy_http_version 1.1;
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_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 60s;
proxy_buffering on;
}
}
Enable and test:
sudo ln -s /etc/nginx/sites-available/myapp /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl restart nginx
The static file configuration is important — Nginx serves JS, CSS, and images directly from disk instead of proxying them through the Node.js process. This reduces load on your app and improves response times for static assets.
Step 6: Set up TLS with Certbot
HTTPS is required for any production site. Install Certbot and obtain a certificate:
sudo apt install certbot python3-certbot-nginx -y
sudo certbot --nginx -d yourdomain.com -d www.yourdomain.com
Set up automatic renewal:
sudo certbot renew --dry-run
Certbot adds a systemd timer that renews certificates automatically.
Step 7: Configure DeployHQ
Create a project
- Sign up or log in to DeployHQ
- Create a new project and connect your GitHub or GitLab repository
- Add an SSH/SFTP server:
- Host: your VPS IP
- Port: 22
- Username:
deploy - Authentication: SSH key (generate one in DeployHQ and add the public key to
~/.ssh/authorized_keyson your VPS)
- Set the deploy path to
/var/www/myapp/
Add a build step
In DeployHQ's Build Commands:
npm ci
npm run build
# Copy static assets into the standalone directory
cp -r .next/static .next/standalone/.next/static
cp -r public .next/standalone/public
npm ci is preferred over npm install for CI/CD — it installs exact versions from package-lock.json and is faster.
Configure deployed files
In DeployHQ's Deployment settings, set the subdirectory to .next/standalone so only the self-contained build is uploaded — not node_modules or source files.
Add environment variables
Use DeployHQ's Config Files to inject a .env.production file:
DATABASE_URL=postgresql://user:password@db-host:5432/myapp
NEXT_PUBLIC_API_URL=https://api.yourdomain.com
SESSION_SECRET=your-secret-here
This keeps secrets out of Git while ensuring they are available at runtime.
Add SSH commands
In DeployHQ's SSH Commands (run after deploy):
cd /var/www/myapp
# Reload PM2 with zero-downtime restart
pm2 reload ecosystem.config.cjs --update-env
# Save PM2 process list (survives server reboot)
pm2 save
# On first deploy only, start the app instead:
# pm2 start ecosystem.config.cjs
# pm2 save
# pm2 startup
pm2 reload performs a zero-downtime restart — it starts a new process, waits for it to be ready, then kills the old one. No dropped requests.
Complete deployment flow
After setup, every deployment follows this flow automatically:
- Push to Git — developer pushes to main branch
- DeployHQ builds — runs
npm ci,npm run build, copies static assets - DeployHQ deploys — uploads
.next/standalone/to your VPS via SSH - SSH command runs —
pm2 reloadrestarts the app with zero downtime - Nginx serves — static assets from disk, dynamic routes via proxy
No SSH sessions, no manual commands, no forgotten steps.
Managing environment variables
Next.js has two types of environment variables:
| Type | Available in | Set at |
|---|---|---|
NEXT_PUBLIC_* |
Browser + server | Build time (baked into JS bundles) |
| Everything else | Server only | Runtime |
NEXT_PUBLIC_* variables must be available during npm run build — they are inlined into the client-side JavaScript. Set these in DeployHQ's build step environment, not just in runtime .env files.
Server-only variables (database URLs, API keys) only need to be available at runtime. Use DeployHQ's config files to deploy a .env.production alongside your app.
Performance optimisation
Enable gzip/brotli in Nginx
Add to your server block:
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml;
gzip_min_length 1000;
gzip_vary on;
Increase PM2 instances for multi-core servers
If your VPS has 2+ vCPUs, run multiple Next.js instances:
// ecosystem.config.cjs
module.exports = {
apps: [
{
name: 'myapp',
script: '.next/standalone/server.js',
cwd: '/var/www/myapp',
instances: 2, // Match your vCPU count
exec_mode: 'cluster',
env: {
NODE_ENV: 'production',
PORT: 3000,
HOSTNAME: '0.0.0.0',
},
},
],
};
PM2 cluster mode load-balances requests across instances automatically.
Monitor with PM2
pm2 monit # Real-time CPU and memory
pm2 logs myapp # Application logs
pm2 status # Process status overview
Troubleshooting
| Problem | Cause | Fix |
|---|---|---|
| 502 Bad Gateway | Next.js not running | Check pm2 status and pm2 logs myapp |
| Static assets 404 | Missing copy step in build | Ensure cp -r .next/static .next/standalone/.next/static runs |
| Environment variables undefined | Not available at build time | NEXT_PUBLIC_* vars must be in build environment |
| Port already in use | Previous process still running | pm2 delete myapp then redeploy |
| Slow first page load | No static asset caching | Check Nginx expires directives are active |
| Deploy succeeds but old content shows | Browser cache or CDN cache | Clear cache; check Cache-Control headers |
Further reading
- What Is a Build Pipeline and How Can It Improve Your Workflow? — build step fundamentals
- Protecting Your API Keys: Best Practices for Secure Deployment — managing secrets
- Managing Environment Variables Across Deployment Stages — per-environment config
- Node Application Servers in 2025: PM2, Fastify, and Beyond — PM2 deep dive
- What Is Docker? A Beginner's Guide to Containerisation — alternative: containerised deployment
- Deploy from GitHub | Deploy from GitLab — repository connection guides
- DeployHQ SSH Commands — post-deploy automation
- Next.js Deployment Documentation — official reference
If you have questions or need help, reach out at support@deployhq.com or on Twitter/X.