Nginx Reverse Proxy: The Config I Actually Use
A working nginx reverse proxy on Ubuntu: proxy headers, WebSockets, Let's Encrypt via certbot, a catch-all server, and the 502/413 gotchas.
Almost everything I self-host listens on some random localhost port: a Node app on 3000, a dashboard on 8080, a container on 9000. None of that should face the internet directly. nginx sits in front, terminates TLS, and hands requests to the right backend by hostname. This is the config I copy between boxes — not the most clever one, just the one that works for basically every web app I've thrown at it.
TL;DR: One site file per hostname, a shared snippet with the proxy headers, certbot for HTTPS.
sudo apt install nginx certbot python3-certbot-nginx
sudo nano /etc/nginx/sites-available/app.example.com # config below
sudo ln -s /etc/nginx/sites-available/app.example.com /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx
sudo certbot --nginx -d app.example.com
The DNS A/AAAA record for app.example.com must point at the box before the certbot step, and ports 80 and 443 must be open — sudo ufw allow 'Nginx Full', see the UFW post.
The WebSocket map (once per server)
Lots of apps use WebSockets now — dashboards, panels, chat, anything "live". This map goes in the http context. On Debian and Ubuntu, files in conf.d/ are included there, so:
# /etc/nginx/conf.d/websocket-map.conf
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
If the client asks for an upgrade, we pass Connection: upgrade on. If not, Connection: close. That way the same proxy config works for normal HTTP and WebSockets.
The shared proxy snippet
# /etc/nginx/snippets/proxy.conf
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 X-Forwarded-Host $host;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_read_timeout 300s;
Why each line:
proxy_http_version 1.1— nginx talks HTTP/1.0 to upstreams by default. WebSockets and keepalive need 1.1.Host $host— without it the backend seesHost: 127.0.0.1:3000and builds every absolute URL and redirect with that.X-Real-IP/X-Forwarded-For— otherwise every request in your app logs comes from127.0.0.1, and rate limiting in the app bans... yourself.X-Forwarded-Proto $scheme— tells the app the client used HTTPS. Skip it and many apps think they're on plain HTTP — see the redirect-loop gotcha below.proxy_read_timeout 300s— default is 60s. An idle WebSocket with no traffic for a minute gets cut off. Long-running requests (exports, uploads) too.
The site config
# /etc/nginx/sites-available/app.example.com
server {
listen 80;
listen [::]:80;
server_name app.example.com;
client_max_body_size 50m;
location / {
proxy_pass http://127.0.0.1:3000;
include snippets/proxy.conf;
}
}
That's it. certbot will add the listen 443 ssl block, the certificate paths and an HTTP→HTTPS redirect to this file. Test and reload, every time:
sudo nginx -t && sudo systemctl reload nginx
reload keeps existing connections alive; restart drops them. And nginx -t first, because a reload with a broken config just fails and leaves the old config running — which is fine — but a restart with a broken config takes all your sites down.
I also delete the default site so it doesn't answer for random hostnames:
sudo rm /etc/nginx/sites-enabled/default
HTTPS with certbot
sudo certbot --nginx -d app.example.com
sudo certbot renew --dry-run
systemctl list-timers | grep certbot
The apt package installs certbot.timer, which tries renewal twice a day and renews anything within 30 days of expiry. The dry run proves renewal actually works — do it once right after setup, not when the cert is three days from expiring. If you'd rather never think about certs at all, Caddy does all of this automatically — I compared both in Caddy vs nginx.
Catch-all default server
Bots scan IPs, not hostnames. Without a default server, a request for your bare IP or a random Host: header lands on whichever site nginx considers default — usually the first one alphabetically — and your app answers for hostnames it doesn't own. I add a catch-all that drops those:
# /etc/nginx/sites-available/00-default
server {
listen 80 default_server;
listen [::]:80 default_server;
listen 443 ssl default_server;
listen [::]:443 ssl default_server;
server_name _;
ssl_reject_handshake on;
return 444;
}
444 is nginx-specific: close the connection without sending anything. ssl_reject_handshake on rejects TLS handshakes for unknown names without needing a certificate in this block. It needs nginx 1.19.4 or newer — Ubuntu 24.04 and Debian 12/13 are all well past that. Link it into sites-enabled like any other site.
Gotcha: proxy_set_header doesn't merge
This one is sneaky. proxy_set_header is inherited from the outer level (server, http) only if the inner level defines none at all. Add a single proxy_set_header in a location and every inherited one is gone for that location. Your Host and X-Forwarded-* headers silently disappear.
That's why I put all of them in one snippet and include it in every location that proxies. If one location needs an extra header, put it after the include:
location /api/ {
proxy_pass http://127.0.0.1:4000;
include snippets/proxy.conf;
proxy_set_header X-Api-Gateway "nginx";
}
Gotcha: the trailing slash in proxy_pass
location /app/ {
proxy_pass http://127.0.0.1:3000; # backend gets /app/login
}
location /app/ {
proxy_pass http://127.0.0.1:3000/; # backend gets /login
}
With a URI part in proxy_pass (even just /), nginx replaces the matched location prefix with it. Without, it passes the original path unchanged. Neither is wrong — but it has to match what your app expects. Honestly, if I can, I give the app its own subdomain instead of a subpath. Way fewer broken asset links.
Gotcha: 502 Bad Gateway
nginx is fine, the upstream isn't answering. Test the backend directly from the box:
curl -I http://127.0.0.1:3000
sudo tail -n 20 /var/log/nginx/error.log
connect() failed (111: Connection refused) → the app isn't running or listens on a different port. Classic variant: proxy_pass http://localhost:3000 where localhost resolves to ::1 first, but the app only listens on IPv4. You get intermittent 502s that make no sense. I always write 127.0.0.1 explicitly.
For Docker containers: publish the port on localhost only (127.0.0.1:3000:3000), otherwise it's reachable from outside past nginx and past UFW. Details in the Docker install post.
Gotcha: 413 and redirect loops
413 Request Entity Too Large — client_max_body_size defaults to 1 MB. Any upload bigger than that dies at nginx, and your app never sees it. Set it per site, like in the config above.
Too many redirects — the app forces HTTPS, but since nginx talks plain HTTP to it, the app thinks every request is HTTP and redirects again. Forever. Fix: send X-Forwarded-Proto (the snippet does) and tell the app to trust it. That's a setting in the app — "trust proxy" in Express, TRUSTED_PROXIES or similar in others, $_SERVER['HTTPS'] handling in PHP apps. Check the app's docs for "reverse proxy".
HTTP/2
On nginx 1.25.1 and newer (Debian 13 ships 1.26) HTTP/2 is its own directive in the 443 server block:
http2 on;
On older versions like the 1.24 in Ubuntu 24.04, it's a flag on the listen line instead: listen 443 ssl http2;. Using the old syntax on new nginx only gives a deprecation warning; using http2 on; on 1.24 fails nginx -t. Check nginx -v before you copy configs between boxes.
After the proxy
With nginx in front, the app's port should no longer be reachable from outside at all — only 80/443. Bind the app to 127.0.0.1 and verify from a different machine. Then put fail2ban on the nginx logs for the noisier stuff, and you've got a setup I'm comfortable leaving alone for months.