Database Backups
This page covers troubleshooting. For the initial rclone/Google Drive setup walkthrough, see Database Backups (rclone / Google Drive).
Backup vs. restore are not the same tool
A backup script only ever writes — it dumps the database and uploads the result; nothing about that process reads it back. Restoring is a manual, separate step, run against whatever target database you choose. Treat restore as deliberately manual and un-automated, since it’s a destructive operation against whatever connection you point it at.
Format matters — the most common restore mistake
A plain pg_dump with no explicit format flag produces plain-text SQL
by default, not a binary archive format. That means:
- The output is a literal
.sqlscript ofCREATE TABLE/INSERT/etc. statements (commonly gzipped for storage). - It must be restored with
psql(or a SQL-script-execution tool), never a binary-archive restore tool — a binary-archive tool only understands the binary formats and fails immediately with something like “did not find magic string in file header” on a plain-text file, even after decompressing it correctly. That error means “wrong tool for this file’s format,” not “the file is corrupted.”
To restore from the command line:
psql --host=<target-host> --port=5432 --username=<user> --dbname=<target-db> -f backup.sqlRestoring into a non-empty database
If the dump wasn’t created with clean/drop guards, replaying it against a database that already has these tables will fail with “relation already exists” for every table. Either restore into an empty database/schema, or manually drop the conflicting objects first.
Recovering same-day data after restoring an older backup
A common scenario: the database fails partway through a day, after new rows
were already created since the last backup ran. Restoring the most recent
backup brings back everything up to that point, but not the hours of data
added afterward — and naively replaying the backup on top of the current
(damaged but still-populated) database doesn’t work either, for the same
“relation already exists” / duplicate-row reason described above. This is a
general property of plain, non---clean dumps, not specific to any one
table or app.
The reliable approach is to restore into a separate, empty target first, then reconcile — never restore directly onto a live database that already has rows:
Restore the backup into a throwaway/empty database
A new local database, a temporary schema, or a second scratch instance — anywhere empty. This gives a clean copy of “state as of the last backup” with zero risk of touching the live data while you work.
Assess whether the live database is still readable
This is the fork in the road. If the live database survived (e.g. it’s
degraded/misbehaving but you can still run SELECT queries against it),
today’s newer rows are recoverable. If it’s fully gone or unreadable, only
the backed-up state can be recovered — anything created after the backup
ran is lost, and the rest of this procedure doesn’t apply.
Pull out only the rows created since the backup
Query the live database for rows newer than the backup’s timestamp (most
schemas have a created_at/similar column for this), scoped per table:
SELECT * FROM <table> WHERE created_at > '<backup timestamp>';Export those rows (e.g. via COPY ... TO, or a scripted INSERT dump) so
they can be replayed against the restored copy.
Insert those rows into the restored copy
If the schema uses randomly-generated identifiers (UUIDs are the common case), there’s no numeric-sequence collision risk here — a row’s ID from the live database is already unique and can be inserted as-is into the restored copy, since that copy doesn’t contain it yet. This is what makes the “conflicting IDs” fear usually a non-issue in practice: the actual failure mode is schema/row duplication (replaying a backup over data it already contains), not ID collisions between two genuinely different periods of data.
If the schema instead relies on auto-incrementing integer IDs, check whether the restored copy’s sequence counters need to be advanced first, so newly-inserted rows (and anything the app creates going forward) don’t collide with IDs that already exist in what you’re re-inserting.
Promote the reconciled copy
Once the restored-and-reconciled database has both the backed-up state and today’s recovered rows, it becomes the new source of truth — point the application at it in place of the damaged original.
This procedure depends entirely on the live database still being queryable. A backup/restore setup like this protects against “lost the server, lost the disk, lost everything” — it does not, by itself, guarantee zero data loss for a mid-day failure. If preventing any loss of same-day data matters, that requires a different mechanism entirely (continuous replication, point-in-time recovery via WAL archiving, or more frequent backups) rather than a once-daily dump-and-upload script.
Rolling backups without separate pruning
A simple, low-maintenance rotation pattern: name each day’s backup file by day-of-week (not by date), so uploading day 8’s backup naturally overwrites day 1’s file at the storage destination. This gives a fixed rolling window (e.g. 7 days) with no separate cleanup/pruning job needed — the upload step is the pruning step.
How you find out about a failure
The backup script posts to a Slack webhook (SLACK_WEBHOOK_URL, see
setup) whenever it
fails, so an outage surfaces the same day instead of being discovered by
manually eyeballing file timestamps in Drive. This is exactly how a real
incident was found in practice: rclone’s OAuth token expired
(invalid_grant: maybe token expired?) and the upload step failed
silently for a full week before anyone noticed — the day-of-week rotation
made the gap easy to miss, since a stale slot still looks like a normal,
just-older file rather than an obviously-missing one. If
SLACK_WEBHOOK_URL isn’t configured, backup.log still records every
failure, but nothing actively pages anyone.
OAuth-based cloud storage uploads: common failure signatures
If a backup script uploads to a cloud storage provider via an OAuth-based CLI tool:
- “Command not found” in the backup log — the upload tool was never installed on the host; the cron entry exists but every run fails at the upload step.
- Auth error / “couldn’t fetch token” /
invalid_grant: maybe token expired?— the OAuth token expired or was never fully configured. Most commonly this means the OAuth consent screen is still in Testing status, which makes Google auto-expire the refresh token after exactly 7 days regardless of how often the script runs successfully in between — publish the app (see setup) rather than just reconnecting, or the same failure recurs on the same weekly cadence. Re-run the tool’s reconnect/re-auth flow (rclone config reconnect gdrive:) to restore uploads immediately either way. - “Access blocked” / “developer-approved testers only” during the authorize step — the OAuth consent screen exists but the account doing the authorizing isn’t on its allowed testers list (or a different account than expected is logged into the browser during the interactive auth step).
- Only some of the expected rotation slots ever appear — each dated/named slot is only created the first time that slot’s backup actually succeeds; this is expected until a full rotation cycle has completed at least once, not a bug.
If using a shared/default OAuth client for the storage provider’s API, check whether it’s being deprecated — providers periodically retire shared developer credentials used by CLI tools, which silently breaks scheduled uploads with no warning until the client ID is fully retired. Setting up your own OAuth client is generally worth doing proactively rather than reactively once uploads start failing.