How to Write Effective Ansible Playbooks: Idempotency, Handlers, and Role-Based Organization Explained with Examples

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: present means “make sure it’s installed” — not “install it again”
  • state: started means “make sure it’s running” — not “start it again”
  • ansible.builtin.copy checks 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.template to render a Jinja2 template
  • The validate parameter runs nginx -t to 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

Leave a Comment

Your email address will not be published. Required fields are marked *