Skip to content

Upgrades

AnvilBase uses semantic versioning, and each release version-locks its bundled components (Postgres, Better Auth, realtime, MinIO, Valkey). A version matrix is published per release so you always know what’s inside.

  • Patch (Z) — bug/security fixes; no breaking changes.
  • Minor (Y) — new features, backward compatible.
  • Major (X) — breaking changes (API/schema/component swaps), with a migration guide and a deprecation window.

AnvilBase ships a data-safe upgrade runneranvilbase upgrade (CLI) and POST /api/v1/admin/platform/upgrade (admin API) — that automates the unsafe parts: it takes a pre-upgrade snapshot of every project first, verifies the snapshots succeeded, aborts the whole upgrade if any snapshot fails, and only then applies the platform + per-project migrations idempotently. It records a rollback manifest (prior version + images + per-project snapshot ids) so a rollback is deterministic.

It is version-agnostic: the current version comes from the running binary and the target defaults to the binary you are upgrading to. A downgrade (target < current) is rejected, and re-running once you are already at the target is a no-op.

--dry-run is the default

anvilbase upgrade defaults to a dry-run: it prints exactly what would happen — the target version, the images to pull, the platform + per-project migrations that would apply, and the per-project snapshot plan — and executes nothing (no snapshot, no pull, no migration, no restart). You must pass --yes to actually execute, so a destructive upgrade can never run by accident.

Terminal window
# 1. DRY-RUN first — review the plan. Changes NOTHING.
anvilbase upgrade --target <version> # or omit --target to preview a self-upgrade
anvilbase upgrade --target <version> --dry-run # explicit; identical to the default
# 2. Pull the new images (Compose) — see "Standard upgrade" below.
docker compose pull
# 3. EXECUTE — snapshots every project FIRST, aborts on any snapshot failure,
# then applies migrations idempotently and prints the rollback manifest.
anvilbase upgrade --target <version> --yes
# 4. Roll the app tier to the new images and verify.
docker compose up -d --no-deps control-plane auth webhooks console
curl -s https://<host>/health/services | jq .

The same flow over the API (dry-run by default; ?execute=true to run):

Terminal window
# Dry-run (default) — returns the UpgradePlan, no side effects.
curl -s -X POST -H "Authorization: Bearer $ADMIN_TOKEN" \
"https://<host>/api/v1/admin/platform/upgrade?target=<version>" | jq .
# Execute — snapshot → verify → migrate; returns the plan + rollback manifest.
curl -s -X POST -H "Authorization: Bearer $ADMIN_TOKEN" \
"https://<host>/api/v1/admin/platform/upgrade?target=<version>&execute=true" | jq .

The pre-upgrade snapshots reuse the same per-project pg_dump → object-store backup path as scheduled backups, so vault secrets survive a restore (they are encrypted with a key derived from your CONTROL_PLANE_SECRET, not the pgsodium root key). The one hard requirement: set the same CONTROL_PLANE_SECRET on the upgrade/restore target — it must never change (there is no automated re-key path).

Expect a long-running execute. The execute path snapshots every project inline, one after another, before it migrates — so on deployments with many projects, anvilbase upgrade --yes (and the ?execute=true API call) is a long-running request, not a hung process. Let it run to completion; it returns once all snapshots are taken and migrations are applied. Set a generous client/HTTP timeout for the request.

Pre-upgrade checklist (manual / belt-and-braces)

Even with the runner, a manual checklist is worth keeping for major upgrades:

  1. Note the current version: docker compose exec control-plane /app/anvilbase --version
  2. Check health: curl -s https://<host>/health/services | jq .
  3. Run a dry-run: anvilbase upgrade --target <version> and read the plan.
  4. Extra cold backup (optional, in addition to the runner’s snapshots):
    Terminal window
    docker compose exec postgres pg_dump -U postgres -Fc anvilbase_platform > backup-platform.dump
    docker compose exec postgres psql -U postgres -t -A \
    -c "SELECT datname FROM pg_database WHERE datname LIKE 'platform_%'" \
    | while read db; do docker compose exec postgres pg_dump -U postgres -Fc "$db" > "backup-${db}.dump"; done
  5. Record component versions: docker compose ps --format "{{.Service}} {{.Image}}"
  6. Read the release notes for breaking changes / required project migrations.

Standard upgrade (Compose)

Terminal window
anvilbase upgrade --target <version> # 1. DRY-RUN — review the plan
docker compose pull # 2. pull new images
anvilbase upgrade --target <version> --yes # 3. snapshot-then-migrate (runner)
# rolling restart (no dependents) for near-zero downtime
docker compose up -d --no-deps control-plane
docker compose up -d --no-deps auth
docker compose up -d --no-deps webhooks
docker compose up -d --no-deps console
curl -s https://<host>/health/services | jq . # 4. all green

