Migrate from Supabase
Because AnvilBase is data-plane-compatible with Supabase, migrating is mostly moving your schema and data, then repointing the client. This guide walks the full path in the order that actually works on the shipped stack.
Overview
| Layer | Migration approach |
|---|---|
| Schema | filtered pg_dump --schema=public → AnvilBase migration |
| Auth users | export auth.users → lossless bulk import (preserves UUIDs + bcrypt passwords) |
| Data | filtered pg_dump --data-only --schema=public → restore into the project DB after users |
| RLS policies | come across with the schema dump; re-test in the sandbox |
| Storage objects | create each logical bucket, then mc mirror into the project’s physical bucket (operator) |
| Client code | change the URL + key; auth calls work unchanged (see SDK Compatibility) |
1. Create the target project
anvilbase projects create "My App"# capture id, anon_key, service_role_key, jwt_secret2. Move the schema
Supabase dumps are polluted with platform schemas (auth, storage,
extensions, realtime, vault, graphql_public, pgsodium,
supabase_functions) and superuser-only objects (CREATE EVENT TRIGGER,
COMMENT ON EXTENSION, CREATE EXTENSION pgsodium/pgjwt). AnvilBase applies
each migration file as the non-superuser anvilbase role in a single
transaction — the first unsupported statement rolls back the whole file. So
dump only your own schemas and strip the leftovers:
# Dump only the public schema (add --schema=<name> for each extra schema you own)pg_dump --schema-only --no-owner --no-privileges \ --schema=public \ "postgresql://postgres:…@db.<ref>.supabase.co:5432/postgres" \ > 001_schema.sql
# Strip superuser-only / platform statements the owned image does not accept:# - event triggers (superuser) - COMMENT ON EXTENSION# - CREATE EXTENSION for extensions AnvilBase does not bundle# (check docs/database/extensions.md for the available set)grep -vE '^(CREATE EVENT TRIGGER|COMMENT ON EXTENSION|CREATE EXTENSION (pgsodium|pgjwt|supabase_vault))' \ 001_schema.sql > 001_schema.clean.sql
# place the cleaned file in ./migrations/ and applyanvilbase db push --project <id> --path ./migrations --applyWhy the filter matters. Without
--schema=publicthe dump’sCREATE SCHEMA authcollides with the mirror AnvilBase already provisions, and the file aborts on statement one — leaving your schema unapplied with aFailed/Skippedresult list. Filtering to the schemas you own avoids this entirely.
Extensions your app uses must be in AnvilBase’s bundled set — see
Extensions. Enable them with CREATE EXTENSION in
your schema file (they run fine as the anvilbase role for the bundled set).
RLS policies and functions in the public schema come across with the dump.
After applying, re-test every policy in the
RLS sandbox
— the auth.uid() helpers are provisioned natively (see §6).
3. Migrate auth users (do this before loading table data)
Import before table data so that every REFERENCES auth.users(id) foreign
key and every owner-scoped RLS policy (user_id = auth.uid()) in your data lines
up. AnvilBase’s import is lossless: it preserves the original Supabase UUID,
created_at, email_confirmed_at, and raw_user_meta_data, and imports the
verbatim bcrypt password hash — so migrated users sign in with their
existing password, no reset required.
First, export the users from Supabase (as JSON), mapping encrypted_password →
password_hash:
SELECT id, email, encrypted_password AS password_hash, email_confirmed_at, created_at, raw_user_meta_dataFROM auth.users;Then bulk-import them (up to 1000 records per call) via the management API:
curl -X POST http://localhost:39001/api/v1/projects/<id>/users/import \ -H "Authorization: Bearer $ANVILBASE_TOKEN" -H "Content-Type: application/json" \ -d '{"users":[ {"id":"<supabase-uuid>","email":"alice@example.com", "password_hash":"$2a$10$…","email_confirmed_at":"2024-01-01T00:00:00Z", "created_at":"2023-01-01T00:00:00Z","raw_user_meta_data":{"role":"admin"}} ]}'# → 200 {"imported":1,"failed":0,# "results":[{"email":"alice@example.com","id":"<supabase-uuid>","status":"created"}]}The response reports each record’s outcome (created / skipped /
error); a re-import of an existing email is skipped (no duplicate), so the
loop is safely re-runnable, and a record with a non-UUID id returns error
while the rest still import.
- Password hashes carry over. The verbatim bcrypt hash (
$2a$…) is stored as-is; AnvilBase’s password-verify bridge accepts bcrypt at sign-in, so no password reset is needed. bcrypt’s 72-byte truncation is inherited from Supabase (identical behavior). - Original UUIDs are preserved. Because the imported
idequals the Supabaseauth.users.id, importing users now — before you load your table data (Section 4) — keepsuser_id uuid REFERENCES auth.users(id)FKs and owner-scoped RLS (user_id = auth.uid()) matching. - User metadata & confirmation are preserved.
raw_user_meta_dataandemail_confirmed_atround-trip intoauth.users, so your existinghandle_new_user()triggers readingnew.raw_user_meta_data->>'…'work unchanged (see Auth Overview → User metadata). - Reconfigure OAuth providers in AnvilBase
(OAuth Providers) with the same client apps.
OAuth-only users (no password) import with an empty
password_hash; they re-link on first social login.
CLI shortcut (if available). Once
anvilbase auth users import --project <id> --file users.jsonships (tracked as a follow-up to the import endpoint), it wraps this loop. Until then, use thecurlrecipe above.
4. Move the data
Load table data after the users exist (§3) so foreign keys resolve. Filter to your own schema and load atomically so a bad row aborts cleanly instead of half-loading:
pg_dump --data-only --no-owner --schema=public \ "postgresql://postgres:…@db.<ref>.supabase.co:5432/postgres" \ > data.sql
# The physical DB name strips the hyphens from the project UUID:# project 123e4567-e89b-12d3-a456-426614174000# → database platform_123e4567e89b12d3a456426614174000psql "postgres://anvilbase:…@localhost:39432/platform_<project_id_without_hyphens>" \ --single-transaction -v ON_ERROR_STOP=1 -f data.sqlThe database name has no hyphens.
<project_id_without_hyphens>in this connection string is your project’s UUID with the hyphens stripped — 32 hex characters. A project3f2b17c4-1d2e-4a9b-8c7d-0e1f2a3b4c5dmaps to databaseplatform_3f2b17c41d2e4a9b8c7d0e1f2a3b4c5d, notplatform_3f2b17c4-1d2e-….
--single-transaction -v ON_ERROR_STOP=1 guarantees all-or-nothing: any error
rolls the whole load back rather than leaving silently missing rows. For very
large datasets, dump and load per table in dependency order (parents first).
5. Move storage objects (operator-level)
AnvilBase has no tenant-facing S3/presigned surface — object access goes through the header-authenticated storage facade. Bulk object migration is therefore an operator action on the host, using the platform MinIO endpoint and root credentials.
Physically, each project has one S3 bucket, bucket-<project_id_without_hyphens>,
and your logical buckets are key prefixes inside it (<logical>/<path>).
Mirroring to a top-level bucket named after your logical bucket lands objects
where the storage API never looks.
Step 1 — create each logical bucket first (so the public flag, size/MIME
limits, and the reserved user- prefix semantics exist). Via the SDK or curl:
curl -X POST http://localhost:39001/v1/<project_id>/storage/v1/bucket \ -H "apikey: $ANVILBASE_SERVICE_ROLE_KEY" -H "Content-Type: application/json" \ -d '{"id":"avatars","public":true}'# carry over public / file_size_limit / allowed_mime_types from Supabase's# storage.buckets row. NOTE: an owner-scoped `user-…` bucket cannot be public.Step 2 — mirror the objects into the project’s physical bucket prefix with the platform root credentials:
# alias the bundled MinIO (host port 39900) with the PLATFORM ROOT credsmc alias set anvilbase http://<host>:39900 "$MINIO_ROOT_USER" "$MINIO_ROOT_PASSWORD"
# mirror Supabase bucket <bucket> into bucket-<id>/<logical>/ (mind the trailing slash)mc mirror supabase/<bucket> anvilbase/bucket-<project_id_without_hyphens>/<bucket>/(Any S3 sync tool works; both ends are S3-compatible.)
Owner metadata does not carry over. Supabase’s
storage.objects.owneris not migrated. Owner-scoped access in AnvilBase relies on the<user_id>/…key prefix convention (user-buckets), which survives the mirror only if your app already keyed objects by user id.
CLI shortcut (if available). Once
anvilbase storage import --project <id> --from-s3 <alias/bucket>ships, it hides the physical layout. Until then use themcrecipe above.
6. RLS helpers work natively
Supabase policies using auth.uid(), auth.role(), auth.email(), or
auth.jwt() need no translation: every project DB is provisioned with the
same helper functions, reading the request.jwt.claims context the REST engine
sets per request. User tokens carry the GoTrue-compatible claim set (email,
aud, app_metadata, user_metadata, session_id — see
Auth Overview → Token claims), so policies
like these evaluate unchanged:
USING (user_id = auth.uid())USING (recipient = auth.email())USING (auth.jwt() -> 'app_metadata' ->> 'provider' = 'email')current_setting('app.current_user_id') is also set per request if your
policies use that form. Re-test every policy after importing.
7. Repoint the client
Change the client URL and key (SDK Compatibility):
const supabase = createClient('https://<ref>.supabase.co', SUPABASE_ANON_KEY)const supabase = createClient('https://anvilbase.example.com/v1/<project_id>', ANVILBASE_ANON_KEY)Data, storage, realtime, and auth calls work unchanged — db.auth.signUp(),
db.auth.signInWithPassword(), db.auth.signInWithOtp() all run against the
GoTrue-compatible facade. No adapter shim or call-site rewrite is needed.
Auth notes when migrating. The service-role
supabase.auth.admin.*user-management surface is served too (it requires the project service_role key). One edge to know: a user created via OTP / magic-link sign-in does not capture signup-timeoptions.datametadata at first verify — password and anonymous signup persist it immediately; for OTP/magic-link, callupdateUser({ data })after the first sign-in.
8. Edge functions
Supabase Edge Functions are Deno too. Port the handler and deploy:
anvilbase functions deploy <name> --project <id> --path ./functionsRead project context (DB URL, keys) from the injected headers rather than Supabase’s
Deno.env secrets — see
Edge Functions.
9. Cut over
AnvilBase has no inbound live-replication primitive — it does not ship the
subscriber side of logical replication (CREATE SUBSCRIPTION; see
Logical Replication → Subscriptions),
and the stack runs no superuser. Cutover is therefore a write-freeze window,
not a zero-downtime live sync:
- Freeze writes on Supabase (maintenance window).
- Final incremental data sync (re-dump changed tables, or rely on an app-level dual-write you set up in advance).
- Verify: health, a few reads/writes, an auth login, a storage fetch.
- Flip the client config / DNS.
- Keep the Supabase project until you’re confident, then decommission.
Checklist
- Schema dump filtered to owned schemas, pruned, applied; functions present.
- Users imported first, UUIDs preserved; OAuth providers reconfigured.
- Data loaded (single transaction); row counts match; FKs to auth.users valid.
- RLS helpers native; every policy re-tested as a migrated user.
- Storage: logical buckets created, objects mirrored to
bucket-<id>/<logical>/. - Client URL + key updated; auth calls verified working unchanged.
- Edge functions ported.
- Cutover done with a write-freeze window; end-to-end smoke test passed.
Next: Reference → Management API.