Ansible’s simplicity is seductive. YAML tasks, SSH connections, no agents. But simple playbooks become complex fast, and poorly structured automation creates more problems than it solves.

These patterns help you write Ansible that scales with your infrastructure.

Idempotency: Safe to Run Twice

Every task should be safe to run repeatedly with the same result:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
# Idempotent - creates file if missing, no-op if exists
- name: Create config directory
  file:
    path: /etc/myapp
    state: directory
    mode: '0755'

# Not idempotent - appends every run
- name: Add config line
  shell: echo "setting=value" >> /etc/myapp/config

# Idempotent version
- name: Add config line
  lineinfile:
    path: /etc/myapp/config
    line: "setting=value"

Use Ansible modules over shell commands. Modules are designed for idempotency.

changed_when and failed_when

Control when tasks report changes or failures:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
- name: Check if service is running
  command: systemctl is-active myapp
  register: service_status
  changed_when: false  # This is a check, not a change
  failed_when: false   # Don't fail if service is stopped

- name: Start service if not running
  service:
    name: myapp
    state: started
  when: service_status.stdout != "active"

Without changed_when: false, every check command shows as “changed” in output.

Handlers: React to Changes

Handlers run once at the end, only if notified:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
tasks:
  - name: Update nginx config
    template:
      src: nginx.conf.j2
      dest: /etc/nginx/nginx.conf
    notify: Reload nginx

  - name: Update SSL certificate
    copy:
      src: cert.pem
      dest: /etc/nginx/ssl/cert.pem
    notify: Reload nginx

handlers:
  - name: Reload nginx
    service:
      name: nginx
      state: reloaded

Even if both tasks change, nginx reloads only once.

Block, Rescue, Always

Handle errors gracefully:

 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 with rollback
  block:
    - name: Deploy new version
      copy:
        src: app-v2.tar.gz
        dest: /opt/app/

    - name: Extract and start
      shell: |
        cd /opt/app && tar xzf app-v2.tar.gz
        systemctl restart myapp

    - name: Verify deployment
      uri:
        url: http://localhost:8080/health
        status_code: 200

  rescue:
    - name: Rollback to previous version
      shell: |
        cd /opt/app && tar xzf app-v1.tar.gz
        systemctl restart myapp

    - name: Alert on failure
      debug:
        msg: "Deployment failed, rolled back to v1"

  always:
    - name: Clean up temp files
      file:
        path: /opt/app/*.tar.gz
        state: absent

Variables: Layered and Organized

Ansible has many variable precedence levels. Use them intentionally:

invgheronoawswtuleteoplb_br_.sv0yyea1/amrr.rlvsyse/m/rls.yml###DDSeepffeaacuuillfttisscfftooorrwewevebeb0rs1yetrhvienrggroup
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
# group_vars/all.yml
app_port: 8080
app_user: appuser
log_level: info

# group_vars/webservers.yml
nginx_worker_processes: auto

# host_vars/web01.yml
nginx_worker_processes: 4  # Override for this host

Don’t hardcode values in playbooks. Parameterize everything.

Roles: Reusable Components

Structure complex automation as roles:

rolnegsithtdv/naaeeaxsmnmmnfmrm/kadapgaasasililiui/i/nenanlnn.r.txt..ysye.syym/mscmmll/llnf.j2
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
# roles/nginx/tasks/main.yml
- name: Install nginx
  package:
    name: nginx
    state: present

- name: Configure nginx
  template:
    src: nginx.conf.j2
    dest: /etc/nginx/nginx.conf
  notify: Reload nginx

- name: Ensure nginx is running
  service:
    name: nginx
    state: started
    enabled: true
1
2
3
4
5
# playbook.yml
- hosts: webservers
  roles:
    - nginx
    - { role: app, app_version: "2.0.1" }

Tags: Selective Execution

Tag tasks for partial runs:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
- name: Install packages
  package:
    name: "{{ item }}"
    state: present
  loop:
    - nginx
    - python3
  tags:
    - packages
    - setup

- name: Deploy application
  copy:
    src: app/
    dest: /opt/app/
  tags:
    - deploy

- name: Configure monitoring
  template:
    src: prometheus.yml.j2
    dest: /etc/prometheus/prometheus.yml
  tags:
    - monitoring
1
2
3
4
5
# Run only deploy tasks
ansible-playbook site.yml --tags deploy

# Run everything except monitoring
ansible-playbook site.yml --skip-tags monitoring

Check Mode: Dry Run

Test changes before applying:

1
ansible-playbook site.yml --check --diff

--check: Don’t make changes, just report what would change --diff: Show file content differences

Some tasks don’t support check mode. Mark them:

1
2
3
4
5
- name: Run database migration
  command: /opt/app/migrate.sh
  args:
    creates: /opt/app/.migrated  # Skip if file exists
  check_mode: false  # Always run, even in check mode

Secrets with Ansible Vault

Encrypt sensitive data:

1
2
3
4
5
6
7
8
# Create encrypted file
ansible-vault create group_vars/all/vault.yml

# Edit encrypted file
ansible-vault edit group_vars/all/vault.yml

# Run playbook with vault
ansible-playbook site.yml --ask-vault-pass
1
2
3
4
5
# group_vars/all/vault.yml (encrypted)
vault_db_password: "supersecret123"

# group_vars/all/main.yml (references vault)
db_password: "{{ vault_db_password }}"

Prefix vault variables with vault_ to make references clear.

Delegation and Local Actions

Run tasks on different hosts:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
- name: Remove from load balancer
  uri:
    url: "http://lb.internal/api/remove"
    method: POST
    body: '{"host": "{{ inventory_hostname }}"}'
  delegate_to: localhost

- name: Deploy to this host
  copy:
    src: app/
    dest: /opt/app/

- name: Add back to load balancer
  uri:
    url: "http://lb.internal/api/add"
    method: POST
    body: '{"host": "{{ inventory_hostname }}"}'
  delegate_to: localhost

Serial Execution: Rolling Deployments

Don’t update all hosts at once:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
- hosts: webservers
  serial: 2  # Two hosts at a time
  # Or: serial: "25%"  # 25% at a time

  tasks:
    - name: Deploy
      # ...

    - name: Verify
      uri:
        url: "http://{{ inventory_hostname }}/health"
      delegate_to: localhost

If deployment fails on the first batch, remaining hosts are untouched.

Testing with Molecule

Test roles before deploying:

1
2
3
4
5
pip install molecule molecule-docker

cd roles/nginx
molecule init scenario -d docker
molecule test
1
2
3
4
5
# molecule/default/converge.yml
- name: Converge
  hosts: all
  roles:
    - nginx

Molecule creates containers, applies your role, runs verification, then destroys.


Ansible’s power comes from composition: small, idempotent tasks combined into roles, parameterized with variables, tested before production.

Start simple. Extract to roles when patterns repeat. Use check mode religiously. Encrypt secrets. Test with Molecule.

The best playbook is one you can run confidently at 3am knowing it won’t make things worse.