Back to blogDevOps

Deploying Next.js to a VPS with PM2 and Nginx

Vercel is not the only way to run Next.js. The full setup on a plain Linux box: process manager, reverse proxy, and a deploy on every push.

Anup Bhandari
4 min read
Server racks in a data centre

Most Next.js tutorials end at vercel deploy. That is a genuinely good default, and if you are shipping a side project you should probably stop reading and use it. But there are real reasons to run your own box — predictable pricing at scale, a database that has to live in a particular jurisdiction, background workers that outlive a serverless function, or simply an existing server you are already paying for.

The self-hosted path is less documented than it should be, so here is the whole thing.

What you actually need

Three pieces, and no more:

  1. Node running your app. next build then next start gives you a long-lived HTTP server on a port.
  2. A process manager. Something to restart the app when it crashes and bring it back after a reboot.
  3. A reverse proxy. To terminate TLS, serve on port 443, and forward to your Node process.

Everything else — CI, zero-downtime reloads, health checks — is refinement on top of those three.

Running the app under PM2

next start in a terminal dies the moment you close the terminal. PM2 fixes that. Define the app in a config file rather than passing flags, so the setup is version-controlled:

{
  "apps": [
    {
      "name": "novastack",
      "script": "npm",
      "args": "start",
      "cwd": "/var/www/novastack",
      "env": {
        "NODE_ENV": "production",
        "PORT": "3000"
      }
    }
  ]
}

Then:

pm2 start ecosystem.config.json
pm2 save
pm2 startup

That last pair matters more than it looks. pm2 save writes the current process list to disk, and pm2 startup prints a command that installs a systemd unit to replay it. Skip them and your site stays down after the next unattended reboot — which will happen at the worst possible time.

Nginx in front

Node should not be the thing facing the internet. Put Nginx in front of it:

server {
    listen 443 ssl http2;
    server_name example.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;
    }
}

Two headers earn their place here. X-Forwarded-Proto is what stops Next.js from generating http:// URLs behind your HTTPS proxy. X-Forwarded-For is the only way your app learns the real client IP — without it, every request appears to come from 127.0.0.1, which quietly breaks rate limiting and analytics.

For TLS, use Certbot. It will edit this file for you and set up automatic renewal, and there is no good reason to manage certificates by hand anymore.

One more block worth adding — let Nginx serve Next's immutable build assets directly, so those requests never touch Node:

location /_next/static/ {
    alias /var/www/novastack/.next/static/;
    expires 365d;
    access_log off;
}

Deploying on every push

Manual SSH deploys are fine until the day you are on a phone. A GitHub Actions workflow removes that failure mode:

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'
      - run: npm ci
      - run: npm run lint
      - run: npm run build

  deploy:
    needs: test
    runs-on: ubuntu-latest
    steps:
      - uses: appleboy/ssh-action@v1.0.3
        with:
          host: ${{ secrets.VPS_HOST }}
          username: ${{ secrets.VPS_USERNAME }}
          key: ${{ secrets.VPS_SSH_KEY }}
          script: |
            cd /var/www/novastack
            git pull origin main
            npm ci
            npm run build
            pm2 reload novastack

The needs: test line is the important one. Building in CI first means a broken commit never reaches the server at all — the deploy job simply does not run.

Note pm2 reload rather than pm2 restart. Reload starts the replacement process and only kills the old one once the new one is listening, so in-flight requests are not dropped. With a single instance the window is small but not zero; with instances: "max" in cluster mode it is genuinely seamless.

The mistake worth avoiding

If there is one thing to take from this: do not run npm ci --omit=dev before npm run build.

It is a tempting optimisation, and it fails in a confusing way. Building a Next.js app needs TypeScript, Tailwind, and your PostCSS plugins — all of which live in devDependencies. Omitting them means the build either crashes or, worse, silently produces a site with no styles.

Install everything, build, and only then prune if you care about disk:

npm ci
npm run build
npm prune --omit=dev
pm2 reload novastack

Is it worth it?

Honestly, for most projects, no. Managed hosting is cheap, and the hour you spend on Certbot is an hour not spent on the product.

But the setup above is maybe forty lines of configuration in total, and once it exists it does not need attention. If you are already running a server — or you want your hosting bill to stay flat as traffic grows — it is a reasonable place to land.

Keep reading

Got a project in mind?

We build web applications and micro SaaS products. Tell us what you are working on.

Get in touch