No Docker, no PaaS, no magic. Node.js plus systemd and Nginx is a stack that survives reboots, crashes and traffic — and you can read all of it.
apt update && apt install -y curl
curl -fsSL https://deb.nodesource.com/setup_22.x | bash -
apt install -y nodejs
node --version
Never run a web app as root. A separate user limits the damage if the app is ever compromised.
adduser --system --group --home /opt/myapp app
mkdir -p /opt/myapp
chown app:app /opt/myapp
From your laptop, with rsync over SSH — no Git server required:
rsync -avz --exclude node_modules ./app/ root@YOUR_SERVER_IP:/opt/myapp/
On the server, install dependencies and test once in the foreground:
cd /opt/myapp
sudo -u app npm ci --omit=dev
sudo -u app PORT=3000 node server.js
The app must listen on 127.0.0.1:3000 — not on the public interface. Nginx will face the internet.
cat > /etc/systemd/system/myapp.service <<'EOF'
[Unit]
Description=My Node.js app
After=network-online.target
[Service]
User=app
WorkingDirectory=/opt/myapp
Environment=NODE_ENV=production
Environment=PORT=3000
ExecStart=/usr/bin/node server.js
Restart=always
RestartSec=3
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable --now myapp
systemctl status myapp
Crash? It restarts in three seconds. Reboot? It starts automatically. Deploy a new version? rsync, then systemctl restart myapp.
apt install -y nginx
cat > /etc/nginx/sites-available/myapp <<'EOF'
server {
listen 80;
server_name example.com;
location / {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
EOF
ln -s /etc/nginx/sites-available/myapp /etc/nginx/sites-enabled/myapp
nginx -t && systemctl reload nginx
Then point your domain's A record at the server and add HTTPS — the full walkthrough is in the Nginx and HTTPS guide.
journalctl -u myapp -f --no-pager
tail -f /var/log/nginx/error.log
Console output from your app lands in journald automatically — no log files to configure.
Not really. systemd does everything PM2 does for a single server — auto-restart, boot start, logs — without an extra dependency. PM2 makes sense mainly if you run several Node processes with clustering.
A typical Express app idles around 80–150 MB. A 2 GB server runs it comfortably next to Nginx and PostgreSQL; build steps like webpack are the memory-hungry part, so build in CI or on your laptop.
rsync the new code, then restart — the gap is under a second, because systemd restarts the process immediately. For zero-downtime you would run two ports and switch Nginx upstream, but most projects never need it.
This stack fits comfortably in Buran-1 at €6.5/mo.