← Insights
SYS_LINK: ACTIVE// KINETIC_ENG

Static egress IPs for Postgres on Fly.io: an Envoy egress proxy

Peter HanssensPeter Hanssens

If you run apps on Fly.io and connect to a managed Postgres that supports IP allowlisting — ClickHouse Cloud Postgres, PlanetScale, Neon, Supabase, RDS with security groups, etc. — you quickly hit a mismatch:

Fly Machines do not have stable outbound IP addresses by default. Every app, every region, every scale event can surface a different source IP. Your database provider wants a short, fixed allowlist. You want to deploy without opening Postgres to the entire internet.

This post walks through a pattern we use in production: a dedicated Fly app running Envoy as a TCP egress proxy. All database traffic from your other Fly apps goes through it and exits from one or two static egress IPs you can whitelist once.

Hero example: ClickHouse Cloud Postgres — a separate .pg.clickhouse.cloud hostname, TLS required, IP allowlist in the Cloud console. ClickHouse also ships managed PgBouncer on port 6432 (same hostname as direct Postgres on 5432). We forward Fly :6432 → CH :6432 instead of running our own pooler.

The stack runs as a private Fly app: Envoy v1.38 on Fly Machines. The snippets below are the full config — no public repository needed.


Why one shared egress proxy?

In our setup, many Fly apps — APIs, workers, staging and production stacks — all need Postgres access to several ClickHouse Cloud databases (each with its own .pg.clickhouse.cloud endpoint and IP allowlist). The naïve fix is to allocate a static egress IP on every app that talks to a database. That works, but it scales badly:

  • Cost — Fly charges per allocated egress IP; ten apps means ten IPv4/IPv6 pairs to pay for and renew.
  • Operational drag — every new service, region, or scale event can change outbound addresses unless you pin IPs per app. You end up maintaining allowlists across ClickHouse Cloud and your Fly fleet.
  • Security surface — more egress identities means more entries in every database allowlist, and more places something can drift out of sync.

A single dedicated egress app flips that model:

Instead of…You get…
N apps × static egress IPsOne (or two) egress IPs shared by all consumers
Each team updating ClickHouse allowlistsWhitelist once per database; every app uses my-egress-envoy.flycast
Per-app connection sprawl6432 forwards to ClickHouse’s managed PgBouncer — same credentials as the Connect UI
A single point of failureTwo Machines, TCP health checks, auto_stop_machines = false — small HA footprint for infrastructure everyone depends on

Apps stay on the private Fly network (flycast); the proxy has no public ingress. TLS still runs end-to-end to ClickHouse — the proxy only fixes the outbound IP story. You can point Envoy at different upstream clusters (or run multiple proxy apps) if you need separate egress identities per environment; for most teams, one proxy covering all Postgres on ClickHouse Cloud is enough.


The problem in one diagram

Without egress proxy:

  api.flycast      ──► staging-db.pg....clickhouse.cloud
  worker.flycast   ──► prod-api.pg....clickhouse.cloud   (each app, different egress IP)
  admin.flycast    ──► analytics.pg....clickhouse.cloud

  ClickHouse allowlists: update on every new app / deploy / region
With egress proxy:

  api.flycast      ──┐
  worker.flycast   ──┼──► my-egress-envoy.flycast:6432 ──► *.pg.clickhouse.cloud
  admin.flycast    ──┘         │
                               └── exits as 203.0.113.42 (static)

  ClickHouse allowlists: 203.0.113.42 on each database — done once

The proxy does not terminate TLS for Postgres. It is a layer-4 forwarder. Your app (or psql) still speaks TLS directly to the real database hostname; Envoy just pipes bytes and provides a stable exit IP.


Why Envoy?

ComponentRole
EnvoyL4 TCP proxy, access logs, circuit breakers, one config file for many upstream ports
ClickHouse Cloud PgBouncerManaged pooler on upstream :6432 — not run in this container
Fly static egress IPAllocated per app + region; shared by all Machines in that app

We expose two Postgres entry points that mirror the ClickHouse Connect modal:

Fly portUpstreamUse for
5432CH Postgres :5432 (direct)Migrations, psql, prepared statements, long sessions
6432CH managed PgBouncer :6432Normal application traffic (pooled)

