Ansible Examples
Real-world examples of refactoring Ansible playbooks and roles to improve maintainability, reusability, and adherence to best practices.
Extract Roles from Playbooks¶
Problem: Monolithic playbook doing everything¶
Before (280-line playbook with everything inline):
---
- name: Configure web servers
hosts: webservers
become: true
vars:
app_name: myapp
app_version: 1.0.0
app_port: 8080
tasks:
- name: Install system packages
apt:
name:
- nginx
- python3-pip
- git
- ufw
state: present
update_cache: yes
- name: Create application user
user:
name: "{{ app_name }}"
system: yes
shell: /bin/bash
home: "/opt/{{ app_name }}"
- name: Create application directories
file:
path: "{{ item }}"
state: directory
owner: "{{ app_name }}"
group: "{{ app_name }}"
mode: '0755'
loop:
- "/opt/{{ app_name }}"
- "/opt/{{ app_name }}/releases"
- "/opt/{{ app_name }}/shared"
- "/var/log/{{ app_name }}"
- name: Clone application repository
git:
repo: "https://github.com/example/myapp.git"
dest: "/opt/{{ app_name }}/releases/{{ app_version }}"
version: "v{{ app_version }}"
become_user: "{{ app_name }}"
- name: Install Python dependencies
pip:
requirements: "/opt/{{ app_name }}/releases/{{ app_version }}/requirements.txt"
virtualenv: "/opt/{{ app_name }}/venv"
become_user: "{{ app_name }}"
- name: Copy application config
template:
src: app_config.j2
dest: "/opt/{{ app_name }}/shared/config.yaml"
owner: "{{ app_name }}"
group: "{{ app_name }}"
mode: '0640'
- name: Create systemd service file
template:
src: systemd_service.j2
dest: "/etc/systemd/system/{{ app_name }}.service"
mode: '0644'
notify: reload systemd
- name: Configure nginx
template:
src: nginx_config.j2
dest: "/etc/nginx/sites-available/{{ app_name }}"
mode: '0644'
notify: restart nginx
- name: Enable nginx site
file:
src: "/etc/nginx/sites-available/{{ app_name }}"
dest: "/etc/nginx/sites-enabled/{{ app_name }}"
state: link
notify: restart nginx
- name: Configure firewall
ufw:
rule: allow
port: "{{ item }}"
proto: tcp
loop:
- "80"
- "443"
- "{{ app_port }}"
- name: Enable firewall
ufw:
state: enabled
# ... 100+ more lines of tasks ...
handlers:
- name: reload systemd
systemd:
daemon_reload: yes
- name: restart nginx
service:
name: nginx
state: restarted
- name: restart application
service:
name: "{{ app_name }}"
state: restarted
After (modular role-based structure):
## playbooks/configure_webservers.yml (now 20 lines)
---
- name: Configure web servers
hosts: webservers
become: true
roles:
- role: common
tags: common
- role: nginx
tags: nginx
- role: application
vars:
app_name: myapp
app_version: 1.0.0
app_port: 8080
app_repo: "https://github.com/example/myapp.git"
tags: application
- role: firewall
vars:
firewall_allowed_ports:
- { port: 80, proto: tcp }
- { port: 443, proto: tcp }
- { port: 8080, proto: tcp }
tags: firewall
## roles/common/tasks/main.yml
---
- name: Install system packages
apt:
name: "{{ common_packages }}"
state: present
update_cache: yes
cache_valid_time: 3600
- name: Configure timezone
timezone:
name: "{{ system_timezone }}"
- name: Set hostname
hostname:
name: "{{ inventory_hostname }}"
## roles/common/defaults/main.yml
---
common_packages:
- curl
- git
- vim
- ufw
- python3-pip
system_timezone: "UTC"
## roles/application/tasks/main.yml
---
- name: Include user setup
import_tasks: user.yml
- name: Include directory setup
import_tasks: directories.yml
- name: Include deployment tasks
import_tasks: deploy.yml
- name: Include service configuration
import_tasks: service.yml
## roles/application/tasks/user.yml
---
- name: Create application user
user:
name: "{{ app_name }}"
system: yes
shell: /bin/bash
home: "{{ app_home }}"
create_home: yes
## roles/application/tasks/directories.yml
---
- name: Create application directories
file:
path: "{{ item }}"
state: directory
owner: "{{ app_name }}"
group: "{{ app_name }}"
mode: '0755'
loop: "{{ app_directories }}"
## roles/application/tasks/deploy.yml
---
- name: Clone application repository
git:
repo: "{{ app_repo }}"
dest: "{{ app_release_path }}"
version: "v{{ app_version }}"
become_user: "{{ app_name }}"
notify: restart application
- name: Install Python dependencies
pip:
requirements: "{{ app_release_path }}/requirements.txt"
virtualenv: "{{ app_venv_path }}"
become_user: "{{ app_name }}"
- name: Copy application config
template:
src: config.yaml.j2
dest: "{{ app_shared_path }}/config.yaml"
owner: "{{ app_name }}"
group: "{{ app_name }}"
mode: '0640'
notify: restart application
## roles/application/tasks/service.yml
---
- name: Create systemd service file
template:
src: systemd.service.j2
dest: "/etc/systemd/system/{{ app_name }}.service"
mode: '0644'
notify:
- reload systemd
- restart application
- name: Enable and start application service
service:
name: "{{ app_name }}"
enabled: yes
state: started
## roles/application/defaults/main.yml
---
app_home: "/opt/{{ app_name }}"
app_release_path: "{{ app_home }}/releases/{{ app_version }}"
app_shared_path: "{{ app_home }}/shared"
app_venv_path: "{{ app_home }}/venv"
app_directories:
- "{{ app_home }}"
- "{{ app_home }}/releases"
- "{{ app_home }}/shared"
- "/var/log/{{ app_name }}"
## roles/application/handlers/main.yml
---
- name: reload systemd
systemd:
daemon_reload: yes
- name: restart application
service:
name: "{{ app_name }}"
state: restarted
## roles/nginx/tasks/main.yml
---
- name: Install nginx
apt:
name: nginx
state: present
- name: Configure nginx site
template:
src: site.conf.j2
dest: "/etc/nginx/sites-available/{{ nginx_site_name }}"
mode: '0644'
notify: restart nginx
- name: Enable nginx site
file:
src: "/etc/nginx/sites-available/{{ nginx_site_name }}"
dest: "/etc/nginx/sites-enabled/{{ nginx_site_name }}"
state: link
notify: restart nginx
- name: Start and enable nginx
service:
name: nginx
enabled: yes
state: started
## roles/firewall/tasks/main.yml
---
- name: Configure firewall rules
ufw:
rule: "{{ item.rule | default('allow') }}"
port: "{{ item.port }}"
proto: "{{ item.proto }}"
loop: "{{ firewall_allowed_ports }}"
- name: Enable firewall
ufw:
state: enabled
Improvements:
- ✅ Modular structure with reusable roles
- ✅ Each role has single responsibility
- ✅ Roles can be tested independently
- ✅ Easy to reuse across different playbooks
- ✅ Clear separation of concerns
- ✅ Playbook is now 20 lines instead of 280
Use Blocks for Error Handling¶
Problem: No error handling, tasks fail and leave system in inconsistent state¶
Before:
---
- name: Deploy application
hosts: webservers
become: true
tasks:
- name: Stop application service
service:
name: myapp
state: stopped
- name: Backup current version
archive:
path: /opt/myapp/current
dest: /opt/myapp/backups/backup-{{ ansible_date_time.epoch }}.tar.gz
- name: Download new version
get_url:
url: "https://releases.example.com/myapp/v2.0.0.tar.gz"
dest: /tmp/myapp-v2.0.0.tar.gz
- name: Extract new version
unarchive:
src: /tmp/myapp-v2.0.0.tar.gz
dest: /opt/myapp/current
remote_src: yes
- name: Run database migrations
command: /opt/myapp/current/bin/migrate
# If this fails, app is stopped and new version is broken!
- name: Start application service
service:
name: myapp
state: started
# This never runs if migration fails
After:
---
- name: Deploy application
hosts: webservers
become: true
tasks:
- name: Deploy application with rollback on failure
block:
- name: Stop application service
service:
name: myapp
state: stopped
- name: Backup current version
archive:
path: /opt/myapp/current
dest: /opt/myapp/backups/backup-{{ ansible_date_time.epoch }}.tar.gz
register: backup_result
- name: Download new version
get_url:
url: "{{ app_release_url }}"
dest: "/tmp/{{ app_package_name }}"
checksum: "{{ app_checksum }}"
register: download_result
- name: Create staging directory
file:
path: /opt/myapp/staging
state: directory
mode: '0755'
- name: Extract new version to staging
unarchive:
src: "/tmp/{{ app_package_name }}"
dest: /opt/myapp/staging
remote_src: yes
- name: Run database migrations
command: /opt/myapp/staging/bin/migrate
environment:
DATABASE_URL: "{{ database_url }}"
register: migration_result
changed_when: "'Applied' in migration_result.stdout"
- name: Smoke test new version
uri:
url: "http://localhost:8080/health"
status_code: 200
register: health_check
retries: 3
delay: 5
- name: Replace current with new version
shell: |
rm -rf /opt/myapp/current
mv /opt/myapp/staging /opt/myapp/current
args:
warn: false
- name: Start application service
service:
name: myapp
state: started
- name: Wait for application to be ready
uri:
url: "http://localhost:8080/health"
status_code: 200
register: final_health_check
until: final_health_check.status == 200
retries: 10
delay: 5
rescue:
- name: Log deployment failure
debug:
msg: "Deployment failed. Rolling back to previous version."
- name: Stop failed application
service:
name: myapp
state: stopped
ignore_errors: yes
- name: Restore backup
unarchive:
src: "{{ backup_result.dest }}"
dest: /opt/myapp/current
remote_src: yes
when: backup_result is defined and backup_result.dest is defined
- name: Rollback database migrations
command: /opt/myapp/current/bin/migrate rollback
environment:
DATABASE_URL: "{{ database_url }}"
ignore_errors: yes
- name: Start application service (previous version)
service:
name: myapp
state: started
- name: Verify rollback succeeded
uri:
url: "http://localhost:8080/health"
status_code: 200
register: rollback_health_check
retries: 5
delay: 5
- name: Fail with clear error message
fail:
msg: |
Deployment failed and rolled back to previous version.
Error: {{ ansible_failed_result.msg | default('Unknown error') }}
always:
- name: Clean up temporary files
file:
path: "{{ item }}"
state: absent
loop:
- "/tmp/{{ app_package_name }}"
- /opt/myapp/staging
ignore_errors: yes
- name: Send deployment notification
uri:
url: "{{ slack_webhook_url }}"
method: POST
body_format: json
body:
text: |
Deployment {{ 'succeeded' if ansible_failed_task is not defined else 'failed' }}
Host: {{ inventory_hostname }}
Version: {{ app_version }}
when: slack_webhook_url is defined
delegate_to: localhost
Improvements:
- ✅ Automatic rollback on failure
- ✅ Database migrations are reversible
- ✅ Cleanup happens regardless of success/failure
- ✅ Clear error messages
- ✅ Notifications sent for all outcomes
- ✅ System never left in inconsistent state
Apply Handlers Effectively¶
Problem: Tasks restart services multiple times unnecessarily¶
Before:
---
- name: Configure web server
hosts: webservers
become: true
tasks:
- name: Update nginx config
template:
src: nginx.conf.j2
dest: /etc/nginx/nginx.conf
mode: '0644'
- name: Restart nginx
service:
name: nginx
state: restarted
- name: Update site config
template:
src: site.conf.j2
dest: /etc/nginx/sites-available/mysite
mode: '0644'
- name: Restart nginx again
service:
name: nginx
state: restarted
- name: Copy SSL certificate
copy:
src: "{{ item }}"
dest: /etc/nginx/ssl/
mode: '0600'
loop:
- cert.pem
- key.pem
- name: Restart nginx yet again
service:
name: nginx
state: restarted
# Nginx restarted 3 times when once at the end would suffice!
After:
---
- name: Configure web server
hosts: webservers
become: true
tasks:
- name: Update nginx config
template:
src: nginx.conf.j2
dest: /etc/nginx/nginx.conf
mode: '0644'
validate: 'nginx -t -c %s'
notify: reload nginx
- name: Update site config
template:
src: site.conf.j2
dest: /etc/nginx/sites-available/mysite
mode: '0644'
notify: reload nginx
- name: Enable site
file:
src: /etc/nginx/sites-available/mysite
dest: /etc/nginx/sites-enabled/mysite
state: link
notify: reload nginx
- name: Copy SSL certificate
copy:
src: cert.pem
dest: /etc/nginx/ssl/cert.pem
mode: '0600'
notify: reload nginx
- name: Copy SSL private key
copy:
src: key.pem
dest: /etc/nginx/ssl/key.pem
mode: '0600'
notify: reload nginx
# Nginx will only reload once at the end, after all tasks complete
handlers:
- name: reload nginx
service:
name: nginx
state: reloaded
# Use reload instead of restart for zero-downtime
- name: restart nginx
service:
name: nginx
state: restarted
# Keep restart handler for when reload isn't enough
- name: validate nginx config
command: nginx -t
changed_when: false
# Validation handler for manual triggering
Even Better (with handler dependencies):
---
- name: Configure web server with handler dependencies
hosts: webservers
become: true
tasks:
- name: Update nginx config
template:
src: nginx.conf.j2
dest: /etc/nginx/nginx.conf
mode: '0644'
notify: validate and reload nginx
- name: Update site config
template:
src: site.conf.j2
dest: /etc/nginx/sites-available/mysite
mode: '0644'
notify: validate and reload nginx
- name: Update SSL configuration
template:
src: ssl.conf.j2
dest: /etc/nginx/conf.d/ssl.conf
mode: '0644'
notify: validate and reload nginx
handlers:
- name: validate and reload nginx
listen: "validate and reload nginx"
block:
- name: Validate nginx configuration
command: nginx -t
changed_when: false
- name: Reload nginx
service:
name: nginx
state: reloaded
rescue:
- name: Log validation failure
debug:
msg: "Nginx configuration validation failed. Not reloading."
- name: Restore previous configuration
command: cp /etc/nginx/nginx.conf.backup /etc/nginx/nginx.conf
when: nginx_backup_exists
- name: Fail with error
fail:
msg: "Nginx configuration is invalid. Check your templates."
Improvements:
- ✅ Service reloaded once instead of multiple times
- ✅ Use reload instead of restart (zero-downtime)
- ✅ Configuration validated before reload
- ✅ Handler runs only if notified
- ✅ Handler runs at end of play (all changes applied at once)
- ✅ Automatic rollback if validation fails
Simplify Conditionals¶
Problem: Complex when conditions repeated across tasks¶
Before:
---
- name: Configure application
hosts: all
become: true
tasks:
- name: Install package for production Ubuntu
apt:
name: myapp-pro
state: present
when:
- environment == "production"
- ansible_distribution == "Ubuntu"
- ansible_distribution_major_version|int >= 20
- name: Install package for production CentOS
yum:
name: myapp-pro
state: present
when:
- environment == "production"
- ansible_distribution == "CentOS"
- ansible_distribution_major_version|int >= 8
- name: Install package for staging Ubuntu
apt:
name: myapp-staging
state: present
when:
- environment == "staging"
- ansible_distribution == "Ubuntu"
- ansible_distribution_major_version|int >= 20
- name: Configure production database for Ubuntu
template:
src: db_config_prod.j2
dest: /etc/myapp/database.yml
when:
- environment == "production"
- ansible_distribution == "Ubuntu"
- ansible_distribution_major_version|int >= 20
# Repeated conditionals throughout...
After (using includes and group_vars):
## group_vars/production.yml
---
environment: production
app_package: myapp-pro
db_config_template: db_config_prod.j2
log_level: info
enable_monitoring: true
## group_vars/staging.yml
---
environment: staging
app_package: myapp-staging
db_config_template: db_config_staging.j2
log_level: debug
enable_monitoring: false
## playbook.yml
---
- name: Configure application
hosts: all
become: true
tasks:
- name: Include OS-specific variables
include_vars: "{{ item }}"
with_first_found:
- "{{ ansible_distribution }}-{{ ansible_distribution_major_version }}.yml"
- "{{ ansible_distribution }}.yml"
- "default.yml"
- name: Include OS-specific tasks
include_tasks: "{{ ansible_os_family | lower }}.yml"
## tasks/debian.yml (for Ubuntu/Debian)
---
- name: Install application package (Debian)
apt:
name: "{{ app_package }}"
state: present
update_cache: yes
when: ansible_distribution_major_version|int >= 20
- name: Configure database (Debian)
template:
src: "{{ db_config_template }}"
dest: /etc/myapp/database.yml
mode: '0640'
## tasks/redhat.yml (for CentOS/RHEL)
---
- name: Install application package (RedHat)
yum:
name: "{{ app_package }}"
state: present
when: ansible_distribution_major_version|int >= 8
- name: Configure database (RedHat)
template:
src: "{{ db_config_template }}"
dest: /etc/myapp/database.yml
mode: '0640'
Alternative (using set_fact for complex conditions):
---
- name: Configure application
hosts: all
become: true
tasks:
- name: Set environment facts
set_fact:
is_production_ubuntu: >-
{{ environment == 'production' and
ansible_distribution == 'Ubuntu' and
ansible_distribution_major_version|int >= 20 }}
is_production_centos: >-
{{ environment == 'production' and
ansible_distribution == 'CentOS' and
ansible_distribution_major_version|int >= 8 }}
is_staging: >-
{{ environment == 'staging' }}
- name: Install package for production Ubuntu
apt:
name: myapp-pro
state: present
when: is_production_ubuntu | bool
- name: Install package for production CentOS
yum:
name: myapp-pro
state: present
when: is_production_centos | bool
- name: Configure production database
template:
src: db_config_prod.j2
dest: /etc/myapp/database.yml
when: is_production_ubuntu | bool or is_production_centos | bool
Improvements:
- ✅ No repeated complex conditionals
- ✅ Environment-specific vars in group_vars
- ✅ OS-specific tasks in separate files
- ✅ Clear, readable conditions
- ✅ Easy to add new environments or OS types
Use Collections¶
Problem: Using deprecated or built-in modules with limited functionality¶
Before (using built-in modules):
---
- name: Manage AWS resources
hosts: localhost
gather_facts: no
tasks:
- name: Create EC2 instance (deprecated module)
ec2:
key_name: mykey
instance_type: t3.medium
image: ami-12345
wait: yes
group: webserver
count: 1
vpc_subnet_id: subnet-12345
assign_public_ip: yes
- name: Create S3 bucket (limited functionality)
s3_bucket:
name: my-bucket
state: present
- name: Manage RDS instance (basic module)
rds:
command: create
instance_name: mydb
db_engine: postgres
size: 20
instance_type: db.t3.medium
username: admin
password: "{{ db_password }}"
After (using amazon.aws collection):
---
- name: Manage AWS resources
hosts: localhost
gather_facts: no
collections:
- amazon.aws
- community.aws
tasks:
- name: Create EC2 instance (modern module)
ec2_instance:
key_name: mykey
instance_type: t3.medium
image_id: ami-12345
wait: yes
security_groups:
- webserver
vpc_subnet_id: subnet-12345
network:
assign_public_ip: yes
tags:
Name: "{{ instance_name }}"
Environment: "{{ environment }}"
state: running
- name: Create S3 bucket with advanced features
s3_bucket:
name: my-bucket
state: present
encryption: "AES256"
versioning: yes
public_access:
block_public_acls: yes
block_public_policy: yes
ignore_public_acls: yes
restrict_public_buckets: yes
tags:
Environment: "{{ environment }}"
- name: Create RDS instance with full configuration
rds_instance:
db_instance_identifier: mydb
engine: postgres
engine_version: "13.7"
db_instance_class: db.t3.medium
allocated_storage: 20
storage_type: gp3
storage_encrypted: yes
master_username: admin
master_user_password: "{{ db_password }}"
vpc_security_group_ids:
- "{{ db_security_group_id }}"
db_subnet_group_name: "{{ db_subnet_group }}"
backup_retention_period: 7
preferred_backup_window: "03:00-04:00"
preferred_maintenance_window: "sun:04:00-sun:05:00"
multi_az: yes
auto_minor_version_upgrade: yes
tags:
Name: mydb
Environment: "{{ environment }}"
state: present
- name: Query EC2 instance info
ec2_instance_info:
filters:
"tag:Environment": "{{ environment }}"
instance-state-name: running
register: ec2_info
- name: Display instance IPs
debug:
msg: "Instance {{ item.tags.Name }} has IP {{ item.public_ip_address }}"
loop: "{{ ec2_info.instances }}"
Using Multiple Collections:
---
- name: Complete infrastructure setup
hosts: localhost
gather_facts: no
collections:
- amazon.aws
- community.aws
- community.general
- ansible.posix
tasks:
- name: Create VPC
ec2_vpc_net:
name: "{{ vpc_name }}"
cidr_block: "{{ vpc_cidr }}"
region: "{{ aws_region }}"
tags: "{{ common_tags }}"
state: present
register: vpc
- name: Create CloudWatch log group (community.aws)
cloudwatchlogs_log_group:
log_group_name: "/aws/{{ environment }}/{{ app_name }}"
retention: 7
state: present
- name: Send Slack notification (community.general)
slack:
token: "{{ slack_token }}"
msg: "Infrastructure provisioning started for {{ environment }}"
channel: "#ops"
delegate_to: localhost
Improvements:
- ✅ Modern, maintained modules with full features
- ✅ Better error handling and return values
- ✅ Support for latest AWS features
- ✅ Consistent module interface across providers
- ✅ Community-maintained collections stay up-to-date
- ✅ Access to specialized modules (monitoring, logging, etc.)
Resources¶
Tools¶
- ansible-lint: Linting for playbooks and roles
- yamllint: YAML syntax checking
- molecule: Role testing framework
- ansible-playbook --syntax-check: Syntax validation