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.

Share
Nginx Reverse Proxy: The Config I Actually Use

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 sees Host: 127.0.0.1:3000 and builds every absolute URL and redirect with that.
  • X-Real-IP / X-Forwarded-For — otherwise every request in your app logs comes from 127.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.


Related posts