Migration guide

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.

Two to four hours for the data, plus the time to replace supabase-js in your appDowntime: a few minutes, for the final copy, if you follow the orderLast verified 20 Oct 2026

What things are called here

On Supabase
On Naijacloud
SupabasePostgres database
Postgres 18 database, sized by RAM and disk. Contrib extensions only (pg_trgm, pgcrypto, uuid-ossp, citext, hstore, unaccent and the rest)
SupabaseConnection pooler (Supavisor, port 6543)
No pooler. Connect directly over the private network and pool in your app. Drop pgbouncer=true and Prisma’s directUrl
Supabasepostgis, vector (pgvector), pg_cron, pg_net
Not installed. Use a cron job for pg_cron and pg_net work; there is no substitute for postgis or pgvector here yet
SupabaseStorage buckets
Object storage, S3-compatible, with public-read or private buckets and signed URLs
SupabaseEdge Functions (Deno)
A route on your web service, its own web service, or a cron job
SupabaseDatabase webhooks and pg_cron
Cron job, in UTC, up to 1 hour per run, or the call made from your app
SupabaseAuth (GoTrue)
No managed auth. Keep Supabase Auth, or sign users in from your app; the bcrypt hashes carry over
SupabaseRealtime
No managed realtime. Run a WebSocket server on a web service, or keep Supabase Realtime
SupabaseAuto-generated REST API (PostgREST) and supabase-js queries
No auto-API. Move the queries to your server, or run PostgREST yourself from its Docker image
SupabaseRow Level Security
Comes over in the dump. Keep it by switching role per transaction, or move the checks into your queries
SupabaseAuth emails and SMTP
Naijamail for transactional email

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.

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.

1

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.

Terminal
$ grep -rnE "supabase\.auth|\.storage\.|\.channel\(|\.rpc\(|\.from\(|functions\.invoke" \
--include='*.ts' --include='*.tsx' --include='*.js' src app lib
  • .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.invoke calls an Edge Function, step 6.
  • supabase.auth is 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:

sql
select extname, extnamespace::regnamespace as schema from pg_extension order by 1;
2

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:

Terminal
$ npm install -g @naijacloud/cli
$ naijacloud login
$ naijacloud db query "select name from pg_available_extensions where name in ('uuid-ossp','pgcrypto','pg_trgm','citext','unaccent','postgis','vector','pg_cron','pg_net') order by 1" --service notes-db

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.

3

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.

Terminal
$ git clone https://github.com/naijacloud/guide-supabase-migration
$ cp -r guide-supabase-migration/migration move && cd move
$ export SUPABASE_DB_URL="postgresql://postgres.<ref>:<password>@aws-0-<region>.pooler.supabase.com:5432/postgres"
$ pg_dump --format=custom --no-owner --no-acl --schema=public "$SUPABASE_DB_URL" -f app.dump
$ psql "$SUPABASE_DB_URL" -c "\copy (select id, email, encrypted_password, email_confirmed_at, raw_user_meta_data, created_at from auth.users) to 'users.csv' csv header"
COPY 2

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:

pg_restore into an empty Postgres 18, before prepare.sql
pg_restore: error: could not execute query: ERROR: schema "auth" does not exist
pg_restore: error: could not execute query: ERROR: relation "public.notes" does not exist
pg_restore: error: could not execute query: ERROR: role "authenticated" does not exist
pg_restore: warning: errors ignored on restore: 17

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.

4

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:

Terminal
$ cd .. && tar -czf move.tgz move
$ aws s3 cp move.tgz s3://acme-attachments/migration/move.tgz --endpoint-url https://af.storage.naijacloud.app
$ aws s3 presign s3://acme-attachments/migration/move.tgz --expires-in 3600 --endpoint-url https://af.storage.naijacloud.app
$ cat restore.env
DATABASE_URL=<the database's Internal URL>
BUNDLE_URL=<the link presign printed>
OLD_STORAGE_PREFIX=https://<ref>.supabase.co/storage/v1/object/public/avatars/
NEW_STORAGE_PREFIX=https://af.storage.naijacloud.app/acme-avatars/
$ naijacloud services create restore --env acme/prod --type cron --image alpine:3.23 \
--schedule "0 0 1 1 *" --tier starter --dotenv restore.env \
--command 'apk add --no-cache postgresql18-client curl && curl -fsS "$BUNDLE_URL" | tar -xz -C /tmp && sh /tmp/move/restore.sh'

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:

Run output, the end
COPY 2
UPDATE 2
relname | n_live_tup
----------+------------
notes | 6
profiles | 2
users | 2
(3 rows)
…
policies
----------
5
(1 row)

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.

Terminal
$ DATABASE_URL="postgresql://…naijacloud.app:5432/…?sslmode=require" \
OLD_STORAGE_PREFIX=… NEW_STORAGE_PREFIX=… sh restore.sh

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.

5

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:

Terminal
$ AWS_ACCESS_KEY_ID=<supabase key> AWS_SECRET_ACCESS_KEY=<supabase secret> AWS_REGION=<supabase region> \
aws s3 sync s3://avatars ./storage/avatars --endpoint-url https://<ref>.supabase.co/storage/v1/s3
$ AWS_ACCESS_KEY_ID=<naijacloud key> AWS_SECRET_ACCESS_KEY=<naijacloud secret> AWS_REGION=af-west \
aws s3 sync ./storage/avatars s3://acme-avatars --endpoint-url https://af.storage.naijacloud.app
$ curl -s -o /dev/null -w "%{http_code} %{content_type}\n" \
https://af.storage.naijacloud.app/acme-avatars/<user-id>/avatar.png
200 image/png

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.

6

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).

lib/db.ts
import { Pool, type PoolClient } from 'pg';
export const pool = new Pool({ connectionString: process.env.DATABASE_URL, max: 10 });
export async function asUser<T>(userId: string, fn: (db: PoolClient) => Promise<T>) {
const db = await pool.connect();
try {
await db.query('begin');
await db.query('set local role authenticated');
await db.query("select set_config('request.jwt.claim.sub', $1, true)", [userId]);
const result = await fn(db);
await db.query('commit');
return result;
} catch (err) {
await db.query('rollback');
throw err;
} finally {
db.release();
}
}

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:

app/api/attachments/[id]/route.ts
const { rows } = await asUser(userId, (db) =>
db.query('select attachment_path from notes where id = $1', [id]),
);
if (!rows[0]?.attachment_path) return NextResponse.json({ error: 'not found' }, { status: 404 });
return NextResponse.redirect(await signedAttachmentUrl(rows[0].attachment_path), 302);

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.

7

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:

app/api/login/route.ts
const { rows } = await pool.query(
'select id, encrypted_password from auth.users where email = $1',
[email],
);
const ok = rows[0]?.encrypted_password
? await bcrypt.compare(password, rows[0].encrypted_password)
: false;

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.

8

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:

Terminal
$ naijacloud env set BUNDLE_URL "<new presigned link>" --service restore

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.

CostSee what this would costThe pricing page lists every size and rate in naira, from the same price list the meter charges from.Naijacloud pricing →
RollbackKeep these for 7 days
  • 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.

Move one service first. Keep the old one running.

Start your move

Runs in af-west · Port Harcourt or eu-west · Europe