Module 03 — Playbook Control Flow

Updated 20 August 2026

Module 03 · Playbook Control Flow

Conditionals, loops, error handling, tags, imports and delegation — everything that turns a flat list of tasks into a program that reacts to what it finds.

🧠 concept → 🧪 exercise → ✅ expected result (hidden) → 🎯 interview questions (answers hidden)

Prerequisite: Modules 01 and 02. You already use when: and register — this module covers the full toolkit built on top of them.


Part A · Conditionals

A1 · when: — the basics

Official docs: Conditionals · Tests
The analogy. Think of telling the whole family "take an umbrella, but only if it is raining". The instruction is identical for everyone — but each person looks out of their own window before deciding, and the cousin in another city quite reasonably leaves the umbrella at home. Nobody disobeyed you. They followed the instruction correctly and the answer was no.

That is when: — one condition, written once, but judged separately on every single server. Which is why skipping: in the output means the decision worked, not that something failed. Reading a wall of yellow skipping lines as a problem is one of the most common beginner mistakes.

when: decides whether a task runs. It is evaluated per host, so the same task can run on web01 and skip on web02 in the same play.

yaml
- name: Install Apache on RedHat family only
  ansible.builtin.package:
    name: httpd
    state: present
  when: ansible_facts['os_family'] == "RedHat"
when: is already a Jinja2 expression — do NOT wrap it in {{ }}.

The braces are redundant, ansible-lint flags them, and on complex expressions they can actively break evaluation. This is the single most common style error in beginner playbooks.

Operators

yaml
when: app_port == 8080            # equality
when: app_port != 8080
when: app_port > 1024             # numeric comparison
when: ansible_facts['memtotal_mb'] >= 4096
when: "'nginx' in installed_packages"    # membership - note the quoting
when: "'web' in group_names"             # is this host in a group?
when: app_version is version('2.0', '>=')  # proper version comparison

Combining conditions

yaml
# A LIST is an implicit AND - all must be true. Preferred: it reads better in output.
when:
  - ansible_facts['os_family'] == "Debian"
  - ansible_facts['distribution_major_version'] is version('20', '>=')
  - deploy_enabled | bool

# Explicit and / or / not
when: ansible_facts['os_family'] == "Debian" and deploy_enabled | bool
when: env == "prod" or env == "staging"
when: not maintenance_mode | bool

# Parentheses when mixing and with or - and you MUST, precedence bites otherwise
when: (env == "prod" or env == "staging") and deploy_enabled | bool
and binds tighter than or. So a or b and c means a or (b and c), which is very often not what was intended. When you mix them, parenthesise — even where it is technically unnecessary, because the next person to read it will not do the precedence maths in their head.

Tests — the is family

yaml
when: myvar is defined
when: myvar is not defined
when: myvar is none                       # explicitly null
when: result is succeeded                 # on a registered result
when: result is failed
when: result is changed
when: result is skipped
when: app_version is version('2.0', '>=')
when: path_result.stat.exists
when: some_list | length > 0
when: "'ERROR' in log_output.stdout"
🧪 Exercise A1.1 — Watch a conditional evaluate per host
yaml
---
- name: Conditionals are per-host
  hosts: all
  gather_facts: true
  tasks:
    - name: Only on Debian family
      ansible.builtin.debug:
        msg: "{{ inventory_hostname }} is Debian family"
      when: ansible_facts['os_family'] == "Debian"

    - name: Only on hosts with 4GB or more
      ansible.builtin.debug:
        msg: "{{ inventory_hostname }} has {{ ansible_facts['memtotal_mb'] }} MB"
      when: ansible_facts['memtotal_mb'] >= 4096

    - name: Only on hosts in the web group
      ansible.builtin.debug:
        msg: "{{ inventory_hostname }} is a web server"
      when: "'web' in group_names"
Expected result — click to reveal
plain text
TASK [Only on Debian family] ***************************************
ok:       [web01] => {"msg": "web01 is Debian family"}
ok:       [web02] => {"msg": "web02 is Debian family"}
skipping: [db01]                                   <-- RedHat family

TASK [Only on hosts with 4GB or more] ******************************
ok:       [web01] => {"msg": "web01 has 7936 MB"}
skipping: [web02]                                  <-- only 2048 MB
ok:       [db01] => {"msg": "db01 has 16384 MB"}

TASK [Only on hosts in the web group] ******************************
ok:       [web01] => {"msg": "web01 is a web server"}
ok:       [web02] => {"msg": "web02 is a web server"}
skipping: [db01]

PLAY RECAP *********************************************************
web01 : ok=4  changed=0  skipped=0
web02 : ok=3  changed=0  skipped=1
db01  : ok=2  changed=0  skipped=2

Look at the recap — three different outcomes from one playbook. Each host evaluated each condition against its own facts and group membership. That is what "conditionals are per-host" means concretely, and it is why a single playbook can serve a heterogeneous fleet.

skipping: is not a failure. A skipped task is a successful decision not to act. Interviewers sometimes probe this: skipped counts in the recap separately from failed precisely because skipping is normal, expected behaviour.

🧪 Exercise A1.2 — Break the operator-precedence rule
yaml
---
- name: Precedence trap
  hosts: localhost
  gather_facts: false
  vars:
    env: "dev"
    is_prod: false
    force_deploy: true
  tasks:
    - name: WITHOUT parentheses - this runs, and it should not
      ansible.builtin.debug:
        msg: "DEPLOYING - no parentheses"
      when: env == "prod" or env == "staging" and force_deploy | bool

    - name: WITH parentheses - correctly skipped
      ansible.builtin.debug:
        msg: "DEPLOYING - with parentheses"
      when: (env == "prod" or env == "staging") and force_deploy | bool
Expected result — click to reveal
plain text
TASK [WITHOUT parentheses - this runs, and it should not] **********
skipping: [localhost]

TASK [WITH parentheses - correctly skipped] ************************
skipping: [localhost]

Both skipped here — now change env to "staging" and run it again:

plain text
TASK [WITHOUT parentheses] ***   ok: [localhost] => {"msg": "DEPLOYING - no parentheses"}
TASK [WITH parentheses] ******   ok: [localhost] => {"msg": "DEPLOYING - with parentheses"}

Still the same. Now set force_deploy: false and env: "prod":

plain text
TASK [WITHOUT parentheses] ***   ok: [localhost]      <-- RUNS despite force_deploy being false
TASK [WITH parentheses] ******   skipping: [localhost]  <-- correctly blocked

There it is. Without parentheses the expression parses as env == "prod" OR (env == "staging" AND force_deploy). Because env is prod, the first branch is true and the whole expression short-circuits to true — force_deploy is never even consulted.

Why this is genuinely dangerous: the safety flag you added to prevent accidental production deploys does nothing, and it does nothing only in production, which is the one case you were guarding. The bug is invisible in dev and staging testing. Parenthesise whenever you mix and with or.

🎯 Interview questions — Conditionals

Q. How do conditionals work in Ansible?

when: on a task, evaluated as a Jinja2 expression without {{ }} braces, per host. If it evaluates false the task is skipped for that host only, and skipped is reported separately from failed in the recap.

Conditions can be combined as a YAML list, which is an implicit AND and reads more clearly, or with explicit and / or / not.

Q. Why should you not write when: "{{ myvar }}"?

when: is already evaluated as a Jinja2 expression, so the braces are redundant. ansible-lint flags it, and on more complex expressions the double evaluation can break — particularly with nested quoting or filters.

The exception people cite is embedding a variable inside a string comparison, and even there the correct form is when: myvar == "value", not braces.

Q. How do you check whether a host belongs to a group inside a task?

