Files
homelab/kubernetes/apps/home-assistant/deployment.yaml
T
security-bot 1a1205110d
ci/woodpecker/cron/issue-fix Pipeline was successful
ci/woodpecker/cron/alert-monitor Pipeline was successful
chore(image-automation): bump ghcr.io/home-assistant/home-assistant:2026.9.3
Updates managed by image-automation-controller.
2026-09-18 12:53:31 +00:00

313 lines
12 KiB
YAML

apiVersion: apps/v1
kind: Deployment
metadata:
name: home-assistant
namespace: home-assistant
labels:
app.kubernetes.io/name: home-assistant
annotations:
# Reloader restarts the whole HA pod when config-sync-token rotates. The
# config-sync sidecar reads HA_TOKEN as a startup env var, so without this
# a quarterly rotation would leave it using the old (revoked) token until
# the pod next restarts for some other reason. The cost is a brief HA
# restart each quarter (04:05 Europe/Berlin, off-hours) — accepted so the
# full-admin config-sync LLAT is lifetime-bounded like the others. See
# kubernetes/infrastructure/openbao/rotation/home-assistant.yaml.
secret.reloader.stakater.com/reload: home-assistant-config-sync-token
spec:
replicas: 1
strategy:
type: Recreate
selector:
matchLabels:
app.kubernetes.io/name: home-assistant
template:
metadata:
labels:
app.kubernetes.io/name: home-assistant
spec:
enableServiceLinks: false
# Explicit `default` overwrites the previously-claimed
# `home-assistant-trusted-proxies` SA ref left behind by SSA
# when that field was dropped from the manifest. Safe to remove
# in a follow-up once SSA owns it with this value.
serviceAccountName: default
# No nodeSelector/toleration: HA reads the power meter over MQTT now
# (sml2mqtt bridge owns the USB device on edge), so it no longer needs
# the edge USB device and schedules on the general cluster pool. This
# gets the heavy HA pod (8Gi) off the soon-to-be-tiny edge box.
initContainers:
# Puts CNPG's connection URI into /config/secrets.yaml as
# `recorder_db_url`, for the recorder's move off SQLite
# (MIGRATION-recorder-postgres.md).
#
# HA has no way to read a db_url from the environment — `!env_var` was
# removed from its YAML loader — so `!secret` is the only mechanism, and
# secrets.yaml is PVC state that config-sync deliberately does not
# manage. Hence a small init container rather than a env: entry.
#
# The URI comes STRAIGHT from the ha-pg-app Secret. CNPG builds and
# percent-encodes it, so there is nothing to escape here: the value is
# restricted to [A-Za-z0-9:/@._%-], verified against the live secret.
# An earlier version of this assembled the URI from user/password/dbname
# and needed percent-encoding plus YAML round-trip validation to be
# safe. That was solving a problem CNPG had already solved.
#
# Re-rendered on every pod start, so a CNPG password rotation takes
# effect rather than silently leaving a stale URI behind.
#
# Load-bearing: `db_url: !secret recorder_db_url` in the homeassistant
# repo's configuration.yaml reads this key, so HA does not start
# without it. Removing this init container takes the recorder down.
- name: render-db-url
image: busybox:1.36
command:
- sh
- -c
- |
set -eu
S=/config/secrets.yaml
T=/config/.secrets.render.tmp
[ -f "$S" ] || : > "$S"
# Filter-then-append, never rewrite in place: this file holds
# every other HA secret (influxdb_token and friends).
grep -v '^recorder_db_url:' "$S" > "$T" || true
printf 'recorder_db_url: "%s"\n' "$DB_URI" >> "$T"
# Refuse to shrink the file. Cheap guard against the one failure
# that would actually hurt: losing the other keys.
[ "$(wc -l < "$T")" -ge "$(wc -l < "$S")" ] || {
echo "FATAL: rewrite would shrink secrets.yaml; aborting" >&2; exit 1; }
chmod 600 "$T"
mv "$T" "$S"
echo "recorder_db_url rendered"
env:
- name: DB_URI
valueFrom:
secretKeyRef: {name: ha-pg-app, key: fqdn-uri}
volumeMounts:
- name: config
mountPath: /config
resources:
requests:
cpu: 10m
memory: 16Mi
limits:
memory: 32Mi
containers:
- name: home-assistant
image: ghcr.io/home-assistant/home-assistant:2026.9.3 # {"$imagepolicy": "flux-system:home-assistant"}
env:
- name: TZ
value: Europe/Berlin
ports:
- name: http
containerPort: 8123
protocol: TCP
volumeMounts:
- name: config
mountPath: /config
# Initial restore of a 1 GB encrypted HAOS backup runs single-
# threaded inside HA's Python and exceeded the old liveness
# window, getting SIGTERM'd mid-restore. Startup probe gives a
# 10 min ceiling; liveness/readiness only kick in afterwards.
startupProbe:
httpGet:
path: /
port: http
initialDelaySeconds: 30
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 60
livenessProbe:
httpGet:
path: /
port: http
periodSeconds: 30
timeoutSeconds: 10
readinessProbe:
httpGet:
path: /
port: http
periodSeconds: 10
timeoutSeconds: 5
# ~10k entities. The legacy HAOS VM ran this workload with 16Gi;
# the 4Gi limit OOM-looped (working set ~3.9Gi, tipped over on
# morning load spikes). 8Gi held on the edge VM but was OOMKilled
# again on 2026-07-09 now that the pod schedules on the general
# pool (~47Gi nodes), so it is back to the HAOS-era 16Gi.
resources:
requests:
cpu: 500m
memory: 2Gi
limits:
memory: 16Gi
# No securityContext/privileged: the USB meter is gone (read via
# MQTT now), which was the only reason HA needed privileged.
# Reverse-proxy sidecar. ingress-nginx → :8124 → 127.0.0.1:8123.
# HA's TCP peer is always loopback, so http.trusted_proxies =
# [127.0.0.1, ::1] is static and never has to change when
# ingress-nginx pods churn.
#
# The XFF trust hinges on nothing else being able to present
# X-Forwarded-For to nginx. The CNP gates :8124 to traefik only;
# nginx additionally binds to ${POD_IP} (not 0.0.0.0) as defense
# in depth against any future pod-interface attachments.
#
# Rendering: the nginx-unprivileged image's entrypoint runs
# envsubst over /etc/nginx/templates/*.template at start, writing
# to /etc/nginx/conf.d/. NGINX_ENVSUBST_FILTER restricts the
# substitution to POD_IP so nginx-runtime variables like $host
# / $http_x_forwarded_for in the template stay intact.
- name: proxy
image: nginxinc/nginx-unprivileged:1.27-alpine
env:
- name: POD_IP
valueFrom:
fieldRef:
fieldPath: status.podIP
- name: NGINX_ENVSUBST_FILTER
value: "^POD_IP$"
ports:
- name: proxy-http
containerPort: 8124
protocol: TCP
volumeMounts:
- name: proxy-config
mountPath: /etc/nginx/templates
readOnly: true
- name: proxy-conf-d
mountPath: /etc/nginx/conf.d
readinessProbe:
tcpSocket:
port: 8124
periodSeconds: 10
resources:
requests:
cpu: 10m
memory: 32Mi
limits:
memory: 64Mi
# Browser-based VS Code editing /config in-place. Sidecar (not
# separate Deployment) because the config PVC is RWO ceph-block.
# Exposed LAN-only on vscode.home.renner.uno; --auth=none means
# any LAN client can edit, matching the user's trust-the-network
# model. Runs as root so its writes match HA's root-owned files.
- name: code-server
image: codercom/code-server:4.118.0
args:
- "--auth=none"
- "--bind-addr=0.0.0.0:8080"
- "--disable-telemetry"
- "--disable-update-check"
- "/config"
env:
- name: HOME
value: /var/lib/code-server
- name: TZ
value: Europe/Berlin
ports:
- name: vscode
containerPort: 8080
protocol: TCP
# /healthz answers 200 whenever the HTTP server is up; its body
# reports session heartbeat state, which is not health — an idle
# editor reports "expired" and is perfectly fine. So this asserts
# that code-server is serving, not that anyone is using it.
#
# Liveness matters here more than elsewhere in this pod: code-server
# shares the Home Assistant config volume, and a wedged editor is
# otherwise invisible until someone tries to open it.
livenessProbe:
httpGet:
path: /healthz
port: vscode
initialDelaySeconds: 30
periodSeconds: 30
timeoutSeconds: 5
readinessProbe:
httpGet:
path: /healthz
port: vscode
initialDelaySeconds: 10
periodSeconds: 10
timeoutSeconds: 5
volumeMounts:
- name: config
mountPath: /config
- name: vscode-state
mountPath: /var/lib/code-server
resources:
requests:
cpu: 50m
memory: 256Mi
limits:
memory: 1Gi
securityContext:
runAsUser: 0
runAsGroup: 0
# Git config-sync sidecar. /config is a git checkout on the PVC; this
# force-syncs only the HA YAML config paths from origin/main of the
# gitea repo into the working tree, then calls homeassistant.reload_all.
# Leaves www/ (HACS-managed) and untracked runtime state (.storage,
# *.db, secrets.yaml) untouched. git auth uses the PAT already embedded
# in the PVC's remote URL; reload auth uses a dedicated long-lived token.
# Runs as root to match HA's root-owned /config files. See config-sync.sh.
- name: config-sync
image: alpine/git:2.54.0
command: ["/bin/sh", "/scripts/sync.sh"]
env:
- name: SYNC_BRANCH
value: main
- name: SYNC_INTERVAL
value: "30"
- name: HA_URL
value: http://127.0.0.1:8123
- name: HA_TOKEN
valueFrom:
secretKeyRef:
name: home-assistant-config-sync-token
key: token
volumeMounts:
- name: config
mountPath: /config
- name: config-sync-script
mountPath: /scripts
readOnly: true
# ~1.1 GB .git (a 1 GB backup tar in history); auto-gc is disabled in
# the script so it never repacks. fetch/checkout of small config blobs
# peak well under this; the headroom covers index-pack on first fetch.
resources:
requests:
cpu: 10m
memory: 32Mi
limits:
memory: 256Mi
securityContext:
runAsUser: 0
runAsGroup: 0
volumes:
- name: config
persistentVolumeClaim:
claimName: home-assistant-config
# Mounts config-sync.sh (via configMapGenerator) read-only at /scripts.
- name: config-sync-script
configMap:
name: ha-config-sync-script
defaultMode: 0755
- name: proxy-config
configMap:
name: ha-proxy-config
# Writable emptyDir for the nginx entrypoint's envsubst output
# (/etc/nginx/conf.d/default.conf rendered from the .template).
- name: proxy-conf-d
emptyDir:
medium: Memory
sizeLimit: 16Mi
# code-server's settings/extensions cache. Not on the config PVC
# so installed extensions don't pollute HA's /config listing.
# Trade-off: extensions reset across pod restarts.
- name: vscode-state
emptyDir:
medium: Memory
sizeLimit: 16Mi