---
title: "Config history for a live Home Assistant"
description: "Home Assistant's live config now writes its own history every hour, and the first version reported success while pushing nothing."
url: "https://michael.zazon.us/notes/config-history-for-a-live-home-assistant"
published: "2026-08-24T12:00:00-04:00"
kind: "build-log"
subject: "home-automation"
tags: ["home-assistant"]
---
HA is already backed up (ZFS snapshots + Backrest, so I can restore everything after a dead disk or a bad upgrade), but backups don't tell you what changed and when. This gap has been on my list since July, but it became unacceptable once I had leak automation that could close the main water valve. If the automation had behaved differently, I could restore a point in time, replacing the older version, but never see the edits I made.

HA got its own git repo on [self-hosted Forgejo](https://michael.zazon.us/notes/self-hosted-git-with-forgejo-and-why-some-repos-never-touch-github). I split it from the home-infra repo. The compose file that runs HA will remain in home-infra, because the service is hosted there, but the project itself lives in its own repo. Moving the compose would mean a new credential and a stack repoint, with no benefit.

The key decision is the direction. Live is the source of truth, so I only sync the live config to the repo, never the other way around. HA rewrites its own YAML through the config API when I make changes in the UI, so the repo is a record, not a deployment. Nothing ever gets applied. I always track an explicit list of the handful of YAML files from the [configuration directory](https://www.home-assistant.io/docs/configuration/).. never a glob. A glob risks accidentally committing a file containing secrets.

An hourly systemd timer runs the sync and pushes a commit if there are changes. I chose auto-commit over an alert waiting for action because a history that depends on someone remembering to run a script isn't a history. Before copying anything, the script aborts if it finds a literal token or inline password value, but `!secret` references are allowed. I never track the secrets file or the entire `.storage/` directory, so ZFS + Backrest remain the only recovery path.

[Diagram: Hourly config sync from Home Assistant to git with a staleness alert](https://michael.zazon.us/notes/config-history-for-a-live-home-assistant)

## Clean run, zero commits

The first version of the sync reported a clean run but didn't commit anything. The problem was a single line where bash's negation didn't cover as much as I thought: I read `!` as "if adding and committing fails", but bash [applies `!` to the single pipeline next to it](https://www.gnu.org/software/bash/manual/html_node/Lists.html), not the entire list:

```bash
# as written (commit never runs after a successful add):
if ! git add config && git commit ...
# parses as:  (! git add config) && (git commit ...)
# fix: group with braces so the negation covers the whole add+commit
```

So after a successful add, the negated half was false, and the `&&` short-circuited before it got to `git commit`. Grouping with braces fixed that. I only caught this because I was checking `git log` had moved instead of the script's exit status (which was zero the whole time).

> [!note] Permanent drift from two bytes
> HA writes `scenes.yaml` as the two bytes `[]` with no final newline, which the repo's `yamllint -d relaxed` pre-commit hook rejects. Fixing only the stored copy would have made the comparison report drift forever, so the sync normalises newlines on both sides before comparing.

## When the timer goes quiet

The wrapper pages on sync failure, but a stopped timer never fails, it just goes quiet, and a stale mirror is worse than no mirror at all because it looks authoritative. So each run writes a last-run timestamp as a node-exporter textfile metric, which lands in [VictoriaMetrics](https://michael.zazon.us/notes/victoriametrics-as-the-first-metrics-database-on-my-home-server). A [file-provisioned Grafana rule](https://grafana.com/docs/grafana/latest/alerting/set-up/provision-alerting-resources/file-provisioning/) alerts after three hours (three missed hourly runs):

```yaml
  # 3h = three consecutive missed hourly runs, allowing for the timer's
  # RandomizedDelaySec=5m and a reboot, so a single skipped run is not a page.
  - orgId: 1
    name: home-assistant-config-mirror
    folder: Home Assistant
    interval: 30m
    rules:
      - uid: ha-config-sync-stale
        title: HA config mirror has not synced in 3h
        condition: A
        data:
          - refId: A
            model:
              expr: '(time() - ha_config_sync_last_run_timestamp_seconds > bool 10800) or absent(ha_config_sync_last_run_timestamp_seconds)'
              instant: true
        noDataState: OK
```

I tested the rule by forcing it to fire, not by watching it stay green. The results: a healthy mirror got me 0, a one-second threshold got me 1, and a bogus metric name got me 1 (via the `absent()` half). That last one is why I can safely set `noDataState: OK`.. a vanished metric still pages, but it doesn't count as no data.

> [!tip] Force the alert to fire
> A healthy mirror gave me 0, a one-second threshold gave me 1, and a bogus metric name gave me 1 (via the `absent()` half). That `absent()` is what makes `noDataState: OK` safe.

## The dashboards with no history

That same afternoon I spent a long time cleaning up entities and dashboards, and doing some registry work like [Naming things in Home Assistant](https://michael.zazon.us/notes/naming-things-in-home-assistant). When I was done, the mirror had made zero commits, and the drift check had been reporting "no drift" the whole time (because the dashboards I'd created in the UI are [storage-mode dashboards](https://www.home-assistant.io/dashboards/) and live in the untracked `.storage/`). The four with no history were the floorplan, kid's room, map, and water heater dashboards.

It turns out that quickly fixing this by loosening the `.gitignore` was the wrong approach: `.storage` contains plaintext credentials (the auth store with long-lived tokens, and every integration's config entry). Instead, I'm syncing exports of each dashboard (just the view and card JSON) from the Lovelace API into `config/dashboards/`. The export lifts the config out of its wrapper and drops the version metadata, so an unrelated HA schema migration isn't seen as a dashboard edit. It sorts object keys but keeps list order because the view/card order is the content. At the end of the week, I had six dashboards with history, and the drift check caught the one I'd deliberately edited as a test.

[Image: Forgejo diff of one exported dashboard JSON: the CO2 gauge minimum changed from 400 to 300](https://michael.zazon.us/notes/config-history-for-a-live-home-assistant)

## Running unattended

The timer ran hourly overnight unattended, zero drift. Every HA session I've had since then ended with the repo clean and pushed. It changed how I build things.. new sensors go into tracked YAML instead of UI helpers so they show up in the mirror.

> [!warning] Reduced, not closed
> Entity renames, areas, labels and config entries still live only in `.storage`, so they have no diff history. ZFS and Backrest can restore them, but they still can't say what changed.

The other weak spot is commit messages. Auto-generated messages tell you what, not why. For changes that need explaining, I run the sync manually and then `git commit --amend` to add a real message. So the history of something like the leak automation can say why as well as what.