
You open your site and get a plain white page: 502 Bad Gateway, with "nginx" underneath, often followed by a version number. The Node.js app behind it worked an hour ago, and PM2 should keep it alive. The page itself proves nginx is running, because nginx wrote it. It passed the request to the address in your proxy_pass line and did not get a valid response back. This guide finds out why, cheapest check first.
Quick answer: Run sudo tail -n 30 /var/log/nginx/error.log. connect() failed (111: Connection refused) means nothing listens on the address after upstream:, so check pm2 status and pm2 logs, then sudo ss -ltnp to see where the app really listens. An app started on localhost can end up on ::1 while nginx connects to 127.0.0.1. upstream prematurely closed connection usually means the app crashed or restarted mid-request. 502s that only appear during deploys come from pm2 restart, or from pm2 reload in fork mode, which PM2 turns into a restart. A 504 is a different problem: nginx waited 60 seconds by default and got no answer.
The commands are for Ubuntu 24.04 and Debian 12 with the distribution's nginx package; reading nginx's logs and configuration needs sudo. On a Space-Node VPS you get full root access, so all of them work as written.
What 502 Means Behind proxy_pass
RFC 9110 uses 502 Bad Gateway when a proxy "received an invalid response from an inbound server", and 504 when it "did not receive a timely response". nginx's source follows that split: a timeout becomes a 504, and almost every other upstream failure becomes a 502, from a refused connection to oversized response headers. The error log tells you which one it was.
Step 1: Read the nginx Error Log
sudo tail -n 30 /var/log/nginx/error.log
The Ubuntu and Debian packages write errors to this file, and their logrotate rule creates the log files with mode 0640 for www-data and the adm group, hence sudo. If a server block sets its own error_log, read that file instead; sudo nginx -T | grep error_log lists them. A typical line (the client is a documentation address):
2026/10/29 09:41:07 [error] 2187#2187: *41 connect() failed (111: Connection refused) while connecting to upstream, client: 203.0.113.24, server: app.example.com, request: "GET / HTTP/1.1", upstream: "http://127.0.0.1:3000/", host: "app.example.com"
upstream: is the exact address nginx tried, and server: names the server block that handled the request. If either looks wrong, you have found the problem. Socket permission errors are logged as [crit], not [error], so filter for both:
sudo grep -E '\[(error|crit)\]' /var/log/nginx/error.log | tail -n 20
| Message | Meaning | Go to |
|---|---|---|
connect() failed (111: Connection refused) | Nothing listens on the upstream: address | Steps 2 to 4 |
upstream prematurely closed connection | The app closed the connection before a full response header | Step 2 |
recv() failed (104: Connection reset by peer) | The app reset the connection mid-request | Step 2 |
no live upstreams | nginx had no usable address left and did not try | Below |
connect() to unix:... failed (13: Permission denied) | nginx may not use the socket file | Step 5 |
upstream sent too big header | The response headers do not fit nginx's buffer | Step 6 |
upstream timed out (110: Connection timed out) | That request got a 504 | 502 or 504 |
About no live upstreams: one failed attempt marks an address unavailable for 10 seconds by default (max_fails=1, fail_timeout=10s). Once every address is marked, nginx returns 502 without trying. A single server is never marked, so this means an upstream block with several servers or a host name with several addresses (Step 4). The real error is in the earlier lines.
Step 2: Check PM2 for Crashes and Restarts
Run these as the user that started the app: PM2 keeps its process list and logs in that user's ~/.pm2, so root or another account sees a different list.
pm2 status
pm2 logs my-app --err --lines 100 --nostream
pm2 show my-app
--nostream prints the lines and exits instead of following the log. pm2 show lists restarts, unstable restarts, exec mode and the log file paths.
- online, restarts climbing: the app crashes and PM2 brings it back, with a short burst of 502s each time. Find the stack trace from the same minute as the nginx errors.
- errored: PM2 gave up, by default after 16 unstable restarts in a row (less than 1 second of uptime, or less than
min_uptimeif you set it). Fix the error, then runpm2 restart my-app.
Our PM2 guide for Discord bots shows an ecosystem file with restart settings. Three causes are easy to miss:
Memory limit. With max_memory_restart, PM2 checks memory every 30 seconds and restarts an app that is over the limit. PM2 calls it a reload, but outside cluster mode it falls back to a restart, so each one is another burst of 502s. Find them with grep max-memory-restart ~/.pm2/pm2.log.
The kernel's OOM killer. When the server runs out of memory, the kernel kills a process and logs "Killed process". Search the current boot with sudo journalctl -k | grep -i 'killed process'. If your app is there, lower its memory use, run fewer instances, or move it to a VPS with more RAM.
EADDRINUSE. Another process holds the port, for example a copy of the app you started by hand. Step 3 shows which.
Step 3: See Where the App Really Listens
sudo ss -ltnp | grep ':3000'
-l shows listening sockets, -t TCP, -n numeric ports and -p the process. Without sudo, ss only names your own user's processes. Read the Local Address column:
127.0.0.1:3000: IPv4 loopback only.[::1]:3000: IPv6 loopback only.*:3000: all addresses. Node does this forlisten(3000)without a host: it listens on::, which on most systems also accepts IPv4.- no line: nothing listens on 3000. Run
sudo ss -ltnpwithout the grep to find the app's real port.
Then ask the app directly, on the exact address from upstream::
curl -I http://127.0.0.1:3000/
curl -I 'http://[::1]:3000/'
Any status line, even a 404, means the app answers there. curl: (7) Failed to connect is what nginx ran into.
Two port mix-ups: if the app calls app.listen(process.env.PORT) and PORT is not set under PM2, Node treats the undefined port as a missing one and the operating system picks an arbitrary free port. And if you changed PORT, PM2 keeps the old environment until you pass --update-env:
PORT=3000 pm2 restart my-app --update-env
Step 4: localhost, 127.0.0.1 and ::1
localhost is a name, and it can have two addresses. cloud-init's hosts template for the Debian family, Ubuntu included, maps it to both 127.0.0.1 and ::1, and on the Ubuntu 24.04 server we checked, getent ahosts localhost listed ::1 first. Check yours with grep -w localhost /etc/hosts and getent ahosts localhost.
Node: app.listen(3000, 'localhost') (Express's app.listen() is identical to Node's server.listen()) resolves the name with dns.lookup() and listens on the first usable address only. Since Node 17, dns.lookup() keeps the resolver's order instead of putting IPv4 first. If ::1 comes first, the app listens on [::1]:3000 and nginx's connections to 127.0.0.1:3000 are refused.
nginx: proxy_pass http://localhost:3000; is resolved when nginx loads its configuration, and nginx then uses all the addresses round-robin. If the app listens on only one, nginx logs a refused connection, passes the request to the next address and skips the failed one for 10 seconds. The site works while the log fills up; with the app down you get no live upstreams.
Use one literal address on both sides:
app.listen(3000, '127.0.0.1');
proxy_pass http://127.0.0.1:3000;
It also keeps port 3000 off the internet: without a host, Node listens on every address, reachable from outside unless a firewall blocks it.
Step 5: Permission Denied on a Unix Socket
With a Unix socket (proxy_pass http://unix:/run/myapp/app.sock;), nginx logs this error at [crit] level:
connect() to unix:/run/myapp/app.sock failed (13: Permission denied) while connecting to upstream
Linux requires write permission on the socket file and search permission on every directory in its path. The packaged nginx.conf runs the workers as www-data, so test as that user:
namei -l /run/myapp/app.sock
sudo -u www-data test -w /run/myapp/app.sock && echo writable || echo "not writable"
The quick fix is Node's writableAll: true listen option, which makes the socket writable for every user; a group that www-data belongs to is the tidier one. (2: No such file or directory) means the file is missing, and (111: Connection refused) means nothing listens on it. After a crash Node leaves the socket file behind, and the next start fails with EADDRINUSE until you delete it.
Step 6: upstream sent too big header
nginx reads the response status line and headers into proxy_buffer_size, one memory page (4K or 8K) by default. Headers that don't fit, such as a large Set-Cookie, make the response invalid, and nginx returns 502. Raise both buffer settings in the server or location block:
proxy_buffer_size 16k;
proxy_buffers 8 16k;
Change both lines, not only the first. On a server with 4K memory pages (getconf PAGESIZE prints 4096), proxy_buffer_size 16k; on its own fails nginx -t: nginx sets proxy_busy_buffers_size to twice the larger buffer size, 32k, and rejects anything above all proxy_buffers minus one buffer, which is 28k with the default 8 4k.
Step 7: Stop 502s During Deploys
pm2 restart stops the process (SIGINT, then SIGKILL after kill_timeout, 1,600 ms by default) and then starts a new one, which needs time before it listens. With one address in proxy_pass, nginx has nowhere else to go, so requests in that gap get a 502.
pm2 reload only avoids this in cluster mode: PM2's code falls back to a plain restart for any app that is not online in cluster mode, including the default fork mode. In cluster mode the Node cluster primary keeps the port open. PM2 starts a new worker, waits until it listens (or calls process.send('ready'), if you set wait_ready), and then removes the old one.
// ecosystem.config.js
module.exports = {
apps: [{
name: 'my-app',
script: './server.js',
exec_mode: 'cluster',
instances: 2,
listen_timeout: 10000,
env: { NODE_ENV: 'production', PORT: 3000 }
}]
};
After listen_timeout (3,000 ms by default) PM2 removes the old worker even if the new one is not listening yet, so raise it if your app connects to a database before listen(). PM2 also wants cluster apps stateless: sessions belong in Redis or a database, and every instance is a full copy of the app.
Switch over once, with one last short gap, then deploy with pm2 reload my-app:
pm2 delete my-app
pm2 start ecosystem.config.js
pm2 save
New to PM2? Our Node.js production guide with PM2 covers the ecosystem file and pm2 startup.
502 or 504: Fix the Right Thing
A 504 Gateway Time-out logs upstream timed out (110: Connection timed out), while reading the response header or while connecting. The limits are proxy_read_timeout and proxy_connect_timeout, 60 seconds each by default, and the read timeout counts between two reads, not for the whole response. The app is up but slow, so time the request directly:
curl -s -o /dev/null -w '%{http_code} %{time_total}s\n' http://127.0.0.1:3000/reports/export
If a route really needs longer, raise proxy_read_timeout in its location only. For WebSockets, nginx closes the connection when the app sends nothing for 60 seconds, unless you raise proxy_read_timeout or the app sends ping frames. Higher timeouts never fix a 502.
A Working Server Block
# /etc/nginx/sites-available/myapp
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
listen 80;
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-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
}
}
sudo ln -s /etc/nginx/sites-available/myapp /etc/nginx/sites-enabled/myapp
sudo nginx -t && sudo nginx -s reload
proxy_http_version 1.1is the default only since nginx 1.29.7. Ubuntu 24.04 ships 1.24.0 and Debian 12 ships 1.22.1, where the default is still 1.0, so keep the line.- The
mapis from nginx's WebSocket guide:Connection: upgradewhen the browser asks for it,closeotherwise. Define it once across your site files. listen [::]:80is a separate line because nginx's IPv6 wildcard socket accepts only IPv6 by default.
For HTTPS, see our guide to nginx as a reverse proxy with SSL.
If It Still Fails
Check what nginx really loaded: sudo nginx -T 2>/dev/null | grep -nE 'server_name|listen|proxy_pass' reveals a second server block with the same name or an old proxy_pass. Then reload the page while sudo tail -f /var/log/nginx/error.log runs in one terminal and pm2 logs my-app in another. If the app logs every request but shows nothing at that second, the request never reached it: back to Steps 3 and 4.
FAQ
Should proxy_pass use localhost or 127.0.0.1?
127.0.0.1, or whichever literal address your app binds to. With a name, nginx uses every address the name resolves to, and localhost can mean both 127.0.0.1 and ::1.
Will a higher proxy_read_timeout fix a 502? No. nginx only turns timeouts into a 504. A 502 is an error such as a refused or dropped connection, which happens long before a timeout runs out.
Do I need to restart nginx once the app is back?
No. nginx never marks a single proxy_pass address unavailable, so the next request goes through. Reload nginx only after changing its configuration.
Why does my site show a 502 after the VPS rebooted?
Usually PM2 is not set up to start at boot, or it starts with an empty process list. Run pm2 startup without sudo, run the command it prints, start your apps, then run pm2 save so PM2 restores them after the next reboot.
The 502 page has Cloudflare branding. Is it the same problem? Most likely. Cloudflare's docs say a Cloudflare-branded 502 or 504 means your origin server responded with one, so start with your nginx error log. Cloudflare's own 502 is a blank page without that branding, with "cloudflare" under the heading where nginx's page says "nginx". Error 521 instead of 502 means the server refused Cloudflare's connection before nginx could answer; our Cloudflare 521 guide covers that.
Sources
All opened on 29 October 2026:
- nginx documentation: ngx_http_proxy_module, ngx_http_upstream_module, ngx_http_core_module, core functionality, Beginner's Guide, WebSocket proxying and command-line parameters
- nginx source code: ngx_http_upstream.c, ngx_http_upstream_round_robin.c, ngx_event_connect.c, ngx_http_proxy_module.c, ngx_http_special_response.c, ngx_http_request.c, ngx_log.c, ngx_times.c, ngx_inet.c and ngx_recv.c
- Ubuntu 24.04 nginx package: published versions, nginx.conf and logrotate file
- Debian 12 nginx package: package page, nginx-common file list, nginx.conf and logrotate file
- IETF: RFC 9110, sections 15.6.3 and 15.6.5
- Node.js documentation: net, dns, cluster and errors; Node.js source: lib/net.js
- Express 5 API: app.listen()
- PM2 documentation: Process Management, Application Declaration, Restart Strategies, Memory Limit, Cluster Mode, Graceful Start/Shutdown, Log Management, Startup Script and Specifics
- PM2 source code: lib/God/Reload.js, lib/Worker.js, constants.js and lib/API/UX/pm2-describe.js
- Linux man pages: ss(8), connect(2), unix(7), getent(1), namei(1) and journalctl(1); Linux kernel source: mm/oom_kill.c
- curl: curl man page
- cloud-init: hosts.debian.tmpl and
cloudinit/distros/__init__.py - Cloudflare docs: Error 502 or 504