# Supavisor keeps one tenant pool alive across a cluster of nodes

> Supavisor is the Elixir connection pooler Supabase puts beside its managed Postgres, collapsing millions of client connections into one owned pool per tenant. The part worth reading closely is what the repository has not built yet: session pooling, query caching and read-replica balancing all sit on the roadmap, while transaction mode is the only pool mode that ships.

**supabase/supavisor** — A cloud-native, multi-tenant Postgres connection pooler.

- Repository: https://github.com/supabase/supavisor
- Website: https://supabase.github.io/supavisor/
- Stars: 2,280 · Forks: 124
- Language: Elixir
- License: Apache-2.0
- Published: 2026-10-14 · Updated: 2026-10-14 · Language: en
- Canonical page: https://hysenlabs.com/projects/supabase-supavisor

## Millions of client sockets, one pool per tenant

Postgres will not open unlimited client backends, and serverless functions ask for them by the thousand. Supavisor is the Elixir answer to that shape of load. It is catalogued as a cloud-native, multi-tenant Postgres connection pooler, and a cluster of its nodes is meant to proxy millions of end-client connections into a stateful pool of native Postgres connections.

The buyer is a platform team running many tenant databases side by side, not one operator with one database. Three motives are written down. Zero-downtime scaling needs something outside Postgres that can buffer and re-route requests while server compute is resized. Serverless developers need a pooler that speaks TCP and HTTP. Efficiency needs stating in the project's own terms: PgBouncer is called resource-efficient, yet it still consumes resources on the database instance, so pooling is moved to a dedicated cluster adjacent to the tenant databases and the instance keeps more capacity for queries.

## Pool process IDs travel across cluster nodes

One pool per tenant, not one pool per node. Tenant configuration lives in a highly available Postgres database, and it is loaded from that Supavisor database at the moment a tenant connection pool is started. When a client reaches any node, that node starts the tenant pool, opens every connection to the tenant database, and then distributes the new pool's process ID to all nodes of the cluster, where it is kept in an in-memory key-value store.

Later clients for the same tenant land on whichever inbound node they happened to reach, and the connection data is proxied from the pool node to the client connection node as needed. The sizing claims are plain: 1 million Postgres connections on a cluster, and 250_000 idle connections on a single 16 core node with 64GB of ram. Because the count of Postgres connections is constrained, keeping one owner of those connections matters more than keeping every node equally busy.

## Two starting pools, one graceful shutdown

The interesting failure case is two clients hitting different nodes in the same instant, each starting its own pool for the same tenant. Both pools exist for a moment. The process ID broadcast settles it: as the pool process IDs spread across the cluster, eventually one of those pools is gracefully shut down.

Node loss travels the same route. Each node monitors the pool processes, and if a node goes down, that process ID is removed from the cluster, so tenant clients start a new pool automatically as they reconnect. That is also what makes upgrades safe: a single VPC across multiple availability zones, plus rolling and blue-green deployment strategies, are listed as supported, while load balancing queries across read replicas is marked todo and sits outside what the topology can do today.

## Starting one node from the compose file

The repository ships a compose file that builds the pooler from its own Dockerfile and puts it beside the `supabase/postgres:15.1.0.148` image. The pooler service publishes three ports and runs migrations before the server starts:

```yaml
  supavisor:
    build: .
    container_name: supavisor
    ports:
      - 4000:4000
      - 5452:5452
      - 6543:6543
    environment:
      PORT: 4000
      PROXY_PORT_SESSION: 5452
      PROXY_PORT_TRANSACTION: 6543
      CLUSTER_POSTGRES: "true"
    command:  sh -c "/app/bin/migrate && /app/bin/server"
```

Port 4000 is the HTTP side, 5452 is the session proxy, 6543 is the transaction proxy, and the service waits on db to come up first. Two entries in that block carry more weight than the rest. `CLUSTER_POSTGRES: "true"` switches the cluster machinery on even in a one container local run, so tenant pool process IDs are broadcast through an in-memory key-value store with a single subscriber. And `VAULT_ENC_KEY` ships as a placeholder string while `API_JWT_SECRET` and `METRICS_JWT_SECRET` are both set to dev, which is fine on a laptop and wrong anywhere else.

Outside a container, the Makefile exposes the same two steps as targets, with migrations pointed at their own schema prefix:

```bash
mix ecto.migrate --prefix _supavisor --log-migrator-sql
docker-compose -f ./docker-compose.db.yml up -d
```

The dev target goes further and launches node1 inside iex as `node1@127.0.0.1` with the cookie cookie, which is how a single machine ends up running more than one cluster node.

## Throughput claim and benchmark sit apart

The feature list puts throughput within 90% of PgBouncer, measured by running `pgbench` locally. The benchmark section behind that line runs `pgbench` against PgBouncer in transaction mode with pool size 60, then against Supavisor with pool size 60 and no logs:

```bash
PGPASSWORD=postgres pgbench -M extended --transactions 100 --jobs 10 --client 100 -h localhost -p 6452 -U postgres postgres
PGPASSWORD=postgres pgbench -M extended --transactions 100 --jobs 10 --client 100 -h localhost -p 7654 -U postgres.localhost postgres
```

