Running node app.js in a terminal is enough to keep an application alive for a demo. In production, that command leaves the service unsupervised: the slightest crash stops it, a server reboot forgets it, and nothing handles encryption or load. Moving from a hand-started process to a reliable service comes down to two proven building blocks, a system supervisor and a reverse proxy. Here is a minimal, dependency-light foundation for putting a Node.js application online cleanly.
Why not run Node directly
A process launched from the keyboard inherits the session that started it. It does not survive an SSH disconnect, does not restart after a crash, does not come back after a reboot, and often listens on a high port exposed as-is. The question is not whether the process will fall over, but when, and what happens next.
| Approach | Auto restart | Survives reboot | Dependency |
|---|---|---|---|
node app.js | No | No | None |
| Application manager (PM2) | Yes | Via script | Global npm package |
| systemd service | Yes | Native | Already on the distribution |
systemd ships by default on most server distributions. It can supervise a process, restart it according to a defined policy, attach it to boot and centralise its logs. Better to lean on it than to stack an extra tool, as the notes on controlled self-hosting already pointed out.
A systemd service to supervise the process
First rule: the application never runs as root. A dedicated system user, with no login shell, limits the attack surface if it is ever compromised.
sudo useradd --system --home /srv/monapp --shell /usr/sbin/nologin monapp
sudo chown -R monapp:monapp /srv/monappThe service is described in a unit file placed under /etc/systemd/system/. Sensitive variables stay out of the repository, loaded from a restricted environment file.
[Unit]
Description=Node application monapp
After=network.target
[Service]
Type=simple
User=monapp
Group=monapp
WorkingDirectory=/srv/monapp
EnvironmentFile=/srv/monapp/.env
Environment=NODE_ENV=production
ExecStart=/usr/bin/node /srv/monapp/server.js
Restart=on-failure
RestartSec=2
# Hardening
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/srv/monapp/tmp
[Install]
WantedBy=multi-user.targetsudo systemctl daemon-reload
sudo systemctl enable --now monapp
sudo systemctl status monapp
journalctl -u monapp -fLogs go to journalctl, timestamped and rotated by the system, with no manual redirection to a file. The Restart=on-failure policy relaunches the service on an abnormal exit, and RestartSec avoids an overly aggressive restart loop.
Nginx as a reverse proxy
The application listens locally, on 127.0.0.1:3000, and is never exposed directly to the internet. Nginx sits in front: it terminates TLS, serves static files, applies compression and forwards the rest to Node.
server {
listen 80;
server_name monapp.example.com;
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;
# WebSocket support
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
gzip on;
gzip_types text/plain text/css application/javascript application/json;
}The certificate is obtained and renewed with Certbot, which switches the configuration to HTTPS automatically.
sudo certbot --nginx -d monapp.example.comOnce the proxy is in place, only port 443 stays open to the public; the application port never leaves the local loopback. This isolation goes hand in hand with regular backups, whose principle is detailed in the guide on automated web-server backups.
Zero-downtime deployment
A plain systemctl restart takes the service down for a moment. To avoid that window of unavailability, two instances run in parallel behind an upstream, and the restart happens one at a time.
upstream monapp {
server 127.0.0.1:3000 max_fails=1 fail_timeout=5s;
server 127.0.0.1:3001 max_fails=1 fail_timeout=5s;
}The application still has to shut down cleanly: it must stop accepting new connections, finish the requests in flight, then exit. That is the job of a shutdown handler on SIGTERM, the signal systemd sends.
const server = app.listen(process.env.PORT || 3000);
process.on("SIGTERM", () => {
server.close(() => {
// connections drained, release resources
process.exit(0);
});
// safety net if a socket stays stuck
setTimeout(() => process.exit(1), 10000).unref();
});The two instances come from a template unit: a [email protected] file reuses the previous unit but replaces the environment line with Environment=PORT=%i, where %i is the number passed after the at sign. The rolling restart then relaunches each instance one after the other, leaving Nginx to route traffic to whichever stays available.
sudo systemctl enable --now monapp@3000 monapp@3001
# zero-downtime update
sudo systemctl restart monapp@3000
sleep 5
sudo systemctl restart monapp@3001In production, the real question is not whether the process will fall over, but what happens the second it does.
Worth watching. Three mistakes come up again and again: running the application as root, forgetting NODE_ENV=production, and committing secrets to the repository. The first opens the door to privilege escalation, the second disables optimisations and leaves error messages too talkative, the third exposes keys on the very first git push. An environment file with 600 permissions, owned by the application user, settles the last point.
Checks before going live
| Check | Expected |
|---|---|
| Run-as user | Dedicated system account, never root |
| Environment variable | NODE_ENV=production |
| Restart policy | Restart=on-failure active |
| Encryption | TLS via Certbot, automatic renewal |
| Application port | Bound to 127.0.0.1, never public |
| Health endpoint | A /health route for monitoring |
| Firewall | Only 80 and 443 open |
The takeaway
A robust Node.js deployment needs no heavy tooling: a systemd service for supervision, an Nginx reverse proxy for TLS and routing, a dedicated user for isolation, and a clean shutdown on SIGTERM for updates without downtime. This foundation runs on any recent server and stays readable six months later, when someone has to pick it up again. It works just as well for an API as for a server-rendered application, like those built on recent versions of Node.js.
I used PM2 for a long time, out of habit, before coming back to systemd on my own servers. The switch saved me one dependency to maintain and gave me logs finally unified with the rest of the machine. My one systematic addition today is a trivial /health route that checks database access: it is what warns me of an incident before the client does. — Simon Janvier
Further reading
Reference for service directives on the primary source: systemd.service documentation.
