Ansible’s directory conventions are load-bearing in a way most tools’ are not: group_vars, host_vars and roles are read by position, not by configuration.
That makes the layout partly a matter of following convention and partly a set of genuine decisions about where inventories live, how roles are organised, and how much one repository should hold.
The shape
Section titled “The shape”ansible/├── ansible.cfg├── requirements.yml├── inventories/│ ├── development/│ │ ├── hosts.yml│ │ ├── group_vars/│ │ │ ├── all.yml│ │ │ └── webservers.yml│ │ └── host_vars/│ └── production/│ ├── hosts.yml│ ├── group_vars/│ │ ├── all.yml│ │ └── vault.yml # Encrypted│ └── host_vars/├── playbooks/│ ├── site.yml│ ├── webservers.yml│ └── database.yml├── roles/│ └── local/│ ├── app_deploy/│ └── monitoring_agent/├── collections/ # Downloaded — gitignored├── tests/├── docs/└── .github/workflows/Inventories are separated by environment, each with its own group_vars and host_vars. This is the primary safety boundary and the most important structural decision in the repository.
roles/local/ holds roles you wrote, with the parent roles/ gitignored so ansible-galaxy can install into it without polluting the diff. The roles_path in ansible.cfg covers both.
Playbooks are thin and live together.
Why inventories go per environment
Section titled “Why inventories go per environment”The alternative — one inventory with environment groups — is common and worse.
# The pattern to avoidall: children: production: children: prod_web: prod_db: development: children: dev_web:Running against everything becomes possible by accident. ansible-playbook site.yml with no --limit targets every host in the inventory, including production. There is no confirmation prompt and no plan to read first — the run begins immediately, and by the time somebody notices, some number of hosts have already been changed.
Variable precedence gets complicated. Environment-specific values need group_vars keyed on environment groups, and the interaction with host groups produces surprises.
A mistake in the inventory file affects both environments.
With separate inventories, hitting production requires naming production’s inventory. That is an explicit act, visible in shell history and in CI configuration.
Variables: where they belong
Section titled “Variables: where they belong”Ansible’s precedence order is long. The discipline that keeps a repository navigable:
| Location | For | Precedence |
|---|---|---|
roles/*/defaults/main.yml | A role’s defaults | Lowest — overridable |
inventories/*/group_vars/all.yml | Environment-wide values | Low |
inventories/*/group_vars/GROUP.yml | Per-group values | Medium |
inventories/*/host_vars/HOST.yml | Genuine per-host facts | Higher |
roles/*/vars/main.yml | Role internals, not for overriding | High |
-e on the command line | One-off overrides only | Highest |
Role defaults are where a role’s variables live. Anything a consumer might change belongs in defaults, not vars.
group_vars is where environment differences live, which makes them diffable between environments — the same argument as Terraform values files.
host_vars should be small. A host_vars directory doing real configuration work means the group structure is wrong: hosts needing the same treatment should share a group.
vars/main.yml in a role is for values the role uses internally and does not want overridden. Using it for configurable values makes the role hard to reuse.
Never rely on -e routinely. It beats almost everything, which makes it right for a one-off and dangerous as a habit.
ansible-inventory --graph --vars shows what a host actually resolves to, which answers “where is this value coming from” faster than reading files.
Naming groups
Section titled “Naming groups”Groups are how playbooks select hosts, and the naming decides how readable that is.
Name by function, not by machine. webservers, databases, loadbalancers. Not server1_group.
Use nested groups for shared configuration. A webservers group containing webservers_eu and webservers_us lets a playbook target all of them or one region.
Do not encode the environment in group names when inventories are already separated. production_webservers inside inventories/production/ is redundant, and it means a role’s when conditions reference environment names that only exist in one inventory.
Keep group names stable. They appear in group_vars filenames, in playbooks, in --limit arguments people type and in whatever runbooks reference them. Renaming one is a repository-wide change with a long tail of places that still say the old name.
The unit of reuse and the unit of testing.
roles/local/app_deploy/├── defaults/main.yml # Overridable variables├── vars/main.yml # Internal, higher precedence├── tasks/main.yml # What it does├── handlers/main.yml # Restart, reload├── templates/ # Jinja2 templates├── files/ # Static files├── meta/main.yml # Dependencies, metadata├── molecule/ # Tests└── README.md # What this is and its variablesOne role, one responsibility. A role that installs a package, configures it and manages its data is three roles, and only one is likely to be reusable elsewhere.
Prefix variables with the role name. app_deploy_port, not port. Ansible has one flat variable namespace and two roles both using port will collide.
meta/main.yml dependencies run before the role, every time it is included. Useful and easy to overuse — a dependency chain three deep produces a run whose order is not obvious from any playbook.
A README per role, stating its purpose, its variables and its dependencies. Roles outlive whoever wrote them, and the variables list is the interface.
Roles that are genuinely shared belong in their own repository, consumed through requirements.yml at a pinned version — covered separately.
Playbooks
Section titled “Playbooks”Thin, and named after what they do.
- name: Configure web servers hosts: webservers become: true roles: - local.common - local.nginx - local.app_deploysite.yml imports the others, so a full run is one command and a partial run is a specific playbook. Use import_playbook rather than duplicating plays — a site.yml that repeats what the individual playbooks contain diverges from them within a month.
hosts: names a group, never a host pattern with wildcards that might match more than intended.
become: true at play level where the whole play needs it; per-task where only some do. A play running as root when it does not need to is a broader grant than necessary.
serial: for anything behind a load balancer. Without it, a playbook restarting a service restarts it everywhere at once — which is an outage rather than a deployment.
Keep playbooks under a screen. One containing forty inline tasks is a role that has not been extracted.
Templates and files
Section titled “Templates and files”Two role directories with different semantics and a common failure.
files/ holds static content copied verbatim. templates/ holds Jinja2 rendered with the run’s variables.
The failure is a template that is effectively static, or a static file that should have been a template — the second producing a role that needs a different file per environment.
Template changes are the ones to review carefully. A one-character change to a Jinja2 conditional can alter a configuration file on every host, and the diff shows the template rather than the rendered result. --diff at run time shows what will actually change on a host, which is the closest equivalent to rendering a Kustomize overlay.
Keep templates readable. A configuration template with nested loops and conditionals is one nobody can predict the output of. Where a template is getting complicated, the usual answer is that the configuration should be assembled from smaller files — many services support a conf.d directory — rather than templated as one large file.
Validate rendered output where you can. Many services have a configuration check command, and a task that runs it with validate: on the template module means a broken configuration fails before the file is written rather than when the service restarts.
Never template a secret into a file without thinking about where it lands. The rendered file exists on the host with whatever permissions the task set. mode: "0600" and an appropriate owner are part of the task, not an afterthought.
Small, medium, large
Section titled “Small, medium, large”The layout should change with the estate, and the signals are recognisable.
Small — a handful of playbooks, one or two inventories, roles inline. Perfectly fine, and most teams should stay here longer than they do. The structure above with three roles is not too little.
Medium — several inventories, roles factored out, group_vars doing real work. This is where lint and Molecule earn their keep, and where a README at the root stops being optional.
Large — roles and collections in their own versioned repositories, consumed through requirements.yml at pinned versions. Deliberately slower to change, which is the point.
The migration people get wrong is jumping to the third stage early: a dozen role repositories each with three commits, and a requirements.yml nobody updates. Extract a role when two consumers genuinely need it at different versions, not when it feels tidier.
The other failure is never splitting. A repository with two hundred roles, six inventories and no ownership boundaries is one where every change needs a reviewer who knows all of it, and no such person exists.
Separating the inventory
Section titled “Separating the inventory”A decision worth making explicitly at medium scale.
Same repository is simpler and means a change to a role and its variables is one pull request.
Separate repository means the host list — which is operational detail, and sometimes sensitive — has its own access control. Somebody can contribute a role improvement without being able to read your production topology.
Dynamic inventory removes the question where hosts are cloud instances: the inventory is generated at run time from tags, so there is no list to store. This is the better answer wherever it applies, and it means the inventory cannot go stale.
The trade-off is coordination. With a separate inventory repository, adding a host and the configuration it needs is two pull requests.
Ownership and review
Section titled “Ownership and review”At any size above one team, CODEOWNERS does the work the directory structure sets up.
Inventories are the highest-value path to own. Production’s inventory decides what gets changed, and a change to it deserves a reviewer who knows the estate.
Encrypted vault files should require the people who hold the key.
Roles need their author or their team, because a role change affects every playbook using it.
.github/workflows/ as everywhere in this pillar.
Playbooks are usually the least sensitive, because they mostly compose roles — but a playbook change altering hosts: or removing serial: is a bigger deal than it looks.
A layout that maps onto teams makes this straightforward; one where every team’s roles are mixed together does not, which is one of the arguments for splitting at large scale.
Documentation
Section titled “Documentation”Ansible repositories are worse documented than most, because playbooks look self-explanatory and are not.
A root README answering: what this manages, how to run it safely, which inventory is which, and who to ask. Four paragraphs.
A README per role, with its variables. The variables are the interface and nothing else documents them.
Comments on non-obvious tasks. A when condition guarding something dangerous deserves a sentence.
A runbook for the playbooks people run under pressure. Which flags, which limit, what to check afterwards. The person running a playbook during an incident is frequently not the person who wrote it.
Record what changed on hosts and why. Ansible does not keep a history the way a state file does, so the repository’s commit history plus a run log is the entire record of what happened to your machines.
The test: could somebody run the development playbook safely, on their first day, from what is written down? If not, the gap is what to document next.
Testing directories
Section titled “Testing directories”Where tests live, and why the placement matters.
Per-role Molecule scenarios live inside the role, in roles/local/NAME/molecule/. That keeps a role and its tests together, so extracting the role later takes its tests with it — which is the main argument for this placement.
A repository-level tests/ holds anything that is not a role test: inventory validation, a syntax check across every playbook, or an integration scenario spanning several roles.
Inventory tests are underrated. A check that every host in production has the variables it needs, and that no group references a group_vars file that does not exist, catches a class of error that only otherwise appears at run time.
Do not put a molecule/ directory at the repository root unless you genuinely have a scenario testing the whole thing. It looks like the roles are tested when they are not.
Test fixtures belong beside their tests, not in the role’s files/. A fixture copied into a real host because it was in the wrong directory is an avoidable and embarrassing failure.
Keep test inventories obviously fake. localhost, container names, or documentation-range addresses. A test inventory that resembles production is one somebody will run a real playbook against.
Common mistakes
Section titled “Common mistakes”One inventory with environment groups. Running against everything becomes possible by accident.
ansible.cfg defaulting to production. A forgotten flag from an incident.
Fat host_vars. The group structure is wrong.
Unprefixed role variables. Collisions between roles, hard to debug.
Configurable values in vars/ rather than defaults/. The role cannot be reused.
Deep meta dependency chains. Run order that no playbook explains.
Playbooks full of inline tasks. Logic that cannot be tested.
No serial on load-balanced services. A restart everywhere at once.
Committing downloaded collections. Nobody can tell what is yours.
Extracting role repositories with one consumer. Version overhead, no benefit.
Collections
Section titled “Collections”Collections are the current packaging unit and they change the structure at scale.
A collection bundles roles, modules, plugins and playbooks under a namespace — community.general, ansible.posix, or your own example.platform.
requirements.yml declares what you consume:
collections: - name: community.general version: "<pinned>" - name: ansible.posix version: "<pinned>" - name: example.platform source: https://github.com/example-org/ansible-collection-platform.git type: git version: v1.4.0Pin versions. A floating version means a collection update changes behaviour with no commit in your repository — the same argument as everywhere in this pillar.
Git sources work, which is how a private collection is consumed without publishing it anywhere. Pin the version to a tag, never a branch.
Fully-qualified module names. ansible.builtin.copy rather than copy is explicit about which collection provides it and prevents a collection you install later shadowing a builtin. This matters more as the number of installed collections grows.
Your own collection is the large-scale answer. Once several repositories share roles, packaging them as a collection with a namespace, a version and a release process is cleaner than a directory of role repositories — and it is covered separately.
The collections directory is gitignored, like roles/. Downloaded content is not source.
Evolving the layout
Section titled “Evolving the layout”The signals that a structure has stopped fitting, and what to do about each.
A host_vars file per host, all with similar content. The group structure is wrong. Find what those hosts have in common and make it a group.
A role nobody can change safely because every playbook uses it differently. It is doing too much; split it by responsibility.
Playbooks that duplicate task sequences. The sequence is a role.
Two teams blocked on each other’s reviews. Ownership boundaries do not match the repository. CODEOWNERS first; splitting only if that is insufficient.
Nobody knows which playbook to run. A discovery problem, and the fix is usually a README rather than a restructure.
Lint and tests take long enough that people skip them. Scope them to what changed before splitting anything.
The same role in three repositories, diverging. Time to extract and version it.
How to move things safely. Restructuring an Ansible repository is lower-risk than restructuring a GitOps one — no controller is watching, and nothing applies until somebody runs a playbook. The verification is running the playbook against a disposable target before and after and confirming the same result.
Do not restructure and change behaviour in the same pull request. A move that is a no-op is reviewable; one mixed with functional changes is not, and the whole point of the verification is that the diff in behaviour should be empty.
One repository or several
Section titled “One repository or several”The question at the top level, once an estate is large.
One repository for everything — every inventory, every role, every playbook — is where teams start and where many should stay. Everything is in one place, a change spanning a role and an inventory is one pull request, and there is one CI configuration.
Separate by team when teams genuinely own different parts of the estate and are blocked on each other’s reviews. Each team’s repository has its own inventories and roles, with shared roles consumed as a collection.
Separate the inventory when the host list needs different access control from the automation.
Separate roles into collections when they are shared across repositories — the strongest and most common split.
What not to do: a repository per playbook. Playbooks are small, they share roles and inventories, and splitting them produces coordination cost for no boundary worth having.
The signal for splitting is the same as everywhere in this pillar: two groups of people who need different permissions, or who are waiting on each other. Not tidiness, and not the size of the directory listing.
And the caution is the same: splitting is easy to do and expensive to undo, and an estate spread across nine repositories with roles duplicated in four of them is worse than the monolith it replaced.
Mental model
Section titled “Mental model”The inventory decides what gets changed. The roles decide what the change is. Keeping those separate — and keeping the inventory explicit — is most of what a good Ansible repository does.
Everything else is convention worth following because Ansible reads directories by position, and a repository that follows the conventions is one any Ansible engineer can navigate on their first day.
What you learned
Section titled “What you learned”- Separate inventories per environment; one inventory with environment groups makes accidents possible
- Point
ansible.cfgat development so a forgotten-iis harmless defaults/for overridable values,vars/for internals,group_varsfor environment differenceshost_varsdoing real work means the group structure is wrong- Prefix role variables with the role name — Ansible has one flat namespace
- Playbooks list hosts and apply roles; logic in a playbook cannot be tested
serial:is what makes a playbook a rolling deployment rather than an outage- Extract role repositories when two consumers need different versions, not before
Exercise
Section titled “Exercise”Use a disposable directory and containers. No real hosts.
-
Build the structure above with
developmentandproductioninventories, usingexample.comhostnames. -
Set
ansible.cfgto default to the development inventory. Runansible all --list-hostswith no-i. Predict: which hosts? -
Define the same variable in a role’s
defaults, ingroup_vars/all.ymland inhost_vars/. Runansible-inventory --graph --vars. Predict: which wins? -
Move it to the role’s
vars/main.ymland try to override it fromgroup_vars. Predict: does the override work? -
Create two roles both using a variable named
port. Apply both in one play. Predict: what happens? -
Rename them with role prefixes and repeat.
-
Add a
serial: 1to a play targeting three containers and watch the execution order. -
Delete the directory and the containers.
Related lessons
Section titled “Related lessons”The GitOps and infrastructure repository templates are in the Professional Toolkit.