Ansible Architecture and Best Practices

Eran Goldman-Malka · November 10, 2025

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.

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:

  1. Store the vault password in your CI/CD secrets store (GitHub Actions Secrets, GitLab CI Variables, HashiCorp Vault).
  2. Write it to a temp file at runtime, not an environment variable.
  3. Never run with verbosity above -v in 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.

Twitter, Facebook