The promise of Ansible is simple: describe your desired state, run the playbook, and the system converges to that state. Run it again, nothing changes. That’s idempotency—and it’s harder to achieve than it sounds.
Here’s how to write playbooks that won’t surprise you on the second run.
The Problem: Commands That Lie#
The command and shell modules are where idempotency goes to die:
1
2
3
| # ❌ BAD: Always reports "changed", even when nothing changed
- name: Create database
command: createdb myapp
|
This fails on the second run because the database already exists. Worse, it always shows as “changed” even when it shouldn’t run at all.
Pattern 1: Use creates/removes#
For commands that create or remove files, tell Ansible:
1
2
3
4
5
6
7
8
9
10
11
| # ✅ GOOD: Only runs if the file doesn't exist
- name: Initialize database
command: initdb /var/lib/postgres/data
args:
creates: /var/lib/postgres/data/PG_VERSION
# ✅ GOOD: Only runs if the file exists
- name: Remove legacy config
command: rm -rf /etc/app/old_config
args:
removes: /etc/app/old_config
|
The creates argument means “skip this task if this path exists.” The removes argument means “skip this task if this path doesn’t exist.”
Pattern 2: Check Mode with changed_when#
For commands where you need to determine if something actually changed:
1
2
3
4
5
6
7
8
9
| - name: Check if user exists
command: id appuser
register: user_check
ignore_errors: true
changed_when: false # This is just a check
- name: Create application user
command: useradd -r -s /bin/false appuser
when: user_check.rc != 0
|
Or combine check and action:
1
2
3
4
5
| - name: Add SSH key to authorized_keys
shell: |
grep -q "{{ ssh_public_key }}" ~/.ssh/authorized_keys 2>/dev/null || echo "{{ ssh_public_key }}" >> ~/.ssh/authorized_keys
register: result
changed_when: "'{{ ssh_public_key }}' not in result.stdout"
|
Pattern 3: Prefer Native Modules#
Ansible’s built-in modules handle idempotency for you:
1
2
3
4
5
6
7
8
9
| # ❌ BAD: Shell command, not idempotent
- name: Install nginx
shell: apt-get install -y nginx
# ✅ GOOD: Native module, inherently idempotent
- name: Install nginx
apt:
name: nginx
state: present
|
Native modules for common operations:
| Operation | Avoid | Use Instead |
|---|
| Package install | shell: apt-get install | apt, yum, dnf |
| User creation | command: useradd | user |
| File copy | shell: cp | copy, template |
| Service control | shell: systemctl | systemd, service |
| Cron jobs | shell: crontab | cron |
Pattern 4: Stateful Checks Before Actions#
For complex operations, check state first:
1
2
3
4
5
6
7
8
| - name: Get current Docker networks
command: docker network ls --format '{{ '{{' }}.Name{{ '}}' }}'
register: docker_networks
changed_when: false
- name: Create application network
command: docker network create --driver bridge app_network
when: "'app_network' not in docker_networks.stdout_lines"
|
For database operations:
1
2
3
4
5
6
7
8
9
10
11
| - name: Check if database exists
postgresql_query:
db: postgres
query: SELECT 1 FROM pg_database WHERE datname = 'myapp'
register: db_check
- name: Create database
postgresql_db:
name: myapp
state: present
when: db_check.rowcount == 0
|
Pattern 5: Handlers for Service Restarts#
Don’t restart services unconditionally—use handlers:
1
2
3
4
5
6
7
8
9
10
11
12
| tasks:
- name: Update nginx configuration
template:
src: nginx.conf.j2
dest: /etc/nginx/nginx.conf
notify: Restart nginx # Only triggers if file changed
handlers:
- name: Restart nginx
systemd:
name: nginx
state: restarted
|
Handlers only run when notified, and only run once even if notified multiple times.
Pattern 6: Atomic File Operations#
For files that need to be updated atomically:
1
2
3
4
5
6
| - name: Update application config
template:
src: config.yml.j2
dest: /etc/app/config.yml
backup: yes
validate: /usr/bin/app --validate-config %s
|
The validate parameter runs before the file is moved into place. If validation fails, the original file remains untouched.
Pattern 7: Looping with Proper State Checks#
When looping, ensure each iteration is idempotent:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
| - name: Create project directories
file:
path: "/var/www/{{ item }}"
state: directory
owner: www-data
mode: '0755'
loop:
- app
- app/static
- app/uploads
- logs
- name: Ensure required packages
apt:
name: "{{ item }}"
state: present
loop:
- python3
- python3-pip
- python3-venv
|
Pattern 8: Using Block for Conditional Groups#
Group related tasks that should run together:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
| - name: Setup SSL certificates
block:
- name: Generate private key
openssl_privatekey:
path: /etc/ssl/private/app.key
size: 4096
- name: Generate CSR
openssl_csr:
path: /etc/ssl/certs/app.csr
privatekey_path: /etc/ssl/private/app.key
common_name: app.example.com
- name: Generate self-signed certificate
openssl_certificate:
path: /etc/ssl/certs/app.crt
privatekey_path: /etc/ssl/private/app.key
csr_path: /etc/ssl/certs/app.csr
provider: selfsigned
when: use_ssl | bool
|
Testing Idempotency#
Always run your playbook twice:
1
2
3
4
5
| # First run - should make changes
ansible-playbook site.yml
# Second run - should report zero changes
ansible-playbook site.yml
|
If the second run shows changes, investigate. Either:
- A task isn’t properly idempotent
- Something external is modifying state between runs
- You’re using
changed_when incorrectly
The check_mode Trap#
Check mode (--check) doesn’t guarantee idempotency:
1
2
3
4
5
6
7
8
9
10
11
| # This looks fine in check mode but breaks on real runs
- name: Get latest release
uri:
url: https://api.github.com/repos/owner/repo/releases/latest
register: release
check_mode: false # Always runs, even in check mode
- name: Download release
get_url:
url: "{{ release.json.assets[0].browser_download_url }}"
dest: /opt/app/release.tar.gz
|
The version could change between check mode and real execution. Design for deterministic state, not latest-whatever.
Key Takeaways#
- Prefer native modules — they handle idempotency for you
- Use
creates/removes — for commands that create or delete files - Check state before acting — don’t assume, verify
- Use handlers for restarts — services shouldn’t bounce unnecessarily
- Test by running twice — the second run should show zero changes
- Be explicit about
changed_when — tell Ansible what “changed” means
Idempotent playbooks are playbooks you can trust. They’re the difference between “run this carefully” and “run this whenever.”
Infrastructure code should be boring. If running your playbook twice is exciting, something’s wrong.