Moving from Firebase
Turn Firestore collections into PostgreSQL tables, bring your users and Storage files across, and replace the Firebase services Fortly lacks.
This guide is for you if you run a web app on Firebase and want a SQL database, your files and the app itself in one Fortly project. It is a bigger move than most, and it is better to know that before you start. Firestore stores documents and Fortly stores PostgreSQL tables, so your data has to be reshaped, not just copied. Code that calls the Firestore SDK has to be rewritten. Some Firebase services have no counterpart on Fortly at all. The parts that move cleanly are your users with their passwords, your Storage files, and your hosting.
If your app depends on Firestore's offline persistence and sync, especially in a mobile app, Fortly has no replacement for it. The same goes for Firestore's live listeners (onSnapshot). Don't move that app.
What maps to what
| Firebase | On Fortly | What changes |
|---|---|---|
| Cloud Firestore | Your project's PostgreSQL database | Collections become tables. You design the columns. |
| Realtime Database | Your project's PostgreSQL database | Same as Firestore: export as JSON, then reshape. |
| Authentication | Nothing built in | Users move as rows in a table, with their password hashes. Your app signs them in. |
| Security Rules | Per-table rules on the publishable key | Rules say which operations a table allows, not which documents a user may see. |
| Cloud Storage | Files | Every file is public at /_files/… on your project's address. |
| Cloud Functions (HTTP and callable) | Routes in your app | Rewritten as part of your app. |
| Cloud Functions (scheduled) | Schedules | A schedule calls a path on your app, on a cron timer in UTC. |
| Cloud Functions (Firestore, Auth and Storage triggers) | Nothing built in | Run that logic in your app, right after the write that used to trigger it. |
| Hosting | Deploying | Static sites and the server frameworks Fortly supports. |
| Live listeners, offline sync | Nothing built in | Poll, or use a separate realtime service. |
| Cloud Messaging, Analytics, Crashlytics, Remote Config | Nothing built in | These don't depend on Firestore. You can keep using them from Firebase. |
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. - Node.js 18 or newer, and the Firebase CLI (
npm install -g firebase-tools, thenfirebase login). - A Firebase service account key: Project settings → Service accounts → Generate new private key. The export scripts read it from
GOOGLE_APPLICATION_CREDENTIALS. - The Google Cloud CLI (
gcloud) if you use Cloud Storage.
Firestore's own managed export (gcloud firestore export) writes files in a format meant for re-importing into Firestore or loading into BigQuery, not SQL. This guide reads your documents with the Firebase Admin SDK and writes SQL instead.
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 the export below uses it to rewrite file links.
Export Firestore as SQL
The first pass copies every document as it is, one table per collection name, with the document stored as jsonb. You reshape it into real columns in the next step, inside PostgreSQL, where you can see the data and try your queries against it.
mkdir firestore-export && cd firestore-export
npm init -y && npm install firebase-admin
export GOOGLE_APPLICATION_CREDENTIALS=/path/to/service-account.json
export FORTLY_FILES_URL=https://my-app-ada.example/_files/import { initializeApp, applicationDefault } from "firebase-admin/app";
import { getFirestore, FieldPath, Timestamp, GeoPoint, DocumentReference } from "firebase-admin/firestore";
initializeApp({ credential: applicationDefault() });
const db = getFirestore();
// Firebase Storage download URLs, rewritten to the same file on Fortly.
const filesUrl = process.env.FORTLY_FILES_URL;
function relink(s) {
const m = filesUrl && s.match(/^https:\/\/firebasestorage\.googleapis\.com\/v0\/b\/[^/]+\/o\/([^?]+)/);
return m ? filesUrl + decodeURIComponent(m[1]) : s;
}
// Firestore values that JSON cannot hold, as plain values.
function plain(v) {
if (v instanceof Timestamp) return v.toDate().toISOString();
if (v instanceof GeoPoint) return { lat: v.latitude, lng: v.longitude };
if (v instanceof DocumentReference) return v.path;
if (Buffer.isBuffer(v)) return v.toString("base64");
if (typeof v === "string") return relink(v);
if (Array.isArray(v)) return v.map(plain);
if (v !== null && typeof v === "object") {
return Object.fromEntries(Object.entries(v).map(([k, x]) => [k, plain(x)]));
}
return v;
}
const quote = (s) => "'" + s.replaceAll("'", "''") + "'";
const tableFor = (id) => "fs_" + id.toLowerCase().replace(/[^a-z0-9]+/g, "_");
const print = (line) => process.stdout.write(line + "\n");
const made = new Set();
async function exportCollection(col) {
const table = tableFor(col.id);
if (!made.has(table)) {
made.add(table);
print(`create table ${table} (path text primary key, parent text, id text not null, data jsonb not null);`);
}
let last = null;
for (;;) {
let query = col.orderBy(FieldPath.documentId()).limit(500);
if (last) query = query.startAfter(last);
const page = await query.get();
if (page.empty) break;
for (const doc of page.docs) {
const parent = col.parent ? quote(col.parent.path) : "null";
const data = quote(JSON.stringify(plain(doc.data())));
print(`insert into ${table} values (${quote(doc.ref.path)}, ${parent}, ${quote(doc.id)}, ${data});`);
for (const sub of await doc.ref.listCollections()) await exportCollection(sub);
}
last = page.docs[page.docs.length - 1];
}
}
for (const col of await db.listCollections()) await exportCollection(col);node export-firestore.mjs > firestore.sqlWhat you get:
- A table per collection name, like
fs_posts. Subcollections with the same name share a table, which is what a Firestore collection group query reads. Theparentcolumn says which document each row belongs under. - Timestamps as ISO 8601 text, references as their path, geopoints as
{"lat", "lng"}, and bytes as base64. - Storage download URLs rewritten to
/_files/<path>on your project, if you setFORTLY_FILES_URL. The access token Firebase put in each URL is dropped.
The script reads 500 documents at a time. It doesn't reach subcollections under a document that was deleted while its subcollections were kept. If you have those, read them with listDocuments(), which also returns documents that don't exist.
Import the raw export
curl -X POST https://api.fortly.com/projects/my-app-ada/import \
-H "Authorization: Bearer $FORTLY_TOKEN" \
--data-binary @firestore.sql{ "imported": "statements", "ran": 18240, "skipped": 0, "tables": 6 }The script runs in one transaction. If a statement is refused, nothing is applied and the reply names the statement. Fix it and send the script again. One script can be up to 256 MB. Split a larger export across several files, or run the script once per top-level collection. See Importing a database.
Reshape documents into tables
This is the part only you can do, because the right schema depends on how your app reads its data. Write a second script that creates proper tables and fills them from the raw ones:
create table authors (
id text primary key,
name text not null
);
insert into authors (id, name)
select id, data->>'name'
from fs_authors;
create table posts (
id text primary key,
author_id text references authors(id),
title text not null,
tags text[] not null default '{}',
published boolean not null default false,
created_at timestamptz not null
);
insert into posts (id, author_id, title, tags, published, created_at)
select id,
split_part(data->>'author', '/', 2), -- "authors/abc" → "abc"
data->>'title',
coalesce(array(select jsonb_array_elements_text(data->'tags')), '{}'),
coalesce((data->>'published')::boolean, false),
(data->>'createdAt')::timestamptz
from fs_posts;
create table comments (
id text primary key,
post_id text not null references posts(id),
body text not null,
created_at timestamptz not null
);
insert into comments (id, post_id, body, created_at)
select id, split_part(parent, '/', 2), data->>'body', (data->>'createdAt')::timestamptz
from fs_comments
where parent like 'posts/%';
drop table fs_authors, fs_posts, fs_comments;curl -X POST https://api.fortly.com/projects/my-app-ada/import \
-H "Authorization: Bearer $FORTLY_TOKEN" \
--data-binary @reshape.sqlThe reshape is where Firestore's lack of a schema catches up with you. A not null column fails on the document that never had that field, and a cast fails on the one that stored a number as a string. The import names the statement that failed, and nothing is applied, so you can fix the query and run it again. Keep the drop table lines until last, or out of the script entirely, until you are happy with the result.
You don't have to give every field a column. A jsonb column for settings, metadata or anything else your app only reads as a whole is fine in PostgreSQL. You can still query inside it with ->>.
Bring your users across
The Firebase CLI exports every user with their password hash:
firebase auth:export users.json --format=json --project your-firebase-projectTurn the export into SQL:
import { readFileSync } from "node:fs";
const { users } = JSON.parse(readFileSync(process.argv[2], "utf8"));
const q = (v) => (v == null ? "null" : "'" + String(v).replaceAll("'", "''") + "'");
console.log(`create table app_users (
id text primary key,
email text,
email_verified boolean not null default false,
password_hash text,
password_salt text,
created_at timestamptz
);`);
for (const u of users) {
const created = u.createdAt ? new Date(Number(u.createdAt)).toISOString() : null;
console.log(
`insert into app_users values (${q(u.localId)}, ${q(u.email)}, ${u.emailVerified ? "true" : "false"}, ` +
`${q(u.passwordHash)}, ${q(u.salt)}, ${q(created)});`,
);
}node users-to-sql.mjs users.json > users.sql
curl -X POST https://api.fortly.com/projects/my-app-ada/import \
-H "Authorization: Bearer $FORTLY_TOKEN" \
--data-binary @users.sqlKeep id as the Firebase UID. Your Firestore documents refer to users by it, so your reshaped tables can reference app_users(id) directly.
Firebase hashes passwords with its own modified scrypt. To check a password against these hashes, your app also needs your project's hash parameters. Find them in the Firebase console under Authentication → Users → ⋮ → Password hash parameters: base64_signer_key, base64_salt_separator, rounds and mem_cost. Store them as secrets in the next step. Firebase has published the algorithm, and open-source implementations exist for Node.js and other languages. These are third-party code, so test one against a few known passwords before you rely on it.
Users who signed in only with Google, Apple or another provider have no password hash. Their provider details are in each user's providerUserInfo, and you need that sign-in flow rebuilt in your app.
Move your Storage files
Find your bucket's name on the Firebase console's Storage page. It ends in .appspot.com or .firebasestorage.app. Then download it:
gcloud storage cp -r "gs://your-firebase-project.appspot.com/*" ./storage/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 file is then at /_files/<path> on your project's address, which is what the Firestore export rewrote your download URLs to. One file can be up to 64 MB. Percent-encode names with spaces or other characters that are not URL-safe. See Files.
Every file on Fortly is public to anyone with its URL. Fortly has no Storage Security Rules and no download tokens. Files that only certain users may read need to live somewhere else, behind a route in your app that checks who is asking.
Set your secrets
curl -X POST https://api.fortly.com/projects/my-app-ada/secrets \
-H "Authorization: Bearer $FORTLY_TOKEN" \
-H "Content-Type: application/json" \
-d '{"name": "FIREBASE_HASH_SIGNER_KEY", "value": "YOUR-BASE64-SIGNER-KEY"}'Do the same for the salt separator, rounds, memory cost, and any API keys your functions used. Names use capital letters, digits and underscores. Add "scope": "both" to a value your build also reads. Setting a secret restarts the project. Fortly sets DATABASE_URL and the PG* variables for your app itself and refuses secrets with those names. See Environment and secrets.
Deploy your app
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.tgzA site you served from Firebase Hosting deploys the same way. Firebase's rewrites to Cloud Functions become routes in your app. Deploying your code lists the frameworks Fortly runs.
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. Remove the records that pointed the domain at Firebase Hosting, then verify:
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
Reads and writes
Calls to the Firestore SDK become calls to your own tables. Public data can be read from the browser with the frontend data API and the project's publishable key:
// Before
const snap = await getDocs(
query(collection(db, "posts"), where("published", "==", true), orderBy("createdAt", "desc"), limit(20)),
);
// After
const res = await fetch(
"https://my-app-ada.example/_data/posts?published=eq.true&order=created_at.desc&limit=20",
{ headers: { Authorization: "Bearer cvp_..." } },
);
const posts = await res.json();Anything private is read by your server code instead. It can use the server data API, or a PostgreSQL driver connected with the DATABASE_URL that Fortly gives your app. Joins you used to avoid by copying data between documents can now be ordinary SQL joins. Leave the copies out of the new schema where you can.
Security Rules do not carry over
Firestore rules like request.auth.uid == resource.data.ownerId decide document by document. Fortly's publishable key has nothing like that. Its rules open a whole table for read, create, update or delete, and every table starts closed. A table whose rows belong to particular users stays closed to the key, and your app reads it on the server after checking who is signed in.
Sign-in
Fortly does not sign your users in. Either sign them in from your app, checking passwords against app_users with Firebase's scrypt parameters, or move them to a hosted auth provider. Several providers can import Firebase scrypt hashes directly. In both cases your users keep their passwords.
Functions and triggers
-
HTTP and callable functions become routes in your app. Replace
httpsCallableon the client with afetchto the route. -
Scheduled functions become schedules that call a route:
Terminalcurl -X POST https://api.fortly.com/projects/my-app-ada/schedules \ -H "Authorization: Bearer $FORTLY_TOKEN" \ -H "Content-Type: application/json" \ -d '{"name": "daily-digest", "spec": "0 7 * * *", "path": "/api/cron/digest"}'Schedules use five cron fields, always in UTC. A schedule is an ordinary request, so protect the route with a shared secret if it should not be public.
-
Firestore, Auth and Storage triggers have no equivalent. Call the same logic from your app right after the write, sign-up or upload that used to trigger it. A trigger fired on every write, however it was made. A call in your app runs only when your app made the write, so imports and SQL you run by hand don't trigger it.
Live updates
Fortly has no live listeners. Poll the data API on an interval, or add a separate realtime service and publish to it from your app when it writes.
What's different
- You design a schema. Columns have types, and a missing field is a
nullor a default, not an absent key. This is most of the work in this move, and also most of what you get out of it. - One project holds everything. The app, its database, its files, its domains and its schedules live together.
- You can export the whole database as SQL at any time. See Backups and exports.
- An idle project stops after 15 minutes and starts again on the next request.
- No offline sync, live listeners, auth, triggers, private files or per-document rules. Each of these is covered above.
Things to check after moving
-
Document and row counts match. Count each collection in Firebase and compare:
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 posts"}' -
Nothing was lost in the reshape. Look for documents that didn't make it into the new table, for example with
select f.id from fs_posts f left join posts p using (id) where p.id is null, before you drop the raw tables. -
An existing user can sign in with their old password.
-
No table is open to the publishable key unless every row in it is meant to be public. Check the Data → API tab.
-
No links still point at
firebasestorage.googleapis.comin your data or your code. -
Your app no longer loads the Firestore SDK. If you kept Cloud Messaging or Analytics, check those still initialize without it.
-
Keep the Firebase project running until you have run on Fortly for a while. Your old data stays there as a reference.