# Move from Supabase to Naijacloud

Source: https://naijacloud.com/guides/migrate-from-supabase

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.

_180 min · Intermediate · Last verified 2026-10-20 · Stack: postgres, storage, nextjs_

## What things are called here

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

## The cutover, in order

1. **Take inventory**: Which Supabase features the app calls.
2. **Create the database**: Check every extension you use is here.
3. **Dump from Supabase**: The public schema, plus your users.
4. **Restore here**: From a one-off job, or your machine in eu-west.
5. **Copy the buckets**: aws s3 sync, then rewrite stored URLs.
6. **Replace supabase-js**: Queries, policies and functions on your server.
7. **Sign users in**: Move Auth, or keep it.
8. **Switch and keep Supabase 7 days**: Final 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](https://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.

```sh
$ 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;
```

## 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](https://naijacloud.com/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:

```sh
$ 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.

> **Warning: If you use postgis or pgvector, stop here**
>
> Those two have no substitute on Naijacloud yet, and the restore fails on any column that uses their types. Move the rest of the app later, or keep that database on Supabase. `pg_cron` and `pg_net` jobs are easier: step 6 moves them to cron jobs.

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

```sh
$ 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:

```text title="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.

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

```sh
$ 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:

```text title="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.

```sh
$ 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.

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

```sh
$ 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.

> **Warning: Storage policies don’t come with the files**
>
> Supabase checked policies on `storage.objects` before it served a private file. Here a private bucket only answers requests signed with your access key. Your server decides who may have a file, then signs a short link for it (step 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`).

```ts title="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:

```ts title="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`.

> **Note: Realtime and the REST API**
>
> There’s no managed Realtime here. If the app uses `.channel(`, run a WebSocket server (ws, Socket.IO) as part of a web service and publish from the routes that write, or keep Supabase Realtime pointed at the old project for that one feature. For the REST API, moving queries to your server as above is the path we tested. PostgREST also ships as a Docker image you can run as a web service against the new database, with `authenticated` and `anon` as its roles.

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

```ts title="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](https://naijacloud.com/guides/send-email-naijamail.md).

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

```sh
$ 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.

> **Warning: Pause the Supabase project, don’t delete it**
>
> Keep it paused for 7 days with the last `app.dump` and `users.csv` stored somewhere private. If something goes wrong, restore the project and put the old variables back. After 7 days, check nothing still calls the old project’s URL, then delete it.

Related: [Move from Vercel](https://naijacloud.com/guides/migrate-from-vercel.md) if your frontend moves too, and [Backups & restore](https://naijacloud.com/docs/databases/backups.md) for the new database.

## Rollback: keep 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.

All guides: https://naijacloud.com/guides · Index for agents: https://naijacloud.com/llms.txt
