All skills
Skillintermediate

Ansible Best Practices

This guide provides comprehensive best practices for writing clean, maintainable, and reliable Ansible playbooks, roles, and collections.

Claude Code Knowledge Pack7/10/2026

Overview

Ansible Best Practices

Overview

This guide provides comprehensive best practices for writing clean, maintainable, and reliable Ansible playbooks, roles, and collections.

Playbook Organization

Directory Structure

ansible-project/
├── ansible.cfg              # Ansible configuration
├── inventory/               # Inventory files
│   ├── production/
│   │   ├── hosts           # Production inventory
│   │   └── group_vars/
│   │       └── all.yml
│   └── staging/
│       ├── hosts           # Staging inventory
│       └── group_vars/
│           └── all.yml
├── group_vars/             # Group-specific variables
│   ├── all.yml
│   ├── webservers.yml
│   └── databases.yml
├── host_vars/              # Host-specific variables
│   └── server1.yml
├── roles/                  # Reusable roles
│   ├── common/
│   ├── webserver/
│   └── database/
├── playbooks/              # Playbooks
│   ├── site.yml           # Master playbook
│   ├── webservers.yml
│   └── databases.yml
├── files/                  # Static files
├── templates/              # Jinja2 templates
├── vars/                   # Additional variables
│   └── external_vars.yml
└── requirements.yml        # Collection dependencies

Role Structure

roles/webserver/
├── README.md              # Role documentation
├── defaults/
│   └── main.yml          # Default variables (lowest precedence)
├── vars/
│   └── main.yml          # Role variables (higher precedence)
├── tasks/
│   ├── main.yml          # Main task list
│   ├── install.yml       # Installation tasks
│   └── configure.yml     # Configuration tasks
├── handlers/
│   └── main.yml          # Handlers
├── templates/
│   └── nginx.conf.j2     # Template files
├── files/
│   └── index.html        # Static files
├── meta/
│   └── main.yml          # Role metadata and dependencies
└── molecule/             # Molecule test scenarios
    └── default/
        ├── molecule.yml
        ├── converge.yml
        └── verify.yml

Task Naming and Documentation

✅ Good Task Names

# Descriptive, action-oriented names
- name: Install nginx web server
  apt:
    name: nginx
    state: present

- name: Configure nginx virtual host for example.com
  template:
    src: vhost.conf.j2
    dest: /etc/nginx/sites-available/example.com

- name: Enable and start nginx service
  systemd:
    name: nginx
    state: started
    enabled: yes

- name: Create application user with limited privileges
  user:
    name: appuser
    system: yes
    shell: /bin/false
    home: /var/lib/app

❌ Bad Task Names

# Vague, uninformative names
- name: Install package
  apt:
    name: nginx

- name: Configure
  template:
    src: vhost.conf.j2
    dest: /etc/nginx/sites-available/example.com

- name: Service
  systemd:
    name: nginx
    state: started

# No name at all
- apt:
    name: nginx

Best Practices

  1. Always name your tasks - makes output readable
  2. Use action verbs - Install, Configure, Enable, Create, etc.
  3. Be specific - mention what is being installed/configured
  4. Keep names concise - but not at the expense of clarity
  5. Use consistent naming - across all playbooks

Variable Management

Variable Naming Conventions

# ✅ Good - Descriptive, namespaced
nginx_version: "1.18.0"
nginx_worker_processes: 4
nginx_worker_connections: 1024
app_database_host: "db.example.com"
app_database_port: 5432

# ❌ Bad - Generic, collision-prone
version: "1.18.0"  # Too generic
workers: 4         # Unclear
db: "db.example.com"  # Vague

Variable Precedence

