Ansible Patterns for Maintainable Infrastructure

Ansible is simple until it isn’t. Here’s how to structure projects that remain maintainable as they grow. Project Structure a β”œ β”œ β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ β”œ β”‚ β”‚ β”‚ β”œ β”‚ β”‚ β”‚ β”” n ─ ─ ─ ─ ─ s ─ ─ ─ ─ ─ i b a i β”œ β”‚ β”‚ β”‚ β”‚ β”” p β”œ β”œ β”” r β”œ β”œ β”” c β”” l n n ─ ─ l ─ ─ ─ o ─ ─ ─ o ─ e s v ─ ─ a ─ ─ ─ l ─ ─ ─ l ─ / i e y e l b n p β”œ β”” s β”œ β”” b s w d s c n p e r l t r ─ ─ t ─ ─ o i e a / o g o c e e o o ─ ─ a ─ ─ o t b t m i s t q . r d g k e s a m n t i u c y u h g β”œ β”” i h g s . e b o x g o i f / c o r ─ ─ n o r y r a n r n r g t s o ─ ─ g s m v s e s e i t u / t u l e e s / m o s p a w s p r s q e n . _ l e . _ s . l n y v l b y v . y / t m a . s m a y m s l r y e l r m l . s m r s l y / l v / m e l r s . y m l Inventory Best Practices YAML Inventory 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: postgresql_version: 15 db2.example.com: postgresql_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 common_packages: - vim - htop - curl 1 2 3 4 5 # inventory/production/group_vars/webservers.yml --- nginx_worker_processes: auto nginx_worker_connections: 1024 app_port: 3000 Role Structure r β”œ β”‚ β”œ β”‚ β”œ β”‚ β”œ β”‚ β”œ β”‚ β”œ β”‚ β”œ β”‚ β”” o ─ ─ ─ ─ ─ ─ ─ ─ l ─ ─ ─ ─ ─ ─ ─ ─ e s d β”” v β”” t β”” h β”” t β”” f β”” m β”” R / e ─ a ─ a ─ a ─ e ─ i ─ e ─ E n f ─ r ─ s ─ n ─ m ─ l ─ t ─ A g a s k d p e a D i u m / m s m l m l n s s / m M n l a a / a e a a g / s a E x t i i i r i t i l i . / s n n n s n e n - n m / . . . / . s x p . d 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 S l a e k d j t e u s l a a l v e 2 t m t a t r i e r o s t c t v i e a a a e ( m f d r b x r p i a i l e e l l t a e c s a e a b s u t t s l t a e a e ( e r s n s h t d i ( g s d l h e e o e r p w r v e e i n s p c d t r e e i s n p o , c r r i i i e e o t t s r y c i ) . t ) y ) Role Defaults 1 2 3 4 5 6 7 # roles/nginx/defaults/main.yml --- nginx_user: www-data nginx_worker_processes: auto nginx_worker_connections: 1024 nginx_keepalive_timeout: 65 nginx_sites: [] Role Tasks 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 35 36 37 38 39 40 41 42 43 44 45 # roles/nginx/tasks/main.yml --- - 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: Configure sites ansible.builtin.template: src: site.conf.j2 dest: "/etc/nginx/sites-available/{{ item.name }}" owner: root group: root mode: '0644' loop: "{{ nginx_sites }}" become: true notify: Reload nginx - name: Enable sites ansible.builtin.file: src: "/etc/nginx/sites-available/{{ item.name }}" dest: "/etc/nginx/sites-enabled/{{ item.name }}" state: link loop: "{{ nginx_sites }}" become: true notify: Reload nginx - name: Start nginx ansible.builtin.service: name: nginx state: started enabled: true become: true Handlers 1 2 3 4 5 6 7 8 9 10 11 12 13 # roles/nginx/handlers/main.yml --- - name: Reload nginx ansible.builtin.service: name: nginx state: reloaded become: true - name: Restart nginx ansible.builtin.service: name: nginx state: restarted become: true Playbook Patterns Main Playbook 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 # playbooks/site.yml --- - name: Apply common configuration hosts: all roles: - common - name: Configure web servers hosts: webservers roles: - nginx - app - name: Configure databases hosts: databases roles: - postgresql Tagged Playbook 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 # playbooks/webservers.yml --- - name: Configure web servers hosts: webservers become: true tasks: - name: Install packages ansible.builtin.apt: name: "{{ item }}" state: present loop: - nginx - python3 tags: - packages - name: Deploy application ansible.builtin.git: repo: "{{ app_repo }}" dest: /var/www/app version: "{{ app_version }}" tags: - deploy - name: Configure nginx ansible.builtin.template: src: nginx.conf.j2 dest: /etc/nginx/nginx.conf notify: Reload nginx tags: - config Run specific tags: ...

