Skip to the article

Backups and restoration

Preserve database, Storage, configuration and encryption keys, choose a backup method and restore a complete set to a blank target.

A recoverable backup keeps the database, raw Storage bytes, configuration, encryption keys and application release together. Choose the filesystem, logical or provider procedure for your installed profile, preserve the matching set off-host and rehearse restoration to a blank target before relying on it. Restoring a backup can lose writes made after that backup.

Create a cold backup of the full filesystem profile

Use this Linux procedure only for the full reference profile with the pinned official Supabase filesystem Storage layout. A physical restore requires the same CPU architecture and exact PostgreSQL image. Managed Supabase, S3 and source deployments use the logical/provider procedure. Announce a write outage and stop external workers. Keep the backup destination encrypted, large enough and outside active data. Never use down --volumes on the source. The backup captures database, Auth, Storage records and bytes, migrations, policies, Vault keys, configuration and release identities together.

Set the installed Compose context

Replace the example release and paths with the instance actually installed. Set MODE=full. Preserve any existing restore override in every Compose command. Never run bare upstream docker compose because it omits the minddy overlay and protected environment. Do not source or print the environment. The following backup stops every service before copying PostgreSQL files.

Bash
set -euo pipefail
export MODE=full
export CURRENT_RELEASE_DIR=/srv/minddy/releases/vX.Y.Z
export SUPABASE_DIR=/srv/minddy/supabase
export MINDDY_ENV_FILE=/etc/minddy/instance.env
export BACKUP_ROOT=/mnt/backup/minddy
compose() {
  local files=()
  if [ "$MODE" = full ]; then
    files+=(-f "$SUPABASE_DIR/docker/docker-compose.yml")
  fi
  files+=(-f "$CURRENT_RELEASE_DIR/deploy/self-hosted/compose.$MODE.yml")
  if [ -n "${RESTORE_OVERRIDE:-}" ]; then
    files+=(-f "$RESTORE_OVERRIDE")
  fi
  docker compose --env-file "$MINDDY_ENV_FILE" "${files[@]}" "$@"
}
compose ps

Seal the matching set

Run the cold-copy sequence below while all writes remain stopped. It records the source and image identities, archives upstream filesystem data and the db-config key volume and preserves the deployment files and protected environment. A db-config lookup failure is an abort condition. Preserve release assets and OCI digests; retain the database image by digest for restoration. A backup containing the root key and ciphertext relies on its outer encryption and access controls for confidentiality.

Bash
export BACKUP_DIR="$BACKUP_ROOT/$(date -u +%Y%m%dT%H%M%SZ)"
test ! -e "$BACKUP_DIR"
install -d -m 0700 "$BACKUP_DIR"
compose ps --format json > "$BACKUP_DIR/services.json"
compose images --format json > "$BACKUP_DIR/images.json"
git -C "$CURRENT_RELEASE_DIR" rev-parse HEAD > "$BACKUP_DIR/source-commit.txt"
git -C "$SUPABASE_DIR" rev-parse HEAD > "$BACKUP_DIR/supabase-commit.txt"
DB_CONTAINER="$(compose ps -q db)"
test -n "$DB_CONTAINER"
DB_CONFIG_VOLUME="$(docker inspect "$DB_CONTAINER" --format '{{range .Mounts}}{{if eq .Destination "/etc/postgresql-custom"}}{{.Name}}{{end}}{{end}}')"
test -n "$DB_CONFIG_VOLUME"
BACKUP_HELPER_IMAGE="$(docker inspect "$DB_CONTAINER" --format '{{.Image}}')"
printf '%s\n' "$BACKUP_HELPER_IMAGE" > "$BACKUP_DIR/postgres-image-id.txt"
STORAGE_CONTAINER="$(compose ps -q storage)"
test -n "$STORAGE_CONTAINER"
STORAGE_VOLUME="$(docker inspect "$STORAGE_CONTAINER" --format '{{range .Mounts}}{{if eq .Destination "/var/lib/storage"}}{{.Name}}{{end}}{{end}}')"
compose stop
sudo tar --numeric-owner --acls --xattrs -C "$SUPABASE_DIR" \
  -czf "$BACKUP_DIR/supabase-docker.tar.gz" docker
