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 bashScript 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
.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.