Deploy a Node.js application with systemd and Nginx
Run a Node.js service without a process-manager dependency, proxy it through Nginx, deploy releases atomically, and roll back quickly.

You do not need a container platform or a JavaScript-specific process manager to run one Node.js application. systemd handles lifecycle and logs; Nginx handles public traffic and TLS.
Use a release layout
/opt/myapp/
releases/20260702-120000/
shared/.env
current -> releases/20260702-120000
Each deploy creates a new immutable release. The current symlink selects which one runs.
Create a service account:
sudo useradd --system --create-home --home-dir /opt/myapp --shell /usr/sbin/nologin myapp
sudo install -d -o myapp -g myapp /opt/myapp/releases /opt/myapp/shared
sudo touch /opt/myapp/shared/.env
sudo chown myapp:myapp /opt/myapp/shared/.env
sudo chmod 600 /opt/myapp/shared/.env
Build a release
Build in CI or on the VPS. A typical server-side sequence is:
release=/opt/myapp/releases/$(date +%Y%m%d-%H%M%S)
sudo -u myapp git clone --depth 1 https://example.com/myapp.git "$release"
cd "$release"
sudo -u myapp npm ci
sudo -u myapp npm run build
sudo -u myapp ln -s /opt/myapp/shared/.env .env
sudo ln -sfn "$release" /opt/myapp/current
Do not run the application as root and do not use npm install in production; npm ci follows the lockfile exactly.
Create the systemd unit
/etc/systemd/system/myapp.service:
[Unit]
Description=My Node.js application
After=network.target
[Service]
Type=simple
User=myapp
Group=myapp
WorkingDirectory=/opt/myapp/current
EnvironmentFile=/opt/myapp/shared/.env
ExecStart=/usr/bin/node server.js
Restart=on-failure
RestartSec=3
TimeoutStopSec=20
KillSignal=SIGTERM
NoNewPrivileges=true
PrivateTmp=true
[Install]
WantedBy=multi-user.target
Enable and inspect it:
sudo systemctl daemon-reload
sudo systemctl enable --now myapp
sudo systemctl status myapp
sudo journalctl -u myapp -n 100 --no-pager
Make the app listen on 127.0.0.1:3000 and implement a /health endpoint.
Proxy through Nginx
server {
listen 80;
listen [::]:80;
server_name app.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;
proxy_read_timeout 60s;
}
}
Validate with sudo nginx -t, reload Nginx, then add HTTPS.
Deploy and roll back
After switching current, restart and test locally:
sudo systemctl restart myapp
curl --fail http://127.0.0.1:3000/health
If it fails, repoint current to the previous release and restart. Keep the last three releases and remove older ones only after the new release has served real traffic.
Add a controlled deploy script
Put the repeated sequence in /usr/local/sbin/deploy-myapp so a human or CI runner follows the same path:
#!/usr/bin/env bash
set -euo pipefail
release="/opt/myapp/releases/$1"
test -x "$release/server.js" || { echo "release is incomplete" >&2; exit 1; }
ln -sfn "$release" /opt/myapp/current
systemctl restart myapp
for attempt in 1 2 3 4 5; do
curl --fail --silent http://127.0.0.1:3000/health >/dev/null && exit 0
sleep 2
done
systemctl status myapp --no-pager
exit 1
Run it as a restricted deployment user with only the required sudo commands. Do not grant CI an unrestricted root shell.
After DNS points at the server, issue a certificate using your ACME client and redirect HTTP to HTTPS. If the application uses WebSockets, test that path explicitly and configure the required upgrade headers. Set a request-body limit appropriate to uploads and watch journalctl -u myapp and Nginx's error log together during the first deploy.
Verify ownership and file permissions
The service user should be able to read the release and shared environment file, but not rewrite the release after deployment:
namei -l /opt/myapp/current/server.js
sudo -u myapp test -r /opt/myapp/shared/.env
sudo -u myapp test -w /opt/myapp/current && echo "release is writable"
The last command should normally produce no output. Keep uploads and runtime state under explicitly writable directories in shared/; never make the whole release tree writable just to fix a permission error.
Continue reading