Ansible Course
Creating and Using Roles
In this lesson
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
Install PostgreSQL and its Python adapter
Configure
postgresql.conf from a Jinja2 template
Create the application database and database user
All configurable
values — version, port, database name, user — must be overridable via
defaults/
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.
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
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
ansible-lint roles/postgresql/ and
yamllint roles/postgresql/. Catches syntax errors, deprecated
modules, and style violations in seconds. Should pass on every commit.
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 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
import_tasks — install.yml, configure.yml,
databases.yml — and orchestrate them from main.yml.
This makes each section navigable and individually testable.
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.
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.
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.