A single Ansible playbook works for a few tasks, but grows messy as you automate more. Roles package related automation (installing and configuring Nginx, hardening SSH, setting up a database) into reusable units, with a standard layout. You can apply the same role to many servers and projects, or share it.
Basics first: Ansible playbooks and Ansible inventory.
Create a role
ansible-galaxy role init roles/nginx
roles/nginx/
├── defaults/main.yml # default variables (lowest priority, meant to be overridden)
├── vars/main.yml # role variables (high priority; rarely overridden)
├── tasks/main.yml # the tasks
├── handlers/main.yml # handlers (restart/reload)
├── templates/ # Jinja2 templates (*.j2)
├── files/ # static files to copy
├── meta/main.yml # metadata and dependencies
└── README.md
Fill it in
roles/nginx/defaults/main.yml:
nginx_sites: []
nginx_client_max_body_size: 64m
roles/nginx/tasks/main.yml:
- name: Install nginx
ansible.builtin.apt:
name: nginx
state: present
update_cache: true
cache_valid_time: 3600
- name: Main config
ansible.builtin.template:
src: nginx.conf.j2
dest: /etc/nginx/nginx.conf
validate: nginx -t -c %s
notify: Reload nginx
- name: Site configs
ansible.builtin.template:
src: site.conf.j2
dest: "/etc/nginx/sites-available/{{ item.name }}"
loop: "{{ nginx_sites }}"
notify: Reload nginx
- name: Enable sites
ansible.builtin.file:
src: "/etc/nginx/sites-available/{{ item.name }}"
dest: "/etc/nginx/sites-enabled/{{ item.name }}"
state: link
loop: "{{ nginx_sites }}"
notify: Reload nginx
- name: Nginx running
ansible.builtin.service:
name: nginx
state: started
enabled: true
roles/nginx/handlers/main.yml:
- name: Reload nginx
ansible.builtin.service:
name: nginx
state: reloaded
validate: checks the generated config before replacing the live file, so a bad template never breaks the running server.
Use roles in a playbook
# site.yml
- hosts: web
become: true
roles:
- role: common
- role: nginx
vars:
nginx_sites:
- { name: example.com, root: /var/www/example.com }
ansible-playbook -i inventory.ini site.yml --check --diff
ansible-playbook -i inventory.ini site.yml
Put host- or group-specific values in group_vars/ and host_vars/ instead of the playbook.
defaults vs vars
- defaults/: values users of the role are expected to override (ports, versions, feature flags).
- vars/: internal values that shouldn’t normally change (package names per OS).
Ansible’s variable precedence has many levels. Keeping overridable settings in defaults/ avoids surprises.
Reuse community roles and collections
ansible-galaxy collection install community.general community.docker
ansible-galaxy role install geerlingguy.docker
Pin versions in requirements.yml, and review community roles before running them with root privileges:
# requirements.yml
roles:
- name: geerlingguy.docker
version: 7.4.1
collections:
- name: community.general
version: ">=9.0.0"
ansible-galaxy install -r requirements.yml
Test roles with Molecule
Molecule creates a temporary container or VM, applies the role, runs it again to check idempotency (no changes the second time), and runs verification tests:
pip install molecule molecule-plugins[docker]
cd roles/nginx && molecule init scenario && molecule test
A typical role set for VPS servers
| Role | Does |
|---|---|
common |
Users, SSH keys, timezone, NTP, base packages, automatic updates |
hardening |
SSH config, firewall, Fail2ban, sysctl. See Ansible server hardening |
nginx, php, mysql |
Web stack |
docker |
Docker Engine + Compose. See Ansible Docker deploy |
backup |
restic schedules. See restic |
Quick reference
| Task | Command / location |
|---|---|
| New role | ansible-galaxy role init roles/x |
| Tasks / handlers | tasks/main.yml, handlers/main.yml |
| Overridable vars | defaults/main.yml |
| Use in playbook | roles: [common, nginx] |
| Dependencies | requirements.yml + ansible-galaxy install -r |
| Test | molecule test |
Related: Ansible Vault and Linux configuration management.