Use the same username and password from the ClickHouse Connect UI. Store credentials in each consumer app’s Fly secrets — the egress proxy does not hold them.


Architecture

Direct path (5432)

Client (Fly app or fly proxy)
  → my-egress-envoy.flycast:5432
    → Envoy listener :5432
      → postgres_direct_backend
        → your-service.pg....clickhouse.cloud:5432

Pooled path (6432)

Client
  → my-egress-envoy.flycast:6432
    → Envoy :6432
      → postgres_pooled_backend
        → your-service.pg....clickhouse.cloud:6432   (ClickHouse managed PgBouncer)

No local PgBouncer process — one Envoy hop each way.


Deploy step by step

1. Create the egress app and allocate static egress IPs

fly apps create my-egress-envoy
fly ips allocate-egress --app my-egress-envoy -r syd   # use your primary region
fly ips list --app my-egress-envoy

You typically get one IPv4 and one IPv6 egress address. Note both — some providers accept only IPv4; Fly may egress over either.

Egress IPs are per app and region. If you scale to two Machines for HA, both share the same egress IPs. That is what you want: whitelist once, run N Machines.

Deploy with no public ingress IPs — this app should only be reachable on the private network:

fly deploy --no-public-ips

2. Configure Envoy upstream

In envoy.yaml, add two clusters on the same .pg.clickhouse.cloud hostname — direct and pooled — matching the ClickHouse Connect UI:

  - name: postgres_direct_backend
    load_assignment:
      cluster_name: postgres_direct_backend
      endpoints:
      - lb_endpoints:
        - endpoint:
            address:
              socket_address:
                address: your-service.pg.xxxxx.aws.pg.clickhouse.cloud
                port_value: 5432

  - name: postgres_pooled_backend
    load_assignment:
      cluster_name: postgres_pooled_backend
      endpoints:
      - lb_endpoints:
        - endpoint:
            address:
              socket_address:
                address: your-service.pg.xxxxx.aws.pg.clickhouse.cloud
                port_value: 6432

Wire listener :5432postgres_direct_backend, listener :6432postgres_pooled_backend.

ClickHouse Cloud exposes two connection modes on one Postgres hostname:

Connect UIPortEnvoy cluster
Directly5432postgres_direct_backend
via PgBouncer6432postgres_pooled_backend

Native/HTTP still use the main *.clickhouse.cloud hostname on 9440/8443.

3. Bind listeners for Fly’s IPv6 network

This is easy to get wrong.

Fly apps talk to each other over 6PN (IPv6). fly proxy from your laptop also tunnels to the Machine’s IPv6 address. If Envoy only listens on 0.0.0.0 (IPv4), private connections fail with “server closed the connection unexpectedly” even though 127.0.0.1 works inside the Machine.

Use dual-stack listeners:

- name: postgres_direct_listener
  address:
    socket_address:
      address: "::"
      port_value: 5432
      ipv4_compat: true

Apply the same pattern to every published port (5432, 6432, etc.).

4. Publish private TCP services in fly.toml

[[services]]
  internal_port = 5432
  protocol = "tcp"
  auto_stop_machines = false
  auto_start_machines = true
  min_machines_running = 2

  [[services.ports]]
    port = "5432"
    handlers = []   # raw TCP — no TLS termination

  [[services.tcp_checks]]
    interval = "15s"
    timeout = "2s"
    grace_period = "30s"
  • handlers = [] — pass-through TCP (required for TLS to the real database).
  • min_machines_running = 2 — HA for infrastructure you depend on.
  • auto_stop_machines = false — do not autostop your egress layer.

Repeat for 6432 (pooled path to CH PgBouncer).

5. Whitelist egress IPs at the database

Add the addresses from fly ips list (type egress) to your provider’s IP allowlist. Until this step is done, connections may reach Envoy and then die immediately at the database — often with zero bytes transferred in access logs.

Example egress IPs from a real deployment:

203.0.113.42                   # IPv4 — add as /32 in most consoles
2001:db8::1                    # IPv6 — add if your provider supports it

ClickHouse Cloud (primary example)

  1. Open your ClickHouse Cloud service → SettingsSecurity (or Network access).
  2. Under IP access list, add your Fly egress IPv4 as 203.0.113.42/32 (use the address from fly ips list, not this example).
  3. Save. Changes apply to the Postgres endpoint (.pg.clickhouse.cloud) as well as native/HTTP ports on the same service.

