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
- A Supabase project and its database password.
- A private GitHub repository to hold the workflow. GitHub turns off scheduled workflows in a public repository after 60 days without activity.
- A SafeGrd account with a verified email address, and the CLI on your laptop
(
curl -fsSL https://safegrd.dev/install.sh | sh). The backup job runs on every plan. The weekly restore into a scratch database needs a paid plan; the free plan records one in-memory drill a month (pricing).
1. Copy the connection string
In the Supabase dashboard, click Connect and copy the
Session pooler string. Add ?sslmode=require to the end:
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:
--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:
safegrd: SafeGrd keeps your key sealed and releases it only to your enrolled hosts, so you can restore even after losing a host. The workflow holds no key: the drill job fetches it from SafeGrd when it runs and keeps it in memory, and each release is in the key access record under Settings.local: only you can decrypt these backups. The key is inkeys/daemon.key. The drill job needs it, from a repository secret, and the backup job never gets it. Keep a copy of the file somewhere safe.
3. Add the secrets
From the same folder, with the GitHub CLI:
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).
What each part does:
- Two schedules. The first runs the backup job every day. The second
runs the drill job once a week, which backs up and then restores that backup. GitHub
delays scheduled runs when it is busy, most at the top of the hour, so both use odd
minutes. Run workflow takes a backup, or a drill with the
drillbox ticked. concurrencystops a manual run and a scheduled one from running at the same time.- The SafeGrd action installs the CLI, writes the config to a file only
the job can read, and backs up. With
drill: trueit then restores that backup into the sandbox and reports the result to SafeGrd. Its README lists every input. - The PostgreSQL 17 client.
pg_dumpmust be the server's major version or newer, and new Supabase projects run PostgreSQL 17.postgres-version: "17"installs that client when the runner has none as new. Check yours withSELECT version();in the SQL editor. If it differs, change bothpostgres-versionlines and the sandbox image tag to match. - The sandbox is a database started for the drill job alone, and it is
gone when the job ends. It runs Supabase's own PostgreSQL image, because a Supabase
database uses extensions such as
supabase_vaultthat stock PostgreSQL does not ship. It connects assupabase_admin, the image's superuser, because Supabase's ownrealtimefunctions set a parameter only a superuser may set. The image's default database already holds Supabase's tables, so the action creates the emptydrilldatabase thatsandbox-urlnames, and restores into it. The drill then checks the table and row counts against the manifest written at backup time and reports the result to SafeGrd. - The drill's schedule should match your plan. SafeGrd records one
drill per surface per period of your plan's drill cadence and reports any extra run as
not recorded. On the free plan, drills check the backup in memory, once a month: remove the
sandbox-urlline and theservicesblock, and run the drill job monthly. Without a sandbox, the action drills in memory. - With a key you hold, uncomment
private-keyin the drill job. The backup job only needs the public key, which is in the config.
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:
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.
This path is tested against PostgreSQL, not yet against a Supabase project.