SSD Nodes Learn 🎉 VPS from $5.50/mo
Guides Matt ConnorBy Matt Connor

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.

Verified Every command ran end-to-end on a fresh Ubuntu 24.04 server, July 30, 2026.

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.yml
  • tasks/main.yml is the entry point. Ansible runs this file when the role is called, and every other directory is optional.
  • defaults/main.yml holds the variables a caller is expected to override. It is the lowest priority source in Ansible, so nearly anything else beats it.
  • vars/main.yml holds 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.yml holds tasks triggered by notify. A handler runs at the end of the play, once, no matter how many tasks notified it.
  • files/ holds files copied verbatim by the copy module, and templates/ holds Jinja2 templates rendered by the template module. Inside a role you reference both by bare filename with no path, because Ansible searches the role's own directories first.
  • meta/main.yml declares 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 common

That 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=local
ansible-playbook -i inventory.ini site.yml

The 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-password

There 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: post

To 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.yml sits 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/ and host_vars/ sit in the middle. This is where your site's own answers belong, and they cleanly override role defaults.
  • roles/<name>/vars/main.yml sits above host_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 -e on 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-cli

The 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.yml

The second recap should look like this:

PLAY RECAP *********************************************************************
localhost   : ok=4  changed=0  unreachable=0  failed=0  skipped=0  rescued=0  ignored=0

changed=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.txt

Run 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/lonely

That 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.yml

There 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.0
ansible-galaxy install -r requirements.yml -p galaxy_roles

Always 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_roles

Roles 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.

#ansible#roles#playbook#structure#automation