Reverse Proxies Explained: Nginx, Caddy, and Traefik for Self-Hosters
Somewhere around the third self-hosted app, port-based URLs stop scaling. http://server:8096 for Jellyfin, :9000 for Portainer, :8080 for something you have already forgotten. No HTTPS on any of them, and every port is another hole in the firewall. The fix is one piece of infrastructure that nearly every homelab converges on: a reverse proxy.
What it actually does
A reverse proxy is a server that accepts every incoming request, on ports 80 and 443 only, examines the hostname, and forwards the request to the right backend:
jellyfin.home.example.com -> 127.0.0.1:8096
photos.home.example.com -> 127.0.0.1:2283
git.home.example.com -> 127.0.0.1:3000
Clients never see the backends. One public IP, two open ports, unlimited services, each with a clean HTTPS subdomain. The name distinguishes it from a forward proxy: a forward proxy represents clients to the world; a reverse proxy represents servers.
The two jobs that matter
Routing by hostname is the visible job. The second job is TLS termination: the proxy holds your certificates, speaks HTTPS with browsers, and talks plain HTTP to backends over the loopback or a private Docker network. Certificate management collapses from per-app chaos into one place, and with Let’s Encrypt automation, into zero ongoing effort. Beyond those two, proxies pick up auxiliary duties as needed: compression, caching, WebSocket passthrough, IP allowlists, and slapping authentication in front of apps that lack it.
The big three
Nginx is the incumbent: enormously capable, documented everywhere, and what most tutorials assume. A minimal site block:
server {
listen 443 ssl;
server_name jellyfin.home.example.com;
ssl_certificate /etc/letsencrypt/live/home.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/home.example.com/privkey.pem;
location / {
proxy_pass http://127.0.0.1:8096;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
Certificates come from certbot, run separately. The proxy_set_header lines forward the real client address and protocol, which backends need for logging and redirect generation; forgetting them is the classic nginx-proxy bug.
Caddy compresses all of the above, including certificate issuance and renewal, into this:
jellyfin.home.example.com {
reverse_proxy 127.0.0.1:8096
}
That is the entire configuration. Caddy obtains the certificate from Let’s Encrypt automatically, renews it forever, redirects HTTP to HTTPS, and sets the forwarding headers with sane defaults. For a homelab where the requirements are “route these five names to these five ports with HTTPS,” Caddy is the least configuration that can possibly work.
Traefik flips the model: instead of a config file listing services, it watches Docker and configures itself from labels on your containers:
services:
jellyfin:
image: jellyfin/jellyfin
labels:
- traefik.enable=true
- traefik.http.routers.jellyfin.rule=Host(`jellyfin.home.example.com`)
- traefik.http.services.jellyfin.loadbalancer.server.port=8096
Deploy the container and the route exists; remove it and the route disappears. In a compose-heavy setup this dynamism is genuinely elegant, at the price of the steepest learning curve of the three and debugging that happens in labels scattered across many files.
Honorable mention: Nginx Proxy Manager, a web UI over nginx with built-in Let’s Encrypt, popular precisely because it requires no config files at all.
Choosing
Start with Caddy if you are configuring by hand and want HTTPS to be a solved problem. Choose Traefik if your whole world is Docker Compose and you want routes to live with the services they belong to. Choose nginx when you need its depth (fine-grained caching, rate limiting, exotic rewrites) or when you want the skill itself, since nginx knowledge transfers to nearly every production environment you will ever touch.
The security caveats that come with the territory
Two things every proxy setup should get right. First, backends must not be independently reachable: bind containers to 127.0.0.1:8096:8096 instead of 8096:8096, or keep them on an internal Docker network with no published ports, and let the firewall admit only 80 and 443. Second, remember that Docker’s published ports bypass ufw by default, so verify exposure from outside the host rather than assuming the firewall rules cover you. A proxy in front of accidentally-exposed backends is decoration, not security.
Frequently Asked Questions
What is a reverse proxy?
A server that receives requests from clients and forwards them to the correct backend service, usually chosen by hostname. Clients only ever talk to the proxy, which is why one IP address can serve dozens of apps on ports 80 and 443.
How is a reverse proxy different from a forward proxy?
A forward proxy sits in front of clients and hides who is browsing. A reverse proxy sits in front of servers and hides how services are deployed. The direction of who it represents is the difference.
Why do self-hosters need a reverse proxy?
Without one, every app needs its own port and its own certificate handling. A proxy gives each app a clean subdomain on standard ports, terminates HTTPS centrally, and keeps backend containers off the public network.
Which reverse proxy is easiest for beginners?
Caddy, by a wide margin. Its two-line site blocks include automatic HTTPS with certificates obtained and renewed for you. Nginx Proxy Manager is a close second for people who prefer a web interface.
What is TLS termination?
The proxy holds the certificates and handles all HTTPS encryption with clients, then talks plain HTTP to backends over a private network. Apps never deal with certificates themselves.
Do I still need a firewall with a reverse proxy?
Yes. The proxy is the only thing that should be reachable on ports 80 and 443, and a firewall enforces that the backends are not directly exposed. The two are complementary layers.