If you prefer to drive migrations by hand instead of the runner, the manual path still works (docker compose exec control-plane /app/migrate), but it does not take the pre-upgrade snapshots for you — use the runner for the data-safety guarantee.

Major version upgrade (Compose)

Major versions may carry breaking schema changes — stop the app tier first:

Terminal window
docker compose stop control-plane auth webhooks console deno
docker compose run --rm control-plane /app/migrate # platform migrations
docker compose run --rm control-plane /app/migrate-projects # per-project, if release notes require
docker compose up -d
curl -s https://<host>/health/services | jq .

Kubernetes (Helm)

Terminal window
helm repo update
helm diff upgrade anvilbase ./helm/anvilbase -f custom-values.yaml # preview
helm upgrade anvilbase ./helm/anvilbase -f custom-values.yaml # pre-upgrade hook snapshots first
kubectl rollout status deployment/anvilbase-control-plane

Pin anvilbaseVersion in your values; Helm won’t skip a major without an explicit flag.

Pre-upgrade snapshot hook (parity with the runner)

Helm mirrors the runner’s snapshot-before-migrate guarantee with a pre-upgrade hook Job (templates/pre-upgrade-backup.yaml, hook-weight -5) that pg_dumps the platform DB and every platform_* project DB before any new pod rolls out — and fails the upgrade if any dump fails (backoffLimit: 1, non-zero exit aborts the release), so Helm never migrates without a recovery point. It is gated on:

backup:
enabled: true
preUpgradeSnapshot: true # default-on; set false to skip the hook

For the full per-project object-store snapshots + rollback manifest, run the runner from a one-shot Job (or kubectl exec into the control-plane pod) as part of the upgrade:

Terminal window
kubectl exec deploy/anvilbase-control-plane -- \
anvilbase upgrade --target <version> --yes

Component-level isolation

Services upgrade independently behind the control-plane abstraction:

  • The REST engine upgrades as part of the control-plane binary (no separate container).
  • Upgrading Valkey doesn’t require a Postgres restart.
  • The control plane negotiates versions during mixed-version transitions.

Postgres image

AnvilBase ships one Postgres image — built FROM postgres:15-bookworm (vanilla OSS Postgres on Debian, with extensions from PGDG apt + source builds). It has no dependency on a third-party Postgres base (no supautils, pgsodium, or pgjwt), so the platform can never be cut off if such an upstream retires a tag. It ships a curated extension set — pgvector, pgvectorscale, pg_graphql, PGMQ, pg_cron, pg_net, pgcrypto, uuid-ossp — and owns the role-demotion regime itself (a bootstrap supabase_admin superuser does privileged init; anvilbase and postgres run non-super at runtime). It also makes logical platform-DB backups reliable: pg_dump of the platform database works cleanly.

  • Compose: docker compose up builds it from docker/postgres/Dockerfile. No action needed for new deployments.
  • Published images: ghcr.io/paxtone-studio/anvilbase-postgres is built from that Dockerfile; Helm and the docker-compose.prod.yml pull-only overlay use it.

Migrating an EXISTING (pre-1.1, supabase-created) data directory

Releases before 1.1 shipped a supabase-based Postgres image. A fresh deployment on 1.1+ needs nothing — the image inits cleanly. But a data directory originally created by that older image needs a two-phase, in-place migration before it will boot on the current image AND be able to provision new projects.

Back up FIRST. Take a pg_dump of every database and a volume/disk snapshot before migrating. The helper script refuses to run without an explicit --i-have-a-backup flag. (Note: pg_dump of the platform DB segfaults on the old supabase-based image — if a platform dump fails there, your volume snapshot is the platform-DB recovery point; the current image fixes the dump afterwards.)

As of 1.2.0-rc.1 the owned image self-heals the critical boot-time requirements on every start via its entrypoint (pg-autotune.sh) — all idempotent and fail-open, so they apply to any deployment (Compose, Helm, or bare), fresh or migrated:

  • ensures include_dir '/etc/postgresql/conf.d' in postgresql.conf (so conf.d preload libraries / archive / autotune load) and anvilbase.conf sets listen_addresses = '*' — together a bootable, reachable server.
  • prepends the T02 loopback-scram pg_hba rule for the anvilbase owner above the loopback trust, closing the passwordless-dblink-hairpin path on a data dir that predates the T02 fix (the existing-deployment follow-up). The opt-in read-replica entrypoint applies the same heal to its own per-node pg_hba.conf (that file is not replicated); the authenticator GUC below needs no action on a replica — it streams from the primary, and a read-only standby cannot ALTER ROLE.
  • resets the orphaned session_preload_libraries = safeupdate GUC that Supabase’s PostgREST leaves on the authenticator role. The owned image ships no pg-safeupdate, so a Supabase-migrated dir would otherwise leave authenticator unable to log in (FATAL: could not access file "safeupdate"), which breaks the least-privilege exec_sql path. Resetting a SUSET GUC needs a superuser — which the control plane’s anvilbase role is not — so it cannot be a platform migration; the image does it in-container as the cluster superuser (detected automatically: postgres on a fresh owned dir, supabase_admin on a migrated one).

