Supabase is really two things bolted together: a PostgreSQL database with an auto-generated REST API, and a suite of hosted services around it, Auth, Storage, Realtime and Edge Functions. WattleDB is the first half, run entirely on Australian-owned infrastructure. So a migration splits cleanly into "the database, which moves" and "the services, which you handle one at a time". Let's be upfront about the shape before touching a command.
What maps, and what doesn't
- Database, RLS, functions, extensions → move in a
pg_dump, with the preparation in step 4 (roles,auth.uid(), grants). - PostgREST REST API → WattleDB has the same thing; re-point the URL and token. Clean.
- Storage → WattleDB gives an S3-compatible bucket. Files copy over; the client API changes.
- Auth (GoTrue) → no generally available hosted equivalent yet (WattleDB's hosted Auth is in early access, by invitation). You keep the users, bring your own auth, and mint JWTs WattleDB verifies.
- Realtime → on the WattleDB roadmap, not live today. Plan around it.
- Edge Functions → no equivalent. Host that code elsewhere.
If your project is mostly database + REST + RLS, this is a short migration. If it leans hard on Auth, Realtime or Edge Functions, read those sections carefully before you commit, because that is where the real work is.
1. Inventory what you use
Open your Supabase project and note which of the six pieces you depend on. Most apps use the database and RLS heavily, Auth moderately, and only some use Storage, Realtime or Edge Functions. This list is your migration scope. In the SQL editor, capture the essentials:
-- Postgres version and installed extensions
select version();
select extname from pg_extension order by 1;
-- Your tables and their RLS status
select relname, relrowsecurity
from pg_class c join pg_namespace n on n.oid = c.relnamespace
where n.nspname = 'public' and c.relkind = 'r' order by 1;
-- How many users are in Auth
select count(*) from auth.users;
2. Provision WattleDB and get the direct URL
Create a database in the WattleDB console. From Database → Connect, copy the DIRECT_URL (session mode) for the migration, and the CA certificate if you want verify-full TLS. Use the direct URL, not the pooled one, for the restore: pg_restore holds long transactions and sets session state that a transaction pooler will break.
export DIRECT_URL="postgresql://user:pass@<db-id>.db.wattledb.com.au:<port>/db?sslmode=require"
3. Dump the Supabase database
Get your Supabase connection string from Project Settings → Database (use the direct connection, or the session pooler on port 5432). Dump the schema you care about, normally public. The custom compressed format lets pg_restore load in parallel and skip pieces if needed.
export SUPABASE_URL="postgresql://postgres:[password]@db.[ref].supabase.co:5432/postgres"
# Your app schema: tables, data, functions, RLS policies
pg_dump "$SUPABASE_URL" -Fc --no-owner --no-privileges \
--schema=public -f supabase.dump
On the auth and storage schemas: these belong to Supabase's own services. You do not need Supabase's copies running on WattleDB, but you may want the data, your users, out of auth.users. Dump those rows separately so you can load them into a users table you control:
# Just your users' rows, to import into your own auth later
pg_dump "$SUPABASE_URL" --data-only --no-owner \
--table=auth.users -f supabase_users.dump
4. Restore into WattleDB
Turn on the REST API for the database first, in the console under Database → REST API. That creates the anon and authenticated roles. Supabase policies name them (to authenticated), and a policy that names a role which does not exist fails to restore and is missing afterwards. Then, in this order: recreate the extensions the dump references, in the extensions schema where Supabase keeps them (the dump writes defaults such as extensions.uuid_generate_v4(), and without that schema the tables that use them are not created); if your policies call auth.uid() or auth.jwt(), or your tables reference auth.users, export your users and create those functions and the loaded users table (the SQL is in Moving off Supabase Auth, steps 2 and 3; a foreign key to auth.users fails to restore if the table is empty); change the function default below; and restore. The order matters: auth.uid() must exist before the default change, or the API roles cannot execute it and every policy that calls it fails with permission denied for function.
# Extensions your source used (from step 1), where Supabase keeps them
psql "$DIRECT_URL" -c 'create schema if not exists extensions;'
psql "$DIRECT_URL" -c 'grant usage on schema extensions to anon, authenticated;'
psql "$DIRECT_URL" -c 'create extension if not exists "uuid-ossp" with schema extensions;'
psql "$DIRECT_URL" -c 'create extension if not exists pgcrypto with schema extensions;'
# auth.uid(), auth.jwt() and auth.users: run steps 2 and 3 of the Supabase Auth guide now (export, then create and load)
# Before the restore: functions created from now on are not executable by everyone
psql "$DIRECT_URL" -c "alter default privileges revoke execute on functions from public;"
# Restore schema + data + RLS, in parallel
pg_restore --no-owner --no-privileges --clean --if-exists \
-j 4 -d "$DIRECT_URL" supabase.dump
# After: lock any function that is still open, then give signed-in users table access back
psql "$DIRECT_URL" -c "revoke execute on all functions in schema public from public, anon, authenticated;"
psql "$DIRECT_URL" -c "grant select, insert, update, delete on all tables in schema public to authenticated;"
psql "$DIRECT_URL" -c "grant usage, select on all sequences in schema public to authenticated;"
--no-owner --no-privileges drops Supabase's role grants, which would not apply on WattleDB, but it also drops every revoke you made there. Postgres lets anyone execute a function by default, and with the API on, every function in public is callable at POST /rpc/<name>, with or without a token, so a security definer function you had locked down on Supabase would answer anyone. The default-privilege line stops restored functions being created open, and the revoke catches the rest. Grant back each function your app calls, for example grant execute on function public.my_function(uuid) to authenticated;, including any your policies use. The table grant restores Supabase's model: signed-in users reach every table and RLS decides which rows, which is why the next step checks that every table has it. If --clean reports cannot drop schema public and schema "public" already exists, both are harmless. Read every other error: pg_restore carries on past a table it cannot create, so a missing schema or function means missing tables and policies, and the checks in step 5 would then pass on an incomplete database. Compare table counts with the source before you go on.
5. Confirm your RLS came across
Row-Level Security policies are part of the schema, so with the roles and functions from step 4 in place they restore with everything else. Verify it landed, and that every table and view the API will serve has RLS on:
psql "$DIRECT_URL" -c "select tablename, policyname, cmd
from pg_policies where schemaname='public' order by 1,2;"
# Anything listed here has no RLS: every caller the API lets in sees all of it
psql "$DIRECT_URL" -c "select relname, relkind from pg_class
where relnamespace = 'public'::regnamespace
and relkind in ('r','p','v','m','f') and not relrowsecurity;"
Policies that call auth.uid() or auth.jwt() depend on the token your app sends, not on Supabase. They keep working as long as the JWT WattleDB receives carries the same sub (user id) and role claims. That's the bridge the next section builds.
6. Re-point the REST API
WattleDB generates the same PostgREST API Supabase does. You turned it on in step 4; on the same Database → REST API tab, generate an authenticated bearer token. Every table becomes an endpoint, exactly as on Supabase.
In code, the query builder inside supabase-js is postgrest-js, and it works against WattleDB directly:
import { PostgrestClient } from '@supabase/postgrest-js'
const db = new PostgrestClient('https://<db-id>.api.wattledb.com.au', {
headers: { Authorization: `Bearer ${process.env.WATTLEDB_TOKEN}` },
})
// identical query surface to supabase.from(...)
const { data } = await db.from('projects').select('*').eq('owner', userId)
Use postgrest-js directly, as above, rather than the full supabase-js client: the full client sends its .from() calls to a /rest/v1/ path, which a WattleDB REST URL does not serve today. Its .auth, .storage and .realtime calls change too, covered below.
7. Auth: keep your users, bring your own issuer
This is the honest gap. Supabase Auth is GoTrue, a hosted service that signs up users, handles passwords and OAuth, and issues JWTs. WattleDB does not run GoTrue. What WattleDB does is verify JWTs and enforce RLS, which is the half your policies depend on. So the plan is:
- Keep the users. Load the rows you dumped in step 3 into a users table you own.
- Pick an issuer. Self-host GoTrue, use an auth provider, or run your own sign-in. Whatever you choose issues the JWT.
- Sign with WattleDB's secret. Set your issuer's signing secret to the database's JWT secret (console → REST API), so PostgREST trusts the token. Put the user id in the
subclaim and setroletoauthenticated.
// your issuer mints a token WattleDB's PostgREST will accept
import jwt from 'jsonwebtoken'
const token = jwt.sign(
{ sub: user.id, role: 'authenticated', email: user.email },
process.env.WATTLEDB_JWT_SECRET, { expiresIn: '1h' })
Your existing auth.uid() policies keep working unchanged, because auth.uid() reads the token's sub. You're replacing the issuer, not the authorization model. The full walk-through, including refusing banned and unconfirmed users the way GoTrue did, is in Moving off Supabase Auth.
8. Storage: copy the files, change the client
Supabase Storage and WattleDB Storage are both S3-compatible underneath, but the client APIs differ, Supabase's storage-js versus a standard S3 SDK. Two steps: move the bytes, then change the call sites. WattleDB gives you the endpoint, bucket and keys under Database → Storage.
# copy files across with rclone (configure both as S3 remotes)
rclone sync supabase:your-bucket wattledb:your-bucket --progress
// upload, WattleDB side, with the AWS S3 SDK
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3'
const s3 = new S3Client({
endpoint: process.env.WATTLEDB_S3_ENDPOINT, region: 'ap-southeast-2',
forcePathStyle: true,
credentials: { accessKeyId: process.env.WATTLEDB_S3_KEY,
secretAccessKey: process.env.WATTLEDB_S3_SECRET },
})
await s3.send(new PutObjectCommand({ Bucket: 'your-bucket', Key: key, Body: body }))
9. Realtime and Edge Functions: plan around them
No dressing this up. Realtime (Postgres change subscriptions over websockets) is on the WattleDB roadmap, not live today, if your app depends on it, keep it running on your current stack during the transition, or fall back to short polling against the REST API. Edge Functions have no WattleDB equivalent; that code is plain server code, so host it wherever you like. To keep the Australian ownership you're migrating for, put it on Australian infrastructure rather than a US edge platform.
10. Verify, then cut over
Before you trust the copy: check row counts and sequences match, run a real authenticated query through the REST API with a minted token, and confirm an RLS policy blocks a row it should.
# counts match between source and target for each table
psql "$SUPABASE_URL" -c "select count(*) from projects;"
psql "$DIRECT_URL" -c "select count(*) from projects;"
# sequences are ahead of max(id), so new inserts don't collide
psql "$DIRECT_URL" -c "select last_value from projects_id_seq;"
For a small database, take a short maintenance window: pause writes, run a final dump/restore of anything that changed, flip your environment variables, done, minutes of downtime. For a large one, use PostgreSQL logical replication to stream changes until the two are in sync, then switch. Either way the cut-over is only changing DATABASE_URL, the REST URL and token, the storage credentials, and your auth issuer.
Migrating to WattleDB specifically
The reason to do this at all is ownership. Supabase's region setting controls geography, not who's behind it: the company is American and runs on US cloud, so the business that owns and operates your data is overseas regardless of which region you picked. WattleDB is Australian owned, operated and hosted, Sydney primary, cross-state backups in Melbourne, and no one outside WattleDB and our Australian hosting and data-centre providers has access to your database or its backups, so your data lives with one Australian company under Australian law. For anything governed by the Privacy Act or an Australian data-residency requirement, that is the whole point.
Migrating from Supabase
Is migrating off Supabase hard?
The database itself is the easy part: Supabase is standard PostgreSQL, so pg_dump from Supabase and pg_restore into WattleDB carry your tables, data, functions and RLS policies, provided you turn on the REST API and create auth.uid() first and re-apply grants afterwards (step 4). The work is the parts that are not the database, Auth, Storage, Realtime and Edge Functions, and this guide covers each honestly.
Do my Row-Level Security policies come across?
Yes, if you prepare for them. RLS policies are in the dump, but a policy that names the authenticated role or calls auth.uid() only restores if that role and function already exist, so turn on the REST API and create the auth functions before the restore. They then keep working as long as the token your app sends carries the same sub and role claims. The restore also drops grants, so re-grant table access and lock functions down afterwards (step 4).
What happens to Supabase Auth?
Supabase Auth is GoTrue, a hosted service WattleDB does not run. You keep your users (dump auth.users), then run your own auth, self-host GoTrue, or use a provider, and mint JWTs signed with your database's JWT secret so PostgREST and your RLS verify them. auth.uid() policies keep working as long as the token's sub is the user id.
Does supabase-js still work?
Not as a whole client. Its query builder, postgrest-js, works when you point it at your WattleDB REST URL with a bearer token. The full supabase-js client sends queries to a /rest/v1/ path that WattleDB does not serve, and its auth, storage and realtime modules talk to Supabase-specific services, so those call sites change.
Why move off Supabase to WattleDB?
Australian ownership. Supabase runs on US cloud under a US parent, so the business that owns and operates your data is overseas wherever the region is. WattleDB is Australian owned, operated and hosted, and no one outside WattleDB and our Australian hosting and data-centre providers has access to your database or its backups, so one Australian company under Australian law stands behind your data end to end. If the Privacy Act and Australian residency matter, that is the difference.