Guide
Reverse proxy and TLS, done properly
Putting one reverse proxy in front of every service is the single highest-leverage change you can make to a home server. It ends port collisions, gives you one place where TLS happens, and turns certificate renewal into something you never think about again.
What is the best reverse proxy setup for a self-hosted server?
Run one reverse proxy on ports 80 and 443, give every service its own subdomain of a domain you actually own, and let the proxy obtain and renew certificates over ACME. Caddy is the right default for most people because automatic HTTPS is the entire product rather than a bolted-on feature. Use the DNS-01 challenge instead of HTTP-01 whenever a hostname is internal-only or your ISP blocks port 80, because DNS-01 is the only way to get a publicly trusted certificate for a name that does not resolve from the internet.
Every service you install wants a port. Jellyfin takes 8096, Immich takes 2283, Home Assistant takes 8123, and Uptime Kuma takes 3001. Each of them serves plain HTTP and each of them has its own idea about certificates, if it has one at all. Ten services in, you have ten port numbers memorized, at least one collision you worked around by editing a compose file at 1am, and no TLS anywhere because doing it per app is ten times the work.
One reverse proxy collapses all of that. Two ports are open, 80 and 443. Every service gets a hostname. Certificates are obtained, renewed and stapled in one place by one process that has exactly one job. Nothing else on the machine needs to know that TLS exists.
Which proxy, honestly#
Use Caddy. It idles at about 25 MB, the config for a working HTTPS reverse proxy is two lines, and certificate renewal is genuinely unattended rather than a cron job you find broken in eight months. Its ops load is 1 out of 5 in this index and that rating is earned.
Two situations change the answer.
Use Traefik if your services come and go. Traefik builds its routing table from Docker labels, so a container that starts with traefik.enable=true and a router label is routable a second later, with a certificate. Nothing file-based matches that. You pay for it: the internet is full of v2 snippets and Traefik does not tell you when you paste one. On v3 rule values go in backticks and combine with &&, Headers became Header, and IPWhiteList became IPAllowList. A stale label does not error. It just fails to match and you get a 404 from whichever router did match. That is why Traefik sits at ops 3 and Caddy at ops 1.
Use Nginx Proxy Manager if you want a form, not a file. Type a domain, an upstream host and port, tick the Let's Encrypt box. Nothing else in this category is that low friction, and it is the honest answer for a household where someone other than you might need to add a proxy host. The uncomfortable part: it is effectively one maintainer, the v3 rewrite has been open since June 2021, and the UI is the source of truth. Anything you hand-edit under /data/nginx gets regenerated on the next save.
Both alternatives are defensible. Reaching for nginx and certbot by hand in 2026 is not, unless you already have an nginx config estate and people who read nginx.
See Caddy vs Traefik for the head to head, and Reverse proxy generator to produce config for your actual hostnames.
HTTP-01 or DNS-01: the decision nobody explains properly#
ACME certificate authorities need proof you control a name. There are two challenge types you will realistically use, and picking wrong is the most common reason a homelab ends up with self-signed certificates and permanently trained-away browser warnings.
| HTTP-01 | DNS-01 | |
|---|---|---|
| How you prove control | CA fetches a file over port 80 from the public internet | You publish a _acme-challenge TXT record |
| Needs inbound port 80 | Yes | No |
| Works for internal-only names | No | Yes |
| Can issue a wildcard | No | Yes |
| Needs a DNS provider API token | No | Yes |
| Setup effort | None | A token, a plugin, and a build for Caddy |
The rule that matters: if the hostname does not resolve to your public IP from the internet, HTTP-01 cannot work, and DNS-01 is the only route to a publicly trusted certificate. This is not a limitation you can engineer around. The CA has to reach something. With DNS-01 the thing it reaches is your DNS provider, not your server, which is why nas.home.example.com can point at 192.168.1.20 and still get a real certificate from Let's Encrypt.
That also covers the second common case: an ISP that blocks inbound 80, or a CGNAT connection where you have no inbound at all. Same answer.
Caddy has one trap here. The official binary and the official caddy Docker image ship zero DNS provider modules. The moment you want DNS-01 you have to build with xcaddy build --with github.com/caddy-dns/cloudflare or use the :builder image in a two-stage Dockerfile, and you own that build pipeline on every upgrade. It is fifteen lines of Dockerfile and it is the price of internal certificates.
While you are experimenting, point at the staging CA. Let's Encrypt allows 50 certificates per registered domain per week and 5 duplicates of the exact same name set, and a redeploy loop with no persistent storage burns through that in an afternoon. Which brings us to the other rule: mount a volume at /data before you point real DNS at Caddy, or every container recreate requests every certificate again.
The headers, and exactly what breaks without each one#
A reverse proxy terminates the client's connection and opens a new one. The backend now sees a request from 172.18.0.1 over plain HTTP to localhost. Four headers restore the truth, and each one has a distinct failure mode.
| Header | What breaks without it |
|---|---|
Host | The app generates absolute URLs and redirects pointing at its container name or localhost. Login redirects land nowhere. |
X-Forwarded-Proto | The app believes the request was HTTP, redirects to http://, your proxy redirects back to HTTPS, and the browser reports ERR_TOO_MANY_REDIRECTS. |
X-Forwarded-For | Every request appears to come from the proxy. Rate limiting throttles everyone at once, audit logs are useless, and fail2ban or CrowdSec bans the proxy, taking the whole site down. |
X-Forwarded-Host | Apps that build canonical links from the original host emit the internal name in emails, webhooks and OIDC redirect URIs. |
Caddy's reverse_proxy sets X-Forwarded-For, X-Forwarded-Proto and X-Forwarded-Host automatically and passes the original Host through unchanged. Traefik does the same. nginx does none of it unless you write the proxy_set_header lines yourself. This is most of the reason to prefer a proxy that was designed after 2015.
The second half of the problem is on the app side: most applications ignore X-Forwarded-* unless you tell them to trust the proxy. Nextcloud wants trusted_proxies in config.php, Django apps want SECURE_PROXY_SSL_HEADER, and getting it wrong in the other direction is a spoofing hole, because anyone who can reach the app directly can forge the header. Bind your apps to the Docker network only, never to 0.0.0.0.
The two bugs that look like application bugs#
Websockets. The page loads, then nothing updates. No error in the app log, no error in the proxy log, because the request was proxied successfully as an ordinary HTTP request that the backend then refused to upgrade. Uptime Kuma, Home Assistant, Immich's upload progress and anything with a live view all depend on this. Caddy and Traefik upgrade automatically. In Nginx Proxy Manager it is a per-host checkbox that is off by default, which is why this is such a persistent support thread.
413 on upload. nginx sets client_max_body_size to 1 MB by default and returns 413 above it. A 40 MB photo import into Immich or a scanned PDF into Paperless-ngx dies at the proxy, and the application never sees the request, so its logs are clean and you go looking for a bug that is not there. Caddy and Traefik have no default body limit; if you want one in Caddy it is request_body { max_size 100MB }, which also returns 413.
Working Caddyfile#
This covers an internet-facing service, an internal-only service with a wildcard from DNS-01, and a raised body limit.
{
email admin@example.com
}
jellyfin.example.com {
reverse_proxy 10.0.0.20:8096
}
photos.example.com {
request_body {
max_size 5GB
}
reverse_proxy 10.0.0.20:2283
}
*.home.example.com {
tls {
dns cloudflare {env.CF_API_TOKEN}
resolvers 1.1.1.1
}
@ha host ha.home.example.com
handle @ha {
reverse_proxy 10.0.0.30:8123
}
@vault host vault.home.example.com
handle @vault {
reverse_proxy 10.0.0.30:8080
}
handle {
abort
}
}Two things worth knowing. Directives run in a fixed built-in order, not the order you wrote them, which is why the internal block uses handle blocks: those are mutually exclusive and evaluated top to bottom. And caddy adapt --config Caddyfile prints the JSON that actually runs, which is the fastest way to find out that your matcher does not match what you think.
Working Traefik v3 labels#
services:
traefik:
image: traefik:v3.7
restart: unless-stopped
command:
- --providers.docker=true
- --providers.docker.exposedByDefault=false
- --entryPoints.web.address=:80
- --entryPoints.web.http.redirections.entryPoint.to=websecure
- --entryPoints.web.http.redirections.entryPoint.scheme=https
- --entryPoints.websecure.address=:443
- --certificatesResolvers.le.acme.email=admin@example.com
- --certificatesResolvers.le.acme.storage=/acme.json
- --certificatesResolvers.le.acme.dnsChallenge.provider=cloudflare
environment:
CF_DNS_API_TOKEN_FILE: /run/secrets/cf_token
ports:
- "80:80"
- "443:443"
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- ./acme.json:/acme.json
jellyfin:
image: jellyfin/jellyfin:latest
restart: unless-stopped
labels:
traefik.enable: "true"
traefik.http.routers.jellyfin.rule: "Host(`jellyfin.example.com`)"
traefik.http.routers.jellyfin.entrypoints: "websecure"
traefik.http.routers.jellyfin.tls.certresolver: "le"
traefik.http.services.jellyfin.loadbalancer.server.port: "8096"Run touch acme.json && chmod 600 acme.json before the first start. If the host path does not exist, Docker creates a directory with that name, Traefik cannot persist certificates, and it re-requests them on every restart until Let's Encrypt stops answering. The 600 mode is enforced, and a group-readable file is ignored with a warning most people never read.
Also note that --providers.docker gives Traefik read access to the Docker socket, which means it can enumerate every container, its environment variables and its mounts. Mount it read-only at minimum and consider a socket proxy.
HSTS: set it late, set it low#
Strict-Transport-Security tells browsers to refuse plain HTTP to your domain for max-age seconds. It is a good header and it is close to irreversible, because the instruction lives in the browser's cache, not on your server. A year-long max-age on a domain you later need to serve over HTTP means a year of broken clients that you cannot fix from your end.
Start at max-age=300. Run it for a week. Raise it once you are sure nothing in the house depends on plain HTTP. Do not add includeSubDomains until every subdomain has a certificate, and do not submit to the preload list at all unless you are certain, because removal takes months and ships on browser release trains.
What to do next#
Buy a domain if you do not have one, then read DNS for self-hosters for the naming scheme and split-horizon resolution that make internal names work. If you want these services reachable away from home, Remote access without port forwarding covers the four routes and why exposing an app's own login page is the wrong one. If you are putting authentication in front of services rather than relying on each app's login, Authelia and authentik both integrate with the proxy through forward auth, and Single sign-on for self-hosters is honest about how far that gets you.
Generate a starting config for your own hostnames with Reverse proxy generator, and check for overlapping defaults before you assign ports with Port conflict checker.
Questions#
Do I need a reverse proxy if I only use my services at home?
Yes, and the reason is not encryption. Without a proxy every service is a different port number you have to remember, two services eventually want the same port, and browsers increasingly refuse features on plain HTTP. Service workers, the clipboard API, WebAuthn and passkeys all require a secure context. If you ever want a passkey login on vaultwarden.home.example.com, you need real TLS on an internal name, which means a proxy and a DNS-01 certificate.
Can I get a Let's Encrypt certificate for a service that is not reachable from the internet?
Yes, with the DNS-01 challenge. You prove control of the domain by writing a _acme-challenge TXT record, which never touches the server, so the hostname can resolve to 192.168.1.20 and the certificate still issues. HTTP-01 cannot do this because the CA has to fetch a file over port 80 from the public internet. DNS-01 is also the only challenge type that can issue a wildcard.
Why does my app load but the live updates never arrive?
The proxy is not forwarding the websocket upgrade. The initial page is plain HTTP so it renders fine, then the Upgrade: websocket request gets proxied as an ordinary request and quietly fails. Caddy and Traefik handle upgrades automatically. Nginx needs proxy_http_version 1.1, proxy_set_header Upgrade $http_upgrade and proxy_set_header Connection "upgrade". In Nginx Proxy Manager it is a per-host checkbox called Websockets Support, and it is off by default.
Why do I get a 413 error when uploading a large file?
nginx caps request bodies at 1 MB by default (client_max_body_size 1m) and returns 413 above it. The upload fails at the proxy, so the application logs show nothing at all and it reads like an app bug. Raise it on the proxy first, then check the app's own limit and, for PHP apps, upload_max_filesize and post_max_size. Caddy and Traefik impose no body limit unless you configure one.
Should I use a wildcard certificate?
Use one if you run more than about ten hostnames or you add services often. A wildcard is one certificate covering *.example.com, so new subdomains need no issuance at all and you stop worrying about the 50 certificates per registered domain per week rate limit. The cost is that the private key now covers every name you have, and wildcards require DNS-01, which means giving the proxy an API token for your DNS provider.
Is Nginx Proxy Manager a bad choice?
No, it is the right choice for one specific person: someone who wants a web form rather than a config file. Be clear-eyed about the project though. It is effectively one maintainer, the v3 rewrite has had an open status issue since June 2021 and has not landed, and the UI regenerates everything under /data/nginx on save so hand edits vanish. Pick it knowing the feature set will not grow.
How do I turn HSTS off once I have enabled it?
You cannot, quickly. HSTS is cached by the browser for max-age seconds, so a header with max-age=31536000 means a year of that browser refusing plain HTTP to your domain no matter what you serve. The only clean rollback is to keep serving HTTPS and publish max-age=0 for long enough that every client has seen it. Submitting to the preload list is worse: removal takes months and rides browser release trains. Start at max-age=300, live with it, then raise it.
Sources#
- Caddy documentation, automatic HTTPS
- Caddy documentation, reverse_proxy directive and forwarded headers
- Caddy documentation, request_body and max_size
- Caddy documentation, building with DNS provider plugins
- nginx documentation, client_max_body_size default and 413
- Let's Encrypt, rate limits
- Traefik documentation, ACME certificate resolvers
- Traefik documentation, v2 to v3 migration
- Nginx Proxy Manager, setup documentation
Published . Last reviewed . Found something out of date? Tell us and we will fix it and log the change.