
Moving a Node.js app from your laptop to a real server isn't just copying files — it's pinning the runtime, choosing a process manager, configuring an edge proxy and TLS, and preparing releases with health checks and rollback. This 2026 guide walks through the VPS setup, compares PM2 and systemd, and explains how to reduce deployment interruptions without promising zero downtime.
Your Node.js app works perfectly on your laptop — npm run dev. Production is a different planet: nobody is there to press Ctrl+C and run it again when the process dies, requests arrive in strange orders at 3 a.m., certificates expire, and memory grows until the box falls over. VPS deployment is not about installing Node; it's about the four surrounding layers — a pinned runtime, a supervised process, a reverse proxy, and a sane deploy path — that keep the app alive.
This guide walks through a 2026 VPS deployment: pin the runtime, supervise the app, put it behind Nginx, prepare rolling releases with health checks and rollback, and avoid common production pitfalls.
Key Takeaways
Don't leave
node app.jsattached to an SSH session. Supervise production processes with PM2 or systemd; choose based on whether you need PM2's Node-specific cluster/reload features or prefer the OS service manager.Pin an exact, tested runtime patch with a version manager or container. At the October 10, 2026 research date, Node 24 is Active LTS; the official schedule lists it moving to Maintenance LTS on October 20, while Node 26 is scheduled to enter Active LTS on October 28. Recheck the release schedule before publishing or deploying.
Put the Node app behind a suitable edge layer—Nginx in this walkthrough—to handle TLS, static assets, request limits, compression if configured, and trusted header forwarding.
pm2 reloadis designed for rolling reloads in cluster mode; it is not an unconditional zero-downtime guarantee. Readiness, graceful shutdown, startup timeouts, and in-memory state matter.NODE_ENV=productionand secrets handled via env vars belong to the server config, not to the codebase.
Step 1: Provision the Server Safely
Before touching Node, give the box a safe baseline (2–5 minutes on any modern VPS):
SSH keys — disable password auth where operationally feasible and retain a tested recovery path.
A non-root deploy user with sudo rights; never run your app permanently as root.
Firewall — UFW (or your provider's panel): allow only required ports, commonly SSH,
80, and443; restrict SSH to known source IPs where practical.Automatic security updates —
unattended-upgradeson Ubuntu keeps the OS patched while you control the app runtime.
For a small, stateless API, 2 vCPU and 2–4 GB RAM can be a starting point—not a sizing guarantee. Benchmark expected traffic and account for per-worker memory, Nginx, databases, and other services; WebSocket-heavy apps may need more RAM or a separate service.
Step 2: Pin the Runtime
The single biggest cause of "works on staging, breaks in prod" for Node is running whatever Node happens to be installed. Pin it.
# Install nvm; its installer updates your shell profile
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.3/install.sh | bash
# Load nvm into this existing Bash session (or reconnect); use ~/.zshrc for zsh
source ~/.bashrc
command -v nvm
# .nvmrc should contain an exact, currently patched version, e.g. 24.21.0
nvm install
nvm use
node --versionPut the same exact Node patch (for example,
24.21.0) in.nvmrc, CI, and production; a major-only value such as24can resolve to different patches over time. The example is illustrative—choose the latest patched release supported by your dependencies.At the October 10, 2026 research date, Node 24 (Krypton) is Active LTS and is scheduled to enter Maintenance LTS on October 20. Node 26 is Current and is scheduled to enter Active LTS on October 28. Dates can change; check the official Node.js release schedule before publishing and choosing an upgrade window.
If anything in your stack still needs Node 18/20: both are past their EOL dates in 2026 — treat the migration as a project, not a TODO.
If you containerize (Docker), pin an exact Node patch and OS variant (for example, node:24.21.0-bookworm-slim; use the currently patched release), or pin the image digest for fully reproducible builds. This guide covers the non-Docker path since it's the classic VPS workflow.
Step 3: Prepare an Immutable Release
Don't run installs or builds inside the directory currently serving traffic. Create a fresh release directory for each commit or build artifact:
: "${BUILD_ID:?Set BUILD_ID to a unique commit SHA or CI build ID}"
RELEASE_DIR="/srv/myapp/releases/${BUILD_ID}"
mkdir -p "$RELEASE_DIR"
# Unpack a CI artifact or check out an exact commit into this new directory.
cd "$RELEASE_DIR"
# Preferred: CI builds the app and packages its output; install runtime deps here.
npm ci --omit=devnpm ciovernpm install— it installs exactly what the lockfile says and is reproducible.--omit=devskips devDependencies. Use it only when the build has already run in CI and the release artifact contains its built output.If building on the VPS, install the build dependencies in this isolated release (
npm ci --include=dev), runnpm run build, thennpm prune --omit=dev. Never omit build tools before a build that needs them.Commit a lockfile (
package-lock.json). Keep.envfiles and secrets outside the release artifact and repository; load them from a protected server-side environment file or secret manager.
Keep the runtime patch consistent across CI and production, and include native dependencies built for the target OS/architecture. This prevents a CI build from silently differing from the release that runs on the VPS.
Step 4: Put It Under a Process Manager
This is the layer ends the "nobody restarts it when it dies" problem. Two credible 2026 options:
Option A: PM2 (the Node-native choice)
npm install -g pm2
# ecosystem.config.js in the app root
module.exports = {
apps: [{
name: 'myapp',
cwd: '/srv/myapp/current', // stable symlink switched to a prepared release
script: 'dist/server.js', // or 'src/app.js'
instances: 2, // example only; size against CPU and per-worker RSS
exec_mode: 'cluster',
listen_timeout: 10000,
kill_timeout: 10000,
max_memory_restart: '512M', // example threshold; PM2 checks periodically, not a hard memory cap
env: {
NODE_ENV: 'production',
PORT: 3000
}
}]
};
pm2 start ecosystem.config.js
pm2 save # persist the process list
pm2 startup # generate the boot-startup hook (run once, follow instructions)PM2 normally considers an HTTP worker ready after it begins listening. If the app binds before its dependencies are ready, configure wait_ready and have the app send PM2's ready signal only after initialization; also handle shutdown signals so in-flight requests can drain. Size workers for memory as well as CPU; reload behavior depends on readiness and graceful shutdown.
max_memory_restart is a restart threshold, not an OS memory limit; PM2 checks memory periodically. --max-old-space-size only limits V8's old-space heap, not total process RSS or native buffers.
Option B: systemd (the lean Linux-native choice)
For a single service, systemd is a reasonable Linux-native option: it is already present on common distributions, integrates with journald, and avoids an additional process-manager dependency. PM2 may be preferable when you need its Node cluster and reload features. Reliability depends on configuration and operational fit, not a universal ranking.
# /etc/systemd/system/myapp.service
[Unit]
Description=MyApp Node.js service
After=network.target
[Service]
Type=simple
User=deploy
WorkingDirectory=/srv/myapp/current
# Replace 24.21.0 with the exact Node patch pinned in .nvmrc.
ExecStart=/home/deploy/.nvm/versions/node/v24.21.0/bin/node /srv/myapp/current/dist/server.js
Restart=always
RestartSec=5
Environment=NODE_ENV=production
Environment=PORT=3000
EnvironmentFile=/etc/myapp/myapp.env
[Install]
WantedBy=multi-user.targetsudo systemctl enable --now myappPM2 vs systemd — comparison
Factor | PM2 | systemd |
|---|---|---|
Cluster mode / multiple workers | Built-in, config-file-driven | Not built in — run the app behind its own cluster code, or run multiple units |
Rolling reload | Supported in cluster mode; readiness and graceful shutdown still matter | Roll-your-own (blue/green or multiple units behind a proxy) |
Runtime deps | Installs globally via npm | None — part of the OS |
Log surface | Own logs ( | Native journald ( |
Single-service boxes | Great; Node-developer-friendly | Great; sysadmin-friendly |
Multi-service / shared boxes | Fine | Excellent — consistent with everything else on the box |
Practical guidance: either works. If you value Node-oriented cluster management and rolling reloads, choose PM2. If you want a native service manager integrated with journald, choose systemd and design your own rolling/blue-green deployment if needed. Do not run the same Node app as both a direct systemd service and a PM2-managed app. Using the systemd startup unit generated by pm2 startup to launch PM2 at boot is normal.
Step 5: Put Nginx in Front
In this example, Nginx handles public traffic. Keep the app port private—bind to loopback where practical or firewall it so only the edge proxy can reach it. A managed load balancer or another reverse proxy can fill the same role; Nginx is not the only valid edge layer. Compression must be enabled explicitly, as shown below.
# Add this map inside nginx's http {} context, not inside a server {} block.
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
listen 443 ssl http2;
server_name example.com;
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
client_max_body_size 10M;
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types text/plain text/css application/json application/javascript application/xml image/svg+xml;
location / {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
# forward the real client identity — critical for auth/rate limiting
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;
# WebSocket support (Socket.io, local dev of next HMR, etc.)
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
}
# serve static assets from disk — never ship this through Node
location /static/ {
alias /srv/myapp/current/public/;
expires 30d;
add_header Cache-Control "public, immutable";
}
}
server {
listen 80;
server_name example.com;
return 301 https://$host$request_uri;
}In Express, trust only the actual proxy chain. app.set('trust proxy', 1) is appropriate when exactly one trusted proxy (this Nginx instance) sits in front; adjust the hop count or trust specific proxy addresses if a CDN/load balancer is also involved. Keep the Node port private so clients cannot bypass the proxy and supply trusted headers directly.
TLS via Let's Encrypt:
sudo apt install certbot python3-certbot-nginx
sudo certbot --nginx -d example.comStep 6: Deploy Releases with Minimal Interruption
On a PM2 cluster, restart can stop all workers together. reload is designed to replace cluster workers one at a time, but it is not an unconditional guarantee: PM2 can fall back to a restart if new workers do not become ready, and long-lived connections may still be interrupted. See the PM2 reload documentation and configure readiness/shutdown handling for your app.
The standard happy path on a PM2/VPS box:
# BUILD_ID should be a unique commit SHA or CI build ID.
: "${BUILD_ID:?Set BUILD_ID to a unique build ID}"
RELEASE_DIR="/srv/myapp/releases/${BUILD_ID}"
mkdir -p "$RELEASE_DIR"
# Copy/unpack an immutable CI artifact here; do not git-pull into the live directory.
cd "$RELEASE_DIR"
npm ci --omit=dev # CI artifact already contains built output
# Atomically switch the stable path used as PM2's cwd.
ln -sfn "$RELEASE_DIR" /srv/myapp/current.next
mv -Tf /srv/myapp/current.next /srv/myapp/current
pm2 reload myapp --update-env
curl --fail --silent http://127.0.0.1:3000/_healthConfigure the ecosystem file with cwd: '/srv/myapp/current'; keep secrets outside the release directory and inject them via the process manager/secret store, or symlink a protected env file if the app expects one. If the VPS builds the app instead of receiving a CI artifact, build inside the new release with npm ci --include=dev && npm run build, then run npm prune --omit=dev before switching. Retain the previous release; if the smoke test fails, atomically point current back to it, reload workers, and verify recovery. Add a /_health endpoint that returns readiness plus a build identifier and smoke-test it after each switch.
⚠️ The reload nuance: PM2's rolling reload applies to cluster mode. For slow-starting apps, configure
wait_readyandlisten_timeoutand send PM2's ready signal only after dependencies are ready. For WebSockets or other long-lived connections, plan reconnect/drain behavior; a rolling worker reload does not preserve every connection or in-memory session.
With systemd, a single service restart is not a built-in rolling deploy. For blue-green deployment, start a second release on another port, health-check it, then switch Nginx's upstream and retain the previous release for rollback. Kubernetes is another option, but is not required for this pattern.
The Failure Modes Worth Knowing in Advance
Process dies on reboot — you ran
pm2 startbut neverpm2 save && pm2 startup, or the systemd unit lacks[Install]. One line today saves a "site is down" call at 6 a.m.Port already in use — two services bound to 3000. Have one port per service and keep a convention (
8001,8002, …).Out of memory / OOM-killer — Node's V8 heap limit does not cap total RSS, and PM2's memory threshold is checked periodically rather than enforced by the kernel. Set host/container memory limits, monitor RSS, and use restarts as a safety net—not a substitute for sizing or leak fixes.
Cookie/session failures due to proxy configuration — make sure the app receives the correct original protocol and client IP. Configure Express's trusted proxies for the actual chain; don't blindly trust forwarded headers.
Sensitive data in logs — avoid logging authorization or cookie headers in either the app or proxy, and rotate certificates on a schedule you've tested.
Hardening That Takes Minutes
fail2banon SSH and Nginx — low-cost brute force dampening.AppArmor (on Ubuntu) for the Node process — kernel-level restriction.
App-level rate limiting (e.g.,
express-rate-limit) — configure the proxy trust boundary correctly so middleware sees a trustworthy client IP; restrict direct access to the app port.Daily automated backups — your VPS provider's snapshot feature plus an offline off-box copy for the database. Automated backup rotation you've actually tested a restore from.
Actionable Checklist
SSH keys only; password auth disabled; non-root deploy user
Firewall wide-open only on 22/80/443;
unattended-upgradesonExact patched Node runtime pinned in
.nvmrcand matched in CI/systemd; recheck LTS status before rolloutLockfile committed; build uses required devDependencies in CI or a release directory; production artifact includes only needed runtime deps; no
.envin the repoChoose one app supervisor: PM2 or a direct systemd unit; PM2's systemd startup integration is expected when using PM2
Test boot persistence with a real reboot (
pm2 save && pm2 startuporsystemctl enablefor a direct systemd service)Edge proxy/load balancer: HTTPS termination, forwarded headers, WebSocket support if needed, and static assets where appropriate
Express proxy trust matches the actual trusted chain; app port is not publicly reachable
Deploy immutable releases, atomically switch the active release, use PM2 cluster reload with readiness handling, health-check, and rehearse rollback
Set host/container memory limits and monitor RSS; treat PM2 memory restart and V8 heap limits as separate safeguards
Logs: pm2-logrotate or journald; alerts on exit loops, memory slope, 5xx rate
Backups: DB dumps off-box daily + one restore actually practiced
The Bottom Line
The VPS deployment story in 2026 has a small, learnable core: pin the runtime, supervise the process, terminate TLS at a suitable edge layer, and deploy immutable releases with tested reload and rollback behavior. These operational basics matter more than the process-manager brand—and make the difference between an app that starts and one that stays available.
Everything else — Docker-first everything, Kubernetes, blue-green, serverless — is a version of these four principles in a slightly different costume. Master them on a single VPS box and every step-up platform becomes an incremental add-on, not a mystery.
The Layer Under Your Node App — Done Properly
SysopHost VPS gives you full root access for runtime pinning, dedicated CPU/RAM, NVMe storage, KVM isolation, and optional 24/7 monitoring by real engineers — the exact substrate this guide relies on.
Get Your Node.js VPSComments
No comments yet. Be the first to comment!
Rohan Verma