TeknoTUT

How to Deploy Next.js to a VPS with PM2 and Nginx

A complete guide to deploying Next.js on an Ubuntu VPS: install Node.js, run the app with PM2, set up an Nginx reverse proxy, and add free HTTPS.

Written by 12 min readBahasa Indonesia
Illustration of a web application being launched onto a VPS server
On This Page

If you are a web developer, you're probably familiar with Next.js. This React framework is a joy to use during development: run npm run dev and your app is right there in the browser.

The trouble usually starts once the app is finished and you want to put it on the internet. Many tutorials point you straight to a managed hosting platform. But if you already have a VPS, a Next.js app runs perfectly well there, with a fixed monthly cost and full control in your hands.

To get there we need two extra companions: PM2, which keeps the app alive, and Nginx, which sits in front as a reverse proxy and takes care of HTTPS.

In this article, we'll walk through deploying Next.js to a VPS from start to finish: installing Node.js, building the app, running it with PM2, setting up Nginx, enabling free HTTPS from Let's Encrypt, and updating the app when new code lands. Let's get started!

An Overview of the Next.js VPS Deployment Architecture

Before typing any commands, it helps to understand what we're building. The flow looks like this:

Visitor (browser)
        │  HTTPS, port 443
        ▼
Nginx (reverse proxy + SSL certificate)
        │  HTTP, 127.0.0.1:3000
        ▼
Next.js (next start), kept alive by PM2

Each component has its own job:

  • Next.js runs the production server through the next start command. This server only listens on 127.0.0.1 port 3000, so it can't be reached directly from the internet.
  • PM2 is a process manager for Node.js. It runs the app in the background, restarts it if it crashes, and brings it back up automatically when the VPS reboots.
  • Nginx accepts every connection from the internet on ports 80 and 443, then forwards it to Next.js.

The official Next.js documentation itself recommends putting a reverse proxy such as Nginx in front of the Next.js server rather than exposing it directly to the internet. A reverse proxy handles things like malformed requests, slow connections, payload size limits, and rate limiting, so the Next.js server can focus on rendering pages.

Why Deploy Next.js to Your Own VPS?

There are a few reasons this approach is appealing:

  • Fixed, predictable cost: you pay the monthly VPS fee no matter how many visitors you get.
  • Full control: the Node.js version, server configuration, and where your data lives are all up to you.
  • Every Next.js feature still works: according to the official docs, features like image optimization (next/image), ISR, and streaming work when self-hosted with next start.
  • One VPS for many things: your database, API, and several websites can live on the same server.

Drawbacks You Should Know About

To keep things balanced, this approach has downsides too:

  • You manage the server: system updates, security, and backups are your responsibility.
  • No automatic scaling: if traffic spikes, you have to upgrade the VPS manually.
  • The build needs RAM: next build is fairly memory-hungry, so small VPSes need a little trick (covered in the troubleshooting section).

Preparation

Before you begin, make sure you have:

  • A VPS running Ubuntu 24.04 LTS or 26.04 LTS. The commands in this article are written for those two versions. For RAM, aim for at least 1 GB; 2 GB makes builds far more comfortable.
  • A regular user with sudo access and an active firewall. If your VPS is brand new, follow the guide on how to secure a Linux VPS first.
  • A domain name whose A record already points to your VPS IP address. This is required to get an HTTPS certificate.
  • A Next.js project in a Git repository (GitHub, GitLab, or similar). Next.js 16 requires Node.js 20.9 or newer.

The examples in this article use the domain example.com, the IP address 203.0.113.10, a user named budi, and a project called nextjs-app. Replace them with your own.

How to Deploy Next.js to a VPS

Step #1: Install Node.js on the VPS

Log in to your VPS, then make sure the package list and basic tools are in place:

sudo apt update
sudo apt install -y curl git

The Node.js version in Ubuntu's default repositories often lags behind. That's why we'll use the NodeSource repository to get the LTS (Long Term Support) release. Download and run its setup script with the following commands:

curl -fsSL https://deb.nodesource.com/setup_lts.x -o nodesource_setup.sh
sudo -E bash nodesource_setup.sh

Once the repository is registered, install Node.js:

sudo apt install -y nodejs

Check the installed versions:

node -v
npm -v

The output looks roughly like this:

v26.13.1
11.6.2

The exact numbers will differ depending on when you install. What matters is that Node.js is 20.9 or newer. At the time of writing, the supported LTS lines are Node.js 22, 24, and 26.

Other ways to install Node.js, including nvm, are covered in the article on how to install Node.js. If you use nvm, keep in mind that node lives inside the user's home folder, which affects the pm2 startup command in Step #4.

Step #2: Get the Code and Build the App

Go to your home folder, then clone your project repository:

cd ~
git clone https://github.com/username/nextjs-app.git
cd nextjs-app

Don't have a project yet and just want to try this out? You can create a sample project right on the VPS with npx create-next-app@latest nextjs-app --yes, then enter its folder.

