Skip to content

The publication for web craftspeople Saturday, 5 September 2026

DevOps & servers

Deploy a Node.js application to production: systemd and a reverse proxy

Running node app.js is fine for a demo, not for production. A systemd service and an Nginx reverse proxy make a minimal, reliable foundation for putting a Node.js application online, with automatic restart, TLS and zero-downtime deployment.

Couverture guide deploiement Node.js en production

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.

ApproachAuto restartSurvives rebootDependency
node app.jsNoNoNone
Application manager (PM2)YesVia scriptGlobal npm package
systemd serviceYesNativeAlready 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/monapp

The 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.target
sudo systemctl daemon-reload
sudo systemctl enable --now monapp
sudo systemctl status monapp
journalctl -u monapp -f

Logs 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.com

Once 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@3001

In 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

CheckExpected
Run-as userDedicated system account, never root
Environment variableNODE_ENV=production
Restart policyRestart=on-failure active
EncryptionTLS via Certbot, automatic renewal
Application portBound to 127.0.0.1, never public
Health endpointA /health route for monitoring
FirewallOnly 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.

Read next