Docs / Surfaces / Databases / PostgreSQL

PostgreSQL

SafeGrd backs up PostgreSQL 13 and later from a read-only role. This page covers what a backup holds, the role to create, how each backup uploads only what changed, and what to know for Supabase, Amazon RDS and Neon.

# PostgreSQL 13 and later; the test suite runs 16
safegrd backup --database-url "$DATABASE_URL" --retention-days 14

The schema comes from pg_dump: types, tables, functions, views, indexes, constraints and triggers, exactly as Postgres describes them. The rows are copied over the wire with binary COPY, table by table, in chunks, so a table larger than the host's memory backs up without staging anything on disk. Both are read from one snapshot, so a database being written to during the backup still produces rows, row counts and sequence positions that agree.

A drill on a database surface asserts table counts, row counts, column counts, installed extensions and that every role the schema names is in the backup. See Restore and Fire Drills.

Roles, owners and grants

The schema is dumped with each object's owner, its grants and its default privileges, and the policies on a table name the roles they apply to. The roles those name (owners, grantees, policy roles) are backed up as pg_dumpall --roles-only writes them, in roles.sql beside the schema, with their password hashes. The rest of the cluster's roles are not backed up.

Each backup uploads what changed

A PostgreSQL backup is a run of the surface's repository. Each table is one file of the run, cut into chunks of about 64 KiB, and a later run in the same month uploads and locks only the chunks that changed. A table nobody wrote, or one that only grows at the end, adds almost nothing. A table updated at random rows all over can add its whole size on every run. The snapshot detail in the console shows what each table uploaded.

Every run is a restore point: a restore brings the database back as that run read it, and nothing between two runs. Pass --format tar, or set format: tar on a surface, for one archive per backup instead. When you add a surface in the console, Backup type sets this: Incremental or Full. On hosted storage, the console can download a full backup as one file; an incremental one restores on a host (hosted storage). A SQLite database is incremental by default too (below). MySQL and MongoDB backups are one archive each.

# the whole database, into an empty one
safegrd restore --snapshot snap-20261004-091500-a1b2c3 --target "postgres://…/app_restored"
# one table: into an empty table of the same definition, or created if it was dropped
safegrd restore --snapshot snap-20261004-091500-a1b2c3 --table public.orders --target "postgres://…/app"
# every version of one table, by run
safegrd find --table public.orders

--table can be given more than once. The tables come from one run, so they are consistent with each other, and load in one transaction that commits only if every table matched its SHA-256 and its row count. The target's other tables stay as they are. A table the target no longer has, such as one an agent dropped, is created as the run defined it, with its sequences, constraints and indexes, and its sequences are set to where the run left them. A table the target has must be empty and defined as it was. The command prints the run's time, the tables the restored ones point at that it did not load, and the sequences of an existing table that it did not move. restore --table public.orders --version 3 restores the third version find --table lists.

Skip tables nobody wrote

A run reads every table, because a database has no file times to compare. On a large database that read takes minutes and loads the server on every run. --change-log, or change_log: true on a surface, lets a run skip each table nothing wrote since the last run:

safegrd backup --database-url "$DATABASE_URL" --change-log
# [postgres-1a2b3c] 5 of 9 tables not read: the change log shows no write since the last run.

Give SafeGrd a read-only role

SafeGrd only reads your database. Give it a role of its own with no write permissions, so that a leaked backup credential cannot change or delete the data it backs up.

# PostgreSQL 14 and later
CREATE ROLE safegrd_backup LOGIN PASSWORD 'a strong password';
GRANT pg_read_all_data TO safegrd_backup;

pg_read_all_data is built in from PostgreSQL 14. It grants SELECT on every table, view and sequence and USAGE on every schema, including ones created later, so a new table is covered without anyone remembering to grant it. PostgreSQL 13 has no such role, so grant it explicitly and set the default for tables created later:

# PostgreSQL 13: repeat the last three lines for every schema
CREATE ROLE safegrd_backup LOGIN PASSWORD 'a strong password';
GRANT CONNECT ON DATABASE app TO safegrd_backup;
GRANT USAGE ON SCHEMA public TO safegrd_backup;
GRANT SELECT ON ALL TABLES IN SCHEMA public TO safegrd_backup;
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO safegrd_backup;

Run the ALTER DEFAULT PRIVILEGES statement too. Without it, tables created later are not readable by the role and are left out of future backups. pg_dump runs as the same role. Check that the role cannot write before you go on:

psql "postgres://safegrd_backup:…@db.internal/app" -c "CREATE TABLE sg_probe(i int);"
# expect: ERROR: permission denied for schema public

safegrd doctor checks this for every PostgreSQL surface the host can open, and safegrd claim checks the surfaces it adds. For a database SafeGrd backs up itself, the console checks it when the connection string is saved and shows the same advice. The line Surface <id> role warns when the role is a superuser, can create roles or databases, or can write to any table, names up to three of those tables, and prints the two lines above as the fix. A second line, Surface <id> row security, names the tables whose rows row-level security hides from the role. A role that is not a superuser cannot read password hashes, so roles are backed up without their passwords (roles).

A table the role cannot read does not fail the backup. The snapshot leaves it out, with its partitions and sequences, and the backup prints a warning naming it and the grant that would include it. Views and row-level security policies that read a left-out table are left out too, and named in a second warning. A restore or restore --to-sql leaves out the foreign keys that point into those tables and says which, and a Fire Drill of the snapshot passes. The snapshot records the list in left-out.json. Needs CLI v0.0.39.

Supabase

Supabase runs PostgreSQL with pre-installed schemas for auth, storage and realtime. To back it up from GitHub Actions, with no server of your own, follow Back up Supabase with GitHub Actions. Or let SafeGrd take the backup: Back up on SafeGrd under Surfaces in the console (how it runs). Use the session pooler address below either way.

Pick a Supabase project in that form signs you in to Supabase and lists your projects. Then choose how SafeGrd connects:

A surface switches between the two from its Details: Credential replaces the sign-in with a connection string, and Sign in through Supabase does the reverse. If Supabase stops accepting the sign-in (you disconnected SafeGrd there, or removed the account's access to the project), the next backup fails before any machine starts and says so; Reconnect Supabase on the surface signs in again.

Amazon RDS and Aurora (PostgreSQL)

RDS restricts superuser access, but SafeGrd only needs a read-only role.

Neon

Neon is serverless Postgres with compute autosuspend.