Docs / Surfaces / Databases

Databases

SafeGrd supports backups across four database engines. Backups are captured using native database tooling from a consistent snapshot where supported, then streamed, compressed, and encrypted directly on the host before being written to locked storage. No superuser privileges are required; a read-only role provides sufficient access for backups.

DatabaseWhat is capturedThe host needs
PostgreSQLSchema from pg_dump, rows over binary COPY, one snapshotpg_dump at least as new as the server
MySQL and MariaDBmysqldump --single-transactionThe MySQL client
MongoDBmongodump --archiveThe MongoDB Database Tools
SQLiteOne committed state, through SQLite's online backupNothing

PostgreSQL

# 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).

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 from the connection string: Back up on SafeGrd under Surfaces in the console (how it runs). Use the session pooler address below either way.

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.

MySQL and MariaDB

# MySQL 8.0 and 8.4, MariaDB 10.6 and 11.4 (each run by the test suite)
safegrd backup --database-url "mysql://backup:$DB_PASSWORD@db.internal:3306/shop" --retention-days 14

MySQL surfaces run mysqldump (or mariadb-dump) with --single-transaction, which reads InnoDB tables from one consistent snapshot without locking them. Backups include stored routines, triggers and events, with binary columns written as hex. The output streams into the encrypted archive as it is produced. Row counts are read from the dump stream itself, so they match the rows in the backup even while the database is being written to.

Amazon RDS and Aurora (MySQL)

Managed MySQL on RDS does not grant SUPER privileges, but SafeGrd does not need them.

MongoDB

# MongoDB 7 and 8 (each run by the test suite)
safegrd backup --database-url "mongodb://backup:$DB_PASSWORD@db.internal:27017/app?authSource=admin" --retention-days 14

MongoDB surfaces capture database archives using native mongodump --archive, streaming data directly into the snapshot stream. Because the archive uses MongoDB's standard format, it can be restored directly with mongorestore independently of SafeGrd. Document counts and collection checksums generated by mongodump are validated as the stream passes.

SQLite

# a database file on this host; no client tools needed
safegrd backup --database-url sqlite:///var/lib/app/app.db --retention-days 14

That is one backup, now. For backups on a schedule, with Fire Drills, name the file as a surface in ~/.safegrd/config.yaml and run the daemon. Pick one of the two for a database: a surface starts its own history, so backups taken with --database-url before it show in the console as a separate surface.

# ~/.safegrd/config.yaml
surfaces:
  - id: app-db
    type: sqlite
    database_url: sqlite:///var/lib/app/app.db
    schedule: 6h
# check it, then run it as a service
safegrd config validate && safegrd daemon install

A file copied off the disk while the app writes can be torn, and in WAL mode the newest transactions are still in the -wal file, which a copy of the database file misses. SafeGrd copies the database through SQLite itself, inside one read transaction: every committed transaction is in the copy, the WAL's included, and writers carry on.

Where the connection URL comes from

PostgreSQL, MySQL and MongoDB connect with a URL. SQLite has no secret: a surface names its file as database_url: sqlite:///var/lib/app/app.db, with no credential block. You choose where each URL lives, per database, and the two can be mixed on one host:

surfaces:
  - id: "app-database"
    type: "postgres"
    credential:   # the URL stays on the host
      from: env
      name: "APP_DATABASE_URL"
  - id: "billing-db"
    type: "mysql"
    credential:   # SafeGrd holds the URL
      from: safegrd

A surface takes its credential from one place. If it has both from: safegrd and a database_url on the host, the backup stops with a configuration conflict error. Having SafeGrd hold the database credential does not make it hold the encryption key. Those are separate choices (Security and key custody).

What a Fire Drill checks

DatabaseWhat the drill checks
PostgreSQLValidates table counts, row counts, installed extensions and the roles the schema names (roles, owners and grants) against the snapshot manifest while verifying binary COPY streams. In-memory drills verify data integrity without running a live database server. Sandbox drills (using --sandbox-target or the daemon's drill.sandbox_url) restore into a temporary database to verify schema definitions and table constraints. On a plan with sandbox drills, a surface with no drill.sandbox_url restores into a throwaway cluster the daemon starts from the host's PostgreSQL server (Daemon).
MySQL and MariaDBConfirms dump completeness by checking terminal markers, verifying table structures, and validating row counts against recorded manifest metadata. In-memory drills check dump integrity; sandbox drills (via --sandbox-target mysql://… or drill.sandbox_url) load the dump into a scratch database, verify table counts, and clean up the database upon completion.
MongoDBVerifies archive stream termination, matching document counts and collection checksums against values recorded by mongodump. Sandbox drills restore the archive into a temporary database using mongorestore, verify collection counts, and drop the temporary collections afterward.
SQLiteExecutes a complete test restore: the database is written to a temporary location, verified with PRAGMA integrity_check, and confirmed against table row counts before the temporary file is removed.

Every surface's drill, files and mail included, is compared in the table on the Surfaces page.