Ansible Lesson 24 – Creating and Using Roles | Dataplexa
Section II · Lesson 24

Creating and Using Roles

In this lesson

Build a complete role Role variable overrides Multi-role site.yml Organising role tasks Testing a role

Creating and using roles is where the theory of Lesson 23 becomes production-grade practice. This lesson builds a complete, working postgresql role from the first ansible-galaxy role init command to a tested, multi-role site.yml that provisions a full application server stack. Every design decision — where to put variables, how to split tasks across files, how to expose role behaviour to callers, and how to verify a role works before committing — is explained in context. By the end you will have a reusable role you can drop into any future project and a pattern you can repeat for every role you write.

The Scenario

The scenario: A backend team needs to provision application servers that run a Python web application with a PostgreSQL database. They already have a working flat playbook but it is 200 lines long and difficult to maintain. The goal is to extract the PostgreSQL configuration into a standalone, reusable role that can be called from any project — and then build a clean site.yml that calls both the existing nginx role from Lesson 23 and the new postgresql role.

postgresql role requirements

1

Install PostgreSQL and its Python adapter

2

Configure postgresql.conf from a Jinja2 template

3

Create the application database and database user

4

All configurable values — version, port, database name, user — must be overridable via defaults/

5

Must be fully idempotent — safe to run on an existing server without data loss

Building the Role Step by Step

Follow this sequence exactly. Each step produces a testable increment — never write all the files at once and then debug everything together.

Step 1

Initialise the role skeleton

ansible-galaxy role init --init-path roles/ postgresql

Step 2

Define defaults — every configurable option

# roles/postgresql/defaults/main.yml
---
postgresql_version: "15"
postgresql_port: 5432
postgresql_data_dir: "/var/lib/postgresql/{{ postgresql_version }}/main"
postgresql_conf_dir: "/etc/postgresql/{{ postgresql_version }}/main"

# Connection settings
postgresql_max_connections: 100
postgresql_shared_buffers: "{{ (ansible_memtotal_mb * 0.25) | int }}MB"
postgresql_work_mem: "4MB"
postgresql_listen_addresses: "localhost"

# Application database — override these per environment
postgresql_databases:
  - name: appdb
    owner: appuser

postgresql_users:
  - name: appuser
    password: "{{ vault_db_password }}"   # reference a vaulted variable
    priv: "ALL"
    db: appdb

Step 3

Split tasks across logical include files

# roles/postgresql/tasks/main.yml — orchestrates the sub-task files
---
- name: Install PostgreSQL packages
  ansible.builtin.import_tasks: install.yml
  tags: [packages, postgresql]

- name: Configure PostgreSQL
  ansible.builtin.import_tasks: configure.yml
  tags: [config, postgresql]

- name: Manage databases and users
  ansible.builtin.import_tasks: databases.yml
  tags: [databases, postgresql]

Step 4

Write the install task file

# roles/postgresql/tasks/install.yml
---
- name: Install PostgreSQL and Python adapter
  ansible.builtin.package:
    name:
      - "postgresql-{{ postgresql_version }}"
      - "postgresql-client-{{ postgresql_version }}"
      - python3-psycopg2    # required for Ansible's postgresql_* modules
    state: present
  notify: Restart PostgreSQL

- name: Ensure PostgreSQL data directory exists
  ansible.builtin.file:
    path: "{{ postgresql_data_dir }}"
    state: directory
    owner: postgres
    group: postgres
    mode: "0700"

Step 5

Write the configure task file

# roles/postgresql/tasks/configure.yml
---
- name: Ensure PostgreSQL service is started
  ansible.builtin.service:
    name: "postgresql@{{ postgresql_version }}-main"
    state: started
    enabled: true

- name: Deploy postgresql.conf from template
  ansible.builtin.template:
    src: postgresql.conf.j2      # found in roles/postgresql/templates/ automatically
    dest: "{{ postgresql_conf_dir }}/postgresql.conf"
    owner: postgres
    group: postgres
    mode: "0640"
  notify: Reload PostgreSQL

Step 6

Write the databases task file

# roles/postgresql/tasks/databases.yml
---
- name: Create application databases
  community.postgresql.postgresql_db:
    name: "{{ item.name }}"
    owner: "{{ item.owner | default('postgres') }}"
    state: present
  loop: "{{ postgresql_databases }}"
  become_user: postgres

- name: Create application users
  community.postgresql.postgresql_user:
    name: "{{ item.name }}"
    password: "{{ item.password }}"
    priv: "{{ item.priv | default('ALL') }}:{{ item.db }}"
    state: present
  loop: "{{ postgresql_users }}"
  become_user: postgres
  no_log: true    # never log task output — would expose passwords

