Writing own Ansible roles: structure, idempotency, reuse
How we structure own Ansible roles: variables, defaults, handlers, tests, and the checklist for when an own role really pays off.
Writing one Ansible playbook is a task. Writing a role is a decision about the future: someone, including future you, will run it against a machine whose shape you cannot fully predict. After years of building roles for update procedures, service deployments, and compliance checks across several estates, this post collects the structure we have settled on, why idempotency does not take care of itself, and the checklist that decides when an own role is worth its price at all.
The structure we settled on
A role in our collection answers exactly one intent, and its layout follows the intent rather than the filesystem convention. Defaults hold values that are safe to publish: versions, paths, policy switches. Variables, loaded from inventory or group data, hold the per-environment truth that must never be guessed inside the role. Handlers fire exactly when something changed, and they are written to be re-runnable: a service reload that fails the second time is a handler with a bug, not a fluke.
The discipline that took longest to adopt: the role may read its environment, but it may not inspect whether it has run before. Every state check happens through Ansible's own change detection, never through local knowledge like "we normally restart after this task". The moment a role relies on memory instead of state, idempotency turns decorative.
Idempotency itself is not self-driving. Every third-party module is idempotent in spirit, but the composed behavior is built from our decisions: checking a service's actual configuration before rewriting it, using commands whose second run confirms rather than mutates, and treating "changed: always" as a smell that earns a comment justifying itself. Our review bar is simple: a role must be safe to run twice, on a machine that just ran it, at three in the morning.
Tests without a testing religion
We test roles with the same small rig everywhere: one throwaway machine built from the same OS image as the target group, the role applied twice, state verified between runs. The double application is the point. The first run creates, the second run must be a no-op or the role ships with a defect. Service responses are verified with a real request where feasible, because an active service with a changed configuration can pass process checks while failing its purpose.
For the bigger roles there is an upgrade test too: apply the previous version, then the new one. Roles mutate estates for years, and the diff between consecutive role versions is exactly the surface where a silent migration breaks a fleet.
Reuse across projects without the universal-role trap
Reuse is the reason roles exist, and it is also where collections go wrong. The universal-role trap: a role grows a parameter for every environment difference until running it requires a fifteen-page variable document and nobody can review the behavior anymore. Our counter-rule is separation of intents. If two environments differ, the role expresses the common operation and the difference lives in inventory data or in explicit conditionals that a reader can find in one screen.
When a role's parameter list starts resembling a second role, we split it. Reuse is served better by two small roles composed by a playbook than by one role with an identity crisis.
The checklist: own role or not
Not everything deserves a role. Our checklist, in order: is the operation the same on at least three hosts, will it run again over their lifetime, does it compose with other operations we already have, and would its loss break a documented procedure rather than a habit. Three yes answers and a role pays off. Fewer than that and a task list inside a playbook is the honest answer, because a role nobody runs twice is structure-shaped cost.
Naming rounds out the checklist. A role's name should say the intent, not the mechanism: an updates-and-window role outlives three generations of package managers, while a name tied to one tool invites a rewrite nobody scheduled. Version the roles like software, keep a changelog per role even if it is one heading in the file, and let the repository history answer who changed what. The extra minutes are the same discipline the estate post argues for from the outside: roles are shared infrastructure, and shared infrastructure deserves records.
The last question is calibration for scale: if the fleet is small, one well-named playbook per operation is entirely respectable, and the pressure to build a role library is a premature optimization. The Ansible structure post covers this from the estate side, the Trixie upgrade report shows the compounding effect of pushing every release's residue into maintained roles, and the own hardware or cloud rules decide where the fleet's workloads run at all. If your own collection needs a review or a rebuild, the contact page is the direct way to reach us.