---
title: "Route TCP at L4"
url: "/guides/proxy/route-tcp-at-l4/"
kind: "guide"
summary: "Front a TCP service with Arion: PROXY protocol from the edge, a connection cap, a source-IP allowlist and a health-checked upstream pair."
---

> Arion site index: [/llms.txt](/llms.txt)

# Route TCP at L4

Front a TCP service with Arion: PROXY protocol from the edge, a connection cap, a source-IP allowlist and a health-checked upstream pair.

## Outcome

This guide puts Arion in front of a non-HTTP service — PostgreSQL here — as a pure L4 proxy: a listener on port 5432 that reads PROXY protocol from the load balancer in front of it, caps concurrent connections at 1000, admits only clients from `10.0.0.0/8`, and streams bytes to a health-checked pair of database servers. Use it whenever the traffic is not HTTP but you still want one hardened entry point.

## Prerequisites

- An `arion` binary — see [install](@/docs/get-started/install.md).
- A TCP service to front. The verify section substitutes throwaway echo listeners, so none is required to follow along.
- `nc` and `socat` for the local verification.

## The architecture

- A listener on `0.0.0.0:5432` whose listener filter parses the PROXY protocol header at accept time, recovering the real client address.
- One filter chain of three network filters, in order: connection limit, RBAC allowlist, TCP proxy. No HTTP connection manager — nothing here parses the byte stream.
- The `postgres` cluster holds two static endpoints, each probed with a TCP connect check every five seconds.

## The complete config

````yaml
# arion.yaml, composed from 6 features.
logging:
  log_level: info
envoy_bootstrap:
  static_resources:
    listeners:
      - name: tcp_5432
        address:
          socket_address: { address: 0.0.0.0, port_value: 5432 }
        listener_filters:
          - name: envoy.filters.listener.proxy_protocol
            typed_config:
              "@type": type.googleapis.com/envoy.extensions.filters.listener.proxy_protocol.v3.ProxyProtocol
        filter_chains:
          - name: tcp_5432_chain
            filters:
              - name: envoy.filters.network.connection_limit
                typed_config:
                  "@type": type.googleapis.com/envoy.extensions.filters.network.connection_limit.v3.ConnectionLimit
                  stat_prefix: cx_limit_tcp_5432
                  max_connections: 1000
              - name: envoy.filters.network.rbac
                typed_config:
                  "@type": type.googleapis.com/envoy.extensions.filters.network.rbac.v3.RBAC
                  rules:
                    action: ALLOW
                    policies:
                      allowed-clients:
                        permissions:
                          - any: true
                        principals:
                          - direct_remote_ip: { address_prefix: 10.0.0.0, prefix_len: 8 }
              - name: envoy.filters.network.tcp_proxy
                typed_config:
                  "@type": type.googleapis.com/envoy.extensions.filters.network.tcp_proxy.v3.TcpProxy
                  cluster: postgres
    clusters:
      - name: postgres
        connect_timeout: 5s
        type: STATIC
        load_assignment:
          cluster_name: postgres
          endpoints:
            - lb_endpoints:
                - endpoint:
                    address:
                      socket_address: { address: 10.0.3.11, port_value: 5432 }
                - endpoint:
                    address:
                      socket_address: { address: 10.0.3.12, port_value: 5432 }
        health_checks:
          - timeout: 1s
            interval: 5s
            healthy_threshold: 3
            unhealthy_threshold: 2
            tcp_health_check: {}
````

## Walkthrough

### PROXY protocol at accept

`listener_filters` runs before any network filter, at connection accept. The `proxy_protocol` filter consumes the PROXY v1/v2 header that an edge load balancer prepends, and from then on Arion treats the address inside the header as the client's address — the allowlist below sees the real client, not the load balancer. A connection that arrives without a valid header is rejected outright, so enable this filter only behind something that always sends it. See [listeners](@/docs/traffic/listeners.md).

### The connection limit

