SSD Nodes Learn Hosting plans →
Guides Matt ConnorBy Matt Connor

Ansible templates and handlers, by example

Render an nginx config from a Jinja2 template, notify a handler so it reloads only on a real change, then run the play twice to prove idempotence.

Verified Every command ran end-to-end on a fresh Ubuntu 24.04 server, August 20, 2026.

What Ansible templates and handlers add to your first playbook

Ansible templates and handlers are the two pieces that turn a static playbook into a useful one. A template renders a config file from your variables, so one file covers every host. A handler runs only when a task actually changed something, so the service reloads on a real config change and is left alone the rest of the time.

This guide picks up exactly where your first Ansible playbook on a VPS stops. You already have a play that installs a package and starts a service. Everything below runs on one machine, because the play targets localhost over a local connection. You do not need a second server to follow it. The same play runs against real inventory hosts with no change to the tasks, and the last section covers what does change.

Set up the working directory

sudo apt update
sudo apt install -y ansible nginx
ansible --version
mkdir -p ~/ansible-templates/templates
cd ~/ansible-templates

nginx is here only because it is a real service with a config file and a reload command, which is everything the example needs. ansible --version prints the ansible-core version and the Python interpreter it will use. Note both. The playbook below uses fully qualified module names such as ansible.builtin.template, which need Ansible 2.10 or newer, and any current distribution package is well past that.

Create inventory.ini:

[local]
localhost ansible_connection=local ansible_python_interpreter="{{ ansible_playbook_python }}"

ansible_connection=local tells Ansible to run each task as a local process instead of opening an SSH session to itself. The second setting is not decoration. When you write localhost into an inventory file it becomes an ordinary host, and it loses the interpreter that Ansible hands to the implicit localhost for free, so it falls back to interpreter discovery and can pick a different Python than the one running the play. ansible_playbook_python is the interpreter running ansible-playbook right now, which keeps the two in step.

Create ansible.cfg:

[defaults]
inventory = inventory.ini

Without that file you pass -i inventory.ini on every command. With no inventory at all, Ansible prints [WARNING]: provided hosts list is empty, only localhost is available. Note that the implicit localhost does not match 'all', and a play with hosts: all then matches nothing. One more thing about ansible.cfg: Ansible ignores it when it sits in a world-writable directory, so keep the project under your home directory. An inventory file holds more than a list of hosts, and this is the smallest one that does the job.

template versus copy, and when each one is right

ansible.builtin.copy transfers a file as it is. ansible.builtin.template runs the file through Jinja2 first and transfers the result. The module source describes template as "a virtual module that is entirely implemented as an action plugin and runs on the controller", and that has a consequence worth remembering: rendering happens on the machine where you typed ansible-playbook. The target host never sees your variables and never needs Jinja2 installed.

Use copy when the file is identical on every host. Use template as soon as one value differs per host, or you need a {% for %} loop or an {% if %} block. copy does have a content: parameter, and variables inside it are substituted like any other task argument, but there are no loops and no conditionals there, so anything with structure belongs in a template. Both modules take the same file options, because both pull in the same documentation fragments, so owner, group, mode, backup and validate behave the same way in each.

Write the template: one variable, one loop

Save this as templates/app.conf.j2:

# {{ ansible_managed }}
upstream {{ app_name }}_backend {
{% for backend in app_backends %}
    server {{ backend.host }}:{{ backend.port }} weight={{ backend.weight }};
{% endfor %}
}

server {
    listen {{ app_listen_port }};
    server_name {{ app_server_name }};

    location / {
        proxy_pass http://{{ app_name }}_backend;
        proxy_set_header Host $host;
    }
}

Two kinds of Jinja2 tag do the work here. {{ ... }} is an expression and prints its value. {% ... %} is a statement and prints nothing of its own. app_backends is a list of dictionaries, so backend.host reads one key out of each entry, and the loop writes one server line per entry however many you define.