when: "'web' in group_names"group_names is a magic variable listing the groups the current host belongs to.

For the reverse direction, checking whether a named host is in a group, use when: "'web01' in groups['web']".

Note the outer quoting: the expression contains single quotes, so the whole thing is wrapped in double quotes to keep YAML happy.

Q. How do you compare version numbers correctly?

The version test: when: app_version is version('2.0', '>=').

String comparison is wrong because "10.0" < "9.0" lexically, and float comparison is wrong because 1.10 becomes 1.1. The version test understands semantic ordering and also supports strict=True for strict semver.

Q. What is the operator-precedence trap in when:?

and binds tighter than or, so a or b and c parses as a or (b and c).

The realistic failure: when: env == "prod" or env == "staging" and force_deploy — in production the first branch is true, the expression short-circuits, and the force_deploy safety flag is never evaluated. The guard silently does nothing, and only in the environment it was protecting.

Always parenthesise when mixing.


Part B · Loops

B1 · loop — the basics

The analogy. Think of watering the plants. You do not write a separate instruction for each pot — you say it once, "water every plant in the house", and it covers all of them. But there is a second everyday habit worth borrowing here: if you need bread, milk and eggs, you do not make three separate trips to the shop. You write one list and go once.

loop is the first habit — and knowing when not to loop is the second. Some modules accept a whole list in one go, so looping over them means three trips to the shop where one would have done. That is why package: name: [nginx, git, htop] beats a loop of three package tasks, and why an interviewer asking "how would you install ten packages?" is often testing exactly this.

Official docs: Loops · loop_control
yaml
- name: Install several packages
  ansible.builtin.package:
    name: "{{ item }}"
    state: present
  loop:
    - nginx
    - git
    - htop

item is the loop variable, provided automatically on each iteration.

But for packages specifically, do not loop. package, yum, apt and dnf all accept a list directly:
yaml
- ansible.builtin.package:
    name: [nginx, git, htop]      # ONE transaction, one round trip
    state: present

Looping runs the package manager three separate times — three SSH round trips, three transactions, roughly three times slower. Ansible will even warn you about it.

The general principle: check whether the module takes a list before reaching for loop. This is a favourite interview follow-up because it separates people who write playbooks from people who tune them.

Looping over a list of dictionaries

yaml
- name: Create application users
  ansible.builtin.user:
    name: "{{ item.name }}"
    uid: "{{ item.uid }}"
    groups: "{{ item.groups | default(omit) }}"
    state: present
  loop:
    - { name: deploy, uid: 2001, groups: "docker" }
    - { name: monitor, uid: 2002 }
    - { name: backup,  uid: 2003 }

Looping over a dictionary — dict2items

You cannot loop a dictionary directly. Convert it first:

yaml
vars:
  app_ports:
    web: 8080
    api: 9090
    admin: 7070

tasks:
  - name: Open each port
    ansible.builtin.debug:
      msg: "{{ item.key }} listens on {{ item.value }}"
    loop: "{{ app_ports | dict2items }}"

Useful loop sources

yaml
loop: "{{ groups['web'] }}"                      # every host in a group
loop: "{{ ansible_facts['mounts'] }}"            # a list from facts
loop: "{{ range(1, 6) | list }}"                 # 1,2,3,4,5
loop: "{{ query('fileglob', '/etc/nginx/conf.d/*.conf') }}"
loop: "{{ list_a | zip(list_b) | list }}"        # pair two lists
loop: "{{ list_a | product(list_b) | list }}"    # every combination
loop: "{{ big_list | unique | sort }}"

B2 · loop_control — making loops readable and usable

The analogy. Think of a teacher taking the register. They read out "Ahmed". They do not read out "Ahmed, born 12 March, lives at 42 Green Road, medical note on file, mother's mobile number". With thirty children, the second version would take the whole lesson — and if the record contains something private, the teacher has just read it aloud to the entire room.

loop_control: label: is reading out only the name. Without it, Ansible prints the whole record for every item it loops over — which is both unreadable at thirty items and, if your list contains passwords or API keys, a genuine security problem in the job log.

yaml
- name: Create users
  ansible.builtin.user:
    name: "{{ item.name }}"
    uid: "{{ item.uid }}"
  loop: "{{ app_users }}"
  loop_control:
    label: "{{ item.name }}"        # show only the name in output, not the whole dict
    index_var: idx                  # idx = 0, 1, 2...
    pause: 2                        # seconds between iterations
    loop_var: user                  # rename 'item' - REQUIRED for nested loops
label: is the one to remember. Without it, a loop over a list of dictionaries prints the entire dictionary on every iteration — including any password or token in it. With hundreds of items the output becomes unreadable, and with secrets in it the output becomes a security problem.

loop_var: matters for nesting. An inner loop's item would shadow the outer one, so include_tasks with a loop inside a loop needs the outer renamed.

🧪 Exercise B2.1 — See what label actually saves you
yaml
---
- name: loop_control label
  hosts: localhost
  gather_facts: false
  vars:
    db_users:
      - { name: app_rw, password: "S3cr3t-App", privs: "ALL" }
      - { name: app_ro, password: "S3cr3t-RO",  privs: "SELECT" }
  tasks:
    - name: WITHOUT label - look at the output carefully
      ansible.builtin.debug:
        msg: "Creating {{ item.name }}"
      loop: "{{ db_users }}"

    - name: WITH label
      ansible.builtin.debug:
        msg: "Creating {{ item.name }}"
      loop: "{{ db_users }}"
      loop_control:
        label: "{{ item.name }}"
Expected result — click to reveal
plain text
TASK [WITHOUT label - look at the output carefully] ****************
ok: [localhost] => (item={'name': 'app_rw', 'password': 'S3cr3t-App', 'privs': 'ALL'}) => {
    "msg": "Creating app_rw"
}
ok: [localhost] => (item={'name': 'app_ro', 'password': 'S3cr3t-RO', 'privs': 'SELECT'}) => {
    "msg": "Creating app_ro"
}

TASK [WITH label] **************************************************
ok: [localhost] => (item=app_rw) => {
    "msg": "Creating app_rw"
}
ok: [localhost] => (item=app_ro) => {
    "msg": "Creating app_ro"
}

The passwords are printed in plain text in the first task. Not in the msg — in the item echo that Ansible adds automatically. They are now in your terminal scrollback, in your CI job log, and in any log aggregation shipping that output.

loop_control: label: fixes it, and no_log: true on the task suppresses output entirely. For any loop over data containing secrets, you need at least one of them — and this is the concrete reason interviewers ask about no_log.

Readability is the secondary benefit and still a real one: a loop over 200 dictionaries is unusable output without a label.

B3 · until — retrying until something is true

The analogy. Think of knocking on a door. No answer, so you wait a few seconds and knock again. You do that a handful of times, and eventually you accept that nobody is home and leave. You were not doing anything different each time — it was the same knock, repeated, until either the door opened or your patience ran out.

That is until with retries and delay: the same task, tried again on a timer, until the condition comes true. And when the patience runs out the task fails, which is exactly what you want from a health check — a service that never came up should not be reported as fine.

yaml
- name: Wait for the application to become healthy
  ansible.builtin.uri:
    url: "http://{{ inventory_hostname }}:8080/health"
    status_code: 200
  register: health
  until: health.status == 200
  retries: 12
  delay: 5              # 12 attempts, 5s apart = up to 60 seconds
  changed_when: false
until is a retry loop, not an iteration loop. It repeats the same task until the condition is met or retries is exhausted. Default retries is 3 and default delay is 5 seconds.

When it finally gives up, the task fails — which is usually what you want, because a health check that never passes should stop the deployment.

