Backup and restore

QuestDB Enterprise schedules and performs backups on the primary. The operator configures the engine and observes backups(); it does not upload data, create a Kubernetes CronJob, expose an on-demand backup API, or read the object store. Status can lag the database by roughly the manager's resync/observation interval (about two minutes).

For more information about backup and restore mechanics, please refer to the official documentation.

Before running any command, replace every <angle-bracket> value; an unreplaced placeholder can be interpreted as shell redirection.

Configure and verify backup

Before you start

Create and validate a same-namespace QuestDBObjectStore as described in Configuration. The object-store CR has no readiness status; proof comes from a consuming database.

Use a prefix reserved for this cluster:

spec:
objectStoreRef:
name: <store-name>
backup:
enabled: true
schedule: "0 * * * *"
timezone: UTC
retention: 5
root: backup/<namespace>/<name>/
stalledAfterSeconds: 3600

Apply the cluster change. It can recreate the primary to install the new configuration, briefly interrupting writes.

Watch

Before the first run, BackupHealthy may be Unknown with reason NoBackupYet. The bounded deadline must exceed the schedule interval plus the roughly two-minute observation delay; this hourly example allows about 75 minutes:

for _ in $(seq 1 450); do
STATUS="$(kubectl get questdbcluster <name> -n <namespace> \
-o jsonpath='{.status.backup.lastBackup.status}')"
REASON="$(kubectl get questdbcluster <name> -n <namespace> \
-o jsonpath='{.status.conditions[?(@.type=="BackupHealthy")].reason}')"
[ "$STATUS" = "completed" ] && break
[ "$STATUS" = "failed" ] && break
[ "$REASON" = "Stalled" ] && break
sleep 10
done
kubectl get questdbcluster <name> -n <namespace> \
-o jsonpath='{range .status.conditions[?(@.type=="BackupHealthy")]}{.status}{" "}{.reason}{" "}{.message}{"\n"}{end}{.status.backup.lastBackup}{"\nlastProgressAt="}{.status.backup.lastProgressAt}{"\n"}'
[ "$STATUS" = "completed" ]

Verify both the first successful run and continued recent successful runs. A failed engine run has lastBackup.status=failed and BackupHealthy=False/Failed; inspect .status.backup.lastBackup.error, database logs, Secret metadata, cloud IAM, DNS, and network access.

A stalled run is deliberately different: the engine status remains in_progress, while BackupHealthy=False/Stalled reports that progressPercent has not changed for the configured threshold. .status.backup.lastProgressAt is when the operator first observed the current run or most recently observed its percentage change. stalledAfterSeconds defaults to 3600 seconds. Setting it explicitly to 0 disables stall detection; it does not cancel the backup, mark it failed, or bound how long in_progress can remain. Diagnose and remediate the engine/store path rather than editing status.

There is no safe "force backup" command in this beta. Temporarily shortening spec.backup.schedule is a schedule/configuration change, not an on-demand backup. It can recreate pods and briefly interrupt writes; if used for a controlled test, restore the production schedule afterward.

Restore into a new cluster

Restore always creates a new QuestDBCluster. Its immutable spec.bootstrap.recovery makes the genesis primary start from backup or not start at all.

The source QuestDBObjectStore must exist in the destination cluster's namespace. A missing source CR safely withholds genesis and retries. Invalid store configuration or an engine/runtime restore problem fails recovery instead of initializing an empty database.

Before you start

  • identify the source store and backup root;
  • choose a destination name, PVCs, and backup/WAL prefixes that cannot collide with the source or another live cluster;
  • for a multi-instance source, determine sourceInstanceName;
  • for PITR, confirm the retained time window before choosing a target.

For an operator-managed source, the current seed identity is normally published here:

kubectl get questdbcluster <source-name> -n <namespace> \
-o jsonpath='{.status.replication.seed.backupInstanceName}{"\n"}'

Confirm it against the source database when possible:

SELECT backup_instance_name();

Set spec.bootstrap.recovery.sourceInstanceName when the source store holds backups from more than one instance. If omitted, the engine selects the only instance in a single-instance source and fails when selection is ambiguous. The operator cannot list the store for you.

Change

Copy the working tenant cluster's spec.image and spec.imagePullSecrets. Set <questdb-enterprise-image> to that private image and <tenant-image-pull-secret> to the pull Secret in the destination namespace. Remove the entire imagePullSecrets block only when every destination node has ambient pull access, such as an authorized EKS worker-node role.

apiVersion: questdb.io/v1alpha1
kind: QuestDBCluster
metadata:
name: <restored-name>
namespace: <namespace>
spec:
image: <questdb-enterprise-image>
imagePullSecrets:
- name: <tenant-image-pull-secret>
storage:
storageClassName: <storage-class>
size: 100Gi
resources:
requests:
memory: 4Gi
limits:
memory: 4Gi
objectStoreRef:
name: <destination-store>
bootstrap:
recovery:
source:
objectStoreRef:
name: <source-store>
root: backup/<source-namespace>/<source-name>/
sourceInstanceName: <source-backup-instance-name>
backup:
enabled: true
schedule: "0 * * * *"
root: backup/<namespace>/<restored-name>/
# Optional tuning block. If set, this root is immutable.
replication:
root: db/<namespace>/<restored-name>/

Apply the reviewed manifest:

kubectl apply -f <restore-file.yaml>

The destination backup and replication prefixes must be distinct from the source/live cluster's prefixes. When omitted, the destination replication root defaults to the identity-scoped db/<namespace>/<restored-name>/; confirm that identity is unique.

