hieuvnlabs logo
Hiusvu

How To Deploy a Next.js App on an Ubuntu VPS with PM2 and Nginx

Published on September 6, 2026

How To Deploy a Next.js App on an Ubuntu VPS with PM2 and Nginx

Introduction

Vercel is the fastest way to deploy Next.js, but it is not always the right fit: you may need full control over the server, want the app next to its database, or simply already have a VPS. In that case, deploying to an Ubuntu VPS yourself is a sensible choice and not hard at all.

In this guide we will:

  • Get the Next.js source code onto the server and build it for production.
  • Use PM2 to run the app in the background and restart it automatically after a reboot.
  • Configure Nginx as a reverse proxy that forwards traffic from ports 80/443 to the Next.js app.
  • Enable HTTPS with Let's Encrypt.

Prerequisites

  • A VPS running Ubuntu 20.04 or 22.04 with a sudo user.
  • Node.js 18 or newer installed. See How To Install Node.js on Ubuntu.
  • Nginx installed and allowed through the firewall. See How To Install Nginx on Ubuntu.
  • A domain name pointing at the VPS (your_domain in the examples).
  • Your Next.js source code in a Git repository (GitHub, GitLab, etc.).

Step 1 — Getting the Source Code onto the Server

Install Git if it is not there yet:

sudo apt update
sudo apt install git

Create a directory for the app and clone the repository. Replace the URL with your own:

sudo mkdir -p /var/www
sudo chown -R $USER:$USER /var/www
cd /var/www
git clone https://github.com/your_user/your_nextjs_app.git
cd your_nextjs_app

If the repository is private, generate an SSH key on the server and add it to your Git account:

ssh-keygen -t ed25519 -C "deploy@your_domain"
cat ~/.ssh/id_ed25519.pub

Step 2 — Configuring Environment Variables

Production apps usually need environment variables (database connection strings, API keys, etc.). Create a .env.production or .env.local file in the project directory:

nano .env.local
DATABASE_URL=postgresql://sammy:password@localhost:5432/myapp
NEXT_PUBLIC_SITE_URL=https://your_domain

Note: Variables prefixed with NEXT_PUBLIC_ are inlined into the client bundle at build time. Never put secrets in them.

Step 3 — Installing Dependencies and Building

Install dependencies. Use npm ci rather than npm install so the exact versions from package-lock.json are used:

npm ci

Build the app for production:

npm run build
Output
    Next.js 14.2.3

   Creating an optimized production build ...
  Compiled successfully
  Linting and checking validity of types
  Collecting page data
  Generating static pages (12/12)
  Finalizing page optimization

Route (app)                              Size     First Load JS
  /                                    5.2 kB          92 kB
  /about                               1.1 kB          88 kB
  /blogs/[slug]                        3.4 kB          90 kB

If the VPS has little RAM (1 GB or less), the build may get killed for running out of memory. You can add a temporary swap file:

sudo fallocate -l 2G /swapfile
sudo chmod 600 /swapfile
sudo mkswap /swapfile
sudo swapon /swapfile

Run the app once to make sure the build works:

npm run start
Output
    Next.js 14.2.3
   - Local:        http://localhost:3000

  Ready in 320ms

Open a second terminal and check:

curl -I http://localhost:3000

If you get HTTP/1.1 200 OK, press CTRL + C in the first terminal to stop the app.

Step 4 — Running the App With PM2

If you close your SSH session, the npm run start process dies with it. PM2 is a process manager for Node.js that keeps the app running in the background, restarts it when it crashes and brings it back after a server reboot.

Install PM2 globally:

sudo npm install -g pm2

Start the app through PM2. The -- separates PM2's own arguments from those passed to npm:

pm2 start npm --name "nextjs-app" -- start
Output
[PM2] Starting /usr/bin/npm in fork_mode (1 instance)
[PM2] Done.
┌────┬───────────────┬─────────┬─────────┬──────────┬────────┬──────┬───────────┐
 id  name           mode             status    cpu     mem   user      