One detail about the whitespace, because it surprises people who know Jinja2 from elsewhere. Ansible sets trim_blocks to yes by default, which Jinja2 itself does not, so the newline immediately after a {% ... %} tag is removed and the loop does not leave a blank line behind it. Ansible leaves lstrip_blocks at no, so any spaces you put in front of a {% tag are kept and appear in the rendered file. If your output comes out with stray indentation, set lstrip_blocks: true on the template task.

{{ ansible_managed }} renders as the literal text Ansible managed by default. Leave it that way. People often redefine ansible_managed in ansible.cfg to include a date, and the moment they do, the rendered file differs on every run, the task reports a change on every run, and the service reloads on every run. That one setting destroys the property the rest of this guide is about. The .j2 extension is a convention, and Ansible does not check it.

The playbook

Save this as site.yml:

- name: Render an nginx site from a template
  hosts: local
  become: true

  vars:
    app_name: learn
    app_listen_port: 8080
    app_server_name: learn.example.com
    app_backends:
      - host: 127.0.0.1
        port: 9001
        weight: 3
      - host: 127.0.0.1
        port: 9002
        weight: 1

  tasks:
    - name: Install nginx
      ansible.builtin.apt:
        name: nginx
        state: present
        update_cache: true
        cache_valid_time: 3600

    - name: Render the site configuration
      ansible.builtin.template:
        src: templates/app.conf.j2
        dest: "/etc/nginx/conf.d/{{ app_name }}.conf"
        owner: root
        group: root
        mode: '0644'
        backup: true
      notify: nginx config changed

    - name: Make sure nginx is enabled and running
      ansible.builtin.service:
        name: nginx
        state: started
        enabled: true

  handlers:
    - name: Test the nginx configuration
      ansible.builtin.command:
        cmd: /usr/sbin/nginx -t
      changed_when: false
      listen: nginx config changed

    - name: Reload nginx
      ansible.builtin.service:
        name: nginx
        state: reloaded
      listen: nginx config changed

mode: '0644' is quoted deliberately. The file options documentation says to quote octal numbers "so Ansible receives a string and can do its own conversion from string into number". Unquoted, the YAML parser reads 0644 as a plain number and you can end up with permissions you did not ask for.

notify: nginx config changed names a topic, not a handler. Both handlers carry listen: nginx config changed, so one notify reaches both of them. Add a third handler with the same listen line later and the template task needs no edit. cache_valid_time: 3600 stops a second run inside the hour from going out to the package mirrors again.

Run it once, then read what it printed

ansible-playbook site.yml

If your sudo asks for a password, add -K and Ansible will prompt for it.

Read the per-task lines first, then the PLAY RECAP at the bottom. Each task prints changed: when Ansible had to do something, or ok: when the host was already in the wanted state, and the recap totals those counters per host. After every task in the play has finished, and not one moment earlier, you get RUNNING HANDLER [Test the nginx configuration] followed by RUNNING HANDLER [Reload nginx].

Now check the machine itself rather than trusting the output:

sudo cat /etc/nginx/conf.d/learn.conf
sudo /usr/sbin/nginx -t
curl -sI http://127.0.0.1:8080/

nginx -t prints nginx: configuration file /etc/nginx/nginx.conf test is successful when the assembled configuration parses. The curl returns a status line from nginx, and 502 Bad Gateway is the correct answer here, because the server block is live and nothing is listening on ports 9001 or 9002. sudo tail /var/log/nginx/error.log states the reason in plain words: connect() failed (111: Connection refused) while connecting to upstream.

Run it a second time to prove idempotence

ansible-playbook site.yml

This is the run that matters, so compare its output against the first one line by line. The template task should now print ok: where it printed changed:, and neither handler should appear anywhere in the output.

The mechanism is simple and worth knowing, because it is what you debug against. template renders the file on the controller and compares the checksum of the result with the checksum of the file already at dest. Matching content, ownership and mode means there is nothing to do, so the task reports ok, so notify never fires, so the handler never runs. Handlers fire on changed and on nothing else.

Prove the other direction too. Change weight: 3 to weight: 1 in vars, run the play again, and the template task reports changed, both handlers run, and sudo cat /etc/nginx/conf.d/learn.conf shows the new value.

If a second identical run still reports a change, the render is not stable. Look for something time-based in the output first, because that is the common cause and a customised ansible_managed is the usual culprit. After that, check that mode and owner on the task match what is actually on disk, because a mismatch there is a change even when the bytes are identical.

See a change before you make it

ansible-playbook site.yml --check --diff

--check runs the play without changing the host. --diff prints what each task would have altered, which for template is a line-by-line difference between the render and the file on disk. Together they answer the question "what would this run do" without doing it. Check mode has sharp edges of its own, mostly on tasks whose result depends on an earlier task that check mode did not actually perform.

Why handlers wait until the end of the play

The handlers documentation is direct about it: "By default, handlers run after all the tasks in a particular play have been completed. Notified handlers are executed automatically after each of the following sections, in the following order: pre_tasks, roles/tasks and post_tasks."

The reason is batching. A play that renders four config files for one service should restart that service once, at the end, with all four files in place. Restarting after each file would restart it four times, and three of those restarts would load a half-finished configuration. The same page states the guarantee plainly: "Notifying the same handler multiple times will result in executing the handler only once regardless of how many tasks notify it."

Ordering is fixed as well: "Handlers are executed in the order they are defined in the handlers section, not in the order listed in the notify statement." That is why Test the nginx configuration sits above Reload nginx in the playbook. The test runs first because it is written first, and nothing in the notify line affects that.

How to run handlers early, and how to run them after a failure

Sometimes a later task in the same play needs the service already running the new configuration. Flush the notified handlers at that point with the meta module, which the docs describe as making "Ansible run any handler tasks which have thus far been notified".

    - name: Run the notified handlers now instead of at the end of the play
      ansible.builtin.meta: flush_handlers

    - name: Wait for the new listener to accept connections
      ansible.builtin.wait_for:
        host: 127.0.0.1
        port: 8080
        timeout: 10

Drop that meta line and the wait_for task runs while nginx is still serving the old configuration. On a first run there is no listener on port 8080 at all yet, so the task waits the full ten seconds and then fails.

The second case is failure. "If a task notifies a handler but another task fails later in the play, by default the handler does not run on that host, which may leave the host in an unexpected state." A play that renders a config and then trips over an unrelated task therefore leaves the new file on disk with the old configuration still loaded in the running service. Override that with --force-handlers on the command line, or with force_handlers: true in the play. The same switch exists as force_handlers = True under [defaults] in ansible.cfg, and as the environment variable ANSIBLE_FORCE_HANDLERS. The default is False.

Handler names collide, and the loser is silent

The documentation states the rule: "Each handler should have a globally unique name. If multiple handlers are defined with the same name, only the last one loaded into the play can be notified and executed." Handlers defined inside a role are not scoped to that role either. They are inserted into one global handler list for the whole play, so two roles that each define Restart nginx leave you with a name that resolves to exactly one of them, and load order decides which one, not the role you notified from.

Test that rule before you rely on it. Save this as handlers-dup.yml:

- name: Two handlers, one name
  hosts: local
  gather_facts: false

  tasks:
    - name: Notify the duplicated name
      ansible.builtin.command:
        cmd: /bin/true
      changed_when: true
      notify: Duplicated handler

  handlers:
    - name: Duplicated handler
      ansible.builtin.file:
        path: /tmp/dup-first
        state: touch
        mode: '0644'

    - name: Duplicated handler
      ansible.builtin.file:
        path: /tmp/dup-second
        state: touch
        mode: '0644'
rm -f /tmp/dup-first /tmp/dup-second
ansible-playbook handlers-dup.yml
ls -l /tmp/dup-first /tmp/dup-second

The play succeeds, RUNNING HANDLER [Duplicated handler] appears once, and ls prints a line for /tmp/dup-first and ls: cannot access '/tmp/dup-second': No such file or directory for the other one. The handler that ran is the one written first, not the last one loaded, which is the opposite of what that sentence predicts.

The difference is worth understanding, because the documented rule is about handler blocks rather than about lines in a file. Handlers that arrive from separate places, one role and then another, are separate blocks, and a later block does shadow an earlier one. A plain handlers: list in a play is a single block, and the search inside a block runs top to bottom and stops at the first name that matches. So inside one file the first definition answers and the second is unreachable, while between roles the shadowing runs the way the documentation describes. Either way you can never reach both, and neither direction is something to build on.

There are two clean ways out. Give every handler name a prefix specific to its role, or notify the qualified form role_name : handler_name, which the documentation gives as the way "to ensure that a handler from a role is notified as opposed to one from outside the role with the same name". The spaces around the colon are part of that syntax. This becomes a live problem the moment you start pulling in roles you did not write.

One more rule from the same page: "Avoid placing variables in the name of the handler. Since handler names are templated early on, Ansible may not have a value available for a handler name like this." A handler called Restart {{ service_name }} fails the whole play when that variable is not defined at the moment the name is templated. Keeping handler names as fixed strings and grouping them with listen avoids the question.

validate: refuse to install a broken render

validate runs a command against the rendered file before Ansible moves it into place. The documentation: "The validation command to run before copying the updated file into the final destination. A temporary file path is used to validate, passed in through %s which must be present as in the examples below. Also, the command is passed securely so shell features such as expansion and pipes will not work."

Two rules come straight out of that text. The %s is mandatory, and a validate string without it fails the task with validate must contain %s. And there is no shell, so pipes, redirection, globbing and && do not work. One command, one file argument.

The official module examples are the two cases where this works perfectly:

- name: Copy a new sudoers file into place, after passing validation with visudo
  ansible.builtin.template:
    src: /mine/sudoers
    dest: /etc/sudoers
    validate: /usr/sbin/visudo -cf %s

- name: Update sshd configuration safely, avoid locking yourself out
  ansible.builtin.template:
    src: etc/ssh/sshd_config.j2
    dest: /etc/ssh/sshd_config
    owner: root
    group: root
    mode: '0600'
    validate: /usr/sbin/sshd -t -f %s
    backup: yes

Both work because each checker takes one file and judges it on its own terms. visudo -cf reads a sudoers file. sshd -t -f reads a complete sshd_config.

Why validate cannot check the nginx file in this guide

Add validate: /usr/sbin/nginx -t -c %s to the template task above and the task fails. The message names the cause:

nginx: [emerg] "upstream" directive is not allowed here in <ansible temporary path>:2

nginx -t -c expects a whole configuration that starts at the top level with events and http blocks. The file this play renders is a fragment, pulled into the http block by include /etc/nginx/conf.d/*.conf; inside /etc/nginx/nginx.conf. Taken on its own, out of that context, upstream really is a directive in the wrong place, so nginx rejects a file that is completely correct where it actually lives. The checker was handed a fragment and asked to treat it as a whole configuration.

The workable answer is the one already in the playbook. Install the fragment, then check the assembled configuration in a handler defined above the reload handler. Because handlers run in the order they are defined, nginx -t sees the real /etc/nginx/nginx.conf with your fragment included, and a failure there fails the play before systemctl reload is ever called. Be clear about the cost: the broken file is on disk when that check fails, and nginx keeps serving the last configuration it loaded until someone restarts it.

That is what backup: true earns its place for. It writes a copy of the previous file next to the original before overwriting, named basename.PID.YYYY-MM-DD@HH:MM:SS~, so the directory ends up holding entries like learn.conf.4127.2026-08-20@11:42:09~. Run sudo ls -l /etc/nginx/conf.d/ after a change and you will find one.

That naming detail matters more than it looks. The backup is harmless in /etc/nginx/conf.d/ because the main config includes only conf.d/*.conf and the backup name ends in a tilde. It is not harmless in a directory that is included with a bare *, and on Debian and Ubuntu /etc/nginx/nginx.conf includes /etc/nginx/sites-enabled/* exactly that way. Template into sites-enabled with backup: true and nginx loads the backup as a second live server block, which is why this play writes to conf.d instead.

Running the same play against real inventory hosts

Change hosts: local to the group name you use, and nothing else in the play moves. The template renders once per host, so app_listen_port and app_backends can come from group_vars and host_vars while the template file itself stays single. That is the payoff for putting values in variables rather than in the file.

Two things do change. become: true now needs a sudo password on each target unless you have passwordless sudo there, so add -K. And any secret in that template, a database password or an API token, must not sit in plain vars: in a file you commit. Encrypt those values with Ansible Vault and reference them by name exactly as you do now, because the template does not care where a variable came from.

When the play grows past one service, vars:, templates/ and handlers: all have a standard home already waiting for them. Moving them there is the whole point of the split between a playbook and a role.

FAQ

Why did my Ansible handler not run?

Almost always because the task that notifies it reported ok rather than changed. Handlers fire on change and on nothing else, so a template task whose render matches the file already on disk never notifies anything. After that, check four things. The string in notify must match the handler name or a listen topic exactly, including case and spacing. A later task that failed on that host suppresses notified handlers unless you pass --force-handlers. A handler defined in a different play is not visible from this one. And a notifying task skipped by a when condition never notifies at all.

Why does my playbook report changed on every run?

The rendered text is not stable between runs. The most common cause is a timestamp in the output, and a customised ansible_managed string that includes a date does exactly that. The next thing to check is mode and owner on the task: if they do not match the file already on disk, Ansible corrects them and reports a change even though the content is identical. Run ansible-playbook site.yml --check --diff to see which of the two it is, because --diff shows you the difference the task intends to make.

What is the difference between template and copy in Ansible?

ansible.builtin.copy sends a file unchanged. ansible.builtin.template renders it through Jinja2 on the controller first and then sends the result, so variables and loops are resolved before the file ever reaches the target host. Use copy for a file that is byte identical everywhere. Use template for anything that varies by host. They share the same file options, so mode, owner, backup and validate work the same way in both.

How do I make a handler run in the middle of a play?

Add ansible.builtin.meta: flush_handlers as a task at the point you want them to run. It triggers every handler notified so far, then the play carries on normally. Use it when a later task in the same play depends on the service already running the new configuration, for example a wait_for on a port that only exists after the reload. It is the supported way to run a handler before the end of the play.

Can I use validate with an nginx config fragment?

Not with nginx -t -c %s. That command expects a complete configuration starting with the top level events and http blocks, so it rejects a conf.d fragment with a message like "upstream" directive is not allowed here. The fragment is valid inside the http block and invalid on its own. Install the file, then run nginx -t against the assembled configuration in a handler defined above the reload handler. Handlers run in the order they are defined, so a bad configuration fails the play before the reload is attempted. Set backup: true on the template task so the previous file is still there to put back.

#ansible#jinja2#handlers#idempotence#automation