Ansible is simple until it isn’t. Here’s how to structure projects that remain maintainable as they grow.
Project Structure#
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#
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:
- Role defaults (
roles/x/defaults/main.yml) - Inventory file variables
- Inventory
group_vars/all - Inventory
group_vars/* - Inventory
host_vars/* - Playbook
vars: - Role vars (
roles/x/vars/main.yml) - Task
vars: - 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.
📬 Get the Newsletter
Weekly insights on DevOps, automation, and CLI mastery. No spam, unsubscribe anytime.