Guide

Backing up Supabase, Neon and PlanetScale

Managed Postgres and MySQL providers give you PITR that lives entirely in their account. If the account goes, so does every recovery option. How to keep an independent copy.

6 min readBackup Data

Managed database providers have made backups feel solved. Supabase does point-in-time recovery, Neon has branching and history, PlanetScale has automatic backups. All of it works, and none of it needs configuring.

All of it also lives inside the provider account, which means one thing is true of every setup above:

If you lose access to the account, you lose the database and every way of getting it back, at the same instant.

Losing account access is not exotic. A payment method expires while someone is on holiday. A billing dispute suspends the project. An owner leaves and nobody else has admin. A team member deletes the wrong project. In every case the recovery feature you were relying on is behind the door that just closed.

There is a second, quieter problem: provider-native recovery is not portable. A Supabase PITR restores into Supabase. It is not a file you can load elsewhere, so it is no help at all if you want to leave, or if the provider has a prolonged incident.

The fix is a plain logical dump, taken on a schedule, stored somewhere unrelated. It takes about ten minutes.

Supabase

Use the direct connection, not the pooler. This is the mistake that wastes an afternoon. Supabase exposes a transaction-mode pooler (commonly on port 6543) and a direct connection (5432). pg_dump needs session-level features that transaction-mode pooling does not provide, and against the pooler it fails or produces something unusable.

Take the connection string from Project Settings → Database, and make sure you are using the direct or session-mode URI rather than the transaction pooler.

mkdir -p ./db-dumps

pg_dump \
  --format=custom \
  --no-owner --no-privileges \
  --file=./db-dumps/supabase.dump \
  "postgresql://postgres:[PASSWORD]@db.[PROJECT-REF].supabase.co:5432/postgres"

--no-owner and --no-privileges matter here: Supabase manages roles like supabase_admin and authenticator that will not exist wherever you restore. Without these flags the restore emits a wall of ownership errors.

Two things worth knowing:

  • Direct connections are IPv6 by default on many Supabase projects. If your backup host is IPv4-only, use the session-mode pooler URI or enable the IPv4 add-on — a connection that times out from CI but works from your laptop is usually this.
  • pg_dump captures the public schema and your data, not the whole platform. Auth users live in auth.users, storage objects live in the storage service, and Edge Function code lives in your repo. Add --schema=auth if you need users, and back up storage buckets separately — see the S3 guide for the same pattern against object storage.

Neon

Neon is standard Postgres with branching on top, so pg_dump works exactly as expected. Copy the connection string from the dashboard.

pg_dump \
  --format=custom \
  --no-owner --no-privileges \
  --file=./db-dumps/neon.dump \
  "postgresql://[USER]:[PASSWORD]@[ENDPOINT].neon.tech/[DB]?sslmode=require"

sslmode=require is not optional — Neon rejects unencrypted connections.

Branches are not backups. Neon's branching is excellent for development and for "let me try this migration against a copy," and a branch is a copy-on-write clone inside the same project. Delete the project, or lose the account, and every branch goes with it. Branch for workflow; dump for recovery.

Watch the compute suspend behaviour. Neon scales compute to zero on idle, so a scheduled dump against a cold endpoint pays a cold-start on connection. Not a problem — just do not set an aggressive connect timeout and then wonder why the 3am job fails intermittently.

PlanetScale

PlanetScale is MySQL on Vitess, and two of its design decisions change how you dump.

mysqldump \
  --single-transaction \
  --set-gtid-purged=OFF \
  --no-tablespaces \
  --ssl-mode=REQUIRED \
  -h [HOST] -u [USERNAME] -p[PASSWORD] [DATABASE] \
  > ./db-dumps/planetscale.sql
  • --single-transaction takes a consistent dump without locking tables.
  • --set-gtid-purged=OFF — without it, mysqldump writes GTID statements that fail on restore anywhere else.
  • --no-tablespaces — the dump user lacks the PROCESS privilege that tablespace introspection needs.

Foreign keys are typically absent. Vitess historically did not support them, and while support has been added, many PlanetScale schemas were designed without them and enforce relationships in the application. That is fine — just do not expect the dump to encode constraints that were never there, and be careful about restore ordering if you load into a database that does enforce them.

