Table of Contents

Playbooks and Tasks

Playbooks are the heart of Ansible automation. A playbook is a YAML file describing an ordered list of plays, and each play maps a set of hosts to a set of tasks. Unlike ad-hoc commands, playbooks are declarative, version-controlled, and repeatable, making them the standard way to define configuration and orchestration.

This page covers playbook and play structure, tasks and modules, registering results, handlers, conditionals, loops, blocks, error handling, tags, running playbooks from the command line, and controlling execution across hosts.

Note

All examples use fully-qualified collection names (FQCN) such as ansible.builtin.apt and target current ansible-core (2.16/2.17). See installation.md for setting up your control node.

Playbook Structure

A playbook is an ordered list of plays. A play binds a group of hosts (from your inventory) to the tasks that run against them. Plays execute top to bottom, and tasks within a play execute in order.

The anatomy of a play:

---
- name: Configure web servers          # human-readable play name
  hosts: webservers                     # inventory group or pattern
  become: true                          # privilege escalation (sudo)
  gather_facts: true                    # collect host facts (default)
  vars:                                 # play-scoped variables
    http_port: 80
    app_user: www-data
  tasks:                                # ordered list of tasks
    - name: Ensure nginx is installed
      ansible.builtin.apt:
        name: nginx
        state: present
        update_cache: true
  handlers:                             # triggered by notify
    - name: Restart nginx
      ansible.builtin.systemd:
        name: nginx
        state: restarted

A single file can contain multiple plays. This is useful for orchestrating different host groups in one run, for example configuring database servers before web servers:

---
- name: Configure database tier
  hosts: databases
  become: true
  tasks:
    - name: Install PostgreSQL
      ansible.builtin.apt:
        name: postgresql
        state: present

- name: Configure web tier
  hosts: webservers
  become: true
  tasks:
    - name: Install nginx
      ansible.builtin.apt:
        name: nginx
        state: present
Tip

Always name your plays and tasks. Names appear in output, make --start-at-task and --list-tasks usable, and document intent for the next reader.

Tasks and Modules

Each task calls exactly one module — a unit of work such as installing a package, copying a file, or managing a service. Use the FQCN (namespace.collection.module) to remove ambiguity and to make dependencies on collections explicit.

tasks:
  - name: Copy application config
    ansible.builtin.copy:
      src: files/app.conf
      dest: /etc/app/app.conf
      owner: root
      group: root
      mode: "0644"

Well-behaved modules are idempotent: running the same task repeatedly converges the system to the desired state without making changes if the state already matches. This is why Ansible reports a status for every task:

Status Meaning
ok Task ran; system already in desired state (no change)
changed Task made a change to reach the desired state
failed Task encountered an error
skipped Task was skipped (a when condition was false)
unreachable Ansible could not connect to the host

For quick, one-off actions you can use an ad-hoc command instead of a playbook:

ansible webservers -i inventory.ini -m ansible.builtin.ping
ansible webservers -i inventory.ini -b -m ansible.builtin.apt -a "name=curl state=present"

Ad-hoc commands are ideal for exploration and troubleshooting; playbooks are for anything you want to reproduce.

Registering Results and Using Them

The register keyword captures a module's return value into a variable. You can then inspect fields such as .stdout, .rc (return code), .stderr, or module-specific keys, and drive follow-up tasks conditionally.

tasks:
  - name: Check whether the app binary exists
    ansible.builtin.command: which myapp
    register: app_check
    changed_when: false          # a check should never report "changed"
    failed_when: false           # don't fail if the binary is missing

  - name: Report the resolved path
    ansible.builtin.debug:
      msg: "myapp is at {{ app_check.stdout }}"
    when: app_check.rc == 0

  - name: Install the app if it is missing
    ansible.builtin.apt:
      name: myapp
      state: present
    when: app_check.rc != 0
Tip

Use ansible.builtin.debug with var: app_check to inspect the full structure of a registered result while developing. See variables-and-facts.md for more on working with variables.

Handlers

Handlers are tasks that run only when notified, and only if the notifying task reported changed. They run once, at the end of the play, after all regular tasks — so many tasks can notify the same handler and it still fires a single time. This is the canonical way to restart a service only when its configuration actually changes.

tasks:
  - name: Deploy nginx configuration
    ansible.builtin.template:
      src: templates/nginx.conf.j2
      dest: /etc/nginx/nginx.conf
      mode: "0644"
    notify: Restart nginx

handlers:
  - name: Restart nginx
    ansible.builtin.systemd:
      name: nginx
      state: restarted

To run pending handlers immediately rather than waiting for the end of the play, flush them explicitly:

  - name: Flush handlers now
    ansible.builtin.meta: flush_handlers

