Beginner
Why Effective Ansible Playbooks Matter in Real DevOps Work
Here’s something I’ve seen repeatedly over the past decade: teams adopt Ansible, write a few playbooks that “work,” and then six months later those playbooks are an unmaintainable mess. They break in unexpected ways. Running them twice produces different results. Nobody wants to touch them.
The difference between a playbook that merely works and one that works reliably comes down to three core concepts: idempotency, handlers, and role-based organization. These aren’t advanced topics — they’re foundational. Once you understand them, every playbook you write will be more predictable, easier to debug, and simpler for your teammates to understand.
In this article, we’ll build real, working examples from scratch. By the end, you’ll have a well-structured Ansible role that you can use as a template for your own projects.
Prerequisites
- A control node with Ansible installed (we’ll use Ansible core 2.15+)
- At least one target host you can SSH into (a local VM or cloud instance works great)
- Basic familiarity with YAML syntax
- Basic Linux command-line skills
Verify your Ansible installation:
ansible --version
You should see output similar to:
ansible [core 2.15.x]
config file = /etc/ansible/ansible.cfg
configured module search path = ['/home/user/.ansible/plugins/modules']
python version = 3.11.x
Part 1: Idempotency — The Most Important Concept in Ansible
What Is Idempotency?
Idempotency means that running an operation once produces the same result as running it multiple times. In Ansible terms: if your playbook installs Nginx, running it once installs Nginx. Running it five more times changes nothing — it simply confirms Nginx is already installed and moves on.
This matters enormously in real operations. You will re-run playbooks. Deployments will fail halfway through and need to be retried. Scheduled runs will apply configuration continuously. If your playbooks aren’t idempotent, re-runs can break things — duplicate config lines, restarted services that shouldn’t restart, or data corruption.
An Idempotent Playbook Example
Let’s start with a simple playbook that sets up an Nginx web server. Create a file called webserver.yml:
---
- name: Configure web server
hosts: webservers
become: true
tasks:
- name: Install Nginx
ansible.builtin.apt:
name: nginx
state: present
update_cache: true
when: ansible_os_family == "Debian"
- name: Ensure Nginx is running and enabled
ansible.builtin.service:
name: nginx
state: started
enabled: true
- name: Deploy index page
ansible.builtin.copy:
src: files/index.html
dest: /var/www/html/index.html
owner: www-data
group: www-data
mode: "0644"
Every task here is idempotent by design:
state: presentmeans “make sure it’s installed” — not “install it again”state: startedmeans “make sure it’s running” — not “start it again”ansible.builtin.copychecks the file’s checksum before copying — if the file hasn’t changed, it does nothing
Run it with:
ansible-playbook -i inventory.ini webserver.yml
On the first run, you’ll see changed status on tasks. On the second run, you’ll see ok — meaning Ansible checked the state and found everything was already correct.
PLAY RECAP *********************************************************************
web01 : ok=3 changed=0 unreachable=0 failed=0 skipped=0
That changed=0 on the second run is the hallmark of an idempotent playbook.
The Non-Idempotent Trap: Avoid command and shell When Possible
Here’s a common beginner mistake. Instead of using the ansible.builtin.apt module, someone writes:
# BAD — Not idempotent
- name: Install Nginx
ansible.builtin.command: apt-get install -y nginx
This runs the command every single time, even if Nginx is already installed. Ansible marks it as “changed” on every run because it has no way to know if the command actually changed anything. This defeats the entire purpose of configuration management.
If you absolutely must use command or shell, use creates or removes parameters to give Ansible a way to check state:
# Better — but still prefer native modules when available
- name: Download application binary
ansible.builtin.command:
cmd: curl -o /opt/myapp/myapp https://example.com/myapp-v2.1
creates: /opt/myapp/myapp
The creates parameter tells Ansible: “Skip this task if /opt/myapp/myapp already exists.” It’s not perfect — it doesn’t verify the file’s content — but it’s vastly better than running blindly every time.
Rule of thumb: Always look for a built-in Ansible module before reaching for command or shell. The modules are designed to be idempotent.
Part 2: Handlers — Reacting to Changes Efficiently
What Are Handlers?
Handlers are special tasks that only run when notified by another task — and only if that task actually made a change. They run once at the end of the play, regardless of how many tasks notify them.
The classic use case: you update a configuration file, and Nginx needs to be reloaded. But you only want to reload Nginx if the configuration actually changed — not on every playbook run.
Handlers in Practice
Let’s extend our webserver playbook:
---
- name: Configure web server
hosts: webservers
become: true
tasks:
- name: Install Nginx
ansible.builtin.apt:
name: nginx
state: present
update_cache: true
when: ansible_os_family == "Debian"
- name: Deploy Nginx configuration
ansible.builtin.template:
src: templates/nginx.conf.j2
dest: /etc/nginx/sites-available/default
owner: root
group: root
mode: "0644"
validate: nginx -t -c %s
notify: Reload Nginx
- name: Deploy index page
ansible.builtin.copy:
src: files/index.html
dest: /var/www/html/index.html
owner: www-data
group: www-data
mode: "0644"
- name: Ensure Nginx is running and enabled
ansible.builtin.service:
name: nginx
state: started
enabled: true
handlers:
- name: Reload Nginx
ansible.builtin.service:
name: nginx
state: reloaded
Let’s break down what happens:
- The “Deploy Nginx configuration” task uses
ansible.builtin.templateto render a Jinja2 template - The
validateparameter runsnginx -tto syntax-check the config before putting it in place — this is a fantastic safety net - If (and only if) the template produces a different file than what’s already on disk, the task reports “changed” and sends a notification to the “Reload Nginx” handler
- The handler runs once at the end of all tasks
Here’s a simple templates/nginx.conf.j2 to go with this:
server {
listen {{ nginx_listen_port | default(80) }};
server_name {{ ansible_fqdn }};
root /var/www/html;
index index.html;
location / {
try_files $uri $uri/ =404;
}
}
Common Handler Mistakes
Mistake 1: Handler name mismatch. The notify value must exactly match the handler’s name. This is case-sensitive. If they don’t match, the handler silently never runs — no error, just nothing happens. This one bites everyone at least once.
Mistake 2: Expecting handlers to run immediately. Handlers run at the end of the play by default. If a later task depends on Nginx being reloaded, you need to flush handlers explicitly:
- name: Deploy Nginx configuration
ansible.builtin.template:
src: templates/nginx.conf.j2
dest: /etc/nginx/sites-available/default
notify: Reload Nginx
- name: Force handler execution now
ansible.builtin.meta: flush_handlers
- name: Verify Nginx is responding
ansible.builtin.uri:
url: "http://localhost/"
status_code: 200
Mistake 3: Using a regular task instead of a handler for service restarts. If you put a service restart as a normal task, it runs every time, even when nothing changed. Unnecessary restarts cause downtime — however brief.
Part 3: Role-Based Organization — Scaling Beyond a Single Playbook
Why Roles?
A single playbook works fine for simple tasks. But in production, you’ll have dozens of services across multiple environments. Roles give you a standard directory structure that makes your automation modular, reusable, and testable.
Think of a role as a self-contained package: it contains everything needed to configure one concern (like “nginx” or “postgresql” or “monitoring-agent”).
Creating a Role with ansible-galaxy init
ansible-galaxy role init --init-path roles nginx
This creates the following structure:
roles/nginx/
├── defaults/
│ └── main.yml # Default variables (lowest precedence)
├── files/ # Static files to copy
├── handlers/
│ └── main.yml # Handlers
├── meta/
│ └── main.yml # Role metadata and dependencies
├── tasks/
│ └── main.yml # Main task list
├── templates/ # Jinja2 templates
├── tests/
│ └── test.yml # Test playbook
└── vars/
└── main.yml # Role variables (higher precedence)
You don’t need to use every directory. If you don’t need vars/ or meta/, just leave them empty or delete them. Ansible won’t complain.
Building a Complete Nginx Role
Let’s move our earlier work into a proper role structure.
roles/nginx/defaults/main.yml — Default variables that users of this role can override:
---
nginx_listen_port: 80
nginx_server_name: "{{ ansible_fqdn }}"
nginx_root: /var/www/html
nginx_index: index.html
roles/nginx/tasks/main.yml — The main task list:
---
- name: Install Nginx
ansible.builtin.apt:
name: nginx
state: present
update_cache: true
cache_valid_time: 3600
when: ansible_os_family == "Debian"
- name: Deploy Nginx site configuration
ansible.builtin.template:
src: default.conf.j2
dest: /etc/nginx/sites-available/default
owner: root
group: root
mode: "0644"
notify: Reload Nginx
- name: Deploy index page
ansible.builtin.copy:
src: index.html
dest: "{{ nginx_root }}/index.html"
owner: www-data
group: www-data
mode: "0644"
- name: Ensure Nginx is running and enabled
ansible.builtin.service:
name: nginx
state: started
enabled: true
Notice cache_valid_time: 3600 on the apt task. This tells Ansible to skip the apt update if the cache is less than 3600 seconds old. This speeds up repeated runs significantly — a small detail that adds up in larger playbooks.
roles/nginx/handlers/main.yml:
---
- name: Reload Nginx
ansible.builtin.service:
name: nginx
state: reloaded
- name: Restart Nginx
ansible.builtin.service:
name: nginx
state: restarted
I’ve included both a reload and restart handler. Reload is preferred because it’s graceful — existing connections aren’t dropped. But sometimes a full restart is needed (e.g., when changing listen addresses). Having both available gives flexibility.
roles/nginx/templates/default.conf.j2:
server {
listen {{ nginx_listen_port }};
server_name {{ nginx_server_name }};
root {{ nginx_root }};
index {{ nginx_index }};
location / {
try_files $uri $uri/ =404;
}
}
roles/nginx/files/index.html:
<!DOCTYPE html>
<html>
<head><title>Managed by Ansible</title></head>
<body><h1>This server is managed by Ansible.</h1></body>
</html>
Using the Role in a Playbook
Now your main playbook becomes beautifully simple. Create site.yml