Context
Backups themselves work. What doesn't exist is anything that sets them up for you — every policy in the system is hand-built, one service at a time, and nothing derives a policy from what an app actually is.
I went looking for where an app's default backup could possibly come from and traced the whole path for it — the catalog schema, the installer, every policy-creation call site, and the producer registry. There is no such path: nothing between "install this app" and "this app is backed up" exists today. Every finding below came out of that trace and each one is checkable in about a minute.
Opening this as a feature request rather than a bug, and per the PR template's rule that new features need scope and approach agreed in an issue first. Verified against 8443f1ec.
What the manual path costs today
Exactly two call sites create a backup_policy row, and both are driven by an explicit user action:
backup.controller.ts:65 → createPolicy (backup.service.ts:47), behind POST /projects/:projectId/backup-policies (backup.routes.ts:31)
mail.controller.ts:1470, the mail admin backup tab
createPolicy takes ten caller-supplied fields: destinationId, cronExpression, triggerOnPreDeploy, enableWebhook, retainCount, retainDays, payloadKind, payloadConfig, preHook, postHook. PolicyEditor.tsx renders all of them, per service.
The app installer creates none. grep -ci backup apps/api/src/modules/apps/app-install.service.ts → 0.
And the catalog has no way to ask for one. appTemplateSchema (packages/core/src/apps/schema.ts:251) declares services, configFields, settings, prepare, connection, endpoints, files, provides, requires, minResources — there is no backup field of any kind. None of the 28 bundled entries mentions one: grep -l backup packages/core/src/apps/catalog/*.json returns nothing.
How much of the catalog this leaves uncovered
24 of the 28 bundled apps ship at least one service with a persistent volume — 33 stateful services in total:
| app |
stateful services |
| posthog |
6 |
| supabase |
3 |
| ghost, neon |
2 each |
| code-server, convex, directus, freshrss, gitea, grafana, kafka, meilisearch, metabase, minio, mongodb, n8n, nocodb, qdrant, redis, stirling-pdf, umami, uptime-kuma, vaultwarden, webmail |
1 each |
Installing any of them is one click. Protecting the data it just created is not: it's a trip to Backup settings and the ten-field form, once per service. PostHog is six policies by hand. Until that happens the app is running with no backup coverage at all, and nothing in the product says so.
Most of the machinery for this already exists
This is wiring, not a new engine:
- The table already models it.
backup_policy (packages/db/src/schema/backup.ts:107) has the per-service cascade built in — serviceId NULL is the project default, non-null is a service override, and the resolution is pick-one rather than a JSON merge. A per-service default is the shape it was designed for.
- The payload kind can usually be left alone.
payloadKind: "auto" resolves through resolveProducerForService (packages/adapters/src/backup/registry.ts:94), walking detect() in registration order over six self-registering producers — volume, pg_dump, mysql_dump, mongo, redis, custom_command — with volume as the universal fallback. So a catalog declaration mostly does not need to name a producer.
- The one-click destination concept is already there.
backup_destination.isDefault (packages/db/src/schema/backup.ts:85) is commented, verbatim, as "the default one-click target".
- Sane defaults already land.
createPolicy defaults retention to DEFAULT_RETAIN_COUNT when neither retention field is given, and calls syncPolicySchedule itself.
- There is already a precedent, hardcoded for one app.
apps/api/src/modules/mail/admin/backup-plan.ts turns operator checkboxes into the custom_command producer's produce/restore shell, so the operator never writes shell for a mail backup. Openship curating the hard part of a policy for a known workload is an established pattern here — it just isn't available to the catalog.
What I'd like to add
- An additive, optional
backup declaration on appTemplateSchema, scoped per service, so a catalog entry can state the sensible default backup for each of its services.
- The installer applies it — one call into the existing
createPolicy per declared service, no new backup machinery.
- A retro-apply path for apps that are already installed, so existing projects get the same one click rather than only new installs.
Rough shape, for discussion only:
{
"id": "ghost",
"services": [{ "name": "ghost" }, { "name": "db" }],
"backup": {
"services": [
{ "service": "db", "payloadKind": "auto", "cronExpression": "17 3 * * *", "retainCount": 7 },
{ "service": "ghost", "payloadKind": "volume", "cronExpression": "37 3 * * *", "retainCount": 7 }
]
}
}
Open questions — these need a maintainer's call, not mine
- The destination is the real knot.
backup_policy.destination_id is NOT NULL with onDelete: "restrict" (packages/db/src/schema/backup.ts:135), so a policy without a destination cannot exist — and a fresh org has no destination at all. Three ways out, and I don't want to pick one unilaterally: (a) create the policies enabled: false and surface a "backups are configured, choose where they go" prompt; (b) only apply catalog defaults when an isDefault destination exists, and otherwise offer the one click later; (c) auto-provision a local destination on first install. My preference is (a) — the intent is recorded and visible instead of silently skipped — but (c) changes storage behavior without asking, so it's a product decision.
- Enabled with a live cron, or created idle? Auto-scheduling a nightly job that writes to a user's S3 bucket has a consent and a billing dimension. I'd default to created-but-idle and let the one click arm them, but this is the crux of "one click and everything is set up" and should be decided deliberately.
- How far does the retro-apply reach? Per project, per app, or an org-wide "protect everything that has a volume"? The wider it goes the more it needs a preview of what it's about to create.
- Does this want a
schemaVersion bump? MAX_SUPPORTED_SCHEMA is 1 and unknown keys are stripped rather than rejected (packages/core/src/apps/schema.ts:12), so an optional additive field means older builds ignore a backup block instead of dropping the entry — remote overlay entries stay ingestible either way. That suggests no bump is needed, but I'd rather confirm than assume.
Scope
No new producer, no new destination kind, no change to the orchestrator FSM or the run/restore state machines. The catalog gains a declaration, the installer gains a call to the policy creator that already exists, and per-service defaults land in the same table the manual form writes to.
Happy to implement this once there's agreement on the four questions above — particularly (1) and (2), since they decide what "one click" actually does on a brand-new instance.
Context
Backups themselves work. What doesn't exist is anything that sets them up for you — every policy in the system is hand-built, one service at a time, and nothing derives a policy from what an app actually is.
I went looking for where an app's default backup could possibly come from and traced the whole path for it — the catalog schema, the installer, every policy-creation call site, and the producer registry. There is no such path: nothing between "install this app" and "this app is backed up" exists today. Every finding below came out of that trace and each one is checkable in about a minute.
Opening this as a feature request rather than a bug, and per the PR template's rule that new features need scope and approach agreed in an issue first. Verified against
8443f1ec.What the manual path costs today
Exactly two call sites create a
backup_policyrow, and both are driven by an explicit user action:backup.controller.ts:65→createPolicy(backup.service.ts:47), behindPOST /projects/:projectId/backup-policies(backup.routes.ts:31)mail.controller.ts:1470, the mail admin backup tabcreatePolicytakes ten caller-supplied fields:destinationId,cronExpression,triggerOnPreDeploy,enableWebhook,retainCount,retainDays,payloadKind,payloadConfig,preHook,postHook.PolicyEditor.tsxrenders all of them, per service.The app installer creates none.
grep -ci backup apps/api/src/modules/apps/app-install.service.ts→0.And the catalog has no way to ask for one.
appTemplateSchema(packages/core/src/apps/schema.ts:251) declaresservices,configFields,settings,prepare,connection,endpoints,files,provides,requires,minResources— there is no backup field of any kind. None of the 28 bundled entries mentions one:grep -l backup packages/core/src/apps/catalog/*.jsonreturns nothing.How much of the catalog this leaves uncovered
24 of the 28 bundled apps ship at least one service with a persistent volume — 33 stateful services in total:
Installing any of them is one click. Protecting the data it just created is not: it's a trip to Backup settings and the ten-field form, once per service. PostHog is six policies by hand. Until that happens the app is running with no backup coverage at all, and nothing in the product says so.
Most of the machinery for this already exists
This is wiring, not a new engine:
backup_policy(packages/db/src/schema/backup.ts:107) has the per-service cascade built in —serviceIdNULL is the project default, non-null is a service override, and the resolution is pick-one rather than a JSON merge. A per-service default is the shape it was designed for.payloadKind: "auto"resolves throughresolveProducerForService(packages/adapters/src/backup/registry.ts:94), walkingdetect()in registration order over six self-registering producers —volume,pg_dump,mysql_dump,mongo,redis,custom_command— withvolumeas the universal fallback. So a catalog declaration mostly does not need to name a producer.backup_destination.isDefault(packages/db/src/schema/backup.ts:85) is commented, verbatim, as "the default one-click target".createPolicydefaults retention toDEFAULT_RETAIN_COUNTwhen neither retention field is given, and callssyncPolicyScheduleitself.apps/api/src/modules/mail/admin/backup-plan.tsturns operator checkboxes into thecustom_commandproducer's produce/restore shell, so the operator never writes shell for a mail backup. Openship curating the hard part of a policy for a known workload is an established pattern here — it just isn't available to the catalog.What I'd like to add
backupdeclaration onappTemplateSchema, scoped per service, so a catalog entry can state the sensible default backup for each of its services.createPolicyper declared service, no new backup machinery.Rough shape, for discussion only:
{ "id": "ghost", "services": [{ "name": "ghost" }, { "name": "db" }], "backup": { "services": [ { "service": "db", "payloadKind": "auto", "cronExpression": "17 3 * * *", "retainCount": 7 }, { "service": "ghost", "payloadKind": "volume", "cronExpression": "37 3 * * *", "retainCount": 7 } ] } }Open questions — these need a maintainer's call, not mine
backup_policy.destination_idisNOT NULLwithonDelete: "restrict"(packages/db/src/schema/backup.ts:135), so a policy without a destination cannot exist — and a fresh org has no destination at all. Three ways out, and I don't want to pick one unilaterally: (a) create the policiesenabled: falseand surface a "backups are configured, choose where they go" prompt; (b) only apply catalog defaults when anisDefaultdestination exists, and otherwise offer the one click later; (c) auto-provision alocaldestination on first install. My preference is (a) — the intent is recorded and visible instead of silently skipped — but (c) changes storage behavior without asking, so it's a product decision.schemaVersionbump?MAX_SUPPORTED_SCHEMAis 1 and unknown keys are stripped rather than rejected (packages/core/src/apps/schema.ts:12), so an optional additive field means older builds ignore abackupblock instead of dropping the entry — remote overlay entries stay ingestible either way. That suggests no bump is needed, but I'd rather confirm than assume.Scope
No new producer, no new destination kind, no change to the orchestrator FSM or the run/restore state machines. The catalog gains a declaration, the installer gains a call to the policy creator that already exists, and per-service defaults land in the same table the manual form writes to.
Happy to implement this once there's agreement on the four questions above — particularly (1) and (2), since they decide what "one click" actually does on a brand-new instance.