Import manifests reverse-engineered from live cluster

Captured 2026-07-26 from rpi-master (k3s v1.30.3) and cleaned of runtime
fields. Six apps as per-app kustomizations: vaultwarden, searxng,
home-assistant, nginx, pihole(+unbound), unified-streaming.

Intentional divergences from live state:
- pihole WEBPASSWORD and USP_LICENSE_KEY moved from inline plaintext env
  to secretKeyRef (secrets gitignored; templates in secret.example.yaml)
- HA ingress defaultBackend fixed (pointed at nonexistent service)
- unifiedstreaming-svc kept as ClusterIP (LoadBalancer could never bind
  port 80 behind svclb-traefik)

Validated against the live cluster with kubectl apply --dry-run=server:
no immutable-field conflicts; one-time kubectl replace procedure for the
two env->secretKeyRef migrations documented in README.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
jasonwitty 2026-07-26 02:13:56 -07:00
parent ec9b422f87
commit 8afac39d03
40 changed files with 1179 additions and 1 deletions

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
# Real secrets never go in git — only *.example.yaml templates do.
apps/**/secret.yaml
*.secret.yaml

100
README.md
View File

@ -1,3 +1,101 @@
# homelab-k3s # homelab-k3s
k3s deployments and change management for homelab env. k3s deployments and change management for the homelab Raspberry Pi cluster
(rpi-master + rpi-worker-1/2/3, k3s v1.30.3, flannel/traefik/klipper-lb
defaults).
Manifests were reverse-engineered from the live cluster on 2026-07-26 and
cleaned of runtime fields. Except where a file header says **DIVERGENCE**,
they are faithful to what was running.
## Layout
Each app under `apps/` is a self-contained kustomization
(namespace + PV/PVC + deployment + service + ingress):
| App | Namespace | Hostname(s) |
|---|---|---|
| vaultwarden | bitwarden | pm.wittyoneoff.com |
| searxng | default | searxng.wittyoneoff.com, origin-searxng.wittyoneoff.com |
| home-assistant | home-assistant | ha.wittyoneoff.com, ha-origin.wittyoneoff.com |
| nginx | nginx | www.wittyoneoff.com, www.ramonaajj.com |
| pihole (+unbound) | pihole | admin on :8000 via klipper-lb, DNS on :53 |
| unified-streaming | unified-streaming | unified.wittyoneoff.com |
Not in this repo: socktop (own repo + pipeline), rancher/monitoring/logging
(rancher-managed), traefik (k3s packaged component).
## Applying
```sh
kubectl apply -k . # everything
kubectl apply -k apps/home-assistant # one app
```
### One-time secret bootstrap (per cluster)
Three apps read secrets that are NOT in git. Each app dir has a
`secret.example.yaml`; copy to `secret.yaml` (gitignored), fill in, apply.
| Secret | Namespace | Used for |
|---|---|---|
| `pihole-admin` | pihole | pi-hole admin `WEBPASSWORD` |
| `usp-license` | unified-streaming | `USP_LICENSE_KEY` |
| `searxng-1723974683-config` | default | searxng `settings.yml` |
**One-time migration (pihole + unified-streaming):** the live cluster (as of
2026-07-26) has WEBPASSWORD / USP_LICENSE_KEY as inline plaintext `value`s.
`kubectl apply` (client- or server-side) cannot patch an env var from `value`
to `valueFrom` — the API rejects the merged object. Validated procedure:
```sh
# 1. create the two secrets (see secret.example.yaml in each app dir)
# 2. one-time replace (restarts the pods):
kubectl replace -f apps/pihole/deployment-pihole.yaml
kubectl replace -f apps/unified-streaming/deployment.yaml
```
After that, `kubectl apply -k .` works cleanly for everything (verified with
`--dry-run=server` against the live cluster, 2026-07-26 — zero immutable-field
conflicts).
## Cluster assumptions (state that lives outside these manifests)
- **Node labels** — scheduling relies on them: workers carry `cpu=arm`
(all nodes do), the control-plane node carries `controller=true`
(most apps use `controller NotIn (true)` to stay off the master), pis carry
`model=raspi` (pihole/unbound prefer it). Re-label with:
`kubectl label node <n> cpu=arm model=raspi` /
`kubectl label node rpi-master controller=true`.
- **GlusterFS** — every PV is `hostPath` under `/mnt/gvolume0/…`, which is the
gluster volume mounted identically on all nodes. The mount must exist before
pods schedule; PVs bind by explicit `volumeName` + `storageClassName: ""`.
- **Ingress/LB** — traefik (k3s default) terminates all HTTP on 80/443 via
svclb on every node. Because of that, no other LoadBalancer service can
claim host port 80 (that's why unifiedstreaming-svc is ClusterIP).
- **Home Assistant config** — lives on the PV, not in git.
`http.trusted_proxies` must include `10.42.0.0/16` (the cluster pod CIDR)
or HA 400s everything the moment traefik reschedules to another node.
## Known quirks (faithful to live state, fix at leisure)
- Most images are `:latest`/`:stable` — deploys are not reproducible until
pinned. vaultwarden is pinned (1.35.4).
- searxng keeps its helm-generated name suffix (`searxng-1723974683`) because
deployment selectors are immutable; its helm release record still exists in
the cluster — don't run `helm upgrade/uninstall` on it, this repo is the
source of truth now.
- searxng's `TZ=America/Los Angeles` (missing underscore) is invalid tzdata —
container falls back to UTC.
- vaultwarden and home-assistant carry helm-chart-era label/name shapes but
their release records are gone; they are plain manifests now.
- pihole runs `privileged: true` with `PIHOLE_UID=0`.
## History
- 2026-07-25: cluster outage (rpi-master undervoltage → CNI cache corruption).
Recovery runbook in the Notes vault:
`rpi-master_undervoltage_CNI_corruption_incident-2026-07-25.md`.
- 2026-07-26: initial import of manifests from live cluster; HA ingress
defaultBackend fixed; unifiedstreaming-svc LoadBalancer→ClusterIP;
pihole/USP secrets moved to secretKeyRef (pending first apply).

View File

@ -0,0 +1,74 @@
# Originally installed via helm chart home-assistant-13.3.0 (release record no
# longer in cluster); managed as a raw manifest since. Selector labels are
# immutable — do not change them.
# NOTE: /config/configuration.yaml (on the PV, not in git) must keep
# http.trusted_proxies: [10.42.0.0/16] so traefik can reach HA from any node.
apiVersion: apps/v1
kind: Deployment
metadata:
name: home-assistant
namespace: home-assistant
labels:
app.kubernetes.io/instance: home-assistant
app.kubernetes.io/name: home-assistant
spec:
replicas: 1
revisionHistoryLimit: 3
selector:
matchLabels:
app.kubernetes.io/instance: home-assistant
app.kubernetes.io/name: home-assistant
strategy:
type: Recreate
template:
metadata:
labels:
app.kubernetes.io/instance: home-assistant
app.kubernetes.io/name: home-assistant
spec:
affinity:
nodeAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
nodeSelectorTerms:
- matchExpressions:
- key: cpu
operator: In
values:
- arm
- key: controller
operator: NotIn
values:
- "true"
containers:
- name: home-assistant
image: ghcr.io/home-assistant/home-assistant:stable
imagePullPolicy: Always
env:
- name: TZ
value: UTC
ports:
- name: http
containerPort: 8123
protocol: TCP
startupProbe:
tcpSocket:
port: 8123
failureThreshold: 30
periodSeconds: 5
livenessProbe:
tcpSocket:
port: 8123
failureThreshold: 3
periodSeconds: 10
readinessProbe:
tcpSocket:
port: 8123
failureThreshold: 3
periodSeconds: 10
volumeMounts:
- name: config
mountPath: /config
volumes:
- name: config
persistentVolumeClaim:
claimName: home-assistant

View File

@ -0,0 +1,37 @@
# NOTE: the live object's defaultBackend pointed at a service named
# "home-assistant", which does not exist (the real service is
# "home-assistant-svc"). Fixed here — this is the one intentional divergence
# from the cluster state captured 2026-07-26.
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: home-assistant
namespace: home-assistant
spec:
ingressClassName: traefik
defaultBackend:
service:
name: home-assistant-svc
port:
number: 80
rules:
- host: ha.wittyoneoff.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: home-assistant-svc
port:
number: 80
- host: ha-origin.wittyoneoff.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: home-assistant-svc
port:
number: 80

View File

@ -0,0 +1,8 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- namespace.yaml
- storage.yaml
- deployment.yaml
- service.yaml
- ingress.yaml

View File

@ -0,0 +1,4 @@
apiVersion: v1
kind: Namespace
metadata:
name: home-assistant

View File

@ -0,0 +1,15 @@
apiVersion: v1
kind: Service
metadata:
name: home-assistant-svc
namespace: home-assistant
spec:
type: ClusterIP
selector:
app.kubernetes.io/instance: home-assistant
app.kubernetes.io/name: home-assistant
ports:
- name: http
port: 80
targetPort: 8123
protocol: TCP

View File

@ -0,0 +1,30 @@
# /config lives on GlusterFS (/mnt/gvolume0 mounted on every node).
# Known risk: HA recorder DB on gluster + non-atomic renames → occasional
# .storage write errors. See homelab notes.
apiVersion: v1
kind: PersistentVolume
metadata:
name: home-assistant-pv
spec:
capacity:
storage: 10Gi
accessModes:
- ReadWriteOnce
persistentVolumeReclaimPolicy: Retain
volumeMode: Filesystem
hostPath:
path: /mnt/gvolume0/home-assistant
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: home-assistant
namespace: home-assistant
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 10Gi
storageClassName: ""
volumeName: home-assistant-pv

View File

@ -0,0 +1,59 @@
# Created via Rancher originally — the workload.user.cattle.io selector label
# is immutable on the live object; do not change it.
apiVersion: apps/v1
kind: Deployment
metadata:
name: nginx
namespace: nginx
labels:
workload.user.cattle.io/workloadselector: apps.deployment-nginx-nginx
spec:
replicas: 3
selector:
matchLabels:
workload.user.cattle.io/workloadselector: apps.deployment-nginx-nginx
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 25%
maxUnavailable: 25%
template:
metadata:
labels:
workload.user.cattle.io/workloadselector: apps.deployment-nginx-nginx
spec:
affinity:
nodeAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
nodeSelectorTerms:
- matchExpressions:
- key: cpu
operator: In
values:
- arm
- key: controller
operator: NotIn
values:
- "true"
containers:
- name: nginx
image: nginx:latest
imagePullPolicy: Always
ports:
- name: http
containerPort: 80
protocol: TCP
volumeMounts:
- name: vol-vnbxq
mountPath: /usr/share/nginx/html
subPath: www
- name: vol-vnbxq
mountPath: /etc/nginx/conf
subPath: conf
- name: vol-vnbxq
mountPath: /etc/nginx/conf.d
subPath: conf.d
volumes:
- name: vol-vnbxq
persistentVolumeClaim:
claimName: nginx

28
apps/nginx/ingress.yaml Normal file
View File

@ -0,0 +1,28 @@
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: nginx
namespace: nginx
spec:
ingressClassName: traefik
rules:
- host: www.wittyoneoff.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: nginx
port:
number: 80
- host: www.ramonaajj.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: nginx
port:
number: 80

View File

@ -0,0 +1,8 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- namespace.yaml
- storage.yaml
- deployment.yaml
- service.yaml
- ingress.yaml

View File

@ -0,0 +1,4 @@
apiVersion: v1
kind: Namespace
metadata:
name: nginx

14
apps/nginx/service.yaml Normal file
View File

@ -0,0 +1,14 @@
apiVersion: v1
kind: Service
metadata:
name: nginx
namespace: nginx
spec:
type: ClusterIP
selector:
workload.user.cattle.io/workloadselector: apps.deployment-nginx-nginx
ports:
- name: http
port: 80
targetPort: 80
protocol: TCP

35
apps/nginx/storage.yaml Normal file
View File

@ -0,0 +1,35 @@
# Static site content + nginx config live on GlusterFS:
# /mnt/gvolume0/nginx/www → /usr/share/nginx/html
# /mnt/gvolume0/nginx/conf → /etc/nginx/conf
# /mnt/gvolume0/nginx/conf.d → /etc/nginx/conf.d
apiVersion: v1
kind: PersistentVolume
metadata:
name: nginx-gvolume0
spec:
capacity:
storage: 50Gi
accessModes:
- ReadWriteOnce
- ReadOnlyMany
- ReadWriteMany
persistentVolumeReclaimPolicy: Retain
volumeMode: Filesystem
hostPath:
path: /mnt/gvolume0/nginx
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: nginx
namespace: nginx
spec:
accessModes:
- ReadWriteOnce
- ReadOnlyMany
- ReadWriteMany
resources:
requests:
storage: 50Gi
storageClassName: ""
volumeName: nginx-gvolume0

View File

@ -0,0 +1,86 @@
# DIVERGENCE from live state (intentional): the live deployment has WEBPASSWORD
# as a plaintext env value. Here it comes from the `pihole-admin` secret —
# create it (see secret.example.yaml) BEFORE applying this, or the pod will
# fail with CreateContainerConfigError.
apiVersion: apps/v1
kind: Deployment
metadata:
name: pihole
namespace: pihole
labels:
app: pihole
spec:
replicas: 1
selector:
matchLabels:
app: pihole
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 0
maxUnavailable: 1
template:
metadata:
labels:
app: pihole
spec:
affinity:
nodeAffinity:
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 1
preference:
matchExpressions:
- key: cpu
operator: In
values:
- arm
- key: model
operator: In
values:
- raspi
containers:
- name: pihole
image: pihole/pihole:latest
imagePullPolicy: IfNotPresent
env:
- name: TZ
value: America/Los_Angeles
- name: WEBPASSWORD
valueFrom:
secretKeyRef:
name: pihole-admin
key: WEBPASSWORD
- name: PIHOLE_UID
value: "0"
ports:
- containerPort: 53
protocol: TCP
- containerPort: 53
protocol: UDP
- containerPort: 67
protocol: UDP
- containerPort: 80
protocol: TCP
- containerPort: 443
protocol: TCP
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: "1"
memory: 2Gi
securityContext:
privileged: true
volumeMounts:
- name: gv-pihole
mountPath: /etc/pihole
- name: gv-pihole-dnsmasq-volume
mountPath: /etc/dnsmasq.d
volumes:
- name: gv-pihole
persistentVolumeClaim:
claimName: pihole-gvolume0
- name: gv-pihole-dnsmasq-volume
persistentVolumeClaim:
claimName: pihole-dnsmasq-volume

View File

@ -0,0 +1,54 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: unbound
namespace: pihole
labels:
app: unbound
spec:
replicas: 3
selector:
matchLabels:
app: unbound
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 25%
maxUnavailable: 25%
template:
metadata:
labels:
app: unbound
spec:
affinity:
nodeAffinity:
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 1
preference:
matchExpressions:
- key: cpu
operator: In
values:
- arm
- key: model
operator: In
values:
- raspi
containers:
- name: unbound
image: docker.io/mvance/unbound-rpi:latest
imagePullPolicy: Always
ports:
- name: svc-53-udp
containerPort: 53
protocol: UDP
- name: svc-53-tcp
containerPort: 53
protocol: TCP
resources:
requests:
cpu: 250m
memory: 50Mi
limits:
cpu: "1"
memory: 1Gi

View File

@ -0,0 +1,10 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
# secret.example.yaml is intentionally NOT listed — copy it to secret.yaml
# (gitignored) and apply manually once per cluster.
resources:
- namespace.yaml
- storage.yaml
- deployment-pihole.yaml
- deployment-unbound.yaml
- service.yaml

View File

@ -0,0 +1,4 @@
apiVersion: v1
kind: Namespace
metadata:
name: pihole

View File

@ -0,0 +1,13 @@
# Copy to secret.yaml (gitignored), set the real admin password, apply once:
# kubectl apply -f secret.yaml
# Or create directly without a file:
# kubectl create secret generic pihole-admin -n pihole \
# --from-literal=WEBPASSWORD='...'
apiVersion: v1
kind: Secret
metadata:
name: pihole-admin
namespace: pihole
type: Opaque
stringData:
WEBPASSWORD: CHANGE-ME

53
apps/pihole/service.yaml Normal file
View File

@ -0,0 +1,53 @@
# pihole-svc is type LoadBalancer → k3s klipper-lb (svclb) binds host ports
# 8000/53/67 on the nodes it schedules on. NodePorts are pinned so they
# survive re-creation.
apiVersion: v1
kind: Service
metadata:
name: pihole-svc
namespace: pihole
spec:
type: LoadBalancer
externalTrafficPolicy: Cluster
selector:
app: pihole
ports:
- name: http-admin
port: 8000
targetPort: 80
nodePort: 31120
protocol: TCP
- name: tcp-53
port: 53
targetPort: 53
nodePort: 32580
protocol: TCP
- name: udp-53
port: 53
targetPort: 53
nodePort: 31281
protocol: UDP
- name: udp-67
port: 67
targetPort: 67
nodePort: 31025
protocol: UDP
---
apiVersion: v1
kind: Service
metadata:
name: unbound-service
namespace: pihole
spec:
type: ClusterIP
selector:
app: unbound
ports:
- name: udp-53
port: 53
targetPort: 53
protocol: UDP
- name: tcp-53
port: 53
targetPort: 53
protocol: TCP

55
apps/pihole/storage.yaml Normal file
View File

@ -0,0 +1,55 @@
apiVersion: v1
kind: PersistentVolume
metadata:
name: pihole-gvolume0-pv
spec:
capacity:
storage: 10Gi
accessModes:
- ReadWriteOnce
persistentVolumeReclaimPolicy: Retain
volumeMode: Filesystem
hostPath:
path: /mnt/gvolume0/pihole
---
apiVersion: v1
kind: PersistentVolume
metadata:
name: pihole-dnsmasq-volume-pv
spec:
capacity:
storage: 10Gi
accessModes:
- ReadWriteOnce
persistentVolumeReclaimPolicy: Retain
volumeMode: Filesystem
hostPath:
path: /mnt/gvolume0/pihole-dnsmasq-volume
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: pihole-gvolume0
namespace: pihole
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 10Gi
storageClassName: ""
volumeName: pihole-gvolume0-pv
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: pihole-dnsmasq-volume
namespace: pihole
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 10Gi
storageClassName: ""
volumeName: pihole-dnsmasq-volume-pv

View File

@ -0,0 +1,78 @@
# Helm release searxng-1723974683 (chart searxng-1.0.0) in the *default*
# namespace — the release is still recorded in the cluster, so if you ever
# `helm upgrade` it, helm and this manifest will fight. Prefer retiring the
# helm release (helm delete --no-hooks would delete resources; instead just
# treat this repo as the source of truth and never run helm against it).
# Names keep the timestamped release suffix because selectors are immutable.
# NOTE (faithful to live state): TZ value "America/Los Angeles" is missing the
# underscore — an invalid tzdata name, so searxng falls back to UTC. Kept as-is;
# fix to America/Los_Angeles when convenient.
apiVersion: apps/v1
kind: Deployment
metadata:
name: searxng-1723974683
namespace: default
labels:
app.kubernetes.io/instance: searxng-1723974683
app.kubernetes.io/name: searxng
spec:
replicas: 1
revisionHistoryLimit: 3
selector:
matchLabels:
app.kubernetes.io/instance: searxng-1723974683
app.kubernetes.io/name: searxng
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 25%
maxUnavailable: 25%
template:
metadata:
labels:
app.kubernetes.io/instance: searxng-1723974683
app.kubernetes.io/name: searxng
spec:
containers:
- name: searxng-1723974683
image: searxng/searxng:latest
imagePullPolicy: Always
env:
- name: AUTOCOMPLETE
value: google
- name: BASE_URL
value: http://searxng.wittyoneoff.com/
- name: INSTANCE_NAME
value: witty-one-off-searxng
- name: TZ
value: America/Los Angeles
ports:
- name: http
containerPort: 8080
protocol: TCP
startupProbe:
httpGet:
path: /healthz
port: 8080
failureThreshold: 30
periodSeconds: 5
livenessProbe:
httpGet:
path: /healthz
port: 8080
failureThreshold: 3
periodSeconds: 10
readinessProbe:
httpGet:
path: /healthz
port: 8080
failureThreshold: 3
periodSeconds: 10
volumeMounts:
- name: searxng-config
mountPath: /etc/searxng/settings.yml
subPath: settings.yml
volumes:
- name: searxng-config
secret:
secretName: searxng-1723974683-config

33
apps/searxng/ingress.yaml Normal file
View File

@ -0,0 +1,33 @@
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: searxng-ingress
namespace: default
spec:
ingressClassName: traefik
defaultBackend:
service:
name: searxng-1723974683
port:
number: 8080
rules:
- host: searxng.wittyoneoff.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: searxng-1723974683
port:
number: 8080
- host: origin-searxng.wittyoneoff.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: searxng-1723974683
port:
number: 8080

View File

@ -0,0 +1,8 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
# secret.example.yaml is intentionally NOT listed — copy it to secret.yaml
# (gitignored) and apply manually once per cluster.
resources:
- deployment.yaml
- service.yaml
- ingress.yaml

View File

@ -0,0 +1,14 @@
# Copy to secret.yaml (gitignored), set a real secret_key, and apply once:
# kubectl apply -f secret.yaml
# The secret already exists in the cluster; this file only documents its shape.
apiVersion: v1
kind: Secret
metadata:
name: searxng-1723974683-config
namespace: default
type: Opaque
stringData:
settings.yml: |
server:
secret_key: CHANGE-ME
use_default_settings: true

18
apps/searxng/service.yaml Normal file
View File

@ -0,0 +1,18 @@
apiVersion: v1
kind: Service
metadata:
name: searxng-1723974683
namespace: default
labels:
app.kubernetes.io/instance: searxng-1723974683
app.kubernetes.io/name: searxng
spec:
type: ClusterIP
selector:
app.kubernetes.io/instance: searxng-1723974683
app.kubernetes.io/name: searxng
ports:
- name: http
port: 8080
targetPort: http
protocol: TCP

View File

@ -0,0 +1,69 @@
# DIVERGENCE from live state (intentional): the live deployment has
# USP_LICENSE_KEY as a plaintext env value. Here it comes from the
# `usp-license` secret — create it (see secret.example.yaml) BEFORE applying
# this. The Rancher-era selector label is immutable; do not change it.
apiVersion: apps/v1
kind: Deployment
metadata:
name: unifiedstreaming
namespace: unified-streaming
labels:
workload.user.cattle.io/workloadselector: apps.deployment-unified-streaming-unifiedstreaming
spec:
replicas: 3
selector:
matchLabels:
workload.user.cattle.io/workloadselector: apps.deployment-unified-streaming-unifiedstreaming
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 25%
maxUnavailable: 25%
template:
metadata:
labels:
app: unifiedstreaming
workload.user.cattle.io/workloadselector: apps.deployment-unified-streaming-unifiedstreaming
spec:
affinity:
nodeAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
nodeSelectorTerms:
- matchExpressions:
- key: cpu
operator: In
values:
- arm
- key: controller
operator: NotIn
values:
- "true"
containers:
- name: unifiedstreaming
image: unifiedstreaming/origin:latest
imagePullPolicy: Always
env:
- name: USP_LICENSE_KEY
valueFrom:
secretKeyRef:
name: usp-license
key: USP_LICENSE_KEY
livenessProbe:
httpGet:
path: /
port: 80
failureThreshold: 3
periodSeconds: 10
readinessProbe:
httpGet:
path: /
port: 80
failureThreshold: 3
periodSeconds: 10
volumeMounts:
- name: vol-apn2a
mountPath: /var/www/unified-origin
volumes:
- name: vol-apn2a
persistentVolumeClaim:
claimName: unified-streaming-pvc

View File

@ -0,0 +1,18 @@
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: unified-streaming-ing
namespace: unified-streaming
spec:
ingressClassName: traefik
rules:
- host: unified.wittyoneoff.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: unifiedstreaming-svc
port:
number: 80

View File

@ -0,0 +1,10 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
# secret.example.yaml is intentionally NOT listed — copy it to secret.yaml
# (gitignored) and apply manually once per cluster.
resources:
- namespace.yaml
- storage.yaml
- deployment.yaml
- service.yaml
- ingress.yaml

View File

@ -0,0 +1,4 @@
apiVersion: v1
kind: Namespace
metadata:
name: unified-streaming

View File

@ -0,0 +1,16 @@
# Copy to secret.yaml (gitignored), paste the real USP license key, apply once:
# kubectl apply -f secret.yaml
# Or create directly:
# kubectl create secret generic usp-license -n unified-streaming \
# --from-literal=USP_LICENSE_KEY='<base64-looking license blob>'
# The current key can be read from the live deployment:
# kubectl get deploy -n unified-streaming unifiedstreaming \
# -o jsonpath='{.spec.template.spec.containers[0].env[?(@.name=="USP_LICENSE_KEY")].value}'
apiVersion: v1
kind: Secret
metadata:
name: usp-license
namespace: unified-streaming
type: Opaque
stringData:
USP_LICENSE_KEY: CHANGE-ME

View File

@ -0,0 +1,18 @@
# ClusterIP on purpose: this was type LoadBalancer for ~2 years, but klipper-lb
# can never bind host port 80 (svclb-traefik owns it on every node), so the
# svclb pods sat Pending forever. Traffic flows through the ingress. Changed
# to ClusterIP 2026-07-26.
apiVersion: v1
kind: Service
metadata:
name: unifiedstreaming-svc
namespace: unified-streaming
spec:
type: ClusterIP
selector:
app: unifiedstreaming
ports:
- name: http
port: 80
targetPort: 80
protocol: TCP

View File

@ -0,0 +1,32 @@
# Origin content (ism/ismv etc.) on GlusterFS, served by all replicas.
apiVersion: v1
kind: PersistentVolume
metadata:
name: unified-streaming-pv
spec:
capacity:
storage: 100Gi
accessModes:
- ReadWriteOnce
- ReadOnlyMany
- ReadWriteMany
persistentVolumeReclaimPolicy: Retain
volumeMode: Filesystem
hostPath:
path: /mnt/gvolume0/unified-streaming
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: unified-streaming-pvc
namespace: unified-streaming
spec:
accessModes:
- ReadWriteOnce
- ReadOnlyMany
- ReadWriteMany
resources:
requests:
storage: 100Gi
storageClassName: ""
volumeName: unified-streaming-pv

View File

@ -0,0 +1,75 @@
# Originally installed via helm chart vaultwarden-5.1.0 (release record no longer
# in cluster); managed as a raw manifest since. Selector labels are immutable —
# do not change them.
apiVersion: apps/v1
kind: Deployment
metadata:
name: bitwarden-vaultwarden
namespace: bitwarden
labels:
app.kubernetes.io/instance: bitwarden
app.kubernetes.io/name: vaultwarden
spec:
replicas: 1
revisionHistoryLimit: 3
selector:
matchLabels:
app.kubernetes.io/instance: bitwarden
app.kubernetes.io/name: vaultwarden
strategy:
type: Recreate
template:
metadata:
labels:
app.kubernetes.io/instance: bitwarden
app.kubernetes.io/name: vaultwarden
spec:
affinity:
nodeAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
nodeSelectorTerms:
- matchExpressions:
- key: cpu
operator: In
values:
- arm
- key: controller
operator: NotIn
values:
- "true"
containers:
- name: bitwarden-vaultwarden
image: vaultwarden/server:1.35.4
imagePullPolicy: IfNotPresent
env:
- name: DATA_FOLDER
value: config
ports:
- name: http
containerPort: 80
protocol: TCP
- name: websocket
containerPort: 3012
protocol: TCP
startupProbe:
tcpSocket:
port: 80
failureThreshold: 30
periodSeconds: 5
livenessProbe:
tcpSocket:
port: 80
failureThreshold: 3
periodSeconds: 10
readinessProbe:
tcpSocket:
port: 80
failureThreshold: 3
periodSeconds: 10
volumeMounts:
- name: config
mountPath: /config
volumes:
- name: config
persistentVolumeClaim:
claimName: bitwarden-gvolume0

View File

@ -0,0 +1,23 @@
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: bitwarden
namespace: bitwarden
spec:
ingressClassName: traefik
defaultBackend:
service:
name: bitwarden-vaultwarden-svc
port:
number: 8080
rules:
- host: pm.wittyoneoff.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: bitwarden-vaultwarden-svc
port:
number: 8080

View File

@ -0,0 +1,8 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- namespace.yaml
- storage.yaml
- deployment.yaml
- service.yaml
- ingress.yaml

View File

@ -0,0 +1,4 @@
apiVersion: v1
kind: Namespace
metadata:
name: bitwarden

View File

@ -0,0 +1,19 @@
apiVersion: v1
kind: Service
metadata:
name: bitwarden-vaultwarden-svc
namespace: bitwarden
spec:
type: ClusterIP
selector:
app.kubernetes.io/instance: bitwarden
app.kubernetes.io/name: vaultwarden
ports:
- name: http
port: 8080
targetPort: 80
protocol: TCP
- name: websocket
port: 3012
targetPort: websocket
protocol: TCP

View File

@ -0,0 +1,30 @@
# GlusterFS-backed volume: /mnt/gvolume0 is the gluster mount present on every node.
apiVersion: v1
kind: PersistentVolume
metadata:
name: bitwarden-pv
spec:
capacity:
storage: 10Gi
accessModes:
- ReadWriteOnce
- ReadWriteMany
- ReadOnlyMany
persistentVolumeReclaimPolicy: Retain
volumeMode: Filesystem
hostPath:
path: /mnt/gvolume0/bitwarden
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: bitwarden-gvolume0
namespace: bitwarden
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 10Gi
storageClassName: ""
volumeName: bitwarden-pv

9
kustomization.yaml Normal file
View File

@ -0,0 +1,9 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- apps/vaultwarden
- apps/searxng
- apps/home-assistant
- apps/nginx
- apps/pihole
- apps/unified-streaming