Nginx Reverse Proxy for Node.js with a systemd Unit

Run on the server with one command (check the script contents below first):

curl -sSL https://cp.lv/scripts/nginx-reverse-proxy-nodejs.sh | sudo bash

…or with wget:

wget -qO- https://cp.lv/scripts/nginx-reverse-proxy-nodejs.sh | sudo bash

Script contents nginx-reverse-proxy-nodejs.sh:

#!/usr/bin/env bash
# Nginx reverse proxy and a systemd unit for a Node.js app (myapp)
set -euo pipefail

APP_NAME=myapp
APP_DIR=/var/www/myapp
APP_PORT=3000
NODE_BIN=$(command -v node)

# 1. systemd unit (variables are expanded)
sudo tee /etc/systemd/system/${APP_NAME}.service > /dev/null << EOF
[Unit]
Description=Node.js ${APP_NAME}
After=network.target

[Service]
Type=simple
User=www-data
WorkingDirectory=${APP_DIR}
ExecStart=${NODE_BIN} app.js
Restart=on-failure
RestartSec=5
Environment=NODE_ENV=production
Environment=PORT=${APP_PORT}

[Install]
WantedBy=multi-user.target
EOF

sudo systemctl daemon-reload
sudo systemctl enable --now ${APP_NAME}

# 2. Nginx reverse proxy (quoted 'EOF' leaves Nginx variables alone)
sudo tee /etc/nginx/sites-available/${APP_NAME} > /dev/null << 'EOF'
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";
        proxy_read_timeout 60s;
    }
}
EOF

sudo ln -sf /etc/nginx/sites-available/${APP_NAME} /etc/nginx/sites-enabled/${APP_NAME}
sudo nginx -t && sudo systemctl reload nginx
echo "Done: ${APP_NAME} is proxied by Nginx to port ${APP_PORT}."

systemd unit generator

Fill in the fields and get a ready .service file that starts the service automatically.

File myapp.service — save it into /etc/systemd/system/:

Install with a single command:

A Node.js application usually listens on a high port such as 3000, and that port should not be exposed to the internet. The right scheme is Nginx in front of the application as a reverse proxy: it accepts requests on 80/443, serves static files, terminates HTTPS and proxies the dynamic requests to Node.js. Below are the Nginx config and a systemd unit that starts the application automatically.

Why put a reverse proxy in front of Node.js

  • HTTPS is terminated by Nginx, so the application never deals with certificates.
  • Ports 80 and 443 are the standard ones, while Node.js stays on localhost:3000.
  • Static files are served by Nginx directly — faster and cheaper on CPU.
  • Several applications live behind one IP under different domains.

Step 1. Make the app listen on localhost only

Make sure Node.js binds to 127.0.0.1 and not to 0.0.0.0:

app.listen(3000, '127.0.0.1', () => {
  console.log('server on 127.0.0.1:3000');
});

The port is then unreachable from the outside — only through Nginx.

Step 2. A systemd unit for the application

Instead of starting the app by hand, describe it as a systemd service. Open the file in any console editor (nano, vim or mcedit) and paste the contents:

sudo nano /etc/systemd/system/myapp.service

File contents:

[Unit]
Description=Node.js myapp
After=network.target

[Service]
Type=simple
User=www-data
WorkingDirectory=/var/www/myapp
ExecStart=/home/deploy/.nvm/versions/node/v20.11.0/bin/node app.js
Restart=on-failure
RestartSec=5
Environment=NODE_ENV=production
Environment=PORT=3000

[Install]
WantedBy=multi-user.target

Or create the whole file with a single command — copy it and paste it into the console (the heredoc writes the entire file at once):

sudo tee /etc/systemd/system/myapp.service > /dev/null << 'EOF'
[Unit]
Description=Node.js myapp
After=network.target

[Service]
Type=simple
User=www-data
WorkingDirectory=/var/www/myapp
ExecStart=/home/deploy/.nvm/versions/node/v20.11.0/bin/node app.js
Restart=on-failure
RestartSec=5
Environment=NODE_ENV=production
Environment=PORT=3000

[Install]
WantedBy=multi-user.target
EOF

Use the full path to the node binary (find it with which node). Enable and start the service:

sudo systemctl daemon-reload
sudo systemctl enable --now myapp
sudo systemctl status myapp

The application logs now go to journald:

sudo journalctl -u myapp -f

Step 3. The Nginx reverse proxy config

Open the config in an editor (sudo nano /etc/nginx/sites-available/myapp) and paste the contents:

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;

        # WebSocket support
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";

        proxy_read_timeout 60s;
    }
}

Or create the whole config with a single command (the quotes around 'EOF' matter — the Nginx variables $host stay as they are):

sudo tee /etc/nginx/sites-available/myapp > /dev/null << 'EOF'
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";
        proxy_read_timeout 60s;
    }
}
EOF

The X-Forwarded-* headers pass the real client IP and protocol to the application. The Upgrade/Connection block is required for WebSocket (socket.io and similar).

Step 4. Enable the site and add HTTPS

sudo ln -s /etc/nginx/sites-available/myapp /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx

Add a Let’s Encrypt certificate — certbot writes the 443 block and the redirect itself:

sudo certbot --nginx -d app.example.com

Step 5. Serving static files through Nginx

To avoid pushing static files through Node.js, serve them directly. Add this to the server block:

location /static/ {
    alias /var/www/myapp/public/;
    expires 30d;
    access_log off;
}

Troubleshooting

Symptom Cause Check
502 Bad Gateway the application does not answer systemctl status myapp
504 Gateway Timeout Node responds too slowly raise proxy_read_timeout
WebSocket keeps dropping no Upgrade headers add the Upgrade/Connection block
The client sees the server IP no X-Real-IP check proxy_set_header

FAQ

Why does Nginx return 502 while the application is running? Nginx cannot reach the proxy_pass target. Check that the application listens exactly on 127.0.0.1:3000 and that the service is up: journalctl -u myapp.

Do I need pm2 if I already have systemd? No, they are interchangeable ways to keep a process alive. systemd is built into the system and needs no extra packages; pm2 is handier for cluster mode and built-in monitoring. Pick one.

How do I pass the real client IP to the application? Through the X-Real-IP / X-Forwarded-For headers. In the application, enable proxy trust (in Express that is app.set('trust proxy', 1)).

How do I proxy WebSocket connections? Add proxy_http_version 1.1 and the Upgrade / Connection "upgrade" headers to the location block, otherwise the connection keeps breaking.

Nginx plus Node.js under systemd is a solid production foundation. You can pick a server for it on the VPS hosting page, and for container deployments there is Docker VPS.

Kristaps Bērziņš