Watch and verify

GENERATION="$(kubectl get questdbcluster <restored-name> -n <namespace> \
-o jsonpath='{.metadata.generation}')"
for _ in $(seq 1 180); do
OBSERVED="$(kubectl get questdbcluster <restored-name> -n <namespace> \
-o jsonpath='{.status.observedGeneration}')"
RECOVERED="$(kubectl get questdbcluster <restored-name> -n <namespace> \
-o jsonpath='{.status.conditions[?(@.type=="Recovered")].status}')"
FAILED="$(kubectl get questdbcluster <restored-name> -n <namespace> \
-o jsonpath='{.status.conditions[?(@.type=="RecoveryFailed")].status}')"
AVAILABLE="$(kubectl get questdbcluster <restored-name> -n <namespace> \
-o jsonpath='{range .status.conditions[?(@.type=="Available")]}{.status} {.reason}{end}')"
PROGRESSING="$(kubectl get questdbcluster <restored-name> -n <namespace> \
-o jsonpath='{range .status.conditions[?(@.type=="Progressing")]}{.status} {.reason}{end}')"
WRITE_HEALTHY="$(kubectl get questdbcluster <restored-name> -n <namespace> \
-o jsonpath='{range .status.conditions[?(@.type=="WriteHealthy")]}{.status} {.reason}{end}')"
if [ "$OBSERVED" = "$GENERATION" ] && \
[ "$RECOVERED" = "True" ] && [ "$FAILED" != "True" ] && \
[ "$AVAILABLE" = "True PrimaryReady" ] && \
[ "$PROGRESSING" = "False Settled" ] && \
[ "$WRITE_HEALTHY" = "True Healthy" ]; then
break
fi
[ "$FAILED" = "True" ] && break
sleep 10
done
kubectl get questdbcluster <restored-name> -n <namespace> \
-o jsonpath='{range .status.conditions[*]}{.type}{"="}{.status}{" "}{.reason}{" "}{.message}{"\n"}{end}'
[ "$OBSERVED" = "$GENERATION" ] && \
[ "$RECOVERED" = "True" ] && [ "$FAILED" != "True" ] && \
[ "$AVAILABLE" = "True PrimaryReady" ] && \
[ "$PROGRESSING" = "False Settled" ] && \
[ "$WRITE_HEALTHY" = "True Healthy" ]

Recovered=True means the engine reported restore completion; the other gates establish current ordinary writer readiness, including observed WAL write health. They still do not prove that you selected the intended data or guarantee free disk capacity. Before sending traffic, query the restored database and validate critical tables, minimum/maximum timestamps, expected row counts, application invariants, and storage headroom.

If it fails

Read Recovered/RecoveryFailed, then inspect the genesis pod and its recovery init container:

kubectl get pods -n <namespace> -l questdb.io/cluster=<restored-name> -o wide
kubectl describe questdbcluster <restored-name> -n <namespace>
kubectl logs -n <namespace> <restored-name>-1 -c recovery-bootstrap --tail=500
kubectl logs -n <namespace> <restored-name>-1 -c questdb --tail=500

Container names can be confirmed with kubectl get pod <restored-name>-1 -n <namespace> -o jsonpath='{.spec.initContainers[*].name}{" "}{.spec.containers[*].name}{"\n"}'.

Because spec.bootstrap is immutable, correct a wrong source, identity, root, or target in a new cluster. A live failed destination CR continues reconciling and can race cleanup or recreate its pods, so remove it before touching retained storage:

CLEANUP_READY=false
if kubectl delete questdbcluster <restored-name> -n <namespace> --timeout=5m; then
for _ in $(seq 1 120); do
if PODS="$(kubectl get pods -n <namespace> \
-l questdb.io/cluster=<restored-name> -o name)" && [ -z "$PODS" ]; then
CLEANUP_READY=true
break
fi
sleep 5
done
fi
[ "${CLEANUP_READY:-false}" = "true" ] &&
kubectl get pvc -n <namespace> -l questdb.io/cluster=<restored-name> -o wide

Proceed only if the PVC inventory command ran successfully. Confirm the listed PVC belongs only to the failed destination and contains no needed data.

danger

Deleting the retained destination PVC permanently destroys its incomplete restore data. Delete only the PVC you have confirmed is disposable.

In the same shell, delete only the confirmed PVC:

[ "${CLEANUP_READY:-false}" = "true" ] &&
kubectl delete pvc <confirmed-disposable-pvc> -n <namespace> --timeout=5m

Apply the corrected restore with a fresh cluster name, which creates a distinct fresh PVC. Do not reuse <restored-name> or its old PVC.

Point-in-time recovery (PITR)

PITR selects the newest retained backup at or before an RFC3339 target. Its granularity is the backup cadence, not continuous WAL time.

Add this immutable block when creating the restored cluster:

spec:
bootstrap:
recovery:
source:
objectStoreRef:
name: <source-store>
root: backup/<source-namespace>/<source-name>/
sourceInstanceName: <source-backup-instance-name>
recoveryTarget:
timestamp: "2026-06-30T14:00:00Z"
danger

If the target is older than the earliest retained backup, QuestDB restores the earliest backup and reports success. Before applying the restore, confirm the source retention window and that the target is inside it. Afterward, verify actual table timestamps and row counts; Recovered=True alone cannot detect this mistake.

Follow the same bounded watch, failure checks, and data validation as a normal restore.