Caddy vs Traefik: Reverse Proxies Compared Honestly
Two good tools, two very different bets, and the one question that decides it

Contents
Every homelab arrives at the reverse proxy question about three weeks in, usually the day someone gets tired of remembering that Jellyfin is on 8096 and Grafana is on 3000. The shortlist is short: Caddy and Traefik. Both are single Go binaries, both do automatic Let’s Encrypt certificates, both are actively developed and genuinely good. Every comparison you’ll read concludes that Caddy is simpler and Traefik is more powerful, which is true and roughly as useful as being told that a hammer is simpler than a drill.
I’ve run both in anger — Caddy for about three years across two hosts, Traefik for two on a Docker-heavy box and briefly on a K3s cluster. The difference that actually matters turned out to be none of the things the comparisons lead with. It’s a question about where you want the truth to live.
The one real difference
Caddy has a file. You write a Caddyfile, it lists your sites, and that file is the complete description of what the proxy does. To add a service you edit the file and reload. The file is the truth.
Traefik has a discovery mechanism. You point it at Docker’s socket, or a Kubernetes API, or a directory of files, and it assembles its configuration from labels and annotations it finds there. To add a service you add labels to the service’s own container and Traefik notices. The containers are the truth, and Traefik’s config is a set of instructions about where to look.
Everything else follows from that. Traefik’s dashboard, its provider system, its middleware model, its plugin architecture — all of it exists because the configuration is distributed and something has to make the assembled result comprehensible. Caddy’s brevity exists because a static file needs no such machinery.
Neither of these is better. They’re bets on different failure modes. Caddy bets that you’d rather have one file to read at 2am. Traefik bets that you’d rather never touch a central file, because a central file is a thing that drifts out of sync with reality and a bottleneck when several things want to change at once.
What they look like
Caddy, a real config from a real host:
| |
That’s the whole thing. Nineteen lines of meaningful content, three sites on public certificates, a wildcard on a DNS challenge for internal names, TLS handled without the word “TLS” appearing except where I wanted something unusual. Caddy obtains certificates, renews them, redirects HTTP to HTTPS, sets sane security headers and speaks HTTP/2 and HTTP/3, all without being asked. I’ve written up that workflow in full in reverse proxy done right.
Traefik, doing the same job:
| |
| |
More lines, and they buy something specific. That Jellyfin service is now self-describing: everything about how it’s exposed lives next to the thing being exposed. Move that compose file to another host with Traefik on it and the routing comes along. Delete the container and the route disappears the same second. Nothing to clean up, nothing to forget.
Performance, and why it shouldn’t decide this
Somebody always asks about throughput, so: on a homelab, this question is noise. Both are Go, both use the same standard library networking primitives, and both will saturate a gigabit link on a modest CPU while barely registering. The benchmark blogs that put nginx ahead of both are measuring static file serving at tens of thousands of requests a second, which is a workload no home network produces and no reverse proxy in a rack is doing anyway — you’re proxying to a backend that is itself the bottleneck by three orders of magnitude.
Memory is the one number worth a glance. Caddy idles around 20–40MB. Traefik idles around 60–100MB and climbs with the number of routers and the metrics you enable. On anything with 8GB it’s irrelevant. On a Raspberry Pi with 2GB running twelve other things, that gap is a small argument for Caddy, and the honest weight of that argument is “tiebreaker at most”.
The one place performance genuinely diverges is TLS handshake behaviour under a burst of new connections, and Caddy’s certificate cache and on-demand TLS make it the better citizen there. You will never notice. I mention it only so that when somebody links you a benchmark, you know which part of it was real.
What does matter, and gets discussed far less, is what happens when the proxy restarts. Caddy’s reload is graceful: existing connections drain, the new config takes over, nothing drops. Traefik’s provider model means most changes need no restart at all, because it reconfigures live as containers appear. Both have solved this well; it’s worth knowing because it’s the reason neither of them should ever cause you visible downtime during ordinary changes, and if one does, you’ve done something structurally odd.
Where each one genuinely wins
Caddy wins on the config being readable a year later. This sounds soft and it’s the most valuable property either of them has. When something breaks and you haven’t touched it in eight months, cat Caddyfile is a complete answer. Traefik’s equivalent is reading a static file, then reading labels across nine compose files, then checking the dashboard to see what it assembled, then remembering that a middleware defined on one router applies to another.
Caddy wins on TLS defaults. Both automate ACME. Caddy makes it invisible. Traefik makes it a certificatesResolvers block, a resolver name repeated on every router, and an acme.json you must remember to chmod 600 or it silently refuses to start. Caddy also handles Let’s Encrypt rate limits more gracefully — it backs off and retries; Traefik has been known to hammer and get you locked out for a week.
Caddy wins on internal certificates. tls internal gives every internal service a certificate from Caddy’s own CA, no ACME, no DNS challenge, no public exposure. It’s one word. Doing the equivalent elsewhere is a genuine project.
Traefik wins the moment you have many containers. At twenty-plus services that come and go, labels are the right answer. A new stack arrives with its routing attached and Traefik notices in under a second. No file to edit, no reload, no risk of a typo taking down the other nineteen sites — which is Caddy’s real weakness, because the Caddyfile is a shared fate. One syntax error and everything stops.
Traefik wins on middleware. Rate limiting, IP allowlists, retries, circuit breakers, header manipulation, forward auth to an identity provider — all first-class, composable, chainable. Caddy can do most of it with modules and directives, and the ergonomics are worse for anything complicated. Wiring Authelia or Authentik into Traefik is one middleware referenced by name from any router; in Caddy it’s a forward_auth block repeated per site.
Traefik wins on Kubernetes. It’s a proper ingress controller with a CRD-based routing model, and it’s what K3s ships by default. Caddy has an ingress controller and it’s a community effort. If your workloads are in a cluster, this isn’t close.
Traefik wins on observability. The dashboard shows every router, service and middleware and whether they’re healthy. Prometheus metrics are a config block. Caddy has structured JSON logs and an admin API, and no equivalent view.
One asymmetry worth naming, since it decides more arguments than anything above: migration cost. Moving from Caddy to Traefik means writing labels onto every container you own, once. Moving from Traefik to Caddy means reading your labels back out and condensing them into a file, once. Both are an afternoon for a twenty-service rack, and neither requires downtime if you stage the new proxy on different ports first and cut over with a DNS change. This is a reversible decision, and treating it as a permanent architectural commitment is how people spend three weeks reading comparisons instead of one afternoon running something.
What actually goes wrong
Caddy: one bad line, everything down. caddy validate --config /etc/caddy/Caddyfile before every reload, and caddy reload rather than restart — reload validates first and keeps the old config if the new one is broken. Put the validate in a pre-commit hook and the problem disappears, along the lines of any other config that ages well.
Caddy: certificates for a hostname that isn’t public. The default HTTP challenge needs Let’s Encrypt to reach port 80 on that name. For internal names use tls internal or a DNS challenge, and note the DNS challenge needs a Caddy build with your provider’s module compiled in — xcaddy build or the official Docker image variants.
Traefik: 404 on a route that clearly exists. Nine times in ten the container isn’t on the same Docker network as Traefik, or exposedByDefault: false is set and you forgot traefik.enable=true. The dashboard’s router list is definitive: if it’s not there, Traefik never saw the labels.
Traefik: “unable to obtain ACME certificate”. Check acme.json is chmod 600 and writable. Check the resolver name on the router matches the one in the static config exactly. Delete acme.json and start over only as a last resort — you’ll burn rate limit doing it.
Traefik: the port label is required and its absence is silent. If a container exposes more than one port, loadbalancer.server.port is mandatory. Without it Traefik guesses, and it guesses wrong, and the error is a 502 with nothing useful in the log.
Both: WebSockets and streaming. Both handle upgrades transparently and neither needs the incantations nginx demanded. The failure mode that survives is buffering: a proxy that buffers a response before forwarding it breaks server-sent events and long-poll endpoints. Caddy streams by default; Traefik streams by default. If something’s hanging, the culprit is nearly always a backend that never flushes, and the proxy is taking the blame for it.
Both: the certificate is issued and the browser still complains. Almost always an incomplete chain or a name mismatch — a certificate for example.com serving www.example.com. openssl s_client -connect host:443 -servername name.example.com shows exactly what’s being presented. The same class of problem as cert-manager renewals that quietly stop, and it’s always worth checking what the server actually sent before assuming the issuer misbehaved.
Both: split-horizon DNS. Internal clients resolving your public hostname to the public IP will hairpin through your router or fail entirely. Both proxies work fine; DNS is the problem, and the fix is one domain with two answers.
The honest verdict
Run Caddy if your services are stable and you have fewer than about twenty of them. The config fits on a screen, TLS is genuinely free, and you’ll spend zero time on it after the first afternoon. That describes most homelabs, and it’s what I run on the hosts I care about most, precisely because the recovery path when I’m tired is reading one file.
Run Traefik if your workloads move — Kubernetes, Swarm, or a Docker host where stacks come and go weekly. The label model is the correct answer to that problem, and the middleware ecosystem is meaningfully deeper. It’s also the right call if you want a dashboard, or mTLS between services without hand-assembling it.
The wrong reason to pick Traefik is that it looks more professional. The wrong reason to pick Caddy is that Traefik’s docs were confusing on a Tuesday. Both are excellent; you’re picking a failure mode. Caddy fails by having one file that can be wrong all at once. Traefik fails by having configuration in nine places and a dashboard you consult to find out what it decided.
And if you’re currently running neither, the real advice is to start with Caddy regardless. Three lines gets you a working HTTPS site, which teaches you what a reverse proxy is for. Move to Traefik when you can articulate what it would fix — if you can’t, it wouldn’t. That’s the same argument I made in Traefik vs Nginx Proxy Manager, and switching later costs an afternoon, which is a cheap option to keep open.