If allowlisting is wrong or missing, psql through the proxy fails with “server closed the connection unexpectedly” and Envoy logs show 0 bytes on the upstream leg — the TCP handshake to ClickHouse never completes TLS.

Other Postgres providers (same proxy, different console)

The egress proxy is provider-agnostic. Whitelist the same Fly egress IP(s) wherever your backend exposes IP restrictions:

ProviderWhere to allowlistNotes
ClickHouse CloudService → Security / IP access listSeparate .pg.clickhouse.cloud hostname; TLS required
PlanetScale PostgresDatabase → Settings → IP restrictionsCIDR ranges per branch; optional role/schema scope (docs)
NeonProject → Settings → IP AllowIPv4 CIDR rules
SupabaseProject → Database → Network restrictionsIPv4 only on some plans
AWS RDSSecurity group inbound rulePort 5432 from egress IP /32
GCP Cloud SQLAuthorized networksAdd egress IP as a single host

For PlanetScale Vitess (MySQL), IP allowlisting is per password (cidrs on branch passwords) rather than a branch-level IP restrictions tab — same Fly egress IP, different API surface.

Most providers accept IPv4 /32 first; confirm whether IPv6 egress from Fly needs a separate rule for your plan.

6. Point application apps at the proxy

From another Fly app, use .flycast (private routing through Fly Proxy), not .internal DNS to a specific Machine:

postgresql://app_user:secret@/mydb?host=your-service.pg.xxxxx.aws.pg.clickhouse.cloud&hostaddr=my-egress-envoy.flycast&port=6432&sslmode=require

Local testing from your laptop

Before you wire production apps to .flycast, validate the full path from your machine. fly proxy opens a WireGuard tunnel from your laptop to a Fly Machine and forwards a local port to the proxy’s internal TCP port. Your SQL client talks to 127.0.0.1; traffic exits the proxy with the static egress IP — the same path production apps use, minus the hop from another Fly app.

Prerequisites

  1. Deployed proxy — at least one Machine running and healthy:

    fly machines list -a my-egress-envoy
    fly checks list -a my-egress-envoy
    
  2. Egress IPs allowlisted at the database (see step 5 above). Local testing still exits via the Fly egress IP, not your home IP.

  3. Real credentials from the ClickHouse Connect UI (same user/password for direct and via PgBouncer).

  4. Dual-stack listeners (:: + ipv4_compat) in envoy.yaml. Without this, fly proxy fails silently — TCP connects locally, then resets when the tunnel hits the Machine’s IPv6 address.

  5. psql or another Postgres client installed locally (Homebrew: brew install libpq).

Local port map

Map high local ports so they are easy to remember and do not clash with a local Postgres:

Local portfly proxy commandProxy portPath
15432fly proxy 15432:54325432Direct (Envoy → DB)
16432fly proxy 16432:64326432Pooled (Envoy → CH PgBouncer)
9901fly proxy 9901:99019901Envoy admin /stats (optional)

Replace my-egress-envoy with your app name throughout.

Three-terminal workflow

Terminal 1 — tail access logs

fly logs -a my-egress-envoy | rg 'DIRECT|POOLED|CH-|error|upstream'

On macOS, prefer rg over grep -E.

Terminal 2 — start the proxy (leave running)

Direct path first — fewer moving parts:

fly proxy 15432:5432 -a my-egress-envoy

You should see:

Proxying localhost:15432 to remote [fdaa:...]:5432

Terminal 3 — connect with psql

Use the host + hostaddr connection string (see next section). Example with ClickHouse Cloud Postgres:

psql "postgresql://app_user:secret@/mydb?host=your-service.pg.xxxxx.aws.pg.clickhouse.cloud&hostaddr=127.0.0.1&port=15432&sslmode=require"

What success looks like

psql (14.22 (Homebrew), server 18.4 ...)
WARNING: psql major version 14, server major version 18.
         Some psql features might not work.
SSL connection (protocol: TLSv1.3, cipher: TLS_AES_256_GCM_SHA384, bits: 256, compression: off)
Type "help" for help.