Handlers can also be triggered by topic using listen, decoupling the notifier's text from a specific handler name. Multiple handlers may listen on the same topic:

tasks:
  - name: Update TLS certificate
    ansible.builtin.copy:
      src: files/site.crt
      dest: /etc/ssl/certs/site.crt
      mode: "0644"
    notify: "reload web stack"

handlers:
  - name: Reload nginx
    ansible.builtin.systemd:
      name: nginx
      state: reloaded
    listen: "reload web stack"

  - name: Reload haproxy
    ansible.builtin.systemd:
      name: haproxy
      state: reloaded
    listen: "reload web stack"
Important

By default handlers do not run if any task fails earlier in the play. Use --force-handlers on the command line, or force_handlers: true on the play, if handlers must run despite failures.

Conditionals

The when keyword runs a task only if an expression is true. Expressions are Jinja2 (without the surrounding {{ }} for the whole condition) and can reference facts, variables, and registered results.

tasks:
  - name: Install Apache on Debian family
    ansible.builtin.apt:
      name: apache2
      state: present
    when: ansible_facts['os_family'] == "Debian"

  - name: Install Apache on RedHat family
    ansible.builtin.dnf:
      name: httpd
      state: present
    when: ansible_facts['os_family'] == "RedHat"

Combine conditions with and/or, or supply a list (which is an implicit and):

  - name: Start service only on production Debian hosts
    ansible.builtin.systemd:
      name: myapp
      state: started
    when:
      - ansible_facts['os_family'] == "Debian"
      - environment_name == "production"

  - name: Act on staging or QA
    ansible.builtin.debug:
      msg: "Non-production host"
    when: environment_name == "staging" or environment_name == "qa"

Loops

The loop keyword repeats a task once per item in a list. This is far more efficient and readable than writing near-identical tasks. Install several packages in one task:

tasks:
  - name: Install base packages
    ansible.builtin.apt:
      name: "{{ item }}"
      state: present
      update_cache: true
    loop:
      - git
      - curl
      - vim
      - htop
Tip

Package modules like ansible.builtin.apt and ansible.builtin.dnf also accept a list directly for name:, which installs everything in a single transaction and is faster than looping. Prefer name: "{{ packages }}" where the module supports it.

Loop over a list of dictionaries to pass multiple values per iteration:

  - name: Create application users
    ansible.builtin.user:
      name: "{{ item.name }}"
      groups: "{{ item.groups }}"
      shell: "{{ item.shell }}"
    loop:
      - { name: alice, groups: sudo, shell: /bin/bash }
      - { name: bob, groups: developers, shell: /bin/zsh }

Use loop_control to rename the loop variable (avoiding collisions in nested loops) and to produce cleaner output with label:

  - name: Deploy virtual hosts
    ansible.builtin.template:
      src: "templates/vhost.conf.j2"
      dest: "/etc/nginx/sites-available/{{ site.name }}.conf"
      mode: "0644"
    loop: "{{ vhosts }}"
    loop_control:
      loop_var: site
      label: "{{ site.name }}"
Note

The older with_items, with_dict, and other with_* keywords are legacy. New playbooks should use loop (optionally with filters like dict2items or subelements).

Blocks and Error Handling

A block groups tasks so they share directives (such as when or become) and enables try/rescue/finally-style error handling with rescue and always.

tasks:
  - name: Attempt application deployment
    block:
      - name: Pull latest release
        ansible.builtin.get_url:
          url: https://example.com/releases/app-latest.tar.gz
          dest: /tmp/app.tar.gz
          mode: "0644"

      - name: Unpack release
        ansible.builtin.unarchive:
          src: /tmp/app.tar.gz
          dest: /opt/app
          remote_src: true

    rescue:
      - name: Roll back on failure
        ansible.builtin.command: /opt/app/bin/rollback.sh
        changed_when: true

    always:
      - name: Clean up download
        ansible.builtin.file:
          path: /tmp/app.tar.gz
          state: absent

Tasks in rescue run only if a task in the block fails; tasks in always run regardless of outcome. Three related keywords let you fine-tune how success and change are judged per task:

Keyword Purpose
ignore_errors: true Continue the play even if this task fails
failed_when: <expr> Define a custom failure condition
changed_when: <expr> Define a custom "changed" condition
  - name: Run a health check that may exit non-zero
    ansible.builtin.command: /usr/local/bin/healthcheck
    register: health
    changed_when: false
    failed_when: health.rc not in [0, 2]   # treat rc 2 as acceptable
Warning

Prefer failed_when and rescue over blanket ignore_errors: true. Ignoring errors hides real problems and can leave hosts in an inconsistent state.

Tags

Tags let you run — or skip — a subset of tasks or plays without editing the playbook. Attach one or more tags to any task, block, or play.

