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.
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.
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 earliersudo 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/.envnpm 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.
# /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.targetsudo 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# /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;
}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 nginxThe 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.
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.
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.comA repeatable deploy is a script, not a memory. This one pulls, installs, restarts and health-checks.
#!/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:
app ALL=(root) NOPASSWD: /usr/bin/systemctl restart appnginx -t and reloading a broken config, which takes every site on the server down.proxy_set_header Host, so the app generates links to 127.0.0.1:3000.trust proxy, so every request appears to come from 127.0.0.1 and rate limiting is useless.Why does Node listen on 127.0.0.1:3000 instead of directly on port 443?
127.0.0.1:3000 as a systemd service under its own user.nvm, deploy with npm ci --omit=dev, keep secrets in a 600 .env loaded by EnvironmentFile=.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.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.