Skip to content

ADR-0007: ESPHome configs — builder workspace (config/) vs. tracked mirror (devices/)

  • Status: Accepted
  • Date: 2026-07-20

Context

ESPHome device configs are edited in the ESPHome Device Builder (the esphome stack), which mounts stacks/esphome/config/ as its working directory. That directory can't be version-controlled as-is:

  • The builder makes it its own git repository (config/.git) — and recreates that repo from inside its container, which cannot see this repo's .git. So the nested repo keeps coming back, and git refuses to track files inside a nested repo: config/ simply can't be committed here.
  • It also holds a ~10 GB build cache (.esphome/), builder state (.device-builder*), and secrets.yaml with real credentials.

So stacks/esphome/config/ is gitignored, and the device YAMLs are mirrored into a tracked stacks/esphome/devices/. That mirror was maintained by hand and had drifted both ways — a live device (bathroom-full-speakers) missing from the builder, other files missing from the mirror.

Secrets were inconsistent too: WiFi used !secret, but several device YAMLs hard-coded API encryption keys, OTA passwords, and fallback-hotspot AP passwords inline, which then landed in the tracked mirror and git history.

Decision

  • Two directories, one source of truth. config/ is the builder's private workspace (gitignored); devices/ is a one-way export of just the device YAMLs and is the versioned copy.
  • Automated export. stacks/esphome/sync-devices.sh exports config/ → devices/ (rsync; excludes secrets/cache/state; deletion is opt-in via --mirror). A pre-commit hook (tools/hooks/esphome-sync.sh, wired into .githooks/pre-commit) runs it on every commit and stages devices/, so the two can't drift. It is additive and a no-op on clones without the builder.
  • No inline secrets. All API keys / OTA passwords / AP passwords are !secret, sourced from the gitignored config/secrets.yaml; a check that every !secret reference resolves guards against build breakage.

Consequences

  • Edit devices in the builder (config/); the tracked mirror updates itself at commit time. Never hand-edit devices/.
  • Retiring a device needs a deliberate sync-devices.sh --mirror (the hook never deletes).
  • Fresh clones / other hosts have no config/; the hook no-ops there and the committed devices/ is the reference.
  • Pre-existing inline-secret values were scrubbed from history (git-filter-repo --replace-text, dates preserved) alongside this ADR. Rotate the affected keys if that history was ever exposed.