Related but different: the wait_for module blocks until a port or file condition is met, and wait_for_connection waits for the host itself to come back — which is what you use after a reboot.

B4 · register with a loop

A registered variable from a looped task is not a single result — it contains a results list, one entry per iteration.

yaml
- name: Check several services
  ansible.builtin.command: "systemctl is-active {{ item }}"
  loop: [nginx, sshd, cron]
  register: svc
  changed_when: false
  failed_when: false

- name: Report the dead ones
  ansible.builtin.debug:
    msg: "{{ item.item }} is NOT running"
  loop: "{{ svc.results }}"
  loop_control:
    label: "{{ item.item }}"
  when: item.rc != 0
Note item.item. Inside the second loop, item is one result object from the first loop — and its .item key holds the original value that produced it. It reads oddly and it is exactly what interviewers ask about when they want to know whether you have actually done this.

B5 · with_* — the legacy syntax

yaml
with_items: "{{ mylist }}"      # -> loop: "{{ mylist }}"
with_dict: "{{ mydict }}"       # -> loop: "{{ mydict | dict2items }}"
with_fileglob: "/etc/*.conf"    # -> loop: "{{ query('fileglob', '/etc/*.conf') }}"
with_nested: [ [a,b], [1,2] ]   # -> loop: "{{ a | product(b) | list }}"
with_together: [ a, b ]         # -> loop: "{{ a | zip(b) | list }}"
with_* still works and is not deprecated-removed, but loop has been the recommended form since Ansible 2.5. The difference in behaviour worth knowing: with_items flattens nested lists by one level; loop does not. So migrating with_items to loop on a list of lists changes behaviour — add | flatten(1) if you were relying on it.

You must be able to read with_* because it is everywhere in existing code, and you should write loop in new code.

🎯 Interview questions — Loops

Q. What is the difference between loop and with_items?

loop is the modern syntax, recommended since 2.5. with_* is the older family, still functional and everywhere in existing code.

The behavioural difference that matters: with_items flattens one level of nesting, loop does not. Migrating a list-of-lists from with_items to loop changes what the task does unless you add | flatten(1).

The with_* forms that were not simple lists — with_dict, with_fileglob, with_nested — become loop plus a filter or a query().

Q. Why is looping over a package list a bad idea?

Because the package modules accept a list natively. name: [nginx, git, htop] is one transaction and one round trip; looping is three of each, roughly three times slower, and Ansible warns about it.

The general rule: check whether the module already takes a list before reaching for loop. The same applies to user, file and several others in specific cases.

Q. You registered a looped task. What does the variable contain?

Not a single result — an object with a results list, one entry per iteration, each being a full result object. Each entry also carries .item, the original loop value that produced it.

So iterating the results reads as loop: "{{ svc.results }}" with item.item for the original value and item.rc for that iteration's exit code.

Q. What is loop_control and why does label matter?

It configures loop behaviour: label controls what is printed per iteration, index_var exposes the counter, pause inserts a delay, and loop_var renames item — which is required for nested loops, since an inner loop would otherwise shadow the outer one.

label matters for two reasons: without it, a loop over dictionaries prints the entire dictionary, including any password in it, into your terminal and CI logs. And with hundreds of items the output is unreadable. For secrets, combine it with no_log: true.

Q. How do you retry a task until a condition is met?

until: with retries: and delay: — it re-runs the same task until the condition is true or the retries are exhausted, at which point the task fails.

Defaults are 3 retries and 5 seconds. It pairs with register because the condition normally tests the registered result.

Related modules worth naming: wait_for for a port or file condition, and wait_for_connection for waiting on a host to come back after a reboot.


Part C · Error handling and recovery

C1 · Controlling failure and change

The analogy. Think of a smoke alarm that goes off every time you make toast. It is worse than useless, because sooner or later somebody takes the battery out to get some peace — and an alarm with no battery cannot warn anyone about a real fire.

ignore_errors: true is taking the battery out. failed_when: is adjusting the sensitivity so it ignores toast but still screams at smoke. One removes the warning entirely; the other makes it accurate. If you take one habit from this section, make it reaching for failed_when: first and ignore_errors: only when you genuinely do not care about the outcome.

By default a failed task stops the play for that host. Other hosts continue. Four keywords change that.

KeywordEffect
ignore_errors: trueTask can fail; the play continues. The task is still reported as failed
failed_when:Redefine what failure means for this task
changed_when:Redefine what "changed" means — from Module 01
ignore_unreachable: trueContinue even if the host is unreachable, which ignore_errors does not cover
yaml
# ignore_errors - blunt instrument, use sparingly
- name: Try to stop a service that may not exist
  ansible.builtin.service:
    name: legacy-app
    state: stopped
  ignore_errors: true

# failed_when - precise, and almost always better
- name: Run a health check
  ansible.builtin.command: /usr/local/bin/healthcheck
  register: health
  changed_when: false
  failed_when: health.rc not in [0, 2]      # rc=2 means "degraded", acceptable here

# failed_when: false  - "never fail" - clearer intent than ignore_errors
- name: Probe an optional endpoint
  ansible.builtin.uri:
    url: http://localhost:9000/metrics
  register: metrics
  failed_when: false
ignore_errors vs failed_when: false — both let the play continue, but ignore_errors still prints a red fatal: line marked ...ignoring, which trains people to ignore red text in CI logs. failed_when: false says "this task cannot fail" and reports it as ok.

Prefer failed_when: with a real condition. Reach for ignore_errors only when you genuinely cannot predict the failure modes.

And note: neither covers an unreachable host — that needs ignore_unreachable: true.

fail and assert — failing on purpose

yaml
- name: Refuse to run against production without approval
  ansible.builtin.fail:
    msg: "Deploying to prod requires -e approved=true"
  when: env == "prod" and not (approved | default(false) | bool)

- name: Validate inputs before doing anything destructive
  ansible.builtin.assert:
    that:
      - app_version is defined
      - app_version is version('2.0', '>=')
      - target_dir is defined
    fail_msg: "app_version must be >= 2.0 and target_dir must be set"
    success_msg: "Inputs validated"
assert over fail where you can. assert states the conditions that must hold, positively, and reports which one failed. fail with a when: inverts the logic and you end up reading a negated condition to work out what was required.

Put an assert block at the top of any playbook that takes parameters. Failing in two seconds with a clear message beats failing four minutes in with a half-written config file.

C2 · block / rescue / always

The analogy. Think of an operation in a hospital. There is the procedure you intend to carry out. There is what happens if it goes wrong — stop, repair the damage, stabilise the patient. And then there is closing the wound and clearing up, which happens either way, because leaving someone open on the table is never an acceptable outcome no matter how the surgery went.

block is the procedure, rescue is the emergency response, and always is closing up. That last one is why re-enabling a server in the load balancer belongs in always and nowhere else. And here is the trap worth remembering: if the rescue only writes "recovered" in the notes without actually fixing anything, the paperwork says success while the patient is not fine. A rescue that cannot genuinely recover must still end by reporting failure.

Ansible's try/catch/finally.

Diagram source
flowchart TD
    A["block:<br>the tasks you want to run"] --> B{"Did any task fail?"}
    B -->|"No"| C["rescue: is SKIPPED"]
    B -->|"Yes"| D["Stop the block immediately<br>run rescue:"]
    D --> E{"Did rescue succeed?"}
    E -->|"Yes"| F["Host is marked OK again<br>play continues"]
    E -->|"No"| G["Host fails for real"]
    C --> H["always: runs"]
    F --> H
    G --> H
    H --> I["always: ALWAYS runs<br>success, failure, or rescue"]
    style D fill:#FEE2E2,stroke:#DC2626
    style F fill:#D1FAE5,stroke:#059669,stroke-width:2px
    style I fill:#FEF3C7,stroke:#D97706,stroke-width:2px