├────┼───────────────┼─────────┼─────────┼──────────┼────────┼──────┼───────────┤
 0   nextjs-app     fork     0        online    0%      58mb  hiusvu    
└────┴───────────────┴─────────┴─────────┴──────────┴────────┴──────┴───────────┘

Instead of a long command, you can create an ecosystem.config.js in the project directory for more detailed configuration:

module.exports = {
  apps: [
    {
      name: "nextjs-app",
      script: "npm",
      args: "start",
      cwd: "/var/www/your_nextjs_app",
      instances: 1,
      autorestart: true,
      watch: false,
      max_memory_restart: "500M",
      env: {
        NODE_ENV: "production",
        PORT: 3000,
      },
    },
  ],
};

And start it with:

pm2 start ecosystem.config.js

Useful PM2 commands:

pm2 list              # List processes
pm2 logs nextjs-app   # Tail the logs
pm2 restart nextjs-app
pm2 stop nextjs-app
pm2 delete nextjs-app
pm2 monit             # Monitor CPU / RAM

To have PM2 bring your apps back after a reboot, run:

pm2 startup

PM2 prints a command like sudo env PATH=... pm2 startup systemd -u your_user --hp /home/your_user. Copy and run it exactly as shown. Finally, save the current process list:

pm2 save

Step 5 — Configuring Nginx as a Reverse Proxy

The app is listening on port 3000, but you do not want visitors typing :3000. Nginx will listen on port 80, accept requests for your_domain and forward them to port 3000.

Create a new server block:

sudo nano /etc/nginx/sites-available/your_domain
server {
    listen 80;
    listen [::]:80;
    server_name your_domain www.your_domain;

    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;
    }

    # Cache Next.js static assets for a long time
    location /_next/static/ {
        proxy_pass http://127.0.0.1:3000;
        proxy_cache_valid 60m;
        add_header Cache-Control "public, max-age=31536000, immutable";
    }
}

The Upgrade and Connection headers allow WebSockets to work (needed for hot reload in development and for some realtime libraries). The X-Forwarded-* headers let Next.js see the visitor's real IP address and protocol.

Enable the server block, test the syntax and reload Nginx:

sudo ln -s /etc/nginx/sites-available/your_domain /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx

Visit http://your_domain and you should see your Next.js app.

Step 6 — Enabling HTTPS With Let's Encrypt

Install Certbot and obtain a certificate. Certbot edits the server block above to add the SSL configuration and redirect HTTP to HTTPS:

sudo snap install --classic certbot
sudo ln -s /snap/bin/certbot /usr/bin/certbot
sudo ufw allow 'Nginx Full'
sudo certbot --nginx -d your_domain -d www.your_domain

Each step is covered in detail in How To Secure Nginx with Let's Encrypt on Ubuntu.

Step 7 — Updating the App When You Push New Code

Every time you push new code, updating the server means: pull, install dependencies, rebuild and restart PM2. Create a deploy.sh script in the project directory to do all of that in one command:

nano deploy.sh
#!/bin/bash
set -e

cd /var/www/your_nextjs_app

echo ">>> Pulling latest code..."
git pull origin main

echo ">>> Installing dependencies..."
npm ci

echo ">>> Building..."
npm run build

echo ">>> Restarting PM2..."
pm2 restart nextjs-app

echo ">>> Done!"

Make it executable and run it:

chmod +x deploy.sh
./deploy.sh

With pm2 restart the app is briefly unavailable for a few seconds. If you need zero downtime, run PM2 in cluster mode with several instances and use pm2 reload:

pm2 start ecosystem.config.js -i 2
pm2 reload nextjs-app

As a further step, you can trigger this script from GitHub Actions to deploy automatically on every push to main.

Conclusion

You have deployed a Next.js app on an Ubuntu VPS: built for production, kept alive by PM2 with boot persistence, served through an Nginx reverse proxy and secured with HTTPS. This setup is enough for most personal and small-business projects. As the app grows, consider packaging it with Docker or running several instances behind a load balancer.