Ansible’s simplicity is deceptive. Anyone can write a playbook that works once. Writing playbooks that work reliably, repeatedly, and maintainably requires discipline and patterns.

Project Structure

ansibaiprflnnloiesvall/ieyeebnpsbswdscnpssltrtoiea/ogo/ceooaotbtmisr.rdgkesamnticyuhgihgs.eboxgpf/cornoryranrtgtsogsmvsesitu/tulees/ospawsprsqn._le._s.lyvlbyv.y/ma.smaymlryelrmlsmrsl/lv/ers.yml

Inventory Best Practices

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# inventory/production/hosts.yml
all:
  children:
    webservers:
      hosts:
        web1.example.com:
        web2.example.com:
      vars:
        http_port: 80
    
    databases:
      hosts:
        db1.example.com:
          postgres_role: primary
        db2.example.com:
          postgres_role: replica
    
    loadbalancers:
      hosts:
        lb1.example.com:

  vars:
    ansible_user: deploy
    ansible_python_interpreter: /usr/bin/python3

Role Structure

rolesdvthtfmm/eaaaeieonfrsnmltlgaskdpeaeium/msmiclmlnss/mcdnlaa/anoeaag/sauextiiisnritililf/snnntfsnen/nea/...ai/.sx./uyyylgy/.ylmmmlumcmtlll.rlol/yenm.fly.mjl2######DRESDTeoneeefltrpsaervetuyinilvcdntapeegronviircaaneirbtseiltsaeabsrlte(shhia(gnlhdoelwreerspstriporriiotryi)ty)

Task Patterns

Always Name Tasks

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
# Bad
- apt:
    name: nginx
    state: present

# Good
- name: Install nginx
  apt:
    name: nginx
    state: present
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
- name: Configure SSL
  block:
    - name: Copy SSL certificate
      copy:
        src: "{{ ssl_cert }}"
        dest: /etc/ssl/certs/
        mode: '0644'

    - name: Copy SSL private key
      copy:
        src: "{{ ssl_key }}"
        dest: /etc/ssl/private/
        mode: '0600'

    - name: Enable SSL site
      file:
        src: /etc/nginx/sites-available/ssl.conf
        dest: /etc/nginx/sites-enabled/ssl.conf
        state: link
  notify: Reload nginx
  when: ssl_enabled | bool

Error Handling

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
- name: Deploy application
  block:
    - name: Pull latest code
      git:
        repo: "{{ app_repo }}"
        dest: "{{ app_path }}"
        version: "{{ app_version }}"
      register: git_result

    - name: Run migrations
      command: ./manage.py migrate
      args:
        chdir: "{{ app_path }}"
      when: git_result.changed

  rescue:
    - name: Rollback to previous version
      git:
        repo: "{{ app_repo }}"
        dest: "{{ app_path }}"
        version: "{{ previous_version }}"

    - name: Notify failure
      slack:
        token: "{{ slack_token }}"
        msg: "Deploy failed on {{ inventory_hostname }}"

  always:
    - name: Clean up temp files
      file:
        path: /tmp/deploy
        state: absent

Variables

Variable Precedence (use intentionally)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
# defaults/main.yml - Easily overridden defaults
nginx_worker_processes: auto
nginx_worker_connections: 1024

# vars/main.yml - Role-specific constants
nginx_user: www-data
nginx_conf_path: /etc/nginx/nginx.conf

# group_vars/webservers.yml - Group-specific
nginx_worker_connections: 4096

# host_vars/web1.yml - Host-specific
nginx_worker_processes: 4

Variable Validation

1
2
3
4
5
6
7
8
- name: Validate required variables
  assert:
    that:
      - app_version is defined
      - app_version | length > 0
      - db_password is defined
    fail_msg: "Required variables are not set"
    success_msg: "All required variables present"

Default Values

1
2
3
4
5
6
7
8
- name: Set configuration
  template:
    src: config.j2
    dest: /etc/app/config.yml
  vars:
    max_connections: "{{ app_max_connections | default(100) }}"
    timeout: "{{ app_timeout | default(30) }}"
    debug: "{{ app_debug | default(false) | bool }}"

Handlers

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
# handlers/main.yml
- name: Reload nginx
  service:
    name: nginx
    state: reloaded
  listen: "reload web server"

- name: Restart nginx
  service:
    name: nginx
    state: restarted
  listen: "restart web server"

# In tasks
- name: Update nginx config
  template:
    src: nginx.conf.j2
    dest: /etc/nginx/nginx.conf
  notify: "reload web server"

Flush Handlers When Needed

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
- name: Install nginx
  apt:
    name: nginx
    state: present
  notify: Start nginx

- name: Flush handlers
  meta: flush_handlers

- name: Configure nginx
  template:
    src: nginx.conf.j2
    dest: /etc/nginx/nginx.conf
  # nginx is now guaranteed to be running

Conditionals

Clean Conditionals

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# Use bool filter for string booleans
- name: Enable debug mode
  template:
    src: debug.conf.j2
    dest: /etc/app/debug.conf
  when: debug_mode | bool

# Check if variable is defined and not empty
- name: Set custom config
  copy:
    content: "{{ custom_config }}"
    dest: /etc/app/custom.conf
  when: 
    - custom_config is defined
    - custom_config | length > 0

