InfraRunBook
    Back to articles

    Envoy Basic HTTP Proxy Configuration Guide

    Envoy
    Published: Jul 25, 2026
    Updated: Jul 25, 2026

    A practical, from-scratch walkthrough of configuring Envoy as a basic HTTP reverse proxy, covering listeners, route matching, clusters, and the verification steps I run before trusting a config in production.

    Envoy Basic HTTP Proxy Configuration Guide

    Envoy shows up in almost every modern infrastructure stack now, usually as the data plane behind a service mesh, but you don't need Istio or Consul to get value out of it. A standalone Envoy instance configured as a basic HTTP reverse proxy is a perfectly reasonable thing to run in front of a small fleet of backend services, and it's also the best way to actually learn how the pieces fit together before you layer a control plane on top. This guide walks through a static, file-based Envoy configuration from an empty directory to a verified, working proxy.

    I'm going to assume you're running this on a Linux host, either bare metal or a VM, and that you have root or sudo access. Everything here uses Envoy's static configuration mode, meaning no xDS control plane, no dynamic discovery, just a YAML file Envoy reads on startup. That's deliberate. Once you understand static config cold, dynamic config makes a lot more sense.

    Prerequisites

    Before you start, get these in place:

    • A Linux host (I'll use sw-infrarunbook-01 in the examples) with at least 1 vCPU and 512MB of RAM free. Envoy is lightweight, but don't run it on a host that's already swapping.
    • Docker installed, or the Envoy binary itself if you'd rather run it natively. I lean on the official Docker images for this kind of guide because it keeps the host clean and avoids version drift between what you test locally and what ends up in a container image later.
    • At least one backend HTTP service to proxy to. For this walkthrough I'm assuming a simple web service listening on 10.20.30.10:8080 inside your private network. Swap that for whatever you actually have.
    • Basic familiarity with YAML indentation rules. Envoy configs are notoriously unforgiving about this — a misplaced two-space indent will produce an error message that doesn't obviously point at the indentation.
    • curl and netstat or ss on the host for verification later.

    I'd also recommend reading through the config once before running it, rather than copy-pasting and hoping. Envoy's config schema is deep, and knowing what each block does will save you time the first time something doesn't route the way you expect.

    Step-by-Step Setup

    Start by creating a working directory for the config file. I keep these in a predictable location so I'm not hunting for them later.

    mkdir -p /etc/envoy
    cd /etc/envoy

    Envoy's static config is built around a handful of top-level concepts, and it helps to understand them before writing YAML. A listener defines a socket Envoy binds to and accepts connections on. A filter chain attached to that listener defines what Envoy does with the traffic — in our case, the HTTP connection manager filter. The HTTP connection manager owns route configuration, which maps incoming request paths and hosts to clusters. A cluster is just a named group of upstream endpoints, essentially Envoy's abstraction for "a backend service." So the flow is: listener accepts connection → HTTP connection manager parses it as HTTP → router matches request against route config → request forwarded to a cluster → cluster picks an endpoint and forwards the request there.

    Let's build this up piece by piece. First, the listener skeleton:

    static_resources:
      listeners:
      - name: listener_http
        address:
          socket_address:
            address: 0.0.0.0
            port_value: 10000

    This tells Envoy to bind port 10000 on all interfaces. In my experience, binding to 0.0.0.0 is fine for a lab or internal host, but for anything internet-facing you'll want a specific interface address plus a firewall rule in front of it — Envoy itself has no concept of source IP allowlisting unless you configure an RBAC filter for it.

    Next comes the filter chain. This is where most people get tripped up the first time, because the HTTP connection manager config is deeply nested and the field names are verbose. Stick with it — once you've written one, the pattern repeats everywhere in Envoy.

        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
              codec_type: AUTO
              route_config:
                name: local_route
                virtual_hosts:
                - name: backend_service
                  domains: ["*"]
                  routes:
                  - match:
                      prefix: "/"
                    route:
                      cluster: backend_cluster
              http_filters:
              - name: envoy.filters.http.router
                typed_config:
                  "@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router

    A few things worth calling out here. stat_prefix shows up in Envoy's metrics namespace, so name it something meaningful if you're running more than one listener — "ingress_http" is fine for a single-listener setup but gets confusing fast once you add a second one. The domains: ["*"] line means this virtual host matches any Host header, which is the right call for a basic proxy but is exactly the kind of thing you'll want to tighten up later with a real domain match. The http_filters block always needs the router filter as the last entry — Envoy will refuse to start without it, since the router is what actually dispatches to a cluster after all the other HTTP filters (auth, rate limiting, whatever you add later) have run.

    Now the cluster definition, which tells Envoy where the backend actually lives:

      clusters:
      - name: backend_cluster
        connect_timeout: 5s
        type: STRICT_DNS
        lb_policy: ROUND_ROBIN
        load_assignment:
          cluster_name: backend_cluster
          endpoints:
          - lb_endpoints:
            - endpoint:
                address:
                  socket_address:
                    address: 10.20.30.10
                    port_value: 8080

    I used STRICT_DNS here because it's the most common choice for a single static backend — Envoy resolves the address once at startup and re-resolves periodically. If you're pointing at a raw IP rather than a hostname, STATIC works just as well and skips the DNS resolution step entirely. The connect_timeout matters more than people give it credit for — the default is generous, and if your backend is slow to accept connections under load, a tight timeout here will cause Envoy to fail fast and retry rather than let a request hang.

    Full Configuration Example

    Putting it all together, here's the complete file, saved at /etc/envoy/envoy.yaml:

    admin:
      address:
        socket_address:
          address: 127.0.0.1
          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
              codec_type: AUTO
              route_config:
                name: local_route
                virtual_hosts:
                - name: backend_service
                  domains: ["*"]
                  routes:
                  - match:
                      prefix: "/"
                    route:
                      cluster: backend_cluster
              http_filters:
              - name: envoy.filters.http.router
                typed_config:
                  "@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router
    
      clusters:
      - name: backend_cluster
        connect_timeout: 5s
        type: STRICT_DNS
        lb_policy: ROUND_ROBIN
        load_assignment:
          cluster_name: backend_cluster
          endpoints:
          - lb_endpoints:
            - endpoint:
                address:
                  socket_address:
                    address: 10.20.30.10
                    port_value: 8080

    Note the admin block at the top — I bind it to 127.0.0.1 rather than 0.0.0.0 on purpose. The admin interface exposes config dumps, stats, and a way to drain listeners, and none of that should be reachable from outside the host. If you need remote access to it, put it behind an SSH tunnel or a separate internal-only listener, don't just open it up.

    To run this with Docker:

    docker run -d --name envoy-proxy \
      -p 10000:10000 -p 9901:9901 \
      -v /etc/envoy/envoy.yaml:/etc/envoy/envoy.yaml:ro \
      envoyproxy/envoy:v1.29-latest

    If you're running the binary natively instead:

    envoy -c /etc/envoy/envoy.yaml --log-level info

    I'd run it in the foreground with info level logging the first time, not as a daemon. You want to see startup errors immediately rather than digging through a log file after the fact.

    Verification Steps

    Don't just assume it's working because the process didn't crash. Here's the sequence I run every time:

    1. Confirm Envoy is actually listening.

    ss -tlnp | grep -E '10000|9901'

    You should see both ports bound. If 10000 isn't there, check the container logs or terminal output — a config validation error will usually show up right at startup and Envoy will refuse to bind anything.

    2. Check the admin interface for cluster health.

    curl -s http://127.0.0.1:9901/clusters | grep backend_cluster

    This is the step people skip, and it's the most useful one. It'll show you the actual resolved endpoint, its health status, and whether Envoy considers it healthy. If your cluster shows as unhealthy or the endpoint list is empty, your proxy will accept connections but return 503s — which looks like a routing bug when it's actually a backend reachability problem.

    3. Send a real request through the proxy.

    curl -v http://sw-infrarunbook-01:10000/

    Look for a response from your actual backend, not from Envoy itself. If you get a 503 with an "upstream connect error" body, that's Envoy telling you it couldn't reach the cluster endpoint — go back and check connectivity between the Envoy host and 10.20.30.10:8080 directly with curl, bypassing Envoy entirely, to rule out a network or firewall issue.

    4. Check the stats endpoint for request counters.

    curl -s http://127.0.0.1:9901/stats | grep ingress_http

    After sending a few test requests, you should see downstream_rq_total incrementing. This confirms the HTTP connection manager is actually processing traffic and not silently dropping it somewhere in the filter chain.

    5. Validate the config file independently before you ever restart in production.

    envoy --mode validate -c /etc/envoy/envoy.yaml

    I run this before every config change gets pushed. It catches YAML structural errors and schema mismatches without actually starting the proxy, which matters because a failed validation on a live restart means downtime you didn't need to have.

    Common Mistakes

    I've seen the same handful of mistakes come up repeatedly, both from people new to Envoy and from experienced engineers who just weren't paying close attention.

    The most common one is forgetting the router http_filter at the end of the http_filters list. Without it, Envoy will start, the listener will bind, and requests will just hang or return an empty response with no obvious error in the logs pointing at the cause. If routing seems to silently do nothing, check this first.

    Second is mismatching the route match type. Using prefix: "/" catches everything, which is fine for a single-backend proxy, but I've seen people add a second, more specific route below it and wonder why it never matches — Envoy evaluates routes in order and the first match wins, so a catch-all prefix route placed first will shadow everything after it. Order your routes from most specific to least specific.

    Third, people confuse STATIC and STRICT_DNS cluster types and then wonder why a hostname change doesn't take effect, or why Envoy takes a long time to notice a backend IP change. STRICT_DNS re-resolves on a TTL; STATIC never does. If your backend is behind a hostname that can change (like a cloud load balancer), STRICT_DNS is the right choice — but be aware it adds a dependency on DNS resolution working correctly from the Envoy host, which is a separate thing that can fail.

    Fourth, and this one's caused real incidents I've been called into: leaving the admin interface bound to 0.0.0.0. The admin API isn't authenticated by default, and it exposes not just stats but the ability to drain listeners and dump the entire runtime config, which can include upstream addresses you don't want exposed. Bind it to loopback, always, unless you've explicitly put an auth layer in front of it.

    Fifth, indentation errors that produce confusing YAML parse errors rather than clear ones. Envoy's error messages for malformed YAML tend to reference line numbers that don't always line up intuitively with where the actual mistake is, especially with deeply nested structures like the HTTP connection manager's typed_config block. If you get a parse error you can't make sense of, run the file through a standalone YAML linter first — it's usually faster than staring at the Envoy error.

    Last one: not setting connect_timeout and being surprised when a single slow backend causes cascading latency across all requests hitting that cluster. The default timeout is generous enough that a struggling backend can hold connections open far longer than you'd want, and that backs up everywhere upstream of it. Set it explicitly and tune it based on what your backend actually needs to accept a connection under normal load, not under best-case conditions.

    Once this basic setup is running cleanly, the natural next steps are adding TLS termination, health checks on the cluster, and eventually moving from static config to dynamic xDS if you're managing more than a handful of backends. But get comfortable with this static version first — it's the foundation everything else builds on, and debugging a dynamic control plane is a lot harder if you don't already understand what Envoy is doing with a plain YAML file.

    Frequently Asked Questions

    Do I need a service mesh to use Envoy as an HTTP proxy?

    No. A standalone Envoy instance with a static YAML configuration works fine as a basic HTTP reverse proxy in front of one or more backend services, without any control plane like Istio or Consul involved.

    What's the difference between STATIC and STRICT_DNS cluster types?

    STATIC expects raw IP addresses and never re-resolves them. STRICT_DNS resolves a hostname at startup and periodically re-resolves it based on DNS TTL, which is the better choice when your backend address can change, such as behind a cloud load balancer.

    Why is my Envoy proxy returning a 503 upstream connect error?

    This almost always means Envoy can't reach the endpoint defined in your cluster's load_assignment. Check the /clusters endpoint on the admin interface to see the resolved endpoint and health status, then verify connectivity to that endpoint directly with curl, bypassing Envoy.

    Should the Envoy admin interface be exposed externally?

    No. Bind the admin interface to 127.0.0.1 only. It has no built-in authentication and exposes stats, config dumps, and the ability to drain listeners, so exposing it externally is a real security risk.

    How do I validate an Envoy config before restarting the proxy in production?

    Run envoy --mode validate -c /path/to/envoy.yaml. It checks the config against Envoy's schema and catches structural errors without actually starting the proxy, which is much safer than discovering a bad config during a live restart.

    Related Articles