Browse Source

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 <noreply@anthropic.com>
feat/home-assistant-cookbook
Nicolas Massé 2 days ago
parent
commit
e1d3ae1e1e
  1. 12
      cookbooks/home-assistant/Makefile
  2. 198
      cookbooks/home-assistant/README.md
  3. 56
      cookbooks/home-assistant/config/examples/configuration.yaml
  4. 15
      cookbooks/home-assistant/config/examples/secrets.yaml
  5. 50
      cookbooks/home-assistant/home-assistant-init.service
  6. 54
      cookbooks/home-assistant/home-assistant.container
  7. 7
      cookbooks/home-assistant/home-assistant.image
  8. 13
      cookbooks/home-assistant/home-assistant.target
  9. 5
      cookbooks/home-assistant/other/postgresql/home-assistant.sql
  10. 16
      cookbooks/home-assistant/other/traefik/home-assistant.yaml
  11. 9
      cookbooks/home-assistant/overlay.bu
  12. 3
      cookbooks/home-assistant/tmpfiles.d/home-assistant.conf

12
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

198
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
```

56
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

15
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

50
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

54
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

7
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

13
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

5
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';

16
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"

9
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

3
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 -
Loading…
Cancel
Save