mydb=> SELECT 1;
 ?column?
----------
        1
(1 row)

The psql/server version warning is harmless for normal queries. You care about SSL connection and a working prompt.

In Terminal 1, you should see a DIRECT line with non-zero bytes near your attempt:

[2026-06-07T08:11:33.490Z] DIRECT ... your-service.pg....clickhouse.cloud:5432 1284 892 45 "-"

Non-zero %BYTES_RECEIVED% / %BYTES_SENT% means TLS and Postgres startup completed.

Test the pooled path (6432 → CH PgBouncer)

Terminal 2 — swap proxy:

fly proxy 16432:6432 -a my-egress-envoy

Terminal 3:

psql "postgresql://app_user:secret@/mydb?host=your-service.pg.xxxxx.aws.pg.clickhouse.cloud&hostaddr=127.0.0.1&port=16432&sslmode=require"

Logs should show a single POOLED line with upstream ...clickhouse.cloud:6432 and non-zero bytes. ClickHouse validates the user — no proxy-side user list.

Verify without psql

Check the proxy is listening locally:

nc -zv 127.0.0.1 15432

TLS through the tunnel (replace hostname with yours):

echo | openssl s_client \
  -connect 127.0.0.1:15432 \
  -servername your-service.pg.xxxxx.aws.pg.clickhouse.cloud 2>&1 | head -20

You should see a certificate whose CN matches your database hostname. If you get write:errno=54 / Connection reset with no peer certificate, suspect IPv4-only Envoy listeners or a dead Machine — not TLS credentials.

Health and Machines from the CLI:

fly checks list -a my-egress-envoy
fly machines list -a my-egress-envoy

All TCP checks should be passing.

Optional — debug inside the Machine:

fly ssh console -a my-egress-envoy --machine <machine-id> -C \
  "ss -lnt | grep -E '5432|6432'"

You should see Envoy listening on 5432 and 6432.

Local testing checklist

StepCommand / checkPass criteria
1fly checks listAll TCP checks passing
2fly proxy 15432:5432“Proxying localhost:15432…”
3nc -zv 127.0.0.1 15432Connection succeeded
4psql with host + hostaddrSSL prompt, SELECT 1 works
5Logs during psqlDIRECT line with bytes > 0
6fly proxy 16432:6432 + psqlPOOLED line, upstream :6432, bytes > 0
7fly ips list (egress)Same IPs allowlisted at DB provider

Common local failures

What you seeWhat it usually means
connection refused on 127.0.0.1:15432fly proxy not running or wrong local port
closed unexpectedly, 0 bytes in logsWrong connection string (@127.0.0.1 without host=) or egress IP not allowlisted at ClickHouse Cloud or IPv4-only Envoy bind
password authentication failedProxy path works — ClickHouse rejected credentials
Works in fly ssh on 127.0.0.1:5432 but not via fly proxyEnvoy not listening on IPv6

Once direct and pooled paths pass locally, point staging apps at my-egress-envoy.flycast with the same host + hostaddr pattern (swap hostaddr=127.0.0.1 for hostaddr=my-egress-envoy.flycast and use ports 5432 / 6432 directly — no fly proxy needed on Fly).


TLS and connection strings (critical)

Because Envoy is L4-only, the client must initiate TLS to the real database hostname. The TCP connection goes to the proxy; the TLS handshake must use the provider’s certificate name.

If you connect with @127.0.0.1 or @my-egress-envoy.flycast as the only host, libpq sends the wrong SNI and many providers drop the session instantly.

Use libpq’s host + hostaddr split:

ParameterValue
hostReal .pg.clickhouse.cloud hostname (TLS/SNI + cert) — or your provider’s Postgres host
hostaddrProxy address (127.0.0.1 for local dev, my-egress-envoy.flycast in production)
portProxy port (5432 direct, 6432 pooled)
sslmoderequire (or verify-full if you pin the provider CA)

See Local testing from your laptop above for the full three-terminal workflow. Minimal direct-path example:

# Terminal 1
fly proxy 15432:5432 -a my-egress-envoy

# Terminal 2
psql "postgresql://app_user:secret@/mydb?host=your-service.pg.xxxxx.aws.pg.clickhouse.cloud&hostaddr=127.0.0.1&port=15432&sslmode=require"

