Complete Guide to Ansible Playbooks: Structure, Variables, Handlers, and Best Practices

Ansible playbooks are the foundation of automation in Ansible, allowing you to define complex IT infrastructure configurations and orchestrations in simple YAML files. This comprehensive guide covers everything from basic playbook structure to advanced concepts like handlers, variables, and multi-OS support with practical, production-ready examples.

What is an Ansible Playbook?

An Ansible playbook is a YAML file that defines a series of tasks to be executed on remote hosts. Think of it as a recipe or instruction manual that tells Ansible exactly what to do, where to do it, and how to do it. Playbooks are:

  • Declarative: You describe the desired state, not the steps to get there
  • Idempotent: Running the same playbook multiple times produces the same result
  • Human-readable: Written in YAML, easy to understand and maintain
  • Version-controlled: Can be stored in Git for collaboration and history

Basic Playbook Structure

Every Ansible playbook consists of one or more “plays”. Each play maps a group of hosts to a set of tasks. Here’s the anatomy of a playbook:

---
- name: Play Name
  hosts: target_hosts
  become: yes
  gather_facts: yes

  vars:
    variable_name: value

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

  handlers:
    - name: Handler name
      module_name:
        parameter: value

Key Components Explained

  • name: Descriptive name for the play (optional but recommended)
  • hosts: Target hosts or groups from inventory
  • become: Execute tasks with elevated privileges (sudo)
  • gather_facts: Collect system information before running tasks
  • vars: Variables used throughout the playbook
  • tasks: List of actions to perform
  • handlers: Tasks triggered by other tasks (usually for service restarts)

Complete Example: Web Server Setup

Let’s build a complete playbook that sets up Apache web server across different Linux distributions. This example demonstrates variables, conditionals, handlers, and best practices:

---
# =============================================================================
# Basic Web Server Setup - Complete Ansible Playbook Example
# =============================================================================
# This playbook installs and configures Apache web server on Ubuntu/Debian
# and RHEL/CentOS systems, demonstrating cross-platform automation

- name: Basic Web Server Setup
  hosts: webservers
  become: yes
  gather_facts: yes

  vars:
    # Configuration variables
    http_port: 80
    https_port: 443
    max_clients: 200
    server_admin: admin@example.com

  tasks:
    # ==========================================================================
    # TASK 1: Update Package Cache
    # ==========================================================================
    # Before installing packages, we need to update the package cache
    # This is OS-specific, so we use 'when' conditionals

    - name: Update apt package cache (Debian/Ubuntu)
      apt:
        update_cache: yes
        cache_valid_time: 3600  # Cache valid for 1 hour
      when: ansible_os_family == "Debian"

    - name: Update yum package cache (RHEL/CentOS)
      yum:
        update_cache: yes
      when: ansible_os_family == "RedHat"

    # ==========================================================================
    # TASK 2: Install Apache Web Server
    # ==========================================================================
    # Apache package name differs between distributions:
    # - Debian/Ubuntu: apache2
    # - RHEL/CentOS: httpd

    - name: Install Apache on Debian/Ubuntu
      apt:
        name: apache2
        state: present  # Ensure package is installed
      when: ansible_os_family == "Debian"
      notify: Restart Apache Debian  # Trigger handler if package installed

    - name: Install Apache on RHEL/CentOS
      yum:
        name: httpd
        state: present
      when: ansible_os_family == "RedHat"
      notify: Restart Apache RedHat

    # ==========================================================================
    # TASK 3: Ensure Apache is Running and Enabled
    # ==========================================================================
    # Service module ensures Apache starts on boot and is currently running

    - name: Ensure Apache is running (Debian)
      service:
        name: apache2
        state: started    # Start service if not running
        enabled: yes      # Enable service to start on boot
      when: ansible_os_family == "Debian"

    - name: Ensure Apache is running (RHEL)
      service:
        name: httpd
        state: started
        enabled: yes
      when: ansible_os_family == "RedHat"

    # ==========================================================================
    # TASK 4: Display Server Information
    # ==========================================================================
    # Debug module displays information during playbook execution
    # Using multi-line format with Jinja2 variables

    - name: Display server information
      debug:
        msg: |
          Server: {{ ansible_hostname }}
          OS: {{ ansible_distribution }} {{ ansible_distribution_version }}
          IP Address: {{ ansible_default_ipv4.address }}
          HTTP Port: {{ http_port }}
          HTTPS Port: {{ https_port }}
          Admin Email: {{ server_admin }}

  # ============================================================================
  # HANDLERS - Execute only when notified by tasks
  # ============================================================================
  # Handlers run only once at the end of the play, even if notified multiple times
  # They're typically used for service restarts after configuration changes

  handlers:
    - name: Restart Apache Debian
      service:
        name: apache2
        state: restarted

    - name: Restart Apache RedHat
      service:
        name: httpd
        state: restarted

