Traefik vs Caddy vs Nginx: Reverse Proxy Compared (With Real Configs)

Share
Traefik vs Caddy vs Nginx: Reverse Proxy Compared (With Real Configs)
Three glowing servers for Nginx, Caddy, and Traefik with neon traffic lines converging into one path

Pick the wrong reverse proxy and you'll spend a weekend fighting trailing slashes, broken WebSockets, or a TLS renewal that silently died three months ago. The three that actually matter today are Nginx, Caddy, and Traefik. They all terminate TLS and forward requests to a backend, but they get there with completely different philosophies — and that difference is the whole decision.

Here's the short version before the detail: Nginx gives you total manual control and the lowest overhead. Caddy gives you automatic HTTPS and the cleanest config for a fixed set of sites. Traefik gives you dynamic, label-driven routing that updates itself as containers come and go. Everything below is about how that plays out when you're proxying to a backend app.

The mental model is different for each

This is the part people skip, and it's why they fight the tool later.

Nginx is a static config compiler. You write a file, you reload, it serves exactly what you wrote. Nothing is automatic. It doesn't know your containers exist, it won't fetch a certificate on its own, and it will happily proxy to a backend that doesn't exist and return 502 all day. That's not a flaw — it's the contract. You get predictability and almost zero surprise.

Caddy is convention over configuration. The headline feature is automatic HTTPS: point it at a domain, and it provisions and renews a Let's Encrypt certificate without you writing a single line about ACME. Its reverse_proxy directive also sets sane forwarding headers and handles WebSocket upgrades for you. The config file (the Caddyfile) is short because Caddy fills in the boring parts.

Traefik is a dynamic edge router. It was built for the world where backends are ephemeral. Instead of you declaring routes, Traefik discovers them — it watches a provider (Docker, Kubernetes, a file, Consul) and builds routes from labels or annotations in real time. Spin up a container with the right labels and it's routable seconds later, no reload. That's powerful and also where most of the confusion lives.

The same backend, proxied three ways

Say you have an app listening on 127.0.0.1:3000 and you want to serve it at https://app.example.com. Here's that exact job in all three.

Nginx