yaml
- name: Deploy with rollback
  block:
    - name: Take the node out of the load balancer
      ansible.builtin.uri:
        url: "http://lb.internal/drain/{{ inventory_hostname }}"
        method: POST

    - name: Deploy the new release
      ansible.builtin.unarchive:
        src: "app-{{ app_version }}.tar.gz"
        dest: /opt/app/current
        remote_src: false

    - name: Verify it is healthy
      ansible.builtin.uri:
        url: "http://{{ inventory_hostname }}:8080/health"
        status_code: 200
      retries: 6
      delay: 5

  rescue:
    - name: Roll back to the previous release
      ansible.builtin.command: /opt/app/rollback.sh
      
    - name: Alert the team
      ansible.builtin.debug:
        msg: "Deployment failed on {{ inventory_hostname }} and was rolled back"

  always:
    - name: Put the node back in the load balancer whatever happened
      ansible.builtin.uri:
        url: "http://lb.internal/enable/{{ inventory_hostname }}"
        method: POST
The single most important property, and the most commonly missed: if rescue completes successfully, the host is no longer considered failed. The play carries on as though nothing went wrong, and the recap shows failed=0.

That is exactly right for a genuine recovery — you rolled back, the system is consistent, carry on. It is exactly wrong if your rescue only logs the error, because you have now silently swallowed a failure and CI reports green.

If the rescue does not truly fix the problem, end it with a fail task to re-raise.

The variables rescue gives you

yaml
rescue:
  - ansible.builtin.debug:
      msg: |
        Failed task: {{ ansible_failed_task.name }}
        Error: {{ ansible_failed_result.msg | default('no message') }}
🧪 Exercise C2.1 — Prove that a successful rescue clears the failure
yaml
---
- name: block / rescue / always
  hosts: localhost
  gather_facts: false
  tasks:
    - name: Deployment with recovery
      block:
        - name: This will fail
          ansible.builtin.command: /bin/false

        - name: Never reached
          ansible.builtin.debug:
            msg: "you will not see this"

      rescue:
        - name: Recovery ran
          ansible.builtin.debug:
            msg: "Rescued. Failed task was: {{ ansible_failed_task.name }}"

      always:
        - name: Cleanup always runs
          ansible.builtin.debug:
            msg: "always block"

    - name: The play continues normally
      ansible.builtin.debug:
        msg: "still running after the failure"
Expected result — click to reveal
plain text
TASK [This will fail] **********************************************
fatal: [localhost]: FAILED! => {"changed": true, "cmd": "/bin/false", "rc": 1}

TASK [Recovery ran] ************************************************
ok: [localhost] => {
    "msg": "Rescued. Failed task was: This will fail"
}

TASK [Cleanup always runs] *****************************************
ok: [localhost] => {"msg": "always block"}

TASK [The play continues normally] *********************************
ok: [localhost] => {"msg": "still running after the failure"}

PLAY RECAP *********************************************************
localhost : ok=3  changed=1  unreachable=0  failed=0   rescued=1  ignored=0

Read the recap: failed=0 and rescued=1. Despite a red fatal: line in the output, the play succeeded and would exit zero in CI.

Note also that "Never reached" did not run. A block aborts at the first failure — the remaining block tasks are skipped entirely, which is what makes rollback logic safe to write.

The trap: if your rescue only logs, you have converted a real failure into a green build. Anyone reading the exit code sees success. If the rescue does not genuinely restore a good state, end it with:

yaml
- name: Re-raise after logging
  ansible.builtin.fail:
    msg: "Deployment failed and could not be recovered"

C3 · Failure across the whole fleet

KeywordEffect
(default)A failed host drops out; other hosts continue the play
any_errors_fatal: trueOne host failing aborts the play for all hosts, at the end of the current task
max_fail_percentage: 30Abort once more than 30% of hosts in the current batch have failed
force_handlers: trueRun queued handlers even though the play failed
max_fail_percentage only means anything alongside serial, because the percentage is evaluated per batch. With serial: 10 and max_fail_percentage: 0, a single failure in the first batch of ten stops the rollout before it touches the remaining 490 hosts. That combination is the standard safe-rollout pattern and a very good thing to name in an interview.

🎯 Interview questions — Error handling

Q. Explain block, rescue and always.

Ansible's try/catch/finally. block groups tasks; if any fails, the block aborts immediately and rescue runs; always runs regardless of outcome.

The critical detail: if rescue completes successfully the host is no longer marked failed — the recap shows rescued=1, failed=0 and the play continues.

That is correct for a genuine recovery and dangerous if the rescue only logs, because you have turned a real failure into a green CI build. If the rescue does not restore a good state, end it with fail.

ansible_failed_task and ansible_failed_result are available inside rescue for reporting.

Q. ignore_errors vs failed_when: false — which and why?

Both let the play continue. ignore_errors still reports the task as failed with a red fatal: ...ignoring line, which trains people to overlook red text in CI. failed_when: false declares the task cannot fail and reports ok.

Prefer a real failed_when: condition — failed_when: result.rc not in [0, 2] — because it encodes which outcomes are actually acceptable rather than blanket-suppressing everything.

And neither handles an unreachable host; that needs ignore_unreachable: true.

Q. One host fails mid-play. What happens to the others?

By default the failed host is removed from the play and every other host continues. The failure is reported in the recap and the run exits non-zero.

Change it with any_errors_fatal: true to abort everywhere on the first failure, or max_fail_percentage: N to abort once a threshold of the current batch has failed — which only has meaning together with serial.

Q. How would you make a deployment roll back automatically on failure?

block containing drain, deploy and health check; rescue performing the rollback and alerting; always returning the node to the load balancer whatever happened.

Combine with serial so only a batch is affected, and max_fail_percentage: 0 so the rollout halts rather than continuing through the fleet.

And a caveat worth volunteering: if the rescue cannot genuinely restore a good state, it must end with fail, otherwise the pipeline reports success on a broken deployment.

Q. When do you use assert rather than fail?

assert for input validation at the start of a playbook — it states positively what must be true and reports which specific condition failed.

fail for a deliberate stop based on a more complex or procedural condition, where you want to control the message precisely.

The practical argument: failing in two seconds with a clear message beats failing four minutes in with a half-written config on disk.


Part D · Controlling what runs — tags, imports and includes

D1 · Tags

The analogy. Think of a cookbook with coloured tabs down the side — Starters, Mains, Desserts. Tonight you only want dessert, so you flip straight to those pages and ignore the rest. But some instructions are not optional: "preheat the oven" and "wash your hands" apply no matter which section you are cooking from, and nobody thought to tab them under Desserts.

The tabs are tags:, and "wash your hands" is tags: always. Skip those universal steps and the dessert fails for reasons that look nothing like a tagging problem — which is exactly what happens when you run --tags deploy and your include_vars task, untagged, never runs. That is the single most common tagging bug in real playbooks.

Official docs: Tags

Tags let you run part of a playbook without editing it.

yaml
- name: Install packages
  ansible.builtin.package:
    name: nginx
    state: present
  tags: [install, packages]

- name: Deploy configuration
  ansible.builtin.template:
    src: nginx.conf.j2
    dest: /etc/nginx/nginx.conf
  tags: [config, deploy]

- name: Run the full test suite
  ansible.builtin.command: /opt/tests/run.sh
  tags: [test, slow]
bash
ansible-playbook site.yml --tags config           # only config-tagged tasks
ansible-playbook site.yml --tags "config,deploy"  # either tag
ansible-playbook site.yml --skip-tags slow        # everything except slow
ansible-playbook site.yml --list-tags             # what tags exist?