Next, install all dependencies. The npm ci command installs the exact package versions recorded in package-lock.json, so the result matches what you have on your own computer:

npm ci

If your app uses environment variables, create a .env.production file in the project folder:

nano .env.production

Its contents depend on your app, for example:

DATABASE_URL=postgres://user:[email protected]:5432/namadb
NEXT_PUBLIC_SITE_URL=https://example.com

Save the file by pressing Ctrl+X, then Y, and Enter.

Important: variables prefixed with NEXT_PUBLIC_ are inlined into the JavaScript bundle at build time. That means if you change their value, you have to rebuild. Variables without that prefix are only available on the server and are never sent to the browser.

Now build the app for production:

npm run build

This takes anywhere from a few dozen seconds to a few minutes. At the end, Next.js prints a list of routes that looks roughly like this:

Route (app)
┌ ○ /
├ ○ /_not-found
└ ƒ /products/[id]
 
○  (Static)   prerendered as static content
ƒ  (Dynamic)  server-rendered on demand

If that list appears without any error messages, the build succeeded and its output is stored in the .next folder.

Step #3: Run Next.js with PM2

Install PM2 globally with the following command:

sudo npm install -g pm2@latest

We could simply run pm2 start "npm run start". A tidier approach, though, is to use a configuration file so every setting lives in one place. Create an ecosystem.config.js file in the project folder:

nano ecosystem.config.js

Add the following configuration:

module.exports = {
  apps: [
    {
      name: "nextjs-app",
      cwd: "/home/budi/nextjs-app",
      script: "node_modules/next/dist/bin/next",
      args: "start -H 127.0.0.1 -p 3000",
      env: {
        NODE_ENV: "production",
      },
      max_memory_restart: "500M",
    },
  ],
};

A quick explanation of each line:

  • name: the app's name in PM2, used in commands such as pm2 restart nextjs-app.
  • cwd: the project folder. Adjust it to your own user and folder names.
  • script and args: PM2 runs the project's own next program directly with the start argument. The result is the same as npm run start, but without an npm process in the middle.
  • -H 127.0.0.1: this part matters. By default, next start listens on 0.0.0.0, meaning every network address. With 127.0.0.1, the app can only be reached from inside the VPS (by Nginx), not straight from the internet on port 3000.
  • max_memory_restart: PM2 restarts the app if its memory usage goes above 500 MB. Adjust this to your VPS's RAM.

If your project's package.json contains "type": "module", name this file ecosystem.config.cjs so it is still read as CommonJS.

Start the app with the following command:

pm2 start ecosystem.config.js

Then check its status:

pm2 status

The output looks more or less like this:

┌────┬─────────────┬──────┬───────┬────────┬─────────┬────────┬──────┬───────────┐
│ id │ name        │ mode │ pid   │ uptime │ restart │ status │ cpu  │ mem       │
├────┼─────────────┼──────┼───────┼────────┼─────────┼────────┼──────┼───────────┤
│ 0  │ nextjs-app  │ fork │ 12345 │ 10s    │ 0       │ online │ 0%   │ 95.0mb    │
└────┴─────────────┴──────┴───────┴────────┴─────────┴────────┴──────┴───────────┘

If the status column says online and the restart count isn't climbing, the app is running steadily. Also confirm that the app actually responds:

curl -I http://127.0.0.1:3000

If the first line of the output is HTTP/1.1 200 OK, Next.js is serving requests. If the status is errored, find out why with pm2 logs nextjs-app.

Step #4: Start PM2 on Boot

The app is running now, but it will be gone if the VPS reboots. To fix that, ask PM2 to generate a startup script:

pm2 startup

PM2 doesn't install it right away. Instead, it prints a command that you need to copy and run. It looks roughly like this:

[PM2] Init System found: systemd
[PM2] To setup the Startup Script, copy/paste the following command:
sudo env PATH=$PATH:/usr/bin /usr/lib/node_modules/pm2/bin/pm2 startup systemd -u budi --hp /home/budi

Copy the command shown in your own terminal (not the one from the example above, since the paths may differ), then run it. After that, save the list of running processes:

pm2 save

Done! From now on, whenever the VPS boots, PM2 automatically brings back every saved app.

If you ever upgrade your Node.js version, regenerate the startup script: run pm2 unstartup, then pm2 startup once more.

Step #5: Set Up Nginx as a Reverse Proxy

Install Nginx with the following command:

sudo apt install -y nginx

If you use the UFW firewall, allow HTTP and HTTPS connections. The Nginx Full profile opens ports 80 and 443 in one go:

sudo ufw allow 'Nginx Full'

Next, create a configuration file for your website:

sudo nano /etc/nginx/sites-available/nextjs-app

Add the following configuration, replacing example.com with your domain:

server {
    listen 80;
    listen [::]:80;
    server_name example.com www.example.com;
 
    location / {
        proxy_pass http://127.0.0.1:3000;
        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;
    }
}

