Control Flow: Conditionals, Loops, Handlers & Asynchronous Execution

Master playbook control flow: conditional when expressions, Jinja2 tests, loops, delayed execution Handlers, async execution & polling, and serial rolling updates.

intermediate 25 min lesson hands-on task included

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:

  1. Delayed Execution: Handlers do NOT run immediately when notified. They defer execution until the very end of the Play!
  2. 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.
  3. 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.