Ansible playbooks start simple and grow complex. A quick server setup becomes infrastructure-as-code for dozens of machines. Here are patterns that keep playbooks maintainable as they scale.
Project Structure# a ├ ├ │ │ │ │ │ │ │ │ ├ │ │ │ ├ │ │ │ └ n ─ ─ ─ ─ ─ s ─ ─ ─ ─ ─ i b a i ├ │ │ │ │ └ p ├ ├ └ r ├ ├ └ f l n n ─ ─ l ─ ─ ─ o ─ ─ ─ i e s v ─ ─ a ─ ─ ─ l ─ ─ ─ l / i e y e e b n p ├ └ s ├ └ b s w d s c n p s l t r ─ ─ t ─ ─ o i e a / o g o / e o o ─ ─ a ─ ─ o t b t m i s . r d g k e s a m n t c y u h g ├ └ i h g s . e b o x g f / c o r ─ ─ n o r y r a n r g t s o ─ ─ g s m v s e i t u / t u l e e s o s p a w s p r s / n . _ l e . _ s . y v l b y v . y m a . s m a y m l r y e l r m l s m r s l / l v / e r s . y m l ansible.cfg# 1
2
3
4
5
6
7
8
9
[defaults]
inventory = inventory/production
roles_path = roles
host_key_checking = False
retry_files_enabled = False
[ssh_connection]
pipelining = True
control_path = /tmp/ansible-%%r@%%h:%%p
Inventory Patterns# YAML Inventory (Preferred)# 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# inventory/production/hosts.yml
all :
children :
webservers :
hosts :
web1.example.com :
web2.example.com :
vars :
http_port : 80
databases :
hosts :
db1.example.com :
postgres_version : 15
db2.example.com :
postgres_version : 14
loadbalancers :
hosts :
lb1.example.com :
Group Variables# 1
2
3
4
5
6
7
8
9
10
11
12
13
# inventory/production/group_vars/all.yml
---
ansible_user : deploy
ansible_python_interpreter : /usr/bin/python3
ntp_servers :
- 0. pool.ntp.org
- 1. pool.ntp.org
# inventory/production/group_vars/webservers.yml
---
nginx_worker_processes : auto
nginx_worker_connections : 1024
app_root : /var/www/app
Role Structure# r ├ │ ├ │ ├ │ ├ │ ├ │ ├ │ └ o ─ ─ ─ ─ ─ ─ ─ l ─ ─ ─ ─ ─ ─ ─ e s d └ v └ t └ h └ t └ f └ m └ / e ─ a ─ a ─ a ─ e ─ i ─ e ─ n f ─ r ─ s ─ n ─ m ─ l ─ t ─ g a s k d p e a i u m / m s m l m l n s s / m n l a a / a e a a g / s a x t i i i r i t i l i / s n n n s n e n - n / . . . / . s x p . y y y y / . a y m m m m c r m l l l l o a l n m f s . . j c 2 o n # # # # # f # D R T H J R e o a a i o f l s n n l a e k d j e u l a l v e e 2 m t a n r e r t s t t v i r e a a a y ( m d r b r p a i l p e l t a e o s a a b s i t t l n a e a e ( t r s n s h t d i ( g s d l h e e o r p w p v e e r i n s i c d t o e e r s n p i , c r t i i y e e o ) t s r c i . t ) y )
defaults/main.yml# 1
2
3
4
5
6
---
# Overridable defaults
nginx_worker_processes : auto
nginx_worker_connections : 768
nginx_keepalive_timeout : 65
nginx_server_tokens : "off"
tasks/main.yml# 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
---
- name : Install nginx
ansible.builtin.apt :
name : nginx
state : present
update_cache : true
become : true
- name : Configure nginx
ansible.builtin.template :
src : nginx.conf.j2
dest : /etc/nginx/nginx.conf
owner : root
group : root
mode : '0644'
become : true
notify : Reload nginx
- name : Enable and start nginx
ansible.builtin.systemd :
name : nginx
enabled : true
state : started
become : true
handlers/main.yml# 1
2
3
4
5
6
7
8
9
10
11
12
---
- name : Reload nginx
ansible.builtin.systemd :
name : nginx
state : reloaded
become : true
- name : Restart nginx
ansible.builtin.systemd :
name : nginx
state : restarted
become : true
Task Patterns# Idempotent Tasks# 1
2
3
4
5
6
7
8
9
10
# Good - idempotent
- name : Ensure user exists
ansible.builtin.user :
name : deploy
state : present
groups : [ sudo, docker]
# Avoid - not idempotent
- name : Add user
ansible.builtin.command : useradd deploy
Conditional Execution# 1
2
3
4
5
6
7
8
9
10
11
- name : Install package (Debian)
ansible.builtin.apt :
name : nginx
state : present
when : ansible_os_family == "Debian"
- name : Install package (RedHat)
ansible.builtin.dnf :
name : nginx
state : present
when : ansible_os_family == "RedHat"
Loops# 1
2
3
4
5
6
7
8
9
10
- name : Create users
ansible.builtin.user :
name : "{{ item.name }}"
groups : "{{ item.groups }}"
state : present
loop :
- { name: alice, groups : [ developers] }
- { name: bob, groups : [ developers, sudo] }
loop_control :
label : "{{ item.name }}" # Cleaner output
Blocks for Error Handling# 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
- name : Deploy application
block :
- name : Pull latest code
ansible.builtin.git :
repo : "{{ app_repo }}"
dest : "{{ app_root }}"
version : "{{ app_version }}"
- name : Install dependencies
ansible.builtin.pip :
requirements : "{{ app_root }}/requirements.txt"
virtualenv : "{{ app_root }}/venv"
- name : Run migrations
ansible.builtin.command :
cmd : "{{ app_root }}/venv/bin/python manage.py migrate"
chdir : "{{ app_root }}"
rescue :
- name : Notify on failure
ansible.builtin.debug :
msg : "Deployment failed, rolling back"
- name : Rollback to previous version
ansible.builtin.git :
repo : "{{ app_repo }}"
dest : "{{ app_root }}"
version : "{{ previous_version }}"
always :
- name : Restart application
ansible.builtin.systemd :
name : myapp
state : restarted
Variable Precedence# From lowest to highest priority:
Role defaults (roles/x/defaults/main.yml) Inventory group_vars Inventory host_vars Playbook vars Task vars Extra vars (-e flag) — highest priority Use this strategically:
1
2
3
4
5
6
7
8
# roles/nginx/defaults/main.yml - sensible defaults
nginx_worker_processes : auto
# inventory/production/group_vars/webservers.yml - environment-specific
nginx_worker_processes : 4
# Command line override for testing
ansible-playbook site.yml -e "nginx_worker_processes=1"
Secret Management# Ansible Vault# 1
2
3
4
5
6
7
8
9
10
# Create encrypted file
ansible-vault create group_vars/all/vault.yml
# Edit encrypted file
ansible-vault edit group_vars/all/vault.yml
# Run playbook with vault
ansible-playbook site.yml --ask-vault-pass
# Or with password file
ansible-playbook site.yml --vault-password-file ~/.vault_pass
1
2
3
4
5
6
7
# group_vars/all/vault.yml (encrypted)
vault_db_password : "supersecret"
vault_api_key : "abc123"
# group_vars/all/main.yml (reference vault vars)
db_password : "{{ vault_db_password }}"
api_key : "{{ vault_api_key }}"
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
- name : Install packages
ansible.builtin.apt :
name : "{{ item }}"
state : present
loop : [ nginx, python3, git]
tags : [ packages, setup]
- name : Deploy application
ansible.builtin.git :
repo : "{{ app_repo }}"
dest : "{{ app_root }}"
tags : [ deploy]
- name : Configure nginx
ansible.builtin.template :
src : nginx.conf.j2
dest : /etc/nginx/nginx.conf
tags : [ config, nginx]
1
2
3
4
5
6
7
8
# Run only deploy tasks
ansible-playbook site.yml --tags deploy
# Skip config tasks
ansible-playbook site.yml --skip-tags config
# List available tags
ansible-playbook site.yml --list-tags
Testing with Check Mode# 1
2
3
4
5
# Dry run - shows what would change
ansible-playbook site.yml --check
# Diff mode - shows file changes
ansible-playbook site.yml --check --diff
Make tasks check-mode aware:
1
2
3
4
5
6
- name : Run database migration
ansible.builtin.command :
cmd : ./manage.py migrate
when : not ansible_check_mode # Skip in check mode
changed_when : "'No migrations' not in migration_result.stdout"
register : migration_result
Common Anti-Patterns# Don’t Use Shell When Module Exists# 1
2
3
4
5
6
7
8
9
# Bad
- name : Install package
ansible.builtin.shell : apt-get install -y nginx
# Good
- name : Install package
ansible.builtin.apt :
name : nginx
state : present
Don’t Hardcode Paths# 1
2
3
4
5
6
7
8
9
10
11
# Bad
- name : Copy config
ansible.builtin.copy :
src : /home/user/myconfig.conf
dest : /etc/myapp/config.conf
# Good
- name : Copy config
ansible.builtin.template :
src : config.conf.j2
dest : "{{ config_dir }}/config.conf"
Don’t Ignore Errors Silently# 1
2
3
4
5
6
7
8
9
10
11
12
# Bad
- name : Do something risky
ansible.builtin.command : risky-command
ignore_errors : true
# Better - handle the error
- name : Do something risky
ansible.builtin.command : risky-command
register : result
failed_when :
- result.rc != 0
- "'expected error' not in result.stderr"
Quick Reference# Pattern Use Case Roles Reusable, self-contained functionality group_vars Environment/group-specific config Vault Secrets management Tags Selective task execution Handlers Service restarts after changes Blocks Error handling and cleanup Check mode Dry runs and validation
Good Ansible code reads like documentation. Anyone should be able to look at your playbooks and understand what they do. Use meaningful names, organize with roles, and keep tasks idempotent. Your future self will thank you.
📬 Get the Newsletter Weekly insights on DevOps, automation, and CLI mastery. No spam, unsubscribe anytime.