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.
The upgrade runner (recommended)
AnvilBase ships a data-safe upgrade runner — anvilbase 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 upgradedefaults 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.
anvilbaseupgrade--target<version># or omit --target to preview a self-upgrade
anvilbaseupgrade--target<version>--dry-run# explicit; identical to the default
# 2. Pull the new images (Compose) — see "Standard upgrade" below.
dockercomposepull
# 3. EXECUTE — snapshots every project FIRST, aborts on any snapshot failure,
# then applies migrations idempotently and prints the rollback manifest.
anvilbaseupgrade--target<version>--yes
# 4. Roll the app tier to the new images and verify.
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:
Note the current version: docker compose exec control-plane /app/anvilbase --version
# rolling restart (no dependents) for near-zero downtime
dockercomposeup-d--no-depscontrol-plane
dockercomposeup-d--no-depsauth
dockercomposeup-d--no-depswebhooks
dockercomposeup-d--no-depsconsole
curl-shttps://<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:
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
kubectlexecdeploy/anvilbase-control-plane--\
anvilbaseupgrade--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 cannot —
session_preload_libraries is superuser-only):
Terminal window
dockerexec<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):
Phase A performs three steps and then prints the switch instruction:
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).
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.
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.
(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:
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:
Role regime: grants anvilbaseCREATEROLE (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.
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.
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:
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
curl -s https://<host>/health/services | jq . — all green.
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
# 2. Restore each project from its pre-upgrade snapshot id (from the manifest).
anvilbasebackuprestore<backup_id>--project<project_id># repeat per project
curl-shttps://<host>/health/services|jq.
Terminal window
# Helm
helmrollbackanvilbase# 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 start → docker 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.