install -m 0600 "$MINDDY_ENV_FILE" "$BACKUP_DIR/instance.env"
docker run --rm --network none --user 0:0 --entrypoint tar \
  --mount "type=volume,src=$DB_CONFIG_VOLUME,dst=/keys,readonly" \
  "$BACKUP_HELPER_IMAGE" -czf - -C /keys . > "$BACKUP_DIR/db-config.tar.gz"
if [ -n "$STORAGE_VOLUME" ]; then
  docker run --rm --network none --user 0:0 --entrypoint tar \
    --mount "type=volume,src=$STORAGE_VOLUME,dst=/objects,readonly" \
    "$BACKUP_HELPER_IMAGE" --numeric-owner --acls --xattrs -czf - -C /objects . \
    > "$BACKUP_DIR/storage-volume.tar.gz"
fi
tar -C "$CURRENT_RELEASE_DIR" -czf "$BACKUP_DIR/deployment.tar.gz" deploy/self-hosted
if [ -n "${RESTORE_OVERRIDE:-}" ]; then
  install -m 0600 "$RESTORE_OVERRIDE" "$BACKUP_DIR/source-volume-override.yml"
fi
sudo chown "$(id -u):$(id -g)" "$BACKUP_DIR/supabase-docker.tar.gz"
(
  cd "$BACKUP_DIR"
  find . -type f ! -name SHA256SUMS -print0 | sort -z | xargs -0 sha256sum > SHA256SUMS
  sha256sum --check SHA256SUMS
)

Verify off-host and rehearse restoration

Copy the sealed set to an independent disk or host and verify SHA256SUMS there. A second copy on the same host is insufficient protection against host loss. Keep TLS certificate storage separately if your retention policy requires it. For backup-only maintenance, restart with compose up -d --wait after verifying the sealed copy. Before calling the backup usable, restore it to a blank isolated target, authenticate including MFA, inspect retained issues and download attachments with matching SHA-256. Keep the matching historical keys.

The cold-backup command also detects a filesystem Storage named volume and archives its bytes separately. A bind mount remains covered by supabase-docker.tar.gz. Neither case covers an S3/provider backend.

Create a logical or provider-managed backup

Use this procedure for source, managed database or custom Storage deployments. Keep the database, raw Storage and proxy commands mapped to the same installed instance. The self-host:backup, self-host:update and self-host:restore commands are read-only safety gates, not backup, deployment or restoration. A complete backup contains PostgreSQL including auth, storage metadata and migration history, raw Storage bytes, protected configuration and keys and the exact source/Supabase identities from one write-consistent point.

Provider-managed Supabase

For Supabase managed by a provider, record the project, database version, backup or snapshot identifier and its recovery point. Close minddy, workers and scheduled writes, then use the provider’s supported consistency or maintenance controls for direct Auth, PostgREST and Storage writes. If those controls are unavailable, record that limit; stopping the application alone is insufficient. Use the SQL exports below only when your database role and provider support them, including Auth, Storage metadata, migration history and managed policies. Preserve raw object bytes using the provider-supported export or immutable backend snapshot separately from SQL. Preserve minddy’s protected environment and encryption keys, plus the Auth/SMTP, URL, proxy and job settings you control. Platform roles and pgsodium keys may be provider-owned: do not pretend they are local files. The following SUPABASE_COMPOSE_DIR, docker compose, supabase.env, pgsodium and filesystem tar blocks apply only to an operator-controlled Supabase backend. Validate the provider’s complete restore into a separate empty project before relying on the copy. No provider backup/restore was executed for this documentation revision.

Block writes and export SQL

Close the application, workers, scheduler and public Supabase API. Direct PostgREST and Storage writes remain possible if you stop only the web app. Set SUPABASE_DB_URL privately, FROM_TAG to the release tag currently installed and BACKUP_DIR to a new encrypted destination. Run the SQL exports below with the selected release tooling. Confirm every SQL file is non-empty and data.sql includes COPY sections for auth.users, storage.objects and application tables. The regular schema dump omits migration history, so its two separate exports are mandatory. Preserve managed Storage and Realtime policies.

