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
- Always name your tasks - makes output readable
- Use action verbs - Install, Configure, Enable, Create, etc.
- Be specific - mention what is being installed/configured
- Keep names concise - but not at the expense of clarity
- 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):
- role defaults (defaults/main.yml)
- inventory file or script group vars
- inventory group_vars/all
- playbook group_vars/all
- inventory group_vars/*
- playbook group_vars/*
- inventory file or script host vars
- inventory host_vars/*
- playbook host_vars/*
- host facts / cached set_facts
- play vars
- play vars_prompt
- play vars_files
- role vars (vars/main.yml)
- block vars
- task vars
- include_vars
- set_facts / registered vars
- role (and include_role) params
- include params
- 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
- Use modules instead of command/shell whenever possible
- Use creates/removes parameters for command/shell when necessary
- Set changed_when appropriately for read-only commands
- Test idempotency - run playbook twice, second run should show no changes
- 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
- First choice: Specific module (apt, yum, systemd, copy, etc.)
- Second choice: Generic module (package, service, etc.)
- 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
- Handlers run once at the end of a play, even if notified multiple times
- Handlers run in order they're defined, not in order they're notified
- Use listen for handler groups
- Flush handlers with
meta: flush_handlersto 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