Self-Service Restore¶
The portal can let an organization administrator restore a service instance from its backups. This page covers what staff need to enable and operate the feature. The end-user view is described in Restore.
Restore is configured in the restore block of backup_config on the Service
Definition, next to the sources and access blocks described in
Backup Visibility and Access. Design notes live in the portal
repository: docs/plans/2026-09-16-self-service-restore-design.md.
How it works¶
A restore is not an operation on an existing instance. It is a new instance whose
spec carries a restore block naming the source claim. AppCat resolves the source
claim, copies its backup bucket credentials into the new instance's namespace, and
bootstraps the new instance from the source's archive.
Everything else follows from that:
- The source claim must live in the same namespace as the new claim, which means the same organization and the same control plane. There is no target to select.
- The source instance keeps running and keeps billing. A restore always produces a second instance, and the cutover is an application-side connection change the portal cannot make.
- There is no in-place restore, no restore progress resource, and no rollback. The restored instance is an ordinary instance from the first reconcile.
- The portal adds no second write path: a restore goes through
create_instance, the same call the create view uses.
Which services can be enabled¶
Only services whose AppCat composition function actually reads
spec.parameters.restore. Verified against github.com/vshn/appcat on 2026-09-16:
| Service | Mode | User-supplied fields |
|---|---|---|
| PostgreSQL (CNPG) | point in time | claimName, optional recoveryTimeStamp |
| Keycloak | point in time | claimName, optional recoveryTimeStamp |
| Redis | snapshot | claimName, backupName |
A restore block in a CRD is not evidence that restore works
MariaDB, MinIO, OpenBao, and Nextcloud declare a restore block in their CRDs
that no composition function reads. Forgejo and Codey have no such field at all.
Enabling restore for those services produces a brand-new empty instance that
the portal, its annotations, and the audit log all describe as a restore, and
whose recommended next step is deleting the instance that still holds the data.
The portal guards against the typo case (see Path validation) but it cannot tell that a field exists and is ignored. Enabling a service is a deliberate decision that needs an AppCat check first.
The two restore contracts¶
They disagree fundamentally, and the difference reaches the UI.
Point in time (PostgreSQL, Keycloak). recoveryTimeStamp is a free point in time
against the WAL archive, and an empty value means "the latest available state".
Individual backup objects are not selectable: the CNPG path never reads backupName.
The CRD pattern requires %Y-%m-%dT%H:%M:%SZ, with no fractional seconds, which the
portal enforces rather than discovering at the API server.
Two consequences worth knowing in support:
- CNPG resolves the archive path from the new instance's
majorVersion. A mismatch surfaces as "no backups found" hours into a restore rather than as an error, and the same mechanism makes a restore the way back out of a failed major version upgrade. - Keycloak copies the source instance's admin credentials into the restored
instance, so its admin password is the old one. Say so in the
notekey.
Snapshot (Redis). backupName is required and must be the K8up Snapshot
object name in the source instance namespace, which is exactly what the backup
listing preserves verbatim. There is no point-in-time option and no "latest": a
restore is impossible until a snapshot exists.
Configuring a service definition¶
The restore block sits inside backup_config, because it depends on the backup
sources: the snapshot dropdown reads one of them, and the point-in-time window is
derived from them.
"restore": {
"mode": "point_in_time", // or "snapshot"; required
"claim_name_path": "spec.parameters.restore.claimName",
"timestamp_path": "spec.parameters.restore.recoveryTimeStamp", // point_in_time
"backup_name_path": "spec.parameters.restore.backupName", // snapshot
"snapshot_source": "<label of one entry in sources>", // snapshot
"exclude_paths": ["spec.parameters.service.fqdn", "spec.parameters.restore"],
"note": "The restored instance keeps the source instance's admin password."
}
claim_name_pathis where the source claim name is written. The value always comes from the source instance, never from the submitted form.exclude_pathsare reset to their schema default in the restore form's prefill, instead of being cloned from the source. The instance address (FQDN) belongs here, since it is still in use by the running source. List the source's ownrestoreblock too, so a restore of a restore does not inherit the first restore'sclaimName. The portal stripsspec.parameters.restorefrom the clone base in any case.noteis shown on the restore form and on the restored instance's detail card. Use it for anything the user has to know before submitting, such as inherited credentials.- Do not set
backup_name_pathfor a point-in-time service. It would offer a choice that changes nothing.
The paths a restore writes are also the paths the portal owns: they are excluded from
the restore form and rendered hidden and disabled in the CRD forms, so a later edit
cannot repoint claimName or add a restore block to an instance that never was one.
Validation on save¶
validate_restore_config runs when the service definition is saved and rejects:
- a
restoreblock that has something in it but no validmode, - a missing
claim_name_path, snapshotmode withoutbackup_name_path, or with asnapshot_sourcethat matches nolabelinsources,point_in_timemode withouttimestamp_path,- a
restoreblock with neithersourcesnordependent_labels. Keycloak's PostgreSQL is a dependent service, so its definition carriesdependent_labelsand no source of its own, which is why both are accepted.
Blank values are dropped before validation, so the JSON editor materialising every property of the schema still reads as "restore not configured".
Path validation¶
Before the restore form is offered, the configured paths are checked against the
CRD schema of the control plane. The API server prunes unknown fields silently, so a
typo in claim_name_path would otherwise produce an empty instance that everything
else calls a restore. A definition that fails this check shows the user "Self-service
restore is misconfigured for this service and would create an empty instance. Please
contact support." A schema that cannot be fetched is not treated as a failure.
Prerequisites¶
Restore reads the backup listing to build the restore point picker and to decide availability, so the Kubernetes RBAC described in Backup Visibility and Access applies here too. Nothing beyond the create permissions the portal already needs is required to write the restored claim.
Permissions¶
core.restore_serviceinstance: staff or organization administrator (which includes
owners), the same shape as core.access_backup_credentials_serviceinstance.
Not for confidentiality (a member can already read the source's connection credentials) but because a restore doubles what a service costs and starts a cutover somebody has to finish. For Keycloak there is a confidentiality angle as well, since the restored instance inherits the source's admin password.
Members see the Restore button disabled with the reason, rather than hidden: hiding it would read as "this service has no restore", which is a different and wrong answer.
Guardrails¶
get_restore_availability is the only place the preconditions live, and the button,
the form's GET and its POST all go through it, so a stale page or a crafted request
cannot reach a restore the detail page refused to offer. In order:
- the service definition has a usable
restoreconfig, - the user has the
restorepermission (checked before the first Kubernetes call), - the configured paths exist in the CRD schema,
- the instance spec is readable,
- the instance has a
status.instanceNamespace, so the claim is provisioned, - the instance is not paused,
- there is no
unmanagedBucketin the spec: the credential copy job reads a platform-managed secret that a customer-owned bucket does not produce, so the restore would hang rather than fail cleanly, - the organization is below
max_service_instances, checked here rather than after the form is filled in, - backups exist. A hard block in snapshot mode, a warning in point-in-time mode, where an empty listing does not prove the WAL archive is empty.
A listing that could not be read is kept apart from an empty one: snapshot mode blocks with a retry message, point-in-time mode proceeds with the window flagged as unknown.
create_instance refuses a restore itself as well, before creating anything, when
the source is in another organization or on another control plane, when the service
has no restore configuration, or when snapshot mode was asked for with no snapshot.
The availability gate is a UI check against staff-editable configuration; the model
API has to refuse itself.
What is recorded¶
- Database:
ServiceInstance.restored_from(self-FK,SET_NULL),restored_from_nameandrestore_point. The name is denormalised because deleting the source is the documented end of a successful cutover. - Annotations:
servala.com/restoredFromandservala.com/restorePointon the custom resource, so the relationship survives a portal-side rebuild.sync_instancesreads both back on import and resolves the foreign key when the source still exists. "Latest available state" travels as the literallatest, which makes it a reserved value on the wire. - Audit log: an entry against the new instance naming the actor, the source by primary key, resource name and display name, the restore point and the mode. It is rendered as "restored X from Y" rather than as a second "created X".
Troubleshooting¶
- No Restore button at all. Either the service definition has no
restoreblock, or there is no backup to restore from. The button block is hidden in that second case, because the Backup Summary card already says there are no backups. - The button is disabled with a reason. That reason is the guardrail that refused, in the order above.
- "No backups found" hours into a PostgreSQL restore. The major version of the restored instance does not match the archive. The restore form lets the user change it; check what was submitted.
- The restore hangs with no error. Check for
unmanagedBucketin the source spec and for a cross-namespaceclaimName. A claim name only resolves inside one namespace.
Known gaps¶
Recorded as follow-ups at first release:
- A point-in-time value outside the covered window is accepted silently. There is no upper bound either, so a future timestamp passes. The user only learns after the second instance is provisioning.
- The supporting-component plan is not prefilled from the source, so an instance running a non-default supporting plan is downgraded unless the user picks the right one.
- If the source's compute plan assignment is no longer active or allowed, nothing is preselected and the user silently picks a different plan.
- The backups table renders in browser-local time while the restore picker and the window bounds are UTC, with nothing on the page bridging the two.
- Nothing stops the user editing the major version on the restore form.