Deploying config-as-code to live Home Assistant (ha_deploy.py)
ha_deploy.py is the deploy half of the Home Assistant config-as-code loop. ha_export.py pulls live HA config out into per-item files for review and diffing; ha_deploy.py is its inverse — it pushes the repo's per-item files back into the running HA instance.
Guiding principle: the repo is canon. Whatever is committed here is what gets written to /config. The script never reads live state back; it only writes.
This is the deploy step envisaged for the issue #38 self-hosted-runner pipeline. Until that runner exists, run the script by hand from a machine with cluster access (it acts as the runner).
What it does
For each target you name, the script:
- Reassembles the per-item repo files into the monolithic form HA loads (the exact inverse of how
ha_export.pysplit them). - Validates that the reassembled YAML parses (and
--dry-runstops here). - Backs up the current copy on the HA PVC to a timestamped
.bak.<ts>in the pod, pruning to the most recentHA_BACKUPS(default 5). - Writes the new copy into
/configin the running pod viakubectl cp. - Applies the change — either a no-downtime reload over the HA REST API, or a restart (
kubectl rollout restart), depending on the target class.
Reassembly map
| Target | Repo source | Reassembled into |
|---|---|---|
automations |
automations/*.yaml (one dict each) |
automations.yaml (list) |
scenes |
scenes/*.yaml (one dict each) |
scenes.yaml (list) |
scripts |
scripts/*.yaml (bare or {id: cfg}) |
scripts.yaml (map) |
templates |
templates/*.yaml (verbatim) |
templates/ (per-file, stale pruned) |
packages |
packages/*.yaml (verbatim) |
packages/ (per-file, stale pruned) |
dashboards |
dashboards/{overview,rooms,lights}.yaml (verbatim) |
ui-lovelace.yaml + dashboards/*.yaml (mapped, no prune) |
Repo is canon, including deletions. The file targets (
automations.yamletc.) overwrite the whole file, so removing an item from the repo removes it live. For the dir targets (templates,packages) the same holds: a file deleted from the repo is pruned from/config/<dest>/on the next deploy (backed up first). To guard against an accidentally-emptied source dir wiping live config, a target with zero source files is refused unless you setHA_ALLOW_EMPTY=1to confirm you mean to clear it.Scripts caveat. A script file may be keyed (
{script_id: cfg}) or a bare config (alias:/sequence:at the top level). For a bare file the script id is taken from the filename stem, so a bare file must be named after its live script id — otherwise it deploys under the wrong id. When in doubt, key the file explicitly.
Deploy classes — reload vs restart
| Class | Targets | How it applies | Downtime |
|---|---|---|---|
| reload-class | automations, scenes, scripts, templates |
HA REST API …/reload service call |
none |
| restart-class | packages |
kubectl rollout restart (scale-0→1 fallback) |
~1–2 min |
| copy-only | dashboards |
file copy only — YAML-mode dashboards are re-read from disk on the next page load | none |
packages need a restart because they can introduce new helpers, light groups, and platforms that HA only wires up on a full start. If several targets are deployed together and any is restart-class (or HA_FORCE_RESTART is set), a single restart is performed and it covers everything — no redundant reloads. dashboards need neither a reload nor a restart (even under HA_FORCE_RESTART) — the copy is the whole deploy; just reload the page in the browser.
The
configuration.yamlConfigMap is not handled here. It is deployed separately (kubectl applyof the ConfigMap) by the workflow.dashboardsdeploys the file content of already-declared YAML-mode dashboards; adding or removing a dashboard is a ConfigMap change (lovelace:section) plus a restart, and a matching edit toDASHBOARD_MAPinha_deploy.py.
Prerequisites
kubectlonPATH, withKUBECONFIGpointing at a kubeconfig scoped to thehome-assistantnamespace (in this homelab the kubeconfig lives as a Proton Pass attachment — see the team secrets store).python3withPyYAML.- For reload-class targets:
HA_URLand a long-livedHA_TOKEN(see Environment below). Without a token, setHA_FORCE_RESTART=1to apply reload-class changes via a restart instead.
Usage
# Validate only — reassemble + YAML-parse check, touch nothing live.
DRY_RUN=1 python3 ha_deploy.py all
# Deploy the lighting package (restart-class -> one rollout restart).
python3 ha_deploy.py packages
# Deploy automations with a no-downtime reload (needs HA_URL + HA_TOKEN).
export HA_URL=http://192.168.1.22:8123
export HA_TOKEN=… # long-lived token; do NOT commit this
python3 ha_deploy.py automations
# Deploy everything. Reload-class targets reload; packages trigger one restart
# that covers the whole set.
python3 ha_deploy.py all
# Apply a reload-class target when you have no HA_TOKEN, via a restart.
HA_FORCE_RESTART=1 python3 ha_deploy.py automations
# Deploy the Lovelace dashboards (copy-only — no reload, no restart, no token).
python3 ha_deploy.py dashboards
Targets: automations scenes scripts templates packages dashboards — or all.
Environment
| Variable | Default | Purpose |
|---|---|---|
KUBECONFIG |
— | kubeconfig scoped to the home-assistant namespace |
HA_NAMESPACE |
home-assistant |
Kubernetes namespace |
HA_DEPLOY |
home-assistant |
Deployment name (for rollout restart) |
HA_CONTAINER |
home-assistant |
Container name within the pod |
HA_URL |
— | HA base URL, e.g. http://192.168.1.22:8123 (reload only) |
HA_TOKEN |
— | Long-lived access token (reload only) |
HA_BACKUPS |
5 |
Timestamped backups kept per file in the pod |
HA_FORCE_RESTART |
unset | If set, restart instead of API reload (e.g. no token) |
HA_ALLOW_EMPTY |
unset | If set, allow a target with no source files (clears it) |
DRY_RUN |
unset | Reassemble + validate only; nothing live is touched |
HA_TOKEN is a secret — pass it via the environment at run time. Never commit it or write it into a file in the repo.
Automated deploy via the self-hosted runner (#38)
The gh-runner-01 VM runs this deploy as a GitHub Actions job — the workflow .github/workflows/deploy-ha.yml, on the [self-hosted, homelab, deploy] labels.
One-time on the runner — install the deploy toolchain (the github-runner user has no sudo, so the workflow can't install it itself):
ssh debian@192.168.1.6 'sudo bash -s' < install-deploy-tools.sh
#!/usr/bin/env bash
# Install the Home Assistant deploy toolchain on the runner host: python3 +
# PyYAML (always) and kubectl (for real, non-dry-run deploys). Run once as root
# on the runner VM. Needed because the github-runner user has no sudo, so the
# deploy workflow can't install these itself.
set -euo pipefail
if [ "$(id -u)" -ne 0 ]; then
echo "Run as root (sudo)." >&2
exit 1
fi
export DEBIAN_FRONTEND=noninteractive
apt-get update -qq
apt-get install -y --no-install-recommends python3 python3-yaml ca-certificates curl
# kubectl: current stable, checksum-verified before it becomes a root binary.
if ! command -v kubectl >/dev/null 2>&1; then
ver="$(curl -fsSL https://dl.k8s.io/release/stable.txt)"
tmp="$(mktemp)"
curl -fsSL -o "$tmp" "https://dl.k8s.io/release/${ver}/bin/linux/amd64/kubectl"
sum="$(curl -fsSL "https://dl.k8s.io/release/${ver}/bin/linux/amd64/kubectl.sha256")"
echo "${sum} ${tmp}" | sha256sum --check
install -m 0755 "$tmp" /usr/local/bin/kubectl
rm -f "$tmp"
fi
echo "Deploy tools ready:"
echo " $(python3 --version)"
python3 -c 'import yaml; print(" PyYAML", yaml.__version__)'
echo " kubectl $(kubectl version --client -o yaml 2>/dev/null | sed -n 's/.*gitVersion: //p' | head -1)"
Secrets (repo → Settings → Secrets and variables → Actions), needed only for a real (non-dry-run) deploy:
HA_TOKEN— a long-lived HA access token (reload-class targets).HA_KUBECONFIG_B64— base64 of a kubeconfig scoped to thehome-assistantnamespace (base64 -w0 kubeconfig). The workflow decodes it to$KUBECONFIG.
Triggers:
workflow_dispatch— run it manually with atargetsinput (defaultall) and adry_runtoggle (default on). A dry run reassembles + validates only and needs no secrets — the safe first test that the runner works end to end.- push to
maintouchingdocs/home_assistant/{automations,scenes,scripts,templates,packages,dashboards}/**— a real deploy of the changed config (repo is canon). The workflow narrows the targets from the push diff: a dashboards-only push deploys justdashboards(copy-only, no restart), while any push touching another target runs the fullalldeploy (which already covers dashboards). On any ambiguity — a missing/zero before-SHA, a diff error — it falls back toall.
name: deploy-home-assistant
# Deploy Home Assistant config-as-code to live HA via the self-hosted runner
# (issue #38). ha_deploy.py reassembles the per-item repo files and pushes them
# into the running HA pod. Runs on the LAN runner because it must reach the k3s
# cluster and HA at 192.168.1.x.
#
# Requirements on the runner (install once with install-deploy-tools.sh):
# python3 + PyYAML, and kubectl (for a real, non-dry-run deploy).
# Secrets (real deploy only): HA_TOKEN, HA_KUBECONFIG_B64.
on:
workflow_dispatch:
inputs:
targets:
description: "Targets: any of automations scenes scripts templates packages dashboards, or 'all'"
default: "all"
required: true
dry_run:
description: "Dry run (reassemble + validate only; nothing live is touched)"
type: boolean
default: true
push:
branches: [main]
paths:
- "docs/home_assistant/automations/**"
- "docs/home_assistant/scenes/**"
- "docs/home_assistant/scripts/**"
- "docs/home_assistant/templates/**"
- "docs/home_assistant/packages/**"
- "docs/home_assistant/dashboards/**"
# Never let two HA deploys overlap.
concurrency:
group: ha-deploy
cancel-in-progress: false
# The job only checks out code and runs a local script; it never calls the
# GitHub API. Least privilege on a self-hosted runner.
permissions:
contents: read
jobs:
deploy:
# Must match the labels the runner was registered with (register-runner.sh).
runs-on: [self-hosted, homelab, deploy]
steps:
# Pinned to a SHA to match the repo's convention (see ci.yml).
# fetch-depth: 0 so the push diff (github.event.before..sha) is available
# below to decide which targets changed.
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
- name: Deploy to Home Assistant
working-directory: docs/home_assistant
env:
HA_URL: http://192.168.1.22:8123
HA_TOKEN: ${{ secrets.HA_TOKEN }}
HA_KUBECONFIG_B64: ${{ secrets.HA_KUBECONFIG_B64 }}
# workflow_dispatch honours the dry_run toggle; a push to main is a real deploy.
DRY_RUN: ${{ (github.event_name == 'workflow_dispatch' && inputs.dry_run) && '1' || '' }}
TARGETS: ${{ github.event_name == 'workflow_dispatch' && inputs.targets || 'all' }}
# Consumed only via env (never interpolated into the script body) so a
# crafted ref cannot inject shell — these are commit SHAs regardless.
EVENT_NAME: ${{ github.event_name }}
PUSH_BEFORE: ${{ github.event.before }}
PUSH_AFTER: ${{ github.sha }}
run: |
set -euo pipefail
# On a push, narrow TARGETS to what actually changed: a dashboards-ONLY
# push is a copy-only deploy (no restart), while anything touching other
# targets keeps the full 'all' deploy (which already covers dashboards).
# Fall back to 'all' on any ambiguity (missing/zero before-SHA, diff error).
if [ "$EVENT_NAME" = "push" ]; then
changed=""
if [ -n "${PUSH_BEFORE:-}" ] \
&& [ "$PUSH_BEFORE" != "0000000000000000000000000000000000000000" ] \
&& git -C "$GITHUB_WORKSPACE" cat-file -e "${PUSH_BEFORE}^{commit}" 2>/dev/null; then
changed=$(git -C "$GITHUB_WORKSPACE" diff --name-only \
"$PUSH_BEFORE" "$PUSH_AFTER" -- docs/home_assistant/ 2>/dev/null || true)
fi
TARGETS="all"
if [ -n "$changed" ]; then
# Count changed files NOT under dashboards/. Zero => dashboards-only.
# (grep -vc counts non-matching lines and prints 0 when there are
# none; note grep -qv is unreliable here — it can exit non-zero even
# when a non-matching line exists, which would misclassify a mixed
# push as dashboards-only and skip the restart.)
non_dash=$(printf '%s\n' "$changed" \
| grep -vc '^docs/home_assistant/dashboards/' || true)
if [ "${non_dash:-1}" = "0" ]; then
TARGETS="dashboards"
fi
fi
echo "Resolved push TARGETS=$TARGETS"
fi
# Validate targets against the allowed set (defends the unquoted expansion below).
for t in $TARGETS; do
case "$t" in
automations|scenes|scripts|templates|packages|dashboards|all) ;;
*) echo "Invalid target: $t"; exit 1 ;;
esac
done
python3 -c 'import yaml' 2>/dev/null \
|| { echo "PyYAML missing on the runner -- run install-deploy-tools.sh"; exit 1; }
if [ -z "${DRY_RUN}" ]; then
command -v kubectl >/dev/null \
|| { echo "kubectl missing on the runner -- run install-deploy-tools.sh"; exit 1; }
[ -n "${HA_KUBECONFIG_B64:-}" ] \
|| { echo "Secret HA_KUBECONFIG_B64 is not set"; exit 1; }
export KUBECONFIG="$RUNNER_TEMP/kubeconfig"
umask 077
printf '%s' "$HA_KUBECONFIG_B64" | base64 -d > "$KUBECONFIG"
chmod 600 "$KUBECONFIG"
else
echo "DRY RUN -- reassemble + validate only, nothing live is touched."
fi
python3 ha_deploy.py $TARGETS
The configuration.yaml ConfigMap is not deployed here
As noted above, this workflow runs ha_deploy.py only. Deploying the configuration.yaml ConfigMap (kubectl apply) is a separate step — add it to the workflow if/when a restart-class config change needs it.
Recovering from a bad deploy
Every write is preceded by a timestamped backup inside the pod at /config/<dest>.bak.<ts>. To roll back, kubectl exec into the pod and copy the most recent .bak.<ts> back over the live file, then reload/restart that target. Because the repo is canon, the cleaner fix is usually to correct the source file here and re-run the deploy.