Bash
set -euo pipefail
export MINDDY_REPO=/srv/minddy/source
export MINDDY_CURRENT_DIR=/srv/minddy/current
export SUPABASE_COMPOSE_DIR=/srv/supabase/docker
export MINDDY_ENV_FILE=/etc/minddy/minddy.env
export BACKUP_ROOT=/mnt/backup/minddy
export FROM_TAG=v0.11.0
Bash
export BACKUP_ID="$(date -u +%Y%m%dT%H%M%SZ)-${FROM_TAG}"
export BACKUP_DIR="$BACKUP_ROOT/$BACKUP_ID"
test ! -e "$BACKUP_DIR"
install -d -m 0700 "$BACKUP_DIR/database" "$BACKUP_DIR/config"
git -C "$MINDDY_CURRENT_DIR" rev-parse HEAD > "$BACKUP_DIR/minddy-commit.txt"
git -C "$MINDDY_CURRENT_DIR" describe --tags --always --dirty > "$BACKUP_DIR/minddy-version.txt"
cd "$SUPABASE_COMPOSE_DIR"
git -C "$SUPABASE_COMPOSE_DIR" rev-parse HEAD > "$BACKUP_DIR/supabase-commit.txt"
docker compose images > "$BACKUP_DIR/supabase-images.txt"
docker compose ps > "$BACKUP_DIR/supabase-services.txt"
Bash
cd "$MINDDY_REPO"
supabase db dump --db-url "$SUPABASE_DB_URL" \
  -f "$BACKUP_DIR/database/roles.sql" --role-only
supabase db dump --db-url "$SUPABASE_DB_URL" \
  -f "$BACKUP_DIR/database/schema.sql"
supabase db dump --db-url "$SUPABASE_DB_URL" \
  -f "$BACKUP_DIR/database/data.sql" --use-copy --data-only \
  -x 'storage.buckets_vectors' -x 'storage.vector_indexes'
supabase db dump --db-url "$SUPABASE_DB_URL" \
  -f "$BACKUP_DIR/database/history_schema.sql" --schema supabase_migrations
supabase db dump --db-url "$SUPABASE_DB_URL" \
  -f "$BACKUP_DIR/database/history_data.sql" --use-copy --data-only \
  --schema supabase_migrations
psql "$SUPABASE_DB_URL" -X -v ON_ERROR_STOP=1 -At \
  -f scripts/export-managed-policies.sql \
  > "$BACKUP_DIR/database/managed_policies.sql"
psql "$SUPABASE_DB_URL" -X -v ON_ERROR_STOP=1 -Atc "
  select jsonb_build_object(
    'auth.users', (select count(*) from auth.users),
    'public.projects', (select count(*) from public.projects),
    'public.issues', (select count(*) from public.issues),
    'public.attachments', (select count(*) from public.attachments),
    'storage.buckets', (select count(*) from storage.buckets),
    'storage.objects', (select count(*) from storage.objects)
  )::text
" > "$BACKUP_DIR/database/counts.json"

Preserve raw bytes and configuration

For operator-controlled filesystem Storage, stop storage and imgproxy after the SQL dump and archive the backend directory with numeric owners, ACLs and extended attributes. For S3, create an immutable raw backend snapshot/version instead. Never restore via /storage/v1/s3: it creates conflicting metadata. Managed providers control platform roles and filesystem access; use their supported backup and restore process and record its scope. Copy application/Supabase configuration, proxies, jobs, Auth templates, SMTP and any pgsodium root key privately. Include MINDDY_DATA_ROOT_KEY and retained encryption secrets. Database SQL alone does not contain attachment bytes.

For the pinned operator-controlled backend, preserve the complete db-config volume rather than only copying pgsodium_root.key. The helper below retains numeric ownership, permissions, ACLs and extended attributes; retain its exact PostgreSQL image for recovery. A different key mount requires a verified backend-specific procedure. Do not omit keys or assume a newly generated root can read restored Vault data.

