Server

Structuring an Ansible Playbook You Can Maintain

By Prabath Thalangama· October 24, 2026· 3 min read
#ansible#automation#configuration-management

Introduction

A single site.yml with 40 tasks works until it doesn't. Ansible's answer is roles plus a variable hierarchy. Here's a layout that stays readable as the repo grows.

Repository layout

.
├── ansible.cfg
├── inventory/
│   ├── production/
│   │   ├── hosts.ini
│   │   ├── group_vars/
│   │   │   ├── all.yml
│   │   │   ├── web.yml
│   │   │   └── db.yml
│   │   └── host_vars/
│   │       └── web01.yml
│   └── staging/
│       └── ...
├── roles/
│   ├── common/
│   ├── nginx/
│   └── postgres/
├── playbooks/
│   ├── site.yml
│   ├── web.yml
│   └── db.yml
└── requirements.yml          # external roles/collections

Inventory and variable precedence

# inventory/production/hosts.ini
[web]
web01 ansible_host=10.0.1.11
web02 ansible_host=10.0.1.12

[db]
db01 ansible_host=10.0.2.11

[production:children]
web
db

Variables resolve from least to most specific (abridged, low → high): role defaults → group_vars/all → group_vars/<group> → host_vars/<host> → play vars → -e extra vars (always wins).

Put defaults in roles/<role>/defaults/main.yml (lowest precedence, meant to be overridden). Put environment facts in group_vars. Reserve host_vars for genuinely per-host values (IPs, hostnames).

Role anatomy

roles/nginx/
├── defaults/main.yml      # default variables (overridable)
├── vars/main.yml          # internal constants (high precedence, don't override)
├── tasks/main.yml         # the work
├── handlers/main.yml      # restart/reload triggered by notify
├── templates/nginx.conf.j2
├── files/                 # static files to copy
├── meta/main.yml          # dependencies, supported platforms
└── molecule/              # tests
# roles/nginx/tasks/main.yml
- name: Install nginx
  ansible.builtin.package:
    name: nginx
    state: present

- name: Deploy nginx.conf
  ansible.builtin.template:
    src: nginx.conf.j2
    dest: /etc/nginx/nginx.conf
    validate: nginx -t -c %s
  notify: Reload nginx

- name: Ensure nginx is running
  ansible.builtin.service:
    name: nginx
    state: started
    enabled: true
# roles/nginx/handlers/main.yml
- name: Reload nginx
  ansible.builtin.service:
    name: nginx
    state: reloaded

Handlers run once, at the end of the play, only if notified. The validate: on the template means a broken config never gets deployed.

Playbooks stay thin

# playbooks/web.yml
- hosts: web
  become: true
  roles:
    - common
    - nginx
    - { role: app, tags: [app] }

site.yml just imports the others:

- import_playbook: web.yml
- import_playbook: db.yml

Tags and external content

ansible-playbook playbooks/site.yml --tags app        # only app-tagged tasks
ansible-playbook playbooks/site.yml --skip-tags slow
# requirements.yml
collections:
  - name: community.postgresql
roles:
  - name: geerlingguy.docker
ansible-galaxy install -r requirements.yml

Verification and troubleshooting

ansible-inventory -i inventory/production --graph
ansible-inventory -i inventory/production --host web01     # resolved vars for a host
ansible-playbook playbooks/site.yml --check --diff         # dry run
ansible-playbook playbooks/site.yml --list-tasks
ansible-lint
  • A variable isn't what you expect — precedence. ansible-inventory --host web01 shows the merged result; -e and play vars override group_vars.
  • Handler didn't run — the task that should notify reported ok (no change), so no notification. Or the play failed before the handler flush — use --force-handlers or meta: flush_handlers.
  • Role "not found" — not in roles/, not installed via ansible-galaxy, or roles_path in ansible.cfg is wrong.
  • Works on staging, fails on prod — a group_vars value differs, or prod has hosts staging doesn't. Diff the inventories.
  • Everything re-runs as "changed" every time — a task that isn't idempotent (raw command/shell without creates:/changed_when:). See Ansible idempotency and check mode.
  • Slow — fact gathering on every play (gather_facts: false where not needed, or fact caching), and low forks in ansible.cfg.
PrabathStuck on something this site can't fix?Reach out to Prabath directly on LinkedIn.