Configuration
Your personal repo holds the vars; the shared collection holds the roles. Organizing that repo comes down to two independent choices: where your vars live, which scales with how many machines you run, and which roles each machine runs. The git_config and git_repositories vars are the same at every scale, only their location changes.
| You have | Vars live in | Roles live in | Files |
|---|---|---|---|
| One machine | playbook.yaml | playbook.yaml | examples/single/ |
| Multiple machines | one inventory.yaml | playbook.yaml | examples/multiple/ |
| Many machines | group_vars/ + host_vars/ | playbook.yaml | examples/many/ |
Only the Vars live in column tracks machine count; Roles live in stays playbook.yaml at every scale. Which roles a machine runs is an independent axis that works with any of these layouts, see Different roles per machine, where the role list becomes a variable (and so follows the Vars column).
Where vars live
Per-host values can't live in playbook.yaml: vars set there apply to the whole play (every host) so the moment a second machine needs to differ, its vars belong in inventory.yaml (or group_vars/host_vars).
Start with the first and graduate when a second machine appears. Run with --limit $(hostname) to apply only the current machine's config. The Examples page shows the full files for each layout to copy into your own repo: single (one machine), multiple (a few, one inventory.yaml), and many (group_vars/host_vars).
How values combine across scopes
With hash_behaviour = merge in ansible.cfg, dict vars merge across the all and per-host scopes, so a host only specifies what differs. Lists are replaced wholesale per host, not merged.
git_repositories (and the machine's projects inventory it defaults to) is a dict keyed by name for exactly this reason: shared repos sit in all, a host adds its own, and a host that sets a shared key to false drops that entry on that machine only.
Different roles per machine
By default a play lists roles statically, so every host it targets runs the same set. There are two ways to vary them, by scale, both in your own repo.
Per-host plays — multiple machines
Split the playbook into plays scoped by hosts:: a shared baseline play for all, then one play per host with that machine's extra roles. No new files, no finalizer, just plain Ansible. Per-host vars still come from the inventory (they're attached to the host, so they reach every play that targets it).
---
# playbook.yaml: baseline for all, then per-host roles
- name: Baseline — every machine
hosts: all
connection: local
diff: true
roles:
# OS base: the matching one runs, the other is skipped (facts decide)
- role: dotfilespro.roles.ubuntu
when: ansible_facts.os_family == 'Debian'
- role: dotfilespro.roles.macos
when: ansible_facts.os_family == 'Darwin'
- dotfilespro.roles.git
- name: Work-laptop only
hosts: work-laptop
connection: local
diff: true
roles:
- dotfilespro.roles.docker
- dotfilespro.roles.kubernetesRun with --limit $(hostname) so each machine applies only the baseline plus its own play. Set gathering = smart in ansible.cfg so facts are gathered once and reused across plays, and add diff: true to each play. The example ansible.cfg files also set gather_subset to skip the expensive hardware and network facts the roles never read, so each gather is cheaper. Keep diff at the play level rather than [diff] always in ansible.cfg: a global always overrides the roles' own diff: false opt-outs, so the quiet /tmp installer cleanups would start printing their removals again.
How vars reach each play
Vars from inventory.yaml / group_vars / host_vars are attached to the host, so a host sees the same vars in every play that targets it, define them once. A play's own vars: block is not shared with other plays (set_fact, register, and gathered facts do persist per host). One host can't read another's vars except explicitly via hostvars['other-host'].
Roles as data — many machines
Once one play per host gets unwieldy, list the ansible role and declare the roles in variables, the role applies them as a finalizer, so there's no hand-written loop and the playbook stops changing as you add machines:
---
# playbook.yaml: the ansible role applies the configured roles
- name: Configure machine
hosts: all
connection: local
gather_facts: true
diff: true
roles:
# OS base, applied per host from facts (the other role is skipped). Every
# machine needs one, so it's here rather than in the OS-neutral ansible_roles_all.
- role: dotfilespro.roles.ubuntu
when: ansible_facts.os_family == 'Debian'
- role: dotfilespro.roles.macos
when: ansible_facts.os_family == 'Darwin'
- dotfilespro.roles.ansible---
# group_vars/all.yaml: the OS-neutral baseline every machine gets (the OS base,
# ubuntu/macos, is applied per host in the playbook, not listed here)
ansible_roles_all:
- dotpro
- ssh
- git
- starship---
# host_vars/work-laptop.yaml: this machine's extra roles
roles_extra:
- docker
- kubernetes
- googlecloud
- terraformThe role applies layered variables: ansible_roles_all is the baseline for all hosts (in group_vars/all.yaml); roles_extra is a host's extra roles (in host_vars), a bare host-level knob like aliases / functions / shell; ansible_roles_include is the finalized list the role includes, ansible_roles_all merged with roles_extra, de-duplicated, with the ansible role removed (no recursion).
Because the baseline and the per-host roles are separate variables, a host just adds its own, no restating the baseline. (roles itself is an Ansible reserved keyword, so the per-host knob is roles_extra.)
Entries may be short names or fully-qualified namespace.collection.role, so you can mix in a shared company collection; roles still tag their own tasks, so --tags git keeps targeting one role.