A single playbook is fine for a demo. After a few services it becomes a long file that mixes web servers, database tuning and user accounts, and nobody dares to touch it. Roles are Ansible’s unit of reuse: a directory with a fixed layout that bundles tasks, templates, files, defaults and handlers for one job such as “configure nginx”.
Versions in use on this date. ansible-core 2.15 (released May 2023, needs Python 3.9 or newer on the control node) and the community package Ansible 8, which bundles it with a set of collections. Ubuntu 22.04 provides Python 3.10 on managed hosts. Use fully qualified module names (ansible.builtin.apt) so playbooks keep working as modules move between collections.
How the parts combine
flowchart TB
PB[site.yml<br/>hosts web, roles nginx and app] --> R
subgraph R[Role nginx]
D[defaults main.yml<br/>lowest precedence, safe knobs]
T[tasks main.yml]
TPL[templates site.conf.j2]
H[handlers main.yml<br/>reload nginx]
M[meta main.yml<br/>role dependencies]
end
GV[group_vars and host_vars<br/>environment values] -->|overrides defaults| R
VAULT[group_vars prod vault.yml<br/>encrypted with Ansible Vault] -->|secrets as vault_ variables| GV
EV[extra vars -e on command line] -->|highest precedence| R
T -->|template changed| H
R --> HOSTS[Managed hosts over SSH]
Create a role
ansible-galaxy init roles/nginx
roles/nginx/
defaults/main.yml # variables users are expected to override
vars/main.yml # internal constants, high precedence, rarely used
tasks/main.yml
handlers/main.yml
templates/site.conf.j2
files/ # static files to copy
meta/main.yml # dependencies and metadata
defaults/main.yml:
nginx_server_name: example.com
nginx_listen_port: 80
nginx_upstream: 127.0.0.1:8080
nginx_worker_connections: 1024
tasks/main.yml:
- name: Install nginx
ansible.builtin.apt:
name: nginx
state: present
update_cache: true
cache_valid_time: 3600
- name: Render site configuration
ansible.builtin.template:
src: site.conf.j2
dest: /etc/nginx/conf.d/site.conf
owner: root
group: root
mode: "0644"
notify: Validate and reload nginx
- name: Ensure nginx is enabled and running
ansible.builtin.service:
name: nginx
state: started
enabled: true
handlers/main.yml runs only when the template changed, once, at the end of the play:
- name: Validate and reload nginx
ansible.builtin.command: nginx -t
changed_when: false
notify: Reload nginx
- name: Reload nginx
ansible.builtin.service:
name: nginx
state: reloaded
templates/site.conf.j2:
server {
listen {{ nginx_listen_port }};
server_name {{ nginx_server_name }};
location / {
proxy_pass http://{{ nginx_upstream }};
proxy_set_header Host $host;
}
}
Use the role in a play:
- hosts: web
become: true
roles:
- role: nginx
vars:
nginx_server_name: shop.example.com
Variables and precedence
Ansible has more than 20 places where a variable can be set. The rule of thumb that keeps things understandable: role defaults (lowest) are overridden by group_vars and host_vars for the environment, which are overridden by play vars and finally extra vars (-e), which always win.
inventory/
production.ini
group_vars/
all.yml # values common to all hosts
web.yml
prod/
vars.yml # plain environment values
vault.yml # encrypted
host_vars/
web-1.yml
Name variables with the role prefix (nginx_...) to avoid collisions, and put only safe, documented knobs in defaults. Do not use vars/ for settings users should change, because it overrides group variables and surprises people.
Secrets with Vault
ansible-vault create inventory/group_vars/prod/vault.yml
ansible-vault edit inventory/group_vars/prod/vault.yml
ansible-vault encrypt_string 'S3cr3t!' --name 'vault_db_password' # encrypt one value inline
ansible-playbook site.yml -i inventory/production.ini --ask-vault-pass
ansible-playbook site.yml -i inventory/production.ini --vault-id prod@~/.ansible/vault-prod
Reference the vault variable from a plain file so searches still show where each secret is used:
# group_vars/prod/vars.yml
db_password: "{{ vault_db_password }}"
and set no_log: true on tasks that handle secrets so they are not printed. Keep the vault password in a password manager or CI secret store, never in the repository, and use vault IDs to use different passwords per environment so a developer can edit staging but not production. Rotate by re-encrypting (ansible-vault rekey). For larger teams, consider fetching secrets at run time from AWS Secrets Manager or HashiCorp Vault with a lookup plugin rather than storing encrypted files.
Quality practices
- Collections: pin versions in
requirements.yml(ansible-galaxy collection install -r requirements.yml) so runs are reproducible. - Lint:
ansible-lintandyamllintin CI catch deprecated syntax, missing names and risky shell usage. - Test: try roles on a throwaway container or VM; Molecule automates create, converge, idempotence check and verify.
- Idempotence check: run the playbook twice; the second run should report
changed=0. - Dry run in production:
--check --diff, then--limitto one host as a canary, then everyone withserial. - Tags and
--start-at-taskhelp with long plays during debugging. - Documentation: a
README.mdin each role listing variables and an example.
Common mistakes
- Putting secrets in
defaultsor committing an unencrypted vault file; check with a pre-commit hook that fails on$ANSIBLE_VAULTheaders missing fromvault.yml. - Using
shellfor things a module does; the task is then never idempotent. - Role dependencies hidden in
metathat nobody expects; document them. - Overriding too much with
-e; it makes runs hard to reproduce. Prefer variables in version control.