The proxy_pass line forwards every request to Next.js. The proxy_set_header lines make sure Next.js still knows the requested domain, the visitor's real IP address, and whether the connection uses HTTPS.

Enable the configuration by creating a symlink in the sites-enabled folder:

sudo ln -s /etc/nginx/sites-available/nextjs-app /etc/nginx/sites-enabled/

Before applying it, check for syntax mistakes:

sudo nginx -t

If the output shows syntax is ok and test is successful, reload Nginx:

sudo systemctl reload nginx

Now open http://example.com in your browser. Your Next.js app should show up, although still without HTTPS.

Using streaming or Suspense? By default, Nginx buffers responses before sending them, so content that should arrive progressively shows up all at once at the end. The Next.js documentation suggests sending an X-Accel-Buffering: no header through the headers() option in next.config.js to turn that buffering off.

Step #6: Enable HTTPS with Certbot

A free SSL certificate from Let's Encrypt is easy to set up with Certbot. The method recommended on Certbot's official site is to install it through snap:

sudo snap install --classic certbot
sudo ln -s /snap/bin/certbot /usr/local/bin/certbot

Then request a certificate and let Certbot edit the Nginx configuration automatically:

sudo certbot --nginx -d example.com -d www.example.com

You'll be asked for an email address and to agree to the terms of service. When it finishes, Certbot adds the SSL settings to the Nginx configuration file from earlier and redirects HTTP to HTTPS.

Let's Encrypt certificates have a limited lifetime, but Certbot renews them automatically. Test that renewal process with the following command:

sudo certbot renew --dry-run

If the simulation runs without errors, automatic renewal is ready to go. Open https://example.com, and the padlock icon in your browser confirms that your website is now secure. Pretty cool, right?

How to Update the App After Code Changes

Deployment isn't a one-time job. Every time new code lands in the repository, run this sequence in the project folder:

cd ~/nextjs-app
git pull
npm ci
npm run build
pm2 restart nextjs-app

To avoid retyping it, save the sequence as a script. Create a deploy.sh file in the project folder:

#!/usr/bin/env bash
set -e
 
cd ~/nextjs-app
git pull
npm ci
npm run build
pm2 restart nextjs-app

The set -e line makes the script stop if any step fails, so the app is never restarted on top of a broken build. Make it executable, and from then on a deploy takes a single command:

chmod +x deploy.sh
./deploy.sh

Keep in mind that this simple approach overwrites the .next folder while the old app is still running, so visitors may experience a brief hiccup during the build and restart. For a personal website or a small project, that's generally not a problem.

Frequently Used PM2 Commands

Command What it does
pm2 status Lists your apps and their status
pm2 logs nextjs-app Streams the app's logs live
pm2 logs nextjs-app --lines 200 Shows the last 200 log lines
pm2 restart nextjs-app Restarts the app
pm2 stop nextjs-app Stops the app
pm2 delete nextjs-app Removes the app from PM2's list
pm2 monit Monitors CPU and memory in real time
pm2 save Saves the process list to restore on boot

Troubleshooting: Common Problems

You Get a 502 Bad Gateway

This message means Nginx is running but can't reach Next.js. Check whether the app is alive with pm2 status, then read its logs with pm2 logs nextjs-app. Also make sure the port in proxy_pass matches the port in ecosystem.config.js.

The Build Stops with "Killed"

If npm run build suddenly stops and prints nothing but Killed, the VPS has almost certainly run out of RAM. The easiest fix is to add swap. You can follow the steps in the article on understanding swap memory on Linux. The alternative is to upgrade the VPS's RAM.

Environment Variable Changes Aren't Picked Up

There are two possibilities. If the variable starts with NEXT_PUBLIC_, you need to run npm run build again because its value is inlined at build time. If it's a regular server variable in .env.production, running pm2 restart nextjs-app is enough.

The App Is Reachable on Port 3000 from the Internet

That means Next.js is still listening on 0.0.0.0. Double-check the -H 127.0.0.1 argument in ecosystem.config.js, then run pm2 restart nextjs-app. And don't open port 3000 in your firewall.

Conclusion

From this article, we can conclude that deploying Next.js to your own VPS isn't as complicated as it might seem. The recipe has three ingredients: Next.js running through next start on 127.0.0.1:3000, PM2 keeping it alive and starting it on boot, and Nginx acting as the front door while handling HTTPS together with Certbot.

This setup is a good fit for personal websites, blogs, landing pages, and small-to-medium apps that a single server can handle. If you need automatic scaling or truly zero-downtime deploys, that's when a managed platform or a container-based setup becomes worth considering.

Things to remember:

  • Always run Next.js on 127.0.0.1, and let Nginx be the only thing facing the internet.
  • Run pm2 save whenever your list of apps changes.
  • NEXT_PUBLIC_ variables require a rebuild.

Prefer a container-based approach? Have a look at the article on learning Docker Compose, or use CapRover if you want deploys that feel as easy as a managed platform.


I hope this article helps.

Continue Reading

More articles you might enjoy.