Deckhand packages a CloudNativePG operator-facing API and UI into a single deployable unit. The backend watches CNPG resources, stores a redacted in-memory runtime snapshot, scrapes per-pod metrics, exposes REST and WebSocket surfaces, and serves an embedded React SPA from the same process.
flowchart LR
subgraph Kubernetes cluster
CNPG[CloudNativePG resources\nClusters / Backups / ScheduledBackups]
Pods[CNPG pods + PVCs]
end
CNPG --> Watcher[Kubernetes watcher\ninternal/k8s/watcher.go]
Pods --> Scraper[Metrics scraper\ninternal/metrics/scraper.go]
Watcher --> Store[In-memory store\ninternal/store/store.go]
Scraper --> Store
Store --> API[REST API\ninternal/api/server.go]
Store --> WS[WebSocket hub\ninternal/api/ws.go]
API --> SPA[Embedded React SPA\nweb/dist via web/embed.go]
WS --> SPA
subgraph Packaging
Helm[Helm chart\ncharts/deckhand]
end
Helm --> Pod[Single Deckhand pod]
Pod --> Watcher
Pod --> Scraper
Pod --> API
Pod --> WS
Pod --> SPA
cmd/deckhand/main.go is the runtime entrypoint. It:
- parses
--listen,--kubeconfig, and--namespaces - bootstraps Kubernetes clients and scheme registration
- creates the in-memory store
- starts the CNPG watcher
- starts the metrics scraper
- starts the WebSocket hub
- mounts REST routes plus the embedded SPA and begins serving HTTP
The startup sequence is intentionally observable in logs with messages such as:
deckhand startingkubernetes runtime readycnpg watcher readymetrics scraper readywebsocket hub readystarting HTTP server
internal/k8s/watcher.go registers controller-runtime informers for:
ClusterBackupScheduledBackup
Each add/update/delete event is normalized into store mutations and emits a store.ChangeEvent with:
- resource kind
- action (
upsert/delete) - namespace
- name
- UTC timestamp
internal/store/store.go is the runtime truth cache. It keeps deep-copied snapshots of CNPG resources by namespace/name and lets subscribers listen for redacted change events. There is no persistent database in the current packaged architecture.
internal/metrics/scraper.go periodically:
- lists clusters from the in-memory store
- resolves pod IPs and PVC capacity through the Kubernetes API
- scrapes per-pod CNPG exporter metrics
- computes cluster and instance health summaries
- stores typed results in an in-memory cache for the API layer
This is why Deckhand needs read access to Pods and PVCs in addition to CNPG resources.
internal/api/server.go mounts:
GET /healthzGET /apiGET /api/clustersGET /api/clusters/{namespace}/{name}GET /api/clusters/{namespace}/{name}/metricsGET|POST /api/clusters/{namespace}/{name}/backupsGET|POST /api/clusters/{namespace}/{name}/restoreGET /api/clusters/{namespace}/{name}/restore-statusGET /ws
DTOs in internal/api/types.go intentionally redact raw CRDs, pod IPs, and sensitive diagnostics before data reaches the browser.
internal/api/ws.go subscribes to the store and broadcasts redacted store.changed events over /ws. The frontend uses these events as invalidation hints, then refetches the relevant REST endpoints to keep the UI authoritative.
The React app lives in web/src. make build or the Docker build compiles it to web/dist, and web/embed.go embeds that directory into the Go binary. At runtime, non-API/non-WS routes fall back to index.html, so the same Deckhand pod serves both the API and the routed SPA.
The shipped UI currently exposes four operator-facing routes:
/— cluster overview/clusters/:namespace/:name— cluster detail/clusters/:namespace/:name/backups— backup management/history/clusters/:namespace/:name/restore— guided restore workflow
Each route surfaces live-status cards and explicit error states so operators can tell whether they are seeing fresh data, a reconnecting socket, or an API failure.
The Helm chart under charts/deckhand/ packages Deckhand as a single Deployment. The same pod hosts:
- Kubernetes watchers
- metrics scraping
- backup/restore mutation logic
- REST endpoints
- WebSocket notifications
- the embedded React SPA
The chart supports two RBAC modes:
- cluster-wide — one
ClusterRoleandClusterRoleBinding - namespace-scoped — one
RoleandRoleBindingper configured namespace
- Deckhand can run cluster-wide or against a namespace allowlist through
DECKHAND_NAMESPACES. - The current architecture is stateless beyond in-memory caches; restarting the pod rebuilds state from Kubernetes and fresh scrapes.
- The browser never receives raw pod IPs; diagnostics are redacted in API DTO assembly.
- The WebSocket channel carries change metadata, not full object payloads.