Moving from Appwrite
Move an Appwrite project's tables, users and files into a Fortly project, and replace the Appwrite services Fortly does not provide.
This guide is for you if your app runs on Appwrite, on Appwrite Cloud or self-hosted, and you want it on Fortly. The app, its data and its files all move into one Fortly project. Appwrite's database has no SQL export, so you read your rows out with Appwrite's server SDK and load them into PostgreSQL, then shape them into real tables. Your app's code changes wherever it calls the Appwrite SDK. Fortly has no built-in auth, messaging, realtime or per-row permissions, and this page says what to do instead of each.
What maps to what
| Appwrite | On Fortly | What changes |
|---|---|---|
| Databases (tables and rows, formerly collections and documents) | Your project's PostgreSQL database | Exported through the SDK, then shaped into SQL tables you design. |
| Auth (accounts, sessions, OAuth, teams) | Nothing built in | Users move as rows in a table, with their password hashes. Your app signs them in. |
| Permissions on tables and rows | Per-table rules on the publishable key | Rules say which operations a table allows, not which rows a user or team may see. |
| Storage | Files | Every file is public at /_files/… on your project's address. No image previews or transformations. |
| Functions (HTTP and SDK executions) | Routes in your app | Rewritten as part of your app. |
| Functions (scheduled) | Schedules | A schedule calls a path on your app, on a cron timer in UTC. |
| Functions (event triggers) | Nothing built in | Run that logic in your app, right after the action that used to trigger it. |
| Sites | Deploying | Static sites and the server frameworks Fortly supports. |
| Realtime | Nothing built in | Poll, or use a separate realtime service. |
| Messaging (email, SMS, push) | Nothing built in | Send from your app through an email, SMS or push provider. |
| Project variables | Environment and secrets | Encrypted values handed to your app when it starts. |
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.
- An Appwrite API key, created in your project's API keys settings in the Appwrite console, with read access to databases, tables, rows, users, buckets and files.
- Your Appwrite endpoint and project ID. On Appwrite Cloud the endpoint looks like
https://fra.cloud.appwrite.io/v1. Self-hosted, it is your own address followed by/v1.
The scripts below use the TablesDB API of Appwrite 1.8 and later. On an older server, the same calls are on Databases: listCollections and listDocuments, returning collections and documents.
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.
Export your tables and users as SQL
mkdir appwrite-export && cd appwrite-export
npm init -y && npm install node-appwrite
export APPWRITE_ENDPOINT=https://fra.cloud.appwrite.io/v1
export APPWRITE_PROJECT=your-project-id
export APPWRITE_KEY=standard_...import { Client, TablesDB, Users, Query } from "node-appwrite";
const client = new Client()
.setEndpoint(process.env.APPWRITE_ENDPOINT)
.setProject(process.env.APPWRITE_PROJECT)
.setKey(process.env.APPWRITE_KEY);
const tablesDB = new TablesDB(client);
const users = new Users(client);
// Every item of a paged list, 100 at a time.
async function* all(list, key) {
let cursor;
for (;;) {
const queries = [Query.limit(100)];
if (cursor) queries.push(Query.cursorAfter(cursor));
const items = (await list(queries))[key];
if (items.length === 0) return;
yield* items;
cursor = items[items.length - 1].$id;
}
}
const q = (v) => (v == null || v === "" ? "null" : "'" + String(v).replaceAll("'", "''") + "'");
const json = (v) => q(JSON.stringify(v ?? null));
const tableFor = (id) => "aw_" + id.toLowerCase().replace(/[^a-z0-9]+/g, "_");
const print = (line) => process.stdout.write(line + "\n");
for await (const db of all((queries) => tablesDB.list({ queries }), "databases")) {
for await (const t of all((queries) => tablesDB.listTables({ databaseId: db.$id, queries }), "tables")) {
const table = tableFor(t.$id);
print(`create table ${table} (id text primary key, created_at timestamptz, updated_at timestamptz, permissions jsonb, data jsonb not null);`);
const rows = (queries) => tablesDB.listRows({ databaseId: db.$id, tableId: t.$id, queries });
for await (const row of all(rows, "rows")) {
const data = Object.fromEntries(Object.entries(row).filter(([k]) => !k.startsWith("$")));
print(`insert into ${table} values (${q(row.$id)}, ${q(row.$createdAt)}, ${q(row.$updatedAt)}, ${json(row.$permissions)}, ${json(data)});`);
}
}
}
print(`create table app_users (
id text primary key, email text, phone text, name text,
email_verified boolean not null default false, enabled boolean not null default true,
labels jsonb, prefs jsonb,
password_hash text, hash_algorithm text, hash_options jsonb,
created_at timestamptz
);`);
for await (const u of all((queries) => users.list({ queries }), "users")) {
print(
`insert into app_users values (${q(u.$id)}, ${q(u.email)}, ${q(u.phone)}, ${q(u.name)}, ` +
`${u.emailVerification ? "true" : "false"}, ${u.status ? "true" : "false"}, ${json(u.labels)}, ${json(u.prefs)}, ` +
`${q(u.password)}, ${q(u.hash)}, ${json(u.hashOptions)}, ${q(u.registration)});`,
);
}node export-appwrite.mjs > appwrite.sqlEach Appwrite table becomes a table named aw_<table id>. It has the row's ID, its timestamps and its permissions as columns, and every other field together in a jsonb column called data. If two databases have a table with the same ID, rename one in the script before you run it.
app_users keeps each user's password hash exactly as Appwrite stored it, along with the algorithm (argon2 unless you imported the user from elsewhere) and its parameters. That's what your app needs to check a password against it, so your users keep their passwords. Users who only sign in with OAuth, magic links or phone codes have no password hash, and need that flow rebuilt in your app.
Import the export
curl -X POST https://api.fortly.com/projects/my-app-ada/import \
-H "Authorization: Bearer $FORTLY_TOKEN" \
--data-binary @appwrite.sql{ "imported": "statements", "ran": 5530, "skipped": 0, "tables": 5 }The script runs in one transaction. If a statement is refused, nothing is applied and the reply's error names it, so you can fix it and send the script again. One script can be up to 256 MB, so split a larger export into several files. See Importing a database.
Shape the rows into tables
Appwrite already gave your rows typed columns, so this step is mostly mechanical. Write a script that creates the tables you want and fills them from the raw ones:
create table posts (
id text primary key,
author_id text references app_users(id),
title text not null,
body text,
published boolean not null default false,
cover text,
created_at timestamptz not null
);
insert into posts (id, author_id, title, body, published, cover, created_at)
select id,
data->>'authorId',
data->>'title',
data->>'body',
coalesce((data->>'published')::boolean, false),
data->>'coverFileId',
created_at
from aw_posts;
drop table aw_posts;curl -X POST https://api.fortly.com/projects/my-app-ada/import \
-H "Authorization: Bearer $FORTLY_TOKEN" \
--data-binary @reshape.sqlRelationship columns arrive as row IDs, or as whole related rows if your table returned them expanded. In both cases, reduce them to the ID and declare a foreign key. Hold off on the drop table lines until you have compared row counts.
Move your storage files
This script copies every file in every bucket straight from Appwrite to Fortly, and writes a table that records where each file went:
import { Client, Storage, Query } from "node-appwrite";
import { extname } from "node:path";
const client = new Client()
.setEndpoint(process.env.APPWRITE_ENDPOINT)
.setProject(process.env.APPWRITE_PROJECT)
.setKey(process.env.APPWRITE_KEY);
const storage = new Storage(client);
const project = process.env.FORTLY_PROJECT;
async function* all(list, key) {
let cursor;
for (;;) {
const queries = [Query.limit(100)];
if (cursor) queries.push(Query.cursorAfter(cursor));
const items = (await list(queries))[key];
if (items.length === 0) return;
yield* items;
cursor = items[items.length - 1].$id;
}
}
const q = (v) => "'" + String(v).replaceAll("'", "''") + "'";
console.log("create table aw_files (bucket_id text, file_id text, name text, path text primary key, bytes bigint);");
for await (const bucket of all((queries) => storage.listBuckets({ queries }), "buckets")) {
for await (const file of all((queries) => storage.listFiles({ bucketId: bucket.$id, queries }), "files")) {
const bytes = await storage.getFileDownload({ bucketId: bucket.$id, fileId: file.$id });
const path = `${bucket.$id}/${file.$id}${extname(file.name).toLowerCase()}`;
const res = await fetch(`https://api.fortly.com/projects/${project}/files/${path}`, {
method: "POST",
headers: { Authorization: `Bearer ${process.env.FORTLY_TOKEN}` },
body: Buffer.from(bytes),
});
if (!res.ok) throw new Error(`${path}: ${res.status} ${await res.text()}`);
console.log(`insert into aw_files values (${q(bucket.$id)}, ${q(file.$id)}, ${q(file.name)}, ${q(path)}, ${file.sizeOriginal});`);
}
}FORTLY_PROJECT=my-app-ada node copy-files.mjs > files.sql
curl -X POST https://api.fortly.com/projects/my-app-ada/import \
-H "Authorization: Bearer $FORTLY_TOKEN" \
--data-binary @files.sqlEach file is then at /_files/<bucket>/<file id><extension> on your project's address. The extension is kept because Fortly decides a file's content type from its name. Where your app built a URL with getFileView or getFileDownload, it now builds that path, and aw_files maps old IDs to new paths. One file can be up to 64 MB. See Files.
Every file on Fortly is public to anyone with its URL. Fortly has no bucket permissions, no file-level permissions and no preview or transformation API. If a bucket holds private files, keep those somewhere else, behind a route in your app that checks who is asking.
Set your secrets
Copy your project variables and function variables to 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. 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. Once your code no longer calls Appwrite, drop your Appwrite endpoint, project ID and API key from the list too. 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.tgzYou can also connect a GitHub repository from the console. Deploying your code lists the frameworks Fortly runs. Appwrite Functions written in a language other than JavaScript or TypeScript have to be rewritten.
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 Appwrite, 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 TablesDB (or Databases) in your frontend become calls to the frontend data API, for data anyone may read. They use the project's publishable key from the Data → API tab:
// Before
const { rows } = await tablesDB.listRows({
databaseId: "main",
tableId: "posts",
queries: [Query.equal("published", true), Query.orderDesc("$createdAt"), Query.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 rows = await res.json();Your server code can use the server data API, or a PostgreSQL driver connected with the DATABASE_URL that Fortly gives your app. That means you can now write joins and aggregates in SQL that Appwrite's query API could not express.
Permissions do not carry over
Appwrite checks permissions like read("user:abc") or update("team:xyz") on every row. Fortly has nothing like that. The publishable key's rules open a whole table for read, create, update or delete, and every table starts closed. The export kept each row's permissions in a permissions column so you can see what they were. Enforce them in your server code, and keep any table with per-user rows closed to the publishable key.
Sign-in
Fortly does not sign your users in, manage sessions, or run OAuth. Either sign users in from your app, checking passwords against app_users with the algorithm in hash_algorithm, or move them to a hosted auth provider that can import Argon2 hashes. In both cases your users keep their passwords. Teams and memberships, if you used them, become tables of your own.
Functions, messaging and realtime
-
Functions you call over HTTP or with
createExecutionbecome routes in your app. Replace the SDK call with afetch. -
Scheduled functions become schedules:
Terminalcurl -X POST https://api.fortly.com/projects/my-app-ada/schedules \ -H "Authorization: Bearer $FORTLY_TOKEN" \ -H "Content-Type: application/json" \ -d '{"name": "weekly-report", "spec": "0 8 * * 1", "path": "/api/cron/report"}'Schedules use five cron fields, always in UTC, and are ordinary requests to your app. Protect the route with a shared secret if it should not be public.
-
Event-triggered functions have no equivalent. Run that logic in your app after the write, sign-up or upload that used to trigger it.
-
Messaging has no equivalent. Send email, SMS and push from your app through a provider of your choice, with its key stored as a secret.
-
Realtime has no equivalent. 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
- One project holds everything, as it did on Appwrite: the app, its database, its files, its domains and its schedules.
- Your database is PostgreSQL. You can query it with SQL, and export it as a SQL dump at any time.
- An idle project stops after 15 minutes and starts again on the next request. There is nothing to restore by hand.
- No auth, messaging, realtime, image previews, private files or per-row permissions. Each of these is covered above.
Things to check after moving
-
Row counts match. Compare each table with Appwrite's console:
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"}' -
Every file arrived.
select count(*) from aw_filesshould equal the number of files across your buckets. -
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.
-
Your app no longer loads the Appwrite SDK, and no URLs point at your Appwrite endpoint.
-
Keep the Appwrite project until you have run on Fortly for a while.