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.
The simplest case: list roles in order. Ansible runs them in sequence. Use this when roles don't need to be conditionally selected.
---
- 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:
---
- 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
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:
---
- 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:
---
# 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:
| Method | How | Best for |
|---|---|---|
set_fact in role A | Sets a host-scope variable; role B reads it | Quick interop, same play |
register + role-args | Capture, then pass to next role explicitly | Clean contract; role B unaware of role A |
hostvars | Cross-host: role B on host X reads facts from host Y | Wiring tiers together |
---
# 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 }}}}"
---
- 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:
---
- 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
- 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.