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-levelSET, 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
| Workload | Use |
|---|---|
| Serverless functions, edge runtimes, Lambda, Cloudflare Workers | Pooled |
| ORMs/clients that open a connection per request (Prisma, Drizzle, serverless Postgres clients) | Pooled |
| A long-running app server with its own internal pool | Direct |
Anything that needs LISTEN/NOTIFY, session SET, or session-scoped prepared statements | Direct |
| Migrations / DDL run once at deploy | Direct |
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(withoutSET 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=falsestyle 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
anvilbase projects pooler <project-id>Prints both the pooled (transaction-mode) and direct (session-mode) strings.
API
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
authenticatedrole only (Row Level Security still applies); it is not a superuser, does not holdservice_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
CONNECTis locked down per project: it is revoked fromPUBLICand granted only to the platform connection role (anvilbase),authenticator, and the project’s ownpooler_<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
CONNECTis revoked fromPUBLICon 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 itCONNECTexplicitly:GRANT CONNECT ON DATABASE <db_name> TO <your_role>;. The built-in pooler and direct strings, and the platform’s own connections (asanvilbase), 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:
SUPAVISOR_API_JWT_SECRET=$(openssl rand -hex 32)SUPAVISOR_VAULT_ENC_KEY=$(openssl rand -hex 16) # 16 random bytes (32 hex chars)docker compose --profile pooler up -dOn 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:
docker exec -i anvilbase-postgres \ psql -U supabase_admin -d postgres \ -c 'CREATE DATABASE supavisor OWNER anvilbase;'Relevant settings (see also Configuration):
| Variable | Purpose | Default (dev) |
|---|---|---|
SUPAVISOR_PORT | Host port mapped to the transaction-mode pooled endpoint | 39654 |
SUPAVISOR_API_URL | Control plane → Supavisor management API base | http://supavisor:4000 |
SUPAVISOR_API_JWT_SECRET | HS256 secret for the management-API token (must match Supavisor’s API_JWT_SECRET) | (required to register tenants) |
SUPAVISOR_VAULT_ENC_KEY | Supavisor’s vault encryption key — 32 chars (openssl rand -hex 16) | (required for the service) |
SUPAVISOR_PUBLIC_HOST | host:port clients dial for the pooled endpoint (goes into the surfaced string) | localhost:39654 |
SUPAVISOR_SSL_MODE | sslmode advertised in the surfaced string | prefer (use require in production) |
SUPAVISOR_POOL_SIZE | Per-project transaction pool size | 15 |
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.