Understanding Key Concepts

1. Variables

Variables make playbooks reusable and flexible. In our example, we define configuration values like ports and admin email:

vars:
  http_port: 80
  https_port: 443
  max_clients: 200
  server_admin: admin@example.com

Variables are referenced using Jinja2 syntax: {{ variable_name }}

2. Ansible Facts

When gather_facts: yes is set, Ansible automatically collects system information. Common facts include:

  • ansible_hostname – Server hostname
  • ansible_distribution – OS distribution (Ubuntu, CentOS, etc.)
  • ansible_distribution_version – OS version number
  • ansible_os_family – OS family (Debian, RedHat, etc.)
  • ansible_default_ipv4.address – Primary IP address
  • ansible_memtotal_mb – Total memory in MB
  • ansible_processor_vcpus – Number of CPU cores

3. Conditionals (when)

The when clause allows conditional task execution. This is crucial for cross-platform playbooks:

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

This task only runs on Debian-based systems. Common conditional patterns:

# Equality check
when: environment == "production"

# Multiple conditions (AND)
when:
  - ansible_distribution == "Ubuntu"
  - ansible_distribution_version >= "20.04"

# OR conditions
when: environment == "dev" or environment == "staging"

# Variable defined check
when: database_host is defined

# String matching
when: ansible_hostname is match('web.*')

4. Handlers

Handlers are special tasks that run only when notified and only once per play, regardless of how many times they’re notified. They’re perfect for service restarts:

tasks:
  - name: Update Apache configuration
    template:
      src: apache.conf.j2
      dest: /etc/apache2/apache2.conf
    notify: Restart Apache  # Triggers handler

handlers:
  - name: Restart Apache
    service:
      name: apache2
      state: restarted

Key points about handlers:

  • Only run if the task that notifies them reports a “changed” status
  • Run at the end of the play, not immediately
  • Run only once even if notified multiple times
  • Run in the order they’re defined, not the order they’re notified

Running the Playbook

Create an Inventory File

First, create an inventory file that defines your target servers:

# inventory
[webservers]
web01 ansible_host=192.168.1.10 ansible_user=ubuntu
web02 ansible_host=192.168.1.11 ansible_user=ubuntu
web03 ansible_host=192.168.1.12 ansible_user=centos

[webservers:vars]
ansible_ssh_private_key_file=~/.ssh/id_rsa

Execute the Playbook

# Basic execution
ansible-playbook -i inventory basic-webserver.yml

# Check mode (dry run - doesn't make changes)
ansible-playbook -i inventory basic-webserver.yml --check

# Verbose output (helpful for debugging)
ansible-playbook -i inventory basic-webserver.yml -v   # Level 1
ansible-playbook -i inventory basic-webserver.yml -vv  # Level 2
ansible-playbook -i inventory basic-webserver.yml -vvv # Level 3

# Limit to specific hosts
ansible-playbook -i inventory basic-webserver.yml --limit web01

# With extra variables (override defaults)
ansible-playbook -i inventory basic-webserver.yml -e "http_port=8080"

# Step-by-step execution (confirm each task)
ansible-playbook -i inventory basic-webserver.yml --step

Best Practices

1. Always Name Your Tasks

Descriptive task names make playbook output readable and help with debugging:

# Good
- name: Install Nginx web server on Ubuntu 20.04
  apt:
    name: nginx
    state: present

# Bad (no name)
- apt:
    name: nginx
    state: present

2. Use Idempotent Modules

Most Ansible modules are idempotent – running them multiple times produces the same result:

# Idempotent - safe to run multiple times
- name: Ensure user exists
  user:
    name: webadmin
    state: present

# NOT idempotent - appends every time
- name: Add line to file
  shell: echo "config=value" >> /etc/config.conf  # WRONG

# Idempotent alternative
- name: Add line to file
  lineinfile:
    path: /etc/config.conf
    line: "config=value"
    state: present

3. Use Variables for Reusability

Extract configuration values into variables:

# Good - easy to change
vars:
  app_port: 8080
  app_user: webapp

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

# Bad - hardcoded values
tasks:
  - name: Create app user
    user:
      name: webapp

4. Use become Wisely

Apply privilege escalation only where needed:

# At play level (all tasks run as root)
- name: System configuration
  hosts: servers
  become: yes
  tasks: [...]

# At task level (only specific tasks run as root)
- name: Mixed privilege tasks
  hosts: servers
  tasks:
    - name: Read user file (no sudo needed)
      command: cat ~/file.txt

    - name: Install package (needs sudo)
      apt:
        name: nginx
        state: present
      become: yes

