This document describes the structure and conventions used in the nerv Kubernetes cluster for AI agents and contributors.
nerv is a GitOps-managed Kubernetes cluster using:
- Flux CD for continuous deployment
- SOPS + AGE for secret encryption
- CloudNativePG for PostgreSQL databases
- Envoy Gateway for ingress (Gateway API)
- bjw-s app-template Helm chart for most applications
nerv/main/cluster/
├── apps/ # Application deployments
│ ├── kustomization.yaml # Root - lists all apps
│ ├── namespace.yaml # apps namespace
│ ├── app-template.yaml # Shared OCI repository for bjw-s helm chart
│ ├── shared/
│ │ ├── postgres/ # CloudNativePG shared cluster
│ │ └── redis/ # Shared Redis
│ └── <app-name>/
│ ├── ks.yaml # Flux Kustomization (entry point)
│ └── app/
│ ├── kustomization.yaml
│ ├── helmrelease.yaml
│ └── secret.yaml # SOPS-encrypted
├── networking-system/
│ └── envoy-gateway/ # Gateway configuration
└── ...
mkdir -p nerv/main/cluster/apps/<app-name>/app---
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata:
name: &app <app-name>
namespace: flux-system
spec:
targetNamespace: apps
path: ./nerv/main/cluster/apps/<app-name>/app
prune: true
sourceRef:
kind: GitRepository
name: flux-system
wait: false
interval: 30m
retryInterval: 1m
timeout: 5m
dependsOn:
- name: shared-postgres # If app needs databaseUses bjw-s app-template. Key sections:
---
apiVersion: helm.toolkit.fluxcd.io/v2
kind: HelmRelease
metadata:
name: <app-name>
namespace: apps
spec:
interval: 15m
chartRef:
kind: OCIRepository
name: app-template
namespace: apps
values:
controllers:
<app-name>:
pod:
securityContext:
runAsUser: 1000
runAsGroup: 1000
runAsNonRoot: true
fsGroup: 1000
fsGroupChangePolicy: OnRootMismatch
containers:
app:
image:
repository: <image>
tag: <tag>@sha256:<digest>
env:
# Direct env vars
envFrom:
- secretRef:
name: <app-name>
probes:
liveness: &probes
enabled: true
custom: true
spec:
httpGet:
path: /health
port: &port 8080
readiness: *probes
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
service:
app:
controller: <app-name>
ports:
http:
port: *port
route:
app:
hostnames:
- <app>.nerv.id
parentRefs:
- name: internal # or external
namespace: networking-system
rules:
- backendRefs:
- name: <app-name>
port: *port
persistence:
data:
type: persistentVolumeClaim
accessMode: ReadWriteOnce
size: 1Gi
storageClass: local-path
globalMounts:
- path: /data---
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- ./helmrelease.yaml
- ./secret.yamlCreate plaintext secret:
apiVersion: v1
kind: Secret
metadata:
name: <app-name>
namespace: apps
type: Opaque
stringData:
KEY: valueEncrypt with SOPS:
sops --encrypt --in-place nerv/main/cluster/apps/<app-name>/app/secret.yamlAdd to nerv/main/cluster/apps/kustomization.yaml:
resources:
- <app-name>/ks.yamlCreate nerv/main/cluster/apps/shared/postgres/app/postgres-<app>-user-secret.yaml:
apiVersion: v1
kind: Secret
metadata:
name: postgres-<app>-user
namespace: apps
type: Opaque
stringData:
username: <app_name> # Use underscores for PG identifiers
password: <generate-strong-password>Encrypt it:
sops --encrypt --in-place nerv/main/cluster/apps/shared/postgres/app/postgres-<app>-user-secret.yamlEdit nerv/main/cluster/apps/shared/postgres/app/postgres-cluster.yaml:
managed:
roles:
# ... existing roles ...
- name: <app_name>
ensure: present
login: true
superuser: false
passwordSecret:
name: postgres-<app>-userEdit nerv/main/cluster/apps/shared/postgres/app/databases.yaml:
---
apiVersion: postgresql.cnpg.io/v1
kind: Database
metadata:
name: <app>
namespace: apps
spec:
cluster:
name: postgres-apps
name: <app_name>
owner: <app_name>Edit nerv/main/cluster/apps/shared/postgres/app/kustomization.yaml:
resources:
# ... existing secrets ...
- postgres-<app>-user-secret.yaml
- postgres-cluster.yaml
- databases.yamlIn your app's secret, use:
postgres://<app_name>:<password>@postgres-apps-rw.apps.svc.cluster.local:5432/<app_name>
Located at nerv/.sops.yaml:
creation_rules:
- encrypted_regex: '((?i)(pass($|word)|claim|secret($|[^N])|key|token|^data$|^stringData|^databaseUrl))'
age: age194r4u78jlkcg3waxh5ddpwe6y0pwenuhk9avnkmc3huzcpf26d0spa3ggf# Encrypt a file
sops --encrypt --in-place <file.yaml>
# Decrypt for viewing
sops --decrypt <file.yaml>
# Edit in-place
sops <file.yaml>| Gateway | IP | Use Case |
|---|---|---|
internal |
192.168.5.12 | Internal services (*.nerv.id) |
external |
192.168.5.11 | Public-facing services |
route:
app:
hostnames:
- app.nerv.id
parentRefs:
- name: internal # or external
namespace: networking-system
rules:
- backendRefs:
- name: <service-name>
port: 8080The .ref/ directory (gitignored) contains reference homelab repos.
- Namespaces: Most apps go in
appsnamespace - Image tags: Always pin with SHA digest (
tag@sha256:...) - Security contexts: Always set
runAsNonRoot,readOnlyRootFilesystem, drop capabilities - Probes: Always configure liveness and readiness probes
- Resources: Always set requests and limits
- Secrets: Never commit plaintext secrets - always SOPS encrypt
- PG identifiers: Use underscores (
pocket_id) not hyphens