Supabase preview-branch compute sizing & a CI parity model
The problem
Section titled “The problem”With the Supabase GitHub integration’s Automatic branching enabled, every pull request spins up its own preview branch (a full, isolated Supabase project). That branch always provisions on the Micro compute tier (1 GB RAM), and there is no setting anywhere to change that default.
Micro is fine for steady-state use, but resetting a branch tears down all
tables/buckets and replays every migration + reseed — effectively a
supabase db reset. On a non-trivial schema, that replay can exhaust Micro’s
1 GB and fail with an out-of-memory error. The manual fix is to open the branch’s
infrastructure menu, bump Micro → Small, then reset again — per branch, by hand.
This guide shows what’s actually configurable, the cost delta, and a CI-driven workaround that provisions branches at the size you want — along with the feature-parity gaps and an IPv6 gotcha you inherit by owning the lifecycle.
Where compute size can be set
Section titled “Where compute size can be set”Size is settable, but only at branch creation, and only via the CLI or Management API — never for the auto-branching flow.
| Surface | Size knob? | Notes |
|---|---|---|
supabase branches create --size <tier> | ✅ | A branch created with --size small provisions ci_small (2 GB). |
POST /v1/projects/{ref}/branches (desired_instance_size) | ✅ | Enum pico|nano|micro|small|medium — API caps branches at medium. |
PATCH /v1/branches/{ref} (resize existing) | ❌ | Body has branch_name / git_branch / reset_on_push / persistent / status / notify_url only — no size field. |
supabase branches update | ❌ | No --size flag — you cannot resize an existing branch from the CLI. |
config.toml | ❌ | Syncs DB/API/Auth/seed/function settings to branches, but carries no compute-size key. |
| GitHub integration UI (auto-branching) | ❌ | Exposes only Automatic branching, Branch limit, Supabase changes only — no default-size setting. |
The takeaway: auto-created PR branches always come up Micro, and resizing
after the fact is dashboard-only. To get a larger tier programmatically you must
be the one calling branches create --size.
Cost delta
Section titled “Cost delta”From GET /v1/projects/{ref}/billing/addons:
| Tier | RAM | Direct conns | Pooler conns | Price |
|---|---|---|---|---|
Micro (ci_micro) | 1 GB | 60 | 200 | $0.01344/hr (~$10/mo) |
Small (ci_small) | 2 GB | 90 | 400 | $0.0206/hr (~$15/mo) |
Branch compute bills only while the branch is awake, is shown as “Branching Compute Hours” on the invoice, and is not covered by the Spend Cap and not eligible for Compute Credits. Preview branches auto-pause on inactivity and auto-delete when the PR merges/closes, so the delta is small in practice.
The two flows
Section titled “The two flows”The workaround: own the lifecycle in CI
Section titled “The workaround: own the lifecycle in CI”To control size you turn Automatic branching off (otherwise you get a duplicate Micro branch and/or hit the branch limit) and let a workflow own create → migrate → delete.
name: supabase-previewon: pull_request: types: [opened, reopened, synchronize, closed] branches: [main] # gate on Supabase files if you like; remove to run on every PR paths: ['supabase/**']
permissions: contents: readconcurrency: group: sb-preview-${{ github.event.pull_request.number }}
env: SUPABASE_ACCESS_TOKEN: ${{ secrets.SUPABASE_ACCESS_TOKEN }} PROJECT_ID: ${{ secrets.SUPABASE_PROJECT_ID }} # parent project ref BRANCH_NAME: ci-preview-${{ github.event.pull_request.number }}
jobs: upsert: # same-repo only (forks carry no secrets); skip on close if: github.event.pull_request.head.repo.full_name == github.repository && github.event.action != 'closed' runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: supabase/setup-cli@v2 with: version: latest github-token: ${{ github.token }}
- name: Create branch at Small (idempotent) run: | set -euo pipefail if ! supabase branches get "$BRANCH_NAME" --project-ref "$PROJECT_ID" >/dev/null 2>&1; then supabase branches create "$BRANCH_NAME" \ --project-ref "$PROJECT_ID" --git-branch "${{ github.head_ref }}" \ --size small --yes fi
- name: Resolve IPv4 session-pooler URL # GH runners have no IPv6 run: | set -euo pipefail supabase branches get "$BRANCH_NAME" --project-ref "$PROJECT_ID" -o env > creds.env POOLER=$(grep '^POSTGRES_URL=' creds.env | cut -d= -f2- | tr -d '"') SESSION="${POOLER/:6543/:5432}" # transaction pooler -> session mode echo "::add-mask::$SESSION" echo "PGURL=$SESSION" >> "$GITHUB_ENV"
- name: Apply migrations run: supabase db push --db-url "$PGURL" --include-all --yes
# Optional parity steps the auto-branching pipeline would do for you: # - name: Seed # run: psql "$PGURL" -f supabase/seed.sql # - name: Deploy edge functions # run: supabase functions deploy --project-ref "$(echo "$PGURL" | grep -oP 'postgres\.\K[a-z0-9]+')"
cleanup: if: github.event.action == 'closed' runs-on: ubuntu-latest env: SUPABASE_ACCESS_TOKEN: ${{ secrets.SUPABASE_ACCESS_TOKEN }} steps: - uses: supabase/setup-cli@v2 with: version: latest github-token: ${{ github.token }} - run: supabase branches delete "ci-preview-${{ github.event.pull_request.number }}" --project-ref "${{ secrets.SUPABASE_PROJECT_ID }}" --yes || trueSUPABASE_ACCESS_TOKEN— an account access token (supabase logintoken, or dashboard → Account → Access Tokens). Set as a repo secret:gh secret set SUPABASE_ACCESS_TOKEN --repo <owner>/<repo> < tokenfile.SUPABASE_PROJECT_ID— the parent project ref (not a branch ref).- In the dashboard: Integrations → GitHub → turn Automatic branching off so Supabase doesn’t also create a Micro branch.
supabase/setup-cli@v2is the official CLI installer action.
Feature-parity matrix
Section titled “Feature-parity matrix”Auto-branching runs a fixed pipeline (clone → pull → health → configure → migrate → seed → deploy) and wires in GitHub checks. Owning the lifecycle means
replicating the parts you need:
| Auto-branching does | DIY equivalent | Notes |
|---|---|---|
| Create branch on PR | branches create --size | You also get size control |
configure (apply config.toml) | Not done by db push — apply via Management API / CLI yourself | Gap to replicate |
| migrate | supabase db push --db-url <session-pooler> | Applies pending migrations |
seed (seed.sql) | psql "$PGURL" -f supabase/seed.sql | Not run by db push; add explicitly |
| deploy (edge functions) | supabase functions deploy | Only if you have functions |
Supabase Preview status check | Make the workflow itself a required check | Rebuild |
| PR comment with branch status | Add a comment step if wanted | Rebuild |
| Auto-delete on PR close | cleanup job on closed event | — |
Net: you gain size control and lose the integration’s turnkey check/comment/configure/seed/deploy ergonomics, which you rebuild in YAML.
Gotcha: GitHub runners have no IPv6
Section titled “Gotcha: GitHub runners have no IPv6”A branch’s direct connection string points at an IPv6-only host:
PGURL = postgresql://postgres@db.<ref>.supabase.co:5432/postgrespsql: error: connection to server at "db.<ref>.supabase.co" (2a05:d014:...), port 5432 failed: Network is unreachablesupabase db push against it fails and the CLI tells you why:
Your network does not support IPv6, which is required for direct connections.Retry with your project's IPv4 transaction pooler connection string via --db-url.Why auto-branching doesn’t hit this: Supabase runs its migrate step on its own (IPv6-capable) infrastructure. The moment you run migrations from a GitHub-hosted runner, you’re on an IPv4-only network.
Fix: use the IPv4 pooler. The branch’s POSTGRES_URL is the
transaction pooler (...pooler.supabase.com:6543). For migrations, derive
the session pooler by swapping the port to 5432 (session mode supports the
advisory locks / session state that db push needs):
POOLER=$(grep '^POSTGRES_URL=' creds.env | cut -d= -f2- | tr -d '"')SESSION="${POOLER/:6543/:5432}" # session pooler, IPv4-reachablesupabase db push --db-url "$SESSION" --include-all --yesAlternative: Cloudflare WARP for IPv6 egress
Section titled “Alternative: Cloudflare WARP for IPv6 egress”If you want the direct connection from a GitHub-hosted runner (e.g. to avoid
the pooler entirely), Cloudflare WARP can hand the IPv4-only runner working
public IPv6 egress. WARP routes through Cloudflare’s network, so it reaches
arbitrary IPv6 destinations — including the db.<ref>.supabase.co host — even
though the runner has no native IPv6.
- name: Cloudflare WARP (IPv6 egress, IPv4 stays direct) run: | set -euo pipefail curl -fsSL https://pkg.cloudflareclient.com/pubkey.gpg \ | sudo gpg --yes --dearmor -o /usr/share/keyrings/cloudflare-warp-archive-keyring.gpg echo "deb [signed-by=/usr/share/keyrings/cloudflare-warp-archive-keyring.gpg] https://pkg.cloudflareclient.com/ $(lsb_release -cs) main" \ | sudo tee /etc/apt/sources.list.d/cloudflare-client.list sudo apt-get update -qq && sudo apt-get install -y -qq cloudflare-warp sudo warp-cli --accept-tos registration new sudo warp-cli --accept-tos mode warp # keep IPv4 OUT of the tunnel so the runner's link to GitHub is untouched sudo warp-cli --accept-tos tunnel ip add-range 0.0.0.0/0 sudo warp-cli --accept-tos connect # connect is async — gate on the daemon actually reporting Connected for i in $(seq 1 30); do sudo warp-cli --accept-tos status 2>/dev/null | grep -qi Connected && break sleep 2 done curl -s6 --max-time 10 https://api6.ipify.org # confirm public IPv6 egress
- name: db push over the DIRECT connection run: | supabase branches get "$BRANCH_NAME" --project-ref "$PROJECT_ID" -o env > creds.env DIRECT=$(grep '^POSTGRES_URL_NON_POOLING=' creds.env | cut -d= -f2- | tr -d '"') echo "::add-mask::$DIRECT" supabase db push --db-url "$DIRECT" --include-all --yesTwo footguns that make this fail silently if you skip them:
- Split-tunnel in default Exclude mode —
tunnel ip add-range 0.0.0.0/0excludes all IPv4 from the tunnel, so only IPv6 routes through WARP and the runner’s IPv4 connection to the GitHub Actions service is left alone. Without this, full-tunnel mode pushes everything through Cloudflare. warp-cli connectis asynchronous — it returnsSuccessimmediately, well before the tunnel is up. Pollingcurlalone races; gate onwarp-cli statusreportingConnectedfirst, otherwise the IPv6 probe runs against a tunnel that hasn’t finished establishing and you get a false negative.
On a clean runner this brings up a CloudflareWARP interface with a global
2606:4700:… address and a default IPv6 route; db push then applies every
migration over the direct host.
What a run looks like
Section titled “What a run looks like”The DIY lifecycle, end to end:
- Auto-created PR branches report
ci_micro(1 GB) from the billing API. branches create … --size smallreportsci_small(2 GB).- A branch created via the CLI/API starts empty —
db pushthen applies every migration:
pastes table present before db push: f # branch starts emptyApplying migration 20260407101812_remote_schema.sql...... (all migrations) ...Finished supabase db push.table public.pastes present: ttable public.slugs present: t- On the
closedevent, only thecleanupjob runs and the branch is deleted, stopping the compute billing.
Recommendation
Section titled “Recommendation”If a repo’s branches OOM on reset, either bump Micro→Small by hand, or adopt the CI workflow above to provision at Small from the start.
See also
Section titled “See also”- Supabase docs: Branching, Working with branches, Manage Branching usage