February 28, 2026 Β· 9 min Β· 1911 words Β· Rob Washington

Ansible Playbook Patterns That Scale

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. Project Structure Start with a structure that scales: a β”œ β”œ β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ β”œ β”‚ β”‚ β”‚ β”œ β”‚ β”‚ β”‚ β”” n ─ ─ ─ ─ ─ s ─ ─ ─ ─ ─ i b a i β”œ β”‚ β”‚ β”‚ β”‚ β”” p β”œ β”œ β”” r β”œ β”œ β”” g β”” l n n ─ ─ l ─ ─ ─ o ─ ─ ─ r ─ e s v ─ ─ a ─ ─ ─ l ─ ─ ─ o ─ / i e y e u b n p β”œ β”” s β”œ β”” b s w d s c n p p a β”œ β”” l t r ─ ─ t ─ ─ o i e a / o g o _ l ─ ─ e o o ─ ─ a ─ ─ o t b t m i s v l ─ ─ . r d g k e s a m n t a / c y u h g β”œ β”” i h g β”” s . e b o x g r v v f / c o r ─ ─ n o r ─ y r a n r s a a g t s o ─ ─ g s o ─ m v s e / r u i t u / t u l e e s s l o s p a w s p a r s / . t n . _ l e . _ l s . y . y v l b y v l . y m y m a . s m a . y m l m l r y e l r y m l l s m r s m l / l v / l e r s . y m l The key insight: separate inventory per environment. Never mix production and staging in the same inventory file. ...

February 26, 2026 Β· 8 min Β· 1636 words Β· Rob Washington

Ansible Playbook Patterns: Idempotent Infrastructure Done Right

