From 768cf11b9caaebb6ebf4bb18e2df3cc098edfec6 Mon Sep 17 00:00:00 2001 From: Inder Singh <85822513+singh-inder@users.noreply.github.com> Date: Thu, 20 Aug 2026 14:51:54 +0530 Subject: [PATCH] feat(self-hosted): add pgbouncer override (#49052) --- .github/workflows/self-host-tests-smoke.yml | 4 +- docker/.env.example | 11 ++-- docker/docker-compose.pgbouncer.yml | 56 +++++++++++++++++++++ docker/tests/test-container-logs.sh | 16 ++++-- docker/tests/test-self-hosted.sh | 43 ++++++++++++++++ 5 files changed, 123 insertions(+), 7 deletions(-) create mode 100644 docker/docker-compose.pgbouncer.yml diff --git a/.github/workflows/self-host-tests-smoke.yml b/.github/workflows/self-host-tests-smoke.yml index ac7e0eecc60..cc826e48b2e 100644 --- a/.github/workflows/self-host-tests-smoke.yml +++ b/.github/workflows/self-host-tests-smoke.yml @@ -20,7 +20,7 @@ jobs: strategy: fail-fast: false matrix: - config: [default, logs, kong, rustfs, kong-rustfs] + config: [default, logs, kong, rustfs, kong-rustfs, pgbouncer] steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 @@ -57,6 +57,8 @@ jobs: docker compose -f docker-compose.yml -f docker-compose.rustfs.yml up --quiet-pull --wait --wait-timeout 180 elif [ "${{ matrix.config }}" = "kong-rustfs" ]; then docker compose -f docker-compose.yml -f docker-compose.kong.yml -f docker-compose.rustfs.yml up --quiet-pull --wait --wait-timeout 180 + elif [ "${{ matrix.config }}" = "pgbouncer" ]; then + docker compose -f docker-compose.yml -f docker-compose.pgbouncer.yml up --quiet-pull --wait --wait-timeout 180 else docker compose up --quiet-pull --wait --wait-timeout 180 fi diff --git a/docker/.env.example b/docker/.env.example index fe428f5091d..997b20e6482 100644 --- a/docker/.env.example +++ b/docker/.env.example @@ -119,19 +119,24 @@ POSTGRES_PORT=5432 ############ -# Supavisor - Database pooler +# Database pooler ############ +# Self-hosted Supabase uses Supavisor as the default database pooler. +# If you use the PgBouncer docker-compose override, Supavisor is disabled +# and the pooler settings below are used to configure PgBouncer instead. +# # Supavisor exposes POSTGRES_PORT and POOLER_PROXY_PORT_TRANSACTION, # POSTGRES_PORT is used for session mode pooling +# PgBouncer only exposes POOLER_PROXY_PORT_TRANSACTION. # # Port to use for transaction mode pooling connections POOLER_PROXY_PORT_TRANSACTION=6543 -# Maximum number of PostgreSQL connections Supavisor opens per pool +# Maximum number of PostgreSQL connections Supavisor or PgBouncer opens per pool POOLER_DEFAULT_POOL_SIZE=20 -# Maximum number of client connections Supavisor accepts per pool +# Maximum number of client connections Supavisor or PgBouncer accepts per pool POOLER_MAX_CLIENT_CONN=100 # Unique Supavisor tenant identifier diff --git a/docker/docker-compose.pgbouncer.yml b/docker/docker-compose.pgbouncer.yml new file mode 100644 index 00000000000..d745ed484d5 --- /dev/null +++ b/docker/docker-compose.pgbouncer.yml @@ -0,0 +1,56 @@ +# PgBouncer override for self-hosted Supabase +# +# Replaces the default Supavisor pooler with PgBouncer in transaction mode. +# +# Usage: +# docker compose -f docker-compose.yml -f docker-compose.pgbouncer.yml up -d +# +# Or use run.sh to add PgBouncer to the stack: +# sh run.sh config add pgbouncer +# sh run.sh start +# + +services: + supavisor: !reset null + + # To expose Postgres directly on POSTGRES_PORT (5432), uncomment the "db" + # section below. + # WARNING: this opens Postgres to the external traffic. Restrict access at the + # network level if necessary. + #db: + # ports: + # - ${POSTGRES_PORT}:${POSTGRES_PORT} + + pgbouncer: + container_name: supabase-pgbouncer + image: edoburu/pgbouncer:v1.25.2-p0 + restart: unless-stopped + ports: + - ${POOLER_PROXY_PORT_TRANSACTION}:6432 + healthcheck: + test: ['CMD', 'pg_isready', '-h', 'localhost', '-p', '6432'] + interval: 10s + timeout: 5s + retries: 3 + start_period: 5s + depends_on: + db: + condition: service_healthy + environment: + # PgBouncer connects to Postgres as the dedicated `pgbouncer` role and + # looks up every other role's password on demand via the auth_query below. + DB_USER: pgbouncer + DB_PASSWORD: ${POSTGRES_PASSWORD} + DB_HOST: ${POSTGRES_HOST} + DB_PORT: ${POSTGRES_PORT} + DB_NAME: ${POSTGRES_DB} + LISTEN_PORT: 6432 + AUTH_TYPE: scram-sha-256 + AUTH_QUERY: SELECT * FROM pgbouncer.get_auth($$1) + # POOL_MODE can be: session, transaction, or statement + POOL_MODE: transaction + DEFAULT_POOL_SIZE: ${POOLER_DEFAULT_POOL_SIZE} + MAX_CLIENT_CONN: ${POOLER_MAX_CLIENT_CONN} + # Only the `pgbouncer` role may run get_auth / the admin console. + ADMIN_USERS: pgbouncer + STATS_USERS: pgbouncer diff --git a/docker/tests/test-container-logs.sh b/docker/tests/test-container-logs.sh index 7780735937c..85a64b6d78e 100644 --- a/docker/tests/test-container-logs.sh +++ b/docker/tests/test-container-logs.sh @@ -148,9 +148,19 @@ check_logs_if_running analytics \ 'Executing startup tasks' \ 'Ensuring single tenant user is seeded' -check_logs supavisor \ - 'Connected to Postgres database' \ - 'HEAD /api/health$' +# Database pooler: Supavisor by default, or PgBouncer when the pgbouncer +# override is enabled (which disables Supavisor). Check whichever is running. +if is_service_running supavisor; then + check_logs supavisor \ + 'Connected to Postgres database' \ + 'HEAD /api/health$' +elif is_service_running pgbouncer; then + check_logs pgbouncer \ + 'process up: PgBouncer' \ + 'listening on .*6432' +else + fail_msg "pooler (neither supavisor nor pgbouncer is running)" +fi check_logs_if_running vector \ 'Vector has started' diff --git a/docker/tests/test-self-hosted.sh b/docker/tests/test-self-hosted.sh index 216a5cdcbf2..d842cc9dcfc 100644 --- a/docker/tests/test-self-hosted.sh +++ b/docker/tests/test-self-hosted.sh @@ -77,6 +77,16 @@ http_body() { curl -s "$@" "$url" } +# Is a compose service running? Falls back to a label lookup so it works +# regardless of which override files are loaded in this shell. +service_running() { + svc="$1" + docker compose ps --services --status running 2>/dev/null | grep -qx "$svc" && return 0 + docker ps --filter "label=com.docker.compose.project=${COMPOSE_PROJECT_NAME:-supabase}" \ + --filter "label=com.docker.compose.service=$svc" \ + --filter "status=running" --quiet | grep -q '.' +} + echo "" echo "=== Self-hosted smoke test against $BASE_URL ===" echo "" @@ -487,6 +497,39 @@ check "Realtime /api/openapi blocked" "403" \ "$(http_status "$BASE_URL/realtime/v1/api/openapi" \ -H "apikey: $ANON_KEY")" +# --------------------------------------------- +# 10. Database pooler (transaction mode) +# --------------------------------------------- + +echo "" +echo "--- Database pooler (transaction mode) ---" +if command -v docker >/dev/null 2>&1; then + pg_password=$(grep '^POSTGRES_PASSWORD=' .env | cut -d= -f2-) + pooler_tenant_id=$(grep '^POOLER_TENANT_ID=' .env | cut -d= -f2-) + # Connect as the postgres role through whichever transaction pooler is running: + # Supavisor (default) -> service 'supavisor', port 6543, user 'postgres.' + # PgBouncer (override) -> service 'pgbouncer', port 6432 (published as 6543), user 'postgres' + # psql runs inside the db container (always has a client) and reaches the + # pooler over the compose network. + if service_running supavisor; then + pooler_user="postgres.$pooler_tenant_id"; pooler_host="supavisor"; pooler_port=6543 + elif service_running pgbouncer; then + pooler_user="postgres"; pooler_host="pgbouncer"; pooler_port=6432 + else + pooler_user="" + fi + if [ -n "$pooler_user" ]; then + pooler_result=$(docker exec -e PGPASSWORD="$pg_password" supabase-db \ + psql "host=$pooler_host port=$pooler_port user=$pooler_user dbname=postgres sslmode=disable" \ + -tAc "select 'pooler_ok';" 2>/dev/null | tr -d '[:space:]') + check "Pooler transaction-mode query (as postgres)" "pooler_ok" "$pooler_result" + else + check "Pooler running (supavisor or pgbouncer)" "true" "false" + fi +else + echo " SKIP: docker not available" +fi + # --------------------------------------------- # Summary # ---------------------------------------------