Production (another Fly app, pooled — no fly proxy):

postgresql://app_user:secret@/mydb?host=your-service.pg.xxxxx.aws.pg.clickhouse.cloud&hostaddr=my-egress-envoy.flycast&port=6432&sslmode=require

Success looks like:

SSL connection (protocol: TLSv1.3, cipher: TLS_AES_256_GCM_SHA384, ...)
mydb=>

Dockerfile sketch

Envoy-only image — no sidecar pooler:

FROM envoyproxy/envoy:v1.38.1

COPY envoy.yaml /etc/envoy/envoy.yaml

ENTRYPOINT ["/usr/local/bin/envoy", "-c", "/etc/envoy/envoy.yaml", "--log-level", "info"]

Pool credentials live on consumer apps (Fly secrets / env), not on the egress proxy.


Observability

Envoy access logs go to stdout — fly logs -a my-egress-envoy. Prefixes distinguish paths:

[2026-06-07T08:11:33.490Z] DIRECT 172.19.26.129:63276 your-service.pg....clickhouse.cloud:5432 1284 892 45 "-"
[2026-06-07T08:11:33.440Z] POOLED 172.19.26.129:49278 your-service.pg....clickhouse.cloud:6432 1284 892 40 "-"
  • DIRECT — client hit :5432, forwarded to CH Postgres :5432.
  • POOLED — client hit :6432, forwarded to CH managed PgBouncer :6432.

Admin stats (localhost only inside the Machine):

fly proxy 9901:9901 -a my-egress-envoy
open http://localhost:9901/stats

Troubleshooting cheat sheet

SymptomLikely causeFix
server closed the connection unexpectedly, 0 bytes in logsWrong TLS SNI (@127.0.0.1 only)Use host + hostaddr connection string
Same error, works on 127.0.0.1 inside Machine but not via fly proxy / .flycastEnvoy listening IPv4-onlyBind :: with ipv4_compat: true
Same error, DIRECT log line appears, 0 bytesEgress IP not allowlisted at ClickHouse Cloud (or other provider)Add fly ips list egress IPs to IP access list
password authentication failed on pooled pathWrong user/passwordSame credentials as Connect UI “via PgBouncer”
Works in fly ssh on 127.0.0.1:5432 but not via fly proxyEnvoy not listening on IPv6Bind :: with ipv4_compat: true

Security notes

  • The proxy app has no public ingress — only private Fly network (flycast) can reach it.
  • The egress app stores no database credentials — Envoy is L4-only; ClickHouse validates on both :5432 and :6432.
  • Put credentials on consumer apps (one CH user per app where possible); rotate there without redeploying the proxy.
  • Network isolation + IP allowlist at ClickHouse is the primary boundary; TLS uses the real .pg.clickhouse.cloud hostname via host + hostaddr.

When not to use this pattern

  • Single app, low traffic — your provider’s “allow all IPs + strong auth” may be enough.
  • Providers with Fly-native integration — some offer private link or built-in Fly peering; use that if available.
  • You need egress IP per tenant — one shared proxy means one shared egress identity; split apps or proxies if you need isolation.

Summary

  1. Create a dedicated Fly app and fly ips allocate-egress in your primary region.
  2. Run Envoy as an L4 TCP proxy — :5432 → CH Postgres, :6432 → CH managed PgBouncer (same .pg hostname).
  3. Listen on :: with ipv4_compat so 6PN and fly proxy work.
  4. Whitelist the egress IPs at ClickHouse Cloud (or PlanetScale, Neon, etc.).
  5. Test locally with fly proxy + psql (host + hostaddr) before pointing apps at .flycast.
  6. Connect from other apps with host=<real-db-host>&hostaddr=<proxy.flycast> so TLS stays correct.
  7. Run two Machines, TCP health checks, and auto_stop_machines = false — treat this as platform infrastructure.

You end up with a small, boring hop that solves a real problem: Postgres sees one stable IP, Fly apps stay dynamic.


Deployment pattern: dedicated Fly egress app, private network only, ClickHouse Cloud Postgres allowlisting.

RELATED_NODES

NODE_CHAIN // SIG_FAST

← All articles

Cloud Shuttle Insights