Ansible Playbook Best Practices: How to Write High-Quality Playbooks
July 13, 2022 - Updated: November 2, 2026 - Words by Jasna Simončič, Anže Luzar - 11 min read
Most failed Ansible runs trace back to a few habits: a messy project structure, tasks that don’t enforce a state, secrets stored in plain text, and code that reached production without being tested. The Ansible playbook best practices below fix those habits, and each one comes with a YAML example you can adapt.
We’ve written countless Ansible Playbooks and Ansible Collections, and this guide collects what we’ve learned along the way. It covers project layout, readable tasks, idempotency, variables, testing, security and reviewing AI-generated code.
Ansible best practices checklist
- Keep every playbook, role and inventory in Git.
- Use a separate inventory for each environment.
- Pin collection versions in
requirements.yml. - Give every play and task a descriptive name.
- Use fully qualified collection names (FQCNs), such as
ansible.builtin.copy. - Set
stateexplicitly, even when it matches the default. - Prefer dedicated modules over
commandandshell. - Run every playbook twice and expect zero changes on the second run.
- Prefix role variables with the role name.
- Encrypt secrets with Ansible Vault and hide them from logs with
no_log. - Check every change against your policy before it runs, including AI-generated code.
1. Structure your Ansible project so anyone can find things
A clear layout is the cheapest best practice to adopt. When playbooks, roles and variables always sit in the same places, a new team member can find the right file in seconds. If you’re developing a role or collection, follow the structure in the Ansible documentation . For a project that manages infrastructure, a layout like this works well:
project/
├── inventories/
│ ├── production/
│ │ └── hosts.yml
│ ├── group_vars/
│ ├── host_vars/
│ └── staging/
│ ├── hosts.yml
│ ├── group_vars/
│ └── host_vars/
├── roles/
│ └── nginx/
│ ├── tasks/
│ │ └── main.yml
│ ├── handlers/
│ │ └── main.yml
│ ├── templates/
│ ├── defaults/
│ │ └── main.yml
│ └── meta/
│ └── main.yml
├── collections/
│ └── requirements.yml
├── site.yml
├── webservers.yml
└── dbservers.yml
A few rules keep this structure working as the project grows:
- Store everything in a Git repository so you can track every change. Commit often, and add a README that explains how to run each playbook.
- Keep one inventory per environment. With separate production and staging inventories, a typo in
--limitcan’t push experimental changes to production. - Split large playbooks into multiple files, and group the content for complex applications into roles. Give each role one job: an
nginxrole should install and configure Nginx and nothing else. - Let top-level playbooks orchestrate.
site.ymlimportswebservers.ymlanddbservers.yml, and each of those applies roles. - Pin your dependencies by listing every collection with a version in
collections/requirements.yml. Without pinned versions, a collection upgrade can change a module’s behavior between two runs of the same playbook.
# collections/requirements.yml
collections:
- name: community.general
version: "9.5.0"
- name: ansible.posix
version: "1.6.2"
2. Write tasks people can read at a glance
An Ansible Playbook should serve as a human-readable description of the desired state, so a reviewer should be able to follow it without running it. Here’s a task that works but is hard to review:
- user: name={{ username }} state=present uid=1050 generate_ssh_key=yes
become: yes
Here’s the same task written to best practices:
- name: Create the deploy user with an SSH key
ansible.builtin.user:
name: "{{ username }}"
state: present
uid: 1050
generate_ssh_key: true
become: true
The second version follows six habits worth adopting everywhere:
- Name every play and task. Start the name with a verb and describe the outcome. Names appear in the run output, so good names make failures much easier to trace.
- Use fully qualified collection names.
ansible.builtin.userpoints to exactly one module, while a short name likeusercan resolve to a module from another collection. - Set state explicitly. Writing
state: presentorstate: absentmakes the intent clear, even when it matches the module’s default. - Put one argument per line. YAML dictionaries are easier to read and diff than inline
key=valuepairs. - Use true and false for booleans. Ansible accepts
yesandno, but Ansible Lint flags them, and mixing styles makes the code harder to scan. - Keep it simple. Prefer modules and simple tasks over complex blocks, nested loops and long inline Jinja expressions that nobody can debug later.
Use task names to describe what each task does, and keep comments for explaining why it exists. Don’t put hostnames, IP ranges or other details about your infrastructure in comments, because anyone with access to the repository can read them.
3. Make every task idempotent
An idempotent task leaves the system in the same state whether it runs once or ten times. Idempotency lets you rerun a playbook after a partial failure without breaking anything, which makes it the single most important Ansible best practice.
Most Ansible modules check whether the desired state already exists and do nothing if it does. The usual exceptions are command and shell. Ansible can’t know what a shell command changed, so it reports “changed” on every run. Prefer a dedicated module whenever one exists:
# Avoid: runs every time and always reports "changed"
- name: Install Nginx
ansible.builtin.shell: apt-get install -y nginx
# Prefer: only acts when Nginx is missing
- name: Install Nginx
ansible.builtin.apt:
name: nginx
state: present
When you do need command or shell, tell Ansible how to judge the result. Use creates or removes to skip the task when it’s already done, and changed_when to report a change only when one happened.
- name: Initialize the application database
ansible.builtin.command: /opt/app/bin/init-db
args:
creates: /opt/app/data/.initialized
- name: Check the current schema version
ansible.builtin.command: /opt/app/bin/schema-version
register: schema_version
changed_when: false
If a playbook ends up with a lot of inline shell and Python commands, consider writing a custom Ansible module instead.
Use handlers for actions that should only happen after a change, such as restarting a service. A handler runs once at the end of the play, and only if a task notified it.
- name: Deploy the Nginx configuration
ansible.builtin.template:
src: nginx.conf.j2
dest: /etc/nginx/nginx.conf
mode: "0644"
notify: Restart Nginx
handlers:
- name: Restart Nginx
ansible.builtin.service:
name: nginx
state: restarted
Before you move on to the next task, run the playbook twice in a row. The second run should report changed=0. If it doesn’t, find the task that changed and fix it before you add more. Always test playbooks in a sandbox environment before you run them in production. For more on this, read our guide to
writing reliable Ansible Playbooks.
4. Keep variables predictable and secrets out of sight
Ansible can read variables from more than 20 places, which makes it easy to lose track of where a value comes from. Pick a few places and use them consistently:
- Put sensible defaults for each role in
roles/<role>/defaults/main.yml. Role defaults have the lowest precedence, so anyone using the role can override them. - Keep environment-specific values in
group_vars/andhost_vars/inside each inventory, not in the playbook itself. - Prefix every role variable with the role name, such as
nginx_portandpostgres_port. Without the prefix, two roles that both define port will silently overwrite each other.
Secrets need extra care because they can leak through the repository and through the logs. Use Ansible Vault to encrypt them so passwords and keys never reach Git in plain text:
ansible-vault encrypt_string 'S3cr3tP@ss' --name 'db_password'
An encrypted secret can still appear in plain text when a task fails or runs with -v. Add no_log: true to every task that handles one:
- name: Create the application database user
community.postgresql.postgresql_user:
name: app
password: "{{ db_password }}"
state: present
no_log: true
5. Control how and where a playbook runs
By default, Ansible runs every task on every host in the play at the same time. In production, you need control over which hosts change, how many change at once, and what happens when a task fails.
- Use
--limitto target specific hosts or groups, and--list-hoststo confirm which hosts a run will touch before it starts. - Use tags to run only part of a playbook, such as
--tags nginx. Tag at the role or block level so the tags stay manageable. - Use
serialfor rolling updates. Withserial: 2, Ansible updates two hosts at a time, so a bad change can’t take down the whole group at once.
Use block and rescue to handle errors in a controlled way. If a task in the block fails, Ansible runs the rescue tasks, for example to roll back a change:
- name: Upgrade the application
block:
- name: Deploy the new release
ansible.builtin.unarchive:
src: "app-{{ app_version }}.tar.gz"
dest: /opt/app
rescue:
- name: Roll back to the previous release
ansible.builtin.command: /opt/app/bin/rollback
Keep blocks short. A block that wraps half the playbook is hard to debug and hides which task actually failed.
6. Test every playbook before it reaches production
Following every practice above still won’t catch every mistake. The workflow below finds most problems on your laptop instead of in production.
Run early and often. Run the playbook after each task you add. Ansible errors can get long and dense, so it helps when the newest task is the only possible cause.
Run it again. A second run should change nothing. This forces you to think about the desired state of the target system, which makes playbooks more robust than ones that simply execute actions.
Check it. Run the playbook in check mode to see what it would do without changing anything on the remote servers. Add diff mode to get before-and-after comparisons:
ansible-playbook site.yml -i inventories/staging --check --diff
A playbook that passes check mode cleanly has a second use. Run it in check mode once a day, and any task that reports a change tells you someone or something has altered the system. That gives you a configuration drift detector for free.
Check it against your policy. Steampunk Spotter checks for the issues that cause real failures and security incidents: deprecated modules, risky file permissions, missing no_log on tasks that handle secrets, unverified downloads, and any custom rules your team defines. It runs 200+ built-in checks, explains each finding, and can rewrite many issues for you. If your team already uses Ansible Lint for style and syntax, you can keep it alongside Spotter.
Automate it in CI/CD. Manual checks get skipped when a deadline is close. Add Spotter to your pipeline in GitHub Actions, GitLab CI, Jenkins or Azure DevOps, so every merge request is checked against your policy before anyone reviews it. With the Red Hat Ansible Automation Platform (AAP) integration, Spotter also checks playbooks right before they run. If you build reusable roles, you can also add Molecule to test how they behave on a throwaway instance. For the mistakes we see most often, read avoiding common mistakes in your Ansible Playbooks.
7. Choose collections you can trust
A playbook is only as reliable as the modules it calls. Before you add a collection, check that it has clear documentation, modules that enforce a state and support check mode, tests in its CI/CD configuration, and maintainers who respond to issues. Certified content on Red Hat Automation Hub has gone through additional testing and comes with support from Red Hat and its partners. Our five tips for choosing an Ansible Collection cover each check in detail.
8. Follow security best practices
Ansible Playbooks describe your infrastructure as code, so a vulnerability in a playbook can become a vulnerability in production. Alongside the Vault and no_log practices above:
- Apply least privilege. Use
become: trueonly on the tasks that need it, not on the whole play. - Set file permissions explicitly with
mode, and don’t make them more open than necessary. - Verify what you download. For example, keep
gpgcheck: truefor theyumanddnfmodules andvalidate_certs: truewhen fetching files over HTTPS. - Upgrade Ansible and your collections regularly so you get security fixes and avoid deprecated modules.
- Store your playbooks in access-controlled repositories, and review every change before it merges.
9. Review AI-generated code and enforce your practices automatically
AI assistants such as GitHub Copilot, Claude and ChatGPT make writing Ansible Playbooks much faster. The code they generate needs the same review as any other code. It can use short module names, fall back to shell when a module exists, skip no_log on a task that handles a password, or call a module that has been deprecated. A generator can’t be its own auditor, so the check has to happen somewhere else.
Checking every practice in this guide by hand doesn’t scale, especially when more of your code comes from AI. Steampunk Spotter is the governance layer that checks every change to Ansible and Terraform code against your policy before it runs. It comes with 200+ built-in checks, including the best practices above, and lets you add custom policies of your own. It checks code in your IDE, in CI/CD pipelines such as GitHub Actions, GitLab CI, Jenkins and Azure DevOps, and before execution in Red Hat Ansible Automation Platform (AAP). Through Spotter MCP, AI assistants can check their output against the same policy while they write it.
Start with the habits that prevent the most failures
High-quality Ansible Playbooks come down to a few consistent habits: a clear structure, readable tasks, idempotent modules, protected secrets and testing before every run. Start with the checklist at the top of this post, fix the gaps that matter most to your team, and automate the checks so they hold up as your codebase grows.
Want to know where your own playbooks stand? Get a free, expert-led review of your Ansible Playbooks .

Frequently Asked Questions
What is the best folder structure for an Ansible project?
Keep a separate inventory directory for each environment, with group_vars/ and host_vars/ inside it. Put reusable logic in single-purpose roles under roles/, and list collection dependencies with pinned versions in collections/requirements.yml. A top-level site.yml should import smaller playbooks for each tier, such as web servers and databases.
How do I make an Ansible playbook idempotent?
Use modules that enforce a state, such as ansible.builtin.apt with state: present, instead of command or shell. When you need a shell command, add creates, removes or changed_when so Ansible knows whether anything changed. Run the playbook twice: an idempotent playbook reports changed=0 on the second run.
Should I use roles or playbooks?
Use both. Playbooks decide which hosts get which configuration, and roles hold the reusable tasks, handlers, templates and defaults. Following Ansible playbook best practices, keep task logic in roles and keep top-level playbooks short.
How do I test Ansible Playbooks?
Run the playbook after each task you add, then run it a second time to confirm nothing changes. Use check mode with diff mode (–check –diff) to preview changes without touching your servers. Add a policy check such as Steampunk Spotter to your CI/CD pipeline so every change is checked before it runs.
