all posts

Routing Postgres connections by SNI

Ajay Kumar··7 min read

HTTP made this easy for everyone. A request carries a `Host` header, so one IP and one port can serve ten thousand sites — the reverse proxy reads the header and forwards accordingly. Every web platform is built on that one line of the spec.

Postgres has no such header. The wire protocol opens with a startup message containing a username and a database name, and nothing that identifies which server the client meant to reach. So when a managed platform gives every database a hostname like `<id>.db.example.com` and they all resolve to the same endpoint on port 5432, something has to work out where each connection goes. The answer is in the TLS handshake.

SNI: the hostname arrives before anything else

Server Name Indication is a TLS extension that exists to solve the certificate problem. Before a server can present a certificate, it has to know which hostname the client is trying to reach — otherwise it can't pick the right one. So the client puts the hostname in the very first message of the handshake, the ClientHello, in plaintext.

That means a proxy can read the intended hostname from the first packet of a connection, before any TLS session is established and long before any application protocol is spoken. It's the earliest possible routing signal, it's protocol-agnostic, and it's what makes hostname-based routing work for anything that speaks TLS — not just HTTP.

So the routing table is simply hostname to backend. `abc123.db.pandastack.ai` maps to the microVM running that database. The proxy reads SNI, looks up the mapping, and connects.

The Postgres quirk: TLS doesn't start at the start

Here's what makes Postgres different from routing HTTPS, and it surprises everyone implementing this for the first time.

A Postgres connection does not begin with a TLS ClientHello. It begins in cleartext: the client sends an SSLRequest message — eight bytes, essentially 'do you support TLS?' — and the server replies with a single byte, `S` for yes or `N` for no. Only after an `S` does the client start the TLS handshake and send its ClientHello with the SNI extension.

Client                          Proxy                        Database VM
  |                               |                                |
  |-- SSLRequest (8 bytes) ------>|                                |
  |                               |  (cleartext, no hostname yet)  |
  |<------------------- 'S' ------|                                |
  |                               |                                |
  |-- TLS ClientHello ----------->|                                |
  |     server_name = abc123.db.pandastack.ai                      |
  |                               |                                |
  |                               |-- look up abc123 -> VM ------->|
  |<===== TLS established ========|===== connection to backend ===>|
  |                               |                                |
  |-- StartupMessage (user, db) ----------------------------------->|

The hostname appears in message 3, not message 1. A proxy that expects
a TLS handshake on the first byte will fail every Postgres connection.

So the proxy has to speak just enough of the Postgres protocol to recognise the SSLRequest, answer it, and only then behave like a TLS-terminating proxy. Generic TLS proxies that assume a handshake begins on byte one do not work here without explicit Postgres support — which is why the feature list of any TCP proxy will mention Postgres separately if it handles it at all.

This is also why `sslmode=disable` cannot work on an SNI-routed endpoint. With no TLS there is no SNI, and with no SNI the proxy has nothing to route on — every connection would be ambiguous. Requiring TLS is not just a security policy here, it is structurally necessary.

Why this architecture is worth the trouble

  • Stable connection strings. The hostname is the identity; the backend behind it can move — a restart, a rebuild on another host, a wake from suspension — and the connection string in your application config never changes.
  • One endpoint, one port. No per-database port assignment, no port ranges to document, no firewall rules to update whenever someone creates a database. Corporate networks that allow outbound 5432 and nothing else keep working.
  • TLS everywhere by default. Since routing requires TLS, there is no unencrypted path to accidentally leave enabled.
  • The databases need no public exposure. Only the proxy is reachable from the internet; the VMs sit on internal addressing. That's a materially smaller attack surface than a database listening on a public IP.
  • Wake-on-connect becomes possible. Because the proxy is in the path and holds the connection, it can restore a suspended database and complete the connection afterwards instead of refusing it.

What to check as a user

Mostly this is invisible, which is the point. Three things do surface.

Your client must send SNI. Every modern Postgres client does when connecting by hostname with TLS enabled, but a client connecting by IP address cannot send a meaningful SNI value, so connecting to the endpoint's IP will fail. Always connect by hostname.

Your `sslmode` must be at least `require`. And `require` alone does not verify the server's certificate — it encrypts without checking who you're talking to. For anything holding real data use `verify-full`, which checks both the certificate chain and that the hostname matches. This is not specific to SNI routing, but a proxied architecture is exactly the context where certificate verification earns its keep.

# Correct: hostname, TLS, full verification.
psql "postgres://pandastack:$PW@abc123.db.pandastack.ai:5432/pandastack?sslmode=verify-full"

