Best Practices: Project Structure and Design
You've learned every piece. This article pulls them into best practices — how to organize and write Ansible so a project is maintainable, safe, and good for teamwork. This is the difference between "a playbook that runs" and "a professional Ansible codebase".
Project structure
A serious Ansible project should have a clear structure, with environments separated:
project/
├── ansible.cfg # configuration
├── requirements.yml # collection/role dependencies (Article 9)
├── inventories/
│ ├── production/
│ │ ├── hosts.yml # prod inventory
│ │ └── group_vars/ # prod-SPECIFIC variables
│ └── staging/
│ ├── hosts.yml
│ └── group_vars/ # staging-specific variables
├── roles/
│ ├── common/ # shared base role
│ └── webserver/ # role by function
├── playbooks/
│ └── site.yml # playbook that calls the roles
└── group_vars/ host_vars/ # variables common to all environments
Two important principles:
- Separate environments with their own inventory (
inventories/production/,inventories/staging/), each with its owngroup_vars/. The same playbook + roles, run against a different inventory → a different environment. Avoid mixing prod/staging variables. - Thin playbook, fat role:
site.ymlonly calls roles; the logic lives in the roles (Article 8). The playbook becomes the "score" that orchestrates, easy to read.
Good role design
A role is the unit of reuse — design it well and you can reuse it everywhere:
- One role, one responsibility: the
nginxrole handles nginx, thepostgresrole handles postgres. Don't cram everything into one role. - Parameterize via
defaults/: anything customizable (port, version, paths) goes indefaults/main.ymlas variables (low priority, easy to override — Article 6). A role shouldn't hard-code. - Absolutely idempotent (Article 5): every task must be safe to re-run.
- Complete
meta/main.yml: declaregalaxy_info(author, license, supported platforms). Recall Article 14 — molecule/galaxy require this. - Include a
READMEandmolecule/: document how to use it + tests (Article 14).
Naming conventions
Consistent naming makes code easier to read and debug:
- Set
name:for EVERY task and play — clearer logs (recall the PLAY RECAP/task name in Article 4). Unnamed tasks show up as a confusing module form. - Use FQCN (Article 9):
ansible.builtin.copyinstead ofcopy. Clear, avoids collisions, andansible-lintrecommends it. - Prefix role variables: variables of the
webserverrole should bewebserver_port,webserver_user... rather thanport,user. Avoid variable clashes between roles (Ansible variables are global within a play). - snake_case for variable names.
Security
A summary across the articles:
- Secrets via Vault (Article 10) or a secret manager (lookup — Article 12). Never leave plaintext passwords in a playbook/Git.
becomeat the right level: elevate only the tasks that need root; don't enablebecomefor the whole play if you don't need to (Linux series, Article 12).- Modules instead of command/shell (Article 5): safer and more idempotent.
- Don't log secrets: add
no_log: trueon tasks handling sensitive data so Ansible doesn't print the value to the log.
ansible-lint: catching issues automatically
Don't rely on memory to keep best practices — use ansible-lint to check automatically:
ansible-lint site.yml
Running it on the site.yml playbook (Article 4) gives a real result:
# Rule Violation Summary
1 risky-file-permissions profile:safety
risky-file-permissions: File permissions unset or incorrect.
site.yml:17 Task/Handler: Deploy the index page
Failed: 1 failure(s) ... Rating: 2/5 star
ansible-lint detected the copy task on line 17 doesn't set mode: — risky because file permissions depend on umask and aren't explicit (recall Linux series, Article 7). The fix: add mode: "0644":
- name: Deploy the index page
ansible.builtin.copy:
content: "..."
dest: /usr/share/nginx/html/index.html
mode: "0644" # ← explicit, lint stops complaining
ansible-lint has many rules and profiles (min, basic, safety, production...) with varying strictness. It exits non-zero when there's a violation — so put it in CI (alongside molecule — Article 14) to block non-compliant code before merge:
# .github/workflows/lint.yml (idea)
- run: ansible-lint
- run: molecule test
A few other conventions worth following
- Pin versions in
requirements.yml(Article 9): pin collection/role versions for reproducible builds. --check --diffbefore applying to prod (Articles 5, 13): always dry-run.- Tags for long playbooks (Article 13): run selectively.
- Don't use
ignore_errorscarelessly: it hides real errors. Usefailed_when/block-rescue(Article 7) deliberately. - Keep playbooks declarative, not imperative: describe state (
state:), avoid procedural chains ofcommand.
🧹 Cleanup
The sample project structure is in the nghiadaulau/ansible-series repo, directory 15-best-practices.
Wrap-up
Best practices: a clear project structure (separate environments with their own inventory, thin playbooks calling fat roles); role design that's single-responsibility, parameterized via defaults/, idempotent, with meta/README/molecule; consistent naming (every task has a name, use FQCN, prefix variables by role); security via Vault/secret manager, minimal become, no_log; and ansible-lint to catch issues automatically (like risky-file-permissions missing mode) — put it in CI alongside molecule. This is the standard of a professional Ansible codebase.
The final article (16) pulls everything into a complete project — deploying a real application with a role, applying best practices — then cleans up the EC2 instance and wraps up the series.