Bash
install -m 0600 "$MINDDY_ENV_FILE" "$BACKUP_DIR/config/minddy.env"
install -m 0600 "$SUPABASE_COMPOSE_DIR/.env" "$BACKUP_DIR/config/supabase.env"
tar --exclude='./volumes' --exclude='./.git' \
  -C "$SUPABASE_COMPOSE_DIR" \
  -czf "$BACKUP_DIR/config/supabase-compose.tar.gz" .
cd "$SUPABASE_COMPOSE_DIR"
DB_CONTAINER="$(docker compose ps -q db)"
test -n "$DB_CONTAINER"
DB_CONFIG_VOLUME="$(docker inspect "$DB_CONTAINER" --format '{{range .Mounts}}{{if eq .Destination "/etc/postgresql-custom"}}{{.Name}}{{end}}{{end}}')"
BACKUP_HELPER_IMAGE="$(docker inspect "$DB_CONTAINER" --format '{{.Image}}')"
printf '%s\n' "$BACKUP_HELPER_IMAGE" > "$BACKUP_DIR/config/postgres-image-id.txt"
if [ -z "$DB_CONFIG_VOLUME" ]; then
  echo "This key mount needs a verified backend-specific backup and restore procedure." >&2
  exit 1
fi
docker run --rm --network none --user 0:0 --entrypoint tar \
  --mount "type=volume,src=$DB_CONFIG_VOLUME,dst=/keys,readonly" \
  "$BACKUP_HELPER_IMAGE" --numeric-owner --acls --xattrs -czf - -C /keys . \
  > "$BACKUP_DIR/config/db-config.tar.gz"
Bash
cd "$SUPABASE_COMPOSE_DIR"
docker compose stop storage imgproxy
test -d "$SUPABASE_COMPOSE_DIR/volumes/storage"
tar --numeric-owner --acls --xattrs \
  -C "$SUPABASE_COMPOSE_DIR/volumes" \
  -czf "$BACKUP_DIR/storage-files.tar.gz" storage
tar -tzf "$BACKUP_DIR/storage-files.tar.gz" > "$BACKUP_DIR/storage-files.list"
cd "$BACKUP_DIR"
find . -type f ! -name SHA256SUMS -print0 | sort -z | xargs -0 sha256sum > SHA256SUMS
sha256sum --check SHA256SUMS

Seal and test the backup

Generate and verify SHA256SUMS over the complete set, encrypt it, copy it off-host and verify again there. Record release and image identities, object counts and sample file hashes. A successful export is not a restore proof. Rehearse a blank-target restore using the saved versions and configuration before relying on the set; report provider limitations explicitly.

Restore a complete set to a blank target

Restoration is destructive for its target and is not a merge. Fetch the sealed off-host backup, verify SHA256SUMS and retain the source data unchanged. Keep target public ingress, scheduler and external workers stopped. Full-profile physical restoration needs the saved architecture and exact PostgreSQL image. Obtain the saved source tag and pinned upstream in new directories and install frozen tooling. Confirm no target database data directory, Storage objects or db-config volume exists. Do not run ordinary installer/bootstrap over an empty physical restore target.

Verified complete off-host backup
Blank isolated target with matching versions
Restore database, raw bytes and keys together
Verify account/content/file bytes before opening
Read the stages in order. Verified complete off-host backup. Blank isolated target with matching versions. Restore database, raw bytes and keys together. Verify account/content/file bytes before opening.

Restore full-profile filesystem data

Use the complete physical sequence below with new unused paths and volume names. It removes stopped source containers without deleting their volumes, extracts cold PostgreSQL and Storage together, restores db-config with the saved image and compares that image before starting PostgreSQL. Edit only target paths, public origins and Auth redirects in the protected restored environment. Preserve JWT, passwords, Vault and application keys and saved image/release identity. Keep RESTORE_OVERRIDE for every later operation; omitting it can select source volumes. Recompile the offline main function, start dependencies and the application and check maintenance readiness before reopening.

Before the first compose down, define the source instance’s installed Compose context, including its existing override.

When storage-volume.tar.gz exists, restore it into a new Linux named volume and attach that exact volume through the persistent restore override before starting Storage. The empty-target guard also applies to this volume.