# Fails: no hostname means no SNI means nothing to route on.
psql "postgres://pandastack:$PW@203.0.113.10:5432/pandastack"

# Fails: no TLS means no SNI at all.
psql "postgres://pandastack:$PW@abc123.db.pandastack.ai:5432/pandastack?sslmode=disable"

# Check what the endpoint presents for a given hostname:
openssl s_client -connect abc123.db.pandastack.ai:5432 \
  -servername abc123.db.pandastack.ai -starttls postgres </dev/null

That last command is genuinely useful for debugging. `openssl s_client` understands the Postgres SSLRequest dance via `-starttls postgres`, so it will show you the certificate the proxy presents for a given SNI value — which tells you immediately whether the routing layer recognises your hostname.

The trade-offs

A proxy in the connection path is not free, and the honest accounting is short.

  • One extra network hop. Small in the same region, meaningful if the proxy and the database are far apart. Ask where the proxy sits relative to your database.
  • The proxy is a dependency. If it's down, every database is unreachable even though every database is fine. It needs to be redundant, and its health is a thing to monitor separately from the databases.
  • Connection setup does more work — the SSLRequest exchange, the routing lookup, then the backend connection. Negligible for pooled connections held open, noticeable for workloads that connect per query. Which is another argument for pooling, alongside all the usual ones.
  • Long-lived connections are pinned to the proxy instance that accepted them, so draining a proxy for maintenance means either waiting for connections to close or breaking them. Every stateful proxy has this problem; it's worth knowing it exists.

In exchange you get a stable address for a thing that moves, one port for a fleet of any size, mandatory encryption, no publicly exposed databases, and the ability to wake something up while a client politely waits. For a multi-tenant database platform that's a straightforwardly good trade — and it all rests on the fact that TLS puts the hostname in the first message, in the clear, for the benefit of certificate selection. A detail designed for one purpose that turned out to be the foundation of hostname-based routing for every protocol that isn't HTTP.

Frequently asked questions

How does a proxy route Postgres connections to the right database?

By reading the hostname from the TLS handshake. Server Name Indication puts the intended hostname in the ClientHello, in plaintext, so a proxy can see which server a client is trying to reach before any TLS session exists and before any application protocol is spoken. The proxy keeps a mapping of hostname to backend, so a connection to abc123.db.example.com is routed to the machine running that specific database. This matters because the Postgres wire protocol has no equivalent of an HTTP Host header — its startup message carries a username and database name but nothing identifying which server was meant.

Why can't I connect with sslmode=disable to a managed database?

Because with no TLS there is no SNI, and with no SNI the proxy has no hostname to route on — every connection would be ambiguous. On an SNI-routed endpoint, requiring TLS is structurally necessary rather than merely a security policy. For the same reason you must connect by hostname rather than IP address: a client connecting to a bare IP cannot send a meaningful SNI value. Use at least sslmode=require, and prefer verify-full for anything holding real data, since require encrypts the connection without verifying who is on the other end.

What is the Postgres SSLRequest and why does it complicate proxying?

A Postgres connection does not begin with a TLS handshake. It begins in cleartext with an eight-byte SSLRequest message asking whether the server supports TLS, to which the server replies with a single byte — S for yes, N for no — and only then does the client send its TLS ClientHello with the SNI extension. So the hostname appears in the third message rather than the first. A generic TLS proxy that assumes a handshake starts on the first byte will fail every Postgres connection, which is why proxies that support this list Postgres explicitly.

What are the benefits of SNI-based database routing?

The connection string becomes stable: the hostname is the database's identity, so the backend behind it can restart, be rebuilt on another host, or wake from suspension without your application configuration changing. One endpoint and one port serve a fleet of any size, so there are no per-database ports to document or firewall rules to update. TLS is mandatory by construction, so there is no unencrypted path to leave enabled by accident. The database machines themselves need no public exposure since only the proxy is internet-reachable. And because the proxy sits in the path holding the connection, it can wake a suspended database and then complete the connection rather than refusing it.

What are the downsides of putting a proxy in front of every database?

An extra network hop, which is small within a region and meaningful if the proxy sits far from the database — worth asking about. The proxy becomes a dependency whose failure makes every database unreachable even though every database is healthy, so it needs redundancy and separate monitoring. Connection setup does more work, which is negligible for pooled connections held open but noticeable for workloads that connect per query. And long-lived connections are pinned to the proxy instance that accepted them, so draining one for maintenance means either waiting for connections to close or breaking them.

Run code in a microVM in one API call.

49ms p50 cold start. Fork, snapshot, and scale to zero.

Start free
Written by Ajay Kumar, Founder, PandaStack.