Ansible’s simplicity is deceptive. Anyone can write a playbook that works once. Writing playbooks that work reliably, repeatedly, and maintainably requires discipline and patterns. 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 s l t r ─ ─ t ─ ─ o i e a / o g o / c e o o ─ ─ a ─ ─ o t b t m i s r . r d g k e s a m n t i c y u h g β”œ β”” i h g s . e b o x g p f / c o r ─ ─ n o r y r a n r t g t s o ─ ─ g s m v s e s i t u / t u l e e s / o s p a w s p r s q n . _ l e . _ s . l 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 Inventory Best Practices 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 # inventory/production/hosts.yml all: children: webservers: hosts: web1.example.com: web2.example.com: vars: http_port: 80 databases: hosts: db1.example.com: postgres_role: primary db2.example.com: postgres_role: replica loadbalancers: hosts: lb1.example.com: vars: ansible_user: deploy ansible_python_interpreter: /usr/bin/python3 Role Structure r β”œ β”‚ β”œ β”‚ β”œ β”‚ β”‚ β”‚ β”œ β”‚ β”œ β”‚ β”œ β”‚ β”œ β”‚ β”” o ─ ─ ─ ─ ─ ─ ─ ─ l ─ ─ ─ ─ ─ ─ ─ ─ e s d β”” v β”” t β”œ β”œ β”” h β”” t β”” f β”” m β”” m β”” / e ─ a ─ a ─ ─ ─ a ─ e ─ i ─ e ─ o ─ n f ─ r ─ s ─ ─ ─ n ─ m ─ l ─ t ─ l ─ g a s k d p e a e i u m / m s m i c l m l n s s / m c d n l a a / a n o e a a g / s a u e x t i i i s n r i t i l i l f / s n n n t f s n e n / n e a / . . . a i / . s x . / u y y y l g y / . y l m m m l u m c m t l l l . r l o l / y e n m . f l y . m j l 2 # # # # # # D R E S D T e o n e e e f l t r p s a e r v e t u y i n i l v c d n t a p e e g r o n v i i r c a a n e i r b t s e i l t s a e a b s r l t e ( s h h i a ( g n l h d o e l w r e e r s p s t r i p o r r i i o t r y i ) t y ) Task Patterns Always Name Tasks 1 2 3 4 5 6 7 8 9 10 # Bad - apt: name: nginx state: present # Good - name: Install nginx apt: name: nginx state: present Use Block for Related Tasks 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 - name: Configure SSL block: - name: Copy SSL certificate copy: src: "{{ ssl_cert }}" dest: /etc/ssl/certs/ mode: '0644' - name: Copy SSL private key copy: src: "{{ ssl_key }}" dest: /etc/ssl/private/ mode: '0600' - name: Enable SSL site file: src: /etc/nginx/sites-available/ssl.conf dest: /etc/nginx/sites-enabled/ssl.conf state: link notify: Reload nginx when: ssl_enabled | bool 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 - name: Deploy application block: - name: Pull latest code git: repo: "{{ app_repo }}" dest: "{{ app_path }}" version: "{{ app_version }}" register: git_result - name: Run migrations command: ./manage.py migrate args: chdir: "{{ app_path }}" when: git_result.changed rescue: - name: Rollback to previous version git: repo: "{{ app_repo }}" dest: "{{ app_path }}" version: "{{ previous_version }}" - name: Notify failure slack: token: "{{ slack_token }}" msg: "Deploy failed on {{ inventory_hostname }}" always: - name: Clean up temp files file: path: /tmp/deploy state: absent Variables Variable Precedence (use intentionally) 1 2 3 4 5 6 7 8 9 10 11 12 13 # defaults/main.yml - Easily overridden defaults nginx_worker_processes: auto nginx_worker_connections: 1024 # vars/main.yml - Role-specific constants nginx_user: www-data nginx_conf_path: /etc/nginx/nginx.conf # group_vars/webservers.yml - Group-specific nginx_worker_connections: 4096 # host_vars/web1.yml - Host-specific nginx_worker_processes: 4 Variable Validation 1 2 3 4 5 6 7 8 - name: Validate required variables assert: that: - app_version is defined - app_version | length > 0 - db_password is defined fail_msg: "Required variables are not set" success_msg: "All required variables present" Default Values 1 2 3 4 5 6 7 8 - name: Set configuration template: src: config.j2 dest: /etc/app/config.yml vars: max_connections: "{{ app_max_connections | default(100) }}" timeout: "{{ app_timeout | default(30) }}" debug: "{{ app_debug | default(false) | bool }}" Handlers 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 # handlers/main.yml - name: Reload nginx service: name: nginx state: reloaded listen: "reload web server" - name: Restart nginx service: name: nginx state: restarted listen: "restart web server" # In tasks - name: Update nginx config template: src: nginx.conf.j2 dest: /etc/nginx/nginx.conf notify: "reload web server" Flush Handlers When Needed 1 2 3 4 5 6 7 8 9 10 11 12 13 14 - name: Install nginx apt: name: nginx state: present notify: Start nginx - name: Flush handlers meta: flush_handlers - name: Configure nginx template: src: nginx.conf.j2 dest: /etc/nginx/nginx.conf # nginx is now guaranteed to be running Conditionals Clean Conditionals 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 # Use bool filter for string booleans - name: Enable debug mode template: src: debug.conf.j2 dest: /etc/app/debug.conf when: debug_mode | bool # Check if variable is defined and not empty - name: Set custom config copy: content: "{{ custom_config }}" dest: /etc/app/custom.conf when: - custom_config is defined - custom_config | length > 0 # Multiple conditions - name: Deploy to production include_tasks: deploy.yml when: - env == 'production' - deploy_enabled | bool - inventory_hostname in groups['webservers'] Loops Modern Loop Syntax 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 35 36 37 # Simple loop - name: Install packages apt: name: "{{ item }}" state: present loop: - nginx - python3 - htop # Better: Install all at once - name: Install packages apt: name: - nginx - python3 - htop state: present # Loop with index - name: Create users user: name: "{{ item.name }}" uid: "{{ 1000 + index }}" groups: "{{ item.groups }}" loop: "{{ users }}" loop_control: index_var: index label: "{{ item.name }}" # Cleaner output # Dict loop - name: Configure services template: src: "{{ item.key }}.conf.j2" dest: "/etc/{{ item.key }}/config.conf" loop: "{{ services | dict2items }}" when: item.value.enabled | bool Templates Jinja2 Best Practices 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 {# templates/nginx.conf.j2 #} # Managed by Ansible - DO NOT EDIT # Last updated: {{ ansible_date_time.iso8601 }} # Host: {{ inventory_hostname }} worker_processes {{ nginx_worker_processes }}; events { worker_connections {{ nginx_worker_connections }}; } http { {% for server in nginx_servers %} server { listen {{ server.port | default(80) }}; server_name {{ server.name }}; {% if server.ssl | default(false) %} ssl_certificate {{ server.ssl_cert }}; ssl_certificate_key {{ server.ssl_key }}; {% endif %} {% for location in server.locations | default([]) %} location {{ location.path }} { {{ location.directive }}; } {% endfor %} } {% endfor %} } Template Validation 1 2 3 4 5 6 - name: Generate nginx config template: src: nginx.conf.j2 dest: /etc/nginx/nginx.conf validate: nginx -t -c %s notify: Reload nginx Idempotency Patterns Check Mode Support 1 2 3 4 5 6 7 8 9 10 11 - name: Get current version command: cat /opt/app/VERSION register: current_version changed_when: false check_mode: false # Always run, even in check mode - name: Deploy new version unarchive: src: "app-{{ target_version }}.tar.gz" dest: /opt/app/ when: current_version.stdout != target_version Custom Changed Conditions 1 2 3 4 5 - name: Run database migration command: ./manage.py migrate --check register: migration_check changed_when: "'No migrations to apply' not in migration_check.stdout" failed_when: migration_check.rc not in [0, 1] Avoid Command When Possible 1 2 3 4 5 6 7 8 9 10 # Bad - not idempotent - name: Create directory command: mkdir -p /opt/app # Good - idempotent - name: Create directory file: path: /opt/app state: directory mode: '0755' Secrets Management Ansible Vault 1 2 3 4 5 6 7 8 9 10 11 # Create encrypted file ansible-vault create secrets.yml # Edit encrypted file ansible-vault edit secrets.yml # Use in playbook 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 8 # Encrypted variables file # group_vars/all/vault.yml vault_db_password: !vault | $ANSIBLE_VAULT;1.1;AES256 ... # Reference in playbook db_password: "{{ vault_db_password }}" No Secrets in Logs 1 2 3 4 5 - name: Set database password mysql_user: name: app password: "{{ db_password }}" no_log: true Performance Gather Facts Selectively 1 2 3 4 5 6 7 8 9 10 11 - hosts: webservers gather_facts: false tasks: - name: Quick task without facts ping: # Or gather specific facts - hosts: webservers gather_subset: - network - hardware Async for Long Tasks 1 2 3 4 5 6 7 8 9 10 11 12 13 - name: Run long backup command: /opt/scripts/backup.sh async: 3600 # 1 hour timeout poll: 0 # Don't wait register: backup_job - name: Check backup status async_status: jid: "{{ backup_job.ansible_job_id }}" register: job_result until: job_result.finished retries: 60 delay: 60 Limit Concurrent Execution 1 2 3 4 - hosts: webservers serial: 2 # Two hosts at a time # Or percentage: serial: "25%" # Or batches: serial: [1, 5, 10] Testing with Molecule 1 2 3 4 5 6 7 8 9 10 11 12 13 # molecule/default/molecule.yml dependency: name: galaxy driver: name: docker platforms: - name: instance image: ubuntu:22.04 pre_build_image: true provisioner: name: ansible verifier: name: ansible 1 2 3 4 5 6 7 8 9 10 11 # molecule/default/verify.yml - name: Verify hosts: all tasks: - name: Check nginx is running service: name: nginx state: started check_mode: true register: result failed_when: result.changed 1 2 # Run tests molecule test Good Ansible is boring Ansible. No surprises, no side effects, same result every time. When your playbooks are truly idempotent, running them becomes a confidence-builder rather than a risk. ...

