
How to Self-Host a Next.js App on a VPS with Nginx and PM2?
Managed platforms make deploying Next.js easy, but they are not always the right fit. Bandwidth and function invocations get expensive at scale, some clients require their code to run on infrastructure they control, and some apps need long-running processes, local file storage, or a database on the same machine. A small virtual private server running Ubuntu can host a production Next.js app for a few dollars a month, with every Next.js feature available, as long as you set it up properly.
Setting it up properly is where most guides fall short. Running npm run start in an SSH session works until you log out. Exposing port 3000 directly works until a slow client ties up the server. Skipping the firewall works until someone finds the database port. The production setup has a few more moving parts, but each one has a clear job.
This article covers a complete, production-ready self-hosted deployment: preparing an Ubuntu server, installing Node.js, building the app, keeping it running with PM2, putting Nginx in front as a reverse proxy, adding free SSL with Certbot, locking things down with a firewall, enabling streaming, and automating deployments with a script. It also covers the standalone output mode for smaller, faster deployments.
The Architecture
Each component in the stack handles one concern:
| Component | Role |
|---|---|
| Ubuntu VPS | The machine, with a public IP address |
| Node.js | Runs the Next.js server |
| PM2 | Keeps the Node.js process alive, restarts it on crashes and reboots |
| Nginx | Reverse proxy on ports 80 and 443, handles TLS, headers, and limits |
| Certbot | Issues and renews free Let's Encrypt SSL certificates |
| UFW | Firewall that only allows SSH, HTTP, and HTTPS |
Requests travel from the browser to Nginx on port 443, which terminates TLS and forwards the request to Next.js on 127.0.0.1:3000. The Next.js server is never reachable from the internet directly. The Next.js documentation recommends exactly this: a reverse proxy in front of the Node.js server to handle malformed requests, slow connections, payload limits, and rate limiting, so the Next.js process can spend its resources on rendering.
If you would rather package the app as a container, see how to use Docker to containerize a Next.js app. For a comparison of deployment targets in general, see how to deploy a Next.js application.
Prerequisites
- A VPS running Ubuntu 24.04 LTS with at least 1 GB of RAM (2 GB is more comfortable for builds), from any provider such as DigitalOcean, Hetzner, Linode, or Vultr.
- A domain name with an A record pointing to the server's IP address. If DNS records are unfamiliar, read the difference between A records and CNAME records first. Create the record early, because Certbot needs it to resolve.
- A Next.js project in a Git repository the server can clone.
- SSH access to the server as root or a sudo user.
Throughout the article, replace example.com with your domain, deploy with your username, and the repository URL with your own.
Step 1: Prepare the Server
Log in as root and create a regular user for running the app. Running a web application as root means any vulnerability in it gives an attacker full control of the server.
# On the server, as root
adduser deploy
usermod -aG sudo deploy
# Copy your SSH key to the new user
rsync --archive --chown=deploy:deploy ~/.ssh /home/deploy
Open a new terminal and confirm that ssh deploy@your-server-ip works before going further. Then disable root login and password authentication so only SSH keys can get in:
# /etc/ssh/sshd_config (change these lines)
PermitRootLogin no
PasswordAuthentication no
# On the server
sudo systemctl restart ssh
sudo apt update && sudo apt upgrade -y
sudo apt install -y git build-essential
Add Swap on Small Servers
next build can use more memory than a 1 GB server has, and the kernel kills the build with no useful error. A swap file prevents that:
# On the server
sudo fallocate -l 2G /swapfile
sudo chmod 600 /swapfile
sudo mkswap /swapfile
sudo swapon /swapfile
echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab
Step 2: Configure the Firewall
Ubuntu ships with UFW, a front end for the kernel firewall. Allow SSH first, or you will lock yourself out:
# On the server
sudo ufw allow OpenSSH
sudo ufw enable
sudo ufw status
You will open HTTP and HTTPS after installing Nginx. Port 3000 is never opened, because only Nginx talks to Next.js.
Step 3: Install Node.js
Next.js 16 requires Node.js 20.9 or later. Use the current LTS release. The two common installation methods are nvm, which installs Node per user and makes switching versions easy, and the NodeSource repository, which installs Node system-wide through apt. This guide uses nvm:
# On the server, as deploy
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.3/install.sh | bash
source ~/.bashrc
nvm install --lts
nvm alias default 'lts/*'
node -v
npm -v
Check the nvm repository for the latest install script version. Then install PM2 globally:
# On the server
npm install -g pm2
Step 4: Clone and Build the App
Create a directory for the app and give the deploy user ownership of it:
# On the server
sudo mkdir -p /var/www/example.com
sudo chown deploy:deploy /var/www/example.com
cd /var/www/example.com
git clone https://github.com/your-name/your-app.git .
For a private repository, create a deploy key with ssh-keygen -t ed25519 on the server and add the public key to the repository's deploy keys with read-only access.
Create the production environment file. Next.js loads .env.production during next build and next start in production mode:
# /var/www/example.com/.env.production
DATABASE_URL=postgresql://app:secret@localhost:5432/app
NEXT_PUBLIC_SITE_URL=https://example.com
# On the server
chmod 600 .env.production
Keep this file out of Git. Remember that NEXT_PUBLIC_ variables are inlined into the JavaScript bundle at build time, so changing them requires a rebuild. Server-only variables are read at runtime, but a restart is still needed for the process to pick them up.
Install dependencies and build:
# On the server
npm ci
npm run build
npm ci installs exactly what the lockfile specifies, which is what you want on a server. The build includes development dependencies like TypeScript and Tailwind that next build needs, so do not install with --omit=dev before building.
Step 5: Run the App with PM2
PM2 is a process manager for Node.js. It runs the app in the background, restarts it if it crashes, collects logs, and starts it again after a reboot. Configure it with an ecosystem file in the project root:
// /var/www/example.com/ecosystem.config.js
module.exports = {
apps: [
{
name: "example-app",
cwd: "/var/www/example.com",
script: "node_modules/next/dist/bin/next",
args: "start --hostname 127.0.0.1 --port 3000",
instances: 1,
exec_mode: "fork",
env: {
NODE_ENV: "production",
},
max_memory_restart: "800M",
kill_timeout: 10000,
time: true,
},
],
};
Key settings:
scriptpoints to the Next.js binary directly instead ofnpm start. This lets PM2 manage the real Node.js process, so signals and restarts reach the server instead of an npm wrapper.--hostname 127.0.0.1binds the server to localhost only. Even if the firewall were misconfigured, Next.js would not accept outside connections.max_memory_restartrestarts the process if it leaks memory past the limit. Set it below the server's available RAM.kill_timeout: 10000gives Next.js 10 seconds to finish in-flight requests and run pendingafter()callbacks on shutdown, matching the drain period the Next.js docs recommend.time: trueprefixes log lines with timestamps.
Start the app and confirm it responds:
# On the server
pm2 start ecosystem.config.js
pm2 status
curl -I http://127.0.0.1:3000
Now make PM2 start on boot. pm2 startup prints a sudo command tailored to your nvm Node path. Copy and run that exact command, then save the process list:
# On the server
pm2 startup systemd
# Run the sudo env PATH=... command that pm2 prints
pm2 save
Reboot the server once with sudo reboot and check pm2 status afterwards to make sure the app comes back on its own.
Fork Mode or Cluster Mode
PM2's cluster mode can run one Next.js process per CPU core with instances: "max" and exec_mode: "cluster". It improves throughput on multi-core servers, but each process keeps its own in-memory cache, so revalidateTag or revalidatePath called in one process does not clear the in-memory cache of the others. Start with a single fork-mode instance, which is enough for most sites, and move to cluster mode only when you need it and have configured a shared cache handler.
Log Rotation
PM2 logs grow forever by default. Install the log rotation module once:
# On the server
pm2 install pm2-logrotate
pm2 set pm2-logrotate:max_size 10M
pm2 set pm2-logrotate:retain 14
Step 6: Configure Nginx as a Reverse Proxy
Install Nginx and open the web ports in the firewall:
# On the server
sudo apt install -y nginx
sudo ufw allow 'Nginx Full'
Create a server block for your site:
# /etc/nginx/sites-available/example.com
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
upstream nextjs_upstream {
server 127.0.0.1:3000;
keepalive 64;
}
server {
listen 80;
listen [::]:80;
server_name example.com www.example.com;
client_max_body_size 10M;
gzip on;
gzip_proxied any;
gzip_types text/plain text/css application/javascript application/json image/svg+xml;
location / {
proxy_pass http://nextjs_upstream;
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 $connection_upgrade;
proxy_read_timeout 60s;
}
}
What the important directives do:
proxy_set_header Host $hostpasses the original domain to Next.js, so redirects,headers(), and absolute URLs use the real hostname instead of127.0.0.1.X-Forwarded-ForandX-Forwarded-Prototell Next.js the client's real IP address and that the original request used HTTPS.UpgradeandConnectionallow WebSocket upgrades, which some apps and libraries need.keepalive 64in the upstream reuses connections between Nginx and Node.js instead of opening a new one per request.client_max_body_sizesets the upload limit. Raise it if your app accepts larger files.
You do not need a separate caching rule for /_next/static/. Next.js already serves those hashed files with Cache-Control: public, max-age=31536000, immutable, and Nginx passes the header through.
Enable the site, remove the default site, test the configuration, and reload:
# On the server
sudo ln -s /etc/nginx/sites-available/example.com /etc/nginx/sites-enabled/
sudo rm /etc/nginx/sites-enabled/default
sudo nginx -t
sudo systemctl reload nginx
Visit http://example.com and the app should load.
Enable Streaming
The App Router streams HTML for loading.tsx boundaries and Suspense, and route handlers can stream responses such as AI chat output. Nginx buffers proxied responses by default, which holds the whole response until it finishes and defeats streaming. The Next.js docs recommend sending the X-Accel-Buffering: no header, which Nginx honors per response:
// next.config.mjs
/** @type {import('next').NextConfig} */
const nextConfig = {
async headers() {
return [
{
source: "/:path*{/}?",
headers: [{ key: "X-Accel-Buffering", value: "no" }],
},
];
},
};
export default nextConfig;
Alternatively, add proxy_buffering off; to the location / block in Nginx. Either way, test a page with a slow Suspense boundary and confirm the fallback appears before the content. This matters for chat interfaces in particular, as covered in how to build an AI chatbot with Next.js and the Vercel AI SDK.
Step 7: Add SSL with Certbot
Let's Encrypt issues free, trusted certificates, and Certbot automates both issuance and renewal. Its Nginx plugin also edits your server block to add the HTTPS listener and an HTTP-to-HTTPS redirect:
# On the server
sudo apt install -y certbot python3-certbot-nginx
sudo certbot --nginx -d example.com -d www.example.com
Certbot asks for an email address for expiry notices and verifies that you control the domain over HTTP. Both names must already resolve to the server's IP, or validation fails. Afterwards, check the configuration and test renewal:
# On the server
sudo nginx -t
sudo certbot renew --dry-run
systemctl list-timers | grep certbot
Certificates are valid for 90 days, and the installed systemd timer renews them automatically. With HTTPS working, add a Strict-Transport-Security header inside the server block that Certbot created for port 443:
# /etc/nginx/sites-available/example.com (inside the listen 443 server block)
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
Step 8: Automate Deployments
Manual deployments drift. A short script makes every release run the same steps in the same order:
#!/usr/bin/env bash
# /var/www/example.com/deploy.sh
set -euo pipefail
APP_DIR="/var/www/example.com"
APP_NAME="example-app"
BRANCH="main"
cd "$APP_DIR"
echo "Fetching latest code..."
git fetch origin "$BRANCH"
git reset --hard "origin/$BRANCH"
echo "Installing dependencies..."
npm ci
echo "Building..."
npm run build
echo "Reloading app..."
pm2 reload ecosystem.config.js --update-env
pm2 save
echo "Deployed $(git rev-parse --short HEAD)"
# On the server
chmod +x deploy.sh
./deploy.sh
set -euo pipefail stops the script at the first failure, so a broken build never reaches the reload step and the running version stays up. --update-env makes PM2 pick up changed environment values.
You can run the script from your machine with ssh deploy@your-server-ip /var/www/example.com/deploy.sh, or from a CI workflow such as GitHub Actions using an SSH key stored as a repository secret.
One caveat: npm run build replaces the .next folder while the old server is still running from it. On a busy site, a few requests during the build can fail to find old assets. If that matters, build in a separate release directory and switch a symlink after the build succeeds, or use the standalone output described next, which lets you build first and swap files in one step.
Using Standalone Output
Setting output: "standalone" makes next build trace exactly which files the server needs and copy them, along with only the required node_modules, into .next/standalone. It also generates a minimal server.js you run with plain Node.js instead of next start.
// next.config.mjs
/** @type {import('next').NextConfig} */
const nextConfig = {
output: "standalone",
};
export default nextConfig;
The standalone folder does not include public or .next/static, because the Next.js team expects a CDN to serve them. To have server.js serve them, copy them in after each build:
# On the server, after npm run build
cp -r public .next/standalone/
cp -r .next/static .next/standalone/.next/
Then point PM2 at the generated server. It reads PORT and HOSTNAME from the environment:
// /var/www/example.com/ecosystem.config.js (standalone variant)
module.exports = {
apps: [
{
name: "example-app",
cwd: "/var/www/example.com/.next/standalone",
script: "server.js",
env: {
NODE_ENV: "production",
PORT: "3000",
HOSTNAME: "127.0.0.1",
},
max_memory_restart: "800M",
kill_timeout: 10000,
time: true,
},
],
};
Standalone output is smaller and starts faster, and it is the usual choice for Docker images. Its main advantage on a VPS is that you can build on a CI server, upload only the standalone folder, and reload, so the production server never runs a build at all. Note that .env.production values used at runtime must be available to the process, so pass them through the PM2 env block or an environment file loaded before start.
Image Optimization and Caching
next/image optimization works with next start and the standalone server without extra setup, using the sharp library. Optimized images and ISR pages are cached on the local disk in .next/cache, which persists across restarts on a VPS, unlike ephemeral serverless platforms. Image optimization is CPU-heavy, so watch CPU usage on small servers, and consider an external image loader or CDN if it becomes a bottleneck.
Monitoring and Maintenance
A few commands cover most day-to-day operations:
# On the server
pm2 status # process list, uptime, restarts, memory
pm2 logs example-app # stream application logs
pm2 monit # live CPU and memory dashboard
sudo tail -f /var/log/nginx/error.log
sudo apt update && sudo apt upgrade -y
Enable unattended security updates with sudo apt install unattended-upgrades and sudo dpkg-reconfigure --priority=low unattended-upgrades. Add an external uptime monitor that checks your homepage every minute and alerts you when it fails, and take regular provider snapshots or backups of anything stored on the server, such as uploads or a local database.
Common Problems and Fixes
- 502 Bad Gateway. Nginx cannot reach Next.js. Check
pm2 statusandpm2 logs, and confirm the app listens on the same host and port as theupstreamblock. - The build is killed without an error. The server ran out of memory. Add a swap file, or build in CI and deploy the standalone output.
- The app does not start after a reboot. The
pm2 startupcommand was not run with the printedsudoline, orpm2 savewas skipped. Run both again. - Redirects go to
http://127.0.0.1:3000. TheHostheader is not forwarded. Addproxy_set_header Host $hostand reload Nginx. - Loading states never appear and pages arrive all at once. Nginx is buffering. Add the
X-Accel-Buffering: noheader orproxy_buffering off. - Certbot fails with a DNS or connection error. The domain does not resolve to this server yet, or port 80 is closed. Check the A record and
sudo ufw status. - Environment variable changes have no effect.
NEXT_PUBLIC_values need a rebuild. Server values needpm2 reload ecosystem.config.js --update-env.
Self-Hosting Next.js FAQ
Yes. Server Components, Server Actions, route handlers, Proxy, image optimization, ISR, streaming, and after all work with next start on a Node.js server. A static export is the only mode that drops server features, and it is not needed for a VPS.
Nginx handles TLS, slow and malformed connections, request size limits, compression, and multiple sites on one server far more efficiently than the Node.js process. The Next.js documentation recommends running a reverse proxy in front of the Next.js server for these reasons.
Both work. PM2 is quicker to set up for Node.js apps and adds log management, memory-based restarts, cluster mode, and zero-downtime reloads. A plain systemd service has fewer dependencies. PM2 itself uses systemd to start on boot.
A 1 GB server with swap can run a small site, but 2 GB of RAM and two CPU cores give comfortable headroom for builds and image optimization. Building in CI and deploying standalone output lowers the memory needs of the production server.
Yes. Run each app with PM2 on its own port, such as 3000 and 3001, and create a separate Nginx server block for each domain pointing to the matching port. Certbot can issue a certificate for each domain.
Often, for steady traffic, because a VPS has a fixed monthly price. You take on the work of updates, security, monitoring, and backups, and you lose a global edge network unless you add a CDN. For small teams, that time is part of the real cost.
Conclusion
Self-hosting Next.js on a VPS gives you full control, predictable costs, and every framework feature, with a stack that has been proven for years. PM2 keeps the Node.js server running and restarts it on crashes and reboots. Nginx sits in front as a reverse proxy, forwarding the right headers, enforcing limits, and terminating TLS with a free Certbot certificate. UFW keeps everything except SSH and the web ports closed.
Bind Next.js to localhost, disable proxy buffering so streaming works, rotate your logs, and put the deployment steps in a script so every release is identical. When builds outgrow the server, switch to standalone output and build in CI. With that foundation in place, a single small server can run a fast, secure Next.js site for a long time.
Here are some useful references for going deeper on self-hosting Next.js:
- Next.js Docs: Self-Hosting — reverse proxies, caching, streaming, and multi-server considerations.
- Next.js Docs: output — how standalone output and the generated server.js work.
- PM2 Docs: Ecosystem File — every option for PM2 process configuration.
- Nginx Docs: ngx_http_proxy_module — reference for proxy_pass, headers, and buffering.
- Certbot: Certbot instructions for Nginx on Ubuntu — official steps for issuing and renewing certificates.


