Two Proxies Walk Into a Data Center
HAProxy has been moving TCP bytes since 2000. Envoy dropped in 2016 with Lyft’s fingerprints all over it and immediately became the sidecar of choice for half the CNCF ecosystem. Both can do L4 and L7 load balancing, TLS termination, health checks, and a bunch more. The question isn’t which one is better — it’s which one belongs in front of your stack.
Spoiler: if you’re running three home servers and want something in front of them, you want HAProxy. If you’re running a Kubernetes mesh where services talk to each other at scale, you probably need Envoy. Let’s walk through why.
What You’re Actually Choosing Between
HAProxy is a C program that reads a config file and does exactly what the config file says. It’s single-threaded by design (though it supports multi-process and multi-thread modes), brutally fast, and the config syntax is about as close to “what you see is what you get” as proxy config gets. The Data Plane API adds dynamic config if you need it, but that’s opt-in.
Envoy is a C++ proxy designed to be driven by a control plane. It doesn’t care much about its static config — the interesting stuff happens when something like Istio, Consul, or a custom xDS server pushes routes, clusters, and listeners to it at runtime. That dynamic config system (xDS) is its superpower and also the source of a lot of complexity headaches.
Both support HTTP/1.1, HTTP/2, gRPC, HTTP/3 (QUIC on Envoy, experimental on HAProxy), WebSockets, health checks, circuit breaking, retries, and TLS. The gap isn’t features — it’s philosophy.
Config: The Scroll vs the Dashboard
HAProxy: One File to Rule Them All
A three-backend HTTP load balancer in HAProxy looks like this:
global log stdout format raw local0 maxconn 50000 nbthread 4
defaults mode http timeout connect 5s timeout client 30s timeout server 30s option httplog option forwardfor
frontend web_frontend bind *:80 bind *:443 ssl crt /etc/ssl/certs/myapp.pem alpn h2,http/1.1 default_backend web_backends
backend web_backends balance roundrobin option httpchk GET /healthz HTTP/1.1\r\nHost:\ localhost server app1 10.0.0.11:8080 check server app2 10.0.0.12:8080 check server app3 10.0.0.13:8080 checkThat’s it. It reads this file on startup (or graceful reload), and it works. No API server. No control plane. No Helm chart. HAProxy’s stats socket gives you runtime visibility and limited dynamic changes (drain a server, change weights), and the Data Plane API wraps that in a REST interface if you want full dynamic management.
ACLs let you do path-based and header-based routing right in the config:
frontend web_frontend bind *:443 ssl crt /etc/ssl/certs/myapp.pem alpn h2,http/1.1 acl is_api path_beg /api/ acl is_static path_beg /static/ use_backend api_backends if is_api use_backend static_backends if is_static default_backend web_backendsClean, readable, debuggable at 2 AM without a PhD.
Envoy: xDS or Bust
Envoy can run with a static config, and it works fine — but that’s not what it was built for. Here’s the equivalent three-backend config in Envoy’s static mode:
static_resources: listeners: - name: listener_0 address: socket_address: address: 0.0.0.0 port_value: 80 filter_chains: - filters: - name: envoy.filters.network.http_connection_manager typed_config: "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager stat_prefix: ingress_http route_config: name: local_route virtual_hosts: - name: local_service domains: ["*"] routes: - match: prefix: "/" route: cluster: web_backends http_filters: - name: envoy.filters.http.router typed_config: "@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router
clusters: - name: web_backends connect_timeout: 5s type: STATIC lb_policy: ROUND_ROBIN load_assignment: cluster_name: web_backends endpoints: - lb_endpoints: - endpoint: address: socket_address: address: 10.0.0.11 port_value: 8080 - endpoint: address: socket_address: address: 10.0.0.12 port_value: 8080 - endpoint: address: socket_address: address: 10.0.0.13 port_value: 8080Same result, four times the YAML. That verbosity is the cost of Envoy’s extensibility — every component is a typed extension, which makes it composable but not exactly terse. When a control plane is driving this dynamically, the verbosity moves into the control plane’s problem. When you’re hand-editing it, it moves into your problem.
Performance
HAProxy is one of the fastest proxies ever written. Willy Tarreau (the maintainer) has been squeezing performance out of it for over two decades. On raw HTTP/1.1 throughput per core, it’s difficult to beat. The single-threaded event loop model means no locking overhead, and the connection handling is highly optimized.
Envoy is close — it’s also a high-performance proxy — but it spends more cycles on its filter chain, stats collection, and the dynamic config machinery. For most workloads this doesn’t matter. At very high connection rates (hundreds of thousands of requests per second), HAProxy tends to edge ahead.
In practice: if you’re worried about proxy overhead at this scale, you probably have other bottlenecks. The performance delta rarely drives the decision.
Observability
This is where Envoy genuinely pulls ahead.
Envoy ships with a built-in stats system that exposes thousands of metrics at /stats (and a Prometheus-compatible endpoint at /stats/prometheus). It also has native OpenTelemetry tracing support — you can configure a Zipkin or OTLP exporter and get distributed traces out of the box. Every connection, every retry, every circuit-breaker trip shows up as a stat.
# Envoy Prometheus endpoint — you didn't configure anything extra for thiscurl http://localhost:9901/stats/prometheus | grep cluster.web_backendsHAProxy requires a bit more legwork. You enable the stats socket and either scrape it directly or run the haproxy_exporter (or the newer native Prometheus exporter in HAProxy 2.0+):
global stats socket /run/haproxy/admin.sock mode 660 level admin
frontend stats bind *:8404 stats enable stats uri /stats stats refresh 10s http-request use-service prometheus-exporter if { path /metrics }# HAProxy native Prometheus exporter (2.0+)curl http://localhost:8404/metrics | grep haproxy_backendBoth can feed Prometheus and Grafana. Envoy’s out-of-box observability story is richer, especially for tracing. HAProxy gets you there but requires more explicit configuration.
Hot Reloads and Dynamic Config
HAProxy does graceful reloads — send it SIGUSR2 (or use haproxy -sf), it starts a new process, drains the old one, and cuts over. Zero dropped connections, but it’s a process restart under the hood. The Data Plane API takes this further, letting you add/remove servers, ACLs, and backends via REST without touching the config file:
# Add a backend server dynamically via HAProxy Data Plane APIcurl -X POST http://admin:password@localhost:5555/v2/services/haproxy/configuration/servers \ -H 'Content-Type: application/json' \ -d '{"name":"app4","address":"10.0.0.14","port":8080}'Envoy was designed from day one to never need a restart. xDS pushes arrive over gRPC, Envoy applies them live — new routes, new clusters, new listeners, TLS certs, all swapped atomically without interrupting existing connections. This is the entire point of its architecture. When Istio or Consul is driving Envoy, this all happens automatically as you deploy services.
If you’re not running a control plane, this advantage mostly disappears. You’re back to editing YAML and doing a container restart.
gRPC and HTTP/2
Both proxies handle gRPC fine. Envoy’s support is more mature — it was designed with gRPC in mind, supports gRPC-Web, gRPC-JSON transcoding (translate REST calls to gRPC upstream), and has better visibility into individual gRPC streams as named stats.
HAProxy supports gRPC over HTTP/2 but doesn’t have gRPC-specific awareness at the same level. For most load-balancing-gRPC use cases, HAProxy works fine. For gRPC transcoding or rich gRPC observability, Envoy wins.
Service Mesh
Envoy is the sidecar proxy for Istio and a key component in Consul Connect, Contour (ingress for Kubernetes), and dozens of other projects. The xDS protocol was specifically designed for the control-plane-drives-many-sidecars pattern. If you’re running a service mesh, you’re almost certainly already running Envoy whether you think about it or not.
HAProxy has no sidecar story. It’s a load balancer, not a mesh component. HAProxy Kubernetes Ingress Controller exists, but it’s north/south only. You’re not deploying HAProxy as a sidecar in your pods.
If “service mesh” appears in your architecture doc, Envoy is the answer. If it doesn’t, you probably don’t need Envoy.
Home Lab Fit
Here’s the honest take.
HAProxy is the right answer for “I want a load balancer in front of my Nextcloud, Jellyfin, and Vaultwarden.” One config file, one binary, systemd unit, done. The mental overhead is low, the documentation is excellent, and it’s been battle-tested longer than some of us have been in tech. It pairs naturally with a simple Prometheus + Grafana setup.
Envoy in a home lab is like hiring a full infrastructure team to run a lemonade stand. The control plane complexity (you need something to drive xDS, or you’re just writing very verbose YAML) adds layers that don’t pay off until you have dozens of services communicating internally and need the mesh to handle retries, circuit breaking, and mTLS automatically.
Unless you’re specifically learning Envoy for your day job or you’re running k3s with something like Contour or Istio installed, HAProxy is the better home lab choice by a wide margin.
Quick Reference
| Feature | HAProxy | Envoy |
|---|---|---|
| Config model | Static file + Data Plane API | xDS (dynamic) |
| Raw performance | Best in class | Excellent |
| Observability | Good (native Prometheus 2.0+) | Excellent (firehose) |
| gRPC | Good | Excellent (transcoding) |
| Hot reload | Graceful process restart | xDS push (no restart) |
| Service mesh | No | Yes (Istio, Consul, etc.) |
| TLS | Yes | Yes |
| HTTP/3 (QUIC) | Experimental | Yes |
| Home lab fit | Excellent | Overkill (unless mesh) |
| Learning curve | Low | High |
Verdict
Choose HAProxy when you want a rock-solid L4/L7 load balancer that reads a config file and just works. Traditional north/south traffic, TLS termination, ACL-based routing, anything from a personal server to a high-traffic production edge — HAProxy handles it with minimal operational overhead. Twenty-plus years of production use means the edge cases are well-documented and the community answers are on the first page of search results.
Choose Envoy when you’re operating east/west traffic in a service mesh, need dynamic config at scale, care deeply about gRPC transcoding, or are building a platform where Envoy is already embedded (Kubernetes ingress via Contour, Istio, etc.). Its observability story is genuinely excellent, and the xDS model is elegant once you have a control plane to drive it.
Both pair well with Prometheus and Grafana. Neither will let you down at the proxy layer — the decision is really about config complexity budget and whether you’re in a mesh world. Your 2 AM self will appreciate having made the right call before the incident.