server {
    listen 443 ssl;
    server_name app.example.com;

    ssl_certificate     /etc/letsencrypt/live/app.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/app.example.com/privkey.pem;

    location / {
        proxy_pass http://127.0.0.1:3000;
        proxy_set_header Host              $host;
        proxy_set_header X-Real-IP         $remote_addr;
        proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

You handle the certificate separately (certbot or acme.sh), and you set every forwarding header by hand. Miss X-Forwarded-Proto and your app generates http:// redirect loops behind TLS. This is the single most common Nginx-behind-TLS bug.

Caddy

app.example.com {
    reverse_proxy 127.0.0.1:3000
}

That's the whole thing. Caddy fetches and renews the certificate, redirects HTTP to HTTPS, sets X-Forwarded-For, X-Forwarded-Proto, and X-Forwarded-Host, and passes the original Host header through. WebSockets work with no extra config. For a fixed set of domains, nothing beats this for signal-to-noise.

Traefik (Docker labels)

services:
  app:
    image: myapp:latest
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.app.rule=Host(`app.example.com`)"
      - "traefik.http.routers.app.entrypoints=websecure"
      - "traefik.http.routers.app.tls.certresolver=le"
      - "traefik.http.services.app.loadbalancer.server.port=3000"

There's no per-app config file — the routing lives on the container itself. Traefik reads the labels, builds the router, requests the certificate through its configured resolver, and starts forwarding. Kill the container and the route disappears. This is the model that makes Traefik worth its complexity, and it's also why a single typo in a label name means your service just silently isn't routed.

⚠️ The proxy_pass trailing-slash trap (Nginx)

This is the gotcha that has eaten more hours than every other reverse-proxy mistake combined. In Nginx, whether proxy_pass ends in a slash changes what URI the backend receives.

# No trailing slash — original URI passed through unchanged
location /api/ {
    proxy_pass http://127.0.0.1:3000;
}
# Request /api/users  ->  backend receives /api/users
# Trailing slash — the matched location prefix is STRIPPED and replaced
location /api/ {
    proxy_pass http://127.0.0.1:3000/;
}
# Request /api/users  ->  backend receives /users

One character flips whether your backend sees /api/users or /users. If your app is mounted at root and you forget the slash, every route 404s. If it expects the prefix and you add the slash, same result. Decide which your backend wants, then be deliberate about it.

The equivalent in the other two:

  • Caddy passes the full path by default. To strip a prefix, use handle_path instead of handlehandle_path removes the matched prefix before proxying.
  • Traefik keeps the full path by default. To strip, attach a stripprefix middleware to the router.

WebSockets

If you're proxying anything real-time — a dashboard, a chat app, live logs, Uptime Kuma — this matters.

Nginx needs explicit upgrade handling, and the cleanest way is a map block so you don't break normal requests:

map $http_upgrade $connection_upgrade {
    default upgrade;
    ''      close;
}

server {
    # ...
    location / {
        proxy_pass http://127.0.0.1:3000;
        proxy_http_version 1.1;
        proxy_set_header Upgrade    $http_upgrade;
        proxy_set_header Connection $connection_upgrade;
    }
}

Caddy and Traefik both detect the Upgrade header and switch to WebSocket mode automatically. No extra config. This is a genuine ergonomic win for both over Nginx, and a frequent source of "it works in dev but breaks behind the proxy" tickets.

Load balancing across multiple backends

When one backend becomes several:

Nginx — an upstream block:

upstream app_backend {
    least_conn;
    server 127.0.0.1:3000;
    server 127.0.0.1:3001;
    server 127.0.0.1:3002 backup;
}

server {
    location / {
        proxy_pass http://app_backend;
    }
}

Caddy — list the upstreams inline and pick a policy:

app.example.com {
    reverse_proxy 127.0.0.1:3000 127.0.0.1:3001 127.0.0.1:3002 {
        lb_policy least_conn
        health_uri /healthz
    }
}

Traefik — add multiple servers to the service. With the Docker provider and a replicated service, it does this automatically as you scale replicas; with the file provider you declare them:

http:
  services:
    app:
      loadBalancer:
        servers:
          - url: "http://127.0.0.1:3000"
          - url: "http://127.0.0.1:3001"
        healthCheck:
          path: /healthz
          interval: 10s

All three do round-robin and health checks. Nginx's algorithms are the most mature; Traefik's advantage is that scaling replicas reshapes the pool with no config change.

TLS, the real differentiator for most people

This is where the daily-driver experience splits hard.

  • Nginx does not manage certificates. You bolt on certbot or acme.sh, wire up a renewal cron, and reload Nginx after each renewal. It works, it's rock solid once set up, but it's a separate moving part you own. ⚠️ The classic failure is a renewal hook that doesn't reload Nginx, so a fresh cert sits on disk while Nginx serves the expired one.
  • Caddy manages certificates as a core feature. Issue, renew, OCSP stapling — all automatic and on by default. For most self-hosted setups this alone is the reason to use it.
  • Traefik manages certificates through ACME cert resolvers, including DNS-01 challenges for wildcard certs, configured once at the entrypoint level and then referenced per-router. Excellent in container environments, slightly fiddly to configure the first time.

Performance and footprint

For a straight reverse proxy on a single box, all three are fast enough that the proxy is almost never your bottleneck — your backend is. That said, the ordering is consistent across every benchmark I've seen: Nginx on top, Caddy close behind, Traefik a step back.

  • Nginx (C, event-driven) has the lowest memory footprint and the longest production track record at scale. If you're running a high-traffic edge on constrained hardware, this is the safe pick.
  • Caddy (Go) uses a bit more memory and has GC pauses that are irrelevant for typical loads. The convenience usually outweighs the marginal overhead.
  • Traefik (Go) carries the most overhead of the three because of everything it's doing — service discovery, dynamic config, metrics, dashboard. That cost buys you the dynamic routing; if you don't need that, you're paying for nothing.

By the numbers

These come from published 2025–2026 benchmarks on commodity hardware (one representative run used an Intel Core i5-12400, 32 GB RAM, NVMe, driven by wrk and Apache Bench over a simple HTTP proxy path). Treat them as relative, not absolute — your own numbers will shift with kernel tuning, keep-alive settings, TLS config, and how heavy the backend is. The shape is what matters, and it holds up across sources.

Metric (simple HTTP proxy) Nginx Caddy Traefik
Throughput Highest (baseline) ~7,920 req/s ~7,340 req/s
Median latency Lowest ~12.6 ms ~13.6 ms
Idle memory, basic setup ~15–50 MB ~30–100 MB ~50–200 MB
TLS handshakes/sec ~8,000 ~6,500 Trails both
Relative raw throughput 100% ~70–80% of Nginx ~70–80% of Nginx

A few things to read out of that table:

  • The gap is real but only matters at scale. Caddy and Traefik land roughly 20–30% behind Nginx on raw throughput. At the latency level, the difference between the three is on the order of a single millisecond at the median — a 12.6 ms vs 13.6 ms split that nobody will ever feel. If you're under ~10,000 requests per second per instance, which covers the overwhelming majority of self-hosted and small-fleet setups, the performance difference is noise. Pick on ergonomics, not benchmarks.
  • Memory is where the philosophy shows up. Nginx idles in the tens of megabytes. Traefik's footprint can climb several times higher because it's holding service-discovery state, metrics, and the dashboard in memory. On a Pi-class node or a tightly-packed VPS, that's a genuine consideration; on a 32 GB box it's a rounding error.
  • TLS handshake throughput follows the same order. Nginx's connection pooling gives it the edge on handshake-heavy workloads. Caddy trades some of that for fully automatic certificate management, which for most people is the better deal.
  • Adoption mirrors the trade-offs. Nginx is the most widely deployed of the three by a wide margin — it serves a third or more of all websites, and one 2025 estimate put the reverse-proxy split at roughly Nginx 65%, Traefik 22%, Caddy 13%. That matters less for performance than for the practical stuff: when something breaks at 2 a.m., Nginx has twenty years of Stack Overflow answers behind it. Traefik's docs and community skew container-native; Caddy's are smaller but unusually good.

⚠️ Don't make a production decision off any single blog benchmark, this one included. The numbers above are directionally reliable across many independent tests, but the absolute values are tied to one specific config. If raw throughput is genuinely your constraint, benchmark all three on your hardware with your TLS settings and your backend before committing.

So which one

Match the tool to the situation instead of picking a favourite:

  • Static set of sites, want it boring and bulletproof, comfortable owning certbot → Nginx. Also the right call when you need fine-grained control: complex rewrites, caching, rate limiting, request body tuning. Nothing else matches its knobs.
  • Static set of sites, want automatic HTTPS and minimal config → Caddy. This is the best default for most self-hosted single-server setups. The Caddyfile for ten services fits on one screen and renews its own certs forever.
  • Docker/Compose or Kubernetes with services that come and go → Traefik. Label-driven routing that tracks your containers is exactly what it's for, and fighting that workflow into Nginx with a templating sidecar is more painful than just running Traefik.

If you're running a fixed handful of self-hosted apps behind one IP, start with Caddy and only reach for Nginx when you hit something Caddy can't express cleanly. If your apps live in Compose stacks you scale up and down, Traefik earns its complexity. Nginx remains the answer when you need control or you're squeezing a busy edge onto small hardware.

Security notes worth flagging

A reverse proxy is a security boundary, so a few things regardless of which you pick:

  • Bind your backends to 127.0.0.1, never 0.0.0.0. If the app only ever talks to the proxy, it has no business listening on a public interface. The proxy config means nothing if someone can hit :3000 directly.
  • Strip or normalize inbound X-Forwarded-* headers from untrusted clients. A client can forge X-Forwarded-For; if your app trusts it blindly for auth or rate limiting, that's a hole. Caddy and Traefik set these from the real connection, but make sure you're not appending to a forged value.
  • Don't leak backend version banners. Set server_tokens off in Nginx; Caddy and Traefik don't advertise versions by default.
  • Lock down the Traefik dashboard. It's incredibly handy and incredibly dangerous exposed — it maps your entire internal routing. If you enable it, put it behind auth and never on a public entrypoint.

References

  • Nginx ngx_http_proxy_module documentation — proxy_pass behaviour and headers
  • Caddy reverse_proxy directive documentation
  • Traefik routers, services, and middlewares documentation
  • Let's Encrypt ACME challenge types (HTTP-01 vs DNS-01)
  • Independent 2025–2026 reverse-proxy benchmarks (homelab i5-12400 throughput/latency tests; published memory-footprint and TLS-handshake comparisons)

Read more