Moving from PlanetScale
Move a PlanetScale database to Fortly's PostgreSQL, converting it from MySQL with pgloader if needed, and deploy the app beside it.
This guide is for you if your database is on PlanetScale and you want it and the app that uses it in one Fortly project. PlanetScale offers two engines, and the move depends on which one you use. A PlanetScale Postgres database comes across with a pg_dump. A PlanetScale MySQL (Vitess) database has to be converted to PostgreSQL first, because Fortly's database is PostgreSQL and nothing on Fortly speaks MySQL. This page converts it with pgloader, a third-party open-source tool, then covers what changes in your app's SQL.
Check that Fortly can run your app before you move the database. Fortly runs JavaScript and TypeScript apps (Next.js, Nuxt, SvelteKit, Astro, Express, NestJS, Hono and others), and static sites. It does not run PHP or Ruby apps, or ordinary Python, Go or Rust servers. A Laravel, Rails or Django app on PlanetScale cannot move to Fortly. See Deploying your code for the full list.
What maps to what
| PlanetScale | On Fortly | What changes |
|---|---|---|
| Database (Postgres) | Your project's PostgreSQL database | Same engine. You load it from a SQL dump. |
| Database (MySQL / Vitess) | Your project's PostgreSQL database | Converted to PostgreSQL. Your app's raw SQL and its driver change. |
| Connection string and passwords | DATABASE_URL, set by Fortly for your app | You don't manage database passwords. There is no public address to connect to from outside. |
| Branches and deploy requests | Nothing built in | Change the schema with your app's migrations or through /sql, against the one database. |
| Sharding (Vitess keyspaces) | Nothing built in | One database per project, on a shared server with per-database limits. |
| Your app, hosted elsewhere | Deploying | The app moves into the same project as its database. |
PlanetScale never handled your users' sign-in or files, so there is no auth or storage to move. Your users are ordinary rows in your own tables, and they come across with everything else.
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.- For a MySQL database, also: the
pscaleCLI (signed in withpscale auth login), Docker,myloaderfrom the mydumper project, and pgloader. All three are third-party tools. Install them with your package manager, for examplebrew install planetscale/tap/pscale mydumper pgloader.
The import takes a SQL script up to 256 MB and runs it in one transaction, and each statement must finish within 30 seconds. A larger database can be imported as several scripts, for example one per group of tables.
Export your database as PostgreSQL
Create a role for the export under your database's Connect page in PlanetScale, and copy its connection string. Then dump the public schema:
export PLANETSCALE_URL='postgresql://USER:PASSWORD@HOST:5432/postgres?sslmode=verify-full'
pg_dump "$PLANETSCALE_URL" \
--schema=public \
--no-owner --no-privileges \
--no-publications --no-subscriptions \
--inserts \
--file=planetscale.sqlYour local pg_dump must be the same major version as the database or newer. --inserts is required: the import runs SQL statements, and a default dump's COPY … FROM stdin blocks are not statements it can run. --no-owner --no-privileges leaves out PlanetScale's roles, which don't exist on Fortly.
The conversion runs on your machine. You load PlanetScale's dump into a local MySQL, let pgloader copy it into a local PostgreSQL, check the result there, and dump that.
1. Dump the database from PlanetScale. This writes a directory of SQL files, a schema file and data files per table:
pscale database dump my-db main --output ./ps-dump2. Start a local MySQL and PostgreSQL. They are only staging, and you delete them when you are done:
docker run -d --name stage-mysql -p 3307:3306 \
-e MYSQL_ROOT_PASSWORD=staging -e MYSQL_DATABASE=my_db \
mysql:8.0 --default-authentication-plugin=mysql_native_password
docker run -d --name stage-postgres -p 5433:5432 \
-e POSTGRES_PASSWORD=staging \
postgres:16The native password plugin is there because many pgloader builds cannot sign in with MySQL 8's default authentication method.
3. Load the dump into MySQL. PlanetScale's dump files are in mydumper's format, so myloader reads them:
myloader --directory ./ps-dump \
--host 127.0.0.1 --port 3307 --user root --password staging \
--database my_db4. Convert it with pgloader. Save this as convert.load:
LOAD DATABASE
FROM mysql://root:staging@127.0.0.1:3307/my_db
INTO postgresql://postgres:staging@127.0.0.1:5433/postgres
WITH include drop, create tables, create indexes, reset sequences, foreign keys
ALTER SCHEMA 'my_db' RENAME TO 'public';pgloader convert.loadpgloader puts each MySQL database in a PostgreSQL schema of the same name by default. The ALTER SCHEMA line puts your tables in public instead, which is where Fortly's data API looks. pgloader prints a summary table at the end, and its row counts should match MySQL's.
5. Check the result, and fix it while you can still connect. This local PostgreSQL is the last copy you can open with psql, so do your review here:
psql "postgresql://postgres:staging@127.0.0.1:5433/postgres" -c '\d+ users'See What the conversion changes for what to look for.
6. Dump the converted database:
pg_dump "postgresql://postgres:staging@127.0.0.1:5433/postgres" \
--schema=public \
--no-owner --no-privileges \
--inserts \
--file=planetscale.sql--inserts is required: the import runs SQL statements, and a default dump's COPY … FROM stdin blocks are not statements it can run.
Either way, remove the line that creates the public schema if the dump has one, because the schema already exists on Fortly:
perl -0777 -pi.bak -e 's/^CREATE SCHEMA public;\n//gm' planetscale.sqlMove to Fortly
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.
Import the dump
curl -X POST https://api.fortly.com/projects/my-app-ada/import \
-H "Authorization: Bearer $FORTLY_TOKEN" \
--data-binary @planetscale.sql{ "imported": "statements", "ran": 9312, "skipped": 14, "tables": 9 }skipped counts the dump's session settings and its own BEGIN/COMMIT, which the import leaves out on purpose. If a statement is refused, nothing is applied and the reply's error names it. Fix that statement and send the script 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.
A create extension line either works or stops the import and names itself. Fortly installs no extension for you.
Point your app at PostgreSQL
Fortly gives your app its database connection as DATABASE_URL, plus PGHOST, PGPORT, PGDATABASE, PGUSER, PGPASSWORD and PGSSLMODE. Read those instead of your PlanetScale credentials, and switch your driver:
| You use | Change |
|---|---|
| Prisma | provider = "postgresql" and url = env("DATABASE_URL") in the datasource. Remove relationMode = "prisma" if you want PostgreSQL to enforce your foreign keys. Then create a fresh baseline migration, because MySQL migrations don't apply to PostgreSQL. |
| Drizzle | Import tables from drizzle-orm/pg-core instead of mysql-core, and use a PostgreSQL driver. |
| Knex, Sequelize, TypeORM, Kysely | Switch the client or dialect to PostgreSQL. |
mysql2 or @planetscale/database directly | Switch to a PostgreSQL driver such as pg, and review every query (see below). |
See Using PostgreSQL directly for connecting and pooling.
Set your secrets
Copy your app's other environment values to the project:
curl -X POST https://api.fortly.com/projects/my-app-ada/secrets \
-H "Authorization: Bearer $FORTLY_TOKEN" \
-H "Content-Type: application/json" \
-d '{"name": "SESSION_SECRET", "value": "..."}'Names use capital letters, digits and underscores. Add "scope": "both" to a value your build also reads. Setting a secret restarts the project. Leave out DATABASE_URL, DATABASE_HOST, DATABASE_USERNAME and DATABASE_PASSWORD from PlanetScale. Fortly refuses secrets named DATABASE_URL or PG* because it sets those itself. 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. See Deploying your code.
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 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.
What the conversion changes
This section is about MySQL databases only. A PlanetScale Postgres database arrives as it was.
Types
pgloader's default conversions are sensible, but check these in the staging PostgreSQL:
| MySQL | Becomes | Check |
|---|---|---|
AUTO_INCREMENT | A sequence-backed integer key | reset sequences sets each sequence past the highest imported id. |
TINYINT(1) | boolean | Code that compares to 1 or 0 now compares to true or false. |
DATETIME, TIMESTAMP | timestamptz | MySQL's DATETIME has no time zone. Check the times came out in the zone you meant. |
'0000-00-00' dates | null | Count them first in MySQL, so you know how many rows changed. |
ENUM(…) | A PostgreSQL enum type | Adding a value later is alter type … add value. |
| Unsigned integers | The next larger signed type | Usually invisible. |
Case-insensitive comparisons
This one fails silently. MySQL's default collations ignore case, so WHERE email = 'Ada@Example.com' finds a row stored as ada@example.com. PostgreSQL compares text exactly, so the same query finds nothing, and a user who typed their email differently can't sign in.
Fix it in the staging database, before you dump:
-- Store one form...
update users set email = lower(email);
-- ...and make that form unique.
create unique index users_email_lower on users (lower(email));Then compare with lower(email) = lower($1) in your code. Do the same for usernames, slugs and any other column you look things up by. If lowering the emails reports a duplicate, two accounts differ only by case. Decide which one to keep before you go on.
LIKE is case-sensitive in PostgreSQL too. Use ILIKE where you relied on MySQL ignoring case.
Raw SQL
An app that goes through an ORM usually has only a few of these. One built on hand-written SQL has many, so search your code for each:
| MySQL | PostgreSQL |
|---|---|
`name` | "name", or no quotes at all |
? placeholders (mysql2) | $1, $2 (pg) |
LIMIT 20, 10 | LIMIT 10 OFFSET 20 |
IFNULL(a, b) | COALESCE(a, b) |
GROUP_CONCAT(x) | string_agg(x, ',') |
INSERT … ON DUPLICATE KEY UPDATE | INSERT … ON CONFLICT (key) DO UPDATE |
LAST_INSERT_ID() | INSERT … RETURNING id |
DATE_FORMAT(d, '%Y-%m') | to_char(d, 'YYYY-MM') |
NOW() in a DATETIME column | now(), which returns a timestamptz |
What's different
- Your app and database are in one project. Deleting the project deletes both.
- Only your app connects to the database. It gets a connection string, but there is no public address for a desktop client or
psql. You load data with the import, and read it with the console, the query endpoint, or an export. - No branches, no deploy requests, no sharding. Schema changes run against the one database, from your app's migrations or through
/sql. Databases share a server, with a small connection limit per database (10 by default), so keep your app's pool small. See Limits. - Foreign keys are enforced if your schema declares them. PlanetScale MySQL often ran without them. Data that was inconsistent there stops the import when the constraint is added.
- An idle project stops after 15 minutes and starts again on the next request.
Things to check after moving
-
Row counts match. Compare each table with PlanetScale:
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 users"}' -
A new row gets a new id. Insert one into a busy table and check it doesn't collide with an imported row.
-
Sign-in works for a user whose email has capitals in it. This is how the collation change shows up.
-
Money and decimal columns kept their precision. Spot-check a few values against PlanetScale.
-
Times are in the right zone. Compare a few
created_atvalues against PlanetScale. -
Your code has no MySQL left in it. Search for backticks,
IFNULL,GROUP_CONCATandON DUPLICATE KEY. -
Remove the staging containers with
docker rm -f stage-mysql stage-postgresonce you are done. -
Keep the PlanetScale database until you have run on Fortly for a while.