# Multiple conditions
- name: Deploy to production
  include_tasks: deploy.yml
  when:
    - env == 'production'
    - deploy_enabled | bool
    - inventory_hostname in groups['webservers']

Loops

Modern Loop Syntax

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
# Simple loop
- name: Install packages
  apt:
    name: "{{ item }}"
    state: present
  loop:
    - nginx
    - python3
    - htop

# Better: Install all at once
- name: Install packages
  apt:
    name:
      - nginx
      - python3
      - htop
    state: present

# Loop with index
- name: Create users
  user:
    name: "{{ item.name }}"
    uid: "{{ 1000 + index }}"
    groups: "{{ item.groups }}"
  loop: "{{ users }}"
  loop_control:
    index_var: index
    label: "{{ item.name }}"  # Cleaner output

# Dict loop
- name: Configure services
  template:
    src: "{{ item.key }}.conf.j2"
    dest: "/etc/{{ item.key }}/config.conf"
  loop: "{{ services | dict2items }}"
  when: item.value.enabled | bool

Templates

Jinja2 Best Practices

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
{# templates/nginx.conf.j2 #}
# Managed by Ansible - DO NOT EDIT
# Last updated: {{ ansible_date_time.iso8601 }}
# Host: {{ inventory_hostname }}

worker_processes {{ nginx_worker_processes }};

events {
    worker_connections {{ nginx_worker_connections }};
}

http {
{% for server in nginx_servers %}
    server {
        listen {{ server.port | default(80) }};
        server_name {{ server.name }};
        
{% if server.ssl | default(false) %}
        ssl_certificate {{ server.ssl_cert }};
        ssl_certificate_key {{ server.ssl_key }};
{% endif %}

{% for location in server.locations | default([]) %}
        location {{ location.path }} {
            {{ location.directive }};
        }
{% endfor %}
    }
{% endfor %}
}

Template Validation

1
2
3
4
5
6
- name: Generate nginx config
  template:
    src: nginx.conf.j2
    dest: /etc/nginx/nginx.conf
    validate: nginx -t -c %s
  notify: Reload nginx

Idempotency Patterns

Check Mode Support

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
- name: Get current version
  command: cat /opt/app/VERSION
  register: current_version
  changed_when: false
  check_mode: false  # Always run, even in check mode

- name: Deploy new version
  unarchive:
    src: "app-{{ target_version }}.tar.gz"
    dest: /opt/app/
  when: current_version.stdout != target_version

Custom Changed Conditions

1
2
3
4
5
- name: Run database migration
  command: ./manage.py migrate --check
  register: migration_check
  changed_when: "'No migrations to apply' not in migration_check.stdout"
  failed_when: migration_check.rc not in [0, 1]

Avoid Command When Possible

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
# Bad - not idempotent
- name: Create directory
  command: mkdir -p /opt/app

# Good - idempotent
- name: Create directory
  file:
    path: /opt/app
    state: directory
    mode: '0755'

Secrets Management

Ansible Vault

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# Create encrypted file
ansible-vault create secrets.yml

# Edit encrypted file
ansible-vault edit secrets.yml

# Use in playbook
ansible-playbook site.yml --ask-vault-pass

# Or with password file
ansible-playbook site.yml --vault-password-file ~/.vault_pass
1
2
3
4
5
6
7
8
# Encrypted variables file
# group_vars/all/vault.yml
vault_db_password: !vault |
  $ANSIBLE_VAULT;1.1;AES256
  ...

# Reference in playbook
db_password: "{{ vault_db_password }}"

No Secrets in Logs

1
2
3
4
5
- name: Set database password
  mysql_user:
    name: app
    password: "{{ db_password }}"
  no_log: true

Performance

Gather Facts Selectively

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
- hosts: webservers
  gather_facts: false
  tasks:
    - name: Quick task without facts
      ping:

# Or gather specific facts
- hosts: webservers
  gather_subset:
    - network
    - hardware

Async for Long Tasks

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
- name: Run long backup
  command: /opt/scripts/backup.sh
  async: 3600  # 1 hour timeout
  poll: 0      # Don't wait
  register: backup_job

- name: Check backup status
  async_status:
    jid: "{{ backup_job.ansible_job_id }}"
  register: job_result
  until: job_result.finished
  retries: 60
  delay: 60

Limit Concurrent Execution

1
2
3
4
- hosts: webservers
  serial: 2  # Two hosts at a time
  # Or percentage: serial: "25%"
  # Or batches: serial: [1, 5, 10]

Testing with Molecule

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
# molecule/default/molecule.yml
dependency:
  name: galaxy
driver:
  name: docker
platforms:
  - name: instance
    image: ubuntu:22.04
    pre_build_image: true
provisioner:
  name: ansible
verifier:
  name: ansible
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# molecule/default/verify.yml
- name: Verify
  hosts: all
  tasks:
    - name: Check nginx is running
      service:
        name: nginx
        state: started
      check_mode: true
      register: result
      failed_when: result.changed
1
2
# Run tests
molecule test

Good Ansible is boring Ansible. No surprises, no side effects, same result every time. When your playbooks are truly idempotent, running them becomes a confidence-builder rather than a risk.

Start with roles, use handlers properly, validate your templates, and test with Molecule. Your future self—and your 3 AM pager—will thank you.