Ansible playbooks execute tasks sequentially top-to-bottom by default. However, real-world production automation requires sophisticated control flow: conditional execution, iterative loops, delayed service notifications, and asynchronous background execution.
Topic 1: Conditionals (when:) & Jinja2 Tests
The when: directive evaluates boolean expressions. If the expression evaluates to true, Ansible executes the task; if false, Ansible skips the task (skipping: [host]):
# 1. OS-Specific Task Execution
- name: Install Apache on Debian/Ubuntu systems
ansible.builtin.apt:
name: nginx
state: present
when: ansible_facts['os_family'] == "Debian"
- name: Install Apache on RHEL/CentOS systems
ansible.builtin.dnf:
name: httpd
state: present
when: ansible_facts['os_family'] == "RedHat"
# 2. Multiple Conditions (Evaluated as Logical AND)
- name: Apply production security hardening
ansible.builtin.include_tasks: production_hardening.yml
when:
- environment_tier == "production"
- ansible_facts['virtualization_role'] == "guest"
- enable_hardening | default(true)
Essential Jinja2 Tests for Conditionals:
when: my_var is defined: Checks if variable is declared.when: my_var is undefined: Checks if variable is missing.when: result is failed: Checks if a previous registered task failed.when: result is succeeded: Checks if a previous registered task succeeded.when: result is changed: Checks if a previous registered task modified system state.
Topic 2: Iteration & Loops (loop & with_items)
Instead of writing repetitive tasks for multiple items, use loop:
1. Simple List Loop
- name: Create multiple application log directories
ansible.builtin.file:
path: "{{ item }}"
state: directory
owner: appuser
group: www-data
mode: "0755"
loop:
- /var/log/myapp/api
- /var/log/myapp/celery
- /var/log/myapp/nginx
2. Dictionary Loop (Accessing Key-Value Attributes)
- name: Provision system user accounts with custom shells
ansible.builtin.user:
name: "{{ item.username }}"
uid: "{{ item.uid }}"
shell: "{{ item.shell }}"
state: present
loop:
- { username: 'alice', uid: 2001, shell: '/bin/bash' }
- { username: 'bob', uid: 2002, shell: '/bin/zsh' }
- { username: 'carol', uid: 2003, shell: '/bin/bash' }
loop_control:
label: "{{ item.username }}" # Clean logging: hides full dictionary in terminal output!
Topic 3: Handlers & Notification Triggers (notify)
Handlers are special tasks that execute ONLY when notified by another task that reported a changed state.
Rules of Handlers:
- Delayed Execution: Handlers do NOT run immediately when notified. They defer execution until the very end of the Play!
- Deduplication: If 10 tasks modify different config files and all notify
notify: Restart Nginx, Nginx is restarted EXACTLY ONCE at the end of the play. - Flushing Handlers: To force handlers to run immediately before proceeding to downstream tasks, use
ansible.builtin.meta: flush_handlers.
tasks:
- name: Deploy Nginx core configuration
ansible.builtin.template:
src: templates/nginx.conf.j2
dest: /etc/nginx/nginx.conf
notify: Restart Nginx Service # Notifies handler ONLY if file changed!
- name: Deploy SSL certificate file
ansible.builtin.copy:
src: files/cert.pem
dest: /etc/ssl/certs/cert.pem
notify: Restart Nginx Service # Notifies same handler
- name: Force handlers to run immediately before running health check
ansible.builtin.meta: flush_handlers
- name: Verify Web Service is responding
ansible.builtin.uri:
url: http://localhost/healthz
status_code: 200
handlers:
- name: Restart Nginx Service
ansible.builtin.service:
name: nginx
state: restarted
Topic 4: Asynchronous Execution & Polling (async: & poll:)
By default, Ansible blocks and waits for each task to complete before moving to the next. For long-running tasks (e.g., performing a 30-minute database backup or updating kernel images), blocking the SSH connection is inefficient.
async: 3600: Maximum time allowed for task (in seconds).poll: 10: How frequently Ansible polls the target node for task completion (default is 10s).poll: 0: Fire and Forget! Launches the task in the background and immediately moves to the next task without waiting.
# Fire-and-Forget Asynchronous Background Task
- name: Launch long database migration script in background
ansible.builtin.command: /opt/db/migrate_large_tables.sh
async: 3600 # Allow up to 1 hour
poll: 0 # Do NOT wait; return immediately!
register: async_job
- name: Perform independent tasks while migration runs...
ansible.builtin.package:
name: htop
state: present
- name: Check status of background database migration
ansible.builtin.async_status:
jid: "{{ async_job.ansible_job_id }}"
register: job_result
until: job_result.finished
retries: 60
delay: 10 # Poll job_id every 10s up to 60 times
Topic 5: Zero-Downtime Rolling Updates (serial:)
By default, Ansible processes all target hosts in parallel. If a bad code update crashes the web service, every web server in your cluster goes down simultaneously!
To perform a zero-downtime rolling update, use serial: to process hosts in small batches:
- name: Zero-Downtime Rolling Application Upgrade
hosts: webservers
become: true
serial: 2 # Process exactly 2 servers at a time
max_fail_percentage: 25% # Abort entire play if >25% of hosts fail!
tasks:
- name: Remove host from load balancer pool
# ...
- name: Deploy application update
# ...
- name: Re-add host to load balancer pool
# ...
Common mistake: Assuming a handler always runs. Handlers fire only at the end of the play, only if a task reported changed, and not at all if a later task fails first — so a config change followed by an unrelated failure leaves the new file on disk with the old process still running. Use meta: flush_handlers when the restart must happen before the next step.