Ansible playbook vs role: when to use each
When a flat Ansible playbook is enough and when a role earns its directories: the role layout, ansible-galaxy init, role calls, and variable precedence.
Ansible playbook vs role: what is the difference
An Ansible playbook is the file you run with ansible-playbook. It maps a group of hosts to the work they need. An Ansible role is a directory with a fixed layout that holds tasks, templates, handlers and default variables, and a playbook calls it by name. The task syntax inside both is identical, so this is not a question of what you can express. It is a question of reuse.
Start with a flat playbook. One site.yml holding a tasks: list is the right shape for your first automation, and it stays right for longer than most people expect. Convert to a role when the same block of tasks has to run for a second group of hosts, or when the file grows past roughly 100 lines and you can no longer find a task by scrolling.
If you have not written one yet, start with a first playbook against a single VPS and come back when it starts growing.
When a flat playbook is the right answer
A flat playbook is correct when the work happens once, or on one host, or when nobody else will read it. Provisioning a single application server, or patching a box before a maintenance window: neither of those earns a directory tree. A role adds seven directories and one layer of indirection. If the only caller is the playbook sitting next to it, that indirection buys nothing and costs you a jump every time you want to read what actually runs.
The flat playbook stops being right at a specific moment, and the moment is easy to spot. You copy a block of tasks into a second playbook. That copy is the signal. From then on every fix has to be made twice, and one day it will only be made once.
What a role directory actually holds
roles/common/
defaults/main.yml
vars/main.yml
tasks/main.yml
handlers/main.yml
templates/99-hardening.conf.j2
files/
meta/main.ymltasks/main.ymlis the entry point. Ansible runs this file when the role is called, and every other directory is optional.defaults/main.ymlholds the variables a caller is expected to override. It is the lowest priority source in Ansible, so nearly anything else beats it.vars/main.ymlholds variables a caller is not expected to override. It sits above inventory in priority, which is a strong statement to make. Use it rarely.handlers/main.ymlholds tasks triggered bynotify. A handler runs at the end of the play, once, no matter how many tasks notified it.files/holds files copied verbatim by thecopymodule, andtemplates/holds Jinja2 templates rendered by thetemplatemodule. Inside a role you reference both by bare filename with no path, because Ansible searches the role's own directories first.meta/main.ymldeclares role dependencies and the metadata Ansible Galaxy reads.
The layout is not a style preference. Ansible looks in these exact paths, so a template you put in roles/common/template/ (singular) is simply never found.
Build the common role with ansible-galaxy init
mkdir -p ~/infra/roles
cd ~/infra
ansible-galaxy init --init-path roles commonThat writes the whole skeleton under roles/common, including directories you will not use and main.yml stubs that contain only ---. Delete the ones you leave empty. An empty vars/main.yml is harmless to Ansible, but it hides which files in the role actually matter.
Now fill in the files that do the work. Defaults first, because they are the role's public interface.
# roles/common/defaults/main.yml
---
common_packages:
- ufw
- fail2ban
- unattended-upgrades
common_admin_group: admins
common_permit_root_login: "no"
common_password_authentication: "no"Quote "no" and "yes". Ansible parses YAML with PyYAML, which reads a bare no as the boolean false, so the rendered config line becomes PermitRootLogin False and sshd rejects it. The quotes keep the value a string.
# roles/common/tasks/main.yml
---
- name: Install the base packages
ansible.builtin.apt:
name: "{{ common_packages }}"
state: present
update_cache: true
cache_valid_time: 3600
- name: Create the admin group
ansible.builtin.group:
name: "{{ common_admin_group }}"
state: present
- name: Install the sshd hardening drop-in
ansible.builtin.template:
src: 99-hardening.conf.j2
dest: /etc/ssh/sshd_config.d/99-hardening.conf
owner: root
group: root
mode: "0644"
validate: /usr/sbin/sshd -t -f %s
notify: Restart sshd# roles/common/handlers/main.yml
---
- name: Restart sshd
ansible.builtin.service:
name: ssh
state: restarted# roles/common/templates/99-hardening.conf.j2
# Managed by Ansible. Local edits are overwritten on the next run.
PermitRootLogin {{ common_permit_root_login }}
PasswordAuthentication {{ common_password_authentication }}On Debian and Ubuntu the systemd unit is called ssh, and on RHEL family systems it is sshd. A handler that names the wrong one fails only when something actually changes the template, which is why it usually surfaces weeks later.
The validate line is the most useful thing in that task. Ansible renders the template to a temporary file, substitutes that file's path for %s, and runs the command. The destination is replaced only if the command exits 0. Put a nonsense directive in the template and run again: the task fails with failed to validate, the real /etc/ssh/sshd_config.d/99-hardening.conf is untouched, and you still have a server you can log into. Be aware that the check tests more than your syntax. If sshd -t cannot read the host keys it exits with sshd: no hostkeys available -- exiting. and Ansible reports the same failed to validate, so read the module's msg before blaming the template.
How a playbook calls a role
# site.yml
---
- name: Base configuration for every server
hosts: all
become: true
roles:
- common# inventory.ini
[local]
localhost ansible_connection=localansible-playbook -i inventory.ini site.ymlThe play should end with failed=0 in the recap. Pass parameters at the call site with the expanded form, which is how one role serves two groups of hosts:
roles:
- role: common
common_admin_group: ops
common_permit_root_login: prohibit-passwordThere is one ordering rule that surprises almost everybody. A play can hold pre_tasks, roles, tasks and post_tasks, and Ansible runs them in that order whatever order you wrote them in the file. Put tasks: above roles: and the roles still run first. So if something must happen before a role, it belongs in pre_tasks:, not at the top of tasks:.
- name: Ordering demonstration
hosts: local
gather_facts: false
pre_tasks:
- name: Runs first
ansible.builtin.debug:
msg: pre
roles:
- common
tasks:
- name: Runs after the role
ansible.builtin.debug:
msg: task
post_tasks:
- name: Runs last
ansible.builtin.debug:
msg: postTo call a role from inside a task list instead of the roles: key, use import_role or include_role.
tasks:
- name: Static, read when the playbook is parsed
ansible.builtin.import_role:
name: common
- name: Dynamic, resolved when the task runs
ansible.builtin.include_role:
name: postgres
when: "'db' in group_names"import_role is static. Ansible reads the role at parse time and its tasks become part of the play, so ansible-playbook --list-tasks site.yml lists them and a tag on the import applies to every task inside. include_role is dynamic. Nothing is read until the task runs, which is what lets you drive the role name from a variable or a loop. The cost is that those tasks are invisible to --list-tasks and to --start-at-task.
One trap lives here. A when: on an include_role task is evaluated before the included role's defaults/main.yml is in scope. Write when: common_packages | length > 0 on the include and the run stops with 'common_packages' is undefined, even though that variable is defined in the very role you are including. The fix is to move the toggle out of the role: put it in group_vars/all.yml, where it is in scope everywhere, and leave the role's defaults for values the role itself consumes.
Which variable wins: defaults, group_vars, vars, extra vars
Ansible documents more than twenty levels of variable precedence. Four of them settle nearly every real argument, and here they are from weakest to strongest.
roles/<name>/defaults/main.ymlsits near the bottom. Almost anything you set anywhere else beats it, which is exactly why it is the right home for a role's tunable knobs.group_vars/andhost_vars/sit in the middle. This is where your site's own answers belong, and they cleanly override role defaults.roles/<name>/vars/main.ymlsits abovehost_vars. A value you put here cannot be overridden from inventory. Reserve it for things the role needs to stay internally consistent, such as a package name that has to match a service name.- A role parameter passed at the call site beats
vars/main.yml, and-eon the command line beats everything, including role parameters.
You can watch this resolve in about a minute. Give a small role one default and one role var, then set the same names in host_vars.
# roles/prec/defaults/main.yml
---
prec_tunable: from-defaults
prec_internal: from-defaults# roles/prec/vars/main.yml
---
prec_internal: from-rolevars# host_vars/localhost.yml
---
prec_tunable: from-hostvars
prec_internal: from-hostvars# roles/prec/tasks/main.yml
---
- name: Show which value survived
ansible.builtin.debug:
msg: "tunable={{ prec_tunable }} internal={{ prec_internal }}"ansible-playbook -i inventory.ini prec.yml
ansible-playbook -i inventory.ini prec.yml -e prec_internal=from-cliThe first run prints tunable=from-hostvars internal=from-rolevars. Inventory beat the role default and lost to the role var. The second run prints internal=from-cli, because extra vars sit at the very top and nothing below can push back. That is also why -e is fine for a one-off run and wrong in a script you keep: it silently outranks every considered decision in your repository.
The working rule: if you want a value to be settable, it goes in defaults/. Putting it in vars/ tells every future user of the role that inventory may not change it. That is occasionally what you meant, and usually an accident.
Prove the role is idempotent: run it twice
An Ansible run worth trusting produces the same result the second time and reports that it changed nothing. Run the playbook twice and read the recap.
ansible-playbook -i inventory.ini site.yml
ansible-playbook -i inventory.ini site.ymlThe second recap should look like this:
PLAY RECAP *********************************************************************
localhost : ok=4 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0changed=0 means every module inspected the current state and found the work already done. changed=2 on a second run means two tasks cannot tell the difference, so they will keep rewriting files and restarting services forever. The usual culprit is command or shell, because Ansible has no way to know what an arbitrary command did.
# traps.yml
---
- name: Command modules do not know what they changed
hosts: local
gather_facts: false
tasks:
- name: This appends a line on every run
ansible.builtin.shell: "echo run >> /tmp/grow.txt"
- name: This appends a line only once
ansible.builtin.shell: "echo run >> /tmp/guarded.txt"
args:
creates: /tmp/guarded.txtRun that playbook twice, then count the lines with wc -l /tmp/grow.txt /tmp/guarded.txt. /tmp/grow.txt holds two lines and /tmp/guarded.txt holds one. On the second run the guarded task did not execute at all, and its result carries the message skipped, since /tmp/guarded.txt exists, because creates gives the module a visible product to look for first. When a command leaves no such product, register its output and decide yourself with changed_when.
ansible-playbook --check --diff site.yml predicts changes without making them, and --diff prints the exact lines a template would rewrite. Read the output with one caveat in mind: shell and command tasks are skipped in check mode, so a plan that looks clean can still hide work.
Why does Ansible say the role was not found
Ansible looks for a roles/ directory next to the playbook file, then in roles_path. The search follows the playbook, not your shell.
ERROR! the role 'common' was not found in /home/deploy/lonely/roles:/home/deploy/lonelyThat message means site.yml and roles/ have drifted apart, and it helpfully prints the paths it tried. Keep the two in the same directory. Running from a parent directory is fine, because the playbook path is what counts:
ansible-playbook -i infra/inventory.ini infra/site.ymlThere is a quieter version of the same problem. Ansible ignores an ansible.cfg in the current directory when that directory is world writable, because any user on the box could drop a config there and change what your run does.
[WARNING]: Ansible is being run in a world writable directory (/tmp/infra), ignoring it as an ansible.cfg source.Your roles_path and inventory settings are then silently absent, and the role lookup fails for a reason that has nothing to do with roles. ansible --version prints the config file it actually loaded, and ansible-config dump --only-changed prints every setting that differs from the built-in defaults. Check both whenever a run behaves as if your config does not exist.
Sharing roles: requirements.yml and a pinned version
A role someone else wrote is installed, not copied. Declare it once:
# requirements.yml
---
roles:
- name: postgres
src: https://github.com/example/ansible-role-postgres
scm: git
version: v1.4.0ansible-galaxy install -r requirements.yml -p galaxy_rolesAlways set version. Without it you get whatever the default branch holds on the day you run the command, so a deployment that worked last month breaks with no change at all in your own repository. Point roles_path at the download directory, and keep that directory out of git:
# ansible.cfg
[defaults]
inventory = inventory.ini
roles_path = ./galaxy_rolesRoles in roles/ next to the playbook are still found, because that path is always searched in addition to roles_path. So your own roles stay committed and reviewed, while third-party roles are reproducible downloads pinned to a tag.
Where roles stop being the answer
A role is a unit of reuse inside one Ansible run. It does not create servers or DNS records at your provider, and trying to make it do that is how playbooks turn into something nobody wants to maintain. the split of work between Ansible and Terraform is worth reading before you start. A role also does not replace inventory design: once you pass a handful of machines, how you group and reach those servers matters more than how the tasks are filed.
The hardening this common role installs deserves its own decisions too. The drop-in above sets two directives and no more, so read which SSH settings are actually worth changing and how to make Ubuntu apply security updates on its own before you decide what belongs in the role for every host you own.
FAQ
When should I turn an Ansible playbook into a role?
When the same block of tasks has to run in a second play, or against a second group of hosts. Copying tasks between playbooks is the signal, because from that moment every fix has to be applied twice and one day it will only be applied once. A single playbook under roughly 100 lines that only ever targets one group gains nothing from a role, and the extra directories make it harder to read.
Do roles run before the tasks in the same play?
Yes. Ansible runs pre_tasks, then everything listed under roles:, then tasks:, then post_tasks:, and it ignores the order those keys appear in your file. Writing tasks: above roles: does not make those tasks run first. If something must happen before a role, put it in pre_tasks:.
Why does my group_vars value not override the role?
Check whether the variable is set in the role's vars/main.yml instead of defaults/main.yml. vars/ sits above group_vars and host_vars in Ansible's precedence order, so inventory cannot override it. Move the variable to defaults/main.yml, which is near the bottom of the order and is the correct home for anything a caller should be able to change. To confirm precedence is the cause rather than a typo, run once with -e name=value, which outranks every other source.
Why does Ansible say the role was not found?
The search starts next to the playbook file, so site.yml and roles/ must sit in the same directory. The error prints the paths it tried, as in the role 'common' was not found in /home/deploy/lonely/roles:/home/deploy/lonely. Running the playbook from a parent directory is fine, because the search follows the playbook path and not your shell's working directory. If you depend on roles_path from ansible.cfg, confirm that file was loaded with ansible --version, since a world writable working directory makes Ansible ignore it.
Do I need ansible-galaxy init to create a role?
No. A role is only directories with the expected names, so mkdir -p roles/common/tasks plus a tasks/main.yml is already a working role. ansible-galaxy init --init-path roles common saves typing and gives you the full skeleton, including meta/main.yml and a README stub. Delete the directories you leave empty, because an empty vars/main.yml hides which files in the role actually do something.