Skip to content

feat(backups): derive an app's backup policies from its catalog entry - #578

Draft
AbdullahM07 wants to merge 1 commit into
oblien:mainfrom
AbdullahM07:feat/catalog-backup-defaults
Draft

feat(backups): derive an app's backup policies from its catalog entry#578
AbdullahM07 wants to merge 1 commit into
oblien:mainfrom
AbdullahM07:feat/catalog-backup-defaults

Conversation

@AbdullahM07

Copy link
Copy Markdown
Member

Opened as a draft on purpose. #577 asks four questions that decide this feature's shape, and none of them has been answered yet — this branch answers them by assumption so there's something concrete to argue with, not because the assumptions are settled. Say the word on any of them and I'll rework it. Nothing here is precious.

The assumptions, spelled out: (1) defaults apply only when the org already has a destination — no migration; (2) derived policies are created enabled, with a live cron; (3) backend only, no dashboard button yet; (4) no schemaVersion bump, because the new field is optional and additive.

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_policy row 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.ts returned 0 — and appTemplateSchema had 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:

  • A volume is the stateful signal. Every entry already declares its volumes because compose needs them. A service with one holds state; a service without one rebuilds from its image. No new field needed to say "back me up".
  • payload_kind: "auto" already knows what to run. It resolves through the producer registry's detect() chain (packages/adapters/src/backup/registry.ts), which picks pg_dump for 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 off 03:17 (the DEFAULT_BACKUP_TIME the 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 — optional backup.services[] on appTemplateSchema (skip, reason, payloadKind, payloadConfig, cronExpression, retainCount, retainDays), service names validated through the refSvc helper already in the file's superRefine. payloadKind is 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 for backup_policy.defaults_applied (uncatalogued events fail audit-taxonomy.test.ts).
  • Catalog entries: posthog skips redis/kafka/zookeeper — mid-flight broker state, not sources of truth, and the events that matter are already in ClickHouse. supabase skips imgproxy, which mounts the same supabase_storage_data volume as storage and would otherwise upload identical bytes twice a night. catalog.json regenerated with bun scripts/gen-catalog.ts.

apps/api

  • src/modules/backups/apply-defaults.service.ts (new) — translates the plan through the existing createPolicy, so cron validation, retention defaulting and syncPolicySchedule are inherited, not reimplemented. Three properties it's built around:
    • It never throws. By the time it runs the install has persisted the project, its services and its env — the install succeeded. A backup problem must not report that as a failure.
    • It's idempotent. Any service already carrying a policy is skipped, so the endpoint is safe to call twice and can't clobber a hand-tuned schedule or collide with the unique index on (project, service).
    • It won't invent a destination. destination_id is NOT NULL and kind: local destinations are deliberately gated behind BACKUP_ALLOW_LOCAL_DESTINATION; with no destination in the org it reports no-destination and does nothing rather than prising open a gate the operator left shut. A cross-org destinationId is treated as absent, with no fallback.
  • src/modules/apps/app-install.service.ts — called at the tail of installApp, wrapped, with applyBackupDefaults: false / backupDestinationId on the install body (plumbed through app.schema.ts + app.controller.ts).
  • src/modules/backups/backup.routes.ts + backup.controller.tsPOST /projects/:projectId/backup-policies/apply-defaults for projects installed before any of this existed, copying the neighbouring route's project:write + cloudProjectProxy shape. Reports ({ applied, skipped, services, reason? }) rather than 4xx-ing: nothing is wrong with the request when an org has no destination yet.

Verification

packages/core   43 files   587 tests   ✅   (12 new in test/backup-defaults.test.ts)
apps/api       104 files  1528 tests   ✅   (9 new applier + 5 new installer-wiring)
tsc --noEmit   packages/core ✅   apps/api ✅
$ cd packages/core && bunx vitest run
 Test Files  43 passed (43)
      Tests  587 passed (587)

$ cd apps/api && bunx vitest run test/modules/apps test/modules/backups test/lib
 Test Files  104 passed (104)
      Tests  1528 passed (1528)

$ cd packages/core && bunx tsc --noEmit   # exit 0
$ cd apps/api    && bunx tsc --noEmit     # exit 0

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 createPolicy call from an install — worth a maintainer running one install against a live destination before this merges.

Out of scope, deliberately

  • Dashboard button + i18n. Backend only, per the scope decision; the UI half means 9 locales and there's a parity ratchet in i18n-parity.test.ts. Happy to follow up once the API shape is agreed.
  • Custom app templates (custom_app_template) — same derivation would work, but uploads are a different trust story.
  • Mail servers — already have their own curated backup plan.
  • Making destination_id nullable, 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

@AbdullahM07
AbdullahM07 force-pushed the feat/catalog-backup-defaults branch from 1b1a800 to 82c03a8 Compare August 16, 2026 11:34
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
AbdullahM07 force-pushed the feat/catalog-backup-defaults branch from 82c03a8 to 009232b Compare August 16, 2026 11:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant