Skip to content

Supabase preview-branch compute sizing & a CI parity model

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 three approaches to get branches off Micro: keep auto-branching and resize after creation via the compute add-on, take over the lifecycle and create at the size you want, or bump it by hand. It also covers the feature-parity gaps and an IPv6 gotcha you inherit when you own the lifecycle.

Size is settable two ways: at branch creation (CLI or Management API), or after creation by applying a compute add-on to the branch (a branch is a project, so the project billing API applies to it). The one place it is not settable is the auto-branching flow itself — there is no default-size knob.

SurfaceSize 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 at creation.
PATCH /v1/projects/{branch_ref}/billing/addons (resize existing){addon_type:"compute_instance", addon_variant:"ci_small"} scales an existing branch up/down — to ci_xlarge. Restarts Postgres.
DELETE /v1/projects/{branch_ref}/billing/addons/{variant}Reverts the compute add-on to the previous size.
PATCH /v1/branches/{ref} (branch metadata)Body has branch_name / git_branch / reset_on_push / persistent / status / notify_url only — no size field.
supabase branches updateNo --size flag.
config.tomlSyncs 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 onlyno default-size setting.

The takeaway: auto-created PR branches always come up Micro, but you have two programmatic levers to get a larger tier — create the branch yourself at the size you want, or let auto-branching create it Micro and then resize it via the compute add-on.

From GET /v1/projects/{ref}/billing/addons:

TierRAMDirect connsPooler connsPrice
Micro (ci_micro)1 GB60200$0.01344/hr (~$10/mo)
Small (ci_small)2 GB90400$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.

Pull Request openedA. Auto-branching only- GitHub App creates branch Micro- configure / migrate / seed / deploy- No size knob -> OOM risk on resetMicro, fixedB. Auto-branching + resize- App creates branch Micro (still)- CI polls for the branch ref- PATCH .../billing/addons -> Small- Restarts PG; keeps the integrationMicro then SmallC. DIY create-at-size- Auto-branching off- branches create --size small- You run migrate / seed / deploy- Born Small; no restart, no OOMSmall from boot

Pick by constraint:

  • Keep the GitHub integration, and the OOM only bites on resetB (resize after). The branch boots Micro, then a one-time add-on call bumps it to Small; later resets re-migrate on Small. One API call, every integration feature intact.
  • Want Small from the first byte, no restart, OOM structurally impossibleC (create at size). You give up the integration’s turnkey check/comment/configure/seed/deploy and own the lifecycle.
  • A is the status quo that OOMs — listed only for contrast.

Approach B: keep auto-branching, resize after creation

Section titled “Approach B: keep auto-branching, resize after creation”

If you want to keep the GitHub integration’s turnkey pipeline (its checks, PR comment, configure/migrate/seed/deploy, and auto-delete) and only need branches off Micro, leave Automatic branching on and add a small workflow that resizes the branch the App created. Because a branch is a project, you apply a compute_instance add-on to its project_ref.

