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](https://www.deployhq.com) 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. If you'd rather use the OS-native equivalent, our guide to [managing application services with systemd and Monit](https://www.deployhq.com/blog/managing-application-services-with-systemd-and-monit) covers a stack-agnostic alternative for Linux servers.

* * *

## 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](https://www.deployhq.com/signup)

* * *

## 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 traffic
- **`max_memory_restart`** prevents memory leaks from taking down the server
- **`script`** points to the standalone `server.js`, not `next 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

1. [Sign up](https://www.deployhq.com/signup) or log in to [DeployHQ](https://www.deployhq.com)
2. Create a new project and connect your [GitHub](https://www.deployhq.com/deploy-from-github) or [GitLab](https://www.deployhq.com/deploy-from-gitlab) repository
3. Add an SSH/SFTP server:
  - **Host** : your VPS IP
  - **Port** : 22
  - **Username** : `deploy`
  - **Authentication** : SSH key (generate one in [DeployHQ](https://www.deployhq.com) and add the public key to `~/.ssh/authorized_keys` on your VPS)

4. 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](https://www.deployhq.com/support/configuration/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:

1. **Push to Git** — developer pushes to main branch
2. **DeployHQ builds** — runs `npm ci`, `npm run build`, copies static assets
3. **DeployHQ deploys** — uploads `.next/standalone/` to your VPS via SSH
4. **SSH command runs** — `pm2 reload` restarts the app with zero downtime
5. **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](https://www.deployhq.com/support/configuration/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?](https://www.deployhq.com/blog/what-is-a-build-pipeline-and-how-can-it-improve-your-workflow) — build step fundamentals
- [Protecting Your API Keys: Best Practices for Secure Deployment](https://www.deployhq.com/blog/protecting-your-api-keys-best-practices-for-secure-deployment) — managing secrets
- [Managing Environment Variables Across Deployment Stages](https://www.deployhq.com/blog/managing-environment-variables-across-deployment-stages-a-guide-for-developers) — per-environment config
- [Node Application Servers in 2025: PM2, Fastify, and Beyond](https://www.deployhq.com/blog/node-application-servers-in-2025-pm2-fastify-and-beyond) — PM2 deep dive
- [What Is Docker? A Beginner's Guide to Containerisation](https://www.deployhq.com/blog/what-is-docker-a-beginners-guide-to-containerization-and-deployment) — alternative: containerised deployment
- [Deploy from GitHub](https://www.deployhq.com/deploy-from-github) | [Deploy from GitLab](https://www.deployhq.com/deploy-from-gitlab) — repository connection guides
- [DeployHQ SSH Commands](https://www.deployhq.com/support/configuration/ssh-commands) — post-deploy automation
- [Next.js Deployment Documentation](https://nextjs.org/docs/app/getting-started/deploying) — official reference

If you have questions or need help, reach out at [support@deployhq.com](mailto:support@deployhq.com) or on [Twitter/X](https://x.com/deployhq).

