Writing Your First Playbook
Build a complete, production-ready playbook with error handling, conditionals, and proper structure.
Now that you understand Ansible's basic concepts, let's build a complete playbook that demonstrates real-world patterns. You'll create a playbook that sets up a web server with proper error handling, security configurations, and flexibility to work across different environments.
Planning Your Playbook
Before writing any code, think about what you want to accomplish. Our example playbook will:
- Install and configure nginx
- Set up a firewall with appropriate rules
- Create a custom index page
- Handle different operating systems
- Include proper error checking
- Be flexible enough for different environments
This approach - planning before coding - prevents playbooks from becoming disorganized collections of tasks.
Building the Basic Structure
Create a new file called web-setup.yml
:
---
- name: Set up web server with security configurations
hosts: webservers
become: yes
gather_facts: yes
vars:
web_user: www-data
web_port: 80
allowed_ports:
- 22
- 80
- 443
tasks:
- name: Update package cache (Debian/Ubuntu)
apt:
update_cache: yes
cache_valid_time: 3600
when: ansible_os_family == "Debian"
- name: Install nginx web server
package:
name: nginx
state: present
Notice several improvements over basic examples:
gather_facts: yes
explicitly enables fact collection- The
package
module works across different Linux distributions when
conditions make tasks conditionalcache_valid_time
prevents unnecessary cache updates
Adding Conditional Logic
Different Linux distributions require different approaches. Let's handle multiple operating systems:
- name: Install firewall (Ubuntu/Debian)
apt:
name: ufw
state: present
when: ansible_os_family == "Debian"
- name: Install firewall (CentOS/RHEL)
yum:
name: firewalld
state: present
when: ansible_os_family == "RedHat"
- name: Configure ufw firewall rules (Ubuntu/Debian)
ufw:
rule: allow
port: '{{ item }}'
proto: tcp
loop: '{{ allowed_ports }}'
when: ansible_os_family == "Debian"
- name: Enable ufw firewall (Ubuntu/Debian)
ufw:
state: enabled
when: ansible_os_family == "Debian"
The when
clause uses Ansible facts to determine the operating system family. This makes your playbook work across different Linux distributions without modification.
Implementing Error Handling
Real-world automation needs robust error handling. Let's add checks to ensure our configuration is successful:
- name: Start nginx service
service:
name: nginx
state: started
enabled: yes
register: nginx_service_result
- name: Verify nginx is responding
uri:
url: 'http://{{ ansible_default_ipv4.address }}:{{ web_port }}'
method: GET
status_code: 200
register: nginx_response
retries: 3
delay: 5
until: nginx_response.status == 200
- name: Display service status
debug:
msg: 'Nginx is running and responding on port {{ web_port }}'
when: nginx_response.status == 200
This sequence:
- Starts nginx and registers the result
- Tests that nginx responds to HTTP requests
- Retries the test up to 3 times with 5-second delays
- Displays success only when verification passes
The register
keyword captures task output for use in later tasks. The until
clause repeats the task until the condition is met.
Creating Dynamic Content
Instead of static files, let's generate content based on system information:
- name: Create custom index page
copy:
content: |
<!DOCTYPE html>
<html>
<head>
<title>{{ inventory_hostname }} - Web Server</title>
<style>
body { font-family: Arial, sans-serif; margin: 40px; }
.info { background-color: #f0f0f0; padding: 20px; border-radius: 5px; }
</style>
</head>
<body>
<h1>Web Server Information</h1>
<div class="info">
<p><strong>Hostname:</strong> {{ inventory_hostname }}</p>
<p><strong>IP Address:</strong> {{ ansible_default_ipv4.address }}</p>
<p><strong>Operating System:</strong> {{ ansible_distribution }} {{ ansible_distribution_version }}</p>
<p><strong>Total Memory:</strong> {{ ansible_memory_mb.real.total }}MB</p>
<p><strong>Processor Count:</strong> {{ ansible_processor_vcpus }}</p>
<p><strong>Server Status:</strong> Online and Running</p>
</div>
<p><em>Last updated: {{ ansible_date_time.iso8601 }}</em></p>
</body>
</html>
dest: /var/www/html/index.html
owner: '{{ web_user }}'
group: '{{ web_user }}'
mode: '0644'
notify: reload nginx
This task creates a dynamic HTML page using Ansible facts. The notify
directive triggers a handler when the file changes.
Adding Handlers
Handlers run only when notified by other tasks and only run once, even if notified multiple times. They're perfect for restarting services after configuration changes:
handlers:
- name: reload nginx
service:
name: nginx
state: reloaded
- name: restart nginx
service:
name: nginx
state: restarted
Place handlers at the same indentation level as tasks
. They run after all tasks complete, ensuring services restart only once even if multiple configuration files change.
Adding Pre-task Validation
Before making changes, validate that the system meets requirements:
pre_tasks:
- name: Check if system has sufficient memory
fail:
msg: 'System requires at least 1GB RAM, but only has {{ ansible_memory_mb.real.total }}MB'
when: ansible_memory_mb.real.total < 1024
- name: Verify SSH connectivity
ping:
- name: Check disk space
shell: df / | tail -1 | awk '{print $5}' | sed 's/%//'
register: disk_usage
changed_when: false
- name: Fail if disk usage is too high
fail:
msg: 'Root filesystem is {{ disk_usage.stdout }}% full. Need at least 20% free space.'
when: disk_usage.stdout|int > 80
Pre-tasks run before regular tasks and can prevent playbook execution if conditions aren't met. The changed_when: false
directive prevents the shell command from being marked as a change.
The Complete Playbook
Here's the full playbook with all components:
---
- name: Set up web server with security configurations
hosts: webservers
become: yes
gather_facts: yes
vars:
web_user: www-data
web_port: 80
allowed_ports:
- 22
- 80
- 443
pre_tasks:
- name: Check system requirements
fail:
msg: 'System requires at least 1GB RAM'
when: ansible_memory_mb.real.total < 1024
- name: Verify connectivity
ping:
tasks:
- name: Update package cache (Debian/Ubuntu)
apt:
update_cache: yes
cache_valid_time: 3600
when: ansible_os_family == "Debian"
- name: Install required packages
package:
name: '{{ item }}'
state: present
loop:
- nginx
- ufw
when: ansible_os_family == "Debian"
- name: Configure firewall rules
ufw:
rule: allow
port: '{{ item }}'
proto: tcp
loop: '{{ allowed_ports }}'
when: ansible_os_family == "Debian"
- name: Enable firewall
ufw:
state: enabled
when: ansible_os_family == "Debian"
- name: Start and enable nginx
service:
name: nginx
state: started
enabled: yes
register: nginx_start
- name: Create custom index page
copy:
content: |
<!DOCTYPE html>
<html>
<head>
<title>{{ inventory_hostname }} - Web Server</title>
<style>
body { font-family: Arial, sans-serif; margin: 40px; }
.info { background-color: #f0f0f0; padding: 20px; border-radius: 5px; }
</style>
</head>
<body>
<h1>{{ inventory_hostname }} Web Server</h1>
<div class="info">
<p><strong>IP:</strong> {{ ansible_default_ipv4.address }}</p>
<p><strong>OS:</strong> {{ ansible_distribution }} {{ ansible_distribution_version }}</p>
<p><strong>Memory:</strong> {{ ansible_memory_mb.real.total }}MB</p>
<p><strong>Status:</strong> Online</p>
</div>
<p><em>Configured by Ansible on {{ ansible_date_time.iso8601 }}</em></p>
</body>
</html>
dest: /var/www/html/index.html
owner: '{{ web_user }}'
group: '{{ web_user }}'
mode: '0644'
notify: reload nginx
- name: Verify web server is responding
uri:
url: 'http://{{ ansible_default_ipv4.address }}:{{ web_port }}'
method: GET
status_code: 200
register: web_check
retries: 3
delay: 5
until: web_check.status == 200
- name: Display success message
debug:
msg: 'Web server successfully configured at http://{{ ansible_default_ipv4.address }}'
handlers:
- name: reload nginx
service:
name: nginx
state: reloaded
post_tasks:
- name: Run final verification
uri:
url: 'http://{{ ansible_default_ipv4.address }}'
return_content: yes
register: final_check
- name: Confirm deployment
debug:
msg: 'Deployment complete. Server is responding correctly.'
when: final_check.status == 200
Running and Testing Your Playbook
Execute your playbook with additional options for better output:
# Run with verbose output to see detailed information
ansible-playbook web-setup.yml -v
# Run in check mode to see what would change
ansible-playbook web-setup.yml --check
# Run and limit to specific hosts
ansible-playbook web-setup.yml --limit web1
# Run with step-by-step confirmation
ansible-playbook web-setup.yml --step
The --step
option asks for confirmation before each task, useful for testing or when you want to carefully control execution.
Troubleshooting Common Issues
When playbooks don't work as expected, use these debugging techniques:
Adding Debug Output
Insert debug tasks to inspect variables:
- name: Debug system information
debug:
var: ansible_facts
when: debug_mode is defined
Run with: ansible-playbook web-setup.yml -e debug_mode=true
Using Tags for Selective Execution
Add tags to run only specific parts of your playbook:
- name: Install packages
package:
name: nginx
state: present
tags:
- packages
- install
- name: Configure firewall
ufw:
rule: allow
port: 80
tags:
- security
- firewall
Run only firewall tasks: ansible-playbook web-setup.yml --tags firewall
Handling Task Failures
Some tasks might fail in ways you can recover from:
- name: Attempt to install optional package
package:
name: htop
state: present
ignore_errors: yes
register: htop_install
- name: Report optional package status
debug:
msg: "htop installation: {{ 'successful' if htop_install.failed == false else 'failed (not critical)' }}"
The ignore_errors: yes
directive continues playbook execution even if the task fails.
Next Steps
You've built a comprehensive playbook that demonstrates many of Ansible's powerful features. This playbook includes validation, error handling, cross-platform support, and verification - patterns you'll use in production automation.
In the next section, we'll explore how to organize multiple servers using inventory files, groups, and variables. You'll learn to manage different environments and scale your automation across larger infrastructures.
The structured approach you've learned here - planning, implementing with error handling, and thorough testing - will serve you well as we tackle more complex scenarios.
Happy configuring!
Found an issue?