February 25, 2026 Β· 10 min Β· 2060 words Β· Rob Washington

Ansible Playbook Patterns: Writing Maintainable Automation

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: ...

February 24, 2026 Β· 8 min Β· 1621 words Β· Rob Washington

Ansible Playbook Patterns: Writing Automation That Doesn't Break

Ansible’s simplicity is seductive. YAML tasks, SSH connections, no agents. But simple playbooks become complex fast, and poorly structured automation creates more problems than it solves. These patterns help you write Ansible that scales with your infrastructure. Idempotency: Safe to Run Twice Every task should be safe to run repeatedly with the same result: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 # Idempotent - creates file if missing, no-op if exists - name: Create config directory file: path: /etc/myapp state: directory mode: '0755' # Not idempotent - appends every run - name: Add config line shell: echo "setting=value" >> /etc/myapp/config # Idempotent version - name: Add config line lineinfile: path: /etc/myapp/config line: "setting=value" Use Ansible modules over shell commands. Modules are designed for idempotency. ...

February 23, 2026 Β· 6 min Β· 1235 words Β· Rob Washington

Ansible Idempotency Patterns: Write Playbooks That Don't Break Things

The promise of Ansible is simple: describe your desired state, run the playbook, and the system converges to that state. Run it again, nothing changes. That’s idempotencyβ€”and it’s harder to achieve than it sounds. Here’s how to write playbooks that won’t surprise you on the second run. The Problem: Commands That Lie The command and shell modules are where idempotency goes to die: 1 2 3 # ❌ BAD: Always reports "changed", even when nothing changed - name: Create database command: createdb myapp This fails on the second run because the database already exists. Worse, it always shows as β€œchanged” even when it shouldn’t run at all. ...

