Skip to content

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

LayerMigration approach
Schemafiltered pg_dump --schema=public → AnvilBase migration
Auth usersexport auth.userslossless bulk import (preserves UUIDs + bcrypt passwords)
Datafiltered pg_dump --data-only --schema=public → restore into the project DB after users
RLS policiescome across with the schema dump; re-test in the sandbox
Storage objectscreate each logical bucket, then mc mirror into the project’s physical bucket (operator)
Client codechange the URL + key; auth calls work unchanged (see SDK Compatibility)

1. Create the target project

Terminal window
anvilbase projects create "My App"
# capture id, anon_key, service_role_key, jwt_secret

2. 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:

Terminal window
# 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 apply
anvilbase db push --project <id> --path ./migrations --apply

Why the filter matters. Without --schema=public the dump’s CREATE SCHEMA auth collides with the mirror AnvilBase already provisions, and the file aborts on statement one — leaving your schema unapplied with a Failed/Skipped result 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_passwordpassword_hash:

SELECT id, email, encrypted_password AS password_hash,
email_confirmed_at, created_at, raw_user_meta_data
FROM auth.users;

Then bulk-import them (up to 1000 records per call) via the management API:

Terminal window
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 id equals the Supabase auth.users.id, importing users now — before you load your table data (Section 4) — keeps user_id uuid REFERENCES auth.users(id) FKs and owner-scoped RLS (user_id = auth.uid()) matching.
  • User metadata & confirmation are preserved. raw_user_meta_data and email_confirmed_at round-trip into auth.users, so your existing handle_new_user() triggers reading new.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.json ships (tracked as a follow-up to the import endpoint), it wraps this loop. Until then, use the curl recipe 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:

Terminal window
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_123e4567e89b12d3a456426614174000
psql "postgres://anvilbase:…@localhost:39432/platform_<project_id_without_hyphens>" \
--single-transaction -v ON_ERROR_STOP=1 -f data.sql

The 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 project 3f2b17c4-1d2e-4a9b-8c7d-0e1f2a3b4c5d maps to database platform_3f2b17c41d2e4a9b8c7d0e1f2a3b4c5d, not platform_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:

Terminal window
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:

Terminal window
# alias the bundled MinIO (host port 39900) with the PLATFORM ROOT creds
mc 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.owner is 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 the mc recipe 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-time options.data metadata at first verify — password and anonymous signup persist it immediately; for OTP/magic-link, call updateUser({ data }) after the first sign-in.

8. Edge functions

Supabase Edge Functions are Deno too. Port the handler and deploy:

Terminal window
anvilbase functions deploy <name> --project <id> --path ./functions

Read 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:

  1. Freeze writes on Supabase (maintenance window).
  2. Final incremental data sync (re-dump changed tables, or rely on an app-level dual-write you set up in advance).
  3. Verify: health, a few reads/writes, an auth login, a storage fetch.
  4. Flip the client config / DNS.
  5. 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.