Linux for Web Developers: Deploying a Node.js App with Nginx

Advanced
16 min

Linux for Web Developers: Deploying a Node.js App with Nginx

Everything so far — users, permissions, services, firewalls, logs — exists so you can run software for other people. This lesson applies it to the most common web-developer task: taking a Node.js application from npm start on a laptop to a domain name served over HTTPS. The architecture is the standard one you will meet at almost every company: Nginx on ports 80 and 443 as a reverse proxy, the app on a local port as a systemd service, and Let's Encrypt certificates renewed automatically. The same shape works for Python, Go, Ruby or .NET backends.

The Architecture

bash
Internet -> :443 Nginx (TLS, static files, compression, rate limits) -> 127.0.0.1:3000 Node.js (systemd service, user "app")

Node never listens on a public port. Nginx terminates TLS, serves static assets faster than Node can, and shields the app from malformed requests. The app runs as an unprivileged user and restarts automatically if it crashes.

1. Install Node.js and Nginx

bash
sudo apt update && sudo apt install -y nginx git curl curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash - sudo apt install -y nodejs node -v && npm -v sudo ufw allow 'Nginx Full' # 80 and 443; SSH was allowed earlier

2. Create a Service User and Deploy the Code

bash
sudo useradd --system --create-home --home-dir /srv/app --shell /usr/sbin/nologin app sudo -u app git clone https://example.com/git/orders-api.git /srv/app/current cd /srv/app/current sudo -u app npm ci --omit=dev sudo -u app bash -c 'cat > /srv/app/current/.env' <<'EOF' NODE_ENV=production PORT=3000 DATABASE_URL=postgres://orders:secret@127.0.0.1:5432/orders EOF sudo chmod 600 /srv/app/current/.env

npm ci installs exactly what the lockfile specifies, and --omit=dev skips test and build tooling. Make sure the app reads PORT and binds to 127.0.0.1.

3. Run It as a systemd Service

bash
# /etc/systemd/system/app.service [Unit] Description=Orders API (Node.js) After=network-online.target Wants=network-online.target [Service] Type=simple User=app Group=app WorkingDirectory=/srv/app/current EnvironmentFile=/srv/app/current/.env ExecStart=/usr/bin/node server.js Restart=on-failure RestartSec=5 NoNewPrivileges=true PrivateTmp=true ProtectSystem=strict ReadWritePaths=/srv/app/current/uploads [Install] WantedBy=multi-user.target
bash
sudo systemctl daemon-reload sudo systemctl enable --now app systemctl status app curl -s http://127.0.0.1:3000/health # {"status":"ok"} journalctl -u app -f

4. Nginx as a Reverse Proxy

bash
# /etc/nginx/sites-available/app.example.com server { 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_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; } location /static/ { alias /srv/app/current/public/; expires 30d; access_log off; } client_max_body_size 20m; }
bash
sudo ln -s /etc/nginx/sites-available/app.example.com /etc/nginx/sites-enabled/ sudo rm -f /etc/nginx/sites-enabled/default sudo nginx -t # always test before reloading sudo systemctl reload nginx

The X-Forwarded-* headers let the app know the real client IP and scheme; in Express, set app.set('trust proxy', 1) so req.ip and secure cookies behave.

5. DNS and HTTPS with Let's Encrypt

Point an A record for app.example.com at the server's IP and wait for dig +short app.example.com to return it. Then let certbot obtain a certificate and rewrite the Nginx config to redirect HTTP to HTTPS.

bash
sudo apt install -y certbot python3-certbot-nginx sudo certbot --nginx -d app.example.com --redirect -m ops@example.com --agree-tos -n sudo certbot renew --dry-run systemctl list-timers | grep certbot # renewal timer installed by the package curl -I https://app.example.com

6. Deploying Updates

A repeatable deploy is a script, not a memory. This one pulls, installs, restarts and health-checks.

bash
#!/usr/bin/env bash # /srv/app/deploy.sh — run as: sudo -u app /srv/app/deploy.sh set -Eeuo pipefail cd /srv/app/current git fetch --all git reset --hard origin/main npm ci --omit=dev npm run build --if-present sudo systemctl restart app sleep 2 curl -sf http://127.0.0.1:3000/health > /dev/null && echo "deploy ok" || { echo "health check failed" >&2; exit 1; }

Grant the app user permission for exactly that restart in /etc/sudoers.d/app:

bash
app ALL=(root) NOPASSWD: /usr/bin/systemctl restart app

Common Mistakes

  • Running the app as root or from your home directory, where a later permissions cleanup breaks it.
  • Exposing port 3000 through the firewall "for testing" and forgetting it.
  • Skipping nginx -t and reloading a broken config, which takes every site on the server down.
  • Missing proxy_set_header Host, so the app generates links to 127.0.0.1:3000.
  • Not setting trust proxy, so every request appears to come from 127.0.0.1 and rate limiting is useless.
  • Installing dependencies with a different Node major version than the one in production.
Quick Quiz
Question 1 of 3

Why does Node listen on 127.0.0.1:3000 instead of directly on port 443?

Key Takeaways

  • Standard layout: Nginx on 80/443 as a reverse proxy, the app on 127.0.0.1:3000 as a systemd service under its own user.
  • Install Node from NodeSource or nvm, deploy with npm ci --omit=dev, keep secrets in a 600 .env loaded by EnvironmentFile=.
  • Forward Host, X-Forwarded-For, X-Forwarded-Proto and the WebSocket headers; enable trust proxy in the app.
  • certbot --nginx provides HTTPS with automatic renewal; verify with certbot renew --dry-run.
  • Deploy with a script that installs, restarts and health-checks; always nginx -t before reload.

Next lesson: Capstone: Set Up a Production Server from Scratch — build a complete, hardened server end to end using everything in this course.

Linux for Web Developers: Deploying a Node.js App with Nginx - Linux & Command Line | CodeYourCraft | CodeYourCraft