Skip to content

Build1 publisher3 min readPublished

One --exclude-table-data flag shrinks this nightly Postgres backup; a separate R2 lifecycle rule keeps it a year

Munchable's nightly GitHub Actions job writes a full dump kept about 30 days plus a catalogue-free core dump kept a year. The preflight that fixed its first failure reads only the host and port out of the connection secret.

The Engineer · Build desk

Illustration accompanying One --exclude-table-data flag shrinks this nightly Postgres backup; a separate R2 lifecycle rule keeps it a year

What happened

  • Munchable's Postgres sits on a hosted tier that provides no managed backups, so a nightly GitHub Actions job is the only defence against data loss.
  • Each night the job writes two archives: a full dump kept about 30 days, and a core dump with the product catalogue's rows excluded that is kept for a year.
  • The catalogue is roughly 400 MB and dominates the dump, and it is also the half the team could rebuild from somewhere else.
  • Checks added 27 minutes after the job first shipped now preflight the connection string and run pg_restore --list over each archive to catch a dump that died halfway.

Compiled by The EngineerSomething wrong?How this is made

Why it matters

  • decision Copying this design means ranking each large table by whether anything outside the database can regenerate it, and if the biggest table is also the unrecoverable one, the second archive buys nothing.
  • constraint With deletion moved to bucket lifecycle rules, the credential the CI job holds cannot shorten history, and changing a retention window becomes a bucket-config change instead of a code change.
  • exposure A rename of the bulk table converts the cheap year-long prefix into a year of full-size dumps, and the only signal is a warning annotation inside a run that otherwise passes.
  • capability Because the few-megabyte archive is pushed first, a broken second upload still leaves that night's accounts, consent records and support threads recoverable.

`--exclude-table-data` keeps the table and drops the rows. The core archive still carries catalog.products' table definition, its indexes and its constraints [5]. Restore it and you get a schema-complete database with one empty table. You refill that table from whatever built the catalogue in the first place.

The split pays only when the big table is the rebuildable one. At Munchable the catalogue is roughly 400 MB, dominates the dump, and is the half that could be regenerated [6]. Accounts, entitlements, contribution rewards and their revisions, consent records, support threads and the curated taxonomy come to a few megabytes and cannot be [7].

The full dump is kept about 30 days and the core dump for a year [4][5], roughly twelve times as long for the archive that is cheap to hold [22]. Neither number is enforced by the job. The bucket has lifecycle rules per prefix instead [9], and the post is blunt about why: "The workflow never deletes anything, because deleting backups from CI is how you lose them to a date-math bug." [10] Both prefixes together fit inside R2's free storage tier at effectively zero cost [8].

The failure that started the hardening was a connection string [11]. The provider offers three ways in and two of them cannot work from this runner [12]. The direct host is IPv6-only on this plan and GitHub's hosted runners are IPv4-only [13]. The transaction pooler on port 6543 is the app's own DATABASE_URL, and pg_dump cannot run against it because transaction mode does not provide the session-level features it needs [14]. Only the session pooler on 5432 works [15].

So the job now reads the host and port out of the secret, tests them against those two cases, and exits in the first seconds with an error annotation that names the fix [15]. It never parses the password, and the post justifies reading the host by pointing out that libpq prints it in its own errors anyway [16]. Each message points at docs/DB-BACKUPS.md, so whoever opens a red run at 4am has somewhere to go [17].

Then two commands per file. `test -s` catches the zero-byte dump, and `pg_restore --list` parses the whole table of contents, which is how a dump that died halfway gets caught without a restore [18]. The third check compares the two file sizes: if the core dump is not smaller than the full one, `--exclude-table-data` matched nothing, most likely because the bulk table was renamed [19]. That one only warns; the job keeps going [19].

Upload order is deliberate. The small archive goes first, so a failure on the larger upload still leaves the important half in the bucket [20]. The write-up also notes that the runner ships an older Postgres client whose PATH entry wins by default [21].

Little of this is a template. The endpoint rules are one provider's topology [12][13] and the zero-cost figure is Cloudflare's pricing [8]. The transferable part is smaller: check the endpoint before running a dump against it, and parse the archive before trusting it. The post covers one repository.

What to watch

  • If GitHub's hosted runners gain IPv6 support, the direct-host branch of the preflight becomes dead code.
  • A restore drill on a year-old core archive would show whether the tiny dump plus a rebuilt catalogue produces a working database.
  • Any change to R2's free storage tier turns the effectively-zero-cost figure into a line item.
Loading claim ledger
Loading source directory links
Loading share composer
Loading topic controls
Loading related stories