Handlers and Template

roles/postgresql/handlers/main.yml

---
- name: Restart PostgreSQL
  ansible.builtin.service:
    name: "postgresql@{{ postgresql_version }}-main"
    state: restarted

- name: Reload PostgreSQL
  ansible.builtin.service:
    name: "postgresql@{{ postgresql_version }}-main"
    state: reloaded

roles/postgresql/templates/postgresql.conf.j2 (excerpt — key tunable settings)

{# postgresql.conf — managed by Ansible. Do not edit manually. #}
{# Generated for: {{ inventory_hostname }} on {{ ansible_date_time.date }} #}

# Connection settings
listen_addresses = '{{ postgresql_listen_addresses }}'
port = {{ postgresql_port }}
max_connections = {{ postgresql_max_connections }}

# Memory settings — auto-sized to this server's RAM
shared_buffers = {{ postgresql_shared_buffers }}
work_mem = {{ postgresql_work_mem }}
maintenance_work_mem = {{ (ansible_memtotal_mb * 0.05) | int }}MB

# Logging
log_destination = 'stderr'
logging_collector = on
log_directory = 'log'
log_filename = 'postgresql-%Y-%m-%d.log'
log_min_duration_statement = 1000    # log queries slower than 1 second

The Lego Modular System Analogy

Splitting role tasks across multiple files is like the Lego modular building system — each module (install, configure, databases) is a self-contained unit that connects cleanly to the others through main.yml. You can replace or extend any module without touching the others. When a new team member opens the role, they immediately know where to find the database creation logic (databases.yml) without reading through hundreds of lines of unrelated installation tasks.

The Multi-Role site.yml

With both roles written and tested, the master playbook becomes a clean orchestration document. It states which roles apply to which hosts — the implementation details live inside the roles. This is the structure that scales from 2 roles to 20.

---
# site.yml — master playbook
# Provisions a complete application server stack

- name: Configure common base settings
  hosts: all
  become: true
  roles:
    - common                    # base packages, timezone, NTP, motd

- name: Configure web tier
  hosts: webservers
  become: true
  roles:
    - role: nginx
      vars:
        nginx_port: 80
        nginx_vhosts:
          - name: app
            server_name: "{{ ansible_fqdn }}"
            root: /var/www/app
            proxy_pass: "http://127.0.0.1:8000"

- name: Configure database tier
  hosts: databases
  become: true
  roles:
    - role: postgresql
      vars:
        postgresql_version: "15"
        postgresql_listen_addresses: "{{ ansible_default_ipv4.address }}"
        postgresql_max_connections: 200
        postgresql_databases:
          - name: "{{ app_db_name }}"
            owner: "{{ app_db_user }}"
        postgresql_users:
          - name: "{{ app_db_user }}"
            password: "{{ vault_db_password }}"
            priv: ALL
            db: "{{ app_db_name }}"

- name: Deploy application
  hosts: appservers
  become: true
  roles:
    - role: app_deploy
      tags: deploy
PLAY [Configure common base settings] *****************************************

TASK [common : Install base packages] *****************************************
ok: [web01]
ok: [db01]
ok: [app01]

PLAY [Configure web tier] *****************************************************

TASK [nginx : Install Nginx web server] ***************************************
ok: [web01]

TASK [nginx : Deploy Nginx configuration] *************************************
changed: [web01]

RUNNING HANDLERS [Configure web tier] *****************************************
changed: [web01]   <-- Nginx reloaded

PLAY [Configure database tier] ************************************************

TASK [postgresql : Install PostgreSQL and Python adapter] *********************
ok: [db01]

TASK [postgresql : Deploy postgresql.conf from template] **********************
changed: [db01]

TASK [postgresql : Create application databases] ******************************
ok: [db01]   <-- appdb already exists

TASK [postgresql : Create application users] **********************************
ok: [db01]   <-- appuser already exists

PLAY RECAP ********************************************************************
app01   : ok=3   changed=0   unreachable=0   failed=0
db01    : ok=6   changed=1   unreachable=0   failed=0
web01   : ok=5   changed=2   unreachable=0   failed=0

What just happened?

Three plays ran against three different host groups. Each play applied the relevant roles — the web tier got Nginx, the database tier got PostgreSQL, and all hosts got the common role. The playbook is 40 lines. The logic is hundreds of lines — but it lives in roles where it is tested, versioned, and reusable. The database and user tasks reported ok because they already existed — idempotency working as designed.

Overriding Role Variables per Environment

One of the main benefits of defining role options in defaults/ is that they can be overridden by anything higher in the variable precedence chain — including group_vars. This means one role and one playbook can serve both staging and production, with different values flowing in from environment-specific inventory variable files.

inventory/staging/group_vars/databases.yml
postgresql_max_connections: 50
postgresql_shared_buffers: 128MB
postgresql_listen_addresses: localhost
postgresql_databases:
  - name: appdb_staging
    owner: appuser
app_db_name: appdb_staging
app_db_user: appuser
inventory/production/group_vars/databases.yml
postgresql_max_connections: 300
postgresql_shared_buffers: "{{ (ansible_memtotal_mb * 0.25)|int }}MB"
postgresql_listen_addresses: "{{ ansible_default_ipv4.address }}"
postgresql_databases:
  - name: appdb
    owner: appuser
app_db_name: appdb
app_db_user: appuser
# Same site.yml, same roles — different values from different inventories
ansible-playbook site.yml -i inventory/staging/
ansible-playbook site.yml -i inventory/production/

Testing a Role

A role that is not tested is a liability. Three levels of testing give increasing confidence — each adds value without requiring the complexity of the next level.

Role testing levels

Level 1 — Lint Run ansible-lint roles/postgresql/ and yamllint roles/postgresql/. Catches syntax errors, deprecated modules, and style violations in seconds. Should pass on every commit.
Level 2 — Syntax check Run ansible-playbook tests/test.yml --syntax-check against the role's built-in test playbook. Verifies that Ansible can parse the role without actually executing it.
Level 3 — Molecule Spins up a Docker container or VM, runs the role against it, verifies the expected state, and destroys the environment. The gold standard for role testing. Covered conceptually in Lesson 10.
# Level 1 — lint the role
ansible-lint roles/postgresql/
yamllint roles/postgresql/

# Level 2 — syntax check with the built-in test playbook
# roles/postgresql/tests/test.yml was generated by ansible-galaxy role init
ansible-playbook roles/postgresql/tests/test.yml --syntax-check

# Level 3 — Molecule (if installed)
cd roles/postgresql/
molecule test       # create → converge → verify → destroy

Molecule documentation: The official guide for setting up and running Molecule tests — including Docker driver setup and writing Testinfra or Ansible verify playbooks.

Molecule docs ↗

Always Use no_log: true on Tasks That Handle Passwords

The postgresql_user task in this lesson creates a database user with a password. Without no_log: true, Ansible prints the full task arguments — including the password — to the terminal and to any logging infrastructure. Always set no_log: true on any task whose arguments contain credentials, tokens, or private keys. The log entry shows "censored due to no_log" instead — confirming the task ran without leaking the secret.

Key Takeaways

Split large role task lists into topic-specific files using import_tasksinstall.yml, configure.yml, databases.yml — and orchestrate them from main.yml. This makes each section navigable and individually testable.
Role defaults + group_vars = environment-specific configuration — the same role and the same site.yml serve staging and production by switching the inventory directory. No code changes needed.
A multi-role site.yml is the natural entry point for provisioning a full stack — each play applies one or more roles to its target group. The playbook becomes a readable declaration of intent.
Always set no_log: true on tasks that process passwords — any task argument containing a credential will be printed to the terminal and logs unless this flag suppresses it.
Lint before every commit, syntax-check before every run — these two checks together catch almost all mechanical errors in under ten seconds and prevent embarrassing failures mid-deployment.

Teacher's Note

Build the full PostgreSQL role from this lesson and run it against your lab VM. Then change postgresql_max_connections in your group_vars and run it again — watch the template task report changed and the reload handler fire automatically. That sequence is the role + variable override pattern that you will use in every real project.

Practice Questions

1. Which directive in a role's tasks/main.yml statically includes an external task file — allowing tags set on the include to propagate into the included tasks?



2. Which task attribute prevents Ansible from printing task arguments — including passwords — to the terminal and log output?



3. What variable source should you use to provide environment-specific values (staging vs production) that override a role's defaults without modifying the playbook or the role?



Quiz

1. A role's tasks/main.yml has grown to 300 lines covering installation, configuration, and database setup. What is the recommended way to organise it?


2. Running the site.yml from this lesson, the Create application databases and Create application users tasks both report ok. What does this mean?


3. You need the PostgreSQL role to use max_connections: 50 in staging and 300 in production. What is the cleanest approach?


Up Next · Lesson 25

Playbook Best Practices

The final lesson of Section II — consolidating everything you have learned into a definitive set of playbook and project conventions that separate maintainable automation from the kind that breaks at 2am.