Moving from Supabase
Move a Supabase project's database, users and storage to Fortly, and replace the parts Fortly does not provide.
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 | Same engine. You load it from a SQL dump. |
| Data API (PostgREST) | The frontend data API at /_data/… and the server data 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 | 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 | A schedule calls a path on your app, on a cron timer in UTC. |
| Vault, project secrets | Environment and secrets | 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. Importing a database needs the account owner or an admin. pg_dumpandpsqlinstalled locally, at the same major version as your Supabase database or newer. Supabase shows the version under Project Settings → Infrastructure. An olderpg_dumprefuses 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.
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
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:
postgresql://postgres.abcdefghijklmnop:[YOUR-PASSWORD]@aws-0-us-east-1.pooler.supabase.com:5432/postgresUse 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.
export SUPABASE_DB_URL='postgresql://postgres.abcdefghijklmnop:YOUR-PASSWORD@aws-0-us-east-1.pooler.supabase.com:5432/postgres'Dump the public schema
pg_dump "$SUPABASE_DB_URL" \
--schema=public \
--no-owner --no-privileges \
--no-publications --no-subscriptions \
--inserts \
--file=supabase.sqlEach 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:
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.sqlpassword_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:
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. |
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:
grep -nE '\b(auth|storage|realtime|extensions|supabase_functions|net|cron|vault|graphql)\.' supabase.sqlEach line it prints needs a decision. Typical ones:
- A function or default that calls
auth.uid()orauth.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 onauth.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:
perl -pi -e 's#https://abcdefghijklmnop\.supabase\.co/storage/v1/object/public/#https://my-app-ada.example/_files/#g' supabase.sqlReplace 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:
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{ "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:
{ "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 for the details.
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:
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-1Use your project ref and your project's region. Then upload every file to Fortly, keeping its path:
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
doneEach 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.
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:
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.
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:
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.tgzYou can also connect a GitHub repository from the console. Deploying your code lists the frameworks Fortly runs and how builds work.
Point your domain at it
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:
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.
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:
// 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 for the full list of operators.
Server-side code can use the server data API, or a PostgreSQL driver or ORM connected with the DATABASE_URL that Fortly gives your app. See Using PostgreSQL directly.
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_hashwith 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.
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 that calls that route:
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
psqlor a GUI client. You load data with the import and read it with the console, the query endpoint, or an export. - Extensions are not promised. A
create extensionline 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:
Terminalcurl -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
setvallines 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.
-
Keep the Supabase project until you have run on Fortly for a while. Pausing or deleting it is the last step, not the first.