February 21, 2026 Β· 5 min Β· 998 words Β· Rob Washington

Ansible Role Design Patterns: Building Reusable Automation

Ansible playbooks get messy fast. Roles fix this by packaging related tasks, variables, and handlers into reusable units. But bad roles are worse than no roles β€” they hide complexity without managing it. Here’s how to design roles that actually help. The Basic Structure r o l m e y s _ t h d v t f m / r a a e a e i e o s m n m f m r m m c l s t m l k a d a a a s a p o e c a a e s i l i u i / i l n s r / i / / n e n l n n a f / i n . r . t . . t i p . y s y s y y e g t y m / m / m m s . . m l l l l j s l 2 h # # # # # # # E E D R J S R n v e o i t o t e f l n a l r n a e j t e y t u a i - l v 2 c m p t t a e o r r t f t i i v i e i a n g a a m l d t g r b p e a e i l l s t r a e a a e b s t d l e a e ( s n t s h d a i s ( g d k l h e s o e p w r e e n s p d t r e e n p c c r e i e d e c e s e n d c e e n ) c e ) Only create directories you need. An empty handlers/ is noise. ...

February 19, 2026 Β· 8 min Β· 1576 words Β· Rob Washington

Ansible Playbook Patterns: Writing Maintainable Infrastructure Code

Ansible playbooks can quickly become unwieldy spaghetti. Here are battle-tested patterns for writing infrastructure code that scales with your team and your infrastructure. The Role Structure That Actually Works Forget the minimal examples. Real roles need this structure: r β”” o ─ l ─ e s w β”œ β”‚ β”œ β”‚ β”œ β”‚ β”‚ β”‚ β”‚ β”œ β”‚ β”œ β”‚ β”œ β”‚ β”” / e ─ ─ ─ ─ ─ ─ ─ b ─ ─ ─ ─ ─ ─ ─ s e d β”” v β”” t β”œ β”œ β”œ β”” h β”” t β”” f β”” m β”” r e ─ a ─ a ─ ─ ─ ─ a ─ e ─ i ─ e ─ v f ─ r ─ s ─ ─ ─ ─ n ─ m ─ l ─ t ─ e a s k d p e a r u m / m s m i c s l m l n s s / m / l a a / a n o e e a a g / s a t i i i s n r r i t i l i s n n n t f v s n e n - n / . . . a i i / . s x p . y y y l g c y / . a y m m m l u e m c r m l l l . r . l o a l y e y n m m . m f s l y l . . m j c l 2 o n # # # # # # # f # D R E P C S R D e o n a o e e e f l t c n r s p a e r k f v t e u y a i i a n l v g g c r d t a p e u e t e r o r / n v i i i a m r c a a n n t a e i r b t s i n l e i l t o a o s a e - a n g a b s l e d l j l f m e ( u a i e h s h s t l n a i t i e t n ( g o s d l h i n l o e n e w r c r e l s s p u t r d e e p c s r e e d c e e n d c e e n ) c e ) The key insight: tasks/main.yml should only contain includes: ...

February 17, 2026 Β· 7 min Β· 1328 words Β· Rob Washington

Ansible: Configuration Management Without the Headache

Why Ansible became my go-to tool for managing servers, and how you can start using it today.

February 10, 2026 Β· 4 min Β· 810 words Β· Rob Washington