Skip to content

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:

  1. Reassembles the per-item repo files into the monolithic form HA loads (the exact inverse of how ha_export.py split them).
  2. Validates that the reassembled YAML parses (and --dry-run stops here).
  3. Backs up the current copy on the HA PVC to a timestamped .bak.<ts> in the pod, pruning to the most recent HA_BACKUPS (default 5).
  4. Writes the new copy into /config in the running pod via kubectl cp.
  5. 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.yaml etc.) 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 set HA_ALLOW_EMPTY=1 to 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.yaml ConfigMap is not handled here. It is deployed separately (kubectl apply of the ConfigMap) by the workflow. dashboards deploys 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 to DASHBOARD_MAP in ha_deploy.py.

Prerequisites

  • kubectl on PATH, with KUBECONFIG pointing at a kubeconfig scoped to the home-assistant namespace (in this homelab the kubeconfig lives as a Proton Pass attachment — see the team secrets store).
  • python3 with PyYAML.
  • For reload-class targets: HA_URL and a long-lived HA_TOKEN (see Environment below). Without a token, set HA_FORCE_RESTART=1 to 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
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 the home-assistant namespace (base64 -w0 kubeconfig). The workflow decodes it to $KUBECONFIG.

Triggers:

  • workflow_dispatch — run it manually with a targets input (default all) and a dry_run toggle (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 main touching docs/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 just dashboards (copy-only, no restart), while any push touching another target runs the full all deploy (which already covers dashboards). On any ambiguity — a missing/zero before-SHA, a diff error — it falls back to all.
deploy-ha.yml
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.