> ## Content Index
> Fetch the complete content index at: https://blog.sudowl.com/llms.txt
> Use this file to discover other available public pages before exploring further.

# Nginx Reverse Proxy: The Config I Actually Use
- URL: https://blog.sudowl.com/nginx-reverse-proxy-setup/
- Published: 2026-04-04T10:00:00.000Z
- Updated: 2026-10-11T10:16:27.000Z
- Description: A working nginx reverse proxy on Ubuntu: proxy headers, WebSockets, Let's Encrypt via certbot, a catch-all server, and the 502/413 gotchas.
- Author: Lukas
- Tags: Linux, Ubuntu

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.

```bash
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](/ufw-firewall-rules-ubuntu/).

## 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:

```nginx
# /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

```nginx
# /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

```nginx
# /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:

```bash
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:

```bash
sudo rm /etc/nginx/sites-enabled/default

```

## HTTPS with certbot

```bash
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](/caddy-vs-nginx-auto-https/).

## 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:

```nginx
# /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:

```nginx
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

```nginx
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:

```bash
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](/docker-install-ubuntu/).

## 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:

```nginx
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](/fail2ban-setup-ssh-nginx/) for the noisier stuff, and you've got a setup I'm comfortable leaving alone for months.

---

### Related posts

- [Ubuntu Server Initial Setup Checklist (2026 Edition)](https://blog.diengdoh.com/ubuntu-server-initial-setup-checklist/?ref=blog.sudowl.com)
- [KeyHelp: The Free Plesk Alternative I Moved To (and Its API Traps)](https://blog.diengdoh.com/keyhelp-free-plesk-alternative/?ref=blog.sudowl.com)
- [Running PHP 7.4, 8.3 and 8.4 Side by Side on One Server](https://blog.diengdoh.com/multiple-php-versions-one-server/?ref=blog.sudowl.com)