The four special tags

TagMeaning
alwaysRuns every time, regardless of --tags — unless explicitly skipped with --skip-tags always
neverNever runs unless the tag is requested explicitly. For destructive or very slow tasks
tagged / untaggedSelect all tasks that do, or do not, carry any tag
allEverything — the default
yaml
- name: Load variables - needed by everything
  ansible.builtin.include_vars: vars/main.yml
  tags: always                    # runs even with --tags config

- name: Wipe and rebuild the database
  ansible.builtin.command: /opt/db/rebuild.sh
  tags: [never, rebuild]          # only runs with --tags rebuild
The tag trap that catches everyone once. Running --tags config skips your include_vars task, so the variables the config template needs are undefined and the run fails — in a way that looks like a variable bug rather than a tagging bug.

Anything that sets up state other tasks depend on — variable loading, fact gathering, assertions — needs tags: always. This is the single most useful thing to know about tags, and it is very commonly asked.

Tags are inherited downward. A tag on a play, block, role or import_tasks applies to every task inside it. So you tag the block, not each of its twelve tasks. Note that tags cannot be applied usefully to a handler — handlers inherit the tags of the task that notifies them.

D2 · import_* vs include_* — static vs dynamic

The analogy. Think of using a recipe that lives in someone else's book. You have two ways to handle it. You can photocopy the page and staple it into your own book before you start cooking — it is now part of your recipe, you can read it, tab it and jump to it. Or you can just write "see the other book, page 40" and go and fetch it when you reach that step — more flexible, because you can decide which book on the night, but until you get there nobody can see what is on that page.

The photocopy is import_*; the note saying "see page 40" is include_*. Decided now versus decided later — and every other difference between them follows from that one sentence, including why you cannot --tags your way into an included file and why a loop only works with include_tasks.

This is the most conceptually important topic in Module 03, and one of the most reliably asked.

Diagram source
flowchart TD
    A["ansible-playbook site.yml"] --> B["PARSE TIME<br>the whole playbook is read"]
    B --> C["import_tasks / import_role<br>import_playbook"]
    C --> D["Contents are inserted INLINE<br>as if you had typed them there"]
    D --> E["Visible to --list-tasks<br>Taggable individually<br>CANNOT use a variable in the filename<br>CANNOT be looped"]
    B --> F["RUN TIME<br>tasks execute one by one"]
    F --> G["include_tasks / include_role<br>include_vars"]
    G --> H["Contents are loaded WHEN REACHED"]
    H --> I["Invisible to --list-tasks<br>Tags apply to the include as a whole<br>CAN use a variable in the filename<br>CAN be looped"]
    style D fill:#DDD6FE,stroke:#7C3AED,stroke-width:2px
    style H fill:#FEF3C7,stroke:#D97706,stroke-width:2px
import_* — STATICinclude_* — DYNAMIC
ProcessedAt parse time, before anything runsAt run time, when reached
Variable in the filename❌ Only variables known at parse time✅ Any variable, including facts
Can be looped❌ No✅ Yes
--list-tasks✅ Shows every inner task❌ Shows only the include line
--start-at-task on inner tasks✅ Works❌ Does not
Tag behaviourTags are inherited by every inner taskTags apply to the include as a whole
when: behaviourCopied onto each inner taskApplied once to the include itself
yaml
# STATIC - fixed structure, fully visible to tooling
- ansible.builtin.import_tasks: setup.yml
- ansible.builtin.import_role:
    name: common

# DYNAMIC - required when the target depends on runtime data
- ansible.builtin.include_tasks: "{{ ansible_facts['os_family'] }}.yml"

- ansible.builtin.include_tasks: configure-app.yml
  loop: "{{ applications }}"
  loop_control:
    loop_var: app
The decision rule: use import_* by default, because static structure is visible to --list-tasks, --start-at-task and ansible-lint. Switch to include_* only when you genuinely need runtime behaviour — a variable filename, a loop, or a conditional that must be evaluated against facts.

The performance note worth adding: importing everything expands the whole playbook at parse time, which on a very large role tree makes startup slow and memory-hungry. Dynamic includes stay cheap until reached. So "import by default" is a readability default, not an absolute.

The when: difference is subtle and important. On an import_tasks, a when: is copied onto every inner task — so each is evaluated separately, and each can reference variables set by earlier tasks in the same file.

On an include_tasks, the when: is evaluated once, against the state at that moment, and either the whole file runs or none of it does.

Same YAML, different semantics. This produces bugs that look impossible until you know the rule.

🧪 Exercise D2.1 — Show that a variable filename only works with include
yaml
---
- name: import vs include
  hosts: localhost
  gather_facts: true
  tasks:
    - name: This works - resolved at run time
      ansible.builtin.include_tasks: "os-{{ ansible_facts['os_family'] }}.yml"

    - name: This fails - the filename is needed at parse time
      ansible.builtin.import_tasks: "os-{{ ansible_facts['os_family'] }}.yml"
bash
printf -- "- debug:\n    msg: Debian tasks ran\n" > os-Debian.yml
printf -- "- debug:\n    msg: RedHat tasks ran\n" > os-RedHat.yml
ansible-playbook importvsinclude.yml
Expected result — click to reveal
plain text
TASK [This works - resolved at run time] ***************************
included: /home/zaeem/lab/os-Debian.yml for localhost

TASK [debug] *******************************************************
ok: [localhost] => {"msg": "Debian tasks ran"}

ERROR! Unable to retrieve file contents
Could not find or access 'os-{{ ansible_facts[\'os_family\'] }}.yml'

The error is the lesson. import_tasks is processed before any host is contacted, so ansible_facts does not exist yet — the filename is still the literal, untemplated string. The include succeeded because by the time it was reached, facts had been gathered.

Now run ansible-playbook importvsinclude.yml --list-tasks with only the include, and note that the inner debug task does not appear. The tooling cannot see inside a dynamic include, which is exactly the trade-off: runtime flexibility costs you static visibility.

The rule that follows: if a filename or a loop depends on runtime data, you have no choice — use include_*. Otherwise prefer import_* and keep your playbook introspectable.

D3 · pre_tasks, roles, tasks, post_tasks

A play runs its sections in a fixed order, regardless of how you order them in the file:

yaml
- name: Ordered play
  hosts: web
  pre_tasks:
    - name: Runs FIRST
      ansible.builtin.debug: {msg: "1 - pre_tasks"}
  roles:
    - common                 # 2 - roles
  tasks:
    - name: Runs THIRD
      ansible.builtin.debug: {msg: "3 - tasks"}
  post_tasks:
    - name: Runs LAST
      ansible.builtin.debug: {msg: "4 - post_tasks"}
Handlers are flushed between each section. So a handler notified in pre_tasks runs before the roles begin — which is exactly why pre_tasks is where you put things like "drain from the load balancer" or "apply pending config and restart", and post_tasks is where verification and re-enabling belong.

This ordering is fixed. Writing tasks: above pre_tasks: in the file changes nothing.

🎯 Interview questions — Tags, imports and includes

Q. What is the difference between import_tasks and include_tasks?

import_tasks is static, processed at parse time — the contents are inserted inline as though you had typed them. include_tasks is dynamic, processed at run time when the task is reached.

The consequences: only include can take a variable filename or be looped, because those depend on runtime data. Only import is visible to --list-tasks, usable with --start-at-task, and fully analysable by ansible-lint.

Tags and when: also behave differently: on an import they are applied to every inner task; on an include they apply to the include as a single unit.

Q. Which should you prefer, and when do you break that rule?

Prefer import_* by default — static structure keeps the playbook introspectable by tooling and by humans.

