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

Card image caption

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 state explicitly, even when it matches the default.
  • Prefer dedicated modules over command and shell.
  • 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 --limit can’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 nginx role should install and configure Nginx and nothing else.
  • Let top-level playbooks orchestrate. site.yml imports webservers.yml and dbservers.yml, and each of those applies roles.
  • Pin your dependencies by listing every collection with a version incollections/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.user points to exactly one module, while a short name like user can resolve to a module from another collection.
  • Set state explicitly. Writing state: present or state: absent makes 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=value pairs.
  • Use true and false for booleans. Ansible accepts yes and no, 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/ and host_vars/ inside each inventory, not in the playbook itself.
  • Prefix every role variable with the role name, such as nginx_port and postgres_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 --limit to target specific hosts or groups, and --list-hosts to 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 serial for rolling updates. With serial: 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: true only 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: true for the yum and dnf modules and validate_certs: true when 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 .

Try Steampunk Spotter, the Ansible Playbook Platform

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.

Found this post useful?

Get our monthly newsletter.

Thank you for subscribing!

Please wait

Processing, please wait...

Keep up with what we do on our social media.