A working playbook and a maintainable playbook are two different things. At small scale the difference doesn’t matter. At fifty hosts it starts to. At five hundred it determines whether your automation is an asset or a liability. The architecture decisions you make early—how you structure inventory, how you handle secrets, how you separate roles from playbooks—compound in both directions.
Recommended Directory Structure
The Ansible best practices layout keeps inventory, variables, roles, and playbooks cleanly separated:
ansible/
├── inventory/
│ ├── production/
│ │ ├── hosts.yml
│ │ ├── group_vars/
│ │ │ ├── all.yml
│ │ │ ├── webservers.yml
│ │ │ └── dbservers.yml
│ │ └── host_vars/
│ │ └── web01.example.com.yml
│ └── staging/
│ └── hosts.yml
├── roles/
│ └── patching/
│ ├── tasks/
│ │ └── main.yml
│ ├── handlers/
│ │ └── main.yml
│ ├── defaults/
│ │ └── main.yml
│ └── vars/
│ └── main.yml
├── playbooks/
│ ├── patch-linux.yml
│ └── patch-windows.yml
└── ansible.cfg
Keep production/ and staging/ inventories strictly separate. Avoid a single hosts file that mixes environments with comments—it will be wrong within a month.
group_vars and host_vars
Variables in group_vars/ apply to all hosts in a group. host_vars/ applies to a single host. Both are loaded automatically when you reference that inventory.
inventory/production/group_vars/webservers.yml:
patch_reboot_allowed: true
patch_schedule: "security"
ansible_user: ubuntu
ansible_ssh_private_key_file: ~/.ssh/ansible_id
inventory/production/host_vars/web01.example.com.yml:
patch_reboot_allowed: false # this host has no maintenance window yet
Host variables always override group variables. Use this for exceptions, not as a primary configuration mechanism.
Roles vs Playbooks
Use playbooks as entry points: they define which roles run against which hosts.
Use roles as reusable units of logic: they contain the actual tasks, handlers, defaults, and templates.
# playbooks/patch-linux.yml
---
- name: Apply Linux patches
hosts: linux_servers
become: true
roles:
- patching
# roles/patching/tasks/main.yml
---
- name: Update package cache
ansible.builtin.apt:
update_cache: true
when: ansible_os_family == "Debian"
- name: Apply security updates
ansible.builtin.apt:
upgrade: dist
when: ansible_os_family == "Debian"
notify: Reboot if required
# roles/patching/handlers/main.yml
---
- name: Reboot if required
ansible.builtin.reboot:
reboot_timeout: 300
when: patch_reboot_allowed | bool
Idempotency
An idempotent operation produces the same result whether run once or ten times. This is critical for patching: if you re-run a playbook after a partial failure, it should only do what wasn’t already done—not undo completed work.
Ansible modules are idempotent by default when used correctly:
# Idempotent: will only install if not already present
- ansible.builtin.apt:
name: openssl
state: present
# NOT idempotent: runs the command every time regardless
- ansible.builtin.command: apt-get install -y openssl
Avoid command and shell modules for package management. They bypass idempotency entirely and make --check mode unreliable.
Ansible Vault: Secrets Management
Never store passwords, API keys, or SSH passphrases in plain text in your repository.
Encrypt a single variable file:
ansible-vault encrypt inventory/production/group_vars/all.yml
Encrypt a single value inline (for embedding in a vars file):
ansible-vault encrypt_string 'SuperSecret123!' --name 'become_password'
This outputs:
become_password: !vault |
$ANSIBLE_VAULT;1.1;AES256
61386637316161...
Running a playbook with a vault password:
# Prompt for password interactively
ansible-playbook patch-linux.yml --ask-vault-pass
# Use a password file (for CI/CD — store this in your secrets manager, not the repo)
ansible-playbook patch-linux.yml --vault-password-file ~/.vault_pass
The CI/CD Credential Leak Risk
A common mistake is passing vault passwords or SSH keys as environment variables in CI/CD pipelines and then using -v verbose mode:
# Dangerous: -vvv can print variable values including decrypted secrets
ANSIBLE_VAULT_PASSWORD=secret ansible-playbook patch.yml -vvv
The safe pattern:
- Store the vault password in your CI/CD secrets store (GitHub Actions Secrets, GitLab CI Variables, HashiCorp Vault).
- Write it to a temp file at runtime, not an environment variable.
- Never run with verbosity above
-vin CI/CD pipelines that stream logs to persistent storage.
# GitHub Actions example
- name: Write vault password
run: echo "$" > /tmp/.vault_pass && chmod 600 /tmp/.vault_pass
- name: Run playbook
run: ansible-playbook -i inventory/production patch-linux.yml --vault-password-file /tmp/.vault_pass
- name: Remove vault password file
if: always()
run: rm -f /tmp/.vault_pass
Using Collections
Ansible collections extend the built-in module library. For patching, community.general adds useful modules for reporting and notification.
Install a collection:
ansible-galaxy collection install community.general
ansible-galaxy collection install ansible.windows
Pin versions in requirements.yml for reproducible installs:
---
collections:
- name: community.general
version: ">=9.0.0"
- name: ansible.windows
version: ">=2.0.0"
Install from requirements:
ansible-galaxy collection install -r requirements.yml
Previous: Must-Know Ansible Commands and Core Concepts
Next in the series: Advanced Ansible Usage for Enterprise Environments — dynamic inventory, parallelism tuning, error handling, and CI/CD pipeline integration.