Break it when you need runtime behaviour: a filename built from a fact, a loop over includes, or a conditional that must be evaluated against gathered data.

The performance caveat worth adding: importing a very large role tree expands everything at parse time, which slows startup and uses memory. Dynamic includes stay cheap until reached, so at scale it becomes a genuine trade-off rather than a style preference.

Q. What are the special tags?

always — runs regardless of --tags, unless explicitly skipped. never — runs only when requested by name, for destructive or very slow tasks. tagged and untagged — select tasks that do or do not carry any tag. all — the default.

The practical point: anything that sets up state other tasks depend on, such as include_vars, must be tagged always, otherwise --tags config skips it and you get confusing undefined-variable errors that look like a variable bug.

Q. What order do pre_tasks, roles, tasks and post_tasks run in?

Always that order, regardless of how they are arranged in the file — and handlers are flushed between each section.

That is why pre_tasks is the natural home for draining a node from a load balancer or applying config that must take effect before the roles run, and post_tasks for verification and re-enabling.


Part E · Orchestration — delegation and rolling updates

E1 · delegate_to, run_once and local_action

The analogy. Think of a parcel arriving while you are out, so reception signs for it on your behalf. The signing happens at the front desk, by somebody else — but the parcel is still yours, and it is your name on the label the whole time.

That is delegate_to: the task runs somewhere else, while everything about it still refers to the server you are currently working on. It is exactly how one server tells the load balancer "stop sending traffic to me" — the API call is made from the control node, but inventory_hostname still means the machine being drained. Without that, every host in the play would have to know how to talk to the load balancer itself.

delegate_to runs a task on a different host than the one currently being processed, while keeping that host's variables in scope.

yaml
- name: Remove this node from the load balancer
  ansible.builtin.uri:
    url: "http://lb.internal/disable/{{ inventory_hostname }}"
    method: POST
  delegate_to: lb01                  # runs ON lb01, about the current host

- name: Wait for the node to come back after reboot
  ansible.builtin.wait_for:
    host: "{{ inventory_hostname }}"
    port: 22
    delay: 10
    timeout: 300
  delegate_to: localhost             # the control node does the waiting

- name: Run the database migration exactly once
  ansible.builtin.command: /opt/app/migrate.sh
  run_once: true                     # first host of the batch only
  delegate_to: "{{ groups['db'][0] }}"
The key property: with delegate_to, the task executes elsewhere but inventory_hostname and all the current host's variables still refer to the original host. That is what makes "tell the load balancer about this node" expressible in one line.

delegate_facts: true additionally assigns any gathered facts to the delegated host instead of the current one.

FormMeaning
delegate_to: localhostRun on the control node — API calls, waiting, local file work
connection: localSame effect for a whole play, without changing the host context
local_action:Older shorthand for delegate_to: localhost. Read it, do not write it
run_once: trueExecute on the first host of the batch only, but apply the result to all

E2 · serial — rolling updates

The analogy. Think of repainting a hotel. You do not close all two hundred rooms at once, because then you have no hotel and no guests — you work through a few floors at a time so there is always somewhere to sleep. And you paint one room first, to check the colour actually looks right on a real wall, before committing to the whole building.

That is serial: [1, 5, "25%"] — one room, then a few, then a wing at a time. The first batch is your canary: if the colour is wrong you have one room to repaint, not two hundred. This is also the coach tour from Module 01 D4 finally being allowed to leave in groups rather than all together.

yaml
- name: Rolling deployment
  hosts: web
  serial: 2                        # two hosts at a time through the WHOLE play
  max_fail_percentage: 0           # any failure halts the rollout
yaml
serial: 1                          # one at a time - safest, slowest
serial: "25%"                      # a quarter of the fleet per batch
serial: [1, 5, 10]                 # canary of 1, then 5, then 10, then 10...
serial: [1, "10%", "50%"]          # mixed
serial: [1, 5, "25%"] is the canary pattern, and naming it earns real credit. One host first — if it breaks, one host is broken. Then five. Then a quarter of the fleet at a time. Paired with max_fail_percentage: 0, the rollout stops at the first sign of trouble instead of marching through 500 servers.
serial batches the entire play, not a task. Each batch runs every task in the play before the next batch starts. That is what makes drain → deploy → verify → re-enable work as a unit.

One consequence people miss: ansible_play_hosts inside a serial play contains only the current batch, not the whole group. If you need the full list, that is ansible_play_hosts_all.

🧪 Exercise E2.1 — A complete rolling deployment
yaml
---
- name: Zero-downtime rolling deployment
  hosts: web
  serial: [1, 2]                   # canary of one, then two at a time
  max_fail_percentage: 0
  become: true

  tasks:
    - name: Drain from the load balancer
      ansible.builtin.uri:
        url: "http://lb.internal/disable/{{ inventory_hostname }}"
        method: POST
        status_code: [200, 204]
      delegate_to: localhost
      changed_when: false

    - name: Wait for connections to finish
      ansible.builtin.pause:
        seconds: 10

    - name: Deploy with automatic rollback
      block:
        - name: Deploy the new version
          ansible.builtin.copy:
            content: "version={{ app_version }}\n"
            dest: /opt/app/VERSION
            mode: "0644"
          notify: Restart app

        - name: Apply the restart now, before verifying
          ansible.builtin.meta: flush_handlers

        - name: Verify health
          ansible.builtin.uri:
            url: "http://{{ inventory_hostname }}:8080/health"
            status_code: 200
          register: health
          until: health.status == 200
          retries: 6
          delay: 5
          changed_when: false

      rescue:
        - name: Roll back
          ansible.builtin.command: /opt/app/rollback.sh

        - name: Re-raise so the rollout halts
          ansible.builtin.fail:
            msg: "Deploy failed on {{ inventory_hostname }}; rolled back and stopping"

      always:
        - name: Return to the load balancer
          ansible.builtin.uri:
            url: "http://lb.internal/enable/{{ inventory_hostname }}"
            method: POST
            status_code: [200, 204]
          delegate_to: localhost
          changed_when: false

  handlers:
    - name: Restart app
      ansible.builtin.service:
        name: myapp
        state: restarted
Expected result — and every design decision in it — click to reveal
plain text
PLAY [Zero-downtime rolling deployment] ****************************

TASK [Drain from the load balancer] ***   ok: [web01]        <-- batch 1: ONE host
TASK [Wait for connections to finish] *   ok: [web01]
TASK [Deploy the new version] *********   changed: [web01]
RUNNING HANDLER [Restart app] *********   changed: [web01]
TASK [Verify health] ******************   ok: [web01]
TASK [Return to the load balancer] ****   ok: [web01]

PLAY [Zero-downtime rolling deployment] ****************************

TASK [Drain from the load balancer] ***   ok: [web02]        <-- batch 2: TWO hosts
                                          ok: [web03]
...

PLAY RECAP *********************************************************
web01 : ok=6  changed=2  failed=0
web02 : ok=6  changed=2  failed=0
web03 : ok=6  changed=2  failed=0

Notice the play header repeats. Each serial batch is a fresh pass through the entire play — that is the mechanism, made visible.

Seven deliberate decisions, each worth being able to justify:

  1. serial: [1, 2] — canary first. A bad release breaks one host, not the fleet.
  2. max_fail_percentage: 0 — the rollout halts rather than continuing.
  3. delegate_to: localhost on the LB calls — the load balancer API is reached from the control node, but inventory_hostname still names the host being drained.
  4. meta: flush_handlers before the health check — without it the restart happens at the end of the play and you would verify the old version. Module 01 Part D3.
  5. until / retries on the health check — services take time to come up; a single immediate check would fail spuriously.
  6. always for re-enabling — a node must never be left drained, no matter what failed.
  7. fail at the end of rescue — without it, a successful rescue clears the failure, max_fail_percentage never triggers, and the rollout continues happily through a broken deployment.

