Ansible Playbooks

What are Playbooks?

Playbooks are Ansible's configuration, deployment, and orchestration language. They describe a set of tasks to execute on managed hosts and are written in YAML format.

Basic Playbook Structure

---
- name: Playbook description
  hosts: target_hosts
  become: yes  # Run with sudo privileges
  gather_facts: yes  # Collect system information

  vars:
    variable_name: value

  tasks:
    - name: Task description
      module_name:
        parameter: value

Your First Playbook

---
- name: Install and start Apache
  hosts: web_servers
  become: yes

  tasks:
    - name: Install Apache
      yum:
        name: httpd
        state: present

    - name: Start Apache service
      service:
        name: httpd
        state: started
        enabled: yes

    - name: Create index page
      copy:
        content: "

Hello from Ansible!

" dest: /var/www/html/index.html

Run the playbook:

ansible-playbook playbook.yml

Variables

Defining Variables

---
- name: Using variables
  hosts: web_servers

  vars:
    app_name: myapp
    app_port: 8080
    app_user: webapp

  tasks:
    - name: Create app user
      user:
        name: "{{ app_user }}"
        state: present

    - name: Display info
      debug:
        msg: "Deploying {{ app_name }} on port {{ app_port }}"

Variables from Files

---
- name: Load variables from file
  hosts: all

  vars_files:
    - vars/common.yml
    - vars/{{ ansible_os_family }}.yml

  tasks:
    - name: Use variables
      debug:
        var: database_name