A plain docker compose up (or helm upgrade) pointing at an existing or cutover data dir therefore produces a bootable server with exec_sql working, even if 00-bootstrap.sh was never run. However, new project provisioning will fail until Phase B is also run — see below.

Applying these fixes immediately, without waiting for a restart. The self-heals run at container start. To fix a running instance right now, run — as the cluster superuser (supabase_admin on a Supabase-migrated instance, postgres on a fresh owned one; the anvilbase owner role cannotsession_preload_libraries is superuser-only):

Terminal window
docker exec <pg-container> psql -U <superuser> \
-c "ALTER ROLE authenticator RESET session_preload_libraries;"

The loopback-scram pg_hba lines apply on the next restart (or append the two host all anvilbase 127.0.0.1/32 | ::1/128 scram-sha-256 rules above the loopback-trust rule and SELECT pg_reload_conf();).

Phase A — run on the supabase image (before switching)

Run --phase a (the default) while still booted on the old (supabase-based) image (steps 1 & 3 need its preload libraries + the supabase_admin superuser):

Terminal window
# Preview the plan, change nothing:
scripts/migrate-postgres-to-owned.sh --i-have-a-backup --dry-run
# Execute Phase A (idempotent — safe to re-run):
scripts/migrate-postgres-to-owned.sh --i-have-a-backup

Phase A performs three steps and then prints the switch instruction:

  1. Drop supabase_vault + pgsodium (CASCADE, IF EXISTS) in anvilbase_platform and every platform_* project DB. Safe — JWT secrets live platform-side in projects.jwt_secret; the per-project vault.secrets rows are redundant copies (the vault is app-layer).
  2. Append include_dir '/etc/postgresql/conf.d' to $PGDATA/postgresql.conf, so the image’s anvilbase.conf (shared_preload_libraries='pg_cron,pg_net', cron.database_name='anvilbase_platform') takes effect.
  3. REINDEX DATABASE then ALTER DATABASE … REFRESH COLLATION VERSION per DB — required for correctness: the old supabase base built collations at glibc 2.39 and the bookworm image provides 2.36, and a glibc collation change can corrupt text indexes if they aren’t rebuilt.
  4. (printed instruction) Switch the postgres image and restart — for Compose: docker compose build postgres && docker compose up -d --no-deps postgres.

Phase B — run on the owned image (after switching)

After the stack comes up on the owned image, run Phase B to complete the migration and enable new project provisioning:

Terminal window
scripts/migrate-postgres-to-owned.sh --i-have-a-backup --phase b

Phase B does three things that the owned image’s first-init script (docker/postgres/owned/init-anvilbase.owned.sh) normally handles on a fresh data directory but skips for an existing one:

  1. Role regime: grants anvilbase CREATEROLE (so the control plane can set the anvilbase_webhook_bridge role’s login password at boot), creates the anvilbase_webhook_bridge NOLOGIN role if absent, and prepends the required scram-sha-256 pg_hba rules for it above any loopback-trust rule. Reloads pg_hba so the running server picks them up immediately.
  2. Seed template1: sources the two template1 EOSQL blocks directly from init-anvilbase.owned.sh and pipes them to psql -d template1 as supabase_admin. This installs all project-needed extensions (uuid-ossp, pgcrypto, vector, vectorscale, pg_graphql, pgmq + ownership reassign, pg_net, dblink + lockdown), graphql/pgmq grants, and the privileged public.pgmq_create / public.pgmq_drop_queue SECURITY DEFINER wrappers, plus the anvilbase_rls_default event trigger. Every new project DB is a CREATE DATABASE clone of template1 and inherits all of this — making provisioning’s CREATE EXTENSION IF NOT EXISTS a privilege-free no-op. SQL is sourced directly from the init script (awk extraction of the marked blocks) to stay byte-faithful and avoid drift. Prerequisite roles (anon, authenticated, service_role, postgres, anvilbase) already exist from the supabase-origin init.
  3. Retrofit note: prints a pointer to the inline RETRO comment in init-anvilbase.owned.sh for any project databases created before this migration that need the pgmq wrappers and RLS trigger applied retroactively.