Bash
compose down
export CURRENT_RELEASE_DIR=/srv/minddy/releases/saved-source
export SUPABASE_DIR=/srv/minddy/supabase-restored
export MINDDY_ENV_FILE=/etc/minddy/restored.env
export RESTORE_OVERRIDE=/etc/minddy/restore-volumes.yml
export RESTORE_DB_CONFIG=minddy-restored-db-config
(cd "$BACKUP_DIR"; sha256sum --check SHA256SUMS)
test ! -d "$SUPABASE_DIR/docker/volumes/db/data"
test -z "$(find "$SUPABASE_DIR/docker/volumes/storage" -type f -print -quit 2>/dev/null)"
if docker volume inspect "$RESTORE_DB_CONFIG" >/dev/null 2>&1; then
  echo "Refusing to overwrite an existing restore volume." >&2
  exit 1
fi
sudo tar --numeric-owner --acls --xattrs -xzf "$BACKUP_DIR/supabase-docker.tar.gz" \
  -C "$SUPABASE_DIR"
install -m 0600 "$BACKUP_DIR/instance.env" "$MINDDY_ENV_FILE"
cat > "$RESTORE_OVERRIDE" <<EOF
volumes:
  db-config:
    name: $RESTORE_DB_CONFIG
  deno-cache:
    name: minddy-restored-deno-cache
  caddy_data:
    name: minddy-restored-caddy-data
  caddy_config:
    name: minddy-restored-caddy-config
EOF
compose() {
  local files=(
    -f "$SUPABASE_DIR/docker/docker-compose.yml"
    -f "$CURRENT_RELEASE_DIR/deploy/self-hosted/compose.full.yml"
    -f "$RESTORE_OVERRIDE"
  )
  docker compose --env-file "$MINDDY_ENV_FILE" "${files[@]}" "$@"
}
BACKUP_HELPER_IMAGE="$(cat "$BACKUP_DIR/postgres-image-id.txt")"
docker image inspect "$BACKUP_HELPER_IMAGE" >/dev/null
docker volume create "$RESTORE_DB_CONFIG"
docker run --rm -i --network none --user 0:0 --entrypoint tar \
  --mount "type=volume,src=$RESTORE_DB_CONFIG,dst=/keys" \
  "$BACKUP_HELPER_IMAGE" -xzf - -C /keys < "$BACKUP_DIR/db-config.tar.gz"
if [ -f "$BACKUP_DIR/storage-volume.tar.gz" ]; then
  export RESTORE_STORAGE_VOLUME=minddy-restored-filesystem-storage
  if docker volume inspect "$RESTORE_STORAGE_VOLUME" >/dev/null 2>&1; then
    echo "Refusing to overwrite an existing Storage restore volume." >&2
    exit 1
  fi
  docker volume create "$RESTORE_STORAGE_VOLUME"
  docker run --rm -i --network none --user 0:0 --entrypoint tar \
    --mount "type=volume,src=$RESTORE_STORAGE_VOLUME,dst=/objects" \
    "$BACKUP_HELPER_IMAGE" --numeric-owner --acls --xattrs -xzf - -C /objects \
    < "$BACKUP_DIR/storage-volume.tar.gz"
  cat >> "$RESTORE_OVERRIDE" <<EOF
  $RESTORE_STORAGE_VOLUME:
    external: true
services:
  storage:
    volumes:
      - $RESTORE_STORAGE_VOLUME:/var/lib/storage
EOF
fi
compose create db
test "$(docker inspect "$(compose ps --all -q db)" --format '{{.Image}}')" = \
  "$BACKUP_HELPER_IMAGE"
cd "$CURRENT_RELEASE_DIR"
pnpm install --frozen-lockfile
node scripts/prepare-self-hosted-functions.mjs --supabase-dir "$SUPABASE_DIR" \
  --env-file "$MINDDY_ENV_FILE"
compose up -d --wait db database-access kong auth rest storage imgproxy
compose up -d --wait minddy
pnpm self-host:doctor -- --mode full --env-file "$MINDDY_ENV_FILE" \
  --supabase-compose "$SUPABASE_DIR/docker/docker-compose.yml" --skip-network --maintenance
