Reverse proxy

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.

· 13 min read · How we verify this

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 Host by 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#

Dimensionnginx (open source)HAProxyEnvoyCaddy v2Traefik v3
Configuration formatDirective DSLSectioned configYAML or JSON over protobuf schemaCaddyfile or JSONYAML/TOML plus labels or CRDs
Applying a changeReload spawns a new worker generationReload spawns a new process, listeners handed overxDS push, live, or hot restartAdmin API or caddy reload, liveProvider watch, live
Backend discoveryDNS re-resolution via resolver, the resolve parameter (open source since 1.27.3), or a variable in proxy_passserver-template with DNS/SRV resolvers, Runtime APIEDS, STRICT_DNS, LOGICAL_DNSDynamic upstreams from A/SRV recordsDocker, Kubernetes, Consul, Nomad, file
Active health checksNot in open sourceYes, check on each serverYes, per clusterYes, health_uriYes, per service
Passive failure handlingmax_fails and fail_timeoutobserve layer7 with on-errorOutlier detection, per endpoint ejectionfail_duration, unhealthy_statusCircuit breaker middleware, per router not per server
Runtime admin APIUpstream reconfiguration is Plus only; open source gained a control API in 1.31.5Runtime API on the stats socketAdmin endpoint plus xDSFull config API on port 2019Read-only API and dashboard
Built-in ACMEOfficial ngx_http_acme_module since 1.29.0, otherwise certbotExperimental ACME client since 3.2, verify yoursNo, certificates arrive via SDSYes, on by defaultYes, certificate resolvers
L4 TCP proxyingstream moduleCore strength, mode tcptcp_proxy filterRequires the layer4 pluginTCP routers with SNI rules
UDP proxyingstream with udpNo generic UDP load balancingudp_proxy filterPluginUDP routers
HTTP/2 to upstreamYes, proxy_http_version 2 since 1.29.4; gRPC via grpc_passYes, proto h2YesYes, transport versions h2c 2Yes, service scheme h2c
HTTP/3 downstream1.25 and later2.6 and later, experimental at firstYesYesv3, experimental earlier
gRPC beyond proxyingProxying onlyProxying onlyTranscoding, gRPC-Web, gRPC health checksProxying onlyProxying only
HTTP cachingproxy_cache, mature and disk backedSmall object cache, memory onlyCache filter, still maturingPluginPlugin or commercial edition
Rate limitinglimit_req, limit_connStick tables, the most flexible of the fiveLocal filter plus external rate limit servicePluginRate limit and in-flight middlewares
AuthN/AuthZ hooksauth_requestLua or SPOEext_authz, jwt_authn, OAuth2 filtersforward_auth, basic_authForwardAuth, BasicAuth middlewares
ExtensibilityC modules, njs, Lua via OpenRestyLua, SPOE, CC++ filters, Lua, Wasm, ext_procGo modules, rebuild with xcaddyGo plugins via Yaegi, Wasm
Metrics out of the boxstub_status, minimalStats page and Prometheus endpointExtensive counters and histograms, native tracingPrometheus endpointPrometheus, Datadog, OpenTelemetry
Session affinityip_hash, hash, and sticky cookie since 1.29.6 (Plus only before)Cookie insertion and stick tablesRing hash, Maglev, cookie based stateful sessionlb_policy cookieSticky cookie per service
Parts that must be runningOne binaryOne binaryBinary plus a control plane for anything dynamicOne binaryBinary 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.

BehaviournginxHAProxyEnvoyCaddy v2Traefik v3
Upstream response timeoutproxy_read_timeout 60stimeout server has no default, startup warnsRoute timeout 15sNone by defaultNone by default
HTTP version to upstreamHTTP/1.1 since 1.29.7, HTTP/1.0 before itHTTP/1.1HTTP/1.1HTTP/1.1HTTP/1.1
Host sent upstreamRewritten to $proxy_hostClient value preserved:authority preservedPreservedPreserved
X-Forwarded-ForNot set, needs proxy_set_headerNot set, needs option forwardforNot appended unless use_remote_address is onSet automaticallySet automatically
X-Forwarded-ProtoNot setNot setSet automaticallySet automaticallySet automatically
Response bufferingOn (proxy_buffering on)Streams through fixed buffersStreamsStreamsStreams
WebSockets with no extra configNo, needs Upgrade headersYesNo, needs upgrade_configsYesYes
Request body size capclient_max_body_size 1m, returns 413UnlimitedUnlimited without a buffer filterUnlimitedUnlimited
Request header size caplarge_client_header_buffers 4x8ktune.bufsize 16k for the whole request headmax_request_headers_kb 60Go default, around 1 MBGo 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.

nginx
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;
}
haproxy
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 check
text
example.com {
	handle_path /api/* {
		reverse_proxy backend:8080
	}
}
yaml
- match: { path_separated_prefix: "/api" }
  route: { cluster: backend, prefix_rewrite: "/", timeout: 30s }
yaml
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=8080

The 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_status plus log parsing. Reloads are graceful but not free: the old worker generation lingers until its connections close, and worker_shutdown_timeout has 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-after the old ones persist as long as their connections do.
  • Envoy: verbose configuration with @type annotations 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 flag UT on 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 client and timeout server in a defaults section. Line them up against the other hops using the timeout ladder checker.
  • Migrating to Caddy or Traefik duplicates forwarded headers. They set X-Forwarded-For themselves, so a ported proxy_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.

  1. nginx ngx_http_proxy_module
  2. nginx controlling and reloading
  3. HAProxy configuration manual
  4. HAProxy Runtime API
  5. Envoy route components API (v3)
  6. Caddy reverse_proxy directive
  7. Caddy automatic HTTPS
  8. Traefik routers
  9. Traefik EntryPoints

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.

More in reverse proxy configuration#