This guide is the single source of truth for recreating the entire project if the repository is lost. Follow the steps in order. The reasoning behind each step is included so the design intent is preserved, not just the commands.
Before writing any code, decide the architecture:
- Separate lifecycle from engine mechanics.
- The operator owns scheduling, state tracking, retries, and S3 orchestration.
- The engine images own how to actually dump/restore each database type.
- Engine-agnostic CRDs.
- CRDs describe connection (
DatabaseInstance), storage (BackupConfig), and lifecycle (Backup,BackupSchedule,Restore), not Postgres.
- CRDs describe connection (
- Container-per-engine plugin model.
- Each engine is a separate image. The operator dispatches them as Jobs.
- Contract over code sharing.
- The operator and engine communicate through env vars, mounted secret files, exit codes, and a JSON result on stdout. They do not import each other's code.
Write these four decisions down. Every file you create afterward should honor them.
mkdir kube-db-backup
cd kube-db-backup
go mod init github.com/kube-db-backup/kube-db-backupCreate the directory layout:
├── cmd/ # operator entrypoint
├── api/v1alpha1/ # CRD Go types
├── internal/controller/ # reconcile loops
├── pkg/engine/ # contract + registry
├── engines/postgres/ # first engine image
├── config/crd/ # CRD YAMLs
├── config/rbac/ # RBAC YAMLs
├── config/manager/ # operator deployment
├── config/samples/ # example CRs
├── charts/kube-db-backup/ # Helm chart
├── test/e2e/ # integration tests
├── docker-compose.yml # local dev stack
├── Dockerfile # operator image
└── Makefile
Why this order: the module and directory structure are the foundation. Everything else hangs on these paths.
Before writing CRDs, define how the operator and engine will talk. This is the most important contract in the system.
Create pkg/engine/contract.go:
- Exit code conventions:
0success,1internal,2auth,3connectivity (retry),4storage (retry). - Max retries and backoff schedule.
Resultstruct for JSON output.ParseResulthelper.
Create pkg/engine/registry.go:
- Static map from
engineTypetoEngineInfo. - Initial entry:
postgres→ imagepg-engine:latest. - Allow override via
PG_ENGINE_IMAGEenv var.
Why this order: if you design the contract after CRDs, the CRDs will leak database-specific assumptions into generic fields. Define the contract first, then force the CRDs to stay generic.
Create api/v1alpha1/:
groupversion_info.go— scheme group version.databaseinstance_types.gobackupconfig_types.gobackup_types.gobackupschedule_types.gorestore_types.gozz_generated.deepcopy.go— implementDeepCopymethods for every type.
Guidelines while writing:
- Keep
engineTypeas a first-class enum. - Use
engineConfigas amap[string]stringblob for engine-specific options. - Reference credentials via Secret refs; never put secrets in CRD specs.
- Include status fields for phase, conditions, timing, size, checksum, retry count, and job name.
Why this order: the CRDs are the public API. Get them right before writing controllers that depend on them.
Create cmd/main.go:
- Build the runtime scheme.
- Register the core scheme and your custom scheme.
- Create a
controller-runtimemanager. - Register the three reconcilers.
- Add health/readiness probes.
Use the correct manager.Options for your controller-runtime version (e.g., Metrics: metricsserver.Options{BindAddress: ...} in v0.24+).
Why this order: controllers need a manager. You can't run the controllers until this file exists.
Create internal/controller/:
backup_controller.gobackupschedule_controller.gorestore_controller.gohelpers.go— shared condition helpers and exit-code extraction.
- Reconcile
Backupobjects. - On
Pendingor eligible retry: validateDatabaseInstanceandBackupConfigrefs. - Look up engine image in registry.
- Build a Kubernetes Job with the engine contract env vars and secret volume mounts.
- Set owner reference on the Job.
- On
Running: poll the Job, parse result, update status. - On failure: implement retry with exponential backoff.
- Reconcile
BackupScheduleobjects. - If suspended, skip.
- Compute next scheduled time.
- When due, create a
Backupobject. - Prune old
Backupobjects based on history limits.
- Same pattern as BackupController, but for restores.
- Validate engine type match between source backup and target instance.
- Build restore Job with
RESTORE_CLEANandRESTORE_FORCEenv vars.
Why this order: controllers are the core logic. They depend on CRDs and the engine contract, so they come after both.
Create engines/postgres/:
Dockerfile— start frompostgres:16-alpine, addaws-cliandbash.entrypoint.sh— dispatch to backup or restore script based onOPERATION.backup.sh:- Read env vars and mounted secrets.
- Run
pg_dumpin custom format (-Fc). - Compute size and SHA-256 checksum.
- Upload
dump.customandbackup-manifest.jsonto S3. - Write the standardized result JSON to stdout.
restore.sh:- Read env vars and mounted secrets.
- Download manifest and dump from S3.
- Check target DB emptiness unless
FORCE=true. - Run
pg_restore --clean. - Run
ANALYZE. - Write result JSON.
Test the engine locally with docker-compose before connecting it to the operator.
Why this order: the engine is a standalone component. Build and validate it independently so you know the contract works before the operator orchestrates it.
Create config/:
crd/— one YAML per CRD. Useapiextensions.k8s.io/v1.rbac/rbac.yaml— ServiceAccount, ClusterRole, ClusterRoleBinding.manager/namespace.yaml— operator namespace.manager/deployment.yaml— operator Deployment.samples/all-in-one.yaml— Secrets,DatabaseInstance,BackupConfig,Backup,BackupSchedule,Restore.
Make sure the operator Deployment sets PG_ENGINE_IMAGE so the registry override works.
Why this order: these manifests prove the system works without Helm. They are also the source material for the Helm chart.
Create:
docker-compose.yml— Postgres 16 + MinIO + bucket creation.Makefile—build,test,docker-build,deploytargets..gitignore.
Verify locally:
docker compose up -d
docker build -t pg-engine:latest engines/postgres/
go run cmd/main.go
kubectl apply -f config/samples/all-in-one.yaml
kubectl get backups -wWhy this order: a working local loop is the fastest way to find integration bugs between the operator, engine, and S3.
Create charts/kube-db-backup/:
Chart.yaml.values.yaml— user-facing config for operator image, engine image, namespace, DB instances, S3 configs, schedules, secrets.crds/— copy the 5 CRD YAMLs here.templates/:namespace.yamlrbac.yamldeployment.yaml(passPG_ENGINE_IMAGEas env var)secrets.yamldatabaseinstances.yamlbackupconfigs.yamlbackupschedules.yaml
Make sure helm template and helm lint pass.
Why this order: Helm is packaging. Package only after the raw manifests are correct and tested.
Add tests in order of value:
pkg/engine/contract_test.go— test result parsing, retry logic, registry lookup.- Engine integration tests — run
engines/postgres/backup.shandrestore.shagainst docker-compose Postgres + MinIO. - Controller envtest tests — verify reconcile loops create Jobs correctly.
- E2E tests in
test/e2e/— kind cluster, full backup/restore flow.
Why this order: start with cheap unit tests, then integration, then expensive e2e. The engine contract tests catch most contract mismatches before you spin up a cluster.
Write:
README.md— quick start.ARCHITECTURE.md— component overview and design rationale.REBUILD.md— this file.PLAN.md— roadmap and abstraction goals.
Why this order: documentation captures intent. Write it while the decisions are fresh.
- Putting credentials in CRD specs. Always use Secret references.
- Leaking engine specifics into CRDs. Keep
engineConfiga free-form map. - Hardcoding engine images without override. Use env vars so Helm can configure them.
- Forgetting
ImagePullPolicy: IfNotPresent. Without it, kind clusters try to pull from a registry. - No backup manifest in S3. The manifest lets you restore even if the
BackupCRD is gone. - Designing for all engines at once. Build Postgres end-to-end first, then abstract. Otherwise you design for hypotheticals.
Before declaring the system done:
-
go build ./...passes. -
go test ./...passes. -
go vet ./...passes. -
helm lint charts/kube-db-backuppasses. -
helm templaterenders valid YAML. - One-shot backup creates a Job, succeeds, and uploads to S3.
- Restore from that backup works into a second database.
- Deleting the operator does not delete existing S3 backups.