Ansible OEL 8 DevOps · OEL 8 · Roles

AnsibleMulti-Role Orchestration

How to compose multiple roles in a single play, when to use roles: vs import_role vs include_role, the four most common orchestration patterns, and how to pass data between roles cleanly.

Real systems rarely need just one role. A typical database host wants OS hardening, the database itself, monitoring, and firewall rules — four roles applied in order. The trick is composing them so each role stays focused while the play wires them together.

PLAY · hosts: databases 3 roles applied in order 1. common SSH hardening, NTP, sysctl, package updates 2. mysql install MySQL · render my.cnf · start mysqld 3. monitoring install node_exporter · open metrics port 9100 Each host: hardened · MySQL running · metrics exported

The simplest case: list roles in order. Ansible runs them in sequence. Use this when roles don't need to be conditionally selected.

YAML — sequential roles
---
- hosts: databases
  become: true
  vars:
    mysql_root_password: "{{{{ vault_mysql_pwd }}}}"

  roles:
    - common              # OS hardening, NTP, SSH config
    - firewall            # firewalld baseline
    - mysql               # install + configure MySQL
    - mysql_replication   # set up replication if enabled
    - monitoring          # node_exporter, mysqld_exporter

The same role list, but apply some only on specific hosts:

YAML — role with when
---
- hosts: databases
  become: true
  roles:
    - common
    - mysql

    # Only on the primary
    - role: mysql_primary_setup
      when: inventory_hostname in groups['mysql_primary']

    # Only on replicas
    - role: mysql_replica_setup
      when: inventory_hostname in groups['mysql_replica']

    # Only if SSL is requested
    - role: mysql_ssl
      when: enable_mysql_ssl | default(false) | bool
💡 Tip: when on a role applies to every task inside it. The same condition appearing on each task individually is a smell — wrap them in a single when on the role invocation.

When the role to apply is decided at runtime — for example based on a fact:

YAML — include_role for OS-specific role
---
- hosts: all
  tasks:
    - name: Apply OS-specific hardening
      ansible.builtin.include_role:
        name: "harden-{{{{ ansible_distribution | lower }}}}"
      # picks harden-oraclelinux, harden-ubuntu, harden-debian, etc.

    - name: Apply role based on user choice
      ansible.builtin.include_role:
        name: "{{{{ chosen_db | default('mysql') }}}}"
        # caller passes -e "chosen_db=postgres" to switch

For multi-tier deploys, one play per tier — each with its own role list:

YAML — site.yml — multi-tier orchestration
---
# Tier 1 — Databases
- hosts: databases
  become: true
  roles:
    - common
    - mysql
    - mysql_replication

# Tier 2 — Cache
- hosts: cache
  become: true
  roles:
    - common
    - redis

# Tier 3 — Web
- hosts: webservers
  become: true
  roles:
    - common
    - nginx
    - app_runtime

# Tier 4 — Wire-up (depends on previous tiers)
- hosts: webservers
  become: true
  tasks:
    - name: Point app at the database primary
      ansible.builtin.template:
        src: db_config.j2
        dest: /etc/myapp/db.ini
      vars:
        db_host: "{{{{ groups['mysql_primary'][0] }}}}"
        cache_host: "{{{{ groups['cache'][0] }}}}"

Role B sometimes needs information role A learned. Three ways:

MethodHowBest for
set_fact in role ASets a host-scope variable; role B reads itQuick interop, same play
register + role-argsCapture, then pass to next role explicitlyClean contract; role B unaware of role A
hostvarsCross-host: role B on host X reads facts from host YWiring tiers together
YAML — set_fact passing between roles
---
# roles/mysql/tasks/main.yml — role A
- name: Get MySQL listening port
  ansible.builtin.command: |
    mysql -BNe "SHOW VARIABLES LIKE 'port'"
  register: port_query
  changed_when: false

- name: Expose for downstream roles
  ansible.builtin.set_fact:
    mysql_actual_port: "{{{{ port_query.stdout.split()[1] }}}}"

# roles/proxysql/tasks/main.yml — role B
- name: Configure ProxySQL with the discovered port
  ansible.builtin.template:
    src: proxysql.cfg.j2
    dest: /etc/proxysql.cfg
  vars:
    backend_port: "{{{{ mysql_actual_port }}}}"
YAML — cross-host hostvars wiring
---
- hosts: webservers
  tasks:
    - name: Look up DB primary's IP
      ansible.builtin.set_fact:
        db_primary_ip: >-
          {{{{ hostvars[groups['mysql_primary'][0]].ansible_default_ipv4.address }}}}

    - name: Render web app config pointing at primary
      ansible.builtin.template:
        src: app.ini.j2
        dest: /etc/myapp/app.ini

Recall from page 10 the play execution order. With roles this becomes useful:

YAML — pre/post around roles
---
- hosts: databases
  become: true

  pre_tasks:
    - name: Update package cache
      ansible.builtin.dnf:
        update_cache: true
    - name: Validate vault secrets are loaded
      ansible.builtin.assert:
        that: vault_mysql_pwd is defined
        fail_msg: "vault_mysql_pwd missing — did you decrypt vault.yml?"

  roles:
    - common
    - mysql

  tasks:
    - name: One-off: open the metrics port
      ansible.posix.firewalld:
        port: 9100/tcp
        state: enabled

  post_tasks:
    - name: Verify MySQL is reachable on the configured port
      ansible.builtin.wait_for:
        port: "{{{{ mysql_port }}}}"
        timeout: 30
✅ Tip: Pre-tasks gate the deployment with sanity checks. Roles do the work. Tasks handle one-off, host-specific tweaks. Post-tasks verify the result. This rhythm makes 200-line playbooks predictable.
  • One-off scripts that won't be reused — keep them as inline tasks.
  • Code that's tightly coupled to one project's data model — leave it in the playbook.
  • Anything you'll change weekly — roles are about stability; volatility belongs in playbooks.
⚠ Warning: Premature role-ification is real. Wait until you copy-paste the same tasks twice before extracting a role. Two callers ≠ a reusable role; three usually does.