Learning Envoy, part 1: how a request moves through Envoy
At work I help teams run Envoy Gateway on Kubernetes. When something breaks, the answer is almost always in the Envoy config that Envoy Gateway generates. The problem is that this config is long, and it only makes sense once you know how Envoy itself is put together.
So I went back to the start. I built a set of small examples that run Envoy on its own in Docker, without Kubernetes, and only then added Envoy Gateway on top. They’re all in giantswarm/envoy-gateway-examples. This series walks through them in order, with the tests I ran and what surprised me along the way.
This first post covers the basics: what an Envoy config looks like, and how to ask a running Envoy what it’s doing.
The smallest useful config
Every example runs with Docker Compose. There are two containers: Envoy, and a small Flask app called helloworld that answers on port 8080. You can start the first one like this:
cd 01-envoy-proxy/01-helloworld-static
make up
curl localhost:10000/
Here’s the Envoy config, without the comments:
admin:
address:
socket_address: { address: 0.0.0.0, port_value: 9901 }
static_resources:
listeners:
- name: listener_http
address:
socket_address: { address: 0.0.0.0, port_value: 10000 }
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
http_filters:
- name: envoy.filters.http.router
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router
route_config:
virtual_hosts:
- name: local_service
domains: ["*"]
routes:
- match: { prefix: "/" }
route: { cluster: helloworld_cluster }
clusters:
- name: helloworld_cluster
type: STRICT_DNS
lb_policy: ROUND_ROBIN
load_assignment:
cluster_name: helloworld_cluster
endpoints:
- lb_endpoints:
- endpoint:
address:
socket_address: { address: helloworld, port_value: 8080 }
It looks like a lot for “forward everything to one app”, but every block has a job:
- A listener owns an address and port, here
:10000. - The listener has a filter chain. Its filters work on raw TCP bytes.
- The most important of those filters is the HTTP connection manager, or HCM. It parses HTTP off the wire.
- Inside the HCM there’s a list of HTTP filters. They run in order on each request. The last one is always the router, which sends the request upstream.
- The HCM also holds the route table. A virtual host is picked by the
Hostheader, and then the first route whosematchfits the request wins. - The route points at a cluster, a named group of backends. With
STRICT_DNS, Envoy resolveshelloworldthrough DNS and uses every address it gets back as an endpoint.
So a request goes listener → filter chain → HCM → HTTP filters → route → cluster → endpoint. This chain is the most useful thing to learn about Envoy. Every later feature plugs into one of these steps. Rate limits and authentication are HTTP filters. Retries and timeouts live on the route. Health checks and circuit breakers live on the cluster.
Breaking it on purpose
The best test in this first example was breaking it. I changed the cluster address from helloworld to doesnotexist and restarted. The request now fails with a 503 and the body no healthy upstream.
The access log line has a UH in the response flags column, right after the status code:
[2026-11-04T11:32:41.543Z] "GET / HTTP/1.1" 503 UH 0 19 1 - "-" "curl/8.7.1" "c1e9dac8-31be-4f0b-8876-141357e5b4e7" "localhost:10000" "-"
Envoy writes these short codes on every log line, and they tell you why a request failed without reading any config. UH means there was no healthy host in the cluster. I’ll come back to these flags in later posts, since they turn out to be the fastest way to debug Envoy.
Asking Envoy what it’s doing
The admin block at the top opens a second HTTP server on port 9901. It isn’t on the request path. It’s there so you can look inside a running Envoy:
curl -s localhost:9901/ready
curl -s localhost:9901/listeners
curl -s localhost:9901/clusters
curl -s localhost:9901/stats | grep helloworld_cluster
curl -s localhost:9901/config_dump | jq .
/config_dump returns the whole live config. On a static setup like this one, that’s the same YAML you wrote. Once Envoy Gateway is in charge, it’s the only place you can see what Envoy actually received. /clusters shows each endpoint with its health and request counters, and /stats has a counter for almost everything.
Note that some admin paths change state. POST /logging?level=debug changes the log level, POST /runtime_modify sets runtime values, and POST /quitquitquit stops Envoy. In production, don’t expose the admin port beyond localhost.
Routes are checked in order
The third example adds more routes, and two things caught me out.
First, routes are checked top to bottom and the first match wins. A catch-all prefix: "/" above a more specific route means the specific one never runs. The order of the list is the policy.
Second, prefix_rewrite only replaces the matched prefix. It keeps the rest of the path. I wanted every path under /admin to go to the backend’s /echo endpoint, so I wrote this:
- match: { prefix: "/admin" }
route:
cluster: helloworld_cluster
prefix_rewrite: "/echo"
/admin became /echo as expected. But /admin/users became /echo/users, and the backend returned a 404. To replace the whole path, use regex_rewrite instead:
- match: { prefix: "/admin" }
route:
cluster: helloworld_cluster
regex_rewrite:
pattern: { regex: ".*" }
substitution: "/echo"
My rule now is to use prefix_rewrite when I peel a fixed prefix off and forward the rest, and regex_rewrite when many paths should land on one endpoint.
What’s next
With the request path clear, the next post looks at what happens when backends misbehave: load balancing, health checks, retries, timeouts and circuit breakers. Each one is a small change to the route or the cluster, and each one has a test that shows it working.