Skip to content

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

Request path

What you will build

ARION 1listener2PROXY protocol3conn limit4allowlist5tcp_proxy client postgres

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.
  • 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

# 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.

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.

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.

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:

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:

arion --config arion.yaml

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

printf 'PROXY TCP4 10.0.0.5 10.0.3.1 41000 5432\r\nping\n' | nc -w 2 127.0.0.1 5432
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:

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 and the TLS inspector reference.

Next

Field detail for the filters in this chain lives in the tcp_proxy, connection limit and network RBAC references. For the HTTP equivalent of this front door, start from the L7 load balancer guide.

Next

Take the configuration further

Continue

Start typing to search the site.

↑ ↓ to move Enter to open Esc to close