Ansible is simple until it isn’t. Here’s how to structure projects that remain maintainable as they grow.

Project Structure

ansibaiprclnnlooesvall/ieyelbnpsbswdscnperltrtoiea/ogoceeooaotbtmistq.rdgkesamntiucyuhgihgs.eboxgoif/cornoryranrnrgtsogsmvseseitu/tulees/mospawsprsqen._le._s.lnyvlbyv.y/tma.smaymslryelrml.smrsly/lv/melrs.yml

Inventory Best Practices

YAML Inventory

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

Group Variables

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
# inventory/production/group_vars/all.yml
---
ansible_user: deploy
ansible_python_interpreter: /usr/bin/python3

ntp_servers:
  - 0.pool.ntp.org
  - 1.pool.ntp.org

common_packages:
  - vim
  - htop
  - curl
1
2
3
4
5
# inventory/production/group_vars/webservers.yml
---
nginx_worker_processes: auto
nginx_worker_connections: 1024
app_port: 3000

Role Structure

rolesdvthtfmR/eaaaeieEnfrsnmltAgaskdpeaDium/msmlmlnss/mMnlaa/aeaag/saExtiiiritili./snnnsnen-nm/.../.sxp.dyyyy/.aymmmmcrmlllloalnmfs..jc2on#####f#DRTHJ#ReoaaioflsnnSlaekdjteuslaalve2tmtatrierostctvieaaae(mfdrbxrpiaileelltaecsaeabsuttsltaeae(ersnshtdi(gsdlheeoerpwrveeinspcdtreeisnpo,crriiieeottsryci).t)y)

Role Defaults

1
2
3
4
5
6
7
# roles/nginx/defaults/main.yml
---
nginx_user: www-data
nginx_worker_processes: auto
nginx_worker_connections: 1024
nginx_keepalive_timeout: 65
nginx_sites: []

Role Tasks

 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
38
39
40
41
42
43
44
45
# roles/nginx/tasks/main.yml
---
- name: Install nginx
  ansible.builtin.apt:
    name: nginx
    state: present
    update_cache: true
  become: true

- name: Configure nginx
  ansible.builtin.template:
    src: nginx.conf.j2
    dest: /etc/nginx/nginx.conf
    owner: root
    group: root
    mode: '0644'
  become: true
  notify: Reload nginx

- name: Configure sites
  ansible.builtin.template:
    src: site.conf.j2
    dest: "/etc/nginx/sites-available/{{ item.name }}"
    owner: root
    group: root
    mode: '0644'
  loop: "{{ nginx_sites }}"
  become: true
  notify: Reload nginx

- name: Enable sites
  ansible.builtin.file:
    src: "/etc/nginx/sites-available/{{ item.name }}"
    dest: "/etc/nginx/sites-enabled/{{ item.name }}"
    state: link
  loop: "{{ nginx_sites }}"
  become: true
  notify: Reload nginx

- name: Start nginx
  ansible.builtin.service:
    name: nginx
    state: started
    enabled: true
  become: true

Handlers

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
# roles/nginx/handlers/main.yml
---
- name: Reload nginx
  ansible.builtin.service:
    name: nginx
    state: reloaded
  become: true

- name: Restart nginx
  ansible.builtin.service:
    name: nginx
    state: restarted
  become: true

Playbook Patterns

Main Playbook

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
# playbooks/site.yml
---
- name: Apply common configuration
  hosts: all
  roles:
    - common

- name: Configure web servers
  hosts: webservers
  roles:
    - nginx
    - app

- name: Configure databases
  hosts: databases
  roles:
    - postgresql

Tagged Playbook

 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
# playbooks/webservers.yml
---
- name: Configure web servers
  hosts: webservers
  become: true

  tasks:
    - name: Install packages
      ansible.builtin.apt:
        name: "{{ item }}"
        state: present
      loop:
        - nginx
        - python3
      tags:
        - packages

    - name: Deploy application
      ansible.builtin.git:
        repo: "{{ app_repo }}"
        dest: /var/www/app
        version: "{{ app_version }}"
      tags:
        - deploy

    - name: Configure nginx
      ansible.builtin.template:
        src: nginx.conf.j2
        dest: /etc/nginx/nginx.conf
      notify: Reload nginx
      tags:
        - config

Run specific tags:

1
2
ansible-playbook playbooks/webservers.yml --tags deploy
ansible-playbook playbooks/webservers.yml --skip-tags packages

Variable Precedence