Only the PgBouncer side is printed in full: latency average = 510.310 ms and tps = 195.959361. The Supavisor output in the README breaks off mid-line at maximum number of trie, so the tps figure the 90% claim rests on is never shown next to it. Two smaller mismatches are easy to miss. The runs connect to ports 6452 and 7654, while the compose file publishes 5452 and 6543 for the session and transaction proxies, and the username changes between the two runs, from postgres to `postgres.localhost`.

## Transaction pooling only, session mode still listed

Pool mode support per tenant is listed as Transaction, one entry and nothing else. Session pooling sits under future work instead, described there as like PgBouncer, and that is the same feature list that calls single-tenant use an easy drop-in replacement for PgBouncer.

Read together, the replacement claim is bounded by the single mode that ships. An application that depends on session state across transactions, on prepared statements that outlive one transaction, or on advisory locks held for the life of a connection has to wait for that roadmap entry. The comparison benchmark agrees with the narrower reading: it measures PgBouncer in transaction mode, which is the one overlap between the two poolers today.

## Two proxy port schemes in one repository

The compose file gives each mode a single port through `PROXY_PORT_SESSION` and `PROXY_PORT_TRANSACTION`. The Makefile does something else for the extra local nodes: node2 and node3 move sessions to 5442 and 5443 and transactions to 6553 and 6554, then add a second tier of listeners as comma separated lists.

```bash
SESSION_PROXY_PORTS="12200,12201,12202,12203"
TRANSACTION_PROXY_PORTS="12204,12205,12206,12207"
```

Only those two nodes set `NODE_IP=localhost`, and they start with `mix phx.server`, while node1 stays on `mix run --no-halt` under iex. Node1 alone also carries `DB_POOL_SIZE="5"` and `METRICS_DISABLED="false"`, so the three node local cluster is not a uniform setup.

Operators reach the pooler through a Prometheus `/metrics` endpoint, an OpenAPI spec at `/api/openapi` and SwaggerUI at `/swaggerui`, with throughput reported by tenant, by tenant database or by individual connection. A `mkdocs.yaml` and a `docs/` directory hold the site, and the README sends readers to the environment variable page for the full key list. Rebuilds pin the toolchain: `ARG ELIXIR_VERSION=1.18.5` and `ARG OTP_VERSION=27.3.4.16` on a Debian trixie base rather than Alpine, because Alpine caused DNS resolution problems in production, and the builder stage re-reads `.tool-versions` and fails the build when those ARG defaults drift from it. The last push was on 2026-09-28 and the newest tagged build is v2.9.13 from 2026-09-10.

## Where PgBouncer stops and the roadmap starts

The stated difference from PgBouncer is placement and shape, not pooling algorithm. PgBouncer sits next to the database and costs resources there; Supavisor runs as its own cluster with a configuration database, a transaction proxy port, a session proxy port, an HTTP interface on 4000, and nodes that can be lost without losing tenant pools.

Everything beyond that is roadmap, and the list is long enough to size a decision: load balancing across read replicas, query caching in the pool cluster, session pooling, a multi-protocol query interface over Postgres binary, HTTPS and websocket, primary election with health checks, and config as code through Pulumi or Terraform. No query cache and no read-replica routing sit in the running system. Two shipped items narrow the operational shape further. Connection buffering gives brief buffering for transparent database restarts or failovers, and the feature list states plainly that the pooler is NOT run in a serverless environment and NOT dependant on Kubernetes.

Apache-2.0 covers the code, so a modified build carries the usual notice obligations of an Apache-licensed deployment.

## Conclusion

Adopt Supavisor when many tenant Postgres databases sit behind serverless clients and pooling has to live on a cluster of its own. Transaction mode, the proxy ports, the /metrics endpoint and the migrate-then-server command are all in the repository today. Leave it alone when session state across transactions matters, when the roadmap has to be waited on, or when an Elixir cluster carrying its own configuration database is more operational weight than you want. Read the environment variable page and the proxy port keys before committing.

## FAQ

### Does Supavisor support session pooling the way PgBouncer does?

Not yet. Pool mode support per tenant is listed as Transaction, while session pooling appears under future work, described as like PgBouncer.

### How many connections can one Supavisor cluster handle?

The feature list states 1 million Postgres connections on a cluster, and 250_000 idle connections on a single 16 core node with 64GB of ram.

### How do I run Supavisor locally with Docker Compose?

The compose file builds the pooler from its own Dockerfile, publishes 4000, 5452 and 6543, and starts with a command that runs /app/bin/migrate before /app/bin/server. The Makefile has matching targets for the database and for migrations under the _supavisor prefix.

### Does Supavisor need Kubernetes to run?

The feature list states that it is NOT dependant on Kubernetes and NOT run in a serverless environment, and it lists rolling and blue-green deployment strategies as supported.

### What happens to tenant pools when a cluster node dies?

Each node monitors the pool processes, and the process ID of a pool on a dead node is removed from the cluster, so tenant clients start a new pool as they reconnect.

## Sources

- [License: Apache-2.0](https://github.com/supabase/supavisor/blob/main/LICENSE)
- [Project website](https://supabase.github.io/supavisor/)
- [README](https://github.com/supabase/supavisor/blob/main/README.md)
- [Releases](https://github.com/supabase/supavisor/releases)
- [supabase/supavisor on GitHub](https://github.com/supabase/supavisor)

---

Hysen Labs editorial analysis, written from the project's own repository and release notes. Cite the canonical page: https://hysenlabs.com/projects/supabase-supavisor
