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:

  1. Variables files - Store reusable configuration data

  2. Task files - Group related tasks together

  3. Playbooks - Complete automation workflows that can be imported

  4. 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:

  1. Static reuse (import_*) - Content is included at playbook parse time

  2. 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:

  1. Static imports are processed during playbook parsing

  2. Dynamic includes are processed at runtime

  3. Role dependencies are resolved before execution

  4. 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