compose up -d --wait
pnpm self-host:doctor -- --mode full --env-file "$MINDDY_ENV_FILE" \
  --supabase-compose "$SUPABASE_DIR/docker/docker-compose.yml"

Restore managed or source backups

For logical restoration, provision a blank stack with recorded PostgreSQL/Supabase versions and the matching configuration/keys. Confirm the target database and raw Storage destination before executing SQL. Restore roles, schema and data in one transaction, then migration history and managed policies. Stop target Storage and restore raw filesystem bytes or the S3 snapshot into a new empty backend. Do not upload through the Storage API. Managed providers require their supported restore workflow rather than filesystem commands. Check out the saved application commit and preserve its keys while changing isolated target origins.

The following logical extraction, run.sh and filesystem Storage commands are only for an operator-controlled backend. For provider-managed Supabase, restore database and raw object bytes into an empty provider target using its supported process, then configure and start minddy using the source installation procedure. Do not run local Compose commands for a supabase.com project.

Before the first database start, the sequence below restores the saved key-volume archive into a new volume and adds a unique docker-compose.restore-keys.*.yml to the installed Compose file list. Keep that override for every later operation. Legacy backups containing only pgsodium_root.key must use a verified recovery procedure that restores its original ownership, permissions and mount before startup; the sequence stops rather than generating replacement keys.

Prepare the saved upstream Supabase checkout first, with its original static files under volumes/, and point RESTORE_SUPABASE_DIR to its docker directory. The configuration archive deliberately excludes volumes/; it cannot provision this checkout by itself. The checks below require its saved commit and initialization files and reject existing database or Storage data. Keep public ingress blocked, and run the Bash sequence with set -euo pipefail so a failed check or extraction stops before startup.

Bash
set -euo pipefail
export RESTORE_DB_URL='postgresql://postgres:...@restore-db:5432/postgres'
export RESTORE_SUPABASE_URL='https://restore-supabase.example.test'
export RESTORE_APP_URL='https://restore-tickets.example.test'
export RESTORE_ANON_KEY='...'
export RESTORE_SERVICE_ROLE_KEY='...'
export RESTORE_SUPABASE_DIR=/srv/restore/supabase/docker
test "$(git -C "$RESTORE_SUPABASE_DIR" rev-parse HEAD)" = \
  "$(cat "$BACKUP_DIR/supabase-commit.txt")"
for file in volumes/db/realtime.sql volumes/db/webhooks.sql volumes/db/roles.sql \
  volumes/db/jwt.sql volumes/db/_supabase.sql volumes/db/logs.sql \
  volumes/db/pooler.sql volumes/api/kong.yml volumes/api/kong-entrypoint.sh \
  volumes/pooler/pooler.exs; do
  test -f "$RESTORE_SUPABASE_DIR/$file"
done
test ! -d "$RESTORE_SUPABASE_DIR/volumes/db/data"
if [ -e "$RESTORE_SUPABASE_DIR/volumes/storage" ]; then
  STORAGE_FIRST_OBJECT="$(find "$RESTORE_SUPABASE_DIR/volumes/storage" -type f -print -quit)"
  test -z "$STORAGE_FIRST_OBJECT"
fi
tar -C "$RESTORE_SUPABASE_DIR" \
  -xzf "$BACKUP_DIR/config/supabase-compose.tar.gz"
install -m 0600 "$BACKUP_DIR/config/supabase.env" "$RESTORE_SUPABASE_DIR/.env"
cd "$RESTORE_SUPABASE_DIR"
if [ -f "$BACKUP_DIR/config/pgsodium_root.key" ] && \
   [ ! -f "$BACKUP_DIR/config/db-config.tar.gz" ]; then
  echo "Legacy standalone key backup: restore its original ownership and mount before startup." >&2
  exit 1
fi
test -f "$BACKUP_DIR/config/db-config.tar.gz"
test -f "$BACKUP_DIR/config/postgres-image-id.txt"
export RESTORE_DB_CONFIG=minddy-logical-restored-db-config
if docker volume inspect "$RESTORE_DB_CONFIG" >/dev/null 2>&1; then
  echo "Refusing to overwrite an existing key restore volume." >&2
  exit 1