Why Phase B must run after the switch: it needs the owned image’s extension binaries (vector, pgmq, pg_graphql, dblink, etc.) present in $libdir for CREATE EXTENSION to succeed. Running it on the still-supabase image would fail because that image has a different extension set.

Without Phase B, a migrated data dir will boot on the owned image but POST /api/v1/projects fails with:

permission denied to create extension "vector"

because the supautils elevation that allowed non-superuser extension creates on the old image is no longer present, and template1 has not been seeded.

Postgres major version

AnvilBase ships PostgreSQL 15 with pgvector, pgvectorscale, pg_graphql, PGMQ, pg_cron, and pg_net. The control-plane integration test suite runs against this image, so the tested behavior matches production exactly — the major version, the supabase_admin superuser model, and the full extension set.

A move to Postgres 16 is intentionally deferred: swapping the image major is a data migration, not a config change — Postgres refuses to start against a data directory written by a different major version, so an in-place image bump would break every existing pg15 deployment’s volume. A future pg16 release will be gated on a pg_upgrade runner that migrates the data directory as part of the upgrade; until then, both production and test stay on pg15. Do not point an existing deployment at a pg16 image manually.

Enabling the connection pooler (Phase 11)

The Supavisor connection pooler is opt-in and does not affect a standard upgrade — a plain docker compose up skips it (it lives behind the pooler compose profile), so upgrading without enabling it changes nothing. To turn it on after upgrading, run anvilbase start (it backfills the new secrets and passes --profile pooler), or set SUPAVISOR_API_JWT_SECRET + SUPAVISOR_VAULT_ENC_KEY in .env and docker compose --profile pooler up -d. On an existing Postgres volume the dedicated supavisor metadata database is created by the control plane at boot; if the anvilbase role lacks CREATEDB, run once:

Terminal window
docker exec -i anvilbase-postgres psql -U supabase_admin -d postgres \
-c 'CREATE DATABASE supavisor OWNER anvilbase;'

Full details: Connection Pooling.

Per-project upgrades

Projects can be migrated individually rather than all-or-nothing — test on a staging project before applying to production. The control plane manages mixed-version project environments during the transition.

Post-upgrade verification

  1. curl -s https://<host>/health/services | jq . — all green.
  2. Open the console (:39004).
  3. curl .../api/v1/projects returns data.
  4. Test an auth login and a storage upload/download.
  5. Check error rates in logs (Monitoring).

Rollback

If a health check fails after ~10 minutes, or any data-consistency check fails, roll back. The pre-upgrade snapshots are your restore points, and the rollback manifest the runner printed on execute is your deterministic recipe.

The manifest (returned by anvilbase upgrade --yes / the execute API call, and audited as platform.upgrade_executed) records:

  • prior_version and prior_images — the images to pin to roll back.
  • snapshots[] — the per-project pre-upgrade backup_id (the restore point).

Compose / runner rollback:

Terminal window
# 1. Pin the PRIOR images (from the rollback manifest's prior_version) and
# bring the prior app tier back up.
docker compose up -d --no-deps control-plane auth webhooks console
# 2. Restore each project from its pre-upgrade snapshot id (from the manifest).
anvilbase backup restore <backup_id> --project <project_id> # repeat per project
curl -s https://<host>/health/services | jq .
Terminal window
# Helm
helm rollback anvilbase # restores the chart to the prior revision

You can also restore the snapshots directly (Backups & Restore → Restore). Because the runner takes a verified snapshot of every project before it migrates anything — and aborts the whole upgrade if any snapshot fails — there is always a recovery point for the prior version.

Blue-green / zero-downtime

The runner’s snapshot+migrate step is decoupled from the image roll, so the app tier can roll after migrations are applied — a rolling docker compose up -d --no-deps <service> (or kubectl rollout) gives near-zero downtime for backward-compatible (patch/minor) upgrades. For major upgrades that carry breaking schema changes, run a blue-green model where resources allow: stand up the new app tier against the migrated database in parallel, cut traffic over at the proxy, and keep the prior tier (pinned to prior_images) warm for a 24-hour rollback window. See your release’s upgrade guide for major-specific steps.

Troubleshooting

  • Control plane won’t startdocker compose logs control-plane --tail 50; usually unapplied migrations, a missing env var, or Postgres not ready.
  • Migration failed → inspect _migrations / _anvilbase_migrations; apply the specific file manually if needed.
  • A service is unhealthy → check its logs and inter-service connectivity.

Next: Disaster Recovery.