Playbooks
A playbook is a YAML file containing one or more plays. Each play maps a group of hosts to a set of tasks.
Playbook Structure
Section titled “Playbook Structure”---- name: Configure web servers hosts: webservers become: true
tasks: - name: Install nginx apt: name: nginx state: present
- name: Start nginx service: name: nginx state: started enabled: true
- name: Configure database servers hosts: dbservers become: true
tasks: - name: Install PostgreSQL apt: name: postgresql state: presentA play has:
name— Human-readable description.hosts— Which inventory hosts/groups to target (supports patterns).become— Whether to use privilege escalation (sudo).tasks— Ordered list of actions to perform.
Each task calls a module with arguments:
tasks: - name: Install packages apt: name: - nginx - curl - git state: present update_cache: true
- name: Copy config file copy: src: files/nginx.conf dest: /etc/nginx/nginx.conf owner: root mode: "0644"
- name: Create application user user: name: deploy shell: /bin/bash groups: www-data append: trueTasks run in order, top to bottom. If a task fails, Ansible stops on that host (but continues on other hosts by default).
Handlers
Section titled “Handlers”Handlers are tasks that only run when notified. Use them for actions that should only happen when something changes (e.g. restart a service after a config file changes):
tasks: - name: Copy nginx config copy: src: files/nginx.conf dest: /etc/nginx/nginx.conf notify: Restart nginx
- name: Copy app config template: src: templates/app.conf.j2 dest: /etc/app/config.yml notify: - Restart app - Restart nginx
handlers: - name: Restart nginx service: name: nginx state: restarted
- name: Restart app service: name: myapp state: restartedKey points:
- Handlers run once at the end of the play, even if notified multiple times.
- Handlers run in the order they are defined (not the order they were notified).
- If a task reports
changed, the notify fires. If the task reportsok(no change), it doesn’t.
Running Playbooks
Section titled “Running Playbooks”ansible-playbook site.yml # run with default inventoryansible-playbook -i inventory.yml site.yml # specify inventoryansible-playbook site.yml --limit webservers # run on a subset of hostsansible-playbook site.yml --limit web1.example.com # single hostansible-playbook site.yml --tags deploy # only tagged tasksansible-playbook site.yml --skip-tags debug # skip tagged tasksCheck Mode (Dry Run)
Section titled “Check Mode (Dry Run)”Preview what would change without actually changing anything:
ansible-playbook site.yml --checkSome modules support check mode natively; others may skip. Use --diff alongside to see file content differences:
ansible-playbook site.yml --check --diffVerbosity
Section titled “Verbosity”Increase output detail for debugging:
ansible-playbook site.yml -v # verboseansible-playbook site.yml -vv # more verboseansible-playbook site.yml -vvv # connection debuggingansible-playbook site.yml -vvvv # full plugin debuggingConditionals (when)
Section titled “Conditionals (when)”Run a task only when a condition is true:
tasks: - name: Install nginx (Debian/Ubuntu) apt: name: nginx state: present when: ansible_os_family == "Debian"
- name: Install nginx (RHEL/CentOS) yum: name: nginx state: present when: ansible_os_family == "RedHat"
- name: Enable debug logging copy: content: "DEBUG=true" dest: /etc/app/debug.conf when: app_debug | default(false)when evaluates Jinja2 expressions. Common conditions:
when: ansible_distribution == "Ubuntu"when: ansible_distribution_version is version('22.04', '>=')when: result.rc == 0when: my_var is definedwhen: my_var | length > 0when: inventory_hostname in groups['webservers']Run a task multiple times with different values:
tasks: - name: Install packages apt: name: "{{ item }}" state: present loop: - nginx - curl - git - htop
- name: Create users user: name: "{{ item.name }}" groups: "{{ item.groups }}" loop: - { name: alice, groups: admin } - { name: bob, groups: developers } - { name: carol, groups: developers }Loop With Index
Section titled “Loop With Index”- name: Create numbered config files copy: content: "Server {{ ansible_loop.index }}" dest: "/etc/app/server-{{ ansible_loop.index }}.conf" loop: "{{ server_list }}" loop_control: extended: trueRegistering Results
Section titled “Registering Results”Capture the output of a task for use in later tasks:
tasks: - name: Check if app is running command: systemctl is-active myapp register: app_status ignore_errors: true
- name: Start app if not running service: name: myapp state: started when: app_status.rc != 0
- name: Show app status debug: msg: "App status: {{ app_status.stdout }}"Error Handling
Section titled “Error Handling”ignore_errors
Section titled “ignore_errors”Continue even if a task fails:
- name: Check optional service command: systemctl status optional-service register: result ignore_errors: truefailed_when
Section titled “failed_when”Define custom failure conditions:
- name: Run health check command: curl -s http://localhost:8080/health register: health failed_when: "'healthy' not in health.stdout"changed_when
Section titled “changed_when”Control when a task reports “changed”:
- name: Check current version command: myapp --version register: version changed_when: false # this command never changes anythingblock / rescue / always
Section titled “block / rescue / always”Try-catch-finally pattern:
tasks: - block: - name: Deploy new version command: deploy.sh
- name: Run smoke tests command: test.sh
rescue: - name: Rollback on failure command: rollback.sh
always: - name: Send notification slack: msg: "Deploy finished ({{ ansible_failed_task | default('success') }})"Rolling Updates (serial)
Section titled “Rolling Updates (serial)”By default, Ansible runs each task on all hosts before moving to the next task. With serial, you deploy to hosts in batches — essential for zero-downtime rolling updates.
Fixed Batch Size
Section titled “Fixed Batch Size”- name: Rolling deploy hosts: webservers serial: 2 # 2 hosts at a time become: true
tasks: - name: Pull latest code git: repo: https://github.com/myorg/app.git dest: /opt/myapp version: main
- name: Restart app service: name: myapp state: restartedIf you have 10 web servers, Ansible processes them in batches of 2. Each batch completes all tasks before the next batch starts.
Percentage
Section titled “Percentage”serial: "25%" # 25% of hosts per batchRamping (Canary Pattern)
Section titled “Ramping (Canary Pattern)”Start small, then increase batch size:
serial: - 1 # first deploy to 1 host (canary) - 3 # then 3 at a time - "50%" # then half the remainingFailure Tolerance
Section titled “Failure Tolerance”- name: Rolling deploy hosts: webservers serial: 2 max_fail_percentage: 25 # stop if >25% of hosts fail
tasks: - name: Deploy # ...Without max_fail_percentage, Ansible stops the current batch on failure but continues to the next batch. With it set, Ansible aborts the entire play if the failure threshold is exceeded.
Pre/Post Tasks for Load Balancers
Section titled “Pre/Post Tasks for Load Balancers”- name: Rolling deploy hosts: webservers serial: 1
pre_tasks: - name: Remove from load balancer uri: url: "http://lb.example.com/api/deregister/{{ inventory_hostname }}" method: POST delegate_to: localhost
tasks: - name: Deploy new version copy: src: app/ dest: /opt/myapp/ - name: Restart app service: name: myapp state: restarted
post_tasks: - name: Add back to load balancer uri: url: "http://lb.example.com/api/register/{{ inventory_hostname }}" method: POST delegate_to: localhostThis takes each host out of rotation, deploys, then re-registers — one at a time.
Delegation
Section titled “Delegation”Run a task on a different host than the one being targeted. Common for load balancer operations, database tasks, or API calls from the control node.
delegate_to
Section titled “delegate_to”- name: Remove host from load balancer uri: url: "http://lb.example.com/api/deregister/{{ inventory_hostname }}" method: POST delegate_to: lb.example.com
- name: Run database migration (from bastion) command: /opt/scripts/migrate.sh delegate_to: bastion.example.com run_once: true # only run once, not per hostThe task runs on lb.example.com or bastion.example.com, but inventory_hostname and other host variables still refer to the current target host.
delegate_to: localhost
Section titled “delegate_to: localhost”Run a task on the control node (your laptop / CI server):
- name: Send Slack notification slack: token: "{{ slack_token }}" msg: "Deployed to {{ inventory_hostname }}" delegate_to: localhost
- name: Wait for host to come back wait_for: host: "{{ ansible_host }}" port: 22 delay: 10 timeout: 300 delegate_to: localhostrun_once
Section titled “run_once”Combine with delegate_to to run a task exactly once across the entire play:
- name: Run database migration command: /opt/myapp/migrate.sh delegate_to: "{{ groups['dbservers'][0] }}" run_once: trueEven if the play targets 20 web servers, the migration runs once on the first database server.
Async and Polling
Section titled “Async and Polling”For long-running tasks (backups, large downloads, database restores) that might exceed the SSH timeout.
Async With Polling
Section titled “Async With Polling”Start the task, poll for completion:
- name: Run long database backup command: /opt/scripts/backup-db.sh async: 3600 # allow up to 1 hour poll: 30 # check every 30 secondsAnsible keeps the SSH connection alive and polls every 30 seconds until the task finishes or times out.
Fire and Forget
Section titled “Fire and Forget”Start the task and move on immediately:
- name: Start background reindex command: /opt/scripts/reindex.sh async: 3600 poll: 0 # don't wait
- name: Do other work # ... other tasks run while reindex is in progress ...
- name: Check reindex status async_status: jid: "{{ reindex_result.ansible_job_id }}" register: job_result until: job_result.finished retries: 60 delay: 30poll: 0 means fire-and-forget. Use async_status later to check if it finished.
When to Use Async
Section titled “When to Use Async”| Situation | Use |
|---|---|
| Task takes > 30 seconds | async with poll |
| Task takes minutes, and you need to do other work meanwhile | async with poll: 0 |
| Normal short tasks | Don’t use async |
Strategies
Section titled “Strategies”Strategies control how Ansible executes tasks across hosts.
linear (Default)
Section titled “linear (Default)”Each task runs on all hosts before the next task starts. All hosts stay in sync:
- name: Deploy app hosts: webservers strategy: linear # default, no need to specifyEach host proceeds through tasks independently — fast hosts don’t wait for slow hosts:
- name: Update all servers hosts: all strategy: free
tasks: - name: Update packages apt: upgrade: distUseful when tasks are independent and hosts have different speeds. Output can be harder to read since hosts interleave.
host_pinned
Section titled “host_pinned”Like free, but each host’s tasks stay pinned together in output. Easier to read than free.
Interactive debugger — step through tasks one at a time:
- name: Debug a play hosts: webservers strategy: debugLets you inspect variables, re-run tasks, and step through execution. Useful for development only.
Setting Default Strategy
Section titled “Setting Default Strategy”# ansible.cfg[defaults]strategy = linearKey Takeaways
Section titled “Key Takeaways”- A playbook is a list of plays; each play targets hosts with ordered tasks.
- Handlers run only when notified and only once at the end of the play.
- Use
--check --diffto preview changes before applying. whenfor conditionals,loopfor iteration,registerto capture output.- Use
block/rescue/alwaysfor error handling and rollback patterns. - Control task reporting with
changed_whenandfailed_when. - Use
serialfor rolling updates — deploy in batches withmax_fail_percentagefor safety. - Use
delegate_toto run tasks on a different host (load balancer, bastion, localhost). - Use
asyncfor long-running tasks;poll: 0for fire-and-forget. linearstrategy keeps hosts in sync;freelets fast hosts proceed independently.