nginx vs HAProxy vs Envoy vs Caddy vs Traefik
A decision framework, a 19-row capability matrix and a defaults table for choosing between nginx, HAProxy, Envoy, Caddy and Traefik, plus what each one is bad at.
Key points
- Pick on how configuration changes and what discovers your backends, because protocol support is broadly comparable and those two axes drive the operational cost.
- nginx open source has no active health checks and no dynamic upstream API; those are nginx Plus features, which is the single most consequential split in this comparison.
- Defaults differ in ways that cause outages: nginx rewrites
Hostby default and used HTTP/1.0 to upstreams until 1.29.7, Envoy times a route out at 15s, HAProxy has no default timeouts at all. - Caddy and Traefik set
X-Forwarded-*automatically, nginx and HAProxy require explicit directives, so a migration silently changes what the application sees.
All five of these proxies terminate TLS, route HTTP, load balance, and speak HTTP/2. The choice is not about capability, it is about two things: how a configuration change reaches the running process, and where the list of backends comes from. nginx and HAProxy are file-configured daemons that reload; Envoy is a data plane that expects a control plane to push it configuration; Caddy and Traefik generate their own configuration from an API or from platform metadata. Everything else in this comparison, including the defaults that cause outages during migrations, follows from that split.
The axes that actually differ#
- Configuration model and reload behaviour. File plus reload (nginx, HAProxy), API plus live push (Envoy, Caddy), or discovered from the platform (Traefik). Reloads matter when connections are long-lived: nginx and HAProxy both end up running two generations at once.
- Dynamic service discovery. Does the proxy learn about backends from DNS, a registry, container labels, or a control plane, and can it do so without a reload?
- Protocol surface. L4 TCP and UDP, HTTP/2 and HTTP/3 on both sides, h2c to upstream, gRPC features beyond plain proxying.
- Observability. How much you learn from the proxy without adding an exporter or a sidecar.
- TLS automation. Whether ACME issuance and renewal are built in, and what state that requires you to persist.
- Extensibility. Compiled modules, an interpreted plugin runtime, Lua, or Wasm, and whether extending means rebuilding the binary.
- Operational surface. How many moving parts must be running and correct for a request to be routed.
Capability matrix#
| Dimension | nginx (open source) | HAProxy | Envoy | Caddy v2 | Traefik v3 |
|---|---|---|---|---|---|
| Configuration format | Directive DSL | Sectioned config | YAML or JSON over protobuf schema | Caddyfile or JSON | YAML/TOML plus labels or CRDs |
| Applying a change | Reload spawns a new worker generation | Reload spawns a new process, listeners handed over | xDS push, live, or hot restart | Admin API or caddy reload, live | Provider watch, live |
| Backend discovery | DNS re-resolution via resolver, the resolve parameter (open source since 1.27.3), or a variable in proxy_pass | server-template with DNS/SRV resolvers, Runtime API | EDS, STRICT_DNS, LOGICAL_DNS | Dynamic upstreams from A/SRV records | Docker, Kubernetes, Consul, Nomad, file |
| Active health checks | Not in open source | Yes, check on each server | Yes, per cluster | Yes, health_uri | Yes, per service |
| Passive failure handling | max_fails and fail_timeout | observe layer7 with on-error | Outlier detection, per endpoint ejection | fail_duration, unhealthy_status | Circuit breaker middleware, per router not per server |
| Runtime admin API | Upstream reconfiguration is Plus only; open source gained a control API in 1.31.5 | Runtime API on the stats socket | Admin endpoint plus xDS | Full config API on port 2019 | Read-only API and dashboard |
| Built-in ACME | Official ngx_http_acme_module since 1.29.0, otherwise certbot | Experimental ACME client since 3.2, verify yours | No, certificates arrive via SDS | Yes, on by default | Yes, certificate resolvers |
| L4 TCP proxying | stream module | Core strength, mode tcp | tcp_proxy filter | Requires the layer4 plugin | TCP routers with SNI rules |
| UDP proxying | stream with udp | No generic UDP load balancing | udp_proxy filter | Plugin | UDP routers |
| HTTP/2 to upstream | Yes, proxy_http_version 2 since 1.29.4; gRPC via grpc_pass | Yes, proto h2 | Yes | Yes, transport versions h2c 2 | Yes, service scheme h2c |
| HTTP/3 downstream | 1.25 and later | 2.6 and later, experimental at first | Yes | Yes | v3, experimental earlier |
| gRPC beyond proxying | Proxying only | Proxying only | Transcoding, gRPC-Web, gRPC health checks | Proxying only | Proxying only |
| HTTP caching | proxy_cache, mature and disk backed | Small object cache, memory only | Cache filter, still maturing | Plugin | Plugin or commercial edition |
| Rate limiting | limit_req, limit_conn | Stick tables, the most flexible of the five | Local filter plus external rate limit service | Plugin | Rate limit and in-flight middlewares |
| AuthN/AuthZ hooks | auth_request | Lua or SPOE | ext_authz, jwt_authn, OAuth2 filters | forward_auth, basic_auth | ForwardAuth, BasicAuth middlewares |
| Extensibility | C modules, njs, Lua via OpenResty | Lua, SPOE, C | C++ filters, Lua, Wasm, ext_proc | Go modules, rebuild with xcaddy | Go plugins via Yaegi, Wasm |
| Metrics out of the box | stub_status, minimal | Stats page and Prometheus endpoint | Extensive counters and histograms, native tracing | Prometheus endpoint | Prometheus, Datadog, OpenTelemetry |
| Session affinity | ip_hash, hash, and sticky cookie since 1.29.6 (Plus only before) | Cookie insertion and stick tables | Ring hash, Maglev, cookie based stateful session | lb_policy cookie | Sticky cookie per service |
| Parts that must be running | One binary | One binary | Binary plus a control plane for anything dynamic | One binary | Binary plus access to the provider |
Defaults that differ and cause incidents#
These are the rows that break migrations, because the configuration you ported was relying on a default you never wrote down.
| Behaviour | nginx | HAProxy | Envoy | Caddy v2 | Traefik v3 |
|---|---|---|---|---|---|
| Upstream response timeout | proxy_read_timeout 60s | timeout server has no default, startup warns | Route timeout 15s | None by default | None by default |
| HTTP version to upstream | HTTP/1.1 since 1.29.7, HTTP/1.0 before it | HTTP/1.1 | HTTP/1.1 | HTTP/1.1 | HTTP/1.1 |
Host sent upstream | Rewritten to $proxy_host | Client value preserved | :authority preserved | Preserved | Preserved |
X-Forwarded-For | Not set, needs proxy_set_header | Not set, needs option forwardfor | Not appended unless use_remote_address is on | Set automatically | Set automatically |
X-Forwarded-Proto | Not set | Not set | Set automatically | Set automatically | Set automatically |
| Response buffering | On (proxy_buffering on) | Streams through fixed buffers | Streams | Streams | Streams |
| WebSockets with no extra config | No, needs Upgrade headers | Yes | No, needs upgrade_configs | Yes | Yes |
| Request body size cap | client_max_body_size 1m, returns 413 | Unlimited | Unlimited without a buffer filter | Unlimited | Unlimited |
| Request header size cap | large_client_header_buffers 4x8k | tune.bufsize 16k for the whole request head | max_request_headers_kb 60 | Go default, around 1 MB | Go default, around 1 MB |
Two of these deserve their own sentence. nginx defaulted to HTTP/1.0 upstream until 1.29.7, which is why so many nginx deployments have no upstream keep-alive at all: on those builds you need proxy_http_version 1.1, a keepalive directive in the upstream block, and proxy_set_header Connection "" together, and missing any one of them silently gives you a fresh TCP connection per request (see keep-alive and upstream connection pooling). From 1.29.7 nginx uses HTTP/1.1 upstream and caches keepalive connections by default, so check which behaviour your build has. And nginx being the only one of the five that rewrites Host by default is why porting a working Caddy or Traefik config to nginx produces immediate 404s from name-based virtual hosts.
The same route in five configurations#
Proxy /api/ to backend:8080 with the prefix stripped and forwarded headers set.
location /api/ {
proxy_pass http://backend:8080/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}frontend fe
bind :443 ssl crt /etc/ssl/site.pem
option forwardfor
http-request set-header X-Forwarded-Proto https
http-request replace-path /api/(.*) /\1 if { path_beg /api/ }
use_backend api if { path_beg /api/ }
backend api
timeout server 30s
server s1 backend:8080 checkexample.com {
handle_path /api/* {
reverse_proxy backend:8080
}
}- match: { path_separated_prefix: "/api" }
route: { cluster: backend, prefix_rewrite: "/", timeout: 30s }labels:
- traefik.http.routers.api.rule=Host(`example.com`) && PathPrefix(`/api/`)
- traefik.http.middlewares.api-strip.stripprefix.prefixes=/api
- traefik.http.routers.api.middlewares=api-strip@docker
- traefik.http.services.api.loadbalancer.server.port=8080The line count is not the interesting part. What is interesting is that the nginx and HAProxy versions must state the forwarded headers explicitly and the other three must not, and that the nginx version encodes prefix stripping in a trailing slash on proxy_pass that is easy to omit. That single character is covered in nginx proxy_pass and the trailing slash, and you can test variants in the proxy_pass URI simulator.
Choose by decision rule, not by preference#
Choose nginx if the backend set is static or DNS-resolvable, you need mature response caching, or you are already serving static files from it. Also choose it when slow clients matter: response buffering is on by default, and it is the one of the five that will spool a large response to disk to release the backend worker early, the argument in proxy buffering and streaming responses.
Choose HAProxy if the workload is connection-heavy or L4, you need fine-grained traffic control (stick tables for rate limiting, quotas and abuse handling, ACLs over anything in the request), or you need to drain and reweight servers from a script. The Runtime API on the stats socket enables, disables and reweights servers with no file edit and no reload, the operational capability nginx open source lacks entirely.
Choose Envoy if configuration must change from a program rather than a file, you need per-route and per-cluster observability by default, or you are proxying gRPC and want transcoding, gRPC-Web or gRPC health checking. It is the right data plane for a mesh or a control-plane-owned edge, and an expensive answer for three static backends. See Envoy listeners, routes and clusters.
Choose Caddy if TLS automation is the main problem and the routing is straightforward. Automatic HTTPS (issuance, renewal, OCSP stapling, HTTP to HTTPS redirects) works with no configuration beyond a hostname, and the whole config is reloadable over an API.
Choose Traefik if your backends are containers and their lifecycle is the routing source of truth. Labels or a Kubernetes CRD create and destroy routers automatically, removing an entire class of "deployed but not routed" incidents. That coupling is also the weakness: outside a container platform, Traefik is a file-configured proxy with fewer knobs than the alternatives.
What each one is bad at#
- nginx: no active health checks and no dynamic upstream reconfiguration API in the open source build, although cookie-based session persistence arrived there in 1.29.6. Observability is
stub_statusplus log parsing. Reloads are graceful but not free: the old worker generation lingers until its connections close, andworker_shutdown_timeouthas no default, so WebSocket traffic can keep old workers alive across many reloads. - HAProxy: no composability worth the name, so large configurations are generated rather than written. Content rewriting and caching are deliberately limited, and certificate lifecycle has historically been an external job. Reload starts a new process, and without
hard-stop-afterthe old ones persist as long as their connections do. - Envoy: verbose configuration with
@typeannotations everywhere, an API that has deprecated and moved fields across versions, and a real footprint per instance. Anything genuinely dynamic implies a control plane, another service to run, secure and debug. The steepest learning curve of the five by a wide margin. - Caddy: extending it means rebuilding the binary with xcaddy, so plugins are a build-time decision, and L4 proxying needs a third-party module. Fewer low-level tuning knobs than nginx or HAProxy. Automatic HTTPS needs outbound access to the ACME provider and a persistent data directory; lose the directory and you re-issue everything.
- Traefik: silent failure is the norm, because a mistyped label is just an unrecognised label, and debugging means reading the dashboard's model rather than a config file. Two breaking migrations so far (v1 to v2, v2 to v3, the latter changing matcher syntax). Reading the Docker socket grants the proxy container effective root on the host.
Failure modes when swapping one for another#
- Migrating away from nginx removes response buffering. Backend concurrency climbs, because every slow client now occupies a backend worker for the full download instead of an nginx buffer. Symptom: the application's connection pool saturates while proxy CPU is flat.
- Migrating to Envoy truncates long requests at 15 seconds. The route timeout default is far shorter than nginx's 60s
proxy_read_timeout. Symptom: 504 with response flagUTon exactly the requests that used to take 20 to 40 seconds. - Migrating to HAProxy with no timeouts set produces a startup warning about missing timeouts and, in production, connections that hang until the client gives up. HAProxy genuinely has no default here; set
timeout connect,timeout clientandtimeout serverin adefaultssection. Line them up against the other hops using the timeout ladder checker. - Migrating to Caddy or Traefik duplicates forwarded headers. They set
X-Forwarded-Forthemselves, so a portedproxy_set_header-style rule or an application that appends its own leaves two entries and breaks client IP extraction. Review the X-Forwarded-For header before trusting the leftmost value. - Migrating to nginx breaks name-based virtual hosts. nginx sends
Host: backend:8080(the value of$proxy_host) unless told otherwise. Symptom: the upstream serves its default site, usually a 404 or the wrong application.
Frequently asked questions#
Is HAProxy faster than nginx?#
For plain L4 and L7 proxying they are in the same class, and published numbers depend far more on TLS settings, buffer sizes, keep-alive behaviour and the test harness than on the proxy. Choose on features: HAProxy for stick tables, the Runtime API and TCP mode, nginx for caching, static file serving and response buffering.
What does nginx Plus add that matters for reverse proxying?#
The features most often missed in the open source build are active health checks, the dynamic reconfiguration API for upstreams, and the extended status metrics. Cookie-based session persistence (sticky) moved into the open source build in 1.29.6. If you need active health checks without paying for Plus, HAProxy, Envoy, Caddy and Traefik all have them built in.
Do I need Envoy if I am not running a service mesh?#
Usually not. Envoy's advantages (configuration pushed by a program, per-cluster observability, gRPC transcoding) only pay for their complexity when something is actually driving the configuration or when gRPC is central. A static Envoy bootstrap is a fine proxy, but so is a much shorter nginx or Caddy file.
Which reverse proxy handles TLS certificates automatically?#
Caddy and Traefik both include ACME clients and can issue and renew certificates without an external tool. Caddy enables it by default for any host name in the config; Traefik requires a certificate resolver and a persisted acme.json. Envoy expects certificates from elsewhere, nginx has shipped an official ACME module (ngx_http_acme_module) since 1.29.0, and HAProxy gained an experimental ACME client in 3.2.
Can Traefik or Caddy replace HAProxy for TCP load balancing?#
Traefik can, within limits: it supports TCP routers with SNI-based rules and UDP routers. Caddy needs a third-party layer4 module. Neither offers HAProxy's stick tables, connection queueing controls or mode tcp tuning depth, so for a demanding L4 tier HAProxy remains the stronger choice.
How do these compare for WebSockets?#
HAProxy, Caddy and Traefik proxy WebSocket upgrades with no extra configuration. nginx needs explicit Upgrade and Connection headers, plus proxy_http_version 1.1 on builds before 1.29.7, and Envoy needs upgrade_configs on the connection manager. All five then need their idle timeouts raised, which is the actual cause of most "WebSocket disconnects every 60 seconds" reports.
Primary sources#
Every normative claim on this page is checked against the specification or the vendor documentation listed here. Where behaviour is version dependent, the version is named in the text.
Found something wrong, or behaviour that differs on your version? Report it with the version number and a primary source. Anything substantive is fixed in the page and logged on the corrections page. See editorial standards for how pages are researched, sourced and reviewed.