feat(backups): derive an app's backup policies from its catalog entry - #578
Draft
AbdullahM07 wants to merge 1 commit into
Draft
feat(backups): derive an app's backup policies from its catalog entry#578AbdullahM07 wants to merge 1 commit into
AbdullahM07 wants to merge 1 commit into
Conversation
AbdullahM07
force-pushed
the
feat/catalog-backup-defaults
branch
from
August 16, 2026 11:34
1b1a800 to
82c03a8
Compare
Installing a catalog app is one click. Protecting what it stores was the
ten-field policy form, once per service — and 24 of the 28 bundled apps ship at
least one service with a persistent volume, 33 stateful services in total. A
fresh PostHog meant six hand-built policies before any of its data was covered,
and nothing in the product said the data was uncovered in the meantime.
Nothing derived a policy from what an app IS: the only two call sites that
create a backup_policy row are the dashboard form's endpoint and the mail admin
tab, both driven by an explicit user action. The installer created none
(`grep -ci backup app-install.service.ts` was 0), and appTemplateSchema had no
backup field for an entry to ask with.
Two things already in the codebase make this derivable rather than 33 authored
policies. A volume is the stateful signal — every entry already declares its
volumes because compose needs them. And `payload_kind: "auto"` already resolves
the right producer per service through the registry's detect() chain, so a
derived plan defers the pg_dump-vs-volume decision to the same lookup the manual
path uses instead of guessing from an image tag.
So `planAppBackupDefaults` (pure, in packages/core) plans one policy per
stateful service, staggered seven minutes apart off the 03:17 the dashboard
already defaults to — six policies at the same minute would start six dumps on
one box competing for the same disk, which is how a backup window becomes an
outage. The new optional `backup` block on a template CORRECTS that plan rather
than restating it: PostHog skips its broker tier (redis, kafka, zookeeper are
mid-flight state, not sources of truth), and Supabase skips imgproxy, which
mounts the same volume as `storage` and would otherwise upload identical bytes
twice every night.
`applyBackupDefaults` translates the plan through the EXISTING `createPolicy`,
so cron validation, retention defaulting and syncPolicySchedule are inherited
rather than reimplemented. Three properties it is built around:
- it never throws. The install has already persisted the project, its
services and its env by the time this runs, so a backup problem must not
report a successful install as a failure.
- it is idempotent. Any service that already has a policy is skipped, so the
apply-defaults endpoint is safe to call twice and cannot clobber a
hand-tuned schedule or collide with the unique index.
- it will not invent a destination. destination_id is NOT NULL and local
destinations are deliberately gated behind BACKUP_ALLOW_LOCAL_DESTINATION,
so with no destination in the org it reports `no-destination` and does
nothing rather than prising open a gate the operator left shut.
Both callers land on that one function: the installer tail (opt out with
`applyBackupDefaults: false`) and POST /projects/:id/backup-policies/
apply-defaults for projects installed before any of this existed.
Related issue: oblien#577
AbdullahM07
force-pushed
the
feat/catalog-backup-defaults
branch
from
August 16, 2026 11:49
82c03a8 to
009232b
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
An app template can now declare what it wants backed up, and installing it creates the policies — one per stateful service, on the org's default destination, instead of the ten-field form once per service.
Motivation
Installing a catalog app is one click. Protecting what it stores was not. 24 of the 28 bundled apps ship at least one service with a persistent volume — 33 stateful services in total (posthog 6, supabase 3, ghost/neon 2), so a fresh PostHog meant six hand-built policies before any of its data was covered, and nothing in the product said it was uncovered in the meantime.
Nothing derived a policy from what an app is. The only two call sites that create a
backup_policyrow are the dashboard form's endpoint (backup.controller.ts) and the mail admin tab (mail.controller.ts), both driven by an explicit user action. The installer created none —grep -ci backup apps/api/src/modules/apps/app-install.service.tsreturned0— andappTemplateSchemahad no backup field for an entry to ask with.Full write-up, with the trace it came from: #577.
Changes
Two things already in the tree made this derivable rather than 33 authored policies, and both are load-bearing for how small the diff is:
payload_kind: "auto"already knows what to run. It resolves through the producer registry'sdetect()chain (packages/adapters/src/backup/registry.ts), which pickspg_dumpfor Postgres,mysql_dump,mongo_dump,redis_rdb, and falls back to a volume copy. So a derived plan defers that choice to the same lookup the manual path uses rather than guessing from an image tag.packages/core
src/apps/backup-defaults.ts(new) —planAppBackupDefaults(template), pure: no DB, no clock, no I/O. One policy per stateful service, staggered 7 minutes apart off03:17(theDEFAULT_BACKUP_TIMEthe dashboard already offers). Six policies on the same minute would start six dumps on one box competing for the same disk and uplink, which is how a backup window becomes an outage.src/apps/schema.ts— optionalbackup.services[]onappTemplateSchema(skip,reason,payloadKind,payloadConfig,cronExpression,retainCount,retainDays), service names validated through therefSvchelper already in the file'ssuperRefine.payloadKindis an enum rather than a free string so a typo is rejected at the ingest gate instead of surfacing at 03:17 with nothing backed up — that does couple it to the registry, noted in the comment.src/constants.ts,src/app-templates.ts,src/audit-taxonomy.ts— shared default time, the TS view of the new field, and the catalogue entry forbackup_policy.defaults_applied(uncatalogued events failaudit-taxonomy.test.ts).redis/kafka/zookeeper— mid-flight broker state, not sources of truth, and the events that matter are already in ClickHouse. supabase skipsimgproxy, which mounts the samesupabase_storage_datavolume asstorageand would otherwise upload identical bytes twice a night.catalog.jsonregenerated withbun scripts/gen-catalog.ts.apps/api
src/modules/backups/apply-defaults.service.ts(new) — translates the plan through the existingcreatePolicy, so cron validation, retention defaulting andsyncPolicyScheduleare inherited, not reimplemented. Three properties it's built around:destination_idisNOT NULLandkind: localdestinations are deliberately gated behindBACKUP_ALLOW_LOCAL_DESTINATION; with no destination in the org it reportsno-destinationand does nothing rather than prising open a gate the operator left shut. A cross-orgdestinationIdis treated as absent, with no fallback.src/modules/apps/app-install.service.ts— called at the tail ofinstallApp, wrapped, withapplyBackupDefaults: false/backupDestinationIdon the install body (plumbed throughapp.schema.ts+app.controller.ts).src/modules/backups/backup.routes.ts+backup.controller.ts—POST /projects/:projectId/backup-policies/apply-defaultsfor projects installed before any of this existed, copying the neighbouring route'sproject:write+cloudProjectProxyshape. Reports ({ applied, skipped, services, reason? }) rather than 4xx-ing: nothing is wrong with the request when an org has no destination yet.Verification
The new tests cover the failure modes rather than the happy path: no destination, a policy that already exists, a planned service whose row is missing, a cross-org destination id, an install that still succeeds when the applier throws, one audit event per apply rather than per policy, and — over the real bundled catalog — that no two policies are ever planned over one shared volume.
What I could not verify, stated plainly: there is no end-to-end run behind this. A real check (install an app, watch a policy fire against a live destination, restore from it) needs Postgres and a configured backup destination, and I have neither in this environment. Every number above is from mocked unit tests and typecheck. The riskiest untested seam is the first real
createPolicycall from an install — worth a maintainer running one install against a live destination before this merges.Out of scope, deliberately
i18n-parity.test.ts. Happy to follow up once the API shape is agreed.custom_app_template) — same derivation would work, but uploads are a different trust story.destination_idnullable, which is what recording backup intent before a destination exists would take. That's option (1) in Feature: let a catalog app declare its default backup policy, so one click covers every service instead of a ten-field form each #577 and it needs a migration, so it's a maintainer's call, not mine.Related issue: #577