This tutorial takes a blank Ubuntu 24.04 or AlmaLinux 9 server to a live, HTTPS-secured Node.js application. You will install Node.js, set up the PM2 process manager, upload your app from your local machine, put Nginx in front as a reverse proxy, open the firewall, and add a free Let's Encrypt certificate. Distro-specific commands are labelled at each step.
On a bare server there is no control panel doing the wiring, so you assemble the pieces yourself. The standard, reliable layout is:
Internet ──► Nginx (ports 80/443, TLS) ──► Node.js app (127.0.0.1:3000) ──► database / cache
package.json and a package-lock.json.Replace example.com, youruser, and the app name myapp with your own values throughout.
Ubuntu 24.04
sudo apt update && sudo apt upgrade -y
AlmaLinux 9
sudo dnf update -y
We install from the NodeSource repository, which tracks upstream LTS releases more closely than the distribution's own packages. To use Node.js 24 instead, change 22 to 24 in the commands below.
Ubuntu 24.04
sudo apt install -y ca-certificates curl gnupg
sudo mkdir -p /etc/apt/keyrings
curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | sudo gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg
NODE_MAJOR=22
echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_$NODE_MAJOR.x nodistro main" | sudo tee /etc/apt/sources.list.d/nodesource.list
sudo apt update
sudo apt install -y nodejs
AlmaLinux 9
sudo dnf module reset nodejs -y
curl -fsSL https://rpm.nodesource.com/setup_22.x | sudo bash -
sudo dnf install -y nodejs
The dnf module reset line prevents AlmaLinux's built-in AppStream module from overriding the NodeSource package. If node -v later shows an unexpected version, run sudo dnf module disable nodejs -y and reinstall.
Confirm both distributions:
node -v
npm -v
You should see v22.x.x and a matching npm version.
sudo npm install -g pm2
PM2 runs the same way on both distributions. It keeps your app alive and manages logs and restarts.
sudo mkdir -p /var/www/myapp
sudo chown -R youruser:youruser /var/www/myapp
Owning the directory as your non-root user means you can deploy without sudo and the app runs under an unprivileged account.
Choose one of these methods. Whichever you use, do not upload the node_modules folder. Native modules must be compiled on the server, so you install dependencies there in the next step.
Option A: rsync (recommended for direct uploads). Run this from your local machine, in your project folder:
rsync -avz --exclude 'node_modules' --exclude '.git' --exclude '.env' \
./ youruser@YOUR_SERVER_IP:/var/www/myapp/
Option B: git (recommended for ongoing deployments). Run this on the server:
cd /var/www
git clone https://github.com/youraccount/myapp.git myapp
cd myapp
Option C: scp (simplest one-off). Run this from your local machine:
scp -r ./ youruser@YOUR_SERVER_IP:/var/www/myapp/
Now, on the server, install production dependencies from the lockfile and run any build step your app needs (for example a React or Next.js build):
cd /var/www/myapp
npm ci --omit=dev
# npm run build # only if your app has a build step
Your app must bind to 127.0.0.1 so it is reachable only through Nginx, not directly from the internet. Confirm your startup file reads the port from the environment and binds to localhost:
const port = process.env.PORT || 3000;
app.listen(port, '127.0.0.1', () => {
console.log('App listening on 127.0.0.1:' + port);
});
If you use environment variables, create a .env file in the app directory (never commit it to git) or set them in the PM2 config in the next step.
cd /var/www/myapp
NODE_ENV=production pm2 start app.js --name myapp
pm2 startup
The pm2 startup command prints a single sudo env ... command. Copy that command, run it exactly as printed, then save the current process list so it is restored on reboot:
pm2 save
Check the app is running:
pm2 status
curl -I http://127.0.0.1:3000
Ubuntu 24.04
sudo apt install -y nginx
AlmaLinux 9
sudo dnf install -y nginx
sudo systemctl enable --now nginx
On both distributions, create the site configuration file /etc/nginx/conf.d/myapp.conf:
server {
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 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;
}
}
The Upgrade and Connection headers allow WebSocket connections to pass through. On Ubuntu, remove the default site so it does not intercept requests:
sudo rm -f /etc/nginx/sites-enabled/default
Test the configuration and reload on both distributions:
sudo nginx -t
sudo systemctl reload nginx
Ubuntu 24.04 (ufw)
sudo ufw allow OpenSSH
sudo ufw allow 'Nginx Full'
sudo ufw enable
sudo ufw status
AlmaLinux 9 (firewalld)
sudo firewall-cmd --permanent --add-service=ssh
sudo firewall-cmd --permanent --add-service=http
sudo firewall-cmd --permanent --add-service=https
sudo firewall-cmd --reload
AlmaLinux 9 only: allow Nginx to make the proxy connection. SELinux blocks this by default, which produces a 502 error even when everything else is correct. Run:
sudo setsebool -P httpd_can_network_connect 1
Only ports 22, 80, and 443 are open to the internet. Your Node.js app on port 3000 stays private on localhost.
Ubuntu 24.04
sudo apt install -y certbot python3-certbot-nginx
sudo certbot --nginx -d example.com -d www.example.com
AlmaLinux 9
sudo dnf install -y epel-release
sudo dnf install -y certbot python3-certbot-nginx
sudo certbot --nginx -d example.com -d www.example.com
Certbot obtains the certificate, edits your Nginx config to serve HTTPS, and sets up automatic renewal. Confirm renewal works:
sudo certbot renew --dry-run
curl -I https://example.com
A working deployment returns 200 over HTTP/2 with a valid certificate. Open the site in a browser to confirm the padlock and your application load correctly. Your app is now live.
Always set NODE_ENV=production. It enables framework optimizations and disables verbose debugging. Set it when starting PM2, or in a config file (below).
Instead of long command lines, define your app in ecosystem.config.js in the app directory. This makes deployments repeatable and keeps environment variables in one place:
module.exports = {
apps: [{
name: 'myapp',
script: 'app.js',
instances: 'max',
exec_mode: 'cluster',
env: {
NODE_ENV: 'production',
PORT: 3000
}
}]
};
Start it with pm2 start ecosystem.config.js.
The instances: 'max' and exec_mode: 'cluster' settings above run one worker per CPU core and load-balance across them, which can multiply throughput on multi-core servers. Cluster mode suits stateless apps; if you keep session state in memory, move it to Redis first.
Let Nginx serve images, CSS, JavaScript bundles, and other static assets directly. It is far faster at this than Node and frees your app for real work. Add a location block above the main one:
location /static/ {
alias /var/www/myapp/public/;
expires 30d;
add_header Cache-Control "public, immutable";
}
Turn on gzip in Nginx to shrink responses. Add inside the server block or in the main Nginx config:
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml image/svg+xml;
gzip_min_length 1024;
Set baseline security headers in the Nginx server block, or use the Helmet middleware in Express:
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
To ship a new version: pull or rsync the code, install dependencies, rebuild if needed, then reload rather than restart. Reload swaps workers gracefully with no dropped requests:
cd /var/www/myapp
git pull # or rsync from local
npm ci --omit=dev
# npm run build
pm2 reload myapp
Without rotation, PM2 logs grow until they fill the disk. Install the log-rotate module once:
pm2 install pm2-logrotate
On servers with under 2 GB of RAM, an npm run build can run out of memory. A small swap file prevents the build from being killed:
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
Ubuntu: sudo apt install -y unattended-upgrades then sudo dpkg-reconfigure unattended-upgrades. AlmaLinux: sudo dnf install -y dnf-automatic then enable the timer with sudo systemctl enable --now dnf-automatic.timer.
Three common causes, in order. First, the app is not running: check pm2 status and curl -I http://127.0.0.1:3000. Second, the port in your Nginx proxy_pass does not match the port your app listens on. Third, on AlmaLinux, SELinux is blocking the proxy connection: run sudo setsebool -P httpd_can_network_connect 1.
No. Exclude it from uploads and run npm ci --omit=dev on the server. This installs the exact versions from your lockfile and compiles any native modules for the server's architecture, which a copied folder from your local machine may not match.
Yes, once PM2 boot startup is configured. After pm2 start, run pm2 startup, execute the command it prints, then run pm2 save. PM2 will relaunch your saved apps automatically on every boot.
Both are supported LTS releases. Node.js 22 is the conservative default for existing applications. Choose Node.js 24 if your app requires its newer features. Match the version to your app's engines field in package.json where one is set.
Yes. The domain's A record must point to your server's IP before you run Certbot, because Let's Encrypt validates ownership by reaching your server over that domain. Set DNS first, confirm it resolves, then request the certificate.
Use pm2 logs myapp for live application output, or pm2 logs myapp --lines 200 for recent history. Nginx access and error logs are in /var/log/nginx/.