You Need a Reverse Proxy. Now Pick One.
You’ve got five services running on a single box — Nextcloud, Gitea, Vaultwarden, some Prometheus stack, maybe a little Jellyfin for the weekend. They’re all on different ports. You’re tired of typing :8096 like an animal. You want subdomains. You want HTTPS. You want it to just work so you can go back to not thinking about it.
Two names come up every single time: Caddy and Traefik.
They solve the same problem. They both handle TLS. They both proxy requests. But the experience of configuring and living with them is about as similar as a bash one-liner and a Helm chart.
Here’s the honest comparison.
What They Are
Caddy is a single Go binary with automatic HTTPS baked in from day one. You write a Caddyfile — a config format that looks like someone said “NGINX config but readable by humans” — and Caddy handles ACME, cert renewal, HTTP/2, HTTP/3, and routing. There’s a module ecosystem for things like Cloudflare DNS, Authelia forward auth, and Docker discovery. The default experience is “it just works.”
Traefik is a cloud-native edge router with deep Docker and Kubernetes integration. Configuration is split between a static config (startup settings, providers) and a dynamic config (routes, middlewares — often pulled live from Docker labels). It has a dashboard, first-class middleware objects, and the word “IngressRoute” appears in its docs without irony. It’s extremely powerful. It is also, at 2 AM, a lot.
Config Style: Caddyfile vs Labels + YAML
This is where the two proxies most visibly diverge. Not in features — in philosophy.
Caddy: One File to Rule Them All
nextcloud.example.com { reverse_proxy nextcloud:80}
git.example.com { reverse_proxy gitea:3000}
vault.example.com { reverse_proxy vaultwarden:8080}
jellyfin.example.com { reverse_proxy jellyfin:8096}
metrics.example.com { basicauth { admin $2a$14$...bcrypthashedpassword... } reverse_proxy prometheus:9090}That’s it. TLS is automatic. HTTP redirects to HTTPS. You don’t ask for it. It just happens. The Caddyfile reads like a config written by someone who respects your time.
Traefik: Labels Everywhere
Traefik configuration is distributed. The static config lives in a file (or CLI args). The routes live on your containers as Docker labels. Middlewares are defined either in a dynamic config file or also as labels. It’s powerful, but when something breaks you’re debugging three different places at once.
version: "3.8"
services: traefik: image: traefik:v3.3 command: - "--api.dashboard=true" - "--providers.docker=true" - "--providers.docker.exposedbydefault=false" - "--entrypoints.web.address=:80" - "--entrypoints.websecure.address=:443" - "--certificatesresolvers.letsencrypt.acme.dnschallenge=true" - "--certificatesresolvers.letsencrypt.acme.dnschallenge.provider=cloudflare" - "--certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json" environment: - CF_DNS_API_TOKEN=your_cloudflare_token ports: - "80:80" - "443:443" volumes: - /var/run/docker.sock:/var/run/docker.sock:ro - traefik-certs:/letsencrypt labels: - "traefik.enable=true" - "traefik.http.routers.dashboard.rule=Host(`traefik.example.com`)" - "traefik.http.routers.dashboard.entrypoints=websecure" - "traefik.http.routers.dashboard.tls.certresolver=letsencrypt" - "traefik.http.routers.dashboard.service=api@internal" - "traefik.http.routers.dashboard.middlewares=auth" - "traefik.http.middlewares.auth.basicauth.users=admin:$$apr1$$..."
nextcloud: image: nextcloud:latest labels: - "traefik.enable=true" - "traefik.http.routers.nextcloud.rule=Host(`nextcloud.example.com`)" - "traefik.http.routers.nextcloud.entrypoints=websecure" - "traefik.http.routers.nextcloud.tls.certresolver=letsencrypt" - "traefik.http.services.nextcloud.loadbalancer.server.port=80"
volumes: traefik-certs:And you’d repeat that label block — slightly different — for every service. For five services you’re writing 20–30 labels. For fifteen services you’re writing 60–90 labels across multiple compose files, trying to remember if traefik.http.routers.myapp.tls=true and traefik.http.routers.myapp.tls.certresolver=letsencrypt are both needed or just one of them. (It depends.)
The power is real. The ergonomics are a tax you pay for that power.
HTTPS and ACME: Automatic vs Configured
Both proxies support Let’s Encrypt out of the box. Both support DNS-01 challenges for wildcard certs.
Caddy makes HTTP-01 work with zero config — point a domain at your box and Caddy handles the cert. For DNS-01 (behind Cloudflare proxy, no port 80 exposure), you add the caddy-dns/cloudflare module and a couple lines to your Caddyfile:
{ acme_dns cloudflare {env.CF_API_TOKEN}}
*.example.com { tls { dns cloudflare {env.CF_API_TOKEN} } @nextcloud host nextcloud.example.com handle @nextcloud { reverse_proxy nextcloud:80 } # ... more matchers}Modules require either building a custom Caddy binary or using the caddy:builder Docker image. It’s a one-time step that most people copy-paste from the docs and never think about again.
Traefik handles DNS-01 through certificate resolvers in the static config and environment variables per DNS provider. It works. The config is more verbose but the concept maps well to what you already know. Wildcard certs get issued once and shared across all *.example.com routes automatically, which is genuinely elegant.
One gotcha that bites everyone: if you’re behind Cloudflare with the proxy enabled, HTTP-01 will route through Cloudflare’s CDN edge rather than hitting your box. DNS-01 is mandatory in that setup. Both proxies handle this fine — you just need to know to ask for it.
Docker Discovery: Labels Win
Traefik’s killer feature is dynamic Docker discovery. Add labels to a container, Traefik picks it up without a restart. Spin down the container, the route disappears. It’s actually magical the first time you see it work.
Caddy doesn’t have this natively. You have two options:
- Caddy with
caddy-docker-proxy— a companion container that watches the Docker API and generates Caddyfile snippets from labels (similar UX to Traefik labels, different format). - Caddy as static config — just edit the Caddyfile, reload with
caddy reload. It’s adocker execor a file edit. Not automatic, but not painful either.
For most single-host homelab setups, the static Caddyfile approach is fine. You’re not spinning containers up and down that often. When you do add a service, editing two lines in a Caddyfile takes thirty seconds.
If you’re running a stack where containers genuinely come and go — say, you’re running dev environments or on-demand services — Traefik’s dynamic discovery goes from a nice feature to load-bearing infrastructure.
Kubernetes: Traefik Native, Caddy Optional
If you’re running k3s at home (and honestly, you might be — welcome to the rabbit hole), Traefik ships as the default IngressController in k3s. It just works. You get IngressRoute CRDs, Middleware objects, the dashboard, the whole thing.
apiVersion: traefik.io/v1alpha1kind: IngressRoutemetadata: name: nextcloudspec: entryPoints: - websecure routes: - match: Host(`nextcloud.example.com`) kind: Rule services: - name: nextcloud port: 80 tls: certResolver: letsencryptCaddy has a Kubernetes operator and can work as an ingress controller, but it’s not the default anywhere and the ecosystem maturity shows. If Kubernetes is in your future, Traefik is the lower-friction path.
Auth and Middleware
Both proxies support forwarding auth to Authelia or Authentik. The config differs.
Caddy handles it with the forward_auth directive:
vault.example.com { forward_auth authelia:9091 { uri /api/verify?rd=https://auth.example.com copy_headers Remote-User Remote-Groups Remote-Name Remote-Email } reverse_proxy vaultwarden:8080}Traefik defines middleware once and reuses it:
# Define once (on the Traefik container or in a dynamic config file)labels: - "traefik.http.middlewares.authelia.forwardauth.address=http://authelia:9091/api/verify?rd=https://auth.example.com" - "traefik.http.middlewares.authelia.forwardauth.trustForwardHeader=true" - "traefik.http.middlewares.authelia.forwardauth.authResponseHeaders=Remote-User,Remote-Groups,Remote-Name,Remote-Email"
# Apply to any servicelabels: - "traefik.http.routers.vault.middlewares=authelia@docker"Traefik’s middleware-as-reusable-object model is genuinely better for large stacks. Define authelia@docker once, apply it to twenty services. With Caddy you’re copying the forward_auth block into every site block. There are ways to reduce repetition with Caddy snippets, but Traefik’s model is cleaner at scale.
HTTP/3 and Performance
Both support HTTP/3 (QUIC). Caddy enables it by default. Traefik requires enabling the http3 experimental feature per entrypoint.
Performance-wise: for a homelab with a few dozen requests per second, it doesn’t matter. Both are written in Go, both are fast, both will idle at a few megabytes of RAM and never be the bottleneck. The benchmarks you find online are measuring scenarios your home server will never see.
The real performance question is startup time and config reload. Caddy’s caddy reload is fast and graceful. Traefik’s dynamic config reloads from label changes without any intervention. Both are fine.
Logging and Observability
Traefik has a built-in dashboard at traefik.example.com (or wherever you expose it) showing services, routers, middlewares, and recent errors. For debugging why a service isn’t routing correctly, the dashboard is genuinely useful.
# Traefik access logs (structured JSON by default)docker logs traefik 2>&1 | tail -50
# Caddy access logs (enable in Caddyfile)# Add to your site block:# log {# output file /var/log/caddy/access.log# format json# }docker logs caddy 2>&1 | tail -50Caddy’s logging requires opt-in per site block. It outputs clean JSON. No dashboard, but honestly for a Grafana+Loki setup, shipping JSON logs is all you need.
For Prometheus metrics: both expose /metrics. Traefik has a dedicated metrics entrypoint. Caddy needs the caddy-prometheus module.
Common Gotchas
Trailing slashes. Both proxies can mangle paths. If your service lives at /app/ but the proxy strips the prefix, you get 404s. Test this. Always.
Host header. Some services (Nextcloud, Vaultwarden) validate the Host header. Make sure your proxy passes it through correctly and that your trusted_proxies / TRUSTED_PROXIES env var on the service matches.
Cloudflare proxy + DNS-01. If Cloudflare is proxying your domain (orange cloud), HTTP-01 validation goes through Cloudflare’s edge and fails. Switch to DNS-01. Both proxies handle this — just configure it before you go live and spend an hour wondering why certs aren’t issuing.
Traefik docker.sock permissions. Mounting the Docker socket read-only (/var/run/docker.sock:ro) is fine for discovery but some operations need write access. Start with read-only. Only grant write if something breaks.
Caddy’s www redirect. Caddy automatically redirects www.example.com to example.com if you only define the bare domain. Usually what you want. Sometimes not. Know it exists.
Traefik label escaping in shell. Dollar signs in basicauth passwords need to be doubled ($$) inside Docker Compose label strings. This will bite you exactly once and you will remember it forever.
Five Services, One Domain: Real Stack Example
Here’s what a five-service stack looks like on each proxy.
Caddy (Caddyfile + Docker Compose):
nextcloud.example.com { reverse_proxy nextcloud:80 }git.example.com { reverse_proxy gitea:3000 }vault.example.com { reverse_proxy vaultwarden:8080 }jellyfin.example.com { reverse_proxy jellyfin:8096 }metrics.example.com { basicauth { admin $2a$14$hashedpassword } reverse_proxy prometheus:9090}Seven lines. Done. TLS is automatic for all five.
Traefik: ~80 labels across your compose files. Each service needs enable=true, a router rule, entrypoint, certresolver, and service port. Multiply by five. It’s not complex — it’s just verbose.
The Verdict
Choose Caddy if:
- You’re running one host with multiple subdomains
- You want HTTPS to be completely automatic with zero ceremony
- You prefer reading config over writing labels
- You don’t need Kubernetes
- You want to onboard someone else to your homelab without explaining what a middleware is
Choose Traefik if:
- You want dynamic container discovery (no config changes when adding services)
- You’re running k3s or any Kubernetes flavor
- You have a large service count and want reusable middleware chains
- You like the dashboard for debugging
- You’re already deep in the CNCF ecosystem and Traefik fits naturally
Both will serve your homelab reliably for years. The choice isn’t really about features — it’s about which mental model you want to maintain at 2 AM when something stops routing. Caddy hands you one file. Traefik hands you a distributed system. Neither answer is wrong. They’re just different trades.
If you’re starting fresh and just want subdomains with HTTPS: Caddy. If you’re already labeling everything and want auto-discovery: Traefik. If someone’s pressuring you to pick right now: flip a coin and move on. There are more interesting problems on your homelab waiting.