Reusing Ansible Tasks, Playbooks, And Roles
Last updated: June 30, 2025
My Journey from Monolithic to Modular Automation
When I first started writing Ansible playbooks, I created massive monolithic files that contained everything—from package installation to service configuration, from file management to user creation. These playbooks worked, but they were difficult to maintain, impossible to test properly, and nearly unusable across different projects.
As my automation needs grew, I realized I was repeatedly writing similar tasks for different projects. Installing Apache, configuring MySQL, setting up users, managing firewall rules—these patterns appeared again and again. That's when I discovered the power of reusing Ansible code through tasks, playbooks, and roles.
Learning to break down my automation into reusable components was transformative. Instead of copy-pasting code between projects, I could create modular, testable, and shareable automation components. This approach not only made my playbooks more maintainable but also significantly reduced development time for new projects.
In this blog post, I'll share my experiences with creating and using reusable Ansible components, demonstrating practical examples for both Linux and Windows environments. Whether you're managing a handful of servers or a complex multi-platform infrastructure, mastering code reuse will dramatically improve your automation efficiency.
Understanding Ansible Code Reuse
Ansible provides several mechanisms for code reuse, each serving different purposes:
Variables files - Store reusable configuration data
Task files - Group related tasks together
Playbooks - Complete automation workflows that can be imported
Roles - Comprehensive packages containing tasks, variables, files, templates, and handlers
The key to effective automation is understanding when to use each approach and how to structure your code for maximum reusability.
Reusing Playbooks with import_playbook
The simplest form of playbook reuse is importing entire playbooks into other playbooks. This approach is useful when you have complete automation workflows that you want to chain together.
Basic Playbook Import
Let's start with a simple example. Suppose we have separate playbooks for different components of a LAMP stack:
apache.yml
---
- name: Install and configure Apache
hosts: web_servers
become: yes
tasks:
- name: Install Apache package
ansible.builtin.package:
name: "{{ apache_package }}"
state: present
vars:
apache_package: "{{ 'httpd' if ansible_facts['os_family'] == 'RedHat' else 'apache2' }}"
- name: Start and enable Apache
ansible.builtin.service:
name: "{{ apache_service }}"
state: started
enabled: yes
vars:
apache_service: "{{ 'httpd' if ansible_facts['os_family'] == 'RedHat' else 'apache2' }}"
- name: Configure firewall for HTTP
ansible.posix.firewalld:
service: http
permanent: yes
state: enabled
immediate: yes
when: ansible_facts['os_family'] == 'RedHat'
mysql.yml
---
- name: Install and configure MySQL
hosts: database_servers
become: yes
tasks:
- name: Install MySQL package
ansible.builtin.package:
name: "{{ mysql_package }}"
state: present
vars:
mysql_package: "{{ 'mysql-server' if ansible_facts['os_family'] == 'RedHat' else 'mysql-server' }}"
- name: Start and enable MySQL
ansible.builtin.service:
name: "{{ mysql_service }}"
state: started
enabled: yes
vars:
mysql_service: "{{ 'mysqld' if ansible_facts['os_family'] == 'RedHat' else 'mysql' }}"
- name: Configure MySQL root password
mysql_user:
name: root
password: "{{ mysql_root_password | default('secure_password') }}"
login_unix_socket: /var/run/mysqld/mysqld.sock
when: ansible_facts['os_family'] == 'Debian'
main.yml
---
# Main playbook that orchestrates LAMP stack deployment
- import_playbook: apache.yml
- import_playbook: mysql.yml
- name: Final configuration tasks
hosts: all
tasks:
- name: Verify services are running
ansible.builtin.service_facts:
- name: Display service status
ansible.builtin.debug:
msg: "{{ item }} is {{ ansible_facts.services[item].state }}"
loop:
- "{{ 'httpd' if ansible_facts['os_family'] == 'RedHat' else 'apache2' }}"
- "{{ 'mysqld' if ansible_facts['os_family'] == 'RedHat' else 'mysql' }}"
when: ansible_facts.services[item] is defined
Windows Example
Here's a similar approach for Windows environments:
iis.yml
---
- name: Install and configure IIS
hosts: windows_web_servers
tasks:
- name: Install IIS feature
win_feature:
name: IIS-WebServerRole
state: present
- name: Install IIS management tools
win_feature:
name: IIS-ManagementConsole
state: present
- name: Start W3SVC service
win_service:
name: W3SVC
state: started
start_mode: auto
- name: Configure Windows Firewall for HTTP
win_firewall_rule:
name: Allow HTTP
localport: 80
action: allow
direction: in
protocol: tcp
state: present
sqlserver.yml
---
- name: Install and configure SQL Server
hosts: windows_database_servers
tasks:
- name: Download SQL Server installer
win_get_url:
url: "{{ sqlserver_installer_url }}"
dest: C:\temp\SQLServer2019-x64-ENU-Dev.exe
- name: Install SQL Server
win_package:
path: C:\temp\SQLServer2019-x64-ENU-Dev.exe
arguments: '/Q /IACCEPTSQLSERVERLICENSETERMS'
state: present
- name: Start SQL Server service
win_service:
name: MSSQLSERVER
state: started
start_mode: auto
Static vs Dynamic Reuse
Ansible provides two approaches for including external content:
Static reuse (import_*) - Content is included at playbook parse time
Dynamic reuse (include_*) - Content is included at runtime
Understanding the difference is crucial for effective code organization.
Static Reuse (import_*)
Static imports are processed when the playbook is parsed, meaning the content becomes part of the main playbook structure:
---
- name: Static reuse example
hosts: all
tasks:
- import_tasks: common_tasks.yml
- import_tasks: security_tasks.yml
vars:
security_level: high
common_tasks.yml
---
- name: Update package cache
ansible.builtin.package:
update_cache: yes
when: ansible_facts['os_family'] == 'Debian'
- name: Install essential packages
ansible.builtin.package:
name:
- vim
- curl
- wget
- htop
state: present
- name: Set timezone
community.general.timezone:
name: "{{ server_timezone | default('UTC') }}"
security_tasks.yml
---
- name: Configure SSH security
ansible.builtin.lineinfile:
path: /etc/ssh/sshd_config
regexp: "{{ item.regexp }}"
line: "{{ item.line }}"
backup: yes
loop:
- { regexp: '^#?PermitRootLogin', line: 'PermitRootLogin no' }
- { regexp: '^#?PasswordAuthentication', line: 'PasswordAuthentication no' }
- { regexp: '^#?MaxAuthTries', line: 'MaxAuthTries 3' }
when: security_level == 'high'
notify: restart ssh
- name: Configure firewall
ansible.posix.firewalld:
service: ssh
permanent: yes
state: enabled
immediate: yes
when: ansible_facts['os_family'] == 'RedHat' and security_level == 'high'
Dynamic Reuse (include_*)
Dynamic includes are processed at runtime, allowing for more flexible, conditional inclusion:
---
- name: Dynamic reuse example
hosts: all
tasks:
- name: Include OS-specific tasks
include_tasks: "{{ ansible_facts['os_family'] | lower }}_tasks.yml"
- name: Include role-specific tasks
include_tasks: "{{ server_role }}_tasks.yml"
when: server_role is defined
- name: Include tasks with loop
include_tasks: service_tasks.yml
vars:
service_name: "{{ item }}"
loop:
- nginx
- postgresql
- redis
redhat_tasks.yml
---
- name: Enable EPEL repository
ansible.builtin.yum:
name: epel-release
state: present
- name: Install Red Hat specific packages
ansible.builtin.yum:
name:
- firewalld
- selinux-policy-targeted
state: present
debian_tasks.yml
---
- name: Update APT package cache
ansible.builtin.apt:
update_cache: yes
cache_valid_time: 3600
- name: Install Debian specific packages
ansible.builtin.apt:
name:
- ufw
- apparmor
state: present
Working with Roles
Roles are the most powerful and flexible way to organize and reuse Ansible code. They provide a standardized directory structure that makes code organization intuitive and sharing straightforward.
Role Directory Structure
Here's the standard structure for an Ansible role:
roles/
webserver/
defaults/
main.yml # Default variables
vars/
main.yml # Role variables
tasks/
main.yml # Main task list
install.yml # Additional task files
configure.yml
handlers/
main.yml # Handlers
templates/
httpd.conf.j2 # Jinja2 templates
index.html.j2
files/
favicon.ico # Static files
meta/
main.yml # Role metadata and dependencies
Creating a Web Server Role
Let's create a comprehensive web server role that works on both Linux and Windows:
roles/webserver/defaults/main.yml
---
# Default variables for webserver role
webserver_port: 80
webserver_ssl_port: 443
webserver_document_root: "/var/www/html"
webserver_index_file: "index.html"
webserver_enable_ssl: false
webserver_firewall_enabled: true
# OS-specific defaults
webserver_packages:
RedHat:
- httpd
- httpd-tools
Debian:
- apache2
- apache2-utils
Windows:
- IIS-WebServerRole
- IIS-ManagementConsole
webserver_service_name:
RedHat: httpd
Debian: apache2
Windows: W3SVC
webserver_config_path:
RedHat: "/etc/httpd/conf/httpd.conf"
Debian: "/etc/apache2/apache2.conf"
Windows: "C:\\inetpub\\wwwroot\\web.config"
roles/webserver/tasks/main.yml
---
# Main task file for webserver role
- name: Include OS-specific variables
include_vars: "{{ ansible_facts['os_family'] }}.yml"
when: ansible_facts['os_family'] != 'Windows'
- name: Include Windows-specific variables
include_vars: "Windows.yml"
when: ansible_facts['os_family'] == 'Windows'
- name: Install web server
include_tasks: "install_{{ ansible_facts['os_family'] | lower }}.yml"
- name: Configure web server
include_tasks: "configure_{{ ansible_facts['os_family'] | lower }}.yml"
- name: Configure firewall
include_tasks: "firewall_{{ ansible_facts['os_family'] | lower }}.yml"
when: webserver_firewall_enabled
- name: Ensure web server is started and enabled
include_tasks: "service_{{ ansible_facts['os_family'] | lower }}.yml"
roles/webserver/tasks/install_redhat.yml
---
- name: Install Apache on Red Hat systems
ansible.builtin.yum:
name: "{{ webserver_packages[ansible_facts['os_family']] }}"
state: present
- name: Install SSL module if SSL is enabled
ansible.builtin.yum:
name: mod_ssl
state: present
when: webserver_enable_ssl
roles/webserver/tasks/install_debian.yml
---
- name: Update package cache
ansible.builtin.apt:
update_cache: yes
cache_valid_time: 3600
- name: Install Apache on Debian systems
ansible.builtin.apt:
name: "{{ webserver_packages[ansible_facts['os_family']] }}"
state: present
- name: Enable SSL module if SSL is enabled
apache2_module:
name: ssl
state: present
when: webserver_enable_ssl
notify: restart apache
roles/webserver/tasks/install_windows.yml
---
- name: Install IIS features on Windows
win_feature:
name: "{{ item }}"
state: present
loop: "{{ webserver_packages[ansible_facts['os_family']] }}"
- name: Install SSL feature if SSL is enabled
win_feature:
name: IIS-HttpsRedirect
state: present
when: webserver_enable_ssl
roles/webserver/tasks/configure_redhat.yml
---
- name: Configure Apache from template
ansible.builtin.template:
src: httpd.conf.j2
dest: "{{ webserver_config_path[ansible_facts['os_family']] }}"
backup: yes
notify: restart apache
- name: Create document root directory
ansible.builtin.file:
path: "{{ webserver_document_root }}"
state: directory
owner: apache
group: apache
mode: '0755'
- name: Deploy index page
ansible.builtin.template:
src: index.html.j2
dest: "{{ webserver_document_root }}/{{ webserver_index_file }}"
owner: apache
group: apache
mode: '0644'
roles/webserver/handlers/main.yml
---
- name: restart apache
ansible.builtin.service:
name: "{{ webserver_service_name[ansible_facts['os_family']] }}"
state: restarted
when: ansible_facts['os_family'] != 'Windows'
- name: restart iis
win_service:
name: "{{ webserver_service_name[ansible_facts['os_family']] }}"
state: restarted
when: ansible_facts['os_family'] == 'Windows'
roles/webserver/templates/httpd.conf.j2
# Apache configuration template
ServerRoot /etc/httpd
Listen {{ webserver_port }}
{% if webserver_enable_ssl %}
Listen {{ webserver_ssl_port }} ssl
{% endif %}
ServerName {{ ansible_fqdn }}:{{ webserver_port }}
DocumentRoot {{ webserver_document_root }}
<Directory {{ webserver_document_root }}>
Options Indexes FollowSymLinks
AllowOverride None
Require all granted
</Directory>
DirectoryIndex {{ webserver_index_file }}
# Security headers
Header always set X-Content-Type-Options nosniff
Header always set X-Frame-Options DENY
Header always set X-XSS-Protection "1; mode=block"
# Log configuration
ErrorLog logs/error_log
CustomLog logs/access_log combined
Using Roles
Now we can use our webserver role in different ways:
Using roles at the play level:
---
- name: Deploy web servers
hosts: web_servers
become: yes
roles:
- role: webserver
vars:
webserver_port: 8080
webserver_enable_ssl: true
Using include_role for dynamic inclusion:
---
- name: Conditional web server deployment
hosts: all
tasks:
- name: Deploy web server on designated hosts
include_role:
name: webserver
vars:
webserver_document_root: "/opt/webapp"
when: "'web' in group_names"
Using import_role for static inclusion:
---
- name: Static web server deployment
hosts: web_servers
tasks:
- name: Pre-deployment tasks
ansible.builtin.debug:
msg: "Preparing for web server deployment"
- import_role:
name: webserver
- name: Post-deployment tasks
ansible.builtin.debug:
msg: "Web server deployment completed"
Role Dependencies
Roles can depend on other roles, creating a dependency chain that Ansible resolves automatically.
roles/webserver/meta/main.yml
---
dependencies:
- role: common
vars:
common_packages:
- curl
- wget
- unzip
- role: firewall
vars:
firewall_allowed_ports:
- "{{ webserver_port }}"
- "{{ webserver_ssl_port if webserver_enable_ssl else [] }}"
galaxy_info:
author: Your Name
description: Cross-platform web server role
license: MIT
min_ansible_version: 2.9
platforms:
- name: EL
versions:
- 7
- 8
- 9
- name: Ubuntu
versions:
- 18.04
- 20.04
- 22.04
- name: Windows
versions:
- 2016
- 2019
- 2022
Sequence Diagram: Ansible Code Reuse Flow
Here's a sequence diagram illustrating how Ansible processes different types of code reuse:
This diagram shows how:
Static imports are processed during playbook parsing
Dynamic includes are processed at runtime
Role dependencies are resolved before execution
Tasks execute in a specific order (pre_tasks → roles → tasks → post_tasks → handlers)
Advanced Role Techniques
Role Argument Validation
You can validate role arguments using argument specifications:
roles/webserver/meta/argument_specs.yml
---
argument_specs:
main:
short_description: Main entry point for webserver role
description:
- This role installs and configures a web server
- Supports Apache on Linux and IIS on Windows
options:
webserver_port:
type: int
required: false
default: 80
description: Port for web server to listen on
webserver_enable_ssl:
type: bool
required: false
default: false
description: Whether to enable SSL support
webserver_document_root:
type: path
required: false
default: "/var/www/html"
description: Document root directory
Multiple Role Entry Points
Roles can have multiple entry points for different use cases:
roles/webserver/tasks/maintenance.yml
---
- name: Stop web server
ansible.builtin.service:
name: "{{ webserver_service_name[ansible_facts['os_family']] }}"
state: stopped
- name: Backup configuration
ansible.builtin.copy:
src: "{{ webserver_config_path[ansible_facts['os_family']] }}"
dest: "{{ webserver_config_path[ansible_facts['os_family']] }}.backup.{{ ansible_date_time.epoch }}"
remote_src: yes
- name: Clear log files
ansible.builtin.shell: |
find /var/log -name "*{{ webserver_service_name[ansible_facts['os_family']] }}*" -type f -exec truncate -s 0 {} \;
when: ansible_facts['os_family'] != 'Windows'
Using alternative entry points:
---
- name: Maintenance tasks
hosts: web_servers
tasks:
- include_role:
name: webserver
tasks_from: maintenance.yml
Best Practices for Code Reuse
Based on my experience building reusable Ansible code, here are some key best practices:
1. Structure for Reusability
Use meaningful variable names with role prefixes
Organize files logically within roles
Document variables and their purposes
Use defaults for common values
2. Make Code OS-Agnostic
# Good: OS-agnostic approach
- name: Install package
ansible.builtin.package:
name: "{{ package_name }}"
state: present
# Better: Handle OS differences gracefully
- name: Install package
ansible.builtin.package:
name: "{{ packages[ansible_facts['os_family']] }}"
state: present
vars:
packages:
RedHat: httpd
Debian: apache2
Windows: IIS-WebServerRole
3. Use Proper Variable Precedence
Understand Ansible's variable precedence to avoid conflicts:
# roles/webserver/defaults/main.yml - lowest precedence
webserver_port: 80
# roles/webserver/vars/main.yml - higher precedence
webserver_config_template: "httpd.conf.j2"
# Playbook vars - even higher precedence
# Group vars and host vars - highest precedence
4. Version Control and Documentation
# roles/webserver/meta/main.yml
galaxy_info:
author: Your Name
description: Production-ready web server role
license: MIT
min_ansible_version: 2.9
# Semantic versioning
version: 1.2.0
# Clear categorization
galaxy_tags:
- web
- apache
- iis
- server
Real-World Example: Multi-Tier Application Deployment
Let's put it all together with a complete example that deploys a multi-tier web application:
site.yml
---
# Main site deployment playbook
- import_playbook: infrastructure.yml
- import_playbook: database.yml
- import_playbook: application.yml
- import_playbook: monitoring.yml
- name: Final verification
hosts: all
tasks:
- name: Run health checks
include_tasks: health_checks.yml
infrastructure.yml
---
- name: Configure infrastructure
hosts: all
become: yes
roles:
- role: common
vars:
common_timezone: "America/New_York"
common_ntp_servers:
- pool.ntp.org
- role: security
vars:
security_ssh_port: 2222
security_fail2ban_enabled: true
- name: Configure load balancers
hosts: load_balancers
become: yes
roles:
- role: nginx
vars:
nginx_role: loadbalancer
nginx_upstream_servers: "{{ groups['web_servers'] }}"
application.yml
---
- name: Deploy web application
hosts: web_servers
become: yes
serial: "25%" # Rolling deployment
pre_tasks:
- name: Remove from load balancer
uri:
url: "http://{{ item }}:8080/remove/{{ inventory_hostname }}"
method: POST
loop: "{{ groups['load_balancers'] }}"
delegate_to: localhost
roles:
- role: webserver
vars:
webserver_enable_ssl: true
webserver_ssl_cert: "{{ ssl_certificate }}"
- role: application
vars:
app_version: "{{ deploy_version | default('latest') }}"
app_environment: "{{ target_environment }}"
post_tasks:
- name: Add back to load balancer
uri:
url: "http://{{ item }}:8080/add/{{ inventory_hostname }}"
method: POST
loop: "{{ groups['load_balancers'] }}"
delegate_to: localhost
This example demonstrates:
Playbook imports for different deployment phases
Role dependencies and variable passing
Rolling deployments with load balancer integration
Separation of concerns between infrastructure and application
Conclusion
Mastering code reuse in Ansible transforms how you approach automation. Instead of writing monolithic playbooks, you can create modular, testable, and shareable components that significantly reduce development time and improve maintainability.
The journey from copy-paste automation to elegant, reusable code isn't always straightforward, but the benefits are substantial. You'll find yourself building a library of proven automation components that can be combined in countless ways to solve new challenges.
As you develop your own reusable components, remember that the best automation code is not just functional—it's readable, documented, and designed with future modifications in mind. Start small, refactor frequently, and always consider how your code might be used by others (including your future self).
Further Resources
Last updated