Move from Supabase to Naijacloud
Your Supabase database, its row level security, your Storage files and your users running on Naijacloud in Port Harcourt or Europe, with each Supabase-only feature replaced by code you own, and the Supabase project kept as a rollback.
What things are called here
Before you move: what Supabase has that we don't
- Managed auth, with social logins, magic links and its own emails.
- Realtime subscriptions to database changes.
- An auto-generated REST and GraphQL API over your tables.
- The postgis, pgvector, pg_cron and pg_net extensions.
- A connection pooler in front of the database.
- A public Postgres endpoint in af-west (Port Harcourt). eu-west has one.
- Edge Functions running near the visitor. Code runs in your service’s region.
- Point-in-time recovery and read replicas for Postgres.
If your app depends on one of these, stay on Supabase for now.
The cutover, in order
The order is what keeps the site up. Each step below matches one point on this line.
- 01Take inventoryWhich Supabase features the app calls.
- 02Create the databaseCheck every extension you use is here.
- 03Dump from SupabaseThe public schema, plus your users.
- 04Restore hereFrom a one-off job, or your machine in eu-west.
- 05Copy the bucketsaws s3 sync, then rewrite stored URLs.
- 06Replace supabase-jsQueries, policies and functions on your server.
- 07Sign users inMove Auth, or keep it.
- 08Switch and keep Supabase 7 daysFinal copy, then pause, not delete.
Supabase is more than Postgres, so this move is bigger than a dump and restore. The database, its policies, your files and your users' passwords all come across. Auth, Realtime and the auto-generated API don’t: for each one this guide says what to run instead. The sample app it was tested with, a Next.js notes app before and after the move, is at github.com/naijacloud/guide-supabase-migration, along with every script below.
Take inventory
Search your code for each Supabase feature it calls. Each one you find is a piece of work in the steps below; each one you don’t find is a step you skip.
.from(and.rpc(are queries the browser sends through Supabase’s REST API. They move to your server in step 6..storage.is files in Supabase Storage, step 5.functions.invokecalls an Edge Function, step 6.supabase.authis Supabase Auth, step 7..channel(is Realtime. There’s no managed equivalent here; see the note in step 6.
Then list the extensions the database uses. In the Supabase SQL editor:
Create the database and check extensions
In the dashboard, create a project in af-west · Port Harcourt (or eu-west · Europe), then add a Database → Postgres. Pick the size by RAM and disk from pricing; the Free size is fine to rehearse with. The database runs PostgreSQL 18.6, and its user owns the database, so it can create extensions.
Open the database’s Studio and run the query there, or use the CLI, and check that every extension from step 1 is available:
On 20 October 2026 it returned citext, pg_trgm, pgcrypto, unaccent and uuid-ossp: the PostgreSQL contrib extensions are all there; postgis, vector, pg_cron and pg_net are not. Supabase’s own extensions (pgsodium, supabase_vault, pg_graphql, pgjwt) aren’t either, and you don’t need them once the app stops calling Supabase.
Dump from Supabase
Use PostgreSQL 18 client tools (pg_dump, pg_restore and psql 18). In Supabase, open Connect and copy the Session pooler string (port 5432), or the direct connection if your network has IPv6. Not the transaction pooler on port 6543: pg_dump needs a session.
Dump only your own schema. Supabase’s schemas (auth, storage, realtime, supabase_functions, graphql, vault, extensions) belong to services that aren’t moving. Your users are the exception: copy the columns you need from auth.users to a CSV.
If your tables live in more schemas than public, add a --schema= for each.
Restored as it is, this dump fails. Your tables point at Supabase pieces that aren’t in it:
prepare.sql in the folder creates them first: the extensions in a schema named extensions, as Supabase has them, with that schema on the search path; the anon, authenticated and service_role roles your policies name; and a small auth schema with a users table for your foreign keys and an auth.uid() function for your policies and column defaults. Edit its extension list to match step 1.
Restore it into Naijacloud
restore.sh runs prepare.sql, loads users.csv, restores app.dump, rewrites the stored file URLs (step 5) and prints the checks in verify.sql. It’s safe to run twice, which is how the final copy in step 8 works.
In af-west, a database has no public endpoint, so run the restore inside your environment, as a one-off cron job. Upload the folder to a private bucket (create it now, as in step 5), with your Naijacloud access key in AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY and AWS_REGION=af-west. Sign a link to it, and create the job:
A job needs a schedule; this one fires on 1 January, and you delete the job long before then. Open it and click Run now. The run’s output ends with the checks:
In eu-west, you can run it from your machine instead: open the database’s Settings, turn on Public access, Add my IP, Save, and copy the Public · TLS URL.
Compare the counts with Supabase: psql "$SUPABASE_DB_URL" -f verify.sql. Then delete move.tgz from the bucket. It holds your users' password hashes.
Copy the storage buckets
Create a bucket for each Supabase bucket: Object storage → New bucket, in the same region as your app. Make it Public read only if the Supabase bucket was public. Bucket names are unique across Naijacloud, so prefix them (acme-avatars). Create an access key under Object storage → Access keys.
In Supabase, open Project Settings → Storage → S3 Connection for the endpoint and region, and create an S3 access key there. Then copy each bucket through your machine:
Each file keeps its path. aws s3 sync sets the content type from the file extension on the way up, so a file whose name has no extension arrives as binary/octet-stream.
A public object’s address changes from https://<ref>.supabase.co/storage/v1/object/public/<bucket>/<path> to https://af.storage.naijacloud.app/<your-bucket>/<path>. If your tables store full URLs, urls.sql rewrites them during the restore with the two prefixes you set; edit its update for your own tables. Paths stored without the host need no change.
Replace supabase-js on your server
Without PostgREST, the browser can’t query the database, so each .from(), .rpc() and .storage call becomes a route on your server. Connect with the database’s Internal URL and one pool per process. There is no pooler in front of the database, so leave out pgbouncer=true and Prisma’s directUrl: one DATABASE_URL is all it needs. A database accepts 50 connections (20 on Free), across every copy of every service that uses it.
Your app connects as the database’s owner, and the owner skips row level security. To keep the Supabase policies doing their job, run each request as the user, the way PostgREST did: switch to the authenticated role and set the id auth.uid() reads, for one transaction. restore.sh applies the grants this needs (rls-grants.sql).
supabase.from('notes').select('*') becomes asUser(userId, (db) => db.query('select * from notes')), and still returns only that user’s notes. Inserts that rely on a default auth.uid() column keep working. If you’d rather not keep the policies, query as the owner and add where user_id = $1 to every statement yourself.
A private file is a signed link your server makes after a query the policies allow:
Edge Functions become routes. Deno.serve(handler) becomes an exported GET or POST; the supabase-js client built from the caller’s token becomes asUser() with your session’s user. A function that ran on a schedule, or a pg_cron job, becomes a cron job that runs a script from your repo. A Deno function can also stay Deno: deploy it as its own web service listening on PORT.
Sign users in
Moving Auth. users.csv brought each user’s id, email and encrypted_password. Supabase stores bcrypt hashes ($2a$10$…), so any bcrypt library checks them, and every user keeps their password. Sign in by comparing against auth.users, then set your own session cookie:
An auth library (Auth.js, Better Auth, Lucia) can take over from there. Point its user table at auth.users or copy the rows into its own, keeping the id so your foreign keys still match. Users who signed in with Google or GitHub have no password: they sign in with the same provider in the new library and are matched by email. Magic links, password resets and verification emails are yours to send now, through Naijamail.
Keeping Supabase Auth. If you’d rather not move it yet, leave the Supabase project running for Auth only. The browser keeps signing in with supabase.auth, sends the access token to your server, and your server verifies it against the project’s signing keys (or its JWT secret, on a project that still uses one) and reads sub as the user id. New users then land in Supabase’s auth.users, not yours, so copy them across before you switch Auth off. A Free Supabase project that sees no activity for a week is paused.
Switch the app and keep Supabase 7 days
Deploy the changed app as a Web service in the same environment as the database, with DATABASE_URL, your session secret and the storage variables. Test it on its naijacloud.app address against the restored data: sign in as a real user, read, write, open a private file.
Then make the final copy. Stop writes on Supabase (a maintenance page, or just a quiet hour), dump again as in step 3, upload the new move.tgz, sign a fresh link, update the job and click Run now again:
Re-sync the buckets the same way. Then point your domain or your frontend’s variables at the new app, and delete the restore job.
Related: Move from Vercel if your frontend moves too, and Backups & restore for the new database.
- The Supabase project, paused, not deleted
- The last app.dump and users.csv, somewhere private
- The Supabase Storage buckets, untouched
- Your frontend’s old variables, saved
To go back: restore the paused Supabase project and put the old variables back on your frontend. Anything written on Naijacloud since the switch has to be copied back by hand.