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 fromfly 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) inenvoy.yaml. Without this,fly proxyfails silently — TCP connects locally, then resets when the tunnel hits the Machine’s IPv6 address. -
psqlor 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
:5432and: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.cloudhostname viahost+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-egressin your primary region. - Run Envoy as an L4 TCP proxy —
:5432→ CH Postgres,:6432→ CH managed PgBouncer (same.pghostname). - Listen on
::withipv4_compatso 6PN andfly proxywork. - 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.
RELATED_NODES
NODE_CHAIN // SIG_FAST
NODE_01
AI Observability in Action: Cost Measurement & Evals with Bifrost, Langfuse, and Drover
Jun 5, 2026 · 06m read
NODE_02
Self-Governing Codebases: Bridging the Code-Architecture Gap with Governed RLMs
Jun 4, 2026 · 06m read
NODE_03
The Cognitive Map: How Governed Ontologies Supercharge Autonomous AI Developer Skills
Jun 2, 2026 · 06m read
Cloud Shuttle Insights