Understand variable precedence (from lowest to highest):

  1. role defaults (defaults/main.yml)
  2. inventory file or script group vars
  3. inventory group_vars/all
  4. playbook group_vars/all
  5. inventory group_vars/*
  6. playbook group_vars/*
  7. inventory file or script host vars
  8. inventory host_vars/*
  9. playbook host_vars/*
  10. host facts / cached set_facts
  11. play vars
  12. play vars_prompt
  13. play vars_files
  14. role vars (vars/main.yml)
  15. block vars
  16. task vars
  17. include_vars
  18. set_facts / registered vars
  19. role (and include_role) params
  20. include params
  21. extra vars (always win precedence)

Variable Organization

# defaults/main.yml - Intended to be overridden
---
nginx_port: 80
nginx_user: www-data
nginx_worker_processes: "auto"

# vars/main.yml - Should not be overridden
---
nginx_config_dir: /etc/nginx
nginx_log_dir: /var/log/nginx
nginx_pid_file: /run/nginx.pid

Using Defaults and Required Variables

# Use default filter for optional variables
- name: Set API endpoint
  set_fact:
    api_endpoint: "{{ custom_api_endpoint | default('https://api.example.com') }}"

# Use required filter for mandatory variables
- name: Configure database
  template:
    src: db.conf.j2
    dest: /etc/app/database.conf
  vars:
    db_password: "{{ database_password | required('database_password must be defined') }}"

Idempotency

What is Idempotency?

Idempotency means running the same playbook multiple times produces the same result without making unnecessary changes.

✅ Idempotent Tasks

# File module - inherently idempotent
- name: Ensure configuration directory exists
  file:
    path: /etc/myapp
    state: directory
    mode: '0755'

# Template module - only changes if content differs
- name: Configure application
  template:
    src: app.conf.j2
    dest: /etc/myapp/app.conf
    mode: '0644'

# Package module - idempotent
- name: Install required packages
  apt:
    name:
      - nginx
      - python3
      - git
    state: present

# Service module - idempotent
- name: Ensure service is running
  systemd:
    name: myapp
    state: started
    enabled: yes

⚠️ Non-Idempotent Tasks (Need Fixes)

# Command/shell without creates/removes
- name: Download file
  command: curl -o /tmp/file.tar.gz https://example.com/file.tar.gz
  # This runs every time!

# Fix with creates
- name: Download file
  command: curl -o /tmp/file.tar.gz https://example.com/file.tar.gz
  args:
    creates: /tmp/file.tar.gz

# Or better - use get_url module
- name: Download file
  get_url:
    url: https://example.com/file.tar.gz
    dest: /tmp/file.tar.gz
    checksum: sha256:abc123...

# Command that always reports changed
- name: Check service status
  command: systemctl status myapp
  register: service_status
  # Always shows as changed!

# Fix with changed_when
- name: Check service status
  command: systemctl status myapp
  register: service_status
  changed_when: false
  failed_when: service_status.rc not in [0, 3]

Best Practices for Idempotency

  1. Use modules instead of command/shell whenever possible
  2. Use creates/removes parameters for command/shell when necessary
  3. Set changed_when appropriately for read-only commands
  4. Test idempotency - run playbook twice, second run should show no changes
  5. Use check mode to verify idempotency without making changes

Module Selection

Prefer Modules Over Commands

# ❌ Bad - Using shell/command
- name: Create directory
  shell: mkdir -p /opt/myapp

- name: Install package
  command: apt-get install -y nginx

- name: Add line to file
  shell: echo "export PATH=$PATH:/opt/bin" >> ~/.bashrc

# ✅ Good - Using appropriate modules
- name: Create directory
  file:
    path: /opt/myapp
    state: directory
    mode: '0755'

- name: Install package
  apt:
    name: nginx
    state: present

- name: Add line to file
  lineinfile:
    path: ~/.bashrc
    line: 'export PATH=$PATH:/opt/bin'
    create: yes

Module Hierarchy

  1. First choice: Specific module (apt, yum, systemd, copy, etc.)
  2. Second choice: Generic module (package, service, etc.)
  3. Last resort: command or shell module

Error Handling

Using Blocks

- name: Handle errors gracefully
  block:
    - name: Attempt risky operation
      command: /usr/local/bin/risky-operation.sh
      register: result

    - name: Process successful result
      debug:
        msg: "Operation succeeded: {{ result.stdout }}"

  rescue:
    - name: Handle failure
      debug:
        msg: "Operation failed, applying fallback"

    - name: Apply fallback configuration
      copy:
        src: fallback.conf
        dest: /etc/app/config.conf

  always:
    - name: Cleanup temporary files
      file:
        path: /tmp/operation.lock
        state: absent

Failed When and Changed When

# Custom failure conditions
- name: Check disk space
  shell: df -h / | tail -1 | awk '{print $5}' | sed 's/%//'
  register: disk_usage
  failed_when: disk_usage.stdout | int > 90

# Custom changed conditions
- name: Verify configuration
  command: /usr/local/bin/check-config.sh
  register: config_check
  changed_when: false
  failed_when: config_check.rc != 0

# Multiple conditions
- name: Run healthcheck
  uri:
    url: http://localhost:8080/health
    method: GET
  register: health
  failed_when:
    - health.status != 200
    - "'healthy' not in health.json.status"

Ignoring Errors (Use Sparingly)

# Only when failure is acceptable
- name: Try to stop service (may not exist)
  systemd:
    name: old-service
    state: stopped
  ignore_errors: yes

# Better approach - check first
- name: Check if service exists
  systemd:
    name: old-service
  register: service_status
  failed_when: false

- name: Stop service if it exists
  systemd:
    name: old-service
    state: stopped
  when: service_status.status.ActiveState is defined

Conditionals and Loops

When Conditions

# Simple condition
- name: Install Apache (Debian)
  apt:
    name: apache2
    state: present
  when: ansible_os_family == "Debian"

# Multiple conditions (AND)
- name: Install package on Ubuntu 20.04
  apt:
    name: package
    state: present
  when:
    - ansible_distribution == "Ubuntu"
    - ansible_distribution_version == "20.04"

# OR conditions
- name: Install on RHEL or CentOS
  yum:
    name: package
    state: present
  when: ansible_distribution == "RedHat" or ansible_distribution == "CentOS"

# Complex conditions
- name: Configure firewall
  ufw:
    rule: allow
    port: '443'
  when:
    - ansible_os_family == "Debian"
    - firewall_enabled | default(true) | bool
    - ansible_virtualization_type != "docker"

Loops

# Simple loop
- name: Install packages
  apt:
    name: "{{ item }}"
    state: present
  loop:
    - nginx
    - python3
    - git

# Loop with hash
- name: Create users
  user:
    name: "{{ item.name }}"
    groups: "{{ item.groups }}"
    state: present
  loop:
    - { name: 'alice', groups: 'developers' }
    - { name: 'bob', groups: 'operators' }

# Loop with dict
- name: Create directories
  file:
    path: "{{ item.path }}"
    state: directory
    mode: "{{ item.mode }}"
  loop:
    - { path: '/opt/app', mode: '0755' }
    - { path: '/var/log/app', mode: '0755' }
    - { path: '/etc/app', mode: '0750' }

# Loop with conditional
- name: Install debug tools (dev only)
  apt:
    name: "{{ item }}"
    state: present
  loop:
    - strace
    - tcpdump
    - gdb
  when: environment == "development"

Templates and Jinja2

Template Best Practices

{# templates/nginx.conf.j2 #}

{# Use comments to explain complex logic #}
user {{ nginx_user }};
worker_processes {{ nginx_worker_processes }};
pid {{ nginx_pid_file }};

{# Conditionals in templates #}
{% if nginx_enable_ssl %}
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
{% endif %}

{# Loops in templates #}
{% for vhost in nginx_vhosts %}
server {
    listen {{ vhost.port }};
    server_name {{ vhost.server_name }};
    root {{ vhost.document_root }};

    {% if vhost.ssl_enabled | default(false) %}
    ssl_certificate {{ vhost.ssl_cert }};
    ssl_certificate_key {{ vhost.ssl_key }};
    {% endif %}
}
{% endfor %}

{# Filters #}
upstream_servers = {{ backend_servers | join(',') }}
max_connections = {{ max_connections | default(1024) }}

Useful Jinja2 Filters

# String manipulation
- debug:
    msg: "{{ 'hello' | upper }}"  # HELLO
    msg: "{{ 'HELLO' | lower }}"  # hello
    msg: "{{ '  hello  ' | trim }}"  # hello

# List operations
- debug:
    msg: "{{ [1,2,3] | first }}"  # 1
    msg: "{{ [1,2,3] | last }}"  # 3
    msg: "{{ [1,2,3] | length }}"  # 3
    msg: "{{ [1,2,3] | join(',') }}"  # 1,2,3

# Default values
- debug:
    msg: "{{ undefined_var | default('default_value') }}"

# Type conversion
- debug:
    msg: "{{ '123' | int }}"  # 123
    msg: "{{ 'true' | bool }}"  # True

# JSON and YAML
- debug:
    msg: "{{ my_dict | to_json }}"
    msg: "{{ my_dict | to_nice_json }}"
    msg: "{{ my_dict | to_yaml }}"

Tags

Using Tags Effectively

---
- name: Configure web server
  hosts: webservers
  tasks:
    - name: Install nginx
      apt:
        name: nginx
      tags:
        - packages
        - nginx

    - name: Configure nginx
      template:
        src: nginx.conf.j2
        dest: /etc/nginx/nginx.conf
      tags:
        - configuration
        - nginx

    - name: Start nginx
      systemd:
        name: nginx
        state: started
      tags:
        - services
        - nginx

    - name: Configure firewall
      ufw:
        rule: allow
        port: '80'
      tags:
        - security
        - firewall

Running with Tags

# Run only nginx tasks
ansible-playbook site.yml --tags nginx

# Run configuration tasks only
ansible-playbook site.yml --tags configuration

# Skip certain tags
ansible-playbook site.yml --skip-tags packages

# Multiple tags
ansible-playbook site.yml --tags "nginx,firewall"

Handlers

Handler Best Practices

# tasks/main.yml
- name: Configure nginx
  template:
    src: nginx.conf.j2
    dest: /etc/nginx/nginx.conf
  notify:
    - Validate nginx configuration
    - Restart nginx

- name: Add virtual host
  template:
    src: vhost.conf.j2
    dest: "/etc/nginx/sites-available/{{ vhost_name }}"
  notify:
    - Reload nginx

# handlers/main.yml
- name: Validate nginx configuration
  command: nginx -t
  changed_when: false

- name: Restart nginx
  systemd:
    name: nginx
    state: restarted

- name: Reload nginx
  systemd:
    name: nginx
    state: reloaded

Handler Facts

  1. Handlers run once at the end of a play, even if notified multiple times
  2. Handlers run in order they're defined, not in order they're notified
  3. Use listen for handler groups
  4. Flush handlers with meta: flush_handlers to run immediately

Check Mode and Diff Mode

Supporting Check Mode

# Task that supports check mode naturally (file module)
- name: Create directory
  file:
    path: /opt/myapp
    state: directory

# Task that doesn't support check mode, but can run anyway
- name: Check service status
  command: systemctl status myapp
  check_mode: no  # Always run, even in check mode
  changed_when: false

# Task that should be sk