The 15-Second Handshake: What Broke When a Bun Service Left Supabase
- Published on
- Reading time
- 10 mins read
On this page

On 10 September I moved a small Bun service off Supabase. Its data went into the PostgreSQL 18 instance on my Coolify server, into a database and role of its own, following the one-database-per-project pattern.
After the cutover, the service couldn't reach its database, and it didn't say why. No "connection refused", no authentication error. The connection sat in the handshake for about 15 seconds, then failed with a timeout.
The cause was one line: tls: true, hard-coded back when Supabase was the only database this code had ever talked to. The new server has SSL disabled, because the app's traffic to it stays on the internal Docker network. That combination should either fail at once or fall back to plaintext. In Bun it did neither.
Where the 15 seconds came from
The service uses Bun's built-in SQL client. Stripped down, the setup looked like this:
import { SQL } from "bun";
export const sql = new SQL(process.env.DATABASE_URL!, {
connectionTimeout: 15, // seconds
tls: true,
});
That connectionTimeout explains the number: Bun gave up exactly when its timer said so. The default is 30 seconds.
The old Supabase connection string ended in ?sslmode=require, so against Supabase the flag and the URL agreed. Against a server with SSL off, the same code waits, and what Bun eventually reports is a generic ERR_POSTGRES_CONNECTION_TIMEOUT, which points at the network rather than at TLS.
How PostgreSQL negotiates TLS
By default, Postgres doesn't start TLS the moment the socket opens, the way HTTPS does. The client opens a plain TCP connection and sends an 8-byte SSLRequest. The server replies with a single byte:
S: TLS is available. The client does the TLS handshake, then sends its startup message encrypted.N: no TLS here. Per the protocol docs, the client either sends the usual startup message in plaintext, or closes the connection if it insisted on encryption.
What the client does with that byte is decided by sslmode. These are the libpq values, which most drivers copy:
sslmode | Behavior | Server with SSL off |
|---|---|---|
disable | Plaintext only | Connects |
allow | Plaintext first, TLS if that fails | Connects |
prefer (libpq default) | TLS first, plaintext if refused | Connects in plaintext |
require | TLS only, no certificate check | Fails at once |
verify-ca | TLS, certificate must chain to a trusted CA | Fails at once |
verify-full | Like verify-ca, and the hostname must match | Fails at once |
With psql, sslmode=require against a server without SSL fails immediately with server does not support SSL, but SSL was required. That's the error you want: specific and instant.
PostgreSQL 17 added sslnegotiation=direct: libpq skips the SSLRequest and starts TLS right after TCP connects (using the postgresql ALPN identifier), saving a round trip. It's only allowed with sslmode=require or stricter, since there's no plaintext fallback. Bun always sends the SSLRequest, so it played no part here.
Why Bun waited instead of failing
Two details of Bun's SQL client on the 1.3 line (the service ran 1.3.0):
- Bun reads
sslmodefrom the URL by itself, and it also has atlsoption. Whentlsis truthy and the URL doesn't ask for TLS, Bun treats the connection asprefer. - Bun's
preferhas a gap. When the server answersN, Bun notes that TLS isn't available and keeps reading, but never sends the plaintext startup message the protocol calls for. The server waits for the client, the client waits for the server, and nothing moves untilconnectionTimeoutfires.
The second part is a known bug, oven-sh/bun#36887, with a fix proposed in #33666. Both were still open in late September 2026. Against Supabase none of this mattered: the URL said require, and the server answered S.
It reproduces with a stock postgres:18-alpine container, where SSL is off by default. With Bun 1.3.14 (same code path as 1.3.0) and connectionTimeout: 3:
| Client config | Result |
|---|---|
no sslmode, no tls option | Connects |
no sslmode, tls: true | Waits 3 s, then ERR_POSTGRES_CONNECTION_TIMEOUT |
sslmode=disable, tls: true | Waits 3 s, then ERR_POSTGRES_CONNECTION_TIMEOUT |
sslmode=prefer | Waits 3 s, then ERR_POSTGRES_CONNECTION_TIMEOUT |
sslmode=require | Fails at once with ERR_POSTGRES_TLS_NOT_AVAILABLE |
The one config that fails fast is the one that says exactly what it wants.
Bun 1.4.0 changed the first detail: an explicit tls option is now read as require, so the same code fails immediately with ERR_POSTGRES_TLS_NOT_AVAILABLE. It also fails verify-ca and verify-full fast, which 1.3.x didn't. The prefer gap is still there in 1.4.2, so sslmode=prefer against a server without TLS still hangs.
The fix: let the URL decide
The fix derives the TLS setting from sslmode in DATABASE_URL instead of hard-coding it. A minimal version of the pattern:
import { SQL } from "bun";
// TLS follows the connection string, not the code.
export function tlsFromUrl(databaseUrl: string): boolean {
const mode = new URL(databaseUrl).searchParams.get("sslmode") ?? "disable";
switch (mode) {
case "disable":
return false;
case "require":
case "verify-ca":
case "verify-full":
return true;
default:
// prefer/allow have no working plaintext fallback in Bun today (oven-sh/bun#36887)
throw new Error(`Unsupported sslmode "${mode}": use disable or require`);
}
}
const url = process.env.DATABASE_URL!;
export const sql = new SQL(url, {
connectionTimeout: 5, // seconds; Bun's default is 30
tls: tlsFromUrl(url),
});
A missing sslmode means no TLS, which is also Bun's own default. The Supabase URL already carries sslmode=require, so behavior there didn't change. The real helper in the service has unit tests for no sslmode, require, disable and a malformed URL.
Since Bun parses sslmode itself, deleting tls: true would also have fixed this case. The explicit function puts the decision where a test can cover it, and refuses prefer and allow, because in Bun today prefer against a plaintext server is exactly the hang above. A startup error beats a 15-second mystery.
Make failures fast on purpose
- Pick the timeout.
connectionTimeoutis in seconds (aliases:connection_timeout,connectTimeout,connect_timeout) and defaults to 30. On an internal network, a few seconds is plenty. - Keep it out of the URL. Bun interprets only a handful of query parameters,
sslmodeamong them. Others, likeconnect_timeoutorsslrootcert, are sent to the server as startup settings, and the server rejects them withunrecognized configuration parameter "connect_timeout". - Query at startup. Bun doesn't open a connection until the first query, so a bad connection string can hide until the first request. A
select 1during boot turns it into a failed deploy instead.
The rest of the checklist for leaving Supabase
TLS is what bit me. These are the other differences between a Supabase project and a vanilla Postgres server worth checking before a cutover.
Pooler ports and prepared statements
Supabase gives you several connection strings:
| Connection | Host and port | Notes |
|---|---|---|
| Direct | db.<project-ref>.supabase.co:5432 | IPv6, unless the project has the IPv4 add-on |
| Shared pooler (Supavisor), session mode | <pooler-host>:5432 | IPv4, user is postgres.<project-ref> |
| Shared pooler, transaction mode | <pooler-host>:6543 | IPv4, no prepared statements |
Transaction mode returns the server connection to the pool after every transaction, so prepared statements and session state (SET, advisory locks, LISTEN/NOTIFY, temp tables) don't survive. An app on port 6543 had to turn prepared statements off (prepare: false in Bun). With no pooler in front of your own Postgres, you can turn them back on, and the postgres.<project-ref> username becomes your new role.
For pg_dump, Supabase recommends the direct connection, or session mode if you're on IPv4 only.
Roles, schemas and extensions that only exist on Supabase
A Supabase project comes with roles such as anon, authenticated and service_role, which its API switches between, plus the auth and storage schemas. Most extensions live in a schema called extensions. None of these exist on a stock server, so anything in your schema that points at them fails on restore:
ERROR: role "authenticated" does not exist
ERROR: schema "auth" does not exist
ERROR: extension "pg_graphql" is not available
The usual suspects are grants and RLS policies that name Supabase roles, policies that call auth.uid(), and column defaults that call functions in the extensions schema. UUIDs don't need an extension: gen_random_uuid() has been in core Postgres since 13.
Row-level security has a quieter trap. If your backend connected to Supabase as postgres and that role owned the tables, RLS never applied to it: table owners bypass it unless it's forced. On the new server, if the app role has grants but doesn't own the tables, RLS without policies is default-deny. Every SELECT returns zero rows, with no error.
Dump and restore
- Use the target's
pg_dump, here 18. It can dump older servers but refuses newer ones, and its output isn't guaranteed to load into an older major version. - Let migrations own the schema. If the app creates its tables through its own migrations, run them on the new database as the app role and move only the data. That sidesteps every Supabase-specific statement:
# Data only, only your schema. Sequence values are included.
pg_dump "$OLD_DATABASE_URL" --data-only --schema=public --file=data.sql
# Load it as the app role, all or nothing.
psql "$NEW_DATABASE_URL" --single-transaction -v ON_ERROR_STOP=1 --file=data.sql
# pg_dump doesn't carry planner statistics by default.
psql "$NEW_DATABASE_URL" -c "ANALYZE"
- Otherwise, read the schema dump before loading it.
supabase db dumprunspg_dumpwith Supabase's managed schemas (auth,storage, extension schemas) excluded, and includes no data or roles unless asked. With plainpg_dump --schema-only --schema=public --no-owner --no-privileges, expect to delete aCREATE SCHEMA publicline (the new database already has one), plus anything that references Supabase roles,auth.*orextensions.*. - Restore as the app role. With
--no-owner, whoever runs the restore owns the objects. If a superuser already did it, runALTER ... OWNER TOper object;REASSIGN OWNED BY postgresrefuses withcannot reassign ownership of objects owned by role postgres because they are required by the database system. - Check sequences.
pg_dumpdata includessetvalcalls. Move rows any other way, such as CSV exports or a copy script, and the sequence stays put, so the next insert fails withduplicate key value violates unique constraint. Fix each one with:
SELECT setval(pg_get_serial_sequence('notes', 'id'), (SELECT max(id) FROM notes));
Keep the way back open
I left the Supabase data untouched and kept the old connection string, so switching back was a one-variable change. The TLS fix didn't close that door either: the old URL still says require, and the new code follows it.
The limit of this kind of rollback: once the new database takes writes, going back means leaving them behind or copying them over. For bigger systems, dual writes are the heavier answer; I covered that in the Couchbase to PostgreSQL migration.
Takeaways
- Before switching
DATABASE_URL, grep the code fortls,sslandsslmode. A TLS flag in code is configuration in disguise. - Write
sslmodeinto every connection string:requireor stricter for managed providers,disableonly on a network you control. - Derive the driver's TLS setting from that URL, in one small function with tests.
- With Bun, know your version: on a URL without
sslmode,tls: truemeanspreferon 1.3.x andrequireon 1.4.x. Avoidsslmode=preferuntil oven-sh/bun#36887 is fixed. - Set
connectionTimeouton purpose, in the options object rather than the URL, and run one query at startup. - Drop pooler-only settings like
prepare: falseonce nothing sits between the app and Postgres. - Search the schema for Supabase roles,
auth.*andextensions.*, and test RLS as the role your app actually uses. - Restore as the app role, check sequences, run
ANALYZE, and keep the old database and its URL until you're sure.
The note I left myself after this move: every other app coming off a managed Postgres onto this server needs its TLS assumptions checked. More lessons from the same box are in Coolify gotchas.