Traefik vs Nginx Proxy Manager: Reverse Proxies for the Rest of Us
One puts everything in labels, the other in a tidy web UI

Contents
Sooner or later, every homelab outgrows the colon. You start with 192.168.1.40:8989 for Sonarr, :3000 for Grafana, :8080 for the thing you can no longer remember, and you keep a sticky note of port numbers like it’s 2003. Then you want HTTPS, because typing passwords over plain HTTP makes your skin crawl, and suddenly you need a reverse proxy: one thing on ports 80 and 443 that looks at the hostname and routes the request to the right backend, terminating TLS on the way through.
Two tools dominate the self-hosting world for this job, and they could not be more different in temperament. Traefik wants you to describe your routing in code and labels. Nginx Proxy Manager (NPM) wants you to click a few buttons and be done. Picking between them is mostly a question of what kind of person you are.
What a reverse proxy actually does
The mechanics are simple. A request for grafana.example.com arrives on port 443. The proxy reads the SNI hostname, matches it against a rule, decrypts the TLS, and forwards the plain request to http://grafana:3000 on your internal network. The response goes back the same way. You get one public entry point, automatic Let’s Encrypt certificates, and clean URLs with no ports in sight.
Both tools handle the certificate side the same way in spirit: they talk ACME to Let’s Encrypt, prove you control the domain, and renew before expiry so you never touch a cert by hand again. If you’ve ever run cert-manager in Kubernetes, the concept is identical — automated ACME issuance and renewal — just packaged for a single host instead of a cluster. And if you want the absolute simplest version of the whole idea, Caddy does HTTPS with zero configuration and is worth a look before you commit to either of these two; this comparison is for when you’ve decided you want more control than Caddy’s happy path gives you.
The differences between Traefik and NPM are entirely in how you tell the proxy about your services.
Nginx Proxy Manager: the friendly front door
NPM is exactly what it says: a web UI bolted onto nginx, with a database tracking your “proxy hosts.” You spin it up, log in, click Add Proxy Host, type the domain, type the internal address and port, tick the SSL box, and request a certificate. It works, and for a lot of people that’s the whole story.
| |
The appeal is obvious. There’s nothing to learn, the certificate management is point-and-click, and you can hand it to someone who has never seen an nginx config and they’ll be fine. The cost is that your routing lives in a SQLite database inside a container. It’s not in Git, it’s not reproducible, and the day that volume corrupts you’re recreating every host by hand from memory. The custom-config escape hatch exists but is fiddly, and NPM has historically been slow to ship newer nginx and security patches.
That last point deserves weight. NPM has, at times, run months behind on the underlying nginx version and on its own security fixes, and because it sits directly on ports 80 and 443 facing the internet, it is your attack surface. A reverse proxy that lags on patches is not a place you want lag. It’s not a dealbreaker — plenty of people run it happily for years — but if you expose it publicly, put it behind updates you actually watch, and don’t assume the container will nag you when a fix lands. The convenience that makes NPM approachable is the same convenience that lets you forget it exists until something goes wrong.
Traefik: routing as configuration
Traefik takes the opposite stance: it discovers your services automatically from Docker labels, so adding a route means adding labels to the container you’re already deploying. No separate step, no UI, no database.
| |
The router watches the Docker socket, sees that label, and the route exists the moment the container starts. Bring the container down and the route vanishes. Your entire edge configuration lives in the same compose files as your apps, which means it’s in version control alongside everything else.
That static-versus-dynamic split is the concept that trips everyone up, so it’s worth naming plainly. Traefik has two kinds of configuration. Static config — entrypoints, certificate resolvers, which providers to watch — is read once at startup and changing it means restarting Traefik. Dynamic config — the actual routers, services and middlewares — is discovered continuously, from Docker labels or a watched file, and updates live with no restart. Get those two mixed up (trying to define an entrypoint in a label, or a router in the static file) and nothing works, with error messages that don’t quite tell you why. Once the distinction lands, the whole tool clicks; before it does, it’s maddening.
The price is a steeper start. That static-versus-dynamic confusion is real, the v1-to-v2 migration broke a lot of old tutorials, and a stray backtick in a Host() rule will have you staring at a 404 wondering what you broke. The dashboard shows you routers and services but you don’t configure through it. When it clicks, though, it’s genuinely elegant — middlewares for auth, rate limiting and headers chain together cleanly, and adding a new service is a four-line copy-paste.
The certificate question nobody tells you about first
Here’s a wrinkle that catches people out on both tools: the HTTP-01 challenge, the default way Let’s Encrypt verifies you own a domain, requires the proxy to be reachable from the public internet on port 80. That’s fine if you’re exposing services outward. But a lot of homelabs want internal HTTPS — grafana.mylab.local, reachable only on the LAN, never exposed — and HTTP-01 simply can’t validate a name that the outside world can’t reach.
The answer is the DNS-01 challenge, where instead of serving a file on port 80 you prove ownership by writing a TXT record into your domain’s DNS. Traefik supports this cleanly through provider plugins; you hand it an API token for your DNS host and it creates and tears down the _acme-challenge records itself:
| |
This also unlocks wildcard certificates — one *.example.com cert covering every subdomain, so you’re not minting a fresh certificate per service. NPM can do DNS-01 too, through its UI, but the experience is clunkier and the provider list is narrower. If internal-only HTTPS or wildcards matter to you, that alone can decide the contest.
Middleware: where Traefik pulls ahead
Routing is only half of what an edge proxy does. The other half is everything you want to happen to a request on the way through — forcing HTTPS, adding security headers, rate limiting, or gating a service behind authentication. Traefik expresses these as middlewares you attach to a router with more labels:
| |
Chain them, reuse them across services, keep them in Git. NPM does the common cases — force SSL, HTTP/2, basic auth — through checkboxes, and for the 80% that covers, it’s genuinely faster to click than to type. But the moment you want a custom chain, you’re hand-editing nginx location snippets in a textarea, and those snippets are back to living in the database rather than your repo.
Troubleshooting: the failures you’ll actually hit
404 with Traefik and you swear the config is right. In roughly this order: is traefik.enable=true on the container? Is the backtick in Host(\…`)a real backtick, not a quote? Is the container on the same Docker network as Traefik? Is theentrypoints` label pointing at the entrypoint you actually defined? Nine times out of ten it’s the network or a typo’d backtick. The Traefik dashboard’s router list will show a route as present-but-unhealthy, which narrows it fast.
Certificate stuck in “TRAEFIK DEFAULT CERT.” That self-signed placeholder means ACME hasn’t issued a real cert yet. Check the logs: usually it’s the HTTP-01 challenge failing because port 80 isn’t reachable, a rate limit from too many failed attempts (Let’s Encrypt is strict — five failures an hour and you’re paused), or a wrong DNS record. Delete acme.json and retry only after you’ve fixed the underlying cause, or you’ll just burn more rate limit.
NPM: “Internal Error” requesting a certificate. Almost always the domain doesn’t resolve to your public IP yet, or port 80/443 forwarding isn’t set on the router. NPM’s error surface is thin here; check the container logs (docker logs) for the actual ACME response, which is far more useful than the UI’s shrug.
A route works, then breaks after a container restart. With NPM this shouldn’t happen (the DB persists). With Traefik, if the backend’s container name or the Docker network changed, the label-derived route now points nowhere. This is the flip side of dynamic config: it’s only as stable as the thing it’s watching.
The honest trade-offs
- Learning curve: NPM is approachable in five minutes; Traefik takes an afternoon and a few swear words.
- Reproducibility: Traefik config is in your compose files and Git. NPM hides it in a database.
- Dynamic routing: Traefik picks up new containers automatically. NPM needs a manual entry each time.
- Middleware: Traefik chains auth, rate limits and redirects natively. NPM does basics, then you’re hand-editing nginx snippets.
- Maintenance: NPM has lagged on updates; Traefik moves fast, occasionally fast enough to break your config on a major bump.
The verdict
If you spin up a service or two a month, want HTTPS without a tutorial, and would rather click than read documentation, Nginx Proxy Manager is the right call and there’s no shame in it. It does the job and gets out of the way.
If your homelab is Docker Compose all the way down, you keep your stack in Git, and you want adding a service to mean adding four labels rather than visiting a UI, Traefik is the better long-term home. I run it precisely because routes appear and disappear with the containers themselves, and my edge configuration is something I can git diff rather than something I have to remember.
Pick the one that matches how you already work. The request still ends up at the right backend either way — the only question is whether you’d rather describe that in a database or in code.




