Guides / Back up Supabase with GitHub Actions

How to back up Supabase with GitHub Actions

Supabase runs your database but gives you no server of your own to run a backup job on, so this guide uses a scheduled GitHub Actions workflow and the SafeGrd action. Every night a runner connects to the database, dumps it, encrypts it on the runner and writes it to storage that refuses to delete it for a set number of days. Once a week a second job restores the newest backup into a throwaway database and checks every table's row count.

If you would rather run no workflow, SafeGrd can take the backup itself from the connection string: Back up on SafeGrd under Surfaces in the console, on paid plans and the trial (how it runs). This guide is for the workflow.

Supabase deletes a project's backups when the project is deleted, and free-plan projects have no automatic backups: Supabase recommends keeping your own copy off-site. This copy lives outside Supabase, encrypted to a key Supabase never sees.

What you need

1. Copy the connection string

In the Supabase dashboard, click Connect and copy the Session pooler string. Add ?sslmode=require to the end:

postgresql://postgres.abcdefghijklmnop:PASSWORD@aws-0-us-west-1.pooler.supabase.com:5432/postgres?sslmode=require

GitHub's hosted runners connect over IPv4 only, and the direct host (db.<ref>.supabase.co) has only an IPv6 address unless you pay for Supabase's IPv4 add-on. The session pooler on port 5432 is reachable over IPv4 and gives each client its own server connection, which the backup needs to read every table from one snapshot. The transaction pooler on port 6543 shares server connections between clients, so a backup cannot run through it.

The postgres user owns the tables you create in the dashboard and the SQL editor, so row-level security does not hide rows from it. To back up with a read-only role instead, see Databases.

2. Register the workflow as a host

SafeGrd tracks each place backups come from as a host. The workflow gets one of its own, registered once from your laptop. Create a token in the console under Tokens, then run this in an empty folder:

mkdir safegrd-supabase && cd safegrd-supabase
read -rs SAFEGRD_TOKEN && export SAFEGRD_TOKEN  # paste the token; it is not shown
safegrd enroll --config ./safegrd.yaml --token env:SAFEGRD_TOKEN --node-name supabase-prod \
  --project clinic-app --storage hosted --key-custody safegrd

--config ./safegrd.yaml keeps this setup inside the folder, apart from any SafeGrd setup already on your laptop. env:SAFEGRD_TOKEN reads the token from the environment, so it stays out of ps and your shell history. The command makes a keypair, registers a host called supabase-prod and writes safegrd.yaml, with the private key beside it in keys/daemon.key. safegrd.yaml holds the host's token and the public key, and names the key file relative to itself. The private key is never written to it, so the file can go into a secret as it is. keys/ stays on your laptop.

--project takes the slug the console shows under Projects (clinic-app here). An agency gives each client its own project, so the client's backups stay apart. Without it the host joins the organization's default project.

--storage hosted writes to SafeGrd's locked bucket, so there is no bucket to set up. To use your own S3 bucket with Object Lock, see Storage and retention.

--key-custody decides who holds the key that decrypts the backups:

3. Add the secrets

From the same folder, with the GitHub CLI:

gh secret set SAFEGRD_CONFIG --repo you/infra < safegrd.yaml
gh secret set SUPABASE_DB_URL --repo you/infra
# only with --key-custody local:
gh secret set SAFEGRD_PRIVATE_KEY --repo you/infra < keys/daemon.key

The second command asks for the connection string from step 1, so it stays out of your shell history. With a key you hold, the third command puts it in a secret that only the drill job reads; then move daemon.key somewhere safe. With SafeGrd holding the key, skip the third command: no key goes to GitHub.

4. Add the workflow

Every backup reads the whole database, and Supabase bills that egress past your plan's allowance. Before you pick the schedule, price it from any machine that can reach the database: safegrd estimate --database-url "$SUPABASE_DB_URL" prints the egress a month for weekly, daily and hourly backups (estimate).

# .github/workflows/supabase-backup.yml
name: Supabase backup
 
