Introduction
To deploy a Next.js application on Ubuntu 24.04, install Node.js via NVM, clone your repository and run the production build, install PM2 to manage the process, configure an Nginx server block to reverse proxy traffic to it, and secure the endpoint with a Let's Encrypt certificate via Certbot. When everything is in place, your Next.js app restarts automatically on crash and on VM reboot, serves traffic over HTTPS, and is ready to handle real users. On a Raff Tier 2 VM (1 vCPU / 2 GB RAM), the full setup takes about 35 minutes on a fresh image.
Next.js is a React framework that supports static generation, server-side rendering, and API routes in a single codebase. In production, it runs as a Node.js process via next start, which means it needs a process manager to stay alive and a reverse proxy to handle the network edge. PM2 is the standard choice for Node.js process management: it restarts the app on crashes, starts it on boot, streams logs, and supports cluster mode to spread load across multiple CPU cores on larger VMs. On a Tier 2 VM running this stack, a minimal Next.js app idles at around 95 MB RAM after a production build — leaving over 1.8 GB available for the Node.js runtime to handle traffic spikes and SSR workloads.
In this tutorial, you will install Node.js using NVM, clone and build your Next.js application, configure PM2 to run and persist the process, set up Nginx as a reverse proxy with correct headers, obtain an SSL certificate with Certbot, and run a full end-to-end verification of the deployed stack.
Note
This tutorial assumes you have a Next.js application in a Git repository. If you are starting from scratch, the steps work identically with npx create-next-app@latest — just push the generated project to a repository first and clone it on the server in Step 3.
Step 1 — Install Node.js with NVM
NVM (Node Version Manager) is the correct way to install Node.js on a Linux server. It lets you install multiple Node.js versions side by side, switch between them per project, and upgrade without affecting other applications. Installing Node.js from Ubuntu's default APT repository gives you an outdated version that is often incompatible with current Next.js requirements.
Install NVM:
bashcurl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash
Load NVM into your current shell session without logging out:
bashexport NVM_DIR="$HOME/.nvm"
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
Install the current LTS version of Node.js (22.x as of 2026):
bashnvm install --lts
nvm use --lts
nvm alias default lts/*
Verify both Node.js and npm are available:
bashnode --version
npm --version
Expected output:
v22.x.x
10.x.x
Note
The nvm alias default lts/* command ensures that new shell sessions and PM2 startup scripts use the LTS version automatically. Skipping this step is a common cause of "node: command not found" errors when PM2 tries to restart the app after a reboot.
Step 2 — Install PM2 Globally
Install PM2 as a global npm package so it is available system-wide:
bashnpm install -g pm2
Verify the installation:
bashpm2 --version
Expected output:
5.x.x
PM2 is now installed but has no processes registered yet. You will configure it with your Next.js app in Step 4.
Step 3 — Clone and Build the Application
Create a directory for your application and clone your repository:
bashsudo mkdir -p /var/www/nextjs
sudo chown $USER:$USER /var/www/nextjs
cd /var/www/nextjs
git clone https://github.com/your-username/your-nextjs-repo.git .
Replace the repository URL with your actual project. The trailing . clones into the current directory rather than creating a subdirectory.
Install production dependencies:
bashnpm ci
npm ci (clean install) is preferable to npm install on a server — it installs exactly what is in package-lock.json, refuses to modify it, and is faster. A common mistake here is running npm install on the server and ending up with slightly different dependency versions than your development environment.
Run the Next.js production build:
bashnpm run build
The build process compiles your application, pre-renders static pages, and outputs the .next directory. On a Tier 2 VM, expect build times of 60–120 seconds for a typical medium-size Next.js application. You will see output like:
Route (app) Size First Load JS
┌ ○ / 5.2 kB 89.4 kB
├ ○ /about 1.1 kB 85.3 kB
└ ○ /api/health 0 B 0 B
○ (Static) prerendered as static content
Warning
If your build fails with a JavaScript heap out-of-memory error, your VM does not have enough RAM for the build process. Either temporarily increase your VM tier for the build, or add a swap file with sudo fallocate -l 2G /swapfile && sudo chmod 600 /swapfile && sudo mkswap /swapfile && sudo swapon /swapfile. On a Tier 2 VM, large Next.js apps occasionally need this safety net during the initial build.
Test the production server manually before handing it to PM2:
bashnpm run start
Expected output:
▶ Next.js 15.x.x
- Local: http://localhost:3000
- Network: http://0.0.0.0:3000
✓ Starting...
✓ Ready in 312ms
Press Ctrl+C to stop. The app runs correctly — now make it permanent.
Step 4 — Configure PM2 with an Ecosystem File
Instead of running PM2 with inline flags, use an ecosystem file. This is a configuration file that PM2 reads to understand how to run your application — it is the equivalent of a systemd unit file, but in JSON format and portable across servers.
Create the ecosystem file in your application directory:
bashnano /var/www/nextjs/ecosystem.config.js
Paste the following:
javascriptmodule.exports = {
apps: [
{
name: "nextjs-app",
script: "node_modules/.bin/next",
args: "start",
cwd: "/var/www/nextjs",
instances: 1,
exec_mode: "fork",
watch: false,
max_memory_restart: "500M",
env: {
NODE_ENV: "production",
PORT: 3000,
},
error_file: "/var/log/pm2/nextjs-error.log",
out_file: "/var/log/pm2/nextjs-out.log",
log_date_format: "YYYY-MM-DD HH:mm:ss",
},
],
};
A few configuration decisions worth explaining:
instances: 1 and exec_mode: "fork" — On a single vCPU Tier 2 VM, one instance in fork mode is correct. Setting instances: "max" with exec_mode: "cluster" on a single-core VM does not improve throughput and adds overhead. If you upgrade to a Tier 3 VM (2 vCPU), change instances to 2 and exec_mode to "cluster" to parallelize across both cores.
max_memory_restart: "500M" — PM2 will restart the process if it exceeds 500 MB RAM. This is a safety net against memory leaks in long-running SSR workloads. Adjust based on your application's actual usage — watch it with pm2 monit after deployment.
watch: false — Never enable file watching in production. It causes unnecessary restarts on any file system change and burns CPU cycles.
Create the log directory:
bashsudo mkdir -p /var/log/pm2
sudo chown $USER:$USER /var/log/pm2
Start the application with PM2:
bashpm2 start /var/www/nextjs/ecosystem.config.js
Expected output:
[PM2] Spawning PM2 daemon with pm2_home=/home/youruser/.pm2
[PM2] PM2 Successfully daemonized
[PM2] Starting /var/www/nextjs/ecosystem.config.js in fork_mode (1 instance)
[PM2] Done.
┌────┬────────────┬─────────────┬─────────┬─────────┬──────────┬────────┬──────┬───────────┐
│ id │ name │ namespace │ version │ mode │ pid │ uptime │ ↺ │ status │
├────┼────────────┼─────────────┼─────────┼─────────┼──────────┼────────┼──────┼───────────┤
│ 0 │ nextjs-app │ default │ 15.x.x │ fork │ 23451 │ 0s │ 0 │ online │
└────┴────────────┴─────────────┴─────────┴─────────┴──────────┴────────┴──────┴───────────┘
Confirm the app is responding locally:
bashcurl http://127.0.0.1:3000
You should receive your application's HTML response. If you get Connection refused, check PM2 logs:
bashpm2 logs nextjs-app --lines 30
Step 5 — Configure PM2 to Start on Boot
PM2 processes do not persist across reboots unless you register them with the system startup manager. PM2 handles this automatically:
bashpm2 startup
PM2 will output a command tailored to your system — it looks like:
[PM2] Init System found: systemd
[PM2] To setup the Startup Script, copy/paste the following command:
sudo env PATH=$PATH:/home/youruser/.nvm/versions/node/v22.x.x/bin \
/home/youruser/.nvm/versions/node/v22.x.x/lib/node_modules/pm2/bin/pm2 \
startup systemd -u youruser --hp /home/youruser
Copy the exact command PM2 outputs and run it — do not copy the example above. The path includes your specific Node.js version and username.
After running the startup command, save the current PM2 process list so it is restored on reboot:
bashpm2 save
Expected output:
[PM2] Saving current process list...
[PM2] Successfully saved in /home/youruser/.pm2/dump.pm2
Verify the startup hook was registered:
bashsudo systemctl status pm2-$USER
Expected output:
● pm2-youruser.service - PM2 process manager
Active: active (running)
Enable: enabled
Step 6 — Configure Nginx as a Reverse Proxy
Create a new Nginx server block for your Next.js application:
bashsudo nano /etc/nginx/sites-available/nextjs
Paste the following, replacing your-domain.com with your actual domain:
nginxserver {
listen 80;
server_name your-domain.com www.your-domain.com;
# Proxy all requests to Next.js
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;
# Required for Next.js HMR WebSocket in dev — harmless in production
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 60s;
proxy_connect_timeout 60s;
}
# Cache Next.js static assets aggressively
location /_next/static/ {
proxy_pass http://127.0.0.1:3000/_next/static/;
proxy_cache_valid 200 1y;
add_header Cache-Control "public, max-age=31536000, immutable";
}
# Increase body size limit for file uploads if needed
client_max_body_size 10M;
}
The /_next/static/ block is worth calling out: Next.js content-hashes all static assets in the _next/static/ path, which means the filenames change on every build. This makes them safe to cache for a full year — the browser fetches fresh assets automatically after a new deployment because the hash in the URL changes. Without this block, Nginx serves static assets with its default short-lived caching headers, and users pay unnecessary bandwidth and latency on every page load.
Enable the site and remove the default:
bashsudo ln -s /etc/nginx/sites-available/nextjs /etc/nginx/sites-enabled/
sudo rm -f /etc/nginx/sites-enabled/default
sudo nginx -t
Expected output:
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful
Reload Nginx:
bashsudo systemctl reload nginx
Step 7 — Add HTTPS with Certbot
Install Certbot and obtain a certificate for your domain:
bashsudo apt install -y certbot python3-certbot-nginx
sudo certbot --nginx -d your-domain.com -d www.your-domain.com
Certbot modifies the Nginx config automatically and sets up HTTP-to-HTTPS redirection. After completion, verify HTTPS is serving correctly:
bashcurl -I https://your-domain.com
Expected output:
HTTP/2 200
server: nginx
Check the auto-renewal timer is active:
bashsudo systemctl status certbot.timer
Expected output:
● certbot.timer - Run certbot twice daily
Active: active (waiting)
Step 8 — Verify the Full Stack End to End
Run a complete stack health check from your local machine:
bash# HTTPS response
curl -I https://your-domain.com
# Application content (should return your app's HTML)
curl -s https://your-domain.com | grep -i "<title>"
# Static asset caching headers
curl -I https://your-domain.com/_next/static/chunks/main-app.js 2>/dev/null | grep -i cache-control
The last command should return:
cache-control: public, max-age=31536000, immutable
Verify PM2 is managing the process correctly:
bashpm2 status
pm2 monit
pm2 monit opens a live dashboard showing CPU and memory usage per process. On a Tier 2 VM at idle, expect the Next.js process to use approximately 95–120 MB RAM and under 1% CPU. Press q to exit.
Simulate a crash to confirm PM2 restarts the process:
bash# Get the process ID
pm2 status
# Kill it forcefully
kill -9 $(pm2 pid nextjs-app)
# Wait 2 seconds and check
sleep 2 && pm2 status
Expected output shows the process back online with a restart count of 1:
│ 0 │ nextjs-app │ fork │ online │ 1 │ 2s
Finally, simulate a reboot to confirm PM2 restores the process on startup:
bashsudo reboot
After reconnecting via SSH, check that the app is running without any manual intervention:
bashpm2 status
curl http://127.0.0.1:3000
Both should succeed. The startup hook registered in Step 5 is doing its job.
Conclusion
Your Next.js application is now running in production on a Raff Ubuntu 24.04 VM: PM2 manages the process lifecycle, Nginx handles the network edge and TLS, and Certbot keeps the certificate renewed. The stack restarts on crash, survives reboots, and serves static assets with aggressive caching — covering the fundamentals that a production deployment needs.
A few natural next steps from here:
- Zero-downtime deployments: When you push a new version, follow this sequence:
git pull && npm ci && npm run build && pm2 reload nextjs-app. Thepm2 reloadcommand performs a rolling restart — it starts the new instance before stopping the old one, so there is no gap in availability.pm2 restartstops and starts, causing a brief downtime; always usereloadin production. - Environment variables: Store sensitive values like API keys in a
.env.localfile in your application directory, not in the ecosystem config. Next.js reads.env.localat build time and runtime. Add.env.localto.gitignoreand never commit it to version control. - Scale to multiple cores: If you upgrade to a Raff Tier 3 VM (2 vCPU), update the ecosystem config to
instances: 2andexec_mode: "cluster", then runpm2 reload ecosystem.config.js. PM2 cluster mode runs one Node.js process per core and load-balances incoming connections across them — doubling throughput for CPU-bound SSR workloads with no application code changes. - Review your firewall: Now that the app is live, confirm only ports 80 and 443 are publicly accessible. Port 3000 should never appear in an external port scan — it is bound to
127.0.0.1and should stay that way. The cloud firewall rules guide covers how to verify this from outside the VM.
This tutorial was tested by Aybars on a Raff Tier 2 VM (1 vCPU / 2 GB RAM) running Ubuntu 24.04 LTS. From a fresh VM image to a verified HTTPS response took 33 minutes, including a 90-second production build for a medium-size Next.js 15 App Router project.

