To deploy a Next.js app on Ubuntu 24.04, build the application with a supported Node.js LTS release, run the production server as a supervised process, bind it to localhost, and place Nginx in front for public HTTP/HTTPS traffic. This tutorial uses Node.js 24 LTS, PM2, Nginx, and Certbot to build that single-server production baseline.
Raff Technologies is used as the Ubuntu VM platform in the saved test workflow. The Next.js application itself listens only on 127.0.0.1:3000; Nginx is the public edge. This matches current Next.js self-hosting guidance, which recommends a reverse proxy rather than exposing the Next.js server directly to the internet.
As of September 7, 2026, the current Node.js 24 LTS release is 24.20.0 and the current stable Next.js documentation version is 16.3.4. Next.js 16.x is Active LTS, while 15.x is Maintenance LTS. Your production deployment should still use the Next.js version pinned by the application's lockfile rather than turning a server migration into an unplanned framework upgrade.
The original workflow was tested on a Raff Ubuntu 24.04 VM with 1 vCPU and 2 GB RAM. Node.js 24.20.0 LTS, Next.js 16.3.4 self-hosting behavior, and PM2 startup guidance were documentation-reviewed for this refresh; no new full machine retest is claimed.
Prerequisites:
- A Raff Linux VM running Ubuntu 24.04
- SSH access with a non-root sudo user
- A Next.js application in a Git repository
- A lockfile such as
package-lock.jsonif you plan to usenpm ci - A domain pointed to the VM for production HTTPS
- Ports
80/tcpand443/tcpavailable for Nginx - A recovery path before enabling or changing firewall rules
Step 1 — Prepare Ubuntu, DNS, and UFW safely
Update Ubuntu and install the base packages:
sudo apt update sudo apt upgrade -y sudo apt install -y git curl ca-certificates nginx ufw dnsutils
Check whether the upgrade requires a reboot:
test -f /var/run/reboot-required && cat /var/run/reboot-required
If a reboot is required, perform it during a safe maintenance window before continuing.
Point your domain's A record to the VM's public IPv4 address. Add an AAAA record only when IPv6 is configured and reachable on this VM.
Verify DNS:
dig +short A your-domain.com
Inspect UFW before changing it:
sudo ufw status verbose
Confirm the actual SSH port:
sudo sshd -T | awk '/^port / {print $2}'
If UFW is already active, keep the existing SSH rule and allow Nginx:
sudo ufw allow 'Nginx Full' sudo ufw status numbered
If UFW is inactive, allow the actual SSH path first. For standard SSH with the OpenSSH profile:
sudo ufw allow OpenSSH
For a custom SSH port, allow that port instead, for example:
sudo ufw allow 2222/tcp
Then allow Nginx:
sudo ufw allow 'Nginx Full'
Keep the current SSH session open and successfully open a second SSH session before enabling an inactive firewall:
sudo ufw enable sudo ufw status numbered
Do not use ufw --force enable as a shortcut around SSH-path verification.
Verify: DNS should resolve to the intended VM, Nginx should be active, the actual SSH path should remain reachable, and UFW should allow only the public services you intend to expose.
Step 2 — Install Node.js 24 LTS with NVM
Node.js currently provides 24.20.0 as the latest v24 LTS release. Install NVM with the current installer shown by Node.js:
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.7/install.sh | bash
Load NVM into the current shell:
export NVM_DIR="$HOME/.nvm" [ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh"
Install Node.js 24 and make it the default for this user:
nvm install 24 nvm alias default 24 nvm use 24
Check the installed versions:
node --version npm --version nvm current
The exact patch can increase later, but on September 7, 2026 the current v24 LTS release is v24.20.0.
Verify: node --version and nvm current should report the same Node.js 24 branch.
Step 3 — Clone the Next.js application and install locked dependencies
Create the application directory:
sudo mkdir -p /var/www/nextjs sudo chown "$USER":"$USER" /var/www/nextjs cd /var/www/nextjs
Clone your repository:
git clone https://github.com/your-username/your-nextjs-repo.git .
If the repository uses npm and contains package-lock.json, install the locked dependency graph:
npm ci
Inspect the installed Next.js version:
npm ls next
Next.js 16.x is the current Active LTS major. The official documentation is on 16.3.4 as of this refresh, but do not replace the application's tested lockfile version merely because a newer release exists.
Verify: package.json and the expected lockfile should exist, npm ci should complete successfully, and npm ls next should resolve the project's installed Next.js version.
Step 4 — Configure production environment variables
Next.js supports both build-time and runtime environment variables. Variables prefixed with NEXT_PUBLIC_ are exposed to the browser and are inlined into client JavaScript during next build, so never store secrets in a public variable.
Create a production-local environment file if your application requires one:
cd /var/www/nextjs nano .env.production.local
Example:
DATABASE_URL=your_server_side_database_url APP_SECRET=your_server_side_secret NEXT_PUBLIC_APP_URL=https://your-domain.com
Protect it:
chmod 600 .env.production.local
Check that local environment files are ignored by Git:
grep -E '^\.env|\.env\*' .gitignore || true git status --short
Add an appropriate ignore rule if your repository does not already exclude the file.
Verify: if .env.production.local exists, it should have mode 600, secrets should not use the NEXT_PUBLIC_ prefix, and the file should not be staged for commit.
Step 5 — Build the Next.js production application
Build the application with its required environment already in place:
cd /var/www/nextjs npm run build
A successful build creates the .next directory.
When troubleshooting, inspect the framework and environment instead of assuming every failed build is a VM-size problem:
npx next info
Check the build output without running the build a second time:
test -d .next && echo ".next build directory exists" test -f package.json && echo "package.json found"
Verify: npm run build should finish without a fatal error and .next should exist afterward.
Step 6 — Test the Next.js production server on localhost only
For this architecture, the application server should not be the public endpoint. Start it on loopback:
cd /var/www/nextjs npm run start -- --hostname 127.0.0.1 --port 3000
In a second SSH session, inspect the listener:
sudo ss -lntp | grep ':3000'
The expected bind is:
127.0.0.1:3000
Test the local application:
curl -I http://127.0.0.1:3000
Stop the manual process with Ctrl+C after the test.
Verify: the local HTTP request should succeed and port 3000 should not listen on 0.0.0.0.
Step 7 — Run Next.js with PM2
Install PM2 under the same Node.js/NVM user that owns the application process:
npm install -g pm2
Create /var/www/nextjs/ecosystem.config.cjs:
nano /var/www/nextjs/ecosystem.config.cjs
Use one process as the baseline:
module.exports = { apps: [ { name: 'nextjs-app', cwd: '/var/www/nextjs', script: 'npm', args: [ 'start', '--', '--hostname', '127.0.0.1', '--port', '3000' ], instances: 1, exec_mode: 'fork', watch: false, env: { NODE_ENV: 'production' } } ] }
Start the process:
cd /var/www/nextjs pm2 start ecosystem.config.cjs
Inspect status and recent logs:
pm2 status pm2 logs nextjs-app --lines 50 --nostream
A single instance is intentional. Current Next.js self-hosting guidance says caches are local to each instance by default, and multi-instance App Router deployments require additional coordination for cache tags, shared cache state, deployment versions, and related behavior.
For PM2-specific operations, see How to Install PM2 and Deploy Node.js on Ubuntu 24.04.
Verify: nextjs-app should be online, curl -I http://127.0.0.1:3000 should succeed, and port 3000 should remain loopback-only.
Step 8 — Configure PM2 reboot persistence
Generate PM2's startup integration as the application user:
pm2 startup
PM2 prints a customized sudo env PATH=... pm2 startup systemd ... command. Run the exact command it prints rather than copying a hard-coded path from another server.
Save the process list:
pm2 save
Find the generated systemd service:
systemctl list-unit-files | grep '^pm2-'
PM2 documents that after changing the Node.js version, you should regenerate the startup integration so its PATH points to the current Node.js binary.
Verify: the PM2 systemd service for your user should be enabled and pm2 save should complete successfully.
Step 9 — Configure Nginx as the public reverse proxy
Create the site configuration:
sudo nano /etc/nginx/sites-available/nextjs
Add:
server { listen 80; listen [::]:80; server_name your-domain.com www.your-domain.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; proxy_buffering off; proxy_read_timeout 60s; proxy_connect_timeout 10s; } }
Next.js recommends a reverse proxy for self-hosting. App Router streaming also requires the proxy path not to buffer the response. proxy_buffering off provides that behavior at this Nginx layer.
Do not invent a separate one-year cache rule for /_next/static/. Next.js already sets immutable caching headers for truly immutable hashed assets.
Enable the site:
sudo ln -sf /etc/nginx/sites-available/nextjs \ /etc/nginx/sites-enabled/nextjs sudo rm -f /etc/nginx/sites-enabled/default
Validate before reloading:
sudo nginx -t sudo systemctl reload nginx
Test the proxy locally with the production Host header:
curl -I http://127.0.0.1 -H 'Host: your-domain.com'
Verify: nginx -t should succeed and the Host-header request should return the Next.js application through Nginx.
Step 10 — Enable HTTPS with Certbot
Install Certbot and its Nginx plugin:
sudo apt install -y certbot python3-certbot-nginx
Request a certificate after public DNS points to the VM and port 80 is reachable:
sudo certbot --nginx --redirect \ -d your-domain.com \ -d www.your-domain.com
If you do not use www, omit it from DNS, Nginx, and Certbot.
Inspect the certificate and test renewal:
sudo certbot certificates sudo certbot renew --dry-run
For certificate troubleshooting, continue with Secure Nginx with Let's Encrypt on Ubuntu 24.04.
Verify: HTTP should redirect to HTTPS, the HTTPS request should succeed, and the renewal dry run should pass.
Step 11 — Verify reboot persistence
Save the final PM2 process list:
pm2 save
Reboot during a maintenance window:
sudo reboot
After reconnecting, verify PM2, the local app, and the public endpoint:
pm2 status curl -I http://127.0.0.1:3000 curl -I https://your-domain.com
If the process did not return, inspect the PM2 service:
systemctl list-units --type=service | grep pm2 systemctl --failed
Verify: nextjs-app should return as online after reboot, port 3000 should respond locally, and the public HTTPS endpoint should work without a manual pm2 start.
Step 12 — Verify the Next.js deployment end to end
Run the final checks:
printf 'Node.js:\n' node --version printf '\nNext.js:\n' cd /var/www/nextjs npm ls next printf '\nPM2:\n' pm2 status printf '\nLocal Next.js:\n' curl -I http://127.0.0.1:3000 printf '\nListening socket:\n' sudo ss -lntp | grep ':3000' printf '\nNginx config:\n' sudo nginx -t printf '\nHTTP redirect:\n' curl -I http://your-domain.com printf '\nHTTPS response:\n' curl -I https://your-domain.com printf '\nFirewall:\n' sudo ufw status numbered
In the application and browser, confirm that:
- the intended production build is being served;
- PM2 reports
nextjs-appas online; - Next.js listens only on
127.0.0.1:3000; - Nginx passes its configuration test;
- HTTP redirects to HTTPS;
- HTTPS serves the application successfully;
- port 3000 has no public UFW allow rule;
- the application returns after reboot; and
- streaming routes, if your app uses them, are not unintentionally buffered by Nginx.
End-to-end verification is complete when the complete request path — browser → HTTPS/Nginx → loopback Next.js → PM2-managed process — works after a reboot without exposing the application port publicly.
