Skip to content
Go back

Caddy vs Traefik

By SumGuy 10 min read
Caddy vs Traefik

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

/etc/caddy/Caddyfile
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.

docker-compose.yml
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:

  1. 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).
  2. Caddy as static config — just edit the Caddyfile, reload with caddy reload. It’s a docker exec or 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.

ingressroute.yaml
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: nextcloud
spec:
entryPoints:
- websecure
routes:
- match: Host(`nextcloud.example.com`)
kind: Rule
services:
- name: nextcloud
port: 80
tls:
certResolver: letsencrypt

Caddy 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:

docker-compose.yml
# 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 service
labels:
- "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.

Terminal window
# 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 -50

Caddy’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):

/etc/caddy/Caddyfile
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:

Choose Traefik if:

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.


Share this post on:

Send a Webmention

Written about this post on your own site? Send a webmention and it'll show up above once verified.


Next Post
Promtail to Alloy Migration: A Practical Diff

Discussion

Powered by Garrul . Sign in with GitHub or Google, or post anonymously.

Related Posts