DEPLOYMENT
How to deploy a Node.js app to production on a Linux server
By Anupa Dinuranga, FounderUpdated 10 min read
This guide assumes a fresh Ubuntu 24.04 LTS server, a user with sudo, a domain whose DNS points at the server, and an app that listens on a port set by the PORT environment variable. If you haven't locked the server down yet, do the first-hour security checklist first: SSH keys, firewall, automatic updates.
Step 1: Install a Node.js LTS release
Ubuntu's own nodejs package is usually several major versions behind. Use the NodeSource repository, which installs a current LTS release system-wide at /usr/bin/node. That matters later: systemd needs a fixed path to the binary, which per-user installers like nvm don't give you.
# Download the setup script, read it, then run it
curl -fsSL https://deb.nodesource.com/setup_24.x -o nodesource_setup.sh
less nodesource_setup.sh
sudo -E bash nodesource_setup.sh
sudo apt-get install -y nodejs git
node -v
npm -vSwap 24 for 22 if your app hasn't been tested on Node.js 24 yet. Both are LTS lines; check the Node.js release schedule for end-of-life dates. Security updates for Node.js then arrive through the normal apt upgrade.
Step 2: Create an app user and get the code on the server
Run the app as its own unprivileged user, never as root. If the app is compromised, the attacker gets that user's permissions, not the whole server.
sudo useradd --system --create-home --home-dir /srv/myapp --shell /usr/sbin/nologin myapp
# Each deploy goes into its own release folder; "current" points at the live one
sudo -u myapp -H mkdir -p /srv/myapp/releases
sudo -u myapp -H git clone https://github.com/you/myapp.git /srv/myapp/releases/first
sudo -u myapp -H bash -c "cd /srv/myapp/releases/first && npm ci --omit=dev"
sudo ln -sfn /srv/myapp/releases/first /srv/myapp/currentnpm ci installs exactly what is in package-lock.json, and --omit=dev skips test and build tools. The commands run as myapp because its home folder may not be readable by your own login. If your app has a build step (TypeScript, a bundler), install everything, build, then prune:
sudo -u myapp -H bash -c "cd /srv/myapp/releases/first && npm ci && npm run build && npm prune --omit=dev"For a private repository, add a read-only deploy key to the repo and clone over SSH instead of storing a personal access token on the server.
Step 3: Keep secrets in an environment file
Database passwords, API keys and session secrets belong in a file on the server, not in the repository. systemd reads the file as root before starting your app, so it can be locked down to root only.
sudo mkdir -p /etc/myapp
sudo nano /etc/myapp/env
sudo chown root:root /etc/myapp/env
sudo chmod 600 /etc/myapp/envNODE_ENV=production
PORT=3000
DATABASE_URL=postgres://myapp:[email protected]:5432/myapp
SESSION_SECRET=a-long-random-stringOne KEY=value per line, no export. Setting NODE_ENV=production matters: Express and many other libraries turn off debug behaviour and turn on caching when they see it.
Generate secrets rather than typing them: openssl rand -hex 32 prints a 64-character random string. The app only reads this file when it starts, so after editing it run sudo systemctl restart myapp. If a secret was ever committed to Git, change it; deleting the file from the repo does not remove it from the history.
Step 4: Run the app with systemd (or PM2)
A process manager starts the app at boot and restarts it when it crashes. systemd is already on every Ubuntu server, so start there.
[Unit]
Description=myapp Node.js service
After=network.target
[Service]
Type=simple
User=myapp
Group=myapp
WorkingDirectory=/srv/myapp/current
EnvironmentFile=/etc/myapp/env
ExecStart=/usr/bin/node server.js
Restart=on-failure
RestartSec=5
# Basic hardening
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=full
[Install]
WantedBy=multi-user.targetRestart=on-failure restarts the app when it crashes or exits with an error, but not when you stop it yourself. RestartSec=5 waits five seconds between attempts, so a crash loop doesn't spin the CPU. Change server.js to your entry point. For an app started with an npm script, use ExecStart=/usr/bin/npm start; for Next.js, use the full path /srv/myapp/current/node_modules/.bin/next start -H 127.0.0.1 (systemd needs an absolute path here). Then load and start it:
sudo systemctl daemon-reload
sudo systemctl enable --now myapp
systemctl status myapp
curl -I http://127.0.0.1:3000Have the app listen on 127.0.0.1 rather than all interfaces (in Express, app.listen(process.env.PORT, "127.0.0.1")). Only Nginx needs to reach it.
Reading the logs
Anything your app writes to stdout or stderr goes to the systemd journal. No log files to rotate yourself.
journalctl -u myapp -f # follow live
journalctl -u myapp --since "1 hour ago" # recent history
journalctl -u myapp -b -p err # errors since last bootPM2 as an alternative
PM2 is a Node.js process manager with a cluster mode that runs several copies of your app across CPU cores and reloads them one at a time. If you want that, install it globally and let it register its own systemd service:
sudo npm install -g pm2
pm2 start server.js --name myapp -i max # cluster mode, one process per core
pm2 startup systemd # prints a sudo command: run it
pm2 save # remember the process list for rebootsRun the pm2 commands as the app user (prefix them with sudo -u myapp -H) so the app doesn't end up running as you. Use one process manager or the other. Running PM2 inside a systemd unit you wrote yourself just gives you two process managers arguing.
Step 5: Put Nginx in front
Nginx takes the public ports 80 and 443, handles TLS and slow clients, and forwards requests to your app. Install it and allow it through the firewall:
sudo apt-get install -y nginx
sudo ufw allow "Nginx Full"# Send "Connection: upgrade" only when the client asks for a WebSocket
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
listen 80;
listen [::]:80;
server_name example.com www.example.com;
client_max_body_size 10m;
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;
# WebSockets (Socket.IO, live reload, etc.)
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_read_timeout 300s;
}
}sudo ln -s /etc/nginx/sites-available/myapp /etc/nginx/sites-enabled/myapp
sudo rm /etc/nginx/sites-enabled/default
sudo nginx -t && sudo systemctl reload nginxBecause requests now arrive from Nginx, your app sees 127.0.0.1 as the client unless you tell it to trust the proxy headers. In Express that is app.set("trust proxy", 1). Without it, rate limiting, logging and secure cookies behave wrongly.
Step 6: Add HTTPS
With DNS pointing at the server and port 80 open, Certbot gets a free Let's Encrypt certificate and edits the Nginx config for you:
sudo apt-get install -y certbot python3-certbot-nginx
sudo certbot --nginx -d example.com -d www.example.com
sudo certbot renew --dry-runCertbot adds a listen 443 ssl block and an HTTP-to-HTTPS redirect, and the package installs a systemd timer that renews certificates before they expire. The dry run confirms renewal will work.
If you would rather put Cloudflare in front, set SSL/TLS to Full (strict) and install either a Let's Encrypt certificate or a Cloudflare Origin Server certificate on the server. Our Cloudflare setup guide covers the modes and how to lock the server to Cloudflare's IP ranges.
Shipping updates with little or no downtime
The tempting approach is git pull in the live folder, then npm ci, then a restart. The problem is that npm ci deletes node_modules while the running app still uses it, and a failed build leaves you with a broken live folder. Build each release in its own folder instead, and only switch once it is ready:
#!/usr/bin/env bash
set -euo pipefail
APP=/srv/myapp
REL="$APP/releases/$(date +%Y%m%d%H%M%S)"
sudo -u myapp -H git clone --depth 1 https://github.com/you/myapp.git "$REL"
sudo -u myapp -H bash -c "cd '$REL' && npm ci && npm run build --if-present && npm prune --omit=dev"
# Switch and restart only after the build succeeded
sudo ln -sfn "$REL" "$APP/current"
sudo systemctl restart myapp
systemctl is-active myappDowntime is now just the restart itself: usually a moment, during which Nginx may return 502 for a few requests. Rolling back is the same switch in reverse: point current at the previous release folder and restart:
sudo ls -1 /srv/myapp/releases # find the previous release
sudo ln -sfn /srv/myapp/releases/20261001093000 /srv/myapp/current
sudo systemctl restart myappEach release folder carries its own node_modules, so they add up. Delete old ones now and then, keeping the last three or so for rollbacks.
A restart still drops requests that are in flight. True zero-downtime deploys need at least two instances, so one serves traffic while the other restarts:
- PM2 cluster mode:
pm2 reload myapprestarts the workers one at a time. - Two systemd instances on different ports, both listed in an Nginx
upstreamblock, restarted one after the other.
Database migrations make rollbacks harder. Back up the database before running them, and prefer changes the previous release can still work with (add a column first, remove the old one in a later release).
After any deploy, watch journalctl -u myapp -f for a minute. Most failed deploys show up there as a crash loop before users notice.
Where NevaraCloud fits
Everything above is what you take on when you run your own server: Node.js updates, Nginx, certificates, deploys and logs. If you would rather just push code, our managed Node.js hosting runs one Node.js app on the Business plan (with a MySQL or PostgreSQL database and daily backups kept seven days) and heavier full-stack apps on Pro. You don't get root access; we don't sell self-managed VPS servers.
If you already have a server and want this set up for you, or something on it is broken, our deployment and fix-it service is quoted after we look at the setup.