Skip to content

Connection Pooling

Every AnvilBase project exposes its Postgres database two ways:

  • Direct (session mode) — a raw connection to the project’s platform_<id> database (the project UUID with hyphens removed — 32 hex chars). Full Postgres session semantics: LISTEN/NOTIFY, session-level SET, advisory locks, and session-scoped prepared statements all work.
  • Pooled (transaction mode) — a connection through Supavisor, AnvilBase’s client-facing connection pooler. Each project is a separate Supavisor tenant with isolated credentials. This is the Neon-style pooled endpoint: ideal for serverless / edge / many-short-lived-connection workloads.

Both authenticate as the same dedicated per-project role (pooler_<id>) with the same per-project password, so you can switch a connection string from direct to pooled without changing your credentials — only the host, port, and username shape change.

When to use which

WorkloadUse
Serverless functions, edge runtimes, Lambda, Cloudflare WorkersPooled
ORMs/clients that open a connection per request (Prisma, Drizzle, serverless Postgres clients)Pooled
A long-running app server with its own internal poolDirect
Anything that needs LISTEN/NOTIFY, session SET, or session-scoped prepared statementsDirect
Migrations / DDL run once at deployDirect

Transaction-mode caveats (important)

In transaction mode a backend connection is handed to your client only for the duration of a single transaction and then returned to the pool. As a consequence:

  • No session state survives across transactions. SET (without SET LOCAL), session-level variables, temp tables scoped to the session, and advisory locks held outside a transaction will not behave as they do on a direct connection.
  • No LISTEN/NOTIFY. Use AnvilBase Realtime for change streams instead.
  • Prepared statements must not span transactions. Most serverless Postgres clients handle this automatically; for some ORMs you may need to disable prepared statements / statement caching (e.g. Prisma’s pgbouncer=true / prepared_statements=false style flags) when pointing at the pooled endpoint.

If any of these bite you, use the direct connection string.

Getting your connection strings

The pooled string embeds your project’s pooler password, so it is treated as a secret reveal — fetching it is audit-logged (pooler.reveal).

CLI

Terminal window
anvilbase projects pooler <project-id>

Prints both the pooled (transaction-mode) and direct (session-mode) strings.

API

Terminal window
curl http://localhost:39001/api/v1/projects/<project-id>/pooler \
-H "Authorization: Bearer $ANVILBASE_TOKEN" | jq .
{
"project_id": "",
"enabled": true,
"mode": "transaction",
"pooled": "postgres://pooler_<id>.<project-id>:<password>@localhost:39654/platform_<id>?sslmode=prefer",
"direct": "postgres://pooler_<id>:<password>@postgres:5432/platform_<id>?sslmode=prefer"
}

This endpoint requires an admin-scoped credential (same posture as the service_role key reveal). When the deployment has no pooler configured, enabled is false and pooled is null — only the direct string is returned.

Console

Open Project → API → Connection strings → Show connection strings. The pooled and direct strings each have a copy button.

How the pooled endpoint works

  • The pooled username is the dotted form pooler_<id>.<project-id>. The suffix after the dot is the Supavisor tenant id (your project’s UUID), which is how Supavisor routes the connection to the right tenant — and therefore to the right project database.
  • The pooler reaches only your project’s own database. The per-project role is granted the authenticated role only (Row Level Security still applies); it is not a superuser, does not hold service_role, and cannot reach the platform database or any other project’s database.
  • Credentials are per project and derived from the deployment secret — no shared pooler password exists, so one project’s pooled credential can never reach another project’s data.
  • Database-level CONNECT is locked down per project: it is revoked from PUBLIC and granted only to the platform connection role (anvilbase), authenticator, and the project’s own pooler_<id> role. Because Postgres login roles are cluster-global, this is what physically stops one project’s pooler credential from connecting to another project’s database.

Note for direct, custom database roles. Because CONNECT is revoked from PUBLIC on each project database, if you create your own login role inside a project database (e.g. via the SQL editor) and want to connect to it directly, grant it CONNECT explicitly: GRANT CONNECT ON DATABASE <db_name> TO <your_role>;. The built-in pooler and direct strings, and the platform’s own connections (as anvilbase), are unaffected.

Self-hosting

The pooler is opt-in. On Docker Compose it lives behind the pooler compose profile, so a plain docker compose up does not start Supavisor — this keeps existing deployments from breaking on upgrade (the pooler’s new required secrets are not validated unless the profile is active). On Kubernetes it is gated by supavisor.enabled (default true). Either way, the control plane treats it as best-effort: if Supavisor is down or unconfigured, every other feature still works — only the pooled endpoint is unavailable. The GET /api/v1/projects/{id}/pooler response carries a pooler_reachable signal (the lazy tenant-ensure outcome), and anvilbase projects pooler warns — with the docker compose --profile pooler up -d hint — when the profile-gated pooler isn’t running, instead of handing you a dead connection string.

Enabling the pooler (Docker Compose)

With the CLI (recommended): anvilbase start enables the pooler for you — it backfills the two new secrets (SUPAVISOR_API_JWT_SECRET, SUPAVISOR_VAULT_ENC_KEY) into an existing .env and brings the stack up with --profile pooler.

Raw Docker Compose: set the two secrets in .env, then bring the stack up with the profile:

.env
SUPAVISOR_API_JWT_SECRET=$(openssl rand -hex 32)
SUPAVISOR_VAULT_ENC_KEY=$(openssl rand -hex 16) # 16 random bytes (32 hex chars)
Terminal window
docker compose --profile pooler up -d

On an existing Postgres volume (upgrade), the dedicated supavisor metadata database is created automatically by the control plane at boot when the pooler is enabled (the Postgres init script only runs on first cluster init). If your anvilbase role lacks CREATEDB, run the one-liner once as a superuser:

Terminal window
docker exec -i anvilbase-postgres \
psql -U supabase_admin -d postgres \
-c 'CREATE DATABASE supavisor OWNER anvilbase;'

Relevant settings (see also Configuration):

VariablePurposeDefault (dev)
SUPAVISOR_PORTHost port mapped to the transaction-mode pooled endpoint39654
SUPAVISOR_API_URLControl plane → Supavisor management API basehttp://supavisor:4000
SUPAVISOR_API_JWT_SECRETHS256 secret for the management-API token (must match Supavisor’s API_JWT_SECRET)(required to register tenants)
SUPAVISOR_VAULT_ENC_KEYSupavisor’s vault encryption key — 32 chars (openssl rand -hex 16)(required for the service)
SUPAVISOR_PUBLIC_HOSThost:port clients dial for the pooled endpoint (goes into the surfaced string)localhost:39654
SUPAVISOR_SSL_MODEsslmode advertised in the surfaced stringprefer (use require in production)
SUPAVISOR_POOL_SIZEPer-project transaction pool size15

Supavisor keeps its tenant catalog in a dedicated supavisor database (its own _supavisor schema) so it never collides with the platform or per-project tables. The database is created by the Postgres init script on a fresh cluster, and by the control plane at boot on an existing volume when the pooler is enabled (see the upgrade note above). Supavisor runs its own migrations on boot.

In production set SUPAVISOR_PUBLIC_HOST to the externally-reachable pooler address and SUPAVISOR_SSL_MODE=require.

Read replicas

To offload read-heavy traffic to a hot standby (and get a read-only connection string in the pooler response), enable an opt-in read replica. It is off by default; writes always hit the primary.