Una aplicación Node puede servir HTTP por sí sola. No debería: tendría que terminar el TLS, servir archivos estáticos, sobrevivir a una caída y atarse al puerto 443 como root. Nginx delante hace las cuatro cosas, y a la aplicación solo le queda ser una aplicación.

El proxy

server {
    listen 443 ssl;
    listen [::]:443 ssl;
    server_name app.example.com;

    ssl_certificate     /etc/letsencrypt/live/app.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/app.example.com/privkey.pem;

    location / {
        proxy_pass http://127.0.0.1:3000;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection $connection_upgrade;
        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;
    }
}

map $http_upgrade $connection_upgrade {
    default upgrade;
    ''      close;
}

La pareja Upgrade y Connection es lo que hace funcionar los WebSockets. Sin ellas la conexión se retransmite y luego se suelta, lo que parece una aplicación rota.

Ate Node solo a localhost

app.listen(3000, '127.0.0.1');
Atarse a 0.0.0.0 deja el puerto 3000 alcanzable desde internet, saltándose todas las reglas que escribió en Nginx. Es el error más común en este montaje.

Manténgalo vivo con systemd, no con una terminal

# /etc/systemd/system/app.service
[Unit]
Description=Node app
After=network.target

[Service]
Type=simple
User=app
WorkingDirectory=/var/www/app
EnvironmentFile=/etc/app.env
ExecStart=/usr/bin/node server.js
Restart=always
RestartSec=5

[Install]
WantedBy=multi-user.target
sudo systemctl enable --now app
journalctl -u app -f

Confíe en el proxy, dentro de la aplicación

Detrás de Nginx, la dirección que ve su aplicación es 127.0.0.1 en cada petición. Dígale al framework que lea la cabecera reenviada -en Express, app.set('trust proxy', 1)- o todos los registros y todos los límites de tasa no significan nada.

Sirva los archivos estáticos desde Nginx con un bloque location y no a través de Node. Es más rápido y deja el bucle de eventos libre para el trabajo que solo su código puede hacer.