Best Practices: Project Structure and Design

K
Kai··4 min read

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 own group_vars/. The same playbook + roles, run against a different inventory → a different environment. Avoid mixing prod/staging variables.
  • Thin playbook, fat role: site.yml only 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 nginx role handles nginx, the postgres role handles postgres. Don't cram everything into one role.
  • Parameterize via defaults/: anything customizable (port, version, paths) goes in defaults/main.yml as 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: declare galaxy_info (author, license, supported platforms). Recall Article 14 — molecule/galaxy require this.
  • Include a README and molecule/: 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.copy instead of copy. Clear, avoids collisions, and ansible-lint recommends it.
  • Prefix role variables: variables of the webserver role should be webserver_port, webserver_user... rather than port, 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.
  • become at the right level: elevate only the tasks that need root; don't enable become for 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: true on 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 --diff before applying to prod (Articles 5, 13): always dry-run.
  • Tags for long playbooks (Article 13): run selectively.
  • Don't use ignore_errors carelessly: it hides real errors. Use failed_when/block-rescue (Article 7) deliberately.
  • Keep playbooks declarative, not imperative: describe state (state:), avoid procedural chains of command.

🧹 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.