`connection_limit` is the first network filter: at most `max_connections: 1000` concurrent connections through this filter chain, with `stat_prefix: cx_limit_tcp_5432` naming its stats. Databases hold state per connection, so capping at the proxy protects the upstream's memory and file descriptors from a connection flood.

### The allowlist

The network `rbac` filter carries `action: ALLOW` and a single policy, `allowed-clients`: any traffic (`permissions: any: true`) from principals whose `direct_remote_ip` falls in `10.0.0.0/8`. With an ALLOW ruleset, anything no policy matches is denied — a connection from outside the range is closed without a byte reaching the database. Policy shapes are covered in [RBAC](@/docs/security/rbac.md).

### The TCP proxy

`tcp_proxy` is the terminal filter: it opens a connection to the `postgres` cluster and copies bytes in both directions until either side closes. There is no routing table at L4 — the filter names its cluster directly. See [TCP proxy](@/docs/traffic/tcp-proxy.md).

### The cluster

`postgres` is a `STATIC` cluster with two endpoints and a 5-second `connect_timeout`. Its health check is `tcp_health_check: {}` — success means the endpoint accepted a TCP connection within `timeout: 1s` — every 5 seconds, ejecting after 2 failures and readmitting after 3 passes.

## Run and verify

Fake the two database servers with echoing TCP listeners:

```bash
socat TCP-LISTEN:15432,reuseaddr,fork EXEC:/bin/cat &
socat TCP-LISTEN:15433,reuseaddr,fork EXEC:/bin/cat &
```

For a local run, change the two cluster endpoints to `127.0.0.1:15432` and `127.0.0.1:15433`, then start Arion:

```bash
arion --config arion.yaml
```

A well-formed connection sends a PROXY header naming an allowed client, then its payload:

```bash
printf 'PROXY TCP4 10.0.0.5 10.0.3.1 41000 5432\r\nping\n' | nc -w 2 127.0.0.1 5432
```

```text
ping
```

The echo proves the path end to end — and that only the payload crossed it: Arion consumed the PROXY line, so the upstream never saw it. Now claim a source outside the allowlist:

```bash
printf 'PROXY TCP4 192.0.2.7 10.0.3.1 41000 5432\r\nping\n' | nc -w 2 127.0.0.1 5432
```

The connection closes with no output — RBAC denied it. Finally, connect with no PROXY header at all (`printf 'ping\n' | nc -w 2 127.0.0.1 5432`): the listener filter rejects the connection and Arion logs that no valid PROXY protocol header was detected.

In a real deployment the edge load balancer adds the header itself and `psql -h <arion-host> -p 5432` connects straight through.

## Production notes

- The allowlist is only as trustworthy as the PROXY header. Expose port 5432 solely to the load balancers that add the header; a client that can reach Arion directly can write any source address into it.
- If nothing in front of Arion speaks PROXY protocol, drop the listener filter — with it enabled, plain connections are rejected, which is exactly what the header-less `nc` test shows.
- A TCP check proves the endpoint accepts connections, not that the database can serve queries. Alert on the database's own signals too.
- Size `max_connections` from the upstream's connection budget (for PostgreSQL, `max_connections` server-side) rather than from expected load.
- To route TLS connections by SNI without terminating TLS — one listener fanning out to different clusters per hostname — add the `tls_inspector` listener filter and give each filter chain a `filter_chain_match` on `server_names`, each ending in its own `tcp_proxy`. See [TCP proxy](@/docs/traffic/tcp-proxy.md) and the [TLS inspector reference](@/reference/filters/listener/tls-inspector.md).

## Next

Field detail for the filters in this chain lives in the [tcp_proxy](@/reference/filters/network/tcp-proxy.md), [connection limit](@/reference/filters/network/connection-limit.md) and [network RBAC](@/reference/filters/network/rbac.md) references. For the HTTP equivalent of this front door, start from the [L7 load balancer guide](@/guides/proxy/l7-load-balancer.md).
