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)
- Role defaults
- Inventory file or script group vars
- Playbook group_vars/all
- Playbook group_vars/*
- Playbook host_vars/*
- Inventory file host vars
- Playbook vars
- Set facts
- Registered vars
- Task vars
- Extra vars (
-ein 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
- 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-checkto validate syntax - Use
--checkmode 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
- Learn about Roles and Collections for code reusability
- Master Ansible Vault for secrets management
- Explore Advanced Topics like plugins and callbacks
- Try interactive examples in the Playground