From lowest to highest:

  1. Role defaults (roles/x/defaults/main.yml)
  2. Inventory file variables
  3. Inventory group_vars/all
  4. Inventory group_vars/*
  5. Inventory host_vars/*
  6. Playbook vars:
  7. Role vars (roles/x/vars/main.yml)
  8. Task vars:
  9. Extra vars (-e "var=value")

Conditionals and Loops

Conditionals

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
- name: Install Apache (Debian)
  ansible.builtin.apt:
    name: apache2
    state: present
  when: ansible_os_family == "Debian"

- name: Install Apache (RedHat)
  ansible.builtin.yum:
    name: httpd
    state: present
  when: ansible_os_family == "RedHat"

- name: Configure if variable defined
  ansible.builtin.template:
    src: config.j2
    dest: /etc/app/config.yml
  when: app_config is defined

Loops

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
- name: Create users
  ansible.builtin.user:
    name: "{{ item.name }}"
    groups: "{{ item.groups }}"
    state: present
  loop:
    - { name: 'alice', groups: 'admin' }
    - { name: 'bob', groups: 'developers' }

- name: Install packages
  ansible.builtin.apt:
    name: "{{ item }}"
    state: present
  loop: "{{ packages }}"

- name: Template multiple files
  ansible.builtin.template:
    src: "{{ item.src }}"
    dest: "{{ item.dest }}"
  loop:
    - { src: 'app.conf.j2', dest: '/etc/app/app.conf' }
    - { src: 'db.conf.j2', dest: '/etc/app/db.conf' }

Error Handling

Ignore Errors

1
2
3
4
5
- name: Try to stop service (might not exist)
  ansible.builtin.service:
    name: myapp
    state: stopped
  ignore_errors: true

Block/Rescue

 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
- name: Handle deployment
  block:
    - name: Deploy application
      ansible.builtin.git:
        repo: "{{ app_repo }}"
        dest: /var/www/app
        version: "{{ app_version }}"

    - name: Run migrations
      ansible.builtin.command:
        cmd: ./migrate.sh
        chdir: /var/www/app

  rescue:
    - name: Rollback on failure
      ansible.builtin.git:
        repo: "{{ app_repo }}"
        dest: /var/www/app
        version: "{{ previous_version }}"

    - name: Notify failure
      ansible.builtin.debug:
        msg: "Deployment failed, rolled back to {{ previous_version }}"

  always:
    - name: Restart application
      ansible.builtin.service:
        name: myapp
        state: restarted

Changed When

1
2
3
4
5
6
- name: Check if config changed
  ansible.builtin.command:
    cmd: diff /etc/app/config.yml /tmp/new-config.yml
  register: config_diff
  changed_when: config_diff.rc != 0
  failed_when: config_diff.rc > 1

Templates (Jinja2)

 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
{# templates/nginx.conf.j2 #}
user {{ nginx_user }};
worker_processes {{ nginx_worker_processes }};

events {
    worker_connections {{ nginx_worker_connections }};
}

http {
    include /etc/nginx/mime.types;
    default_type application/octet-stream;

    sendfile on;
    keepalive_timeout {{ nginx_keepalive_timeout }};

{% for site in nginx_sites %}
    server {
        listen {{ site.port | default(80) }};
        server_name {{ site.server_name }};
        root {{ site.root }};

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

Secrets with Ansible Vault

Create Encrypted File

1
ansible-vault create inventory/production/group_vars/vault.yml

Edit Encrypted File

1
ansible-vault edit inventory/production/group_vars/vault.yml

Encrypt Existing File

1
ansible-vault encrypt secrets.yml

Use in Playbook

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
# Include vaulted variables
- name: Configure with secrets
  hosts: all
  vars_files:
    - vault.yml
  tasks:
    - name: Set database password
      ansible.builtin.template:
        src: db.conf.j2
        dest: /etc/app/db.conf

Run with vault:

1
2
ansible-playbook site.yml --ask-vault-pass
ansible-playbook site.yml --vault-password-file ~/.vault_pass

Testing with Molecule

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
# 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
molecule init role my_role
molecule test
molecule converge  # Just apply
molecule verify    # Just test
molecule destroy   # Cleanup

Useful Patterns

Check Mode

1
2
3
4
5
- name: Validate config
  ansible.builtin.command:
    cmd: nginx -t
  check_mode: false  # Always run, even in check mode
  changed_when: false

Delegate To

1
2
3
4
5
6
- name: Update load balancer
  ansible.builtin.uri:
    url: "http://lb.example.com/api/backends"
    method: POST
    body: '{"host": "{{ inventory_hostname }}"}'
  delegate_to: localhost

Run Once

1
2
3
4
5
- name: Run database migration
  ansible.builtin.command:
    cmd: ./migrate.sh
    chdir: /var/www/app
  run_once: true

Serial Execution

1
2
3
4
5
6
7
8
- name: Rolling update
  hosts: webservers
  serial: 1  # One host at a time
  tasks:
    - name: Deploy
      ansible.builtin.git:
        repo: "{{ app_repo }}"
        dest: /var/www/app

Quick Reference

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
# Run playbook
ansible-playbook playbooks/site.yml

# Limit to hosts
ansible-playbook playbooks/site.yml --limit webservers

# Check mode (dry run)
ansible-playbook playbooks/site.yml --check

# Verbose output
ansible-playbook playbooks/site.yml -vvv

# List hosts
ansible-playbook playbooks/site.yml --list-hosts

# List tasks
ansible-playbook playbooks/site.yml --list-tasks

# Start at task
ansible-playbook playbooks/site.yml --start-at-task "Deploy application"

Ansible’s simplicity is deceptive. Structure it well from the start, and it stays manageable forever.