How to back up self-hosted Supabase (Postgres and Storage) and test the restore
This guide is for people running Supabase from the official Docker Compose setup. Supabase’s docs list managed backups and PITR among the platform features you don’t get when you self-host, and the stack’s update.sh copies configuration files, not Postgres or Storage data. You end up with two scripts: one writes an encrypted backup of the whole stack to an S3-compatible bucket every night, the other restores the newest backup into a throwaway container and checks it.
We ran every command on this page against self-hosted/v0.8.3, the current release on October 18, 2026: supabase/postgres:17.6.1.136 and storage-api v1.81.0 with the default file backend. The shell was Ubuntu 24.04 with GNU tar 1.35 and rclone 1.60, talking to Docker Engine 29.4. The test project had one Auth user, a notes table with 1,000 rows behind a row level security policy, a profiles table with a foreign key to auth.users, one Vault secret and two Storage files. For the last round we added two pg_cron jobs, a custom max_connections setting and a third file stored without a version id. We backed it up, deleted the stack with docker compose down -v, rebuilt it from the backup in an empty directory and signed in as the same user. The one line we didn’t run is the crontab entry.
The short version
- Databases
- pg_dumpall --globals-only for the roles, then pg_dump -Fc --create for each database, as supabase_admin
- db-config volume
- pgsodium_root.key (the Vault key) and conf.d, in one tar file
- Storage
- tar of volumes/storage with --xattrs, which hold each file's content type
- Config
- .env and the rest of the deployment directory
- Off the server
- rclone with a crypt remote on any S3-compatible bucket
- Restore test
- Same Postgres image with Supabase's init scripts switched off; two "already exists" errors are normal
Checked October 18, 2026.
What a full backup has to include
A self-hosted stack keeps its state in six places, and a database dump covers two of them. Miss one and the restore looks fine until someone needs the part that didn’t come back.
| What | Where it lives | Why it matters |
|---|---|---|
| Roles | The Postgres server, outside every database | Auth, Storage and PostgREST log in as their own roles, Realtime and Supavisor as supabase_admin. The role dump keeps the password hashes. |
| postgres database | volumes/db/data | Your tables, plus auth.users, storage.objects (the file metadata) and the Vault secrets. |
| _supabase database | volumes/db/data | Supavisor's own tables, and Logflare's if you turned logging on. Supavisor connects to it at startup. |
| Vault key and Postgres settings | pgsodium_root.key and conf.d in the db-config Docker volume | Vault secrets don't decrypt without the key, and if it's missing the image writes a new one and Postgres starts anyway. conf.d holds the Postgres settings you added. |
| Storage files | volumes/storage | The files themselves. Each file's content type is an extended attribute on the file. |
| .env and config | The deployment directory | In the default setup JWT_SECRET signs your API keys and every user session. Also POSTGRES_PASSWORD, compose overrides and the Edge Function code in volumes/functions. |
Step 1: Dump the roles and every database
Run these from the deployment directory, the one with docker-compose.yml. They use the pg_dump inside the db container, so there is no port to open and the client always matches the server, 17.6 here. In the default stack Postgres isn’t published on the host at all; Supavisor holds port 5432. umask 077 makes every file you write in this shell readable only by you, which matters for all four steps: they produce password hashes, the Vault key and .env.
$ umask 077
$ docker compose exec -T db pg_dumpall -U supabase_admin --globals-only > globals.sql
$ docker compose exec -T db psql -U supabase_admin -d postgres -Atc \
"select datname from pg_database where not datistemplate"
postgres
_supabase
$ docker compose exec -T db pg_dump -U supabase_admin -Fc --create -d postgres > postgres.dump
$ docker compose exec -T db pg_dump -U supabase_admin -Fc --create -d _supabase > _supabase.dump-T turns off the pseudo-terminal that docker compose exec allocates by default, which would mangle a binary dump on its way to the file. supabase_admin is the superuser the image creates; the postgres role isn’t one on self-hosted Supabase either, because the image demotes it during setup. Inside the container supabase_admin connects over the Unix socket without a password, and the restore needs a superuser anyway, to create the extensions and event triggers and to own Supabase’s objects.
Why not one pg_dumpall file?
pg_dumpall writes the roles and every database into one SQL file, and Supabase’s Postgres 17 upgrade guide suggests it for an optional extra copy before upgrading. As a restore it has a gap. It assumes the postgres database already exists on the target and only restores what’s inside it, so the database’s owner, its grants and its settings stay behind. We restored a pg_dumpall file into a fresh server and got one harmless error and all 1,000 notes. The postgres database, though, now belonged to supabase_admin, had no grants and had lost its app.settings.jwt_exp setting. Logged in as postgres, the first create schema failed:
ERROR: permission denied for database postgresWith --create, pg_dump puts the owner, the grants and the settings into each database’s archive. Restored that way they matched the source exactly, and the same create schema worked.
Step 2: Copy the Vault key and your Postgres settings
docker compose exec -T db tar -C /etc/postgresql-custom -cf - pgsodium_root.key conf.d > db-config.tarVault and pgsodium get their root key from the same script in the image, and that script reads pgsodium_root.key from the db-config named volume. If the file isn’t there, the script writes a new random key and Postgres starts as if nothing happened. You find out when you read a secret. Our restore without the key answered select name, decrypted_secret from vault.decrypted_secrets with:
ERROR: pgsodium_crypto_aead_det_decrypt_by_id: invalid ciphertextSupabase’s Postgres 17 upgrade guide warns that losing this key makes Vault secrets unrecoverable, and that copying volumes/db/data doesn’t include it. Copy it even if you have never used Vault.
The same volume holds conf.d, where that guide tells you to put custom Postgres settings such as max_connections, next to a few files the image ships. Ours held a max_connections = 150 we had added; after the restore the new server reported 150 too. The whole tar file was 7 KB.
Step 3: Archive the Storage files
tar --xattrs --xattrs-include='user.*' -czf storage.tar.gz -C volumes storageWith the default file backend, Storage writes each object to this path. The first two parts are stub/stub unless you changed GLOBAL_S3_BUCKET or STORAGE_TENANT_ID in .env:
volumes/storage/<GLOBAL_S3_BUCKET>/<STORAGE_TENANT_ID>/<bucket>/<path>/<version>The last part is the version column of storage.objects, so the files and the database rows have to come from the same backup to line up.
When Storage serves a file from this backend, it takes the content type and cache-control from extended attributes on the file, not from storage.objects:
$ getfattr -d volumes/storage/stub/stub/avatars/readme.txt/*
# file: volumes/storage/stub/stub/avatars/readme.txt/c2949098-ca96-4dae-8fe9-56336ca97c83
user.supabase.cache-control="no-cache"
user.supabase.content-type="text/plain"Without them a file still downloads, but as application/octet-stream. We removed both attributes from a restored text file, and Storage served it as application/octet-stream instead of text/plain until we extracted the archive again with --xattrs. GNU tar only keeps them with that flag. We checked the usual copy commands too: cp -r and rsync -a drop them, cp -a and rsync -aX keep them.
The script dumps the database before it archives the files. A file uploaded in between ends up in the archive with no row pointing at it, which does no harm. A file deleted in between is missing from the archive while its row is still in the dump, and the restore test in step 6 reports it.
If you moved Storage to the S3 backend, volumes/storage stays empty and the files live in that bucket. Copy the bucket separately; this guide covers the file backend only.
Step 4: Keep .env and the rest of the directory
tar --exclude=./volumes/db/data --exclude=./volumes/storage -czf config.tar.gz .This picks up .env, docker-compose.yml and any overrides, the Edge Functions in volumes/functions, the gateway config, and the configuration backups update.sh leaves in backups/. The two excluded paths are covered by steps 1 and 3. volumes/db/data is the live Postgres data directory, and a copy of it taken while Postgres runs isn’t one you can rely on.
.env matters more than its size suggests. In the default setup JWT_SECRET signs the anon and service role keys your apps ship with and every session Auth hands out, so a rebuild with a new secret signs every user out and breaks every client. Keep one more copy of .env in your password manager. The backup in the bucket is encrypted with a passphrase stored on the server, and when the server is gone, that passphrase has to come from somewhere else.
Step 5: Encrypt the backup and ship it off the server
rclone does both. A crypt remote encrypts file names and contents on the way out and sits on top of a plain S3 remote, so the bucket only ever holds ciphertext. That matters here: the backup contains every role’s password hash, the Vault key and .env. Set up the two remotes once. The read line prompts for the secrets without echoing them or saving them in your shell history:
# first prompt: the bucket's secret key; second: a passphrase you keep in your password manager
read -rs S3_SECRET && read -rs CRYPT_PASS
rclone config create bucket s3 provider=Other \
endpoint="https://<your-s3-endpoint>" access_key_id="<access-key-id>" \
secret_access_key="$S3_SECRET" > /dev/null
rclone config create offsite crypt remote="bucket:<bucket-name>/supabase" password="$CRYPT_PASS" --obscure > /dev/null
unset S3_SECRET CRYPT_PASSprovider=Other works with any S3-compatible endpoint; we tested against a local one. To create the bucket and a key limited to it, follow the bucket steps in the Cloudflare R2, AWS S3 or Backblaze B2 guide.
rclone config create prints the remote it just wrote, bucket key included, so its output goes to /dev/null. --obscure makes sure the passphrase is stored obscured; without it, rclone can mistake a long passphrase for one that already is. While each command runs, its arguments show up in the process list, so on a machine other people log in to, run rclone config and answer its prompts instead. Either way the result is ~/.config/rclone/rclone.conf, readable only by you. It holds the bucket key in plain text and the passphrase obscured, which is not encryption: anyone who can read that file can recover both.
Then the backup itself, steps 1 to 4 in one script. Save it as self-hosted-supabase-backup.sh in the deployment directory:
Download self-hosted-supabase-backup.sh
#!/usr/bin/env bash
# self-hosted-supabase-backup.sh: back up a self-hosted Supabase stack (Docker Compose)
# and copy it, encrypted, to an S3-compatible bucket.
# From https://backupdrill.com/guides/self-hosted-supabase-backup (MIT licensed).
#
# Run it from the directory with docker-compose.yml. Needs rclone with a remote
# named "offsite": a crypt remote over your bucket, set up as the guide shows.
set -euo pipefail
umask 077 # the output holds password hashes, the Vault key and .env
STAMP=$(date -u +%Y-%m-%dT%H%M%SZ)
OUT="$HOME/supabase-backups/$STAMP"
mkdir -p "$HOME/supabase-backups"
mkdir "$OUT" # fails instead of mixing two runs in one directory
# 1. Roles and their password hashes. They belong to the server, not to a database.
docker compose exec -T db pg_dumpall -U supabase_admin --globals-only > "$OUT/globals.sql"
# 2. Every database. --create keeps each database's owner, grants and settings.
DATABASE_NAMES=$(docker compose exec -T db psql -U supabase_admin -d postgres -Atc \
"select datname from pg_database where not datistemplate")
mapfile -t DATABASES <<< "$DATABASE_NAMES"
for db in "${DATABASES[@]}"; do
# the name becomes a file name; anything unusual stops the backup instead
if [[ ! "$db" =~ ^[A-Za-z0-9_-]+$ ]]; then echo "unexpected database name: $db" >&2; exit 1; fi
docker compose exec -T db pg_dump -U supabase_admin -Fc --create -d "$db" > "$OUT/$db.dump"
done
# 3. From the db-config volume: the root key Vault encrypts secrets with, and conf.d,
# where custom Postgres settings go.
docker compose exec -T db tar -C /etc/postgresql-custom -cf - pgsodium_root.key conf.d > "$OUT/db-config.tar"
# 4. Storage files. Each file's content type and cache-control are stored in xattrs.
# storage-path.txt records where in the archive Storage keeps them, as Storage sees it.
tar --xattrs --xattrs-include='user.*' -czf "$OUT/storage.tar.gz" -C volumes storage
docker compose exec -T storage sh -c 'echo "$GLOBAL_S3_BUCKET/$TENANT_ID"' > "$OUT/storage-path.txt"
# 5. Everything else: .env, compose files, function code, gateway config.
tar --exclude=./volumes/db/data --exclude=./volumes/storage -czf "$OUT/config.tar.gz" .
(cd "$OUT" && sha256sum -- *.sql *.dump *.tar *.txt *.tar.gz > SHA256SUMS)
# 6. Encrypted copy off the server; keep a week of local copies for quick restores.
rclone copy "$OUT" "offsite:$STAMP"
find "$HOME/supabase-backups" -mindepth 1 -maxdepth 1 -type d -mtime +7 -exec rm -rf {} +
echo "backup $STAMP done: $(du -sh "$OUT" | cut -f1)"Run it once by hand and look at the bucket. The second listing is what your storage provider sees:
$ chmod +x self-hosted-supabase-backup.sh
$ ./self-hosted-supabase-backup.sh
backup 2026-10-18T140107Z done: 900K
$ rclone ls offsite:2026-10-18T140107Z
10 storage-path.txt
301817 storage.tar.gz
13287 _supabase.dump
154206 config.tar.gz
419945 postgres.dump
7168 db-config.tar
563 SHA256SUMS
7291 globals.sql
$ rclone ls bucket:supabase-backups
301827 supabase/3o47ibrpqpmbrdss72v5qv75q8d6tp3cufaj57q797sivsshhis0/6k5bttrnd49rq4dolsjjklaif0
13335 supabase/3o47ibrpqpmbrdss72v5qv75q8d6tp3cufaj57q797sivsshhis0/81jc1uh5n1jqstnhlo6b4sa5kc
112 supabase/3o47ibrpqpmbrdss72v5qv75q8d6tp3cufaj57q797sivsshhis0/cseh3ddh88s15vmpqeb4kcj69oapfe6r1fkcljbb1uk3otj6ting
...Our test backup came to 900 KB, most of it the postgres dump and the Storage files. To run it every night, add a line with crontab -e as the user that runs docker compose, with your own path:
15 3 * * * cd /root/supabase-project && ./self-hosted-supabase-backup.sh >> "$HOME/supabase-backup.log" 2>&1Check the log after the first night. Cron runs with a short PATH, so docker and rclone need to be in /usr/bin or /bin. The script keeps a week of copies on the server. For the bucket, add a lifecycle rule that deletes objects older than the number of days you want to keep; the three bucket guides above show where that setting is. The script sets umask 077, so the copies it keeps on the server are readable only by the user that runs it.
Step 6: Restore-test the backup in a throwaway container
This script downloads the newest backup, restores it into a temporary Postgres container, compares row counts with the live database and checks every Storage file. On our 900 KB test backup it took about four seconds. It needs getfattr, which on Ubuntu comes with the attr package. Save it next to the backup script, and keep IMAGE in step with the db image in docker-compose.yml:
Download self-hosted-supabase-restore-drill.sh
#!/usr/bin/env bash
# self-hosted-supabase-restore-drill.sh: restore the newest off-site backup into a
# throwaway Postgres container and check it against the live stack.
# From https://backupdrill.com/guides/self-hosted-supabase-backup (MIT licensed).
#
# Run it from the directory with docker-compose.yml; the live stack is only read.
# Needs rclone (remote "offsite") and getfattr (Ubuntu: apt install attr).
# Exits with 1 when a check fails.
set -euo pipefail
IMAGE=supabase/postgres:17.6.1.136 # the db image tag from your docker-compose.yml
umask 077
DRILL=$(mktemp -d)
# Names unique to this run, so cleanup never touches another run's container
NAME="supabase-drill-$(basename "$DRILL" | tr -c 'A-Za-z0-9\n' '-')"
cleanup() {
docker rm -f "$NAME" > /dev/null 2>&1 || true
docker volume rm "$NAME-config" > /dev/null 2>&1 || true
rm -rf "$DRILL"
}
trap cleanup EXIT
LATEST=$(rclone lsf --dirs-only offsite: | sort | tail -n 1)
LATEST=${LATEST%/}
echo "drilling backup $LATEST"
rclone copy "offsite:$LATEST" "$DRILL"
if ! (cd "$DRILL" && sha256sum --check --quiet SHA256SUMS); then echo "DRILL FAILED: checksums"; exit 1; fi
# A config volume with the backed-up Vault key and conf.d, owned by the image's postgres user
docker volume create "$NAME-config" > /dev/null
docker run --rm -i -v "$NAME-config:/etc/postgresql-custom" --entrypoint sh "$IMAGE" -c \
'cd /etc/postgresql-custom && tar -xf - && chown -R postgres:postgres pgsodium_root.key conf.d && chmod 640 pgsodium_root.key' \
< "$DRILL/db-config.tar"
# Same image, empty init directory: the dump brings every Supabase schema with it.
# No network and no pg_cron jobs, so restored jobs and pg_net calls can't reach anything.
docker run -d --name "$NAME" --network none --tmpfs /docker-entrypoint-initdb.d \
-v "$NAME-config:/etc/postgresql-custom" -e POSTGRES_PASSWORD=drill-only \
"$IMAGE" postgres -c config_file=/etc/postgresql/postgresql.conf \
-c cron.launch_active_jobs=off > /dev/null
for _ in $(seq 90); do
docker exec "$NAME" pg_isready -q -h localhost -U supabase_admin -d postgres 2> /dev/null && break
if [ "$(docker inspect -f '{{.State.Running}}' "$NAME")" != true ]; then break; fi
sleep 2
done
if ! docker exec "$NAME" pg_isready -q -h localhost -U supabase_admin -d postgres 2> /dev/null; then
docker logs --tail 30 "$NAME"; echo "DRILL FAILED: the drill database did not start"; exit 1
fi
FAILED=0
# Roles first. One error is expected: the image already created supabase_admin.
if ! docker exec -i "$NAME" psql -q -U supabase_admin -d template1 \
< "$DRILL/globals.sql" 2> "$DRILL/globals.log"; then
echo "restoring roles failed:"; cat "$DRILL/globals.log"; FAILED=1
else
UNEXPECTED=$(grep 'ERROR' "$DRILL/globals.log" | grep -v 'role "supabase_admin" already exists' || true)
if [ -n "$UNEXPECTED" ]; then echo "unexpected errors restoring roles:"; echo "$UNEXPECTED"; FAILED=1; fi
fi
# Then each database. postgres.dump may fail exactly once, on CREATE DATABASE,
# because the image created that database too; pg_restore then exits with 1.
for dump in "$DRILL"/*.dump; do
log="$dump.log"
status=0
docker exec -i "$NAME" pg_restore -U supabase_admin -d template1 --create < "$dump" 2> "$log" || status=$?
errors=$(grep -c '^pg_restore: error:' "$log" || true)
if [ "$status" -eq 0 ] && [ "$errors" -eq 0 ]; then
continue
elif [ "$status" -eq 1 ] && [ "$errors" -eq 1 ] && [ "$(basename "$dump")" = postgres.dump ] \
&& grep -q 'database "postgres" already exists' "$log"; then
continue
fi
echo "restoring $(basename "$dump") failed (exit $status):"; cat "$log"; FAILED=1
done
echo "restore: $([ "$FAILED" -eq 0 ] && echo "only the 2 expected errors" || echo "FAILED")"
# Exact row counts in public, auth and storage, live versus restored
COUNTS="select table_schema || '.' || table_name, (xpath('/row/n/text()', query_to_xml(
format('select count(*) as n from %I.%I', table_schema, table_name), false, true, '')))[1]::text
from information_schema.tables
where table_type = 'BASE TABLE' and table_schema in ('public', 'auth', 'storage') order by 1"
docker compose exec -T db psql -U supabase_admin -d postgres -AtF ' ' -c "$COUNTS" > "$DRILL/counts-live.txt"
docker exec "$NAME" psql -U supabase_admin -d postgres -AtF ' ' -c "$COUNTS" > "$DRILL/counts-restored.txt"
if diff "$DRILL/counts-live.txt" "$DRILL/counts-restored.txt"; then
echo "row counts match: $(wc -l < "$DRILL/counts-restored.txt") tables"
else
echo "row counts differ (< live, > restored): check each difference against writes since the backup"
fi
# Vault secrets decrypt only with the original key
if docker exec "$NAME" psql -U supabase_admin -d postgres -Atc \
"select count(*) from vault.decrypted_secrets where decrypted_secret is not null" > "$DRILL/vault.txt"; then
echo "vault secrets readable: $(cat "$DRILL/vault.txt")"
else
echo "vault secrets do not decrypt: wrong or missing pgsodium_root.key in db-config.tar"; FAILED=1
fi
# Every object in storage.objects has its file, with the right size and content type
tar --xattrs --xattrs-include='user.*' -xzf "$DRILL/storage.tar.gz" -C "$DRILL"
ROOT="$DRILL/storage/$(cat "$DRILL/storage-path.txt")"
docker exec "$NAME" psql -U supabase_admin -d postgres -AtF $'\t' -c \
"select bucket_id || '/' || name || coalesce('/' || version, ''), metadata->>'size', metadata->>'mimetype' from storage.objects" \
> "$DRILL/objects.tsv"
while IFS=$'\t' read -r key size type; do
file="$ROOT/$key"
if [ ! -f "$file" ]; then echo "missing file: $key"
elif [ "$(stat -c %s "$file")" != "$size" ]; then echo "wrong size: $key"
elif [ "$(getfattr --only-values -n user.supabase.content-type "$file" 2> /dev/null)" != "$type" ]; then
echo "content type lost: $key"
fi
done < "$DRILL/objects.tsv" > "$DRILL/file-problems.txt"
if [ -s "$DRILL/file-problems.txt" ]; then cat "$DRILL/file-problems.txt"; FAILED=1; fi
echo "storage objects checked: $(wc -l < "$DRILL/objects.tsv"), problems: $(wc -l < "$DRILL/file-problems.txt")"
if [ "$FAILED" -eq 0 ]; then echo "DRILL PASSED"; else echo "DRILL FAILED"; fi
exit "$FAILED"The part that makes this work is the --tmpfs over /docker-entrypoint-initdb.d. A new supabase/postgres container normally runs Supabase’s init scripts and creates the auth and storage schemas, the roles and the extensions, and the dump would then try to create every one of them a second time. With an empty init directory the container is a plain Postgres server that still has Supabase’s extensions available, and the dump brings every schema with it. It also starts with --network none and pg_cron jobs switched off: the dump carries your scheduled jobs, and a job that calls a webhook through pg_net would otherwise fire from the copy. Restoring prints two errors, and both are expected:
ERROR: role "supabase_admin" already exists
pg_restore: error: could not execute query: ERROR: database "postgres" already exists
Command was: CREATE DATABASE postgres WITH TEMPLATE = template0 ENCODING = 'UTF8' LOCALE_PROVIDER = icu LOCALE = 'en_US.UTF-8' ICU_LOCALE = 'en-US';The server creates both of them when it starts. pg_restore carries on after the failed CREATE DATABASE, connects to the existing postgres database and restores into it, owner, grants and settings included. The script accepts exactly these two and fails on anything else, including a pg_restore that exits for any other reason. The backup also records where Storage keeps its files, in storage-path.txt, as the running Storage container sees it, so the drill doesn’t have to parse .env. A passing run on our test backup:
drilling backup 2026-10-18T140107Z
restore: only the 2 expected errors
row counts match: 40 tables
vault secrets readable: 1
storage objects checked: 3, problems: 0
DRILL PASSEDRow count differences are printed but don’t fail the drill, because rows written or deleted since the backup explain some of them. In our test, signing in once after the backup showed up as one more row each in auth.sessions, auth.refresh_tokens, auth.mfa_amr_claims and auth.audit_log_entries. Look into any difference you can’t explain, including a table that came back with only part of its rows. The other checks do fail the drill. We gave the script four broken backups to make sure: one with a different Vault key, a Storage archive made without --xattrs and one file deleted; one with the _supabase dump cut off halfway; one with an empty key file, which stops Postgres from starting; and one with a file changed after its checksum was written. All four ended with DRILL FAILED and exit code 1. The first one printed:
drilling backup 2026-10-18T235901Z
restore: only the 2 expected errors
row counts match: 40 tables
ERROR: pgsodium_crypto_aead_det_decrypt_by_id: invalid ciphertext
vault secrets do not decrypt: wrong or missing pgsodium_root.key in db-config.tar
content type lost: avatars/users/4c8c879c-822c-40db-b6f4-c312ba9fdf3b/blob.bin/4230e119-4473-46df-b503-64c23d76c60c
missing file: avatars/readme.txt/c2949098-ca96-4dae-8fe9-56336ca97c83
content type lost: avatars/legacy.txt
storage objects checked: 3, problems: 3
DRILL FAILEDRun it weekly, an hour or so after a backup, and treat a non-zero exit like a failed backup. It runs on the same server, so it needs enough free disk for one more copy of the database while it runs.
Restoring the whole stack on a new server
When the server itself is gone, the same files rebuild it. Install Docker and rclone on the new machine, create the two rclone remotes as in step 5 with the passphrase from your password manager, and fetch the backup you want; rclone lsf offsite: lists them. Every file has to come back OK; if one doesn’t, stop and use an older backup.
# readable only by you: the backup holds .env, the Vault key and password hashes
(umask 077 && rclone copy offsite:2026-10-18T140107Z ~/restore)
(cd ~/restore && sha256sum --check SHA256SUMS)Then restore the databases, in the directory that should hold the deployment:
mkdir supabase-project && cd supabase-project
tar -xzf ~/restore/config.tar.gz
docker compose pull
# the Vault key and conf.d go into the db-config volume before Postgres first starts
docker compose run --rm --no-deps -T --entrypoint sh db -c \
'cd /etc/postgresql-custom && tar -xf - && chown -R postgres:postgres pgsodium_root.key conf.d && chmod 640 pgsodium_root.key' \
< ~/restore/db-config.tar
# restore into volumes/db/data from a container without Supabase's init scripts
docker run -d --name supabase-db-restore --network none --tmpfs /docker-entrypoint-initdb.d \
-v "$PWD/volumes/db/data:/var/lib/postgresql/data" \
-v supabase_db-config:/etc/postgresql-custom \
-e POSTGRES_PASSWORD=restore-only \
supabase/postgres:17.6.1.136 postgres -c config_file=/etc/postgresql/postgresql.conf \
-c cron.launch_active_jobs=off
for i in $(seq 90); do
docker exec supabase-db-restore pg_isready -q -h localhost -U supabase_admin -d postgres && break
sleep 2
done
if docker exec supabase-db-restore pg_isready -h localhost -U supabase_admin -d postgres; then
docker exec -i supabase-db-restore psql -q -U supabase_admin -d template1 < ~/restore/globals.sql
for dump in ~/restore/*.dump; do
docker exec -i supabase-db-restore pg_restore -U supabase_admin -d template1 --create < "$dump"
done
else
docker logs --tail 30 supabase-db-restore
fiIf the restore container never accepts connections, nothing is restored and its log is printed instead. Stop there and fix what the log shows. Before you run the block again from the docker compose run line, remove the container and the data directory its failed start left behind:
docker rm -f supabase-db-restore
docker run --rm -v "$PWD/volumes/db:/d" --entrypoint rm supabase/postgres:17.6.1.136 -rf /d/dataOtherwise expect exactly the two errors from step 6. If you see any other, stop here: the stack isn’t running yet, so nothing has written to the restored database. If there are only those two, put the Storage files back and start everything:
docker stop supabase-db-restore && docker rm supabase-db-restore
tar --xattrs --xattrs-include='user.*' -xzf ~/restore/storage.tar.gz -C volumes && docker compose up -dThe key and conf.d go in through docker compose run so that they land in supabase_db-config, the same volume the stack mounts later. The restore container writes straight into volumes/db/data with the same empty init directory as the drill, and like the drill it has no network and doesn’t run pg_cron jobs, so nothing scheduled fires against a half-restored database. Those two settings only apply to that container. When the stack starts, Postgres finds an existing data directory and skips Supabase’s init scripts. POSTGRES_PASSWORD=restore-only only lasts until globals.sql sets every role’s password back to its original hash.
In our test all eleven containers came up healthy. The same user signed in with the same password, the API returned their 1,000 notes through the row level security policy and none to the anon key, all three Storage files (the one without a version id too) downloaded with their original SHA-256 and content type, and the Vault secret decrypted. max_connections was still 150, and both pg_cron jobs were active again. After that, point your DNS or reverse proxy at the new machine, and delete ~/restore: it holds .env and the Vault key unencrypted.
What this doesn’t cover
- Writes after the last backup. A dump is the database as it was when pg_dump ran. If losing a day of writes is too much, you need continuous WAL archiving with a tool such as pgBackRest or WAL-G, which this guide doesn’t set up.
- Storage on the S3 backend, as noted in step 3.
- Anything outside the deployment directory: a reverse proxy you run on the host, firewall rules, DNS, and the crontab itself.
For projects on Supabase’s hosted platform, BackupDrill runs scheduled backups and restore drills: the database, plus Storage files once you add the project’s Storage S3 keys, goes into your own bucket. It doesn’t connect to self-hosted stacks.
FAQ
Does self-hosted Supabase back up automatically?
No. Supabase's self-hosting docs list managed backups and point-in-time recovery among the platform features you don't get when you self-host, and its update guide says update.sh backs up your configuration files but not Postgres or Storage data. You need your own scheduled dump of the roles and databases, a copy of volumes/storage, the Vault key from the db-config volume and your .env.
Where does self-hosted Supabase store Storage files?
With the default file backend, under volumes/storage in the deployment directory. Each file sits at GLOBAL_S3_BUCKET / STORAGE_TENANT_ID / bucket / object path / version, which with the example .env starts with stub/stub. The version matches the version column of storage.objects. Each file's content type and cache-control are extended attributes on the file, so copy the directory with tar --xattrs, cp -a or rsync -aX.
Is pg_dumpall enough to back up self-hosted Supabase?
Not on its own. pg_dumpall assumes the postgres database already exists on the target and only restores what is inside it, so the database's owner, grants and settings stay behind. In our test the restored postgres role could no longer create a schema. Dump the roles with pg_dumpall --globals-only and each database with pg_dump --create, and copy the Vault key, which is in no dump at all.
Why do Vault secrets fail with invalid ciphertext after a restore?
The restored server has a different root key. Supabase's Postgres image reads it from /etc/postgresql-custom/pgsodium_root.key on the db-config Docker volume and writes a new random one when the file is missing, so the restore succeeds and reading vault.decrypted_secrets fails with pgsodium_crypto_aead_det_decrypt_by_id: invalid ciphertext. Put the original key file in the volume before Postgres starts, as the restore steps in this guide do.
Can I back up self-hosted Supabase by copying volumes/db/data?
Only with Postgres stopped. PostgreSQL's documentation says the server must be shut down for a file-system copy of the data directory to be usable, and even then the copy leaves out the Vault key, which lives in a separate Docker volume. pg_dump runs while the stack keeps serving traffic.
Sources
- Supabase docs — Self-Hosting
- Supabase docs — Self-Hosting with Docker
- Supabase docs — Accessing Postgres (self-hosted)
- Supabase docs — Updating self-hosted Supabase
- Supabase docs — Upgrade self-hosted Postgres to 17
- Supabase docs — Configure S3 Storage (self-hosted)
- supabase/supabase, docker/docker-compose.yml at self-hosted/v0.8.3
- supabase/storage v1.81.0 — file backend (src/storage/backend/file.ts)
- PostgreSQL source, REL_17_STABLE — src/bin/pg_dump/pg_dumpall.c
- PostgreSQL docs — pg_dump
- PostgreSQL docs — pg_dumpall
- PostgreSQL docs — pg_restore
- PostgreSQL docs — File System Level Backup
- rclone docs — Crypt
- GNU tar manual — Extended File Attributes
Facts and prices last verified October 18, 2026 against the sources above. Written by the team behind BackupDrill.