The Home Assistant config I am migrating away from is split by type, the way most guides show it: input_boolean.yaml, input_datetime.yaml, templates.yaml and one file per category under automations/. Together that is about 4,800 lines of YAML, of which about 2,900 lines are automations in 13 files. For the new instance, which runs 2026.8.2 in Docker on the machine I rebuilt the setup on, I dropped that layout and put everything into packages, one file per area. The price is that package automations can only be edited in YAML. I ship the files with a deploy script that checks the configuration and restores the previous files when the check fails.
Where the split by type stopped helping
I wrote earlier about splitting automations.yaml into category files, and for the problem I had then it worked. I could open the heating automations without scrolling past everything else. The eight files from that post have grown to 13 since. That split only ever covered automations. The helpers stayed in input_boolean.yaml, the time windows in input_datetime.yaml and the display sensors in templates.yaml, so one area of the house was spread over five to six files.
This started to matter with the migration, because I am moving the logic from the old instance to the new one step by step. I want to take over one area completely, test it and switch it over. With the old layout I have to collect the helpers, templates and automations of an area from five or six files before I can move it.
One package per area
A package bundles everything one area needs in a single file: input helpers, template sensors, automations, scripts, and where needed integrations such as modbus or rest. One line in configuration.yaml includes the whole directory, and the file name becomes the package name. The example below is shortened and uses placeholder names, but the shape is the one my real packages have.
# configuration.yaml
homeassistant:
packages: !include_dir_named packages # file name = package name
# packages/example_area.yaml (shortened, placeholder names)
input_boolean:
example_area_enabled:
name: Example area enabled
template:
- sensor:
- name: "Example area status"
state: "{{ 'on' if is_state('input_boolean.example_area_enabled', 'on') else 'off' }}"
automation:
- id: example_area_evening
alias: "Example area: evening routine"
initial_state: false # shadow mode while the old instance still runs this
triggers:
- trigger: time
at: "19:00:00"
actions:
- action: light.turn_on
target:
entity_id: light.exampleThe first thing I converted was the demo data the new instance had carried until then, 21 template entities. It used to be pulled in by name with a single !include and is now packages/demo.yaml like everything else. After that, each area of the old instance gets its own file as it is taken over.
The price: no UI editor for package automations
Automations that come from a package cannot be edited in the UI editor. They show up in the UI and I can read their traces, but saving works only in YAML. That suits the way I work, because I change automations in the repository and deploy them from there. Anyone who builds automations mainly by clicking should weigh this before switching.
Shadow mode while two instances run
Once the new instance is connected to KNX, both instances share the bus for the rest of the migration. If the same automation runs on both, every action happens twice, so covers move twice and two push notifications go out. Every automation I take over therefore arrives on the new instance with initial_state: false, which I call shadow mode. The switch happens per area and always in the same order: I disable the automations of that area on the old instance first and then enable them on the new one. The initial_state: false line has to be removed at that point, because otherwise the next restart switches them off again. The previous post in this series covers the first steps of that parallel run.
I ordered the areas by risk. Energy and PV come first because that area only reads and controls nothing, then helpers and time control, lights, covers, heating, and after those cameras and notifications. At the time of this first step the new machine had no connection to the house at all, so automations could be transferred and validated but could not yet be tested against real devices.
The deploy script
On the old instance a deploy meant copying files with scp to /tmp and then running sudo cp into the config folder, by hand. The new instance gets one script in the repository with four steps. It backs up configuration.yaml and packages/ on the machine and keeps the last 10 states. It runs rsync for only the files that are maintained in git, so secrets, .storage and the themes and cards installed through HACS stay untouched. It runs check_config inside the container and does not reload if that fails. The fourth step is optional in the full script, a reload of selected domains over the REST API or a full restart. The shortened version below always reloads automations, has no dry-run mode and assumes that configuration.yaml and packages/ already exist on the machine. It does not reload helpers or templates. The full script takes a list of domains, for example --reload automation,input_boolean,template, and I name the domains that a change touches. A change to configuration.yaml or a new integration needs --restart.
#!/usr/bin/env bash
# deploy script, shortened; host, paths and URL are placeholders
set -euo pipefail
HOST=ha-host; REMOTE=/path/to/config; SRC=config; HA_URL=http://localhost:PORT
# 1. back up the current config on the machine, keep the last 10 states
TS=$(date +%Y%m%d-%H%M%S)
ssh "$HOST" "cd $REMOTE && mkdir -p .deploy-backups/$TS \
&& cp -a configuration.yaml packages .deploy-backups/$TS/ \
&& ls -1dt .deploy-backups/*/ | tail -n +11 | xargs -r rm -rf"
# 2. transfer only what is maintained in git; only packages/ is mirrored
ssh "$HOST" "mkdir -p $REMOTE/packages" # rsync --dry-run cannot create it
rsync -rlt "$SRC/configuration.yaml" "$HOST:$REMOTE/"
rsync -rlt --delete "$SRC/packages/" "$HOST:$REMOTE/packages/"
# 3. check_config in the container; read the output as well as the exit code
CHECK=$(ssh "$HOST" "docker exec homeassistant python -m homeassistant \
--script check_config -c /config" 2>&1) || RC=$?
echo "$CHECK"
if [[ -n "${RC:-}" ]] || grep -qi "invalid config" <<<"$CHECK"; then
# restore the backup, otherwise the next restart loads the broken file
ssh "$HOST" "cd $REMOTE && rm -rf packages && cp -a .deploy-backups/$TS/. ."
exit 1 # nothing is reloaded
fi
# 4. optional reload over the REST API; the token goes over stdin
printf 'Authorization: Bearer %s\n' "$(cat .ha-token)" | \
ssh "$HOST" "curl -fsS -X POST -H @- $HA_URL/api/services/automation/reload"Only packages/ is mirrored, so a package I delete in the repository also disappears on the machine. The token for the reload call goes to the machine over stdin, because on the command line it would be readable in the process list for every process on the host.
What the script caught
The first trial run of the full script with --dry-run aborted. In a dry run rsync cannot create a target directory that it is supposed to look into afterwards, so the script now creates packages/ over SSH beforehand. The second surprise was a single file transferred under a different name: the dry-run output showed the source name where the target name should have been. I stopped guessing and now transfer that one file with ssh and cat. That transfer is not in the shortened example. I saw this with the rsync 2.6.9 from 2006 that macOS still ships, and I have not tried a newer version.
The more important finding came a day later. check_config reported an invalid configuration and still exited with success. The script looked only at the exit code and restarted Home Assistant. Since then it reads the output of check_config and aborts on the words Invalid config, and in that case it restores the backup from step one automatically. Before that change a deploy that failed the check left the faulty file on the machine, where the next restart would have loaded it. A transfer that fails halfway is still not covered, because the script exits before it reaches the restore. I tested both additions with a deliberately broken file, and the script detected it, aborted and rolled back.
Later the same day the hardened script was needed for real. While building a monitoring package I made an indentation error in the YAML. The script recognised it, aborted and restored the backup before anything was reloaded. After that day I would not deploy a config without the output check and the restore. It follows the same idea as the backup, test and rollback routine I use for container updates, applied to the config files.
Recorder limits from the first day
The recorder settings went in at the same step. I keep 7 days of history instead of 10, and one counter is excluded. The reason is the old instance, where a single sensor, the telegram counter of the KNX interface, accounted for 98 % of all rows in the recorder database, 8.39 of 8.56 million in 7 days. It increases with every bus telegram, and each intermediate value was written as its own row.
recorder:
purge_keep_days: 7
exclude:
entities:
# on the old instance this counter produced 98 % of all recorder rows
- sensor.knx_telegram_counter
# no include block: everything is recorded except this counterThe old instance also had an include list next to the exclude block, on the assumption that it worked as a whitelist. On the new instance I want to record everything except this counter, so I use only an exclude list.
After the switch to packages a restart reached HTTP 200 after 4 s, measured once per second from docker compose restart. HTTP 200 does not mean that all integrations have finished loading. The instance then had 40 entities, 21 of them demo, and the log showed no errors or warnings apart from the usual note about HACS being a custom integration.
Frequently asked questions
Can I still edit automations in the UI when they live in a package?
No. Package automations and their traces are visible in the UI, but changes have to be made in the YAML file.
How do I avoid double actions when two instances run the same automations?
I transfer every automation with initial_state: false. Per area I then turn the automations off on the old instance, remove initial_state: false on the new one and enable them there.
Is the exit code of check_config enough for a deploy script?
In my case it was not. check_config reported an invalid configuration and still exited with code 0. My script now also searches the output for Invalid config and restores the backup when it finds it.



