Browse Source
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
12 changed files with 438 additions and 0 deletions
@ -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 |
|||
@ -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 |
|||
``` |
|||
@ -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 |
|||
@ -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 |
|||
@ -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 |
|||
@ -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 |
|||
@ -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 |
|||
@ -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 |
|||
@ -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'; |
|||
@ -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" |
|||
@ -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 |
|||
@ -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…
Reference in new issue