Who is this guide for?
It is for anyone shipping an Express, Fastify, NestJS or Next.js server to a single Ubuntu 24.04 VPS and wanting it to survive crashes, reboots and deploys. It is not for serverless functions or teams already deploying containers; for those, see Kamal or Docker Compose.
The end state: Node runs as an unprivileged user, bound to 127.0.0.1:3000, supervised and sandboxed by systemd. nginx terminates TLS and proxies to it. Logs go to the journal.
NodeSource or the Ubuntu package?
Use NodeSource for production. Ubuntu 24.04 ships Node 18 in its archive, and the Node 18 line reached end of life in April 2025, so it no longer gets upstream security fixes. NodeSource publishes signed apt repositories for each LTS line and you get patch releases through normal apt upgrade.
curl -fsSL https://deb.nodesource.com/setup_24.x -o /tmp/nodesource_setup.sh
less /tmp/nodesource_setup.sh # read it before running it
sudo bash /tmp/nodesource_setup.sh
sudo apt install -y nodejs
node -v # v24.x
24 is the active LTS line; 22 still gets fixes but is in maintenance. Move to 26 once it is promoted to LTS. Pin one major per server; mixing lines between staging and production causes native module rebuild surprises. The distro package remains fine for a quick script, not for a service exposed to the internet.
Create an app user and directory
sudo adduser --system --group --home /srv/shop --shell /usr/sbin/nologin shop
sudo install -d -o shop -g shop /srv/shop /var/lib/shop
sudo -u shop git clone https://git.example.com/shop.git /srv/shop/app
cd /srv/shop/app && sudo -u shop npm ci --omit=dev
The app user cannot log in and owns only its own tree. Code under /srv/shop/app, writable state under /var/lib/shop. For repeatable releases, pair this with git push deploys.
Write the systemd unit
# /etc/systemd/system/shop.service
[Unit]
Description=shop Node.js app
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=shop
Group=shop
WorkingDirectory=/srv/shop/app
Environment=NODE_ENV=production
Environment=PORT=3000
EnvironmentFile=/etc/shop/shop.env
ExecStart=/usr/bin/node --max-old-space-size=1024 server.js
Restart=on-failure
RestartSec=3
TimeoutStopSec=20
MemoryMax=1536M
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
PrivateDevices=true
ProtectKernelTunables=true
ProtectControlGroups=true
RestrictSUIDSGID=true
ReadWritePaths=/var/lib/shop
[Install]
WantedBy=multi-user.target
What the sandbox lines buy you:
ProtectSystem=strictmounts the whole filesystem read-only for this service.ReadWritePathspunches the one hole the app needs.NoNewPrivilegesstops the process gaining rights through setuid binaries, even if an attacker gets code execution.ProtectHomehides/homeand/root, so SSH keys stay out of reach.
Put secrets in /etc/shop/shop.env as KEY=value lines, owned by root with mode 0600. systemd reads the file as root before dropping privileges, so the app user never needs read access to it.
sudo install -d -m 750 /etc/shop
sudo install -m 600 /dev/null /etc/shop/shop.env
sudo systemctl daemon-reload
sudo systemctl enable --now shop
systemd-analyze security shop # lower score = tighter sandbox
Make sure the app listens on 127.0.0.1, not 0.0.0.0: app.listen(process.env.PORT, '127.0.0.1'). Handle SIGTERM by closing the server and finishing in-flight requests; systemd waits TimeoutStopSec before sending SIGKILL.
Where are the logs?
Anything the app writes to stdout or stderr lands in the systemd journal, tagged with the unit name. You do not need log files or a rotation config. Read them with journalctl, filter by time or priority, and cap total journal size in journald.conf so logs never fill a small disk.
journalctl -u shop -f
journalctl -u shop --since "10 min ago" -p warning
sudo sed -i 's/^#\?SystemMaxUse=.*/SystemMaxUse=500M/' /etc/systemd/journald.conf
sudo systemctl restart systemd-journald
Log JSON lines (pino does this by default) and you can pipe journalctl -o cat into jq.
Put nginx in front
upstream shop {
server 127.0.0.1:3000;
keepalive 16;
}
server {
server_name shop.example.com;
location / {
proxy_pass http://shop;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
Then get a certificate with certbot as covered in nginx and TLS with certbot. In Express, set app.set('trust proxy', 'loopback') so req.ip and req.secure reflect the real client.
How do you restart without dropping requests?
A single process restart leaves a gap of a second or two where nginx gets connection refused. Fix it by running two instances on different ports behind one nginx upstream and restarting them in turn. Socket activation is the other option, but it queues connections rather than serving them during startup.
Turn the unit into a template: rename it shop@.service and change two lines.
Environment=PORT=%i
ExecStart=/usr/bin/node --max-old-space-size=1024 server.js
sudo systemctl enable --now shop@3001 shop@3002
List both ports in the upstream and let nginx retry the other one on failure:
upstream shop {
server 127.0.0.1:3001 max_fails=1 fail_timeout=5s;
server 127.0.0.1:3002 max_fails=1 fail_timeout=5s;
keepalive 16;
}
# inside location /:
proxy_next_upstream error timeout http_502;
A deploy restarts one, waits for its health endpoint, then the other:
for p in 3001 3002; do
sudo systemctl restart shop@$p
until curl -fsS http://127.0.0.1:$p/healthz >/dev/null; do sleep 1; done
done
With socket activation, a shop.socket unit holds port 3000 and passes it to Node as file descriptor 3 (server.listen({ fd: 3 })). The kernel keeps accepting connections while the service restarts, so nobody sees a refusal, but requests wait for boot. That is fine for fast-starting apps, less so for ones that warm caches for ten seconds.
How much heap should Node get?
Set --max-old-space-size to roughly half to two thirds of the RAM you can spare per process, leaving room for buffers, native memory, nginx and the OS. V8's default may not match your plan, and an oversized heap grows until the kernel's OOM killer ends the process.
| Plan | RAM | Suggested layout |
|---|---|---|
| Spark | 2 GB | 2 instances at --max-old-space-size=512, MemoryMax=768M each |
| Boost | 4 GB | 2 instances at 1024, MemoryMax=1536M; room for Redis |
| Turbo | 8 GB | 4 instances at 1024, or 2 at 1536 plus Postgres on the same host |
Keep MemoryMax about 50 percent above the heap flag, since buffers and native add-ons live outside the V8 heap. Watch real usage with systemctl status shop@3001, which prints current memory, and adjust after a week of traffic. Node is single-threaded per process, so an instance per vCPU is a sensible ceiling. For alerting on memory and restarts, see monitoring basics.