Have you ever heard "Caddy is great" and wondered what actually makes it different from Nginx? Or maybe you were evaluating Traefik for a Kubernetes setup, went down a rabbit hole of comparison articles, and gave up halfway through?
Part 1 ("For People Who've Configured a Reverse Proxy but Can't Explain How It Works") covered how reverse proxies work under the hood. Part 2 — this article — focuses on tool selection: the design philosophies behind Nginx, Caddy, Traefik, and HAProxy, and a mental model for deciding which one fits your use case. If you haven't read Part 1, one baseline is enough: a reverse proxy is software that sits between the browser and the app server, relaying traffic between them.
The four tools and what each one is
A quick one-liner for each:
- Nginx: the most widely used general-purpose web server and reverse proxy
- Caddy: simple config file, automatic TLS certificate management built in
- Traefik: designed for dynamic routing config in container environments
- HAProxy: a full-featured load balancer that operates at both the TCP (L4) and HTTP (L7) levels
All four can do load balancing, reverse proxying, and TLS termination. Nginx can handle load balancing. HAProxy can work as a reverse proxy. Capability overlap is the point — what differs is design emphasis, not capability ceiling. That emphasis is what drives the choice.
Consider what "emphasis" means in practice. Nginx was built as a general-purpose machine: serve static files, reverse proxy, act as a load balancer — all from one piece of software. Traefik was born from a specific frustration: having to rewrite config files every time a container starts or stops. The starting point differs, so even when both do the same job, one is working in its natural mode and the other is being stretched.
Read this article through that lens: where does each tool's center of gravity sit?
One tool you won't see in this comparison is Envoy. Envoy has native support for circuit breaking, distributed rate limiting, and gRPC load balancing, and it's usually encountered in the context of service meshes like Istio. The configuration complexity and operational overhead are in a different league. For someone who's only used Nginx, it's too big a jump. Envoy does appear in the bonus section at the end — not as "here's the next step to consider," but as "here's what these four can't reach."
Nginx vs Caddy: what's actually different
Put the minimal HTTPS configuration for each side by side.
The Nginx version:
server {
listen 443 ssl;
server_name example.com;
ssl_certificate /etc/ssl/certs/example.com.crt;
ssl_certificate_key /etc/ssl/private/example.com.key;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
location / {
proxy_pass http://localhost:3000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
The equivalent Caddyfile:
example.com {
reverse_proxy localhost:3000
}
The line count difference might look like Caddy is skipping things — it isn't. With Caddy, TLS certificate retrieval and renewal happen automatically. It fetches a certificate from Let's Encrypt and renews it automatically well before the 90-day expiry, with no manual steps needed. There's no ssl_certificate line because you don't supply the cert file yourself. There are no proxy_set_header lines because Caddy forwards those headers by default.
If you've ever wrestled with Nginx TLS config, Caddy's automatic TLS will be a real relief. Especially if you've had a service go down because a certificate renewal slipped through the cracks.
That said, Caddy's simplicity cuts both ways. The convenient defaults make it harder to reason about what's happening when you need to override something. Nginx's verbosity is a feature: every directive is explicit, which makes troubleshooting more straightforward.
The question to ask when choosing: do you want to control TLS yourself, or do you want to hand it off?
Caddy does have cases where it doesn't fit. Internal services — those not exposed to the internet — won't work with ACME-based automatic TLS. Let's Encrypt challenges require the domain to be publicly reachable, so for a service on an internal DNS, you'd need extra DNS-01 challenge configuration to get Caddy's automatic TLS working. Going in with the assumption that "Caddy means TLS is automatic" will cause problems for internal services.
There's also a debugging gap. caddy reload and caddy validate error messages can be less specific than Nginx's. Nginx's nginx -t gives you the exact line number of the problem; Caddy sometimes only tells you "configuration is invalid." For a team with deep Nginx experience, troubleshooting an unfamiliar tool takes longer than it should.
Why Traefik pairs well with Kubernetes
Traefik's starting point was: "I don't want to rewrite config files every time a container is added."
With Nginx, you write this:
upstream backend {
server app1:3000;
server app2:3000;
}
Adding app3 means opening the config file, adding server app3:3000;, and reloading Nginx. Manual every time.
Traefik works differently. With Docker, you attach labels to the container at startup and it joins the routing automatically.
services:
app:
image: my-app
labels:
- "traefik.enable=true"
- "traefik.http.routers.app.rule=Host(`example.com`)"
- "traefik.http.services.app.loadbalancer.server.port=3000"
The moment that container starts, Traefik reads the Docker labels and adds the route. When the container stops, the route disappears. No config reload needed.
In Kubernetes, the same thing happens through Ingress annotations. Deploy a new service with kubectl apply, and Traefik detects it and updates routing. The "update config files on every deploy across 20 containers" workflow goes away.
If static config files are enough, Nginx works fine. When containers come and go frequently, that dynamic config automation is the reason to choose Traefik.
Going deeper into Kubernetes: Traefik has its own CRD called IngressRoute. It's more expressive than the standard Ingress resource, but it means writing Traefik-specific config into your K8s manifests. Nginx Ingress Controller is built on the standard Ingress resource and uses annotations to control fine-grained behavior.
Which you choose depends on whether you'd rather write in the proxy's config language or stay close to K8s manifests. If your team already knows Nginx, the Nginx Ingress Controller has a lower learning curve. If your service churn is high and you want routing definitions managed as code inside K8s, Traefik's IngressRoute is where it earns its keep.
Why HAProxy gets chosen as a dedicated load balancer
Ask the question "Nginx can load balance too, so why choose HAProxy?" and the answer surfaces.
A health check in HAProxy config looks like this:
backend api_servers
balance roundrobin
option httpchk GET /health HTTP/1.1
http-check expect status 200
default-server inter 2s fall 3 rise 2 maxconn 30
server app1 10.0.0.1:3000 check
server app2 10.0.0.2:3000 check
inter 2s fall 3 rise 2 means: check every 2 seconds, mark down after 3 consecutive failures, mark up after 2 consecutive successes. Nginx can do something similar, but HAProxy lets you express this level of control directly in the config.
Where HAProxy really stands out is observability — seeing what's happening in real time. The built-in stats dashboard shows connection counts, error rates, and latency per backend, live. If one backend starts spiking in latency because it's running out of memory, you can dig through Nginx access logs to find the cause eventually. Opening HAProxy's dashboard makes it immediately obvious which backend has the elevated error rate. When you're handling high traffic and need to know instantly which backend is the bottleneck, that data matters.
HAProxy also handles both TCP (L4) and HTTP (L7) load balancing. Beyond HTTP apps, you can balance database connections and manage WebSocket traffic in config.
HAProxy makes sense when traffic is heavy and you need fine-grained control over backend behavior. Reaching for it on a simple reverse proxy setup means absorbing configuration complexity without much benefit.
Picking one for your situation
Mapping the discussion to four scenarios:
| Scenario | Choice | Main reason |
|---|---|---|
| Simple VPS + TLS termination | Caddy | Automatic TLS removes certificate management overhead |
| Small Docker microservices (stable topology) | Nginx | Proven track record, large community, explicit config |
| Small Docker microservices (high churn) | Traefik | Routing updates automatically as containers start and stop |
| Kubernetes cluster | Traefik / Nginx Ingress | Depends on what the team already knows |
| High-traffic load balancer | HAProxy | Health check granularity and built-in observability |
For a simple VPS with TLS termination, Caddy is the pick. Offloading certificate management is a meaningful gain, and the short config file is easy to maintain. It suits personal projects and small services.
For small Docker-based microservices, Nginx or Traefik are both candidates. If the container count is stable and doesn't change much, Nginx is plenty. If you're adding and removing services regularly, Traefik's dynamic config earns its place.
For Kubernetes, Traefik or Nginx Ingress Controller. Traefik has a head start on K8s integration, but Nginx Ingress Controller is mature and fully viable. If your team has deep Nginx knowledge, there's no good reason to switch to Traefik just because it's the trendy choice.
For a high-traffic load balancer, HAProxy. That's where its design philosophy — fine-grained control and first-class observability — actually comes into play.
My last personal choice was Caddy on a VPS. The reason was simple: I'd taken a service down once because I forgot to renew a certificate. Since switching to Caddy, I haven't thought about certificates once. That alone was worth it.
Config files and benchmark
Working examples for all four proxies are in shinagawa-web/reverse-proxy-bench — each proxy has its own directory with a config file and a docker-compose.yml. Run any of them locally with make bench-nginx (requires Docker and k6).
The benchmark runs 60 seconds at 20 concurrent virtual users against a minimal Go backend (/ping → 200 OK), with default configurations:
| Proxy | req/s | p50 (ms) | p95 (ms) | p99 (ms) |
|---|---|---|---|---|
| HAProxy | 10,762 | 1.53 | 3.45 | 4.89 |
| Traefik | 7,946 | 2.15 | 4.62 | 6.48 |
| Caddy | 7,535 | 2.28 | 4.93 | 6.81 |
| Nginx | 4,386 | 4.37 | 5.43 | 6.83 |
These results reflect default configurations on a 2-vCPU GitHub Actions runner. Nginx in particular has significant headroom — worker_processes auto alone would narrow the gap considerably. The intent isn't to declare a winner, but to show how the design emphasis of each tool translates into behavior under the same load.
Bonus: what these four can't do
For intermediate and above: the areas where all four have the same blind spot.
Distributed rate limiting
Nginx, Caddy, Traefik, and HAProxy all have rate limiting, but all of them apply limits per instance. Scale out to four instances and your limit effectively quadruples. A "100 requests per second" rule becomes "400 requests per second" in practice. If you hit a situation right after a scale-out where rate limiting suddenly seems broken, the connection to instance count isn't always obvious.
You can share state via an external store like Redis — each proxy instance increments a Redis counter on every request and returns 429 when the threshold is hit. That turns Redis into a single point of failure, so you also need an availability design for Redis. Every request that touches Redis also adds the round-trip latency — typically 0.5–2ms on a local network, but under traffic spikes Redis itself can become the bottleneck, slowing the proxy down across the board.
The tools that provide this natively are Kong and Envoy. Kong's Rate Limiting Advanced plugin uses a hybrid approach: local counters per instance, synchronized to a shared store on a configurable interval. Requests don't need a Redis write on every hit, which reduces the latency overhead significantly. Envoy is designed to integrate with a dedicated external rate limit service from the start.
Circuit breaking
Suppose an upstream service starts responding slowly. The proxy holds connections open until timeout, and new requests keep trying to connect. The connection pool fills with waiting connections, other backends start backing up too, and failures cascade through the whole system. That's a cascading failure.
A circuit breaker cuts that chain. When it detects consecutive failures to an upstream, it stops sending new requests there and returns errors immediately. Envoy has this natively via outlier_detection — setting consecutive_5xx: 5 and base_ejection_time: 30s is enough to get it working. None of these four have an equivalent. Traefik's circuit breaker plugin uses similar configuration, but it operates at the route level rather than the stream level, which makes it less effective for long-lived streams like gRPC where a single stream can hold a backend connection open for minutes.
Native JWT verification / OIDC
Traefik and Caddy have plugin support for this, but many of the available plugins are community-maintained. How they handle JWKS endpoint caching and key rotation varies, and some don't document their behavior clearly. If a signing key rotates and the plugin doesn't refresh its cache in time, valid tokens start getting rejected. Nginx requires Lua or the NJS module. HAProxy is in a similar position.
If you want to handle authentication at the proxy layer reliably, Kong or Envoy are the more practical choices. Kong's JWT plugin caches JWKS with configurable TTL and handles key rotation predictably — which matters when authentication is in the critical path.
gRPC load balancing
With HTTP/1.1, a new connection is opened per request (or closed quickly), so the proxy distributes at the request level. gRPC runs over HTTP/2, which multiplexes requests over a single long-lived connection.
Put Nginx in front of a gRPC backend as a load balancer, and Nginx maintains one persistent connection to each backend. Those connections stay alive for a long time. Stream distribution becomes uneven, and specific backends end up with concentrated load. You can have 10 backends but effectively route to only 1 or 2 of them.
Nginx can partially work around this with keepalive on the upstream block combined with HTTP/2 proxying, but stream-level routing — deciding per-request which backend to send to — remains limited. Traefik opens multiple HTTP/2 streams per backend connection and rebalances more aggressively than Nginx's static upstream model, so load spreads more evenly across backends. Envoy is built gRPC-first, with stream-level load balancing as a baseline assumption.
This article was originally published by DEV Community and written by Kazu.
Read original article on DEV Community