Structuring an Ansible Playbook You Can Maintain
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 web01shows the merged result;-eand play vars override group_vars. - Handler didn't run — the task that should
notifyreportedok(no change), so no notification. Or the play failed before the handler flush — use--force-handlersormeta: flush_handlers. - Role "not found" — not in
roles/, not installed viaansible-galaxy, orroles_pathinansible.cfgis wrong. - Works on staging, fails on prod — a
group_varsvalue 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/shellwithoutcreates:/changed_when:). See Ansible idempotency and check mode. - Slow — fact gathering on every play (
gather_facts: falsewhere not needed, or fact caching), and lowforksinansible.cfg.
Related tools and reading¶
- On-site: YAML validator, Regex tester.
- Related posts: Ansible idempotency and check mode, Writing a Bash script that won't bite you.
Stuck on something this site can't fix?Reach out to Prabath directly on LinkedIn.More in DevOps & CI/CD
OpenTelemetry for Distributed Tracing
A trace follows one request across services. OpenTelemetry is the vendor-neutral way to produce them — here's the model, the Collector, and sampling that won't bankrupt you.
October 24, 2026pre-commit Hooks That Actually Help
pre-commit runs checks before a commit lands, catching formatting, secrets, and syntax errors locally. Here's a config that helps without being annoying.
October 24, 2026Chaos Engineering Basics
Chaos engineering is controlled experiments that inject failure to verify your system tolerates it. Here's the method, safe blast radius, starter experiments, and game days.
October 24, 2026