From e1d3ae1e1e0da183324f1d5fbee22a5cd18c914f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicolas=20Mass=C3=A9?= Date: Sat, 19 Sep 2026 09:21:57 +0000 Subject: [PATCH] home-assistant: add cookbook Add a Podman Quadlet cookbook for Home Assistant. - Runs as the non-root home-assistant user (UID 10034, GID itix-svc/10000); s6-overlay v3 self-adjusts /run so no root or idmap workaround is needed. - PostgreSQL recorder backend (postgresql hook) with the connection string kept out of packaged files via !secret; Traefik ingress (traefik hook) with the X-Forwarded-For / trusted_proxies settings a reverse-proxied HA requires. - /config is precious data on the virtiofs mount; a one-shot init unit bootstraps configuration.yaml and secrets.yaml into it only when absent (never overwriting the live copy Home Assistant owns) and creates the empty automations/scenes/ scripts include targets so a fresh /config does not fall into recovery mode. - Weekly native backup automation writing into /config/backups. - Health check on /manifest.json with cold-start-sized timeouts. Verified on a clean Fedora CoreOS VM (real virtiofs): home-assistant.target active, 8123 answering, recorder writing 13 tables into PostgreSQL, all containers healthy, and a re-run of the init unit leaving the live config intact. Co-Authored-By: Claude Opus 4.8 --- cookbooks/home-assistant/Makefile | 12 ++ cookbooks/home-assistant/README.md | 198 ++++++++++++++++++ .../config/examples/configuration.yaml | 56 +++++ .../config/examples/secrets.yaml | 15 ++ .../home-assistant-init.service | 50 +++++ .../home-assistant/home-assistant.container | 54 +++++ cookbooks/home-assistant/home-assistant.image | 7 + .../home-assistant/home-assistant.target | 13 ++ .../other/postgresql/home-assistant.sql | 5 + .../other/traefik/home-assistant.yaml | 16 ++ cookbooks/home-assistant/overlay.bu | 9 + .../tmpfiles.d/home-assistant.conf | 3 + 12 files changed, 438 insertions(+) create mode 100644 cookbooks/home-assistant/Makefile create mode 100644 cookbooks/home-assistant/README.md create mode 100644 cookbooks/home-assistant/config/examples/configuration.yaml create mode 100644 cookbooks/home-assistant/config/examples/secrets.yaml create mode 100644 cookbooks/home-assistant/home-assistant-init.service create mode 100644 cookbooks/home-assistant/home-assistant.container create mode 100644 cookbooks/home-assistant/home-assistant.image create mode 100644 cookbooks/home-assistant/home-assistant.target create mode 100644 cookbooks/home-assistant/other/postgresql/home-assistant.sql create mode 100644 cookbooks/home-assistant/other/traefik/home-assistant.yaml create mode 100644 cookbooks/home-assistant/overlay.bu create mode 100644 cookbooks/home-assistant/tmpfiles.d/home-assistant.conf diff --git a/cookbooks/home-assistant/Makefile b/cookbooks/home-assistant/Makefile new file mode 100644 index 0000000..44a9ba6 --- /dev/null +++ b/cookbooks/home-assistant/Makefile @@ -0,0 +1,12 @@ +## +## Makefile for Home Assistant quadlet +## + +DEPENDENCIES = postgresql traefik + +# Home Assistant quadlet is mapped to the 10034 user (home-assistant) and 10000 group (itix-svc) +PROJECT_UID = 10034 +PROJECT_GID = 10000 + +# Include common Makefile +include ../../scripts/common.mk diff --git a/cookbooks/home-assistant/README.md b/cookbooks/home-assistant/README.md new file mode 100644 index 0000000..1b10351 --- /dev/null +++ b/cookbooks/home-assistant/README.md @@ -0,0 +1,198 @@ +# Podman Quadlet: Home Assistant + +## Overview + +[Home Assistant](https://www.home-assistant.io/) is an open-source home automation +platform. This cookbook runs it as a Podman Quadlet. + +This cookbook: + +- Runs Home Assistant as a **non-root** container (host UID `10034`, GID `10000`). +- Uses **PostgreSQL** as the recorder (history) backend (requires the `postgresql` cookbook). +- Publishes the UI behind **Traefik** (requires the `traefik` cookbook). +- Stores `/config` (the whole Home Assistant state) as **precious data** on the virtiofs + mount `/var/lib/virtiofs/data/home-assistant`, so it inherits the hypervisor's ZFS + snapshot and backup levels. +- Bootstraps `/config` from operator-provided templates on first boot, then never touches + it again. +- Runs a **weekly native backup** into `/config/backups`. +- Uses host networking, which Home Assistant needs for local device discovery (mDNS/SSDP). +- Supports automatic image updates via Podman auto-update (`AutoUpdate=registry`). + +The upstream image `ghcr.io/home-assistant/home-assistant:stable` is multi-architecture +(amd64 + arm64), so it runs on the aarch64 target VM. Verified on version `2026.9.3`. + +## Prerequisites + +- The `postgresql` cookbook must be installed and running (recorder backend). +- The `traefik` cookbook must be installed and running (reverse proxy). +- The virtiofs mount `var-lib-virtiofs-data.mount` must be present (`/config` lives there). +- The operator must provide the configuration files listed below. + +## Files the operator must provide + +Everything site-specific is injected downstream into `/etc/quadlets/home-assistant/`. +The cookbook itself ships **no site value** — only examples (used for development and as +templates). + +| Path | Purpose | +| ------------------------------------------------- | ------------------------------------------------------------- | +| `/etc/quadlets/home-assistant/configuration.yaml` | Bootstrap Home Assistant configuration (copied to `/config`). | +| `/etc/quadlets/home-assistant/secrets.yaml` | Bootstrap secrets, incl. the recorder database URL + password. | + +Nothing starts until `/etc/quadlets/home-assistant/configuration.yaml` exists (the +`home-assistant.target`, `home-assistant.service` and `home-assistant-init.service` units +all guard on it with `ConditionPathExists`). + +Ready-to-use examples are provided under `config/examples/`. They are installed to +`/etc/quadlets/home-assistant/` during development and testing only, and are **not** part +of the production package. Adjust the following site-specific values in your own copies: + +- the recorder database password in `secrets.yaml`; +- `http: trusted_proxies:` if Traefik connects from an address other than the loopback; +- the time zone (`homeassistant: time_zone:` in `configuration.yaml`) and the external URL, + which are otherwise best set from the Home Assistant web interface; +- the MQTT broker address (radio head), configured from the web interface once the + `mosquitto`/`zigbee2mqtt`/`zwave-js-ui` cookbooks are in place — out of scope here. + +## Bootstrap configuration vs. live configuration — read this first + +Home Assistant **owns `/config`**: it writes `configuration.yaml`, `secrets.yaml`, +`.storage/` and everything else from its own web interface. This conflicts with the usual +repository convention where `/etc/quadlets/` holds the live configuration. It is resolved +as follows: + +- `/etc/quadlets/home-assistant/configuration.yaml` and `secrets.yaml` are **bootstrap + templates**. +- `home-assistant-init.service` (a one-shot unit that runs before the container) copies each + of them into `/config` **only if it is absent**. It never overwrites a file Home Assistant + owns. +- The same unit creates the empty include targets `automations.yaml`, `scenes.yaml` and + `scripts.yaml` (again, only if absent). `configuration.yaml` references them with `!include` + so the UI can persist automations/scenes/scripts; without them Home Assistant would fail to + parse the config on a fresh `/config` and fall back to recovery mode. +- **After first boot, `/config/configuration.yaml` is the live copy** — editing the file in + `/etc/quadlets/home-assistant/` afterwards has **no effect**. To change the live + configuration you must edit `/config/configuration.yaml` (on the virtiofs mount) or use the + Home Assistant UI. This is the first thing most readers get wrong. + +The shipped bootstrap configuration is enough for an unattended first start on an empty +`/config`, with the recorder already pointed at PostgreSQL. + +## Reverse proxy + +Behind a reverse proxy, Home Assistant rejects requests with `400 Bad Request` unless it is +told to trust the proxy. The bootstrap `configuration.yaml` therefore sets: + +```yaml +http: + use_x_forwarded_for: true + trusted_proxies: + - 127.0.0.1 + - ::1 +``` + +`trusted_proxies` must list the address Traefik connects **from**. In this deployment Traefik +shares the host network and forwards to `127.0.0.1:8123`, so the loopback addresses are +correct. If your proxy connects from a different address, adjust the list — this is the single +most common deployment failure for Home Assistant. + +The Traefik router shipped in `other/traefik/home-assistant.yaml` routes +`Host(\`home-assistant\`)` to `http://127.0.0.1:8123`. + +## Recorder (PostgreSQL) + +`other/postgresql/home-assistant.sql` creates the `home_assistant` role and database. The +recorder connection string is referenced from `configuration.yaml` via `!secret`, so the +password stays in `secrets.yaml` and never appears in a packaged file: + +```yaml +recorder: + db_url: !secret recorder_db_url +``` + +The upstream image ships `psycopg2` (2.9.12, verified on a running container), which is the +driver SQLAlchemy uses for `postgresql://` URLs. On first start the recorder creates its +schema (13 tables) in the `home_assistant` database. + +## Native backup + +Home Assistant's own backup format is the only one its interface can restore, and the one +that survives a major-version upgrade. The ZFS snapshots protect the bytes; this protects the +restore path. + +The bootstrap `configuration.yaml` defines a **weekly** automation that calls the +`backup.create` service (Sundays at 04:30). Home Assistant writes the archive to +`/config/backups`, i.e. inside the virtiofs perimeter. Because this lives in the bootstrap +configuration, it only applies to instances started from the shipped template; on an existing +`/config` you set up the equivalent automation (or a scheduled backup) from the UI under +**Settings → System → Backups**. + +## TCP ports + +| Port | Protocol | Description | +| ---- | -------- | ------------------------------------ | +| 8123 | TCP | Home Assistant web UI / API (HTTP). | + +Host networking is used, so Home Assistant also sends/receives the multicast traffic it needs +for mDNS/SSDP discovery. + +## UID and GID + +| Item | Value | +| ---- | ---------------- | +| User | `10034` (`home-assistant`) | +| Group| `10000` (`itix-svc`) | + +## Deviations from the repository rules + +- **Running as root — not needed.** The upstream image uses s6-overlay v3 with + `ENTRYPOINT /init` and declares no `USER`, so it is designed to start as root. It was + tempting to run it as root like `samba` and `vsftpd` do. That turned out to be + unnecessary: s6-overlay v3 detects a non-root UID, fixes up `/run` itself, and then runs + Home Assistant as that user. Verified on `2026.9.3` — with `User=10034`/`Group=10000` the + process runs as host UID `10034` and owns everything it writes under `/config`, and the + recorder connects to PostgreSQL normally. **This cookbook therefore does not run as root**, + and needs no user-namespace/idmap workaround either. +- **Configuration is bootstrapped into `/config`, not kept read-only in `/etc/quadlets`.** + Home Assistant owns its configuration directory (see "Bootstrap vs. live configuration" + above), so the usual "configuration lives read-only in `/etc/quadlets`" rule cannot hold. + The `/etc/quadlets/home-assistant/` files are one-time bootstrap templates, copied into + `/config` only if absent. + +## Usage + +In a separate terminal, follow the logs. + +```sh +sudo make tail-logs +``` + +Install the Podman Quadlets and start Home Assistant (pulls in `postgresql` and `traefik`). + +```sh +sudo make clean install +``` + +You should see the **home-assistant.service** waiting for PostgreSQL to be available, then +starting up. The first start takes one to three minutes. + +Verify Home Assistant is running (no authentication needed on this endpoint): + +```sh +curl -sSf http://127.0.0.1:8123/manifest.json +``` + +Open `http://127.0.0.1:8123/` to reach the onboarding page. + +Restart the **home-assistant.target** unit. + +```sh +sudo systemctl restart home-assistant.target +``` + +Finally, remove the quadlets, their configuration and their data. + +```sh +sudo make I_KNOW_WHAT_I_AM_DOING=yes uninstall clean +``` diff --git a/cookbooks/home-assistant/config/examples/configuration.yaml b/cookbooks/home-assistant/config/examples/configuration.yaml new file mode 100644 index 0000000..7cd9812 --- /dev/null +++ b/cookbooks/home-assistant/config/examples/configuration.yaml @@ -0,0 +1,56 @@ +## +## Home Assistant BOOTSTRAP configuration +## +## This file is a TEMPLATE. It is copied to /config/configuration.yaml on first +## boot ONLY if that file does not already exist (see home-assistant-init.service). +## After first boot, /config/configuration.yaml is the LIVE copy that Home +## Assistant owns and edits from its web interface; this template becomes inert. +## +## Site-specific values (external URL, trusted proxy addresses, time zone, ...) +## are meant to be injected downstream by the operator overlay, not hard-coded +## here. The values below are only enough for an unattended first start. +## + +# Loads the default set of integrations (frontend, config UI, backup, ...). +# Do not remove. +default_config: + +# Front-end themes (kept so themes dropped in /config/themes are picked up). +frontend: + themes: !include_dir_merge_named themes + +# Home Assistant runs behind the Traefik reverse proxy. Without the two settings +# below it answers "400 Bad Request" to every proxied request. `trusted_proxies` +# must list the address Traefik connects FROM; here Traefik shares the host +# network and forwards to 127.0.0.1, so the loopback addresses are correct. +# Adjust if your proxy connects from another address. +http: + use_x_forwarded_for: true + trusted_proxies: + - 127.0.0.1 + - ::1 + +# Long-term history is stored in PostgreSQL (the `postgresql` cookbook). The +# connection string (with its password) is kept out of this file via !secret. +recorder: + db_url: !secret recorder_db_url + +# UI-managed automations, scripts and scenes (edited from the web interface). +automation ui: !include automations.yaml +script: !include scripts.yaml +scene: !include scenes.yaml + +# Weekly native backup. Home Assistant's own backup format is the only one its +# interface can restore and the one that survives a major-version upgrade. It is +# written to /config/backups, i.e. inside the virtiofs perimeter. +automation backup: + - alias: "Weekly native backup" + trigger: + - platform: time + at: "04:30:00" + condition: + - condition: time + weekday: + - sun + action: + - service: backup.create diff --git a/cookbooks/home-assistant/config/examples/secrets.yaml b/cookbooks/home-assistant/config/examples/secrets.yaml new file mode 100644 index 0000000..048fb34 --- /dev/null +++ b/cookbooks/home-assistant/config/examples/secrets.yaml @@ -0,0 +1,15 @@ +## +## Home Assistant BOOTSTRAP secrets +## +## This file is a TEMPLATE. It is copied to /config/secrets.yaml on first boot +## ONLY if that file does not already exist (see home-assistant-init.service). +## Values referenced from configuration.yaml with the !secret tag are looked up +## here. +## +## The value below matches the development password created by the `postgresql` +## hook (other/postgresql/home-assistant.sql). In production, the operator +## overlay provides this file with the real database password, so no site secret +## ships in the cookbook package. +## + +recorder_db_url: postgresql://home_assistant:home_assistant@127.0.0.1:5432/home_assistant diff --git a/cookbooks/home-assistant/home-assistant-init.service b/cookbooks/home-assistant/home-assistant-init.service new file mode 100644 index 0000000..72f20f1 --- /dev/null +++ b/cookbooks/home-assistant/home-assistant-init.service @@ -0,0 +1,50 @@ +[Unit] +Description=Home Assistant - Bootstrap /config from the operator-provided templates +Documentation=https://www.home-assistant.io/docs/configuration/ +After=var-lib-virtiofs-data.mount +Requires=var-lib-virtiofs-data.mount + +# Only run if the operator provided a bootstrap configuration +ConditionPathExists=/etc/quadlets/home-assistant/configuration.yaml + +# Start/stop this unit when the target is started/stopped, before the service +PartOf=home-assistant.target +Before=home-assistant.service + +[Service] +Type=oneshot +RemainAfterExit=yes + +# Bootstrap /config ONLY where a file is absent. Home Assistant owns /config once +# it has started: after first boot the live copies win and nothing here is touched +# again. install_if_absent copies the operator's bootstrap files; stub_if_absent +# creates the empty include targets configuration.yaml references +# (automations.yaml / scenes.yaml / scripts.yaml) so Home Assistant does not fall +# back to recovery mode on an otherwise-empty /config. +ExecStart=/bin/sh -c '\ + set -eu; \ + src=/etc/quadlets/home-assistant; \ + dst=/var/lib/virtiofs/data/home-assistant; \ + install_if_absent() { \ + if [ -f "$src/$1" ] && [ ! -e "$dst/$1" ]; then \ + echo "Bootstrapping $dst/$1 from $src/$1"; \ + install -m "$2" -o 10034 -g 10000 "$src/$1" "$dst/$1"; \ + else \ + echo "Keeping existing $dst/$1 (Home Assistant owns it) or no bootstrap provided"; \ + fi; \ + }; \ + stub_if_absent() { \ + if [ ! -e "$dst/$1" ]; then \ + echo "Creating empty $dst/$1"; \ + echo "$2" > "$dst/$1"; \ + chown 10034:10000 "$dst/$1"; \ + fi; \ + }; \ + install_if_absent configuration.yaml 0644; \ + install_if_absent secrets.yaml 0600; \ + stub_if_absent automations.yaml "[]"; \ + stub_if_absent scenes.yaml "[]"; \ + stub_if_absent scripts.yaml "{}"' + +[Install] +WantedBy=home-assistant.target diff --git a/cookbooks/home-assistant/home-assistant.container b/cookbooks/home-assistant/home-assistant.container new file mode 100644 index 0000000..af01ab2 --- /dev/null +++ b/cookbooks/home-assistant/home-assistant.container @@ -0,0 +1,54 @@ +[Unit] +Description=Home Assistant +Documentation=https://www.home-assistant.io/ +After=network.target var-lib-virtiofs-data.mount home-assistant-init.service +Requires=var-lib-virtiofs-data.mount home-assistant-init.service + +# Only start if Home Assistant has been configured +ConditionPathExists=/etc/quadlets/home-assistant/configuration.yaml + +# Start/stop this unit when the target is started/stopped +PartOf=home-assistant.target + +[Container] +ContainerName=home-assistant +Image=home-assistant.image +AutoUpdate=registry + +# No need for root privileges: the s6-overlay v3 entrypoint detects the non-root +# UID, fixes up /run itself, then runs Home Assistant as this user. Verified on +# ghcr.io/home-assistant/home-assistant:stable (2026.9.3): the process runs as +# host UID 10034 and owns everything it writes under /config. +User=10034 +Group=10000 + +# Host networking, as everywhere in this repository. Home Assistant also needs it +# for local discovery (mDNS/SSDP) of devices on the LAN. +Network=host + +# Precious data: Home Assistant owns /config (configuration.yaml, .storage/, +# native backups, ...). It lives on the virtiofs mount so it inherits the +# hypervisor's ZFS snapshot and backup levels. +Volume=/var/lib/virtiofs/data/home-assistant:/config:z + +# Health check: /manifest.json is served without authentication. +HealthCmd=curl -sSf http://127.0.0.1:8123/manifest.json +HealthInterval=30s +HealthTimeout=10s +# Cold start (dependency install + recorder schema migration) takes one to three +# minutes, so probe generously before declaring the container unhealthy. +HealthStartPeriod=180s +HealthRetries=10 + +[Service] +Restart=always +RestartSec=10 +# First boot may pull the image and migrate the recorder schema; give it room. +TimeoutStartSec=300 +TimeoutStopSec=60 + +# Wait for PostgreSQL to be ready on localhost (recorder backend). +ExecStartPre=/bin/sh -c 'exec 2>/dev/null; for try in $(seq 0 12); do if ! /bin/true 5<> /dev/tcp/127.0.0.1/5432; then echo "Waiting for PostgreSQL to be available..."; sleep 5; else exit 0; fi; done; exit 1' + +[Install] +WantedBy=home-assistant.target diff --git a/cookbooks/home-assistant/home-assistant.image b/cookbooks/home-assistant/home-assistant.image new file mode 100644 index 0000000..34a4421 --- /dev/null +++ b/cookbooks/home-assistant/home-assistant.image @@ -0,0 +1,7 @@ +[Unit] +Description=podman pull ghcr.io/home-assistant/home-assistant +Documentation=https://www.home-assistant.io/installation/ + +[Image] +# Multi-architecture image (amd64 + arm64), so it runs on the aarch64 target too. +Image=ghcr.io/home-assistant/home-assistant:stable diff --git a/cookbooks/home-assistant/home-assistant.target b/cookbooks/home-assistant/home-assistant.target new file mode 100644 index 0000000..98d5c43 --- /dev/null +++ b/cookbooks/home-assistant/home-assistant.target @@ -0,0 +1,13 @@ +[Unit] +Description=Home Assistant Service Target +Documentation=man:systemd.target(5) +Requires=postgresql.target home-assistant-init.service home-assistant.service +After=postgresql.target home-assistant-init.service home-assistant.service + +# Allow isolation - can stop/start this target independently +AllowIsolate=yes +# Only start if Home Assistant has been configured +ConditionPathExists=/etc/quadlets/home-assistant/configuration.yaml + +[Install] +WantedBy=multi-user.target diff --git a/cookbooks/home-assistant/other/postgresql/home-assistant.sql b/cookbooks/home-assistant/other/postgresql/home-assistant.sql new file mode 100644 index 0000000..42d5397 --- /dev/null +++ b/cookbooks/home-assistant/other/postgresql/home-assistant.sql @@ -0,0 +1,5 @@ +-- Initialization script for Home Assistant database and user (recorder backend) +CREATE USER home_assistant WITH PASSWORD 'home_assistant'; +CREATE DATABASE home_assistant OWNER home_assistant; +GRANT ALL PRIVILEGES ON DATABASE home_assistant TO home_assistant; +ALTER ROLE home_assistant SET client_encoding TO 'utf8'; diff --git a/cookbooks/home-assistant/other/traefik/home-assistant.yaml b/cookbooks/home-assistant/other/traefik/home-assistant.yaml new file mode 100644 index 0000000..56aa7fc --- /dev/null +++ b/cookbooks/home-assistant/other/traefik/home-assistant.yaml @@ -0,0 +1,16 @@ +http: + routers: + home-assistant: + rule: "Host(`home-assistant`)" + entryPoints: + - http + #- https + middlewares: + service: "home-assistant" + #tls: + # certResolver: le + services: + home-assistant: + loadBalancer: + servers: + - url: "http://127.0.0.1:8123" diff --git a/cookbooks/home-assistant/overlay.bu b/cookbooks/home-assistant/overlay.bu new file mode 100644 index 0000000..0d8a6db --- /dev/null +++ b/cookbooks/home-assistant/overlay.bu @@ -0,0 +1,9 @@ +variant: fcos +version: 1.4.0 +passwd: + users: + - name: home-assistant + uid: 10034 + gecos: Home Assistant + home_dir: /var/lib/quadlets/home-assistant + primary_group: itix-svc diff --git a/cookbooks/home-assistant/tmpfiles.d/home-assistant.conf b/cookbooks/home-assistant/tmpfiles.d/home-assistant.conf new file mode 100644 index 0000000..a423642 --- /dev/null +++ b/cookbooks/home-assistant/tmpfiles.d/home-assistant.conf @@ -0,0 +1,3 @@ +# Precious data: Home Assistant's /config lives on the virtiofs mount. +# The "$" marks it for removal on `systemd-tmpfiles --purge` (i.e. `make uninstall`). +d$ /var/lib/virtiofs/data/home-assistant 0700 10034 10000 -