5. Organize with Comments

Use comments to explain complex logic:

tasks:
  # First, we need to stop the service to prevent lock conflicts
  - name: Stop application service
    service:
      name: myapp
      state: stopped

  # Update configuration files while service is stopped
  - name: Deploy new configuration
    template:
      src: app.conf.j2
      dest: /etc/myapp/app.conf

  # Finally, restart with new configuration
  - name: Start application service
    service:
      name: myapp
      state: started

Common Patterns and Examples

Installing Multiple Packages

- name: Install required packages
  apt:
    name:
      - vim
      - git
      - htop
      - curl
      - wget
      - python3-pip
    state: present
    update_cache: yes

Creating Directories with Proper Permissions

- name: Create application directories
  file:
    path: "{{ item }}"
    state: directory
    mode: '0755'
    owner: www-data
    group: www-data
  loop:
    - /var/www/myapp
    - /var/www/myapp/uploads
    - /var/www/myapp/logs

Deploying Configuration Files

- name: Deploy Apache virtual host configuration
  copy:
    content: |
      
          ServerName {{ ansible_hostname }}
          DocumentRoot /var/www/html
          ErrorLog ${APACHE_LOG_DIR}/error.log
          CustomLog ${APACHE_LOG_DIR}/access.log combined
      
    dest: /etc/apache2/sites-available/mysite.conf
    mode: '0644'
  notify: Reload Apache

Conditional Service Management

- name: Check if service exists
  stat:
    path: /etc/systemd/system/myapp.service
  register: service_status

- name: Start service if exists
  service:
    name: myapp
    state: started
  when: service_status.stat.exists

Troubleshooting Common Issues

Issue: “Permission denied”

Solution: Add become: yes to tasks that need root privileges:

- name: Install package
  apt:
    name: nginx
    state: present
  become: yes

Issue: “Module not found”

Solution: Update Ansible or install required collections:

# Update Ansible
pip install --upgrade ansible

# Install specific collection
ansible-galaxy collection install community.general

Issue: Tasks not idempotent

Solution: Use changed_when to control change reporting:

- name: Check service status
  command: systemctl status nginx
  register: result
  changed_when: false  # Never report as changed
  failed_when: result.rc not in [0, 3]  # 0=running, 3=stopped

Issue: Slow playbook execution

Solution: Disable fact gathering if not needed:

- name: Quick tasks
  hosts: servers
  gather_facts: no  # Skip fact gathering
  tasks: [...]

Advanced Tips

Using ansible.cfg for Defaults

Create ansible.cfg in your project directory:

[defaults]
inventory = ./inventory
host_key_checking = False
retry_files_enabled = False
gathering = smart
fact_caching = jsonfile
fact_caching_connection = /tmp/ansible_facts

[privilege_escalation]
become = True
become_method = sudo
become_user = root

Tags for Selective Execution

tasks:
  - name: Install packages
    apt:
      name: nginx
      state: present
    tags: ['install', 'packages']

  - name: Configure service
    template:
      src: nginx.conf.j2
      dest: /etc/nginx/nginx.conf
    tags: ['config', 'nginx']

# Run only tagged tasks
# ansible-playbook playbook.yml --tags "install"
# ansible-playbook playbook.yml --skip-tags "config"

Next Steps

Now that you understand basic playbooks, you can explore:

  • Jinja2 Templates: Create dynamic configuration files
  • Loops and Iteration: Process multiple items efficiently
  • Roles: Organize playbooks into reusable components
  • Ansible Vault: Encrypt sensitive data
  • Dynamic Inventory: Pull inventory from cloud providers

Summary

Ansible playbooks provide a powerful yet simple way to automate IT infrastructure. Key takeaways:

  • Playbooks are YAML files defining desired system state
  • Tasks execute in order on specified hosts
  • Variables make playbooks flexible and reusable
  • Conditionals enable cross-platform automation
  • Handlers manage service restarts efficiently
  • Facts provide system information for decision-making
  • Idempotency ensures safe repeated execution

Start simple, test thoroughly, and gradually build more complex automation as you gain confidence with Ansible playbooks.

Was this article helpful?

RS

About the Author: Ramesh Sundararamaiah

Red Hat Certified Architect

Ramesh is a Red Hat Certified Architect with extensive experience in enterprise Linux environments. He specializes in system administration, DevOps automation, and cloud infrastructure. Ramesh has helped organizations implement robust Linux solutions and optimize their IT operations for performance and reliability.

Expertise: Red Hat Enterprise Linux, CentOS, Ubuntu, Docker, Ansible, System Administration, DevOps

Add Comment