# Moving from Supabase

> Move a Supabase project's database, users and storage to Fortly, and replace the parts Fortly does not provide.

Source: https://docs.fortly.com/migrate/supabase

This guide is for you if your app keeps its data in Supabase and you want the database, the files and the app itself in one Fortly project. Your tables, data, views, functions and triggers come across as PostgreSQL, because Fortly's database is PostgreSQL. Your app's code changes in three places: sign-in, the calls it makes through `supabase-js`, and anything that relied on Realtime or row-level security. Fortly has no built-in auth service, no realtime and no per-user rules on the browser key, and this page says what to do instead.

## What maps to what

| Supabase | On Fortly | What changes |
|---|---|---|
| Postgres database | Your project's [PostgreSQL database](https://docs.fortly.com/data) | Same engine. You load it from a SQL dump. |
| Data API (PostgREST) | The [frontend data API](https://docs.fortly.com/data/frontend-api) at `/_data/…` and the [server data API](https://docs.fortly.com/data/server-api) | Similar `column=op.value` filters, but a different address and key. `supabase-js` does not talk to it. |
| Auth | Nothing built in | Your users move as rows in a table, and your app signs them in. |
| Row Level Security | Per-table rules on the publishable key | Rules say which operations a table allows, not which rows a user may see. |
| Storage | [Files](https://docs.fortly.com/files) | Every file is public at `/_files/…` on your project's address. |
| Edge Functions | Routes in your app | Rewritten as part of your app. There is no separate functions runtime. |
| Realtime | Nothing built in | Poll, or use a separate realtime service. |
| `pg_cron` | [Schedules](https://docs.fortly.com/deploy/schedules) | A schedule calls a path on your app, on a cron timer in UTC. |
| Vault, project secrets | [Environment and secrets](https://docs.fortly.com/deploy/environment) | Encrypted values handed to your app when it starts. |
| Database extensions | Whatever the database server has installed | Nothing is installed for you. PostGIS, pgvector and TimescaleDB are not available. |

## Before you start

You need:

- A Fortly API token in `$FORTLY_TOKEN`, from [API tokens](https://docs.fortly.com/account/tokens). Importing a database needs the account owner or an admin.
- `pg_dump` and `psql` installed locally, at the **same major version as your Supabase database or newer**. Supabase shows the version under **Project Settings → Infrastructure**. An older `pg_dump` refuses to dump a newer server.
- `perl`, which macOS and most Linux systems already have, to clean the dump.
- The AWS CLI if you use Supabase Storage.

> **Note:**
>
> The import takes a SQL script up to 256 MB and runs it in one transaction. If your dump is bigger, split it into schema and data scripts, or into groups of tables, and import them one after another.

## Move your project

### Create the Fortly project

```bash
export FORTLY_TOKEN=fly_...

curl -X POST https://api.fortly.com/projects \
  -H "Authorization: Bearer $FORTLY_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name": "my-app"}'
```

The reply has a `name`, a `host` and a `url`. Fortly adds your account's suffix to the name you typed, so `my-app` comes back as something like `my-app-ada`. Every `/projects/…` route takes that full `name`, which this guide writes as `my-app-ada`, and `GET /projects` lists it for every project you have. Keep the `url` as well: it is your project's address, and you need it to rewrite file links.

### Get your Supabase connection string

In the Supabase dashboard, click **Connect** and copy the **Session pooler** string. It looks like this:

```text
postgresql://postgres.abcdefghijklmnop:[YOUR-PASSWORD]@aws-0-us-east-1.pooler.supabase.com:5432/postgres
```

Use the session pooler rather than the direct connection. The direct host (`db.<ref>.supabase.co`) only answers over IPv6 unless you pay for the IPv4 add-on, so it fails on many networks. The pooler's username is `postgres.<your-project-ref>`, not `postgres`.

```bash
export SUPABASE_DB_URL='postgresql://postgres.abcdefghijklmnop:YOUR-PASSWORD@aws-0-us-east-1.pooler.supabase.com:5432/postgres'
```

### Dump the public schema

```bash
pg_dump "$SUPABASE_DB_URL" \
  --schema=public \
  --no-owner --no-privileges \
  --no-publications --no-subscriptions \
  --inserts \
  --file=supabase.sql
```

Each flag is there for a reason:

| Flag | Why |
|---|---|
| `--schema=public` | Your tables. Supabase's own schemas (`auth`, `storage`, `realtime`, `extensions` and others) are handled separately or not at all. |
| `--no-owner --no-privileges` | Supabase's roles (`postgres`, `anon`, `authenticated`, `service_role`) do not exist on Fortly, so `OWNER TO` and `GRANT` lines would stop the import. |
| `--no-publications --no-subscriptions` | Supabase Realtime uses a publication, which has no meaning here. |
| `--inserts` | **Required.** The import runs SQL statements, and a default dump's `COPY … FROM stdin` data blocks are not statements it can run. |

If your `pg_dump` is version 18 or newer, also add `--no-policies`, and you can skip the policy clean-up in the next steps.

### Bring your users across

Fortly has no `auth` schema. Copy the parts of `auth.users` you need into an ordinary table of your own:

```bash
cat > users.sql <<'SQL'
create table public.app_users (
  id                 uuid primary key,
  email              text,
  password_hash      text,
  email_confirmed_at timestamptz,
  created_at         timestamptz not null default now()
);
SQL

psql "$SUPABASE_DB_URL" -At -c "
  select format(
    'insert into public.app_users (id, email, password_hash, email_confirmed_at, created_at) values (%L, %L, %L, %L, %L);',
    id, email, encrypted_password, email_confirmed_at, created_at)
  from auth.users" >> users.sql
```

`password_hash` holds Supabase's bcrypt hashes (`$2a$…`) exactly as they were. Your app checks a password against them with any bcrypt library, so your users keep their passwords. Users who only ever signed in with an OAuth provider or a magic link have no password hash, and need that flow rebuilt in your app.

Keep the `id` values: your own tables refer to them, and the next step points those references at `app_users`.

### Clean the dump

A Supabase dump of `public` usually refers to things that only exist on Supabase. This rewrites the common ones in place, keeping a `.bak` copy:

```bash
perl -0777 -pi.bak -e '
  s/^CREATE POLICY .*?;\n//gms;
  s/^ALTER TABLE .*? ENABLE ROW LEVEL SECURITY;\n//gm;
  s/^CREATE SCHEMA public;\n//gm;
  s/^COMMENT ON SCHEMA public IS .*?;\n//gm;
  s/extensions\.(uuid_generate_v4|gen_random_uuid)\(\)/gen_random_uuid()/g;
  s/REFERENCES auth\.users\(id\)/REFERENCES public.app_users(id)/g;
' supabase.sql
```

| What it removes or rewrites | Why |
|---|---|
| `CREATE POLICY` and `ENABLE ROW LEVEL SECURITY` | Policies call `auth.uid()`, which does not exist here, and Fortly does not enforce row-level security for you. See [Row-level security does not carry over](#row-level-security-does-not-carry-over). |
| `CREATE SCHEMA public` and its comment | The schema already exists. |
| `extensions.uuid_generate_v4()` | Supabase installs `uuid-ossp` in its own `extensions` schema. `gen_random_uuid()` is built into PostgreSQL and needs no extension. |
| `REFERENCES auth.users(id)` | Points your foreign keys at the `app_users` table from the previous step. |

Then look for anything else that points at a Supabase schema:

```bash
grep -nE '\b(auth|storage|realtime|extensions|supabase_functions|net|cron|vault|graphql)\.' supabase.sql
```

Each line it prints needs a decision. Typical ones:

- A function or default that calls `auth.uid()` or `auth.jwt()`. Remove it, and do that check in your app instead.
- A trigger that calls `supabase_functions.http_request` (a Supabase Database Webhook). Remove it, and make the call from your app after the write.
- A `handle_new_user()` function written for a trigger on `auth.users`. The trigger is not in the dump, so the function does nothing. Remove it, or call the same logic when your app creates a user.

### Rewrite links to stored files

If your rows store full Supabase Storage URLs, point them at Fortly's file address now, while the data is still a text file. Use your project's `url` from the first step:

```bash
perl -pi -e 's#https://abcdefghijklmnop\.supabase\.co/storage/v1/object/public/#https://my-app-ada.example/_files/#g' supabase.sql
```

Replace both addresses with your own. Files keep their bucket name as the first part of the path, so `avatars/u1.png` stays `avatars/u1.png`. Paths you store without the host need no change.

### Import

Put the users first, so the foreign keys that now point at `app_users` find their rows, and send it as one script:

```bash
cat users.sql supabase.sql > import.sql

curl -X POST https://api.fortly.com/projects/my-app-ada/import \
  -H "Authorization: Bearer $FORTLY_TOKEN" \
  --data-binary @import.sql
```

```json
{ "imported": "statements", "ran": 4182, "skipped": 41, "tables": 12 }
```

`skipped` counts the dump's session settings (`SET` lines and `set_config` calls) and its own `BEGIN`/`COMMIT`, which the import leaves out on purpose. If a statement fails, nothing is applied and the reply names it:

```json
{ "error": "stopped after 214 statements. This one was not accepted: CREATE FUNCTION public.current_profile() RETURNS public.profiles LANGUAGE sql AS $$ select * from public.profiles where id = auth.uid() $$ (error returned from database: schema \"auth\" does not exist)" }
```

Fix that statement in `import.sql` and send it again. Because a failed import changes nothing, retrying is safe. A successful import is not undone, so don't send the same script twice. See [Importing a database](https://docs.fortly.com/data/import) for the details.

> **Note:**
>
> `pg_dump` normally switches off function-body checking with a `SET` line, and the import skips `SET` lines. A `language sql` function that names a table created later in the dump can therefore fail. Move that function below the table in `import.sql`, or rewrite it in `plpgsql`, and import again.

### Move your storage files

Supabase Storage has an S3-compatible endpoint. Create an access key under **Project Settings → Storage → S3 Access Keys**, then download each bucket:

```bash
aws configure --profile supabase   # paste the access key and secret

aws s3 cp s3://avatars ./storage/avatars --recursive \
  --profile supabase \
  --endpoint-url https://abcdefghijklmnop.supabase.co/storage/v1/s3 \
  --region us-east-1
```

Use your project ref and your project's region. Then upload every file to Fortly, keeping its path:

```bash
cd storage
find . -type f | while read -r f; do
  path="${f#./}"
  curl -sS -X POST "https://api.fortly.com/projects/my-app-ada/files/$path" \
    -H "Authorization: Bearer $FORTLY_TOKEN" \
    --data-binary "@$f"
  echo
done
```

Each upload replies with the file's `path`, `bytes` and `url`. One file can be up to 64 MB. If a name has spaces or other characters that are not URL-safe, percent-encode it in the request path. See [Files](https://docs.fortly.com/files).

> **Warning:**
>
> Every file on Fortly is served publicly from `/_files/…` on your project's address. Fortly has no private buckets and no signed URLs. Before you upload a private bucket, decide where those files should live instead. One option is a storage service you control, behind a route in your app that checks who is asking.

### Set your secrets

Copy the values your app needs, like payment keys and email provider keys, into the project's environment:

```bash
curl -X POST https://api.fortly.com/projects/my-app-ada/secrets \
  -H "Authorization: Bearer $FORTLY_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name": "STRIPE_SECRET_KEY", "value": "sk_live_..."}'
```

Names use capital letters, digits and underscores. A secret reaches your running app by default. Add `"scope": "build"` or `"scope": "both"` for values your build reads, like a framework's public variables. Setting a secret restarts the project.

Don't copy `SUPABASE_URL`, `SUPABASE_ANON_KEY` or `SUPABASE_SERVICE_ROLE_KEY` once your code no longer uses them. Don't set `DATABASE_URL` or the `PG*` variables either: Fortly sets those itself and refuses secrets with those names. See [Environment and secrets](https://docs.fortly.com/deploy/environment).

### Deploy your app

Supabase never ran your app, so it is probably on another host today. Upload your source and Fortly installs dependencies and runs your `build` script:

```bash
tar --exclude=node_modules --exclude=.git -czf release.tgz .

curl -X POST https://api.fortly.com/projects/my-app-ada/deploys \
  -H "Authorization: Bearer $FORTLY_TOKEN" \
  --data-binary @release.tgz
```

You can also connect a GitHub repository from the console. [Deploying your code](https://docs.fortly.com/deploy) lists the frameworks Fortly runs and how builds work.

### Point your domain at it

```bash
curl -X POST https://api.fortly.com/projects/my-app-ada/domains \
  -H "Authorization: Bearer $FORTLY_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"host": "www.example.com"}'
```

Publish the two records the reply gives you: a `TXT` record that proves the domain is yours, and a `CNAME` that sends traffic to your project. Then ask Fortly to check:

```bash
curl -X POST https://api.fortly.com/projects/my-app-ada/domains/www.example.com/verify \
  -H "Authorization: Bearer $FORTLY_TOKEN"
```

The domain serves nothing until it is verified. See [Custom domains](https://docs.fortly.com/domains).

## Change your app's code

### Data calls

`supabase-js` builds requests for Supabase's addresses and keys, so it does not work against Fortly. The filter grammar in Fortly's data API will look familiar, though:

```js
// Before
const { data } = await supabase
  .from("posts")
  .select("id,title,author(name)")
  .eq("published", true)
  .order("created_at", { ascending: false })
  .limit(20);

// After: from the browser, with the project's publishable key
const res = await fetch(
  "https://my-app-ada.example/_data/posts?select=id,title,author(name)&published=eq.true&order=created_at.desc&limit=20",
  { headers: { Authorization: "Bearer cvp_..." } },
);
const data = await res.json();
```

The publishable key (`cvp_…`) is on the project's **Data → API** tab. Every table starts closed to it, and you open read, create, update and delete one table at a time. Database functions you call with `supabase.rpc()` can be called with `POST /_data/rpc/<name>` once you open them on the same tab. See [The frontend data API](https://docs.fortly.com/data/frontend-api) for the full list of operators.

Server-side code can use the [server data API](https://docs.fortly.com/data/server-api), or a PostgreSQL driver or ORM connected with the `DATABASE_URL` that Fortly gives your app. See [Using PostgreSQL directly](https://docs.fortly.com/data/postgres).

### Sign-in

Fortly does not sign your users in. You have two options:

- **Sign users in from your app.** Use an auth library that runs in your framework, check passwords against `app_users.password_hash` with bcrypt, and keep sessions in your database or in signed cookies. Your users keep their passwords.
- **Use a hosted auth provider.** Most of them can import bcrypt hashes, so your users keep their passwords there too.

### Row-level security does not carry over

On Supabase, a policy like `auth.uid() = user_id` decides which rows a signed-in user can see, even when the browser talks to the database directly. Fortly has nothing equivalent. The publishable key's rules are per table and per operation, so a table you open for reading is readable in full by anyone with the key.

So any table whose rows belong to particular users stays **closed** to the publishable key. Your app reads it on the server, after checking who is signed in. Use the frontend data API only for data that every visitor may see or write.

> **Warning:**
>
> Don't open a table to the publishable key because it "had RLS on Supabase". The policies were removed in the clean-up step and nothing replaces them. Opening that table makes every row public.

### Realtime

Fortly has no realtime service, and nothing pushes database changes to the browser. To show changes, poll the data API on an interval, or add a separate realtime service and publish to it from your app when it writes.

### Edge Functions and cron jobs

Rewrite each Edge Function as a route in your app. Supabase runs them on Deno, so replace `Deno.env.get("X")` with `process.env.X` and check your imports. A function `pg_cron` called on a timer becomes a [schedule](https://docs.fortly.com/deploy/schedules) that calls that route:

```bash
curl -X POST https://api.fortly.com/projects/my-app-ada/schedules \
  -H "Authorization: Bearer $FORTLY_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name": "nightly-cleanup", "spec": "0 3 * * *", "path": "/api/cron/cleanup"}'
```

Schedules use five cron fields in UTC. A schedule is an ordinary request to your app, so check a shared secret in that route if it should not be public.

## What's different

- **One project holds everything.** The app, its database, its files, its domains and its schedules live together, and deleting the project deletes all of them.
- **Nothing pauses.** An idle project stops after 15 minutes and starts again on the next request. There is nothing to restore by hand.
- **You can't connect from your laptop.** Your app gets a connection string, but there is no public address for `psql` or a GUI client. You load data with the import and read it with the console, the query endpoint, or an [export](https://docs.fortly.com/data/backups).
- **Extensions are not promised.** A `create extension` line in your dump either works or stops the import and names itself. If you depend on PostGIS, pgvector or TimescaleDB, Fortly is not the right home for that database today.
- **No auth, realtime, private files or per-row rules.** Each of these is covered above.

## Things to check after moving

- **Row counts match.** Compare a few tables on both sides:

  ```bash
  curl -X POST https://api.fortly.com/projects/my-app-ada/query \
    -H "Authorization: Bearer $FORTLY_TOKEN" \
    -H "Content-Type: application/json" \
    -d '{"sql": "select count(*) from orders"}'
  ```

- **Generated keys continue from the right number.** The dump's `setval` lines are kept, so a new row should not collide with an imported one. Insert a test row in each busy table to confirm.
- **An existing user can sign in** with their old password through your new sign-in code.
- **No table is open to the publishable key unless every row in it is meant to be public.** Check the **Data → API** tab.
- **Old storage URLs are gone.** Search your data and your code for `supabase.co`.
- **Your app no longer imports `@supabase/supabase-js`.**
- **Scheduled jobs ran.** Each schedule shows its next run, and its requests appear in the project's [logs](https://docs.fortly.com/deploy/logs).
- **Keep the Supabase project** until you have run on Fortly for a while. Pausing or deleting it is the last step, not the first.

- [Importing a database](https://docs.fortly.com/data/import): What the import accepts, what it skips, and how it fails.
- [The frontend data API](https://docs.fortly.com/data/frontend-api): Publishable keys, per-table rules and the filter grammar.
- [Files](https://docs.fortly.com/files): Upload, list and serve files from your project's address.
