Skip to content

The 15-Second Handshake: What Broke When a Bun Service Left Supabase

Published on
Reading time
10 mins read

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:

db.ts
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:

sslmodeBehaviorServer with SSL off
disablePlaintext onlyConnects
allowPlaintext first, TLS if that failsConnects
prefer (libpq default)TLS first, plaintext if refusedConnects in plaintext
requireTLS only, no certificate checkFails at once
verify-caTLS, certificate must chain to a trusted CAFails at once
verify-fullLike verify-ca, and the hostname must matchFails 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):

  1. Bun reads sslmode from the URL by itself, and it also has a tls option. When tls is truthy and the URL doesn't ask for TLS, Bun treats the connection as prefer.
  2. Bun's prefer has a gap. When the server answers N, 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 until connectionTimeout fires.

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 configResult
no sslmode, no tls optionConnects
no sslmode, tls: trueWaits 3 s, then ERR_POSTGRES_CONNECTION_TIMEOUT
sslmode=disable, tls: trueWaits 3 s, then ERR_POSTGRES_CONNECTION_TIMEOUT
sslmode=preferWaits 3 s, then ERR_POSTGRES_CONNECTION_TIMEOUT
sslmode=requireFails 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:

db.ts
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. connectionTimeout is 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, sslmode among them. Others, like connect_timeout or sslrootcert, are sent to the server as startup settings, and the server rejects them with unrecognized 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 1 during 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:

ConnectionHost and portNotes
Directdb.<project-ref>.supabase.co:5432IPv6, unless the project has the IPv4 add-on
Shared pooler (Supavisor), session mode<pooler-host>:5432IPv4, user is postgres.<project-ref>
Shared pooler, transaction mode<pooler-host>:6543IPv4, 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 dump runs pg_dump with Supabase's managed schemas (auth, storage, extension schemas) excluded, and includes no data or roles unless asked. With plain pg_dump --schema-only --schema=public --no-owner --no-privileges, expect to delete a CREATE SCHEMA public line (the new database already has one), plus anything that references Supabase roles, auth.* or extensions.*.
  • Restore as the app role. With --no-owner, whoever runs the restore owns the objects. If a superuser already did it, run ALTER ... OWNER TO per object; REASSIGN OWNED BY postgres refuses with cannot reassign ownership of objects owned by role postgres because they are required by the database system.
  • Check sequences. pg_dump data includes setval calls. Move rows any other way, such as CSV exports or a copy script, and the sequence stays put, so the next insert fails with duplicate 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 for tls, ssl and sslmode. A TLS flag in code is configuration in disguise.
  • Write sslmode into every connection string: require or stricter for managed providers, disable only 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: true means prefer on 1.3.x and require on 1.4.x. Avoid sslmode=prefer until oven-sh/bun#36887 is fixed.
  • Set connectionTimeout on purpose, in the options object rather than the URL, and run one query at startup.
  • Drop pooler-only settings like prepare: false once nothing sits between the app and Postgres.
  • Search the schema for Supabase roles, auth.* and extensions.*, 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.