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

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 IPs | One (or two) egress IPs shared by all consumers
Each team updating ClickHouse allowlists | Whitelist once per database; every app uses my-egress-envoy.flycast
Per-app connection sprawl | 6432 forwards to ClickHouse’s managed PgBouncer — same credentials as the Connect UI
A single point of failure | Two 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?

Component | Role
Envoy | L4 TCP proxy, access logs, circuit breakers, one config file for many upstream ports
ClickHouse Cloud PgBouncer | Managed pooler on upstream :6432 — not run in this container
Fly static egress IP | Allocated per app + region; shared by all Machines in that app

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

Fly port | Upstream | Use for
5432 | CH Postgres :5432 (direct) | Migrations, psql, prepared statements, long sessions
6432 | CH managed PgBouncer :6432 | Normal 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 :5432 → postgres_direct_backend, listener :6432 → postgres_pooled_backend.

ClickHouse Cloud exposes two connection modes on one Postgres hostname:

Connect UI | Port | Envoy cluster
Directly | 5432 | postgres_direct_backend
via PgBouncer | 6432 | postgres_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)

Open your ClickHouse Cloud service → Settings → Security (or Network access).

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

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:

Provider | Where to allowlist | Notes
ClickHouse Cloud | Service → Security / IP access list | Separate .pg.clickhouse.cloud hostname; TLS required
PlanetScale Postgres | Database → Settings → IP restrictions | CIDR ranges per branch; optional role/schema scope (docs)
Neon | Project → Settings → IP Allow | IPv4 CIDR rules
Supabase | Project → Database → Network restrictions | IPv4 only on some plans
AWS RDS | Security group inbound rule | Port 5432 from egress IP /32
GCP Cloud SQL | Authorized networks | Add 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

Deployed proxy — at least one Machine running and healthy:

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

Egress IPs allowlisted at the database (see step 5 above). Local testing still exits via the Fly egress IP, not your home IP.

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

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.

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 port | fly proxy command | Proxy port | Path
15432 | fly proxy 15432:5432 | 5432 | Direct (Envoy → DB)
16432 | fly proxy 16432:6432 | 6432 | Pooled (Envoy → CH PgBouncer)
9901 | fly proxy 9901:9901 | 9901 | Envoy 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

Step | Command / check | Pass criteria
1 | fly checks list | All TCP checks passing
2 | fly proxy 15432:5432 | “Proxying localhost:15432…”
3 | nc -zv 127.0.0.1 15432 | Connection succeeded
4 | psql with host + hostaddr | SSL prompt, SELECT 1 works
5 | Logs during psql | DIRECT line with bytes > 0
6 | fly proxy 16432:6432 + psql | POOLED line, upstream :6432, bytes > 0
7 | fly ips list (egress) | Same IPs allowlisted at DB provider

Common local failures

What you see | What it usually means
connection refused on 127.0.0.1:15432 | fly proxy not running or wrong local port
closed unexpectedly, 0 bytes in logs | Wrong connection string (@127.0.0.1 without host=) or egress IP not allowlisted at ClickHouse Cloud or IPv4-only Envoy bind
password authentication failed | Proxy path works — ClickHouse rejected credentials
Works in fly ssh on 127.0.0.1:5432 but not via fly proxy | Envoy 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:

Parameter | Value
host | Real .pg.clickhouse.cloud hostname (TLS/SNI + cert) — or your provider’s Postgres host
hostaddr | Proxy address (127.0.0.1 for local dev, my-egress-envoy.flycast in production)
port | Proxy port (5432 direct, 6432 pooled)
sslmode | require (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

Symptom | Likely cause | Fix
server closed the connection unexpectedly, 0 bytes in logs | Wrong 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 / .flycast | Envoy listening IPv4-only | Bind :: with ipv4_compat: true
Same error, DIRECT log line appears, 0 bytes | Egress IP not allowlisted at ClickHouse Cloud (or other provider) | Add fly ips list egress IPs to IP access list
password authentication failed on pooled path | Wrong user/password | Same credentials as Connect UI “via PgBouncer”
Works in fly ssh on 127.0.0.1:5432 but not via fly proxy | Envoy not listening on IPv6 | Bind :: 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

Create a dedicated Fly app and fly ips allocate-egress in your primary region.

Run Envoy as an L4 TCP proxy — :5432 → CH Postgres, :6432 → CH managed PgBouncer (same .pg hostname).

Listen on :: with ipv4_compat so 6PN and fly proxy work.

Whitelist the egress IPs at ClickHouse Cloud (or PlanetScale, Neon, etc.).

Test locally with fly proxy + psql (host + hostaddr) before pointing apps at .flycast.

Connect from other apps with host=<real-db-host>&hostaddr=<proxy.flycast> so TLS stays correct.

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.