Skip to content

Ansible Best Practices

Role Design

One role, one concern. If you are tempted to add something to a role, ask: "Does every node this role applies to need this?" If no, it belongs in another role.

Defaults over vars. Put everything in defaults/main.yml. Only use vars/main.yml for constants that should never be overridden.

Never hardcode values in tasks. Every IP, port, username, and path must be a variable. Tasks should read like instructions, not config files.

Idempotent always. Running the playbook 10 times must produce the same result as running it once. Use state: present, never raw shell commands that don't check first.

Task Writing

Always name every task. The name: field is mandatory.

Bad Good
- name: Install packages - name: Install base system packages — Debian
- name: Restart service - name: Restart timesyncd after NTP config change

Use modules, not shell. The apt module over shell: apt-get install. Modules are idempotent. Shell commands usually are not.

OS conditions. Use when: ansible_os_family == "Debian" to keep roles portable.

Handlers for service restarts. Never restart a service in tasks directly.

# tasks/main.yml
- name: Configure NTP
  template:
    src: timesyncd.conf.j2
    dest: /etc/systemd/timesyncd.conf
  notify: restart timesyncd   # <-- correct

# handlers/main.yml
- name: restart timesyncd
  systemd:
    name: systemd-timesyncd
    state: restarted

Tags on every task. Allows partial playbook runs.

- name: Install base packages
  apt:
    name: "{{ base_packages }}"
    state: present
  tags: [common, packages, baseline]

Variables

Variable priority order (lowest to highest):

role defaults  <  inventory group_vars  <  inventory host_vars  <  extra vars (-e)

This means inventories/ot/group_vars/all.yml automatically overrides roles/common/defaults/main.yml. This is intentional — use it.

Never store secrets in vars. Passwords, tokens, and API keys come from Infisical at runtime. Never from git.

Prefix role-specific vars to avoid collisions between roles.

# Good
common_admin_user: operator
common_ntp_servers:
  - 0.ca.pool.ntp.org

# Bad
admin_user: operator   # could collide with another role

Security

no_log: true on sensitive tasks.

- name: Set database password
  mysql_user:
    name: app
    password: REDACTED db_password }}"
  no_log: true

Least privilege. Only use become: true on tasks that need root. Not at the play level if you can avoid it.

SSH keys only. Never password auth in inventory.

File Structure

roles/my_role/
  defaults/main.yml     # overridable defaults
  vars/main.yml         # non-overridable constants (rare)
  tasks/main.yml        # imports task files
  tasks/install.yml     # split tasks by concern if > 50 lines
  tasks/configure.yml
  handlers/main.yml     # service restarts
  templates/            # Jinja2 templates (.j2)
  meta/main.yml         # role dependencies

Split tasks if over 50 lines:

# tasks/main.yml
- import_tasks: install.yml
  tags: [install]
- import_tasks: configure.yml
  tags: [configure]
- import_tasks: service.yml
  tags: [service]

Role Dependencies

Declare dependencies in meta/main.yml, not in playbooks:

# roles/docker/meta/main.yml
dependencies:
  - role: common

This ensures common always runs before docker, regardless of playbook order.

Testing with Molecule

Every role gets a Molecule test. See Ansible Molecule Guide.

The One Rule

If you have to think about what a task does when reading it 6 months later, rewrite it.