tasks:
  - name: Install packages
    ansible.builtin.apt:
      name: nginx
      state: present
    tags:
      - packages
      - nginx

  - name: Deploy configuration
    ansible.builtin.template:
      src: templates/nginx.conf.j2
      dest: /etc/nginx/nginx.conf
      mode: "0644"
    tags:
      - config

Run only tagged tasks, or exclude them:

ansible-playbook -i inventory site.yml --tags config
ansible-playbook -i inventory site.yml --skip-tags packages

The special tags always (runs unless explicitly skipped) and never (runs only when its tag is requested) are useful for debug or destructive tasks.

Running Playbooks

Run a playbook with ansible-playbook, passing an inventory and the playbook file:

ansible-playbook -i inventory site.yml

Key flags:

Flag Purpose
--check Dry run — report what would change without applying
--diff Show file/content differences for changed tasks
--limit HOST Restrict the run to a host or group subset
--tags TAGS Run only tasks with the listed tags
--skip-tags TAGS Run everything except the listed tags
-e "k=v" Pass extra variables (highest precedence)
--start-at-task NAME Begin execution at a named task
--step Prompt before each task (interactive)
-v / -vvv Increase verbosity (-vvvv adds connection debug)
--syntax-check Parse the playbook without running it
--list-tasks List tasks that would run
--list-hosts List hosts that would be targeted
Tip

Combine --check --diff to preview exactly what a run would change. Note that tasks depending on a prior task's real effect (via register) may behave differently in check mode.

Controlling Execution Across Hosts

By default Ansible runs each task on all hosts before moving to the next task (the linear strategy). Several keywords change this behavior for orchestration and rolling deployments.

Keyword Effect
serial: Run the play in batches (rolling updates)
strategy: linear (default) or free (hosts advance independently)
run_once: true Run the task on a single host on behalf of the batch
delegate_to: Run the task on a different host than the current one
throttle: Cap concurrent hosts for a specific task

A minimal rolling deployment that updates two hosts at a time and pauses if too many fail:

---
- name: Rolling web deploy
  hosts: webservers
  become: true
  serial: 2
  max_fail_percentage: 25
  tasks:
    - name: Remove host from the load balancer
      ansible.builtin.command: /usr/local/bin/lb-drain {{ inventory_hostname }}
      delegate_to: loadbalancer01
      changed_when: true

    - name: Update the application package
      ansible.builtin.apt:
        name: myapp
        state: latest
        update_cache: true

    - name: Add host back to the load balancer
      ansible.builtin.command: /usr/local/bin/lb-enable {{ inventory_hostname }}
      delegate_to: loadbalancer01
      changed_when: true

    - name: Send a single deploy notification
      ansible.builtin.uri:
        url: https://hooks.example.com/deploy-complete
        method: POST
      run_once: true

Complete Worked Example

The following play updates the package cache, installs and configures nginx from a Jinja2 template, restarts it only when the configuration changes, and opens the firewall — all with FQCN modules and idempotent state.

---
- name: Provision an nginx web server
  hosts: webservers
  become: true
  gather_facts: true
  vars:
    http_port: 80
    server_name: www.example.com

  tasks:
    - name: Update apt cache and install nginx
      ansible.builtin.apt:
        name: nginx
        state: present
        update_cache: true
        cache_valid_time: 3600

    - name: Ensure the site document root exists
      ansible.builtin.file:
        path: /var/www/example
        state: directory
        owner: www-data
        group: www-data
        mode: "0755"

    - name: Deploy the nginx site configuration
      ansible.builtin.template:
        src: templates/example.conf.j2
        dest: /etc/nginx/sites-available/example.conf
        owner: root
        group: root
        mode: "0644"
      notify: Restart nginx

    - name: Enable the site
      ansible.builtin.file:
        src: /etc/nginx/sites-available/example.conf
        dest: /etc/nginx/sites-enabled/example.conf
        state: link
      notify: Restart nginx

    - name: Ensure nginx is started and enabled at boot
      ansible.builtin.systemd:
        name: nginx
        state: started
        enabled: true

    - name: Open the HTTP port in firewalld
      ansible.posix.firewalld:
        port: "{{ http_port }}/tcp"
        permanent: true
        immediate: true
        state: enabled

  handlers:
    - name: Restart nginx
      ansible.builtin.systemd:
        name: nginx
        state: restarted

Run it, previewing changes first:

ansible-playbook -i inventory site.yml --check --diff
ansible-playbook -i inventory site.yml

Running it a second time should report ok for every task (no changed), demonstrating idempotency. For structuring reusable versions of plays like this, see roles-and-collections.md, and for hiding secrets used in variables, see vault-and-secrets.md.

References