Core principle
Self-hosting Next.js 15 on a VPS is not complicated, but the gaps between tutorials and production reality are where failures happen. This guide covers the full stack: application build, PM2 process management, Nginx reverse proxy, and Cloudflare SSL — with the configuration choices that reflect actual production use rather than minimal working examples.
This is the stack AKORNET uses for Dentalytic on a Hetzner VPS at 5.75.157.137, alongside PHP-based applications for other products on the same server.
Server prerequisites
- Ubuntu 24 on a Hetzner CX22 or larger (2 vCPU, 4GB RAM handles 2–3 Next.js applications comfortably)
- Node.js 20 LTS — install via
nvmfor version management flexibility - PM2 installed globally:
npm install -g pm2 - Nginx installed:
apt install nginx - Cloudflare managing DNS for the domain, with proxy enabled
Building the Next.js application
Next.js 15 App Router applications must be built for standalone output for efficient self-hosting. In next.config.js:
/** @type {import('next').NextConfig} */
const nextConfig = {
output: 'standalone',
}
module.exports = nextConfig
The standalone build produces a .next/standalone directory that contains only the server files needed to run the application — no node_modules tree. This significantly reduces the deployment footprint.
Build on the server (or build locally and copy the output):
npm install
npm run build
After the build, copy the static and public assets into the standalone directory:
cp -r .next/static .next/standalone/.next/static
cp -r public .next/standalone/public
PM2 configuration
Create a ecosystem.config.js in the project root:
module.exports = {
apps: [
{
name: 'dentalytic',
script: '.next/standalone/server.js',
instances: 'max',
exec_mode: 'cluster',
env: {
NODE_ENV: 'production',
PORT: 3000,
},
env_file: '.env',
max_memory_restart: '512M',
error_file: '/var/log/pm2/dentalytic-error.log',
out_file: '/var/log/pm2/dentalytic-out.log',
},
],
}
Key choices:
instances: 'max'uses all available CPU cores via cluster mode. On a 2-vCPU server this doubles throughput for CPU-bound work.env_file: '.env'loads environment variables from the project.envfile. Ensure this file is outside the webroot and haschmod 600permissions.max_memory_restartprevents a memory leak from taking down other applications on the server.
Start and persist the process:
pm2 start ecosystem.config.js
pm2 save
pm2 startup systemd
The last command outputs a systemctl command to run — this registers PM2 to start automatically on server reboot.
Nginx reverse proxy configuration
Create /etc/nginx/sites-available/dentalytic:
server {
listen 80;
server_name dental.akor.net;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
server_name dental.akor.net;
ssl_certificate /etc/ssl/certs/cloudflare-origin.pem;
ssl_certificate_key /etc/ssl/private/cloudflare-origin.key;
ssl_protocols TLSv1.2 TLSv1.3;
include snippets/security.conf;
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;
}
location /_next/static/ {
alias /var/www/dentalytic/.next/static/;
expires 1y;
add_header Cache-Control "public, immutable";
}
location /public/ {
alias /var/www/dentalytic/public/;
expires 30d;
}
}
Note that include snippets/security.conf applies the shared security configuration from the Nginx + Cloudflare Security Hardening guide — .env blocking, security headers, and dotfile protection.
Enable the site:
ln -s /etc/nginx/sites-available/dentalytic /etc/nginx/sites-enabled/
nginx -t
systemctl reload nginx
SSL with Cloudflare origin certificates
With Cloudflare proxying the domain, the SSL certificate on the origin server is a Cloudflare Origin Certificate — not a Let's Encrypt certificate. This is the correct setup for Full (Strict) SSL mode.
Generate an origin certificate in the Cloudflare dashboard under SSL/TLS → Origin Server → Create Certificate. Download the certificate and key, then copy them to the server:
# Certificate
cp origin.pem /etc/ssl/certs/cloudflare-origin.pem
chmod 644 /etc/ssl/certs/cloudflare-origin.pem
# Private key
cp origin.key /etc/ssl/private/cloudflare-origin.key
chmod 600 /etc/ssl/private/cloudflare-origin.key
Update the Nginx ssl_certificate and ssl_certificate_key paths to match.
Environment variable management
The .env file for a Next.js application on this stack should:
- Live in the project root (
/var/www/dentalytic/.env) - Be owned by the application user:
chown appuser:appuser .env - Have permissions
600:chmod 600 .env - Be excluded from git: confirm with
git check-ignore -v .env
Never place the .env file inside the webroot or any directory that Nginx could serve directly. The snippets/security.conf include blocks direct HTTP access to .env files, but the file placement itself is the primary defence.
Verifying the deployment
# Check PM2 process status
pm2 status
# Check application logs
pm2 logs dentalytic --lines 50
# Test Nginx config
nginx -t
# Check that the application responds on the local port
curl -I http://127.0.0.1:3000
# Check that .env is not publicly accessible
curl -I https://dental.akor.net/.env
# Should return 404
Summary
Self-hosting Next.js 15 on Hetzner with PM2 and Nginx is a cost-effective production setup for SaaS applications at early and growth stage. The key configuration points are: standalone output mode, PM2 cluster configuration with auto-restart and systemd integration, Nginx reverse proxy with static asset caching and shared security headers, and Cloudflare origin certificate for Full (Strict) SSL.
The setup runs multiple Next.js applications alongside PHP applications on the same server — different domains, different ports, same Nginx instance with shared security configuration.
AKORNET runs this stack in production. See our products at akor.net →