Point 7 is the one that separates candidates. It is the interaction between two features — rescue clearing failure state, and max_fail_percentage depending on failure state — and you only find it by having been burned.

🎯 Interview questions — Orchestration

Q. What is delegate_to and when do you use it?

It runs a task on a different host while keeping the current host's variables in scope — so inventory_hostname still refers to the host being processed.

Typical uses: calling a load balancer API from localhost to drain the current node; adding a DNS record on a management host; running a database migration on a single DB node.

delegate_facts: true assigns gathered facts to the delegated host instead of the current one. local_action is the older shorthand for delegate_to: localhost.

Q. Design a zero-downtime rolling deployment across 500 servers.

serial: [1, 10, "25%"] for a canary then progressive batches, with max_fail_percentage: 0 so the rollout halts on the first failure.

Per batch: drain from the load balancer with delegate_to: localhost, pause for connection draining, deploy inside a block, meta: flush_handlers to apply the restart, health check with until/retries, and always to re-enable.

rescue performs the rollback and must end with fail, otherwise the recovered state clears the failure, max_fail_percentage never fires, and the rollout continues through a broken release.

Before any of it: --check --diff --limit one-host.

Q. What is run_once and what is it commonly paired with?

It executes a task on the first host of the batch only, while applying the result to the whole play — used for singular operations such as a schema migration, creating a shared resource, or sending one deployment notification.

Commonly paired with delegate_to to control which host it runs on, since "the first host of the batch" is otherwise arbitrary.

Caveat: with serial, run_once fires once per batch, not once per play — which surprises people writing migrations.

Q. Inside a serial play, what does ansible_play_hosts contain?

Only the hosts in the current batch, not the whole group. For the complete list use ansible_play_hosts_all.

It matters when building a config that must list every peer — an nginx upstream block or a cluster member list — because with serial you would otherwise generate a config naming only the two hosts in the current batch.


Part F · Putting it together

F1 · Production practice

HabitWhy
Parenthesise whenever and and or appear togetherand binds tighter — your safety flag silently stops being evaluated, and only in production
Never write {{ }} inside when:Already a Jinja2 expression; redundant, linted against, and breaks on complex expressions
Check whether a module takes a list before using loopLooping package is three round trips instead of one
loop_control: label: on every loop over dictionariesOtherwise the full dict — including passwords — is printed to logs
no_log: true on any task handling secretsSuppresses both arguments and output from logs
failed_when: with a real condition, not blanket ignore_errorsEncodes which outcomes are acceptable instead of hiding all of them
End a rescue with fail unless it truly restored good stateA successful rescue clears the failure and turns a broken deploy into a green build
assert block at the top of any parameterised playbookFail in two seconds with a clear message, not four minutes in with a half-written config
tags: always on include_vars and other setup tasksOtherwise --tags config skips them and you debug a phantom variable bug
import_* by default, include_* only when you need runtime behaviourKeeps --list-tasks, --start-at-task and ansible-lint working
serialmax_fail_percentage: 0 for any fleet-wide changeHalts at the first failure instead of propagating a bad release to 500 hosts
always: blocks for anything that must be undoneA node left drained from the load balancer is an outage you caused

F2 · Capstone exercise

Attempt this without looking anything up. It exercises conditionals, loops, error handling, tags, delegation and rolling updates together.

Brief. Write a playbook that patches the web group:

  1. Runs one host at a time, halting entirely on the first failure
  2. Refuses to run at all unless -e approved=true is supplied
  3. Loads variables from a file that still loads when run with --tags patch
  4. Installs a list of packages in one transaction, not a loop
  5. Drains the host from the load balancer before patching and always re-enables it
  6. Reboots only if a reboot is actually required, then waits for the host to return
  7. Rolls back and halts the rollout if the post-patch health check fails
  8. Tags the slow full-test-suite task so it runs only when explicitly requested
Model answer — attempt it first, then click
yaml
---
- name: Rolling patch with rollback
  hosts: web
  serial: 1                          # requirement 1
  max_fail_percentage: 0             # requirement 1
  become: true

  pre_tasks:
    - name: Requirement 2 - refuse to run without approval
      ansible.builtin.assert:
        that:
          - approved | default(false) | bool
        fail_msg: "Refusing to patch. Re-run with -e approved=true"
      tags: always                   # must run even with --tags patch

    - name: Requirement 3 - load vars, always
      ansible.builtin.include_vars: vars/patching.yml
      tags: always

  tasks:
    - name: Requirement 5 - drain from the load balancer
      ansible.builtin.uri:
        url: "http://lb.internal/disable/{{ inventory_hostname }}"
        method: POST
        status_code: [200, 204]
      delegate_to: localhost
      changed_when: false
      tags: patch

    - name: Patch with rollback
      block:
        - name: Requirement 4 - ONE transaction, not a loop
          ansible.builtin.package:
            name: "{{ patch_packages }}"      # a LIST variable
            state: latest

        - name: Requirement 6 - is a reboot needed?
          ansible.builtin.stat:
            path: /var/run/reboot-required
          register: reboot_flag

        - name: Requirement 6 - reboot only if required
          ansible.builtin.reboot:
            reboot_timeout: 600
          when: reboot_flag.stat.exists

        - name: Requirement 7 - health check
          ansible.builtin.uri:
            url: "http://{{ inventory_hostname }}:8080/health"
            status_code: 200
          register: health
          until: health.status == 200
          retries: 12
          delay: 5
          changed_when: false

      rescue:
        - name: Roll back the packages
          ansible.builtin.command: /opt/scripts/rollback-packages.sh

        - name: Requirement 7 - re-raise so the rollout HALTS
          ansible.builtin.fail:
            msg: "Patching failed on {{ inventory_hostname }}; rolled back and stopping"

      always:
        - name: Requirement 5 - always re-enable
          ansible.builtin.uri:
            url: "http://lb.internal/enable/{{ inventory_hostname }}"
            method: POST
            status_code: [200, 204]
          delegate_to: localhost
          changed_when: false
      tags: patch

  post_tasks:
    - name: Requirement 8 - slow suite, opt-in only
      ansible.builtin.command: /opt/tests/full-suite.sh
      changed_when: false
      tags: [never, fulltest]

The six things most people miss:

  1. tags: always on the assert and include_vars. Run with --tags patch and without them, the approval gate is skipped entirely and patch_packages is undefined.
  2. name: "{{ patch_packages }}" with a list variable — one transaction. A loop here would be three or ten round trips and Ansible would warn about it.
  3. fail at the end of rescue. Without it the rescue clears the failure, max_fail_percentage: 0 never fires, and the patch rolls on to every remaining host.
  4. always: on the block re-enables the node even when the rescue itself failed. A host left drained is an outage you created while trying to be careful.
  5. stat before reboot — requirement 6 said "only if required". Rebooting unconditionally is both slower and a needless availability risk.
  6. tags: [never, fulltest]never is what makes it opt-in. A plain fulltest tag would still run in a normal untagged execution.

Verify:

bash
ansible-playbook patch.yml                                  # should FAIL on the assert
ansible-playbook patch.yml -e approved=true --check --diff --limit web01
ansible-playbook patch.yml -e approved=true --list-tasks
ansible-playbook patch.yml -e approved=true --tags patch
ansible-playbook patch.yml -e approved=true --tags fulltest

F3 · Command reference — everything from this module

Commands and keywords introduced in Module 03. ⭐ marks genuinely daily-use.

Tag-driven runs