on:
  schedule:
    - cron: "17 3 * * *" # backup, every day at 03:17 UTC
    - cron: "47 4 * * 1" # Fire Drill, Mondays at 04:47 UTC
  workflow_dispatch:
    inputs:
      drill:
        description: Back up and restore into the sandbox
        type: boolean
        default: false
 
concurrency:
  group: supabase-backup
  cancel-in-progress: false
 
jobs:
  backup:
    if: github.event.schedule != '47 4 * * 1' && !inputs.drill
    runs-on: ubuntu-24.04
    timeout-minutes: 60
    steps:
      - uses: safegrd/backup-action@v0
        with:
          config: ${{ secrets.SAFEGRD_CONFIG }}
          database-url: ${{ secrets.SUPABASE_DB_URL }}
          postgres-version: "17"
 
  drill:
    if: github.event.schedule == '47 4 * * 1' || inputs.drill
    runs-on: ubuntu-24.04
    timeout-minutes: 90
    services:
      sandbox:
        image: supabase/postgres:17.6.1.178
        env:
          POSTGRES_PASSWORD: sandbox
        ports:
          - 5432:5432
        options: >-
          --health-cmd "pg_isready -U postgres -h localhost"
          --health-interval 5s --health-timeout 5s --health-retries 24
    steps:
      - uses: safegrd/backup-action@v0
        with:
          config: ${{ secrets.SAFEGRD_CONFIG }}
          database-url: ${{ secrets.SUPABASE_DB_URL }}
          postgres-version: "17"
          drill: true
          sandbox-url: postgres://supabase_admin:sandbox@localhost:5432/drill?sslmode=disable
          # Only with a key you hold. Leave it out when SafeGrd holds the key.
          # private-key: ${{ secrets.SAFEGRD_PRIVATE_KEY }}

What each part does:

5. Run it once by hand

Open Actions, pick Supabase backup and click Run workflow. With the drill box clear it runs the backup job. When it finishes, supabase-prod shows its first snapshot in the SafeGrd console.

From then on SafeGrd expects a backup from this host every 24 hours. When none arrives for 48, it marks the host Overdue and sends an alert to the addresses and webhooks under Settings, Alerts. GitHub also emails whoever last changed the schedule when a scheduled run fails.

6. Restore

A Supabase backup restores into Supabase's PostgreSQL image, for the same reasons the drill's sandbox uses it. A Supabase project already holds Supabase's own schemas and its postgres user is not a superuser, so the restore goes into a container rather than into a project. On any machine with Docker and the CLI, put the contents of the SAFEGRD_CONFIG secret in safegrd.yaml inside an empty folder, as in step 2, then:

docker run -d --name restore -e POSTGRES_PASSWORD=restore -p 54320:5432 supabase/postgres:17.6.1.178
docker exec restore psql -U supabase_admin -h localhost -d postgres -c "CREATE DATABASE restored"
safegrd --config ./safegrd.yaml list
export RESTORE_URL="postgres://supabase_admin:restore@localhost:54320/restored"
safegrd --config ./safegrd.yaml restore --snapshot snap-20260929-132639-ce834b --target env:RESTORE_URL

env:RESTORE_URL reads the target from the environment, so its password stays out of ps and your shell history. The restore checks every table's row count against the manifest written at backup time. With a key you hold, set SAFEGRD_PRIVATE_KEY to its contents as well.

Into an existing project

After an incident you want the data back in the project, not in a container. A project is never empty, so restore the schemas that are yours and leave Supabase's alone: --schema public recreates that schema's tables and rows, and --data-only-schema auth --data-only-schema storage loads those schemas' rows into the tables the project already has. Drop or rename the tables in public first, because the restore creates them. The whole restore is one transaction: a row count that does not match the manifest rolls all of it back.

export RESTORE_URL="postgres://postgres:PASSWORD@db.PROJECT.supabase.co:5432/postgres"
safegrd --config ./safegrd.yaml restore --snapshot snap-20260929-132639-ce834b --target env:RESTORE_URL \
  --schema public --data-only-schema auth --data-only-schema storage

This path is tested against PostgreSQL, not yet against a Supabase project.