Variable Precedence (Lowest to Highest)

  1. Role defaults
  2. Inventory file or script group vars
  3. Playbook group_vars/all
  4. Playbook group_vars/*
  5. Playbook host_vars/*
  6. Inventory file host vars
  7. Playbook vars
  8. Set facts
  9. Registered vars
  10. Task vars
  11. Extra vars (-e in command line)

Conditionals

---
- name: Conditional execution
  hosts: all

  tasks:
    - name: Install Apache on Red Hat
      yum:
        name: httpd
        state: present
      when: ansible_os_family == "RedHat"

    - name: Install Apache on Debian
      apt:
        name: apache2
        state: present
      when: ansible_os_family == "Debian"

    - name: Check multiple conditions
      debug:
        msg: "This is a RHEL 8 system"
      when:
        - ansible_distribution == "RedHat"
        - ansible_distribution_major_version == "8"

    - name: Complex conditions
      service:
        name: nginx
        state: restarted
      when: >
        (ansible_distribution == "Ubuntu" and
         ansible_distribution_version is version('20.04', '>='))
        or
        (ansible_distribution == "CentOS" and
         ansible_distribution_major_version == "8")

Loops

Simple Loops

---
- name: Loop examples
  hosts: localhost

  tasks:
    - name: Create multiple users
      user:
        name: "{{ item }}"
        state: present
      loop:
        - alice
        - bob
        - charlie

    - name: Install packages
      yum:
        name: "{{ item }}"
        state: present
      loop:
        - httpd
        - php
        - mariadb-server

Loop with Dictionary

---
- name: Loop with dictionaries
  hosts: localhost

  tasks:
    - name: Create users with properties
      user:
        name: "{{ item.name }}"
        uid: "{{ item.uid }}"
        groups: "{{ item.groups }}"
      loop:
        - { name: 'alice', uid: 1001, groups: 'wheel' }
        - { name: 'bob', uid: 1002, groups: 'users' }
        - { name: 'charlie', uid: 1003, groups: 'developers' }

Loop with Index

---
- name: Loop with index
  hosts: localhost

  tasks:
    - name: Display items with index
      debug:
        msg: "Item {{ index }}: {{ item }}"
      loop:
        - apple
        - banana
        - cherry
      loop_control:
        index_var: index

Handlers

Handlers are tasks that only run when notified by another task. They run at the end of the play.

---
- name: Handlers example
  hosts: web_servers
  become: yes

  tasks:
    - name: Copy Apache configuration
      template:
        src: httpd.conf.j2
        dest: /etc/httpd/conf/httpd.conf
      notify:
        - restart apache
        - check apache status

    - name: Copy virtual host config
      template:
        src: vhost.conf.j2
        dest: /etc/httpd/conf.d/vhost.conf
      notify: restart apache

  handlers:
    - name: restart apache
      service:
        name: httpd
        state: restarted

    - name: check apache status
      command: systemctl status httpd
      register: apache_status

    - name: display status
      debug:
        var: apache_status.stdout_lines

Templates (Jinja2)

Template file (nginx.conf.j2):

server {
    listen {{ nginx_port }};
    server_name {{ server_name }};

    location / {
        proxy_pass http://{{ backend_host }}:{{ backend_port }};
    }

    {% for location in custom_locations %}
    location {{ location.path }} {
        {{ location.config }}
    }
    {% endfor %}
}

Playbook:

---
- name: Deploy Nginx configuration
  hosts: web_servers
  become: yes

  vars:
    nginx_port: 80
    server_name: example.com
    backend_host: 192.168.1.100
    backend_port: 8080
    custom_locations:
      - { path: '/api', config: 'proxy_pass http://api.backend.com;' }
      - { path: '/static', config: 'root /var/www/static;' }

  tasks:
    - name: Deploy nginx config
      template:
        src: nginx.conf.j2
        dest: /etc/nginx/conf.d/app.conf
      notify: reload nginx

  handlers:
    - name: reload nginx
      service:
        name: nginx
        state: reloaded

Error Handling

Ignore Errors

- name: This won't fail the playbook
  command: /bin/false
  ignore_errors: yes

Failed When

- name: Check service status
  command: systemctl is-active myservice
  register: result
  failed_when: "'inactive' in result.stdout"

Changed When

- name: Check configuration
  command: /usr/local/bin/check_config.sh
  register: config_check
  changed_when: "'updated' in config_check.stdout"

Blocks and Rescue

---
- name: Error handling with blocks
  hosts: web_servers
  become: yes

  tasks:
    - name: Attempt deployment
      block:
        - name: Stop service
          service:
            name: myapp
            state: stopped

        - name: Deploy new version
          copy:
            src: /tmp/app-v2.0.tar.gz
            dest: /opt/myapp/

        - name: Start service
          service:
            name: myapp
            state: started

      rescue:
        - name: Rollback on failure
          debug:
            msg: "Deployment failed, rolling back..."

        - name: Restore backup
          copy:
            src: /opt/myapp/backup/
            dest: /opt/myapp/

        - name: Restart with old version
          service:
            name: myapp
            state: restarted

      always:
        - name: This always runs
          debug:
            msg: "Deployment attempt completed"

Tags

---
- name: Using tags
  hosts: web_servers

  tasks:
    - name: Install packages
      yum:
        name: httpd
        state: present
      tags:
        - install
        - packages

    - name: Configure Apache
      template:
        src: httpd.conf.j2
        dest: /etc/httpd/conf/httpd.conf
      tags:
        - configure

    - name: Start service
      service:
        name: httpd
        state: started
      tags:
        - service
        - start

Run specific tags:

ansible-playbook site.yml --tags "install"
ansible-playbook site.yml --tags "configure,service"
ansible-playbook site.yml --skip-tags "install"

Import and Include

Include Tasks

---
- name: Main playbook
  hosts: all

  tasks:
    - name: Include common tasks
      include_tasks: tasks/common.yml

    - name: Include OS-specific tasks
      include_tasks: "tasks/{{ ansible_os_family }}.yml"

Import Playbook

---
# site.yml
- import_playbook: webservers.yml
- import_playbook: databases.yml
- import_playbook: loadbalancers.yml

Advanced Playbook Features

Delegation and Run Once

Run tasks on different hosts or only once:

---
- name: Delegation examples
  hosts: webservers

  tasks:
    - name: Add server to load balancer
      command: /usr/local/bin/add_to_lb.sh {{ inventory_hostname }}
      delegate_to: loadbalancer.example.com

    - name: Run on localhost
      command: /usr/local/bin/backup_script.sh
      delegate_to: localhost

    - name: Create backup directory (only once)
      file:
        path: /backup/{{ ansible_date_time.date }}
        state: directory
      run_once: true
      delegate_to: backup_server

    - name: Database migration (run once across all hosts)
      command: /opt/app/migrate.sh
      run_once: true

Serial Execution

Control how many hosts run in parallel for rolling updates:

---
- name: Rolling update
  hosts: webservers
  serial: 2  # Update 2 servers at a time

  tasks:
    - name: Remove from load balancer
      command: /usr/local/bin/remove_from_lb.sh {{ inventory_hostname }}
      delegate_to: loadbalancer

    - name: Update application
      yum:
        name: myapp
        state: latest

    - name: Restart service
      service:
        name: myapp
        state: restarted

    - name: Wait for service to be ready
      wait_for:
        port: 8080
        delay: 5
        timeout: 60

    - name: Add back to load balancer
      command: /usr/local/bin/add_to_lb.sh {{ inventory_hostname }}
      delegate_to: loadbalancer

# Alternative: Use percentages
- name: Rolling update by percentage
  hosts: webservers
  serial: "25%"  # Update 25% of hosts at a time

Pre-tasks and Post-tasks

Tasks that run before/after main tasks and roles:

---
- name: Application deployment
  hosts: appservers

  pre_tasks:
    - name: Announce deployment start
      slack:
        token: "{{ slack_token }}"
        msg: "Deployment starting on {{ inventory_hostname }}"

    - name: Take backup before deployment
      shell: /usr/local/bin/backup.sh
      register: backup_result

    - name: Verify backup successful
      assert:
        that:
          - backup_result.rc == 0
        fail_msg: "Backup failed, aborting deployment"

  tasks:
    - name: Deploy application
      copy:
        src: /tmp/app-v2.0.tar.gz
        dest: /opt/myapp/

  post_tasks:
    - name: Verify deployment
      uri:
        url: http://localhost:8080/health
        status_code: 200
      register: health_check
      retries: 5
      delay: 10

    - name: Announce deployment complete
      slack:
        token: "{{ slack_token }}"
        msg: "Deployment completed successfully on {{ inventory_hostname }}"

Wait For and Assert

Wait for conditions and make assertions:

---
- name: Advanced waiting and assertions
  hosts: web servers

  tasks:
    - name: Wait for port to be available
      wait_for:
        port: 8080
        delay: 5
        timeout: 300
        state: started

    - name: Wait for file to exist
      wait_for:
        path: /var/run/myapp.pid
        state: present
        timeout: 60

    - name: Wait for service to be stopped
      wait_for:
        port: 8080
        state: stopped
        timeout: 120

    - name: Wait for string in file
      wait_for:
        path: /var/log/myapp.log
        search_regex: "Application started successfully"
        timeout: 180

    - name: Check system requirements
      assert:
        that:
          - ansible_memtotal_mb >= 4096
          - ansible_processor_vcpus >= 2
          - ansible_distribution_version is version('20.04', '>=')
        fail_msg: "System does not meet minimum requirements"
        success_msg: "System requirements verified"

    - name: Assert variables are defined
      assert:
        that:
          - database_host is defined
          - database_password is defined
          - app_version is defined
        quiet: true

Meta Tasks

Special tasks for controlling playbook execution:

---
- name: Meta task examples
  hosts: all

  tasks:
    - name: Refresh inventory
      meta: refresh_inventory

    - name: Clear gathered facts
      meta: clear_facts

    - name: Clear host errors
      meta: clear_host_errors

    - name: End play for this host
      meta: end_host
      when: ansible_distribution != "Ubuntu"

    - name: End entire play
      meta: end_play
      when: emergency_stop is defined

    - name: Flush handlers now (don't wait until end)
      meta: flush_handlers

    - name: Reset connection
      meta: reset_connection

Advanced Loops and Filters

---
- name: Advanced loop examples
  hosts: localhost
  gather_facts: no

  vars:
    users:
      - name: alice
        uid: 1001
        groups: ['wheel', 'developers']
      - name: bob
        uid: 1002
        groups: ['users']

  tasks:
    - name: Loop with condition
      debug:
        msg: "{{ item.name }} is in wheel group"
      loop: "{{ users }}"
      when: "'wheel' in item.groups"

    - name: Loop until condition met
      shell: /usr/bin/check_service.sh
      register: result
      until: result.rc == 0
      retries: 10
      delay: 5

    - name: Nested loops (cartesian product)
      debug:
        msg: "{{ item.0 }} - {{ item.1 }}"
      loop: "{{ ['web', 'db', 'cache'] | product(['dev', 'staging', 'prod']) }}"

    - name: Loop over dictionary
      debug:
        msg: "{{ item.key }}: {{ item.value }}"
      loop: "{{ {'name': 'myapp', 'version': '2.0', 'port': 8080} | dict2items }}"

    - name: Loop with subelements
      user:
        name: "{{ item.1 }}"
        groups: "{{ item.0.name }}"
      loop: "{{ groups | subelements('users') }}"

    - name: Parallel execution with async
      command: /usr/bin/long_task.sh {{ item }}
      loop: "{{ servers }}"
      async: 300
      poll: 0
      register: async_results

    - name: Wait for async tasks
      async_status:
        jid: "{{ item.ansible_job_id }}"
      loop: "{{ async_results.results }}"
      register: async_poll
      until: async_poll.finished
      retries: 30
      delay: 10

Advanced Templating with Jinja2

---
- name: Advanced Jinja2 templating
  hosts: webservers
  vars:
    app_env: production
    enable_ssl: true
    upstream_servers:
      - { host: '192.168.1.10', weight: 3 }
      - { host: '192.168.1.11', weight: 2 }
      - { host: '192.168.1.12', weight: 1 }

  tasks:
    - name: Deploy nginx config with advanced template
      template:
        src: nginx-advanced.conf.j2
        dest: /etc/nginx/nginx.conf
      notify: reload nginx

Template file (nginx-advanced.conf.j2):

{% set total_weight = upstream_servers | map(attribute='weight') | sum %}

upstream backend {
    {% for server in upstream_servers %}
    server {{ server.host }} weight={{ server.weight }};
    {% endfor %}
}

server {
    listen 80;
    server_name {{ ansible_fqdn }};

    {% if enable_ssl %}
    listen 443 ssl;
    ssl_certificate /etc/ssl/certs/{{ ansible_hostname }}.crt;
    ssl_certificate_key /etc/ssl/private/{{ ansible_hostname }}.key;
    {% endif %}

    location / {
        proxy_pass http://backend;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;

        {% if app_env == 'production' %}
        proxy_cache my_cache;
        proxy_cache_valid 200 1h;
        {% endif %}
    }

    # Environment-specific settings
    {% if app_env == 'development' %}
    error_log /var/log/nginx/error.log debug;
    {% else %}
    error_log /var/log/nginx/error.log warn;
    {% endif %}
}

# Generated on {{ ansible_date_time.iso8601 }}
# Total backend weight: {{ total_weight }}

Multi-Play Playbooks

Multiple plays in one playbook for complex orchestrations:

---
# Play 1: Configure load balancers
- name: Setup load balancers
  hosts: loadbalancers
  become: yes

  tasks:
    - name: Install HAProxy
      yum:
        name: haproxy
        state: present

    - name: Configure HAProxy
      template:
        src: haproxy.cfg.j2
        dest: /etc/haproxy/haproxy.cfg
      notify: restart haproxy

  handlers:
    - name: restart haproxy
      service:
        name: haproxy
        state: restarted

# Play 2: Configure application servers
- name: Deploy application servers
  hosts: appservers
  become: yes
  serial: "33%"

  tasks:
    - name: Deploy application
      include_role:
        name: application
        tasks_from: deploy

    - name: Register with load balancer
      uri:
        url: "http://{{ groups['loadbalancers'][0] }}/api/register"
        method: POST
        body_format: json
        body:
          host: "{{ inventory_hostname }}"
          port: 8080

# Play 3: Configure database
- name: Setup database
  hosts: databases[0]  # Only primary database
  become: yes

  tasks:
    - name: Initialize database
      mysql_db:
        name: myapp
        state: present

    - name: Run migrations
      command: /opt/myapp/migrate.sh

# Play 4: Health checks
- name: Verify deployment
  hosts: localhost
  gather_facts: no

  tasks:
    - name: Check application health
      uri:
        url: "http://{{ item }}/health"
        status_code: 200
      loop: "{{ groups['appservers'] }}"
      register: health_checks

    - name: Report results
      debug:
        msg: "All services healthy!"
      when: health_checks is succeeded

Dynamic Includes

Include tasks dynamically based on conditions:

---
- name: Dynamic task inclusion
  hosts: all

  tasks:
    - name: Include OS-specific variables
      include_vars: "vars/{{ ansible_os_family }}.yml"

    - name: Include distribution-specific tasks
      include_tasks: "tasks/{{ ansible_distribution | lower }}.yml"
      when: ansible_distribution in ['Ubuntu', 'CentOS', 'RedHat']

    - name: Include tasks with loop
      include_tasks: deploy_app.yml
      loop:
        - { app: 'web', port: 8080 }
        - { app: 'api', port: 8081 }
        - { app: 'admin', port: 8082 }
      loop_control:
        loop_var: app_config

    - name: Conditionally include tasks
      include_tasks: secure_hardening.yml
      when:
        - environment == 'production'
        - security_hardening | default(true)

    - name: Include role with inline vars
      include_role:
        name: nginx
      vars:
        nginx_port: 8080
        nginx_workers: 4

Execution Strategies

Control how Ansible executes tasks across hosts:

---
# Linear Strategy (default) - waits for all hosts before next task
- name: Linear execution
  hosts: all
  strategy: linear

  tasks:
    - name: This waits for all hosts
      command: /bin/sleep 5

# Free Strategy - hosts proceed independently
- name: Free execution
  hosts: all
  strategy: free

  tasks:
    - name: Hosts don't wait for each other
      command: /usr/local/bin/long_task.sh

# Debug Strategy - interactive debugging
- name: Debug execution
  hosts: all
  strategy: debug

  tasks:
    - name: Task to debug
      command: /bin/false

# Host Pinned Strategy - tasks run on same host until completion
- name: Host pinned execution
  hosts: all
  strategy: host_pinned

  tasks:
    - name: All tasks for a host run together
      shell: echo "Processing {{ inventory_hostname }}"

Playbook Organization Best Practices

Directory Structure

production                # Inventory for production
staging                   # Inventory for staging

group_vars/
   all.yml                # Variables for all groups
   webservers.yml         # Variables for webservers group
   databases.yml          # Variables for databases group

host_vars/
   web01.example.com.yml  # Variables for specific host

library/                  # Custom modules
module_utils/             # Custom module utilities
filter_plugins/           # Custom Jinja2 filters

roles/
   common/                # Common role
   webserver/             # Web server role
   database/              # Database role

site.yml                  # Main playbook
webservers.yml            # Webserver-specific playbook
databases.yml             # Database-specific playbook

ansible.cfg               # Ansible configuration

Naming Conventions

File Naming Best Practices:
  • Playbooks: Use descriptive names (deploy-webserver.yml)
  • Variables: Use snake_case (app_port, db_host)
  • Roles: Single, descriptive words (nginx, postgresql)
  • Tags: Use categories (install, configure, deploy)
  • Handlers: Use action verbs (restart apache, reload nginx)

Testing and Validation

# Syntax validation
ansible-playbook playbook.yml --syntax-check

# Dry run (check mode)
ansible-playbook playbook.yml --check

# Dry run with diff
ansible-playbook playbook.yml --check --diff

# List all tasks
ansible-playbook playbook.yml --list-tasks

# List all tags
ansible-playbook playbook.yml --list-tags

# List all hosts
ansible-playbook playbook.yml --list-hosts

# Step through tasks interactively
ansible-playbook playbook.yml --step

# Start at specific task
ansible-playbook playbook.yml --start-at-task="Install packages"

# Limit to specific hosts
ansible-playbook playbook.yml --limit "web01,web02"

# Extra verbosity
ansible-playbook playbook.yml -vvv

Common Playbook Patterns

Blue-Green Deployment

---
- name: Blue-Green Deployment
  hosts: localhost
  gather_facts: no

  vars:
    active_env: blue
    new_env: green

  tasks:
    - name: Deploy to inactive environment
      include_role:
        name: deploy
      vars:
        target_env: "{{ new_env }}"
        target_hosts: "{{ groups[new_env] }}"

    - name: Run smoke tests
      include_tasks: tests/smoke_tests.yml
      vars:
        test_env: "{{ new_env }}"

    - name: Switch load balancer to new environment
      include_tasks: switch_lb.yml
      vars:
        from_env: "{{ active_env }}"
        to_env: "{{ new_env }}"

    - name: Monitor for issues
      pause:
        minutes: 5
        prompt: "Monitoring new deployment. Press Ctrl+C to rollback, or wait to continue"

    - name: Decommission old environment
      include_role:
        name: decommission
      vars:
        target_env: "{{ active_env }}"

Canary Deployment

---
- name: Canary Deployment
  hosts: webservers
  serial:
    - 1        # First host (canary)
    - 25%      # 25% of remaining
    - 100%     # Rest of fleet

  tasks:
    - name: Deploy new version
      copy:
        src: app-v2.tar.gz
        dest: /opt/app/

    - name: Restart service
      service:
        name: myapp
        state: restarted

    - name: Wait for service
      wait_for:
        port: 8080
        timeout: 60

    - name: Health check
      uri:
        url: http://{{ inventory_hostname }}:8080/health
        status_code: 200
      retries: 5
      delay: 10

    - name: Pause for monitoring (canary only)
      pause:
        minutes: 10
        prompt: "Monitor canary host. Ctrl+C to abort deployment"
      when: ansible_play_batch.index(inventory_hostname) == 0

Best Practices

  • Always name your plays and tasks descriptively
  • Use ansible-playbook --syntax-check to validate syntax
  • Use --check mode to test without making changes
  • Keep playbooks simple and use roles for complex logic
  • Use variables for values that might change
  • Document your playbooks with comments
  • Version control your playbooks (Git)
  • Use handlers instead of always restarting services
  • Implement error handling for critical operations
  • Use tags for selective execution
  • Test playbooks in development before production
  • Use serial execution for rolling updates
  • Implement proper logging and notification
  • Use assertions to validate prerequisites
  • Keep secrets in Ansible Vault

Next Steps