Practical patterns for writing maintainable Ansible playbooks across growing infrastructure
February 26, 2026 · 8 min · 1636 words · Rob Washington
Table of Contents
Ansible is easy to start and hard to master. A simple playbook works great for 5 servers. The same playbook becomes unmaintainable at 50. Here are the patterns that keep Ansible codebases sane as they grow.
# roles/nginx/defaults/main.yml# Users CAN override thesenginx_worker_processes:autonginx_worker_connections:1024nginx_client_max_body_size:10m# roles/nginx/vars/main.yml # Users SHOULD NOT override these (internal implementation)nginx_config_path:/etc/nginxnginx_service_name:nginx
- name:Update configtemplate:src:app.conf.j2dest:/etc/app/confignotify:restart app# Force handler to run NOW (before continuing)- meta:flush_handlers- name:Run migrations (needs app running with new config)command:/opt/app/migrate
- name:Check if app is installedstat:path:/opt/app/bin/appregister:app_binary- name:Download appget_url:url:"https://releases.example.com/app-{{ app_version }}.tar.gz"dest:/tmp/app.tar.gzwhen:not app_binary.stat.exists or force_reinstall | default(false)
- name:Run database migrationscommand:/opt/app/migrateregister:migrate_resultchanged_when:"'Applied' in migrate_result.stdout"- name:Check service statuscommand:systemctl is-active myappregister:service_statuschanged_when:false# Never report as changedfailed_when:false# Don't fail if inactive
- name:Run long backupcommand:/usr/local/bin/backup.shasync: 3600 # Max runtime:1hourpoll:0# Don't waitregister:backup_job- name:Continue with other tasksdebug:msg:"Doing other work while backup runs"- name:Wait for backup to completeasync_status:jid:"{{ backup_job.ansible_job_id }}"register:job_resultuntil:job_result.finishedretries:60delay:60
# Only run on specific hostsansible-playbook site.yml --limit web1.example.com
# Only run specific tagsansible-playbook site.yml --tags "nginx,ssl"# Skip specific tagsansible-playbook site.yml --skip-tags "slow"
1
2
3
4
5
6
7
8
9
10
11
# Tag tasks for selective runs- name:Install packagesapt:name:"{{ packages }}"tags:[packages, slow]- name:Update configtemplate:src:app.conf.j2dest:/etc/app/configtags:[config]
- name:Deploy applicationblock:- name:Stop serviceservice:name:myappstate:stopped- name:Deploy new versioncopy:src:app.tar.gzdest:/opt/app/- name:Start serviceservice:name:myappstate:startedrescue:- name:Rollback on failurecopy:src:/opt/app/backup/dest:/opt/app/remote_src:yes- name:Start old versionservice:name:myappstate:startedalways:- name:Clean up temp filesfile:path:/tmp/deploystate:absent
# Syntax checkansible-playbook site.yml --syntax-check
# List hosts that would be affected ansible-playbook site.yml --list-hosts
# List tasks that would runansible-playbook site.yml --list-tasks
# Step through tasks one at a timeansible-playbook site.yml --step
# Start at specific taskansible-playbook site.yml --start-at-task="Deploy application"
Ansible rewards good structure. The patterns above—clear inventory separation, well-designed roles, proper secret management, and idempotent tasks—make the difference between “works on my machine” and “works reliably in production.” Start simple, refactor as you grow, and always test with --check first.
📬 Get the Newsletter
Weekly insights on DevOps, automation, and CLI mastery. No spam, unsubscribe anytime.