Branches and deploy requests are schema tooling, not backups. Same reasoning as Neon.

Get the dump off the provider

The dump now exists on whatever machine ran the command. That is not yet a backup.

Sign in at Backup Data, claim the free 5 GB workspace, mint an API key scoped backup:write, backup:read, snapshots:read, then:

npm install @lighthouse-web3/baas-js-sdk
export LH_API_KEY="lh_xxxxxxxxxxxxxxxxxxxxxxxx"
export LH_WORKSPACE_ID="your-workspace-uuid"
import { BackupClient } from "@lighthouse-web3/baas-js-sdk";

const client = new BackupClient({
  apiKey: process.env.LH_API_KEY,
  workspaceId: process.env.LH_WORKSPACE_ID,
});

const snapshot = await client.backup(["./db-dumps"], {
  description: "nightly managed-db dump",
  tags: { type: "db-backup", provider: "supabase", env: "prod" },
  encryption: {
    keyfilePath: "/secure/lh.keyfile",
    passphrase: process.env.LH_KEYFILE_PASSPHRASE,
  },
});

console.log(`snapshotId=${snapshot.snapshotId} totalSize=${snapshot.totalSize}`);

Keep the dump filename stable — overwrite supabase.dump every run. Content-defined chunking then ships only the delta, so a nightly dump of a database with modest churn uploads a fraction of its size. Dated filenames throw that away.

Encrypt, because a dump of an application database is the application's user data. AES-GCM on your machine, ciphertext on the server. You hold the keys — lose the keyfile and passphrase and the data is unrecoverable, with no server-side reset. Store it outside the provider account you are protecting against.

Prove it restores somewhere else

The whole point is a copy that works without the provider, so drill it against plain Postgres or MySQL — not against a new project at the same vendor.

import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";

const target = mkdtempSync(join(tmpdir(), "managed-db-drill-"));
await client.restore(snapshot.snapshotId, target, {
  onProgress: (e) => console.log(`[${e.phase}] ${e.current}/${e.total}`),
});
docker run -d --name pg_drill -e POSTGRES_PASSWORD=test postgres:16
sleep 5
docker exec -i pg_drill createdb -U postgres drill
docker exec -i pg_drill pg_restore -U postgres -d drill --no-owner \
  < "$TARGET/db-dumps/supabase.dump"
docker exec -i pg_drill psql -U postgres -d drill -c "SELECT count(*) FROM users;"

Expect a few ownership and extension warnings on the first run — that is the drill doing its job. Extensions in particular are worth checking: if your schema uses pgcrypto, postgis or pg_stat_statements, they need installing in the target before the restore will complete cleanly. Finding that out now is the entire value.

The drill checklist:

  • ✅ Restore into vanilla Postgres or MySQL, not the same provider
  • ✅ Install required extensions in the target first
  • ✅ Compare row counts against production
  • ✅ Confirm auth or user tables came across if you need them
  • ✅ Note anything living outside the database — storage buckets, functions, secrets
  • ✅ Record wall-clock time — that is your real RTO

Schedule and retain

# /etc/cron.d/managed-db-backup (nightly at 02:30)
30 2 * * * backup /opt/backup/db-backup.sh >> /var/log/db-backup.log 2>&1
const policy = {
  sourceId: snapshot.sourceId,
  keepLatest: 30,
  before: new Date(Date.now() - 90 * 86400_000).toISOString(),
};
await client.pruneSnapshots({ ...policy, dryRun: true });

Keep the provider features on

Nothing here argues for turning off PITR or branching. They are faster and more granular than a nightly dump, and for "someone deleted rows an hour ago" they are the right tool by a wide margin.

They just cannot survive the account they live in. The dump is the copy that does — and it is also the copy that lets you leave, which is worth having regardless of whether you ever intend to.

Start with the free tier: backupdata.io has 5 GB free, no card, and the 10-minute quickstart gets you to a first verified snapshot today.

Start with the free tier

5 GB free, no card required. Point your existing dump at Backup Data and get to a first verified snapshot in about ten minutes.

Read the quickstart

Keep reading

Guide · 7 minAutomating backups with GitHub Actions (and when not to)Guide · 6 minBack up model checkpoints before your spot instance disappearsGuide · 7 minBacking up a Linux server without backing up the whole disk