refactor(apps): split monolithic apps Kustomization into dependency waves

Replaces the single `apps` Flux Kustomization with dependency-ordered waves
(apps-wave-0..2) so a crashlooping workload isolates to its wave + downstream
instead of stalling the entire app tier. Parallel within a wave, ordered across.

- catalog/render.py: compute waves by longest dependency-path depth over the
  ServiceDefinition `dependencies:` edges (a `<svc>-db` is a leaf, so its
  consumer lands strictly higher — the "DB below consumer" rule falls out of
  longest-path, no special case). `--emit-waves` generates the per-wave
  kustomizations + Flux CRs; `--wave-layout` places renders into the right dir.
  wave 0: pihole gitea jackett plex qbittorrent nextcloud-db semaphore-db
  wave 1: sonarr radarr nextcloud semaphore gitea-actions
  wave 2: seerr
- apps/*.yaml -> apps/wave-{0,1,2}/ (byte-identical moves; SOPS files intact).
- clusters/base: apps.yaml removed; apps-wave-{0,1,2}.yaml added (dependsOn
  chain: platform-config -> wave-0 -> wave-1 -> wave-2, prune+wait each).
- clusters/dev: one SOPS decryption patch per wave (load-bearing).
- DESIGN-waves.md: wave table, topology, cutover runbook.

Cutover: preceded by disabling prune on `apps` (1bfb0db) so removing that
object here does not GC its resources; the wave Kustomizations SSA-adopt the
byte-identical manifests in place (no restart, no PVC churn).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
jakman85andClaude Opus 4.8 committed 2026-08-26 12:19:00 -05:00
1 parent 1bfb0db6f1
commit 36a1a4e1f9
34 files changed
+793 -93

No files matched your search

@@ -0,0 +1,36 @@
# Gitea headless-install bootstrap secret (WS-APPS, Phase 6). SOPS-encrypted (age).
# secret-key: FIXED, persistent Gitea SECRET_KEY (must not regenerate per restart).
# admin-*: break-glass local admin (docs/15 availability caveat). Recover with:
# infra/devcontainer/dev.sh run sops -d apps/gitea-bootstrap-secret.sops.yaml
apiVersion: v1
kind: Secret
metadata:
name: gitea-bootstrap
namespace: gitea
labels:
app.kubernetes.io/name: gitea
type: Opaque
stringData:
secret-key: ENC[AES256_GCM,data:N4wgSipwTYNgkwujPzGs8Bo63IzUip1TwfVq9fG+Z2nECrgIV9PYkEyL4ic3Ej9f5udOE4fHwsbgcyuiJw==,iv:p33bef3z2fDbaAi6beGF+CWmeTeS7Vn46ce1/2zQH4g=,tag:CLJ+kZbmz0tiPtOsI3+dIg==,type:str]
admin-username: ENC[AES256_GCM,data:hyWYQ3UC8c9H2Os=,iv:bgn72bsILLxChY9QAsAD3tYWvsWS2CBe0FlneCCSr+E=,tag:7qP/P8TjDmRFsuXjJF8A2g==,type:str]
admin-password: ENC[AES256_GCM,data:GPz1vracB1U6YurrV1AEhX9VEGx3lwCOXqOmKAg6+/6NjGPmTv+r2uosawVYtwie,iv:L/QKFqo7RpSg4eQGie/+R5BDBgTiTzwDdWuofJeebFE=,tag:cD6AU9tahLskTaaU/A7BOA==,type:str]
sops:
kms: []
gcp_kms: []
azure_kv: []
hc_vault: []
age:
- recipient: age175l8mdpdfu7hq0aml3exs3gykarwjmuvr23sq56s3wjejnyk44wsp4mfzg
enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSA1bXVWK2sxUDNyZ0UvcFhW
MDZ2akY3ditycjdQN0FSM2JYcXZod2lqS0F3ClF6T2pOdmxNZ3JwSjdLeDBBYmR4
QU52bWlDdGVBZkM2ZW1BNkRFSjVpcTQKLS0tIHdYS1VQaE5mSUhXR1U4UktyM1Jm
Nys4V2NBZWF6U2lER05HV3FNZ1pOQ1kKEVHBTPTAJfiQBzkP/QezTzOfS96nYzxg
Ad1m50j67vyFaBCVAb0/0toEUAm+qEV4wzOglaNmZuScavY4ntwCIw==
-----END AGE ENCRYPTED FILE-----
lastmodified: "2026-08-25T23:06:33Z"
mac: ENC[AES256_GCM,data:ik735BfSMdlB4IUceScEfY4aNNNo61po7W7x/6iod1VvsiMo0+7nUrVqD2uiL8p066k6g/hV+BNg5BwnYRVjvkVm8tU60ctcjEDfinxtgnqRNBVK81RROn5GtsRCzuBxiJ5J8ulgb45+jG9ND6Xuo9XuTZ35PWs/4/Ebdl/jpPM=,iv:Nt3QtDz3S37AnrtG2nd1TIHgFFemgxXrKrHfXTVo6S4=,tag:ScMtwQAF8a4heyhN+iXt/A==,type:str]
pgp: []
encrypted_regex: ^(data|stringData)$
version: 3.9.4
+245
View File
@@ -0,0 +1,245 @@
---
# Namespace (trust zone; default-deny + policies apply)
apiVersion: v1
kind: Namespace
metadata:
name: gitea
labels:
homelab.keithjackson.dev/app-namespace: 'true'
gateway-access: shared
annotations:
homelab.keithjackson.dev/backup-policy: daily-offcluster
---
# Default-deny baseline (R-NET-01): deny all, allow only DNS
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: default-deny
namespace: gitea
labels:
homelab.keithjackson.dev/baseline: default-deny
spec:
podSelector: {}
policyTypes:
- Ingress
- Egress
ingress: []
egress:
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: kube-system
ports:
- protocol: UDP
port: 53
- protocol: TCP
port: 53
---
# PVC gitea-data (R-STATE)
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: gitea-data
namespace: gitea
labels:
app.kubernetes.io/name: gitea
spec:
accessModes:
- ReadWriteOnce
storageClassName: longhorn
resources:
requests:
storage: 10Gi
---
# Deployment (R-PLAT-02)
apiVersion: apps/v1
kind: Deployment
metadata:
name: gitea
namespace: gitea
labels:
app.kubernetes.io/name: gitea
spec:
replicas: 1
strategy:
type: Recreate
selector:
matchLabels:
app.kubernetes.io/name: gitea
template:
metadata:
labels:
app.kubernetes.io/name: gitea
homelab.keithjackson.dev/egress-class: internet
homelab.keithjackson.dev/auth-mode: oidc
annotations:
homelab.keithjackson.dev/auth-mode: oidc
homelab.keithjackson.dev/auth-groups: platform-admins
spec:
containers:
- name: gitea
image: gitea/gitea:1.24.3
ports:
- containerPort: 3000
env:
- name: GITEA__security__INSTALL_LOCK
value: 'true'
- name: GITEA__security__SECRET_KEY
valueFrom:
secretKeyRef:
name: gitea-bootstrap
key: secret-key
- name: GITEA__server__DOMAIN
value: gitea.home.keithjackson.dev
- name: GITEA__server__ROOT_URL
value: https://gitea.home.keithjackson.dev/
- name: GITEA__actions__ENABLED
value: 'true'
- name: GITEA__actions__DEFAULT_ACTIONS_URL
value: github
- name: GITEA_ADMIN_USERNAME
valueFrom:
secretKeyRef:
name: gitea-bootstrap
key: admin-username
- name: GITEA_ADMIN_PASSWORD
valueFrom:
secretKeyRef:
name: gitea-bootstrap
key: admin-password
volumeMounts:
- name: data
mountPath: /data
lifecycle:
postStart:
exec:
command:
- /bin/sh
- -c
- "# Idempotent break-glass admin bootstrap. Runs alongside the gitea entrypoint\n# (which, with INSTALL_LOCK=true, headless-installs + migrates the DB on first\n# boot). Waits for the DB, then creates the admin only if it does not exist.\nset -u\nexport HOME=/data/git\ni=0\nuntil su-exec git gitea admin user list >/dev/null 2>&1; do\n i=$((i + 1))\n [ \"$i\" -ge 150 ] && { echo \"[gitea-bootstrap] DB not ready after wait; skipping\" >&2; exit 0; }\n sleep 2\ndone\nif su-exec git gitea admin user list --admin 2>/dev/null | awk 'NR>1{print $2}' | grep -qx \"$GITEA_ADMIN_USERNAME\"; then\n echo \"[gitea-bootstrap] admin '$GITEA_ADMIN_USERNAME' already present\"\n exit 0\nfi\nn=0\nuntil su-exec git gitea admin user create \\\n --username \"$GITEA_ADMIN_USERNAME\" \\\n --password \"$GITEA_ADMIN_PASSWORD\" \\\n --email \"[email protected]\" \\\n --admin --must-change-password=false; do\n n=$((n + 1))\n [ \"$n\" -ge 10 ] && { echo \"[gitea-bootstrap] admin create failed after retries\" >&2; exit 0; }\n sleep 3\ndone\necho \"[gitea-bootstrap] created break-glass admin '$GITEA_ADMIN_USERNAME'\"\n"
volumes:
- name: data
persistentVolumeClaim:
claimName: gitea-data
---
# Service
apiVersion: v1
kind: Service
metadata:
name: gitea
namespace: gitea
labels:
app.kubernetes.io/name: gitea
spec:
selector:
app.kubernetes.io/name: gitea
ports:
- name: port-3000
port: 3000
targetPort: 3000
---
# CiliumNetworkPolicy gateway-ingress allow (R-NET-01/02): gitea-allow-gateway-ingress
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: gitea-allow-gateway-ingress
namespace: gitea
labels:
app.kubernetes.io/name: gitea
spec:
endpointSelector:
matchLabels:
app.kubernetes.io/name: gitea
ingress:
- fromEntities:
- ingress
toPorts:
- ports:
- port: '3000'
protocol: TCP
---
# CiliumNetworkPolicy world egress allow (egress class governs the path; R-NET-05): gitea-allow-internet-egress
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: gitea-allow-internet-egress
namespace: gitea
labels:
app.kubernetes.io/name: gitea
spec:
endpointSelector:
matchLabels:
app.kubernetes.io/name: gitea
egress:
- toEntities:
- world
---
# HTTPRoute gitea-3000 (R-TLS/R-DNS)
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: gitea-3000
namespace: gitea
labels:
app.kubernetes.io/name: gitea
annotations:
homelab.keithjackson.dev/auth-mode: oidc
homelab.keithjackson.dev/auth-groups: platform-admins
spec:
parentRefs:
- name: shared
namespace: gateway
sectionName: https
hostnames:
- gitea.home.keithjackson.dev
rules:
- matches:
- path:
type: PathPrefix
value: /
backendRefs:
- name: gitea
port: 3000
---
# ServiceMonitor (R-OBS-01)
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
name: gitea
namespace: gitea
labels:
release: kube-prometheus-stack
app.kubernetes.io/name: gitea
spec:
selector:
matchLabels:
app.kubernetes.io/name: gitea
namespaceSelector:
matchNames:
- gitea
endpoints:
- port: metrics
interval: 30s
---
# PrometheusRule SLI alerts (R-OBS-01)
apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
name: gitea-slis
namespace: gitea
labels:
release: kube-prometheus-stack
app.kubernetes.io/name: gitea
spec:
groups:
- name: gitea.slis
rules:
- alert: gitea-down
expr: up{job="gitea"} == 0
for: 5m
labels:
severity: critical
service: gitea
annotations:
summary: gitea is down
description: 'gitea has been unreachable for 5m (SLI: up).'
+256
View File
@@ -0,0 +1,256 @@
---
# Namespace (trust zone; default-deny + policies apply)
apiVersion: v1
kind: Namespace
metadata:
name: jackett
labels:
homelab.keithjackson.dev/app-namespace: 'true'
gateway-access: shared
annotations:
homelab.keithjackson.dev/backup-policy: daily-offcluster
---
# Default-deny baseline (R-NET-01): deny all, allow only DNS
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: default-deny
namespace: jackett
labels:
homelab.keithjackson.dev/baseline: default-deny
spec:
podSelector: {}
policyTypes:
- Ingress
- Egress
ingress: []
egress:
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: kube-system
ports:
- protocol: UDP
port: 53
- protocol: TCP
port: 53
---
# PVC jackett-config (R-STATE)
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: jackett-config
namespace: jackett
labels:
app.kubernetes.io/name: jackett
spec:
accessModes:
- ReadWriteOnce
storageClassName: longhorn
resources:
requests:
storage: 2Gi
---
# Deployment (R-PLAT-02)
apiVersion: apps/v1
kind: Deployment
metadata:
name: jackett
namespace: jackett
labels:
app.kubernetes.io/name: jackett
spec:
replicas: 1
strategy:
type: Recreate
selector:
matchLabels:
app.kubernetes.io/name: jackett
template:
metadata:
labels:
app.kubernetes.io/name: jackett
homelab.keithjackson.dev/egress-class: vpn
homelab.keithjackson.dev/auth-mode: forward-auth
annotations:
homelab.keithjackson.dev/auth-mode: forward-auth
homelab.keithjackson.dev/auth-groups: media-users
spec:
containers:
- name: jackett
image: lscr.io/linuxserver/jackett:latest
ports:
- containerPort: 9117
volumeMounts:
- name: config
mountPath: /data/config
volumes:
- name: config
persistentVolumeClaim:
claimName: jackett-config
---
# Service
apiVersion: v1
kind: Service
metadata:
name: jackett
namespace: jackett
labels:
app.kubernetes.io/name: jackett
spec:
selector:
app.kubernetes.io/name: jackett
ports:
- name: port-9117
port: 9117
targetPort: 9117
---
# CiliumNetworkPolicy reciprocal ingress allow (R-NET-02): jackett-allow-radarr-9117
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: jackett-allow-radarr-9117
namespace: jackett
labels:
app.kubernetes.io/name: jackett
spec:
endpointSelector:
matchLabels:
app.kubernetes.io/name: jackett
ingress:
- fromEndpoints:
- matchLabels:
app.kubernetes.io/name: radarr
k8s:io.kubernetes.pod.namespace: radarr
toPorts:
- ports:
- port: '9117'
protocol: TCP
---
# CiliumNetworkPolicy reciprocal ingress allow (R-NET-02): jackett-allow-sonarr-9117
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: jackett-allow-sonarr-9117
namespace: jackett
labels:
app.kubernetes.io/name: jackett
spec:
endpointSelector:
matchLabels:
app.kubernetes.io/name: jackett
ingress:
- fromEndpoints:
- matchLabels:
app.kubernetes.io/name: sonarr
k8s:io.kubernetes.pod.namespace: sonarr
toPorts:
- ports:
- port: '9117'
protocol: TCP
---
# CiliumNetworkPolicy gateway-ingress allow (R-NET-01/02): jackett-allow-gateway-ingress
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: jackett-allow-gateway-ingress
namespace: jackett
labels:
app.kubernetes.io/name: jackett
spec:
endpointSelector:
matchLabels:
app.kubernetes.io/name: jackett
ingress:
- fromEntities:
- ingress
toPorts:
- ports:
- port: '9117'
protocol: TCP
---
# CiliumNetworkPolicy world egress allow (egress class governs the path; R-NET-05): jackett-allow-internet-egress
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: jackett-allow-internet-egress
namespace: jackett
labels:
app.kubernetes.io/name: jackett
spec:
endpointSelector:
matchLabels:
app.kubernetes.io/name: jackett
egress:
- toEntities:
- world
---
# HTTPRoute jackett-9117 (R-TLS/R-DNS)
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: jackett-9117
namespace: jackett
labels:
app.kubernetes.io/name: jackett
annotations:
homelab.keithjackson.dev/auth-mode: forward-auth
homelab.keithjackson.dev/auth-groups: media-users
spec:
parentRefs:
- name: shared
namespace: gateway
sectionName: https
hostnames:
- jackett.home.keithjackson.dev
rules:
- matches:
- path:
type: PathPrefix
value: /
backendRefs:
- name: jackett
port: 9117
---
# ServiceMonitor (R-OBS-01)
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
name: jackett
namespace: jackett
labels:
release: kube-prometheus-stack
app.kubernetes.io/name: jackett
spec:
selector:
matchLabels:
app.kubernetes.io/name: jackett
namespaceSelector:
matchNames:
- jackett
endpoints:
- port: metrics
interval: 30s
---
# PrometheusRule SLI alerts (R-OBS-01)
apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
name: jackett-slis
namespace: jackett
labels:
release: kube-prometheus-stack
app.kubernetes.io/name: jackett
spec:
groups:
- name: jackett.slis
rules:
- alert: jackett-down
expr: up{job="jackett"} == 0
for: 5m
labels:
severity: critical
service: jackett
annotations:
summary: jackett is down
description: 'jackett has been unreachable for 5m (SLI: up).'
+18
View File
@@ -0,0 +1,18 @@
---
# Wave 0 of the app tier — GENERATED by `catalog/render.py --emit-waves`.
# Reconciled by the Flux Kustomization `apps-wave-0` (clusters/base/apps-wave-0.yaml), which dependsOn `platform-config` and health-gates
# (wait:true) ONLY this wave — a crashloop here cannot stall an earlier wave.
# DO NOT hand-edit: re-run `render.py --emit-waves` after onboarding a service.
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- gitea-bootstrap-secret.sops.yaml
- gitea.yaml
- jackett.yaml
- nextcloud-db-app.sops.yaml
- nextcloud-db.yaml
- pihole.yaml
- plex.yaml
- qbittorrent.yaml
- semaphore-db-app.sops.yaml
- semaphore-db.yaml
+36
View File
@@ -0,0 +1,36 @@
# Nextcloud PostgreSQL owner credentials (WS-APPS, Phase 6). SOPS-encrypted (age).
# type basic-auth (keys username/password) so the CNPG nextcloud-db Cluster consumes it as the
# nextcloud role via spec.bootstrap.initdb.secret. The SAME password is mirrored (out of band) into
# nextcloud-bootstrap/db-password in ns nextcloud for the app env (cross-ns Secret reads are not
# possible). Recover: infra/devcontainer/dev.sh run sops -d apps/nextcloud-db-app.sops.yaml
apiVersion: v1
kind: Secret
metadata:
name: nextcloud-db-app
namespace: nextcloud-db
labels:
app.kubernetes.io/name: nextcloud-db
type: kubernetes.io/basic-auth
stringData:
username: ENC[AES256_GCM,data:fB2YI9sNjeZj,iv:o1L0XH5bMOPODdk4cFxMMTOOCQNhAPm9aI708x4FoCE=,tag:nKrVmibWaPeJTnFN84BlSQ==,type:str]
password: ENC[AES256_GCM,data:09mWJL4t7yOoYsMFsoPNib7wgPJKQ3vmQudDu4+hQwDOReZTA3pgTsRkh4E//DN5,iv:nHA+fHGV6pAh+khwaBQLHgqdSjHY7U8MCDTgFWcgzEg=,tag:1zyzN4/L9LWkBZ0kjusLhg==,type:str]
sops:
kms: []
gcp_kms: []
azure_kv: []
hc_vault: []
age:
- recipient: age175l8mdpdfu7hq0aml3exs3gykarwjmuvr23sq56s3wjejnyk44wsp4mfzg
enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBZT3B5K3BETnlyK3JSQzV1
aFc4RlZBdFBza1Y1cDR1dnJJZGdySjNEY25RCkdsYVltWGlRSlQwTnRYMnFoNHZW
OXZWOHFYRS9RbEtmVjczRUh5V0VBZ2cKLS0tICtvSlp6YkQ3S2s4bWNLMzhKeEd2
K2h1TjV2aFdxdWhHZ1RqOXhTS0g3OUEKQdlDeBcuzhkCjh3ubLp8F7cn9dsqNEni
hYv08MMmO8xR1fTUvShloWYeg3/UnnjM/jihPKzvS9wIdv4fTY2ShA==
-----END AGE ENCRYPTED FILE-----
lastmodified: "2026-08-26T06:30:08Z"
mac: ENC[AES256_GCM,data:u1DrTgAtbzzIKjZwEdDYxtWSN3yFf2yCZ8u9QZZDglT7KwjNRdgckpeoNst5Qq0iGkzldKQmBeSKkOvSeMe1iFam9qKM3abcqDRbaw+cAr9XKaUgKaNKyVvaiHqNYoaWynn5GcxKfe6TrYAAxTN4kJAAk71sSzbTP0fTEO0tOKs=,iv:LiSIBo1qZ7/txIqsHEb9hnhXLZI15NOU/byLN4skL/o=,tag:oxwNBvRP77TTzwAbK8F9hA==,type:str]
pgp: []
encrypted_regex: ^(data|stringData)$
version: 3.9.4
+74
View File
@@ -0,0 +1,74 @@
---
# Nextcloud's PostgreSQL — a CloudNativePG Cluster (CR of the CNPG CRDs from platform/cnpg), modeled
# on platform/keycloak/postgres-cluster.yaml. This is the first CNPG-DB in apps/ and establishes the
# reusable pattern for later DB-backed services (semaphore, unifi, ...).
#
# WHY A DEDICATED `nextcloud-db` NAMESPACE (not co-located in `nextcloud`):
# The app->db edge in catalog/nextcloud.yaml (`dependencies: to: nextcloud-db`) compiles to a
# CiliumNetworkPolicy whose egress selector pins BOTH the destination pod label
# (app.kubernetes.io/name=nextcloud-db) AND its namespace (k8s:io.kubernetes.pod.namespace=
# nextcloud-db) — cross-namespace Cilium selectors must name the namespace (catalog/render.py
# r_dependency_policies). So the DB MUST live in a namespace literally named `nextcloud-db` and its
# pods MUST carry app.kubernetes.io/name=nextcloud-db, injected below via inheritedMetadata.
#
# DEFAULT-DENY POSTURE — mirrors keycloak (platform/keycloak/namespace.yaml): this namespace is
# INTENTIONALLY not homelab.keithjackson.dev/app-namespace=true, so Kyverno does NOT generate the app
# default-deny baseline here and CNPG runs under Cilium's cluster-wide default-allow — the SAME posture
# the verified keycloak-db uses. The nextcloud SIDE keeps its default-deny; its rendered egress-allow
# opens ONLY nextcloud -> nextcloud-db:5432, and the DB side's default-allow accepts that reciprocal
# ingress. No app-namespace policy (auth-mode/host-namespace/secret-provenance) is weakened.
apiVersion: v1
kind: Namespace
metadata:
name: nextcloud-db
labels:
# Match the Cilium egress selector's namespace pin; NOT an app-namespace (see header).
app.kubernetes.io/name: nextcloud-db
---
# Owner credentials for the CNPG cluster. SOPS-encrypted (age); type basic-auth so CNPG's initdb
# consumes it as the `nextcloud` role's password instead of generating a random one — this lets the
# SAME password be mirrored into ns nextcloud (nextcloud-bootstrap/db-password) for the app's env,
# since a Pod cannot read a Secret in another namespace. Provided as apps/nextcloud-db-app.sops.yaml.
#
# CNPG Cluster (CR of postgresql.cnpg.io installed by platform/cnpg).
apiVersion: postgresql.cnpg.io/v1
kind: Cluster
metadata:
name: nextcloud-db
namespace: nextcloud-db
spec:
# Single Postgres instance on 3-replica Longhorn is acceptable at home scale (ADR-0014, same as
# keycloak-db). The storage is already 3-way replicated by Longhorn; raise to 3 for Postgres-level
# HA as a future hardening.
instances: 1
imageName: ghcr.io/cloudnative-pg/postgresql:16.4
# Stamp app.kubernetes.io/name=nextcloud-db onto every generated pod/service so the rendered
# nextcloud egress CiliumNetworkPolicy's toEndpoints selector matches the primary pod (R-NET-02).
inheritedMetadata:
labels:
app.kubernetes.io/name: nextcloud-db
storage:
storageClass: longhorn
size: 5Gi
bootstrap:
initdb:
database: nextcloud
owner: nextcloud
# Use the pre-provisioned owner secret (basic-auth) so its password is known + reusable by the
# app in the other namespace, instead of the random CNPG-generated one.
secret:
name: nextcloud-db-app
# Emit Postgres metrics so Prometheus scrapes DB health (R-OBS-01). The CNPG operator creates the
# PodMonitor at runtime (CRD present from Phase-4 monitoring).
monitoring:
enablePodMonitor: true
resources:
requests:
cpu: 100m
memory: 256Mi
limits:
memory: 512Mi
# --- Off-cluster DR backup (R-STATE-03) — OPERATOR HAND-OFF (OQ-BKP, shared with Velero, Phase 4) ---
# When the off-cluster backup target is chosen, add a Barman object store + a ScheduledBackup here
# (same hand-off as keycloak-db). Until then the DB is protected by 3-replica Longhorn + (Phase-4)
# Velero PVC backups of the namespace; the SD declares backup.policy daily-offcluster as the intent.
+212
View File
@@ -0,0 +1,212 @@
---
# Namespace (trust zone; default-deny + policies apply)
apiVersion: v1
kind: Namespace
metadata:
name: pihole
labels:
homelab.keithjackson.dev/app-namespace: 'true'
gateway-access: shared
annotations:
homelab.keithjackson.dev/backup-policy: daily-offcluster
---
# Default-deny baseline (R-NET-01): deny all, allow only DNS
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: default-deny
namespace: pihole
labels:
homelab.keithjackson.dev/baseline: default-deny
spec:
podSelector: {}
policyTypes:
- Ingress
- Egress
ingress: []
egress:
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: kube-system
ports:
- protocol: UDP
port: 53
- protocol: TCP
port: 53
---
# PVC pihole-config (R-STATE)
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: pihole-config
namespace: pihole
labels:
app.kubernetes.io/name: pihole
spec:
accessModes:
- ReadWriteOnce
storageClassName: longhorn
resources:
requests:
storage: 2Gi
---
# Deployment (R-PLAT-02)
apiVersion: apps/v1
kind: Deployment
metadata:
name: pihole
namespace: pihole
labels:
app.kubernetes.io/name: pihole
spec:
replicas: 1
strategy:
type: Recreate
selector:
matchLabels:
app.kubernetes.io/name: pihole
template:
metadata:
labels:
app.kubernetes.io/name: pihole
homelab.keithjackson.dev/egress-class: internet
homelab.keithjackson.dev/auth-mode: forward-auth
annotations:
homelab.keithjackson.dev/auth-mode: forward-auth
homelab.keithjackson.dev/auth-groups: platform-admins
spec:
containers:
- name: pihole
image: pihole/pihole
ports:
- containerPort: 80
volumeMounts:
- name: config
mountPath: /data/config
volumes:
- name: config
persistentVolumeClaim:
claimName: pihole-config
---
# Service
apiVersion: v1
kind: Service
metadata:
name: pihole
namespace: pihole
labels:
app.kubernetes.io/name: pihole
spec:
selector:
app.kubernetes.io/name: pihole
ports:
- name: port-80
port: 80
targetPort: 80
---
# CiliumNetworkPolicy gateway-ingress allow (R-NET-01/02): pihole-allow-gateway-ingress
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: pihole-allow-gateway-ingress
namespace: pihole
labels:
app.kubernetes.io/name: pihole
spec:
endpointSelector:
matchLabels:
app.kubernetes.io/name: pihole
ingress:
- fromEntities:
- ingress
toPorts:
- ports:
- port: '80'
protocol: TCP
---
# CiliumNetworkPolicy world egress allow (egress class governs the path; R-NET-05): pihole-allow-internet-egress
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: pihole-allow-internet-egress
namespace: pihole
labels:
app.kubernetes.io/name: pihole
spec:
endpointSelector:
matchLabels:
app.kubernetes.io/name: pihole
egress:
- toEntities:
- world
---
# HTTPRoute pihole-80 (R-TLS/R-DNS)
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: pihole-80
namespace: pihole
labels:
app.kubernetes.io/name: pihole
annotations:
homelab.keithjackson.dev/auth-mode: forward-auth
homelab.keithjackson.dev/auth-groups: platform-admins
spec:
parentRefs:
- name: shared
namespace: gateway
sectionName: https
hostnames:
- pihole.home.keithjackson.dev
rules:
- matches:
- path:
type: PathPrefix
value: /
backendRefs:
- name: pihole
port: 80
---
# ServiceMonitor (R-OBS-01)
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
name: pihole
namespace: pihole
labels:
release: kube-prometheus-stack
app.kubernetes.io/name: pihole
spec:
selector:
matchLabels:
app.kubernetes.io/name: pihole
namespaceSelector:
matchNames:
- pihole
endpoints:
- port: metrics
interval: 30s
---
# PrometheusRule SLI alerts (R-OBS-01)
apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
name: pihole-slis
namespace: pihole
labels:
release: kube-prometheus-stack
app.kubernetes.io/name: pihole
spec:
groups:
- name: pihole.slis
rules:
- alert: pihole-down
expr: up{job="pihole"} == 0
for: 5m
labels:
severity: critical
service: pihole
annotations:
summary: pihole is down
description: 'pihole has been unreachable for 5m (SLI: up).'
+228
View File
@@ -0,0 +1,228 @@
---
# Namespace (trust zone; default-deny + policies apply)
apiVersion: v1
kind: Namespace
metadata:
name: plex
labels:
homelab.keithjackson.dev/app-namespace: 'true'
gateway-access: shared
annotations:
homelab.keithjackson.dev/backup-policy: daily-offcluster
---
# Default-deny baseline (R-NET-01): deny all, allow only DNS
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: default-deny
namespace: plex
labels:
homelab.keithjackson.dev/baseline: default-deny
spec:
podSelector: {}
policyTypes:
- Ingress
- Egress
ingress: []
egress:
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: kube-system
ports:
- protocol: UDP
port: 53
- protocol: TCP
port: 53
---
# PVC plex-config (R-STATE)
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: plex-config
namespace: plex
labels:
app.kubernetes.io/name: plex
spec:
accessModes:
- ReadWriteOnce
storageClassName: longhorn
resources:
requests:
storage: 8Gi
---
# Deployment (R-PLAT-02)
apiVersion: apps/v1
kind: Deployment
metadata:
name: plex
namespace: plex
labels:
app.kubernetes.io/name: plex
spec:
replicas: 1
strategy:
type: Recreate
selector:
matchLabels:
app.kubernetes.io/name: plex
template:
metadata:
labels:
app.kubernetes.io/name: plex
homelab.keithjackson.dev/egress-class: internet
homelab.keithjackson.dev/auth-mode: native
spec:
containers:
- name: plex
image: plexinc/pms-docker:latest
ports:
- containerPort: 32400
volumeMounts:
- name: config
mountPath: /data/config
volumes:
- name: config
persistentVolumeClaim:
claimName: plex-config
---
# Service
apiVersion: v1
kind: Service
metadata:
name: plex
namespace: plex
labels:
app.kubernetes.io/name: plex
spec:
selector:
app.kubernetes.io/name: plex
ports:
- name: port-32400
port: 32400
targetPort: 32400
---
# CiliumNetworkPolicy reciprocal ingress allow (R-NET-02): plex-allow-seerr-32400
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: plex-allow-seerr-32400
namespace: plex
labels:
app.kubernetes.io/name: plex
spec:
endpointSelector:
matchLabels:
app.kubernetes.io/name: plex
ingress:
- fromEndpoints:
- matchLabels:
app.kubernetes.io/name: seerr
k8s:io.kubernetes.pod.namespace: seerr
toPorts:
- ports:
- port: '32400'
protocol: TCP
---
# CiliumNetworkPolicy gateway-ingress allow (R-NET-01/02): plex-allow-gateway-ingress
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: plex-allow-gateway-ingress
namespace: plex
labels:
app.kubernetes.io/name: plex
spec:
endpointSelector:
matchLabels:
app.kubernetes.io/name: plex
ingress:
- fromEntities:
- ingress
toPorts:
- ports:
- port: '32400'
protocol: TCP
---
# CiliumNetworkPolicy world egress allow (egress class governs the path; R-NET-05): plex-allow-internet-egress
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: plex-allow-internet-egress
namespace: plex
labels:
app.kubernetes.io/name: plex
spec:
endpointSelector:
matchLabels:
app.kubernetes.io/name: plex
egress:
- toEntities:
- world
---
# HTTPRoute plex-32400 (R-TLS/R-DNS)
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: plex-32400
namespace: plex
labels:
app.kubernetes.io/name: plex
spec:
parentRefs:
- name: shared
namespace: gateway
sectionName: https
hostnames:
- plex.home.keithjackson.dev
rules:
- matches:
- path:
type: PathPrefix
value: /
backendRefs:
- name: plex
port: 32400
---
# ServiceMonitor (R-OBS-01)
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
name: plex
namespace: plex
labels:
release: kube-prometheus-stack
app.kubernetes.io/name: plex
spec:
selector:
matchLabels:
app.kubernetes.io/name: plex
namespaceSelector:
matchNames:
- plex
endpoints:
- port: metrics
interval: 30s
---
# PrometheusRule SLI alerts (R-OBS-01)
apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
name: plex-slis
namespace: plex
labels:
release: kube-prometheus-stack
app.kubernetes.io/name: plex
spec:
groups:
- name: plex.slis
rules:
- alert: plex-down
expr: up{job="plex"} == 0
for: 5m
labels:
severity: critical
service: plex
annotations:
summary: plex is down
description: 'plex has been unreachable for 5m (SLI: up).'
+256
View File
@@ -0,0 +1,256 @@
---
# Namespace (trust zone; default-deny + policies apply)
apiVersion: v1
kind: Namespace
metadata:
name: qbittorrent
labels:
homelab.keithjackson.dev/app-namespace: 'true'
gateway-access: shared
annotations:
homelab.keithjackson.dev/backup-policy: daily-offcluster
---
# Default-deny baseline (R-NET-01): deny all, allow only DNS
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: default-deny
namespace: qbittorrent
labels:
homelab.keithjackson.dev/baseline: default-deny
spec:
podSelector: {}
policyTypes:
- Ingress
- Egress
ingress: []
egress:
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: kube-system
ports:
- protocol: UDP
port: 53
- protocol: TCP
port: 53
---
# PVC qbittorrent-config (R-STATE)
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: qbittorrent-config
namespace: qbittorrent
labels:
app.kubernetes.io/name: qbittorrent
spec:
accessModes:
- ReadWriteOnce
storageClassName: longhorn
resources:
requests:
storage: 5Gi
---
# Deployment (R-PLAT-02)
apiVersion: apps/v1
kind: Deployment
metadata:
name: qbittorrent
namespace: qbittorrent
labels:
app.kubernetes.io/name: qbittorrent
spec:
replicas: 1
strategy:
type: Recreate
selector:
matchLabels:
app.kubernetes.io/name: qbittorrent
template:
metadata:
labels:
app.kubernetes.io/name: qbittorrent
homelab.keithjackson.dev/egress-class: vpn
homelab.keithjackson.dev/auth-mode: forward-auth
annotations:
homelab.keithjackson.dev/auth-mode: forward-auth
homelab.keithjackson.dev/auth-groups: media-users
spec:
containers:
- name: qbittorrent
image: lscr.io/linuxserver/qbittorrent:5.1.2
ports:
- containerPort: 8080
volumeMounts:
- name: config
mountPath: /data/config
volumes:
- name: config
persistentVolumeClaim:
claimName: qbittorrent-config
---
# Service
apiVersion: v1
kind: Service
metadata:
name: qbittorrent
namespace: qbittorrent
labels:
app.kubernetes.io/name: qbittorrent
spec:
selector:
app.kubernetes.io/name: qbittorrent
ports:
- name: port-8080
port: 8080
targetPort: 8080
---
# CiliumNetworkPolicy reciprocal ingress allow (R-NET-02): qbittorrent-allow-radarr-8080
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: qbittorrent-allow-radarr-8080
namespace: qbittorrent
labels:
app.kubernetes.io/name: qbittorrent
spec:
endpointSelector:
matchLabels:
app.kubernetes.io/name: qbittorrent
ingress:
- fromEndpoints:
- matchLabels:
app.kubernetes.io/name: radarr
k8s:io.kubernetes.pod.namespace: radarr
toPorts:
- ports:
- port: '8080'
protocol: TCP
---
# CiliumNetworkPolicy reciprocal ingress allow (R-NET-02): qbittorrent-allow-sonarr-8080
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: qbittorrent-allow-sonarr-8080
namespace: qbittorrent
labels:
app.kubernetes.io/name: qbittorrent
spec:
endpointSelector:
matchLabels:
app.kubernetes.io/name: qbittorrent
ingress:
- fromEndpoints:
- matchLabels:
app.kubernetes.io/name: sonarr
k8s:io.kubernetes.pod.namespace: sonarr
toPorts:
- ports:
- port: '8080'
protocol: TCP
---
# CiliumNetworkPolicy gateway-ingress allow (R-NET-01/02): qbittorrent-allow-gateway-ingress
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: qbittorrent-allow-gateway-ingress
namespace: qbittorrent
labels:
app.kubernetes.io/name: qbittorrent
spec:
endpointSelector:
matchLabels:
app.kubernetes.io/name: qbittorrent
ingress:
- fromEntities:
- ingress
toPorts:
- ports:
- port: '8080'
protocol: TCP
---
# CiliumNetworkPolicy world egress allow (egress class governs the path; R-NET-05): qbittorrent-allow-internet-egress
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: qbittorrent-allow-internet-egress
namespace: qbittorrent
labels:
app.kubernetes.io/name: qbittorrent
spec:
endpointSelector:
matchLabels:
app.kubernetes.io/name: qbittorrent
egress:
- toEntities:
- world
---
# HTTPRoute qbittorrent-8080 (R-TLS/R-DNS)
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: qbittorrent-8080
namespace: qbittorrent
labels:
app.kubernetes.io/name: qbittorrent
annotations:
homelab.keithjackson.dev/auth-mode: forward-auth
homelab.keithjackson.dev/auth-groups: media-users
spec:
parentRefs:
- name: shared
namespace: gateway
sectionName: https
hostnames:
- qbittorrent.home.keithjackson.dev
rules:
- matches:
- path:
type: PathPrefix
value: /
backendRefs:
- name: qbittorrent
port: 8080
---
# ServiceMonitor (R-OBS-01)
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
name: qbittorrent
namespace: qbittorrent
labels:
release: kube-prometheus-stack
app.kubernetes.io/name: qbittorrent
spec:
selector:
matchLabels:
app.kubernetes.io/name: qbittorrent
namespaceSelector:
matchNames:
- qbittorrent
endpoints:
- port: metrics
interval: 30s
---
# PrometheusRule SLI alerts (R-OBS-01)
apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
name: qbittorrent-slis
namespace: qbittorrent
labels:
release: kube-prometheus-stack
app.kubernetes.io/name: qbittorrent
spec:
groups:
- name: qbittorrent.slis
rules:
- alert: qbittorrent-down
expr: up{job="qbittorrent"} == 0
for: 5m
labels:
severity: critical
service: qbittorrent
annotations:
summary: qbittorrent is down
description: 'qbittorrent has been unreachable for 5m (SLI: up).'
+36
View File
@@ -0,0 +1,36 @@
# Semaphore PostgreSQL owner credentials (WS-APPS, Phase 6). SOPS-encrypted (age).
# type basic-auth (keys username/password) so the CNPG semaphore-db Cluster consumes it as the
# semaphore role via spec.bootstrap.initdb.secret. The SAME password is mirrored (out of band) into
# semaphore-bootstrap/db-password in ns semaphore for the app env (cross-ns Secret reads are not
# possible). Recover: infra/devcontainer/dev.sh run sops -d apps/semaphore-db-app.sops.yaml
apiVersion: v1
kind: Secret
metadata:
name: semaphore-db-app
namespace: semaphore-db
labels:
app.kubernetes.io/name: semaphore-db
type: kubernetes.io/basic-auth
stringData:
username: ENC[AES256_GCM,data:HHZSssc3EUBI,iv:rSQDLlUB7jJmNt/aWK192Dai02DLnUR8gUBvTjPOR2w=,tag:536QJ0eni7ywFNcGsahRGg==,type:str]
password: ENC[AES256_GCM,data:U08COmf1X+JXjdEiKLQbYLcwU9/dAY/tnr4ErJxYY1CfVT1g6O9EfPCAGr4Bcb7w,iv:FNX8fCYuiXzG4SPwqwsVQwGOCxrz7WIsoZgD7QFeF14=,tag:tzEF58j4iLF9GLW6spo8Ww==,type:str]
sops:
kms: []
gcp_kms: []
azure_kv: []
hc_vault: []
age:
- recipient: age175l8mdpdfu7hq0aml3exs3gykarwjmuvr23sq56s3wjejnyk44wsp4mfzg
enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSByMk1DQXI4T1NycFRaTnpw
eXprNTRpQUlmWkMrVkJBand5UUtQYmxZT0E0CkNZMU9Ic0FuS0dLMzJQUWVBdmhU
ZmQvYjRhNWs4SVFLYzhLV1lRdDNQc1UKLS0tIEtEQmFOOWdlRkUwczB1RzRPSU5R
Y1ZFVWZnS2dZUlV1cDJTRHNSWjFrZzgKT3O1iKP40UDdecai9wygeqe1W+wWqiL/
jxZAK0Nwk/AMCgxuNKruDsjBTJ7SLoCVn+uyp6CUdPt/Xbax/XOnIw==
-----END AGE ENCRYPTED FILE-----
lastmodified: "2026-08-26T06:45:19Z"
mac: ENC[AES256_GCM,data:gpPdGR59gDiMlKtpaIaYUDArOH0ItuJ9egGC8/QNV3JHkvPgKbA0Czwy4qrVOOOi1/9FVD2i0an5O1OPHy5B9R8I1xanXjCzlz1x7+Q76xIGZHwAHPsjPJyqOefuwWFoE114dStHhNGdLN2nNnDJX2bjJHyMtKHi8CK7BNkpJxY=,iv:jV3dwb0f7c+RrqkufxOYBwh4yNlsfK2KfMh2nn9+9WI=,tag:Yg9B/q6fxz4MobsF/1agHg==,type:str]
pgp: []
encrypted_regex: ^(data|stringData)$
version: 3.9.4
+75
View File
@@ -0,0 +1,75 @@
---
# Semaphore's PostgreSQL — a CloudNativePG Cluster (CR of the CNPG CRDs from platform/cnpg), modeled
# on apps/nextcloud-db.yaml (the first CNPG-DB) / platform/keycloak/postgres-cluster.yaml. This is the
# SECOND CNPG-DB in apps/, reusing the pattern established by nextcloud.
#
# WHY A DEDICATED `semaphore-db` NAMESPACE (not co-located in `semaphore`):
# The app->db edge in catalog/semaphore.yaml (`dependencies: to: semaphore-db`) compiles to a
# CiliumNetworkPolicy whose egress selector pins BOTH the destination pod label
# (app.kubernetes.io/name=semaphore-db) AND its namespace (k8s:io.kubernetes.pod.namespace=
# semaphore-db) — cross-namespace Cilium selectors must name the namespace (catalog/render.py
# r_dependency_policies). So the DB MUST live in a namespace literally named `semaphore-db` and its
# pods MUST carry app.kubernetes.io/name=semaphore-db, injected below via inheritedMetadata.
#
# DEFAULT-DENY POSTURE — mirrors keycloak/nextcloud-db (platform/keycloak/namespace.yaml): this
# namespace is INTENTIONALLY not homelab.keithjackson.dev/app-namespace=true, so Kyverno does NOT
# generate the app default-deny baseline here and CNPG runs under Cilium's cluster-wide default-allow —
# the SAME posture the verified keycloak-db / nextcloud-db use. The semaphore SIDE keeps its
# default-deny; its rendered egress-allow opens ONLY semaphore -> semaphore-db:5432, and the DB side's
# default-allow accepts that reciprocal ingress. No app-namespace policy (auth-mode/host-namespace/
# secret-provenance) is weakened.
apiVersion: v1
kind: Namespace
metadata:
name: semaphore-db
labels:
# Match the Cilium egress selector's namespace pin; NOT an app-namespace (see header).
app.kubernetes.io/name: semaphore-db
---
# Owner credentials for the CNPG cluster. SOPS-encrypted (age); type basic-auth so CNPG's initdb
# consumes it as the `semaphore` role's password instead of generating a random one — this lets the
# SAME password be mirrored into ns semaphore (semaphore-bootstrap/db-password) for the app's env,
# since a Pod cannot read a Secret in another namespace. Provided as apps/semaphore-db-app.sops.yaml.
#
# CNPG Cluster (CR of postgresql.cnpg.io installed by platform/cnpg).
apiVersion: postgresql.cnpg.io/v1
kind: Cluster
metadata:
name: semaphore-db
namespace: semaphore-db
spec:
# Single Postgres instance on 3-replica Longhorn is acceptable at home scale (ADR-0014, same as
# keycloak-db / nextcloud-db). The storage is already 3-way replicated by Longhorn; raise to 3 for
# Postgres-level HA as a future hardening.
instances: 1
imageName: ghcr.io/cloudnative-pg/postgresql:16.4
# Stamp app.kubernetes.io/name=semaphore-db onto every generated pod/service so the rendered
# semaphore egress CiliumNetworkPolicy's toEndpoints selector matches the primary pod (R-NET-02).
inheritedMetadata:
labels:
app.kubernetes.io/name: semaphore-db
storage:
storageClass: longhorn
size: 5Gi
bootstrap:
initdb:
database: semaphore
owner: semaphore
# Use the pre-provisioned owner secret (basic-auth) so its password is known + reusable by the
# app in the other namespace, instead of the random CNPG-generated one.
secret:
name: semaphore-db-app
# Emit Postgres metrics so Prometheus scrapes DB health (R-OBS-01). The CNPG operator creates the
# PodMonitor at runtime (CRD present from Phase-4 monitoring).
monitoring:
enablePodMonitor: true
resources:
requests:
cpu: 100m
memory: 256Mi
limits:
memory: 512Mi
# --- Off-cluster DR backup (R-STATE-03) — OPERATOR HAND-OFF (OQ-BKP, shared with Velero, Phase 4) ---
# When the off-cluster backup target is chosen, add a Barman object store + a ScheduledBackup here
# (same hand-off as keycloak-db / nextcloud-db). Until then the DB is protected by 3-replica Longhorn
# + (Phase-4) Velero PVC backups of the namespace; the SD declares backup.policy daily-offcluster.