Disaster recovery: rebuilding Nezal from nothing

This is the sequence to rehearse and the one to follow when the cluster is gone. It assumes the worst realistic case: the cluster no longer exists and all you have is a backup file and your git server.

It is deliberately a runbook rather than a command. A restore that can be run against a live installation is a way to destroy one. A disaster recovery plan a security team can sign off is a sequence its own people can rehearse rather than a button.

Nezal's own release checks perform exactly these steps on a fresh cluster before every release so the procedure cannot rot.

What you need before you start

Thing Where it should already be If you do not have it
A database backup Wherever the backup CronJob writes (see the admin guide) The installation is not recoverable. See "If you have no backup"
The nezal-api-keys Secret Backed up separately from the database The backup cannot be decrypted. There is no recovery path
The licence key Your own records. Also inside the backup Ask your Nezal contact for a replacement
Repository credentials Your own records The operator cannot re-read your repositories until these are re-entered
The container images Your registry mirror or the offline bundle Re-mirror from the bundle before installing
A dump of the Nezal CRDs Your cluster's own backup tool (Velero or etcd snapshots or kubectl get -o yaml) Applications and policies must be re-entered by hand. See step 6

The keys are the thing to check now rather than later. They live in the nezal-api-keys Secret and they are deliberately not in the backup: a stolen volume snapshot should not yield both the data and the key that decrypts it. The backup command says so every time it runs. Back that Secret up somewhere your database backup tooling does not reach.

The Secret holds three keys:

Losing session costs everyone a re-enrolment. Losing backup costs you the backups and nothing can be done about it afterwards.

The sequence

1. Install onto the new cluster

Follow the install guide as for a first install with one difference: do not enter the licence key or configure anything yet. You are creating an empty installation to restore into.

helm install nezal oci://registry.nezal.dev/charts/nezal --version <the version you were running> \
  --namespace nezal-system --create-namespace \
  --set ingress.domain=dev.acme.internal \
  --set api.persistence.enabled=true

Install the same version you were running. A backup restores into its own schema. A newer version would migrate it forward on first start. That is a second change happening during an incident and one change at a time is the rule.

2. Put the keys back before the database

The API's signing keys live in the Secret nezal-api-keys and not in the backup. Restore that Secret from wherever you keep it:

kubectl apply -f ./nezal-api-keys.yaml       # the Secret as you backed it up
kubectl rollout restart deploy/nezal-api -n nezal-system

Without this Secret the backup in step 3 cannot be decrypted at all. That is why it comes first.

Keys first because the API mints new ones on first start if it finds none. A restored database whose stored TOTP secrets were encrypted under the old session key would then be undecryptable and every user with a second factor would be locked out.

If the keys are gone: the database still restores and the product works. Everyone signs in again. Users with a second factor must re-enrol (Users → Reset second factor). Evidence packages exported before the loss can no longer be verified against this installation's public key. They remain verifiable against the old public key inside those packages so keep the packages themselves.

3. Restore the database

Follow Backup and restore in the admin guide from step 1. In short: scale the API to zero and decrypt the backup. Copy it over nezal.db in the data volume from a pod mounting the same claim and scale back to one.

Decrypt it with the backup key from the Secret you restored in step 2:

kubectl -n nezal-system get secret nezal-api-keys -o jsonpath='{.data.backup}' | base64 -d > ./backup.key
nezal-api verify-backup  --in ./nezal-2026-09-12.db.enc --key-file ./backup.key
nezal-api decrypt-backup --in ./nezal-2026-09-12.db.enc --out ./nezal.db --key-file ./backup.key

verify-backup proves the key is right and the file opens before you overwrite anything. decrypt-backup then writes the plain database to copy into place. Remove that plain copy and backup.key once the restore is done.

4. Check what came back before going further

kubectl -n nezal-system port-forward svc/nezal-api 8080:8080 &
curl -s -H "Authorization: Bearer $TOKEN" localhost:8080/api/v1/audit/verify | jq

intact: true and a headSeq matching what you had is the signal that this is the same installation and not a new one wearing its name. That database holds the users and workspaces together with the repository definitions and settings and the licence. All of them should be present. Applications and policies are not — they are Kubernetes objects and step 6 deals with them.

5. Re-enter repository credentials

Credentials are in Kubernetes Secrets and not in the database so they did not come back with it. Under Repositories re-enter the credential for each repository. Everything else about the repository — its URL and ref and the application built from it — is restored already.

6. Restore the Kubernetes objects

Applications (Baselines) and Environments and Policies live in the cluster and not in the database. They are Custom Resources and they went with the cluster. This is the step most likely to go wrong and the one to rehearse.

If you have a CRD dump — from Velero or an etcd snapshot or a scheduled kubectl get baselines,policies -A -o yaml — restore Baselines and Policies from it:

kubectl apply -f ./nezal-baselines.yaml
kubectl apply -f ./nezal-policies.yaml

Take that dump on a schedule. It is two objects per team and it is the difference between a ten-minute recovery and re-entering every team's quotas and TTL limits and terminal settings by hand:

kubectl get baselines,policies -A -o yaml > nezal-crds-$(date +%F).yaml

If you do not have one then re-create them through the dashboard. Connect each application again under Applications (its repository and credentials are restored so this is filling in the same form). Set each team's Policy again under Quotas & cost. Write down what your policies were before you need this.

Environments are not worth restoring: a personal environment is a copy of a branch and a developer re-creates theirs with nezal up in a minute. Per-PR environments reappear on the next webhook event from an open pull request.

7. Confirm end to end

Create one environment and open its preview URL. Check that the audit log shows the whole sequence. The installation is recovered when a developer can do their ordinary work.

What you get back and what you do not

Restored from the database backup Needs your CRD dump or re-entering Gone and not worth keeping
Users and roles and team memberships Applications and their Baselines Environments (re-create with nezal up)
Workspaces Policies: quotas and TTL and terminal settings Per-PR environments (return on the next webhook)
Repository and registry definitions (not their credentials) Sessions: everyone signs in again
The licence and all settings (SSO and SMTP and SIEM and AI assist) Builds in flight
The audit log with its chain intact Open terminal sessions
Release history and seat usage

Terminal history survives. A shell that was open when the cluster went is gone with it but the audit entries for every session are in the database and come back with it. Who opened each session and which pod it entered and when and why it ended is all still there for an investigator.

If you have no backup

The audit log is gone and nothing recreates it: that is the point of a hash-chained log. What you can rebuild is the working product. Install fresh and enter the licence. Re-create workspaces and re-connect repositories. Users re-appear as they sign in through your identity provider.

Say so plainly in your incident report rather than quietly starting a new log. An auditor who finds a gap they were not told about draws a worse conclusion than one who reads that the cluster was lost on a known date and the log restarts there.

Rehearsing it

Rehearse it by hand once a year against a restored copy of your own backup. The parts that go wrong in practice are always the same three:

All three are cheap to check today and expensive to discover during an incident.