Envoy doesn’t actually route TCP traffic; it proxies it, meaning it terminates the TCP connection from the client and initiates a new one to the upstream service.

Let’s see how this looks in practice. Imagine you have an Envoy configuration that needs to route TCP traffic for a Redis service running on port 6379.

static_resources:
  listeners:
  - name: listener_0
    address:
      socket_address: { address: 0.0.0.0, port_value: 10000 }
    filter_chains:
    - name: tcp_chain
      filters:
      - name: envoy.filters.network.tcp_proxy
        typed_config:
          "@type": type.googleapis.com/envoy.extensions.filters.network.tcp_proxy.v3.TcpProxy
          stat_prefix: tcp_proxy
          cluster: redis_cluster
  clusters:
  - name: redis_cluster
    connect_timeout: 0.25s
    load_assignment:
      cluster_name: redis_cluster
      endpoints:
      - lb_endpoints:
        - endpoint:
            address:
              socket_address: { address: 127.0.0.1, port_value: 6379 }

In this setup, Envoy listens on 0.0.0.0:10000. When a TCP connection arrives, the envoy.filters.network.tcp_proxy filter takes over. It looks at the cluster field, which is set to redis_cluster. Envoy then uses its load balancing mechanisms to select an endpoint from the redis_cluster (in this case, 127.0.0.1:6379) and establishes a new TCP connection to it. The data is then transparently forwarded between the client’s connection and the upstream connection.

The core problem Envoy solves here is providing a single point of ingress for various backend services, abstracting away their locations and enabling advanced traffic management. You can route based on SNI, destination IP/port, or even custom TCP metadata if you’re using a protocol-aware filter before the TCP proxy. The tcp_proxy filter itself is remarkably simple: it just forwards bytes. The real power comes from how you configure Envoy to select which tcp_proxy filter and which cluster to use.

The filter_chains array is crucial. You can have multiple filter_chains on a single listener, each with its own set of filters. Envoy iterates through these filter_chains in order, checking if the incoming connection matches the criteria defined within each filter_chain. A filter_chain can be matched based on the transport_socket (e.g., TLS configuration) or, more commonly for plain TCP, implicitly by the order. Once a match is found, the filters within that chain are applied.

A detail that often trips people up is how Envoy handles connection termination and re-initiation. When the tcp_proxy filter is engaged, the original TCP connection from the client is closed by Envoy’s listener. A new TCP connection is then established from Envoy to the upstream host. This means that any TCP-level state, like TCP options negotiated at the start of the client connection, is not preserved across the proxy. If you need to preserve certain TCP-level aspects or perform modifications based on the raw TCP stream, you’d typically need a more specialized filter or a higher-level protocol proxy.

The next step is often to add TLS termination to this listener, requiring a different transport_socket configuration within the filter_chain and a corresponding tls_context.

Want structured learning?

Take the full Envoy course →