Nginx Reverse Proxy on a VPS: Setup Guide and Fixing 502/504
If you're running a Node.js, Python, or Docker app on a VPS, you can't just point a domain straight at it. You put Nginx in front — it takes traffic on ports 80 and 443 and quietly forwards it to your app running on some internal port like 3000 or 8000. Get the config right and it's invisible. Get it wrong and you're staring at a 502 or 504 with no idea why.
What a reverse proxy is actually doing here
Your app process (say, a Node app started with node server.js or under PM2) binds to 127.0.0.1:3000 — it only listens on localhost, not the public internet. Nginx sits in front, listens on 80/443 where the world can reach it, and forwards each request to that internal port. This gets you a few things in one shot: a real domain and SSL cert on a plain app server, the ability to run several apps on one VPS behind different domains, and a layer that can handle gzip, caching, and rate limiting before requests ever hit your app.
Basic reverse proxy setup
Install Nginx if it isn't already there:
sudo apt update
sudo apt install nginx -y
Create a server block for your domain:
sudo nano /etc/nginx/sites-available/example.com
Paste this in, swapping in your domain and the port your app actually listens on:
server {
listen 80;
server_name example.com www.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;
}
}
Enable the site and test the config before reloading — this catches typos before they take your site down:
sudo ln -s /etc/nginx/sites-available/example.com /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx
Make sure the domain's A record already points to your VPS's IP (if it doesn't yet, that's a separate DNS step — do that first and let it propagate before touching Nginx).
Adding SSL with Let's Encrypt
Once the domain resolves to your server and the plain HTTP proxy works, add SSL with Certbot:
sudo apt install certbot python3-certbot-nginx -y
sudo certbot --nginx -d example.com -d www.example.com
Certbot edits the server block for you, adds the certificate paths, and sets up a redirect from port 80 to 443. It also installs a renewal timer — confirm it's active with sudo systemctl status certbot.timer so you're not caught out by an expired cert six months from now.
Fixing "502 Bad Gateway"
Symptom: the browser shows 502 Bad Gateway immediately, no delay.
Cause: Nginx reached out to the upstream port and got nothing back — usually because the app isn't running, crashed, or is listening on a different port than the one in proxy_pass.
Fix:
- Check the app is actually running:
pm2 statusorsudo systemctl status yourapp - Confirm it's listening where you think it is:
sudo ss -tlnp | grep 3000 - Read the real error:
sudo tail -30 /var/log/nginx/error.log— it'll usually say "connection refused" if the port's wrong or the app is down - Restart the app and watch the log for a crash on startup (a missing env var or bad DB connection string is the usual culprit)
Prevention: run your app under a process manager that auto-restarts on crash — PM2 with pm2 startup, or a systemd unit with Restart=always. A 502 you never see because the app restarted itself in two seconds is a much better Tuesday than one you find out about from a customer.
Fixing "504 Gateway Timeout"
Symptom: the page hangs for a while — usually right around 60 seconds — then fails with 504.
Cause: the app is running fine but taking too long to respond (a slow database query, a hung external API call), and Nginx's default proxy timeouts give up before your app finishes.
Fix: if the slowness is expected for a specific route (a report generator, a bulk export), raise the timeouts for that location block:
location /export {
proxy_pass http://127.0.0.1:3000;
proxy_read_timeout 300s;
proxy_connect_timeout 75s;
proxy_send_timeout 300s;
}
But treat this as a patch, not a fix — a request that takes five minutes is a UX problem on its own. If it's happening on routes that should be fast, the real issue is upstream: check slow query logs, add an index, or see if an external API call needs a timeout and a fallback instead of blocking the whole request.
Prevention: set deliberate, generous-but-bounded timeouts per route rather than raising the global default for everything, and move genuinely long-running work (exports, video processing, bulk emails) into a background job queue instead of a synchronous HTTP request.
WebSocket connections keep dropping
Symptom: chat, live notifications, or anything using WebSockets connects fine but drops after roughly a minute of inactivity.
Cause: Nginx doesn't forward the WebSocket upgrade handshake by default — without the right headers it treats the connection as a normal HTTP request and closes it on the standard timeout.
Fix: add the upgrade map once near the top of nginx.conf (inside the http block):
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
Then in the location block handling the WebSocket path:
location /socket.io/ {
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_read_timeout 3600s;
}
Prevention: if you know from the start that the app uses WebSockets, add this block during initial setup instead of debugging mystery disconnects later.
Running multiple apps behind one Nginx
This is the other big reason people reach for a reverse proxy — one VPS, several domains, each routed to its own app:
| Domain | Internal port | App |
|---|---|---|
| app1.example.com | 3000 | Node API |
| app2.example.com | 8000 | Django admin panel |
| status.example.com | 9000 | Uptime dashboard |
Each gets its own file in /etc/nginx/sites-available/ with a matching server_name and proxy_pass. Keep them separate rather than cramming multiple location blocks into one server — it's easier to reason about and easier to hand SSL renewal off to Certbot per-domain.
Prevention checklist
- Firewall the internal app port so it's not reachable from outside — only Nginx (on localhost) should talk to it:
sudo ufw deny 3000if UFW is active, since the app should only ever be hit via127.0.0.1 - Run
nginx -tbefore every reload, no exceptions - Keep an eye on
/var/log/nginx/error.log— it names the exact problem almost every time - Use a process manager with auto-restart for the app, not a bare terminal session
- Set route-specific timeouts instead of one giant global timeout that hides real slowness
Most reverse proxy pain comes down to one of three things: the app isn't actually running where Nginx expects it, the timeout is too short for what the route needs, or the WebSocket upgrade headers are missing. Check those three first and you'll solve most 502s and 504s in under five minutes.
Frequently asked questions
What's the difference between using Nginx as a reverse proxy versus Apache with mod_proxy?
Both can do the job. Nginx tends to handle high concurrency and static file serving with less memory overhead, which is why it's the more common choice for fronting Node.js, Python, or Docker apps on a VPS. If you're already running Apache for other sites on the same server, mod_proxy works too — the config concepts (upstream port, proxied headers, timeouts) are the same either way.
Do I need a reverse proxy if my site is just PHP served directly by Nginx?
No. A reverse proxy is for forwarding requests to a separate application process listening on its own port — a Node app, a Python app, a Docker container. Plain PHP sites (including WordPress) are usually served directly by Nginx or Apache with PHP-FPM, no proxying involved.
My app works when I curl 127.0.0.1:3000 on the server, but not through the domain. Why?
Check three things in order: that the domain's A record actually points to this VPS's IP (dig +short yourdomain.com), that the Nginx server_name in your config matches the domain exactly, and that ports 80/443 are allowed through your firewall (sudo ufw status).
Can I reverse proxy to an app running inside a Docker container?
Yes — same setup, just point proxy_pass at whatever port you've mapped the container to on the host, e.g. proxy_pass http://127.0.0.1:3000; if you ran the container with -p 3000:3000. Nginx doesn't need to know it's talking to a container.
How do I know if a 502 is Nginx's fault or my app's fault?
It's almost always the app. Nginx returns 502 when it can't get a valid response from the upstream it proxied to — check that the app process is running and listening on the exact port in your proxy_pass line before looking at Nginx config at all.