fi
RESTORE_KEYS_OVERRIDE="$(mktemp docker-compose.restore-keys.XXXXXX.yml)"
BACKUP_HELPER_IMAGE="$(cat "$BACKUP_DIR/config/postgres-image-id.txt")"
docker image inspect "$BACKUP_HELPER_IMAGE" >/dev/null
docker volume create "$RESTORE_DB_CONFIG"
docker run --rm -i --network none --user 0:0 --entrypoint tar \
  --mount "type=volume,src=$RESTORE_DB_CONFIG,dst=/keys" \
  "$BACKUP_HELPER_IMAGE" --numeric-owner --acls --xattrs -xzf - -C /keys \
  < "$BACKUP_DIR/config/db-config.tar.gz"
cat > "$RESTORE_KEYS_OVERRIDE" <<EOF
volumes:
  db-config:
    name: $RESTORE_DB_CONFIG
EOF
sh run.sh config add "$RESTORE_KEYS_OVERRIDE"
sh run.sh start
psql "$RESTORE_DB_URL" -X -v ON_ERROR_STOP=1 -Atc \
  'select current_database(), inet_server_addr(), version()'
Bash
psql --single-transaction --variable ON_ERROR_STOP=1 \
  --file "$BACKUP_DIR/database/roles.sql" \
  --file "$BACKUP_DIR/database/schema.sql" \
  --command 'SET session_replication_role = replica' \
  --file "$BACKUP_DIR/database/data.sql" \
  --dbname "$RESTORE_DB_URL"
psql --single-transaction --variable ON_ERROR_STOP=1 \
  --file "$BACKUP_DIR/database/history_schema.sql" \
  --file "$BACKUP_DIR/database/history_data.sql" \
  --dbname "$RESTORE_DB_URL"
psql --single-transaction --variable ON_ERROR_STOP=1 \
  --file "$BACKUP_DIR/database/managed_policies.sql" \
  --dbname "$RESTORE_DB_URL"
Bash
cd "$RESTORE_SUPABASE_DIR"
docker compose stop storage imgproxy
test -d "$RESTORE_SUPABASE_DIR/volumes/storage"
tar --numeric-owner --acls --xattrs \
  -C "$RESTORE_SUPABASE_DIR/volumes" \
  -xzf "$BACKUP_DIR/storage-files.tar.gz"
docker compose up -d storage imgproxy
Bash
export RESTORE_RELEASE_DIR="/srv/restore/minddy/releases/$BACKUP_ID"
git -C "$MINDDY_REPO" worktree add --detach "$RESTORE_RELEASE_DIR" \
  "$(cat "$BACKUP_DIR/minddy-commit.txt")"
cd "$RESTORE_RELEASE_DIR"
install -m 0600 "$BACKUP_DIR/config/minddy.env" .env.local
export MINDDY_PUBLIC_SUPABASE_URL="$RESTORE_SUPABASE_URL"
export MINDDY_PUBLIC_SUPABASE_ANON_KEY="$RESTORE_ANON_KEY"
export SUPABASE_SERVICE_ROLE_KEY="$RESTORE_SERVICE_ROLE_KEY"
export MINDDY_PUBLIC_APP_URL="$RESTORE_APP_URL"
pnpm install --frozen-lockfile
pnpm build
pnpm verify:supabase --db-url "$RESTORE_DB_URL" \
  --supabase-url "$MINDDY_PUBLIC_SUPABASE_URL" \
  --service-role-key "$SUPABASE_SERVICE_ROLE_KEY"

After the source build and verification above, start the recovered application with pnpm start or the recorded process supervisor from the source installation procedure. Keep public ingress and jobs closed until the checks below pass.

Prove recovery before relying on it

Compare recorded database/object counts. Authenticate with the restored account and MFA, read encrypted project/issue/page content and download objects from every bucket with matching SHA-256. Check restored integrations, revoked keys, Realtime, jobs, OAuth/MCP and callback origins. Record destination paths, volumes, versions, elapsed time and observed data loss window. After migration failure, use the matching pre-update set for rollback. Reopening writes after backup introduces later changes that a rollback can lose. Never invent reverse migrations or claim a successful restore from healthy containers alone.

Related articles