Backups
What holds state on a self-hosted Floot backend, Floot's backup recipe as the reference, restoring onto a new server, and the queue registry a restore must refill.
On your own server, backups are yours: nothing in the stack takes one. This page names what on the server holds state, offers Floot's recipe as the reference for backing it up, and restores a backup onto a new server.
Supabase's self-hosting docs ship no backup recipe, and upstream's update.sh
archives configuration only. Three things on the server hold state:
-
The database, in
volumes/db/data. -
The Storage files, in
volumes/storage. -
The pgsodium root key,
pgsodium_root.keyin thedb-configvolume, mounted at/etc/postgresql-custom. Vault encrypts every row with it. A database restored next to any other key cannot read its Vault rows, which fail withinvalid ciphertext. Postgres makes a random key on first start if none exists, so copy yours into your password manager right after the first start:Terminal (on the server) docker exec supabase-db cat /etc/postgresql-custom/pgsodium_root.keyThe stack's secrets in
.envneed the same care: keep them in a secret store, not only on the server.
Never remove the db-config volume
A docker compose down -v, or a cleanup that prunes volumes, deletes the
pgsodium key with it. Without a copy, every Vault row is lost, even with a
good database backup.
Floot's recipe
Floot's backup is the reference this page offers. Every run:
- Dumps the database with
pg_dump -Fc postgres, then the roles withpg_dumpall --globals-only, both inside the db container assupabase_admin. - Archives
volumes/storagewithtar | zstd, after the dump, so a file uploaded in between is a harmless orphan rather than a row with no file. - Encrypts each file with age to a public recipient on its way out of the pipe, so plaintext never touches the disk. The server holds only the recipient; the private key is kept off the server, so the server cannot read its own backups.
- Uploads each as a new, dated object in a bucket only this server writes to, with a manifest naming the stack's version, the Postgres image and each file's sha256. The server never deletes.
docker exec supabase-db pg_dump -U supabase_admin -d postgres -Fc | age -r "$AGE_RECIPIENT" -o postgres.dump.age
docker exec supabase-db pg_dumpall -U supabase_admin --globals-only | age -r "$AGE_RECIPIENT" -o globals.sql.age
tar -C volumes --xattrs --xattrs-include='user.*' -cf - storage | zstd -q | age -r "$AGE_RECIPIENT" -o storage.tar.zst.ageAround that core:
- Every 4 hours, so a lost server costs at most 4 hours of writes.
- Kept by lifecycle rules: intraday runs 7 days, the nightly run 30 days, the first of the month 90 days. Production's bucket carries a lock of the same length, so a stolen server token cannot erase history. Erased user data leaves the backups within 90 days.
- The pgsodium key and
.envstay out of the archive. Their copies live in the password manager and the secret store, so one leaked age key never exposes Vault plaintext. - A Restore check every night: before encrypting, the plaintext dump is
restored into a scratch
supabase/postgrescontainer of the running version, its queue registry refilled (below), and checked (roles present, row counts, a recent newest row); after the upload, the object is read back and its checksum compared. A broken dump fails the same night. - A Restore drill every quarter, by hand, on a second server: decrypt,
restore the database (refilling its queue registry, below), the Storage
files and the pgsodium key, and sign in.
It is the only proof that decryption and the runbook work. Before the rest
of the stack starts, it switches every
cron.joboff and points the Vault's URLs and webhook secret at the drill server, so the copy never calls production. - A dump by hand before an upgrade or a destructive migration, and the release's own dump before any push with pending migrations.
- A push to the watcher at the end of every run,
downwith the reason when a run fails; a run that never comes trips the monitor's deadline of 4 hours 30 minutes. Watching adds the same kind of monitor to the guide's nightly backup, as an option.
Restore
A restore puts the database and the Storage files back as they were when the backup ran, and discards everything written since: every sign-up, upload and change after that moment is gone, and an account deleted since comes back. Each deletion sends an "account deleted" email, so your mail provider's log names those deleted after the backup's time: delete each again. So it is a human decision, taken when the data is lost or damaged past fixing forward, never a step a script takes on its own.
To restore onto a new server, prepared as the first one was (its proxy and firewall in place, DNS still on the old address), work in this order:
-
The backup. Where the age private key is, compare each file's sha256 with the manifest, decrypt the three files, and copy them to the new server, into a directory only you can read (
~/restorebelow):Terminal shasum -a 256 postgres.dump.age globals.sql.age storage.tar.zst.age for f in postgres.dump globals.sql storage.tar.zst; do age -d -i age-key.txt -o "$f" "$f.age"; done -
The stack at the backup's tag. The manifest names the stack's version: install that release with
setup.sh --ref, as in The stack, and add your compose files. Start nothing yet. To prepare the new server with the step-by-step guide, follow its steps 1 to 8, with the manifest's tag in step 6. Leave out step 2 and step 8's checks: DNS still points at the old server until step 8 below, so Caddy gets its certificate only then. -
.envfrom the secret store, in place of the onesetup.shwrote. The restored roles' passwords and every key your clients and functions hold are the old server's, so the new server runs on the old.envfrom its first start. -
The pgsodium root key, before the first start. Postgres makes a new random key when it starts without one, so put yours into the
db-configvolume first, through a container of the stack's Postgres image. Compose names the volume after the project,supabasein the stock file:Terminal (on the server, in supabase-project) IMAGE=$(docker compose config --images | grep '^supabase/postgres:') docker volume create --label com.docker.compose.project=supabase \ --label com.docker.compose.volume=db-config supabase_db-config read -rs PGSODIUM_KEY printf '%s' "$PGSODIUM_KEY" | docker run --rm -i --network none --user postgres --entrypoint sh \ -v supabase_db-config:/etc/postgresql-custom "$IMAGE" \ -c 'umask 077 && cat >/etc/postgresql-custom/pgsodium_root.key' unset PGSODIUM_KEY -
The globals, then the dump. Load them into a fresh cluster, in a Postgres of the same image with no network and the stack's init scripts held back, so nothing the dump holds is created twice. Then run those scripts once: they create what no dump holds, the
_supabasedatabase and the database settings, and give the services' roles their passwords from.env. In the same shell as step 4:Terminal (on the server, in supabase-project) POSTGRES_PASSWORD=$(sed -n 's/^POSTGRES_PASSWORD=//p' .env) JWT_EXP=$(sed -n 's/^JWT_EXPIRY=//p' .env) \ docker run -d --name restore-db --network none --tmpfs /docker-entrypoint-initdb.d \ -e POSTGRES_PASSWORD -e JWT_EXP -v "$PWD/volumes/db/data:/var/lib/postgresql/data" \ -v "$PWD/volumes/db:/stack-db:ro" -v supabase_db-config:/etc/postgresql-custom \ "$IMAGE" postgres -D /etc/postgresql -c cron.launch_active_jobs=off until docker exec restore-db pg_isready -q -h localhost -U supabase_admin; do sleep 1; done grep -vx 'CREATE ROLE supabase_admin;' ~/restore/globals.sql | docker exec -i restore-db psql -U supabase_admin -d postgres -X -q -v ON_ERROR_STOP=1 docker exec -i restore-db pg_restore -U supabase_admin -d postgres --exit-on-error < ~/restore/postgres.dump printf '\\i /stack-db/%s\n' _supabase.sql logs.sql pooler.sql realtime.sql jwt.sql roles.sql | docker exec -i restore-db psql -U supabase_admin -d postgres -X -q -v ON_ERROR_STOP=1The fresh cluster already has
supabase_admin, so itsCREATE ROLEis the one line of the globals left out, andcron.launch_active_jobs=offkeeps the restored cron jobs from running while it loads. Then refill the queue registry in the same container, with the samepsqland the SQL on its input, and remove it:docker stop -t 120 restore-db && docker rm restore-db. -
The Storage archive, unpacked with the extended attributes Storage keeps each file's content type in. A new server may lack
zstd, so install it first:Terminal (on the server, in supabase-project) sudo apt-get update && sudo apt-get install -y zstd zstd -dc ~/restore/storage.tar.zst | sudo tar -C volumes --numeric-owner --xattrs --xattrs-include='user.*' -xf - -
The stack and the functions. If the old server still runs, stop its stack first (
docker compose stop, neverdown -v): the restored cron jobs start with the new stack, and until DNS moves they call your public host, which still answers from the old server. Thendocker compose up -d, and wait untildocker compose psshows every service healthy;restis healthy at once, since the restored database has its schemas. Deploy your functions as they were when the backup ran, as in Deploy. -
DNS, then the smoke check. Point the API host's record at the new server, and once your proxy holds its certificate, run the smoke check. When it passes, delete the plaintext files, in
~/restoreand wherever you decrypted them, and start backing up the new server.
After a restore, refill the queue registry
The cleanup outboxes are in every dump, pending messages included:
pgmq.q_storage_cleanup, pgmq.q_stripe_cleanup and their a_ archives. The
kit makes each queue with app.create_queue, which detaches its tables from
the pgmq extension, because pg_dump leaves an extension's own tables out.
Make any queue you add the same way; one made with pgmq.create alone is in
no backup.
pgmq keeps its registry of queues in one of its own tables, pgmq.meta, so no
dump carries it. A restored database has its queues and their messages, and
sends and reads work, but pgmq.list_queues() and pgmq.metrics_all() see no
queue. After pg_restore, refill the registry from the restored queue tables:
INSERT INTO pgmq.meta (queue_name, is_partitioned, is_unlogged)
SELECT substr(c.relname, 3), false, c.relpersistence = 'u'
FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname = 'pgmq' AND c.relkind = 'r' AND c.relname LIKE 'q\_%'
ON CONFLICT DO NOTHING;It adds one row per restored queue, and nothing on a database that already lists them, so it is safe to run twice.
Configuration
Every setting of a self-hosted Floot backend and where it lives: Supabase's setup and your own compose file, the config.toml to environment mapping, function secrets, the JWT switch and the Send Email hook.
Watching
Optional: a Telegram message when a self-hosted Floot backend breaks. Uptime Kuma beside Caddy, its monitors for the API, the certificate, the backups and the disk, and what a watcher on its own server cannot see.