bash
ansible-playbook site.yml --list-tags                # ⭐ what tags exist?
ansible-playbook site.yml --tags config              # ⭐ run one slice
ansible-playbook site.yml --tags "config,deploy"     # either tag
ansible-playbook site.yml --skip-tags slow           # ⭐ everything except
ansible-playbook site.yml --tags never,rebuild       # opt in to a 'never' task

Partial and targeted runs

bash
ansible-playbook site.yml --list-tasks               # ⭐ full ordered scope
ansible-playbook site.yml --limit web01              # ⭐ one host first, always
ansible-playbook site.yml --limit @site.retry        # ⭐ only the hosts that failed
ansible-playbook site.yml --start-at-task "Deploy"   # resume after a failure
ansible-playbook site.yml --step                     # confirm each task
ansible-playbook site.yml --check --diff             # ⭐ dry run with the diff
ansible-playbook site.yml --force-handlers           # run handlers despite a failure
ansible-playbook site.yml --flush-cache              # discard cached facts

Control-flow keywords — the quick reference

yaml
# CONDITIONALS
when: ansible_facts['os_family'] == "RedHat"     # ⭐ no {{ }} braces
when:                                            # ⭐ a list is an implicit AND
  - a == 1
  - b | bool
when: (a or b) and c                             # ⭐ parenthesise when mixing
when: myvar is defined                           # ⭐
when: app_version is version('2.0', '>=')        # ⭐ correct version comparison
when: "'web' in group_names"                     # ⭐ group membership

# LOOPS
loop: "{{ mylist }}"                             # ⭐
loop: "{{ mydict | dict2items }}"                # ⭐ dictionaries need converting
loop: "{{ groups['web'] }}"
loop_control:
  label: "{{ item.name }}"                       # ⭐ hides secrets, shortens output
  loop_var: outer                                # required for nesting
  index_var: idx
until: result.status == 200                      # ⭐ retry loop
retries: 12
delay: 5

# ERROR HANDLING
ignore_errors: true                              # blunt - prefer failed_when
ignore_unreachable: true                         # ignore_errors does NOT cover this
failed_when: result.rc not in [0, 2]             # ⭐ precise
changed_when: false                              # ⭐ read-only tasks never "change"
no_log: true                                     # ⭐ suppress secrets from logs
any_errors_fatal: true
max_fail_percentage: 0                           # ⭐ only meaningful with serial
block: / rescue: / always:                       # ⭐ try / catch / finally

# STRUCTURE
import_tasks: setup.yml                          # ⭐ static, parse time
include_tasks: "{{ os_family }}.yml"             # ⭐ dynamic, run time
tags: always                                     # ⭐ on include_vars and asserts
tags: [never, rebuild]                           # opt-in only

# ORCHESTRATION
delegate_to: localhost                           # ⭐ run here, about the remote host
delegate_facts: true
run_once: true                                   # ⭐ once per BATCH, not per play
serial: [1, 5, "25%"]                            # ⭐ the canary pattern
meta: flush_handlers                             # ⭐ apply restarts before verifying
The safe-rollout incantation, worth memorising as a unit:
yaml
serial: [1, 5, "25%"]
max_fail_percentage: 0

plus a block / rescue / always where the rescue ends in fail. Those four elements together are what "zero-downtime deployment with automatic rollback" actually means in Ansible, and being able to write them from memory answers most orchestration questions on its own.


F4 · Official documentation

LinkCovers
Conditionalswhen:, operators, conditions on roles and includes
Testsis defined, is version(), is succeeded and the rest
Loopsloop, loop_control, until, and migrating from with_*
Error handling in playbooksblock/rescue/always, failed_when, any_errors_fatal
TagsTag inheritance and the special tags
Re-using files: includes vs importsThe static/dynamic distinction in full
Delegation and local actionsdelegate_to, run_once, delegate_facts
Controlling execution: strategiesserial, strategy, throttle, forks
Playbook keywordsWhich of these keywords is legal at play, block or task level

F5 · Self-assessment

1. Why must you never write when: "{{ myvar }}"?

when: is already evaluated as a Jinja2 expression, so the braces are redundant, ansible-lint flags them, and on complex expressions the double evaluation can break — particularly with nested quoting or filters.

2. What is the operator-precedence trap, and why is it dangerous?

and binds tighter than or, so a or b and c parses as a or (b and c).

The realistic failure: when: env == "prod" or env == "staging" and force_deploy — in production the first branch is true, the expression short-circuits, and the safety flag is never evaluated. The guard silently does nothing, only in the environment it was protecting.

3. Why should you not loop over a package list?

The package modules take a list natively — one transaction, one round trip. Looping runs the package manager once per item, which is several times slower, and Ansible warns about it.

The general rule: check whether the module already accepts a list before reaching for loop.

4. What does loop_control: label: protect you from?

Without it, a loop over a list of dictionaries prints the entire dictionary on every iteration — including any password or token — into your terminal, CI logs and log aggregation.

It also makes a 200-item loop readable. For genuine secrets, combine with no_log: true.

5. A registered variable from a looped task — what is in it?

A results list, one full result object per iteration. Each entry carries .item, the original loop value that produced it — hence the item.item idiom when iterating over results.

6. What happens to the host after a successful rescue?

It is no longer marked failed — the recap shows rescued=1, failed=0 and the play continues.

Correct for a genuine recovery, dangerous if the rescue only logs, because a real failure becomes a green build. If the rescue does not restore good state, end it with fail.

7. ignore_errors vs failed_when: false?

Both continue the play. ignore_errors still reports a red fatal: ...ignoring, training people to overlook red text in CI. failed_when: false reports ok.

Prefer a real condition — failed_when: rc not in [0, 2] — which encodes which outcomes are acceptable. Neither covers an unreachable host; that needs ignore_unreachable.

8. Name three things import_tasks can do that include_tasks cannot, and vice versa.

Import only: visible to --list-tasks; usable with --start-at-task; tags and when: are applied to every inner task individually.

Include only: a variable in the filename; being looped; a when: evaluated once against runtime state.

Import is parse time, include is run time — every difference follows from that.

9. Why does include_vars need tags: always?

Because running with --tags config skips every untagged task, including the variable load — so the config template then fails on undefined variables, and the error looks like a variable bug rather than a tagging bug.

Anything that sets up state other tasks depend on needs tags: always.

10. What does delegate_to change, and what does it deliberately not change?

It changes where the task executes. It deliberately does not change the host context — inventory_hostname and all the current host's variables still refer to the original host.

That is what makes "tell the load balancer to drain this node" a single line.

11. Write the four ingredients of a safe fleet-wide rollout.

serial: [1, 5, "25%"] for a canary then progressive batches. max_fail_percentage: 0 to halt on failure. block/rescue/always for rollback and guaranteed re-enable. And a fail at the end of the rescue so the failure state survives for max_fail_percentage to act on.

Preceded by --check --diff --limit one-host.

12. Inside a serial play, what is in ansible_play_hosts?

Only the current batch. For every host in the play use ansible_play_hosts_all.

It matters when templating a config that must list all peers — an nginx upstream block or a cluster member list — because otherwise you generate a config naming only the current batch.


Next — Module 04 · Jinja2 Templating.

You have used filters throughout this module — default, bool, dict2items, version. Module 04 covers the templating engine properly: expressions, tests, whitespace control, template vs copy, and building config files that stay readable.

📚 Sources for the interview questions

Behaviour verified against the current official Ansible documentation.

Question selection cross-referenced against publicly published 2026 Ansible interview question sets:

Answers were rewritten and deepened rather than reproduced — published versions are usually correct but shallow, and the added operational detail is what differentiates a candidate in the room.

Spotted a mistake or want something added? Send me a note.