.github/workflows/supabase-preview-resize.yml
name: supabase-preview-resize
on:
pull_request:
types: [opened, reopened]
permissions:
contents: read
concurrency:
group: sb-resize-${{ github.event.pull_request.number }}
jobs:
resize:
# same-repo only (forks carry no secrets)
if: github.event.pull_request.head.repo.full_name == github.repository
runs-on: ubuntu-latest
env:
SUPABASE_ACCESS_TOKEN: ${{ secrets.SUPABASE_ACCESS_TOKEN }}
PARENT_REF: ${{ secrets.SUPABASE_PROJECT_ID }} # parent project ref
PR_NUMBER: ${{ github.event.pull_request.number }}
TARGET: ci_small # ci_micro|ci_small|ci_medium|ci_large|ci_xlarge
steps:
- name: Wait for the auto-branch, then resize it
run: |
set -euo pipefail
api="https://api.supabase.com/v1"
auth=(-H "Authorization: Bearer $SUPABASE_ACCESS_TOKEN")
# 1. Wait until the App has FINISHED its pipeline on this PR's branch.
# Resizing restarts Postgres, so we must not resize mid-migration —
# gate on MIGRATIONS_PASSED / FUNCTIONS_DEPLOYED, not mere existence.
branch_ref=""
for i in $(seq 1 60); do # ~15 min @ 15s
branch_ref=$(curl -fsS "${auth[@]}" "$api/projects/$PARENT_REF/branches" \
| jq -r --argjson pr "$PR_NUMBER" \
'.[]|select(.pr_number==$pr and (.status=="MIGRATIONS_PASSED" or .status=="FUNCTIONS_DEPLOYED"))|.project_ref' | head -n1)
[ -n "$branch_ref" ] && [ "$branch_ref" != "null" ] && break
echo "branch not ready ($i)…"; sleep 15
done
[ -n "$branch_ref" ] || { echo "::error::branch not ready in time"; exit 1; }
# 2. Skip if already at target.
cur=$(curl -fsS "${auth[@]}" "$api/projects/$branch_ref/billing/addons" \
| jq -r '.selected_addons[]?|select(.type=="compute_instance")|.variant.id')
[ "$cur" = "$TARGET" ] && { echo "already $TARGET"; exit 0; }
# 3. Apply the compute add-on to the BRANCH project (this restarts it).
curl -fsS -X PATCH "${auth[@]}" -H "Content-Type: application/json" \
"$api/projects/$branch_ref/billing/addons" \
-d "$(jq -n --arg v "$TARGET" \
'{addon_type:"compute_instance",addon_variant:$v}')"
# 4. Confirm.
for i in $(seq 1 20); do
now=$(curl -fsS "${auth[@]}" "$api/projects/$branch_ref/billing/addons" \
| jq -r '.selected_addons[]?|select(.type=="compute_instance")|.variant.id')
[ "$now" = "$TARGET" ] && { echo "resized to $now"; exit 0; }
sleep 15
done
echo "::warning::resize not confirmed yet"

Net: you keep every integration feature (checks, PR comment, configure / seed / deploy, auto-delete) and add one API call, at the cost of a Postgres restart and the Micro boot/first-migration window. It can also scale beyond the create-time medium cap (up to ci_xlarge).

Approach C: own the lifecycle in CI (create at Small)

Section titled “Approach C: own the lifecycle in CI (create at Small)”

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.

.github/workflows/supabase-preview.yml
name: supabase-preview
on:
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: read
concurrency:
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 || true

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 doesDIY equivalentNotes
Create branch on PRbranches create --sizeYou also get size control
configure (apply config.toml)Not done by db push — apply via Management API / CLI yourselfGap to replicate
migratesupabase db push --db-url <session-pooler>Applies pending migrations
seed (seed.sql)psql "$PGURL" -f supabase/seed.sqlNot run by db push; add explicitly
deploy (edge functions)supabase functions deployOnly if you have functions
Supabase Preview status checkMake the workflow itself a required checkRebuild
PR comment with branch statusAdd a comment step if wantedRebuild
Auto-delete on PR closecleanup 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: the configure step needs your config.toml secrets (or it lies with MIGRATIONS_FAILED)

Section titled “Gotcha: the configure step needs your config.toml secrets (or it lies with MIGRATIONS_FAILED)”

This one only bites the full-deploy path — native auto-branching, or a branch created with a git association so Supabase runs the whole configure -> migrate -> deploy -> seed pipeline from your repo. Approach C above sidesteps it precisely because bare-branch + db push skips configure (the “Gap to replicate” row in the matrix). If you do want configure parity, read this first.

The branch status reflects the whole deploy, and a failure in the configure step is still reported as MIGRATIONS_FAILED — so you go debugging SQL that was never wrong. The usual trigger: config.toml sets custom Auth via env() secrets, e.g.

[auth.rate_limit]
email_sent = 30 # a custom rate limit ...
[auth.email.smtp]
sender_name = "Pasteriser" # ... and a custom SMTP sender ...
pass = "env(RESEND_API_KEY)" # ... whose password is an env() secret
[auth.external.github]
enabled = true
client_id = "env(GH_OAUTH_CLIENT_ID)"
secret = "env(GH_OAUTH_CLIENT_SECRET)"

A preview branch does not inherit the parent project’s secrets. So env(RESEND_API_KEY) resolves to empty, and because you asked for a custom sender name / email rate limit without a valid SMTP password, Supabase rejects the configure step with a 401:

unexpected status 401: {"message":"Custom SMTP required to configure
SMTP_SENDER_NAME or RATE_LIMIT_EMAIL_SENT. Missing SMTP_PASS fields."}

The deploy aborts and the branch lands MIGRATIONS_FAILED. Same project, same schema; the only variable is whether the branch got the secrets:

Branchsecrets supplied at createresult
default compute, no secretsnoMIGRATIONS_FAILED (configure step)
nano compute, no secretsnoMIGRATIONS_FAILED (configure step)
default compute, secrets in create bodyyesFUNCTIONS_DEPLOYED — all migrations applied, tables present

Every migration replays clean via psql in all three cases — the SQL is never the problem.

  • Native integration: commit a dotenvx-encrypted supabase/.env.preview and upload the decrypt key once with supabase secrets set --env-file supabase/.env.keys. The branching executor decrypts it for every branch it creates. Only designated secret fields accept the encrypted: syntax - relevantly auth.email.smtp.pass and auth.external.*.secret.

  • Self-driven (Management API): pass a secrets object in the create body - no committed ciphertext, the values come from GitHub Actions secrets:

    Terminal window
    curl -fsSL -X POST -H "Authorization: Bearer $SUPABASE_ACCESS_TOKEN" \
    -H 'Content-Type: application/json' \
    -d "$(jq -n --arg gb "$GIT_BRANCH" --arg r "$RESEND_API_KEY" \
    --arg ci "$GH_OAUTH_CLIENT_ID" --arg cs "$GH_OAUTH_CLIENT_SECRET" \
    '{branch_name:$gb, git_branch:$gb, secrets:{RESEND_API_KEY:$r, GH_OAUTH_CLIENT_ID:$ci, GH_OAUTH_CLIENT_SECRET:$cs}}')" \
    "https://api.supabase.com/v1/projects/$PROJECT_ID/branches"
  • Or dodge it: disable custom SMTP / external OAuth for ephemeral branches (a throwaway preview should not send real email or do real OAuth anyway), or take the bare-branch + db push route which never runs configure.

A branch’s direct connection string points at an IPv6-only host:

PGURL = postgresql://postgres@db.<ref>.supabase.co:5432/postgres
psql: error: connection to server at "db.<ref>.supabase.co" (2a05:d014:...),
port 5432 failed: Network is unreachable

supabase 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):

Terminal window
POOLER=$(grep '^POSTGRES_URL=' creds.env | cut -d= -f2- | tr -d '"')
SESSION="${POOLER/:6543/:5432}" # session pooler, IPv4-reachable
supabase db push --db-url "$SESSION" --include-all --yes

Alternative: 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 --yes

Two footguns that make this fail silently if you skip them:

  • Split-tunnel in default Exclude modetunnel ip add-range 0.0.0.0/0 excludes 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 connect is asynchronous — it returns Success immediately, well before the tunnel is up. Polling curl alone races; gate on warp-cli status reporting Connected first, 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.

The DIY lifecycle, end to end:

  • Auto-created PR branches report ci_micro (1 GB) from the billing API.
  • branches create … --size small reports ci_small (2 GB).
  • A branch created via the CLI/API starts emptydb push then applies every migration:
pastes table present before db push: f # branch starts empty
Applying migration 20260407101812_remote_schema.sql...
... (all migrations) ...
Finished supabase db push.
table public.pastes present: t
table public.slugs present: t

The branch is a genuinely isolated instance with no production data copied. Counting rows over REST against each project confirms it — the branch has its own ref and keys and starts empty, while the parent still has its data:

# branch (pwhpidokrvvgrcuymejh) -> content-range: */0 (0 rows, RLS-visible)
# parent (dewddkcmwrzbpynylyhg) -> content-range: 0-0/1 (its own data)
  • On the closed event, only the cleanup job runs and the branch is deleted, stopping the compute billing.
  • Keep auto-branching, OOM bites on reset → Approach B: add the resize workflow. One API call per PR, every integration feature intact (restart + Micro boot window are the cost).
  • Want Small from boot / OOM bites on the initial migrate / don’t need the integration’s turnkey pipeline → Approach C: own the lifecycle and create at Small. No restart, OOM impossible, but you rebuild checks/comments/seed.
  • One-off → bump Micro→Small by hand in the dashboard.

Neither auto-branching nor config.toml exposes a default compute size, so a larger tier always costs you either a resize step (B) or ownership of branch creation (C).