A Home Assistant configuration structure that finally feels like “Everything in Its Right Place”
This is a structure I've arrived at that makes large YAML configurations much easier to maintain. I'm putting it out there because I'd like the pattern to be discussed and, if people find it useful, perhaps adopted more widely.
As a Home Assistant configuration grows, simply splitting files by domain eventually stops being enough.
I’ve been reorganizing my Home Assistant YAML for a while, mainly because the usual approach starts to get messy once you have more than a handful of automations and helpers.
The common approach is to organize everything by Home Assistant domain:
automations/ scripts/ sensors/ input_booleans/ input_selects/ ...
That works, but it means configuration belonging to completely different systems ends up mixed together.
For example, a Roborock automation sits next to an unrelated lighting automation simply because both happen to be automations.
I wanted the opposite:
project → domain → individual configuration
Packages as project containers
I use Home Assistant packages as containers for complete projects.
My main configuration stays very small:
homeassistant: packages: !include _packages/packages.yaml automation: !include_dir_merge_list _shared/automations/ binary_sensor: !include_dir_merge_list _shared/binary_sensors/ command_line: !include_dir_merge_list _shared/command_line/ counter: !include_dir_merge_named _shared/counters/ group: !include _shared/groups.yaml input_number: !include_dir_merge_named _shared/input_numbers/ input_text: !include_dir_merge_named _shared/input_text/ input_select: !include_dir_merge_named _shared/input_selects/ input_boolean: !include_dir_merge_named _shared/input_booleans/ input_datetime: !include_dir_merge_named _shared/input_datetime/ ...
Then packages.yaml acts as a project registry:
roborock: !include roborock/roborock.yaml
The Roborock package itself contains the domain boundaries:
input_boolean: !include_dir_merge_named input_booleans input_select: !include_dir_merge_named input_selects script: !include_dir_merge_named scripts automation: !include_dir_merge_list automations
And finally the individual files contain only the actual configuration.
So the structure becomes:
configuration.yaml homeassistant: packages: !include _packages/packages.yaml ↓ _packages/packages.yaml roborock: !include roborock/roborock.yaml ↓ _packages/roborock/roborock.yaml input_boolean: !include_dir_merge_named input_booleans input_select: !include_dir_merge_named input_selects script: !include_dir_merge_named scripts automation: !include_dir_merge_list automations ↓ _packages/roborock/input_booleans/*.yaml _packages/roborock/input_selects/*.yaml _packages/roborock/scripts/*.yaml _packages/roborock/automations/*.yaml
The package is a project container
The important part for me is that roborock is not a Home Assistant domain.
It is simply my project name.
The package gives me a namespace in which I can organize everything belonging to that project.
Technically, the nested include structure could also be built without packages. The problem is that I cannot simply invent a top-level key such as roborock: in configuration.yaml. Home Assistant expects recognized configuration keys there.
Packages give me that arbitrary project boundary.
The individual files don't contain domain keys
For example, this is a file inside input_booleans/:
auto_clean: name: Auto clean
And a script file:
clean_kitchen: sequence: ...
There is no input_boolean: or script: inside these files.
The domain was already established one level higher.
This makes the individual files very small and predictable.
Every include has its own relative path
Another thing I really like about this structure is that include paths are relative to the file containing the include.
So when packages.yaml includes:
roborock: !include roborock/roborock.yaml
the next file gets its own reference point.
Inside roborock.yaml I can simply write:
script: !include_dir_merge_named scripts
rather than having to know where the package sits relative to configuration.yaml.
Each level effectively establishes a new reference point.
That makes the whole thing feel almost like a nested set of containers.
_shared still exists
I don't put everything into packages.
I still have a _shared/ directory for configuration that doesn't really belong to a particular project:
_shared/ ├── automations/ ├── binary_sensors/ ├── scripts/ ├── sensors/ ├── templates/ └── ...
So there are essentially two categories:
Shared configuration
_shared/ domain/ configuration.yaml
Project configuration
_packages/ project/ project.yaml domain/ configuration.yaml
This distinction has turned out to be useful.
Moving things between _packages and _shared
The boundary isn't permanent.
If something starts as a general automation but later becomes part of a larger project, I can simply move it into that project's domain directory.
Likewise, if something no longer belongs to a particular project, I can move it back to _shared.
The configuration itself doesn't need to change much because the domain wrapper is provided by the parent include.
The project template
I also made a template directory containing all the possible domain directories:
_template/ ├── _template.yaml ├── automations/ ├── binary_sensors/ ├── command_line/ ├── counters/ ├── groups/ ├── input_booleans/ ├── input_datetime/ ├── input_numbers/ ├── input_selects/ ├── input_text/ ├── lights/ ├── notify/ ├── rest_commands/ ├── rest_sensors/ ├── scenes/ ├── scripts/ ├── sensors/ ├── shell_commands/ ├── switches/ ├── templates/ └── utility_meters/
The template YAML contains commented-out includes for all of them.
For example:
# --- Entity-ID-keyed domains --- # group: !include_dir_merge_named groups # input_boolean: !include_dir_merge_named input_booleans # input_select: !include_dir_merge_named input_selects # input_number: !include_dir_merge_named input_numbers # input_text: !include_dir_merge_named input_text # input_datetime: !include_dir_merge_named input_datetime # script: !include_dir_merge_named scripts # --- List-based / platform domains --- # automation: !include_dir_merge_list automations # scene: !include_dir_merge_list scenes # template: !include_dir_merge_list templates # rest: !include_dir_merge_list rest_sensors # switch: !include_dir_merge_list switches # sensor: !include_dir_merge_list sensors # binary_sensor: !include_dir_merge_list binary_sensors
The rule is simple:
commented include = unused
uncommented include = used
Because all directories already exist in the template, uncommenting an include never creates a dangling directory reference.
Empty directories can simply remain empty.
Adding a new project
When I start a new project, I copy _template/:
_template/ ↓ _packages/new_project/
Then I rename _template.yaml to new_project.yaml, uncomment the domains I need, and add one line to packages.yaml:
new_project: !include new_project/new_project.yaml
That's it.
configuration.yaml doesn't need to change.
This is one of the reasons I prefer having packages.yaml as a separate registry. I could technically put every package directly under homeassistant: packages:, but then adding a project would require editing the main configuration.
With the registry, the main configuration remains project-agnostic.
It's also non-destructive
The migration doesn't require removing my existing _shared/ structure.
If I decide I don't want packages anymore, I can remove:
homeassistant: packages: !include _packages/packages.yaml
The _shared/ configuration remains untouched.
So I can gradually move projects into packages instead of having to reorganize everything at once.
The resulting mental model
The structure I ended up with is essentially:
Home Assistant │ ├── _shared │ └── domain │ └── configuration │ └── _packages └── project ├── project.yaml │ └── domain └── configuration
It feels much more natural to me than having one huge collection of domain folders containing unrelated projects.
Packages provide the project boundary, nested includes provide the domain boundary, and the individual YAML files contain only the actual configuration.
Everything in its right place.
Source: r/ansible · by /u/Healthy-Target697