Ansible basics · Lesson 5 of 12 · about 25 minutes

Templates and handlers

Lesson 4 ended with web2's page claiming port 8080 while nginx listened on 80. This lesson builds nginx's config from the variables with a template, so web2 really moves, and reloads nginx with a handler, only when its config changes. Then a config broken on purpose: nginx -t catches it, the reload never happens, and the site keeps serving the whole time.

By the end you'll know

  • What a template is, and how Jinja2 fills it in with {{ }} and {% if %}
  • How the template module writes a file per server, and shows what it would change
  • What a handler is, and why it runs once, at the end, and only after a change
  • How to check a config before anything reloads it
  • What happens to a running service when a broken config is caught, and what's left behind

Before you start

  • You need: Lessons 1 to 4 done, with the three servers running and site.yml applied.
  • Get the new files: in ansible-practice, git pull. You'll have a templates folder with two files, and a rewritten web play at the end of site.yml.
  • Where commands run: your own computer's terminal, in ansible-practice.

A variable nothing used

Lesson 4 gave web2 a home page saying "on port 8080". It still answered on 80, because the only thing that used http_port was the page. nginx has its own config file, and nothing had told it about the variable. Before this lesson, both web servers had nginx's default site enabled, listening on 80:

BASH
ansible web -m command -a "ls /etc/nginx/sites-enabled"
ansible web -m shell -a "ss -ltn | grep -E \":(80|8080) \""
OUTPUT
web1 | CHANGED | rc=0 >>
default
web2 | CHANGED | rc=0 >>
default

web1 | CHANGED | rc=0 >>
LISTEN 0      511          0.0.0.0:80        0.0.0.0:*          
LISTEN 0      511             [::]:80           [::]:*          
web2 | CHANGED | rc=0 >>
LISTEN 0      511          0.0.0.0:80        0.0.0.0:*          
LISTEN 0      511             [::]:80           [::]:*          

ss -ltn lists listening ports, from Linux Lesson 9: both servers on 80, nothing on 8080. The fix needs two things: nginx's config built from the variables, and nginx reloaded when that config changes. A template, and a handler.

Templates

A template is a file with gaps, filled in for each server. Think of a form letter. The text is written once; the name, the address and the date are filled in for each person who gets one. Ansible uses the Jinja2 template language, the same {{ }} you've used since Lesson 2, in a whole file.

templates/bookshop.conf.j2 is nginx's config for the bookshop site (.j2 marks a Jinja2 template):

NGINX
# {{ ansible_managed }}
# The bookshop site on {{ inventory_hostname }}.
server {
    listen {{ http_port }} default_server;
    listen [::]:{{ http_port }} default_server;
    server_name {{ site_name }};

    root /var/www/html;
    index index.html;

    location / {
        try_files $uri $uri/ =404;
    }

    access_log /var/log/nginx/{{ site_name }}.access.log;
}

It's an ordinary nginx server block, with the port, the name and the log file filled in from the variables. {{ ansible_managed }} becomes a comment saying the file is managed by Ansible, so anyone who opens it on the server knows editing it by hand is pointless. ($uri is nginx's own variable, not Jinja's; Jinja only touches {{ }} and {% %}.)

The home page moved into a template too, templates/index.html.j2, with one new trick:

HTML
<h1>{{ site_name }}</h1>
<p>Served by {{ inventory_hostname }} ({{ ansible_facts['default_ipv4']['address'] }}), on port {{ http_port }}.</p>
{% if http_port != 80 %}
<p>This server uses a non-standard port.</p>
{% endif %}
<p>Questions? {{ admin_email }}</p>

{% if %} and {% endif %} include a line only when the condition is true. {{ }} puts a value in; {% %} decides what's in the file at all. Jinja also has loops, {% for %}, for repeating lines; Lesson 6 covers loops in tasks.

The new web play

site.yml's web play now does the whole job:

YAML
- name: Web servers
  hosts: web
  become: true
  tasks:
    - name: Install nginx
      ansible.builtin.apt:
        name: nginx
        state: present

    - name: Remove nginx's default site
      ansible.builtin.file:
        path: /etc/nginx/sites-enabled/default
        state: absent
      notify: Reload nginx

    - name: Write the bookshop site's config
      ansible.builtin.template:
        src: templates/bookshop.conf.j2
        dest: /etc/nginx/sites-available/bookshop
        owner: root
        group: root
        mode: "0644"
      notify: Reload nginx

    - name: Enable the bookshop site
      ansible.builtin.file:
        src: /etc/nginx/sites-available/bookshop
        dest: /etc/nginx/sites-enabled/bookshop
        state: link
      notify: Reload nginx

    - name: Check nginx's config before anything reloads it
      ansible.builtin.command: nginx -t
      changed_when: false

    - name: Keep nginx running, and start it at boot
      ansible.builtin.service:
        name: nginx
        state: started
        enabled: true

    - name: Put up the bookshop's home page
      ansible.builtin.template:
        src: templates/index.html.j2
        dest: /var/www/html/index.html
        owner: root
        group: root
        mode: "0644"

  handlers:
    - name: Reload nginx
      ansible.builtin.service:
        name: nginx
        state: reloaded
  • ansible.builtin.template is copy for templates: src is the template on your computer, dest is where the filled-in file goes on each server. Each server gets its own version.
  • The default site goes, and the bookshop site is enabled with a link from sites-available to sites-enabled, Debian's usual way of switching nginx sites on and off.
  • nginx -t tests nginx's whole config, without changing anything. It's here so that a bad config stops the run before anything reloads nginx. More on that below.
  • notify: Reload nginx on three tasks, and a handlers: section at the end.

Handlers

A handler is a task that only runs when another task notifies it, and only if that task changed something. Think of a sign on a door: "if you change the thermostat, tell reception." Nobody calls reception for walking past it. And if three people change the thermostat in one morning, reception adjusts the heating once, after the last one.

That's exactly how handlers behave:

  • Only after a change. If the config task reports ok, nothing is notified, and nginx isn't touched.
  • Once. Three tasks can notify Reload nginx in one run; it runs one time.
  • At the end of the play, after all the tasks, so nginx reloads with the finished config, not half of it.

This is the fix for Lesson 3's Spot the problem, the playbook that restarted nginx on every run. reloaded, rather than restarted, tells nginx to reread its config without dropping the connections it has open: visitors don't notice.

The dry run, again

BASH
ansible-playbook site.yml --check --diff --limit web
OUTPUT
TASK [Remove nginx's default site] *********************************************
--- before
+++ after
@@ -1,4 +1,4 @@
 {
     "path": "/etc/nginx/sites-enabled/default",
-    "state": "link"
+    "state": "absent"
 }

changed: [web1]
...

TASK [Write the bookshop site's config] ****************************************
--- before
+++ after: /Users/you/.ansible/tmp/ansible-local-40675396zywol/tmpte0ae4cz/bookshop.conf.j2
@@ -0,0 +1,16 @@
+# Ansible managed
+# The bookshop site on web1.
+server {
+    listen 80 default_server;
+    listen [::]:80 default_server;
+    server_name bookshop;
...
changed: [web1]
--- before
+++ after: /Users/you/.ansible/tmp/ansible-local-40675396zywol/tmpsb57lgh5/bookshop.conf.j2
@@ -0,0 +1,16 @@
+# Ansible managed
+# The bookshop site on web2.
+server {
+    listen 8080 default_server;
+    listen [::]:8080 default_server;
+    server_name bookshop;
...
changed: [web2]

TASK [Enable the bookshop site] ************************************************
fatal: [web2]: FAILED! => {"changed": false, "msg": "src file does not exist, use \"force=yes\" if you really want to create the link: /etc/nginx/sites-available/bookshop", "path": "/etc/nginx/sites-enabled/bookshop", "src": "/etc/nginx/sites-available/bookshop"}
fatal: [web1]: FAILED! => {"changed": false, "msg": "src file does not exist, use \"force=yes\" if you really want to create the link: /etc/nginx/sites-available/bookshop", "path": "/etc/nginx/sites-enabled/bookshop", "src": "/etc/nginx/sites-available/bookshop"}

(Shortened; just the web play.) The template's diff is the useful part: each server's own config, filled in. web1 would listen on 80, web2 on 8080, before anything is written. (The after: path is the temporary copy Ansible made on your computer to compare with.) And then the same blind spot as Lesson 3: in a dry run, the config file was never really written, so the link to it can't be made, and the check fails. A failure in --check that depends on an earlier change is expected. Read the diffs; don't trust the dry run past the first change it can't make.

The real run

BASH
ansible-playbook site.yml --limit web
OUTPUT
PLAY [Web servers] *************************************************************
...
TASK [Remove nginx's default site] *********************************************
changed: [web2]
changed: [web1]

TASK [Write the bookshop site's config] ****************************************
changed: [web1]
changed: [web2]

TASK [Enable the bookshop site] ************************************************
changed: [web1]
changed: [web2]

TASK [Check nginx's config before anything reloads it] *************************
ok: [web1]
ok: [web2]

TASK [Keep nginx running, and start it at boot] ********************************
ok: [web2]
ok: [web1]

TASK [Put up the bookshop's home page] *****************************************
ok: [web1]
changed: [web2]

RUNNING HANDLER [Reload nginx] *************************************************
changed: [web2]
changed: [web1]

PLAY RECAP *********************************************************************
web1                       : ok=14   changed=4    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0   
web2                       : ok=14   changed=5    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0   

Three tasks notified Reload nginx, and it ran once per server, at the very end: RUNNING HANDLER. nginx -t passed first, so the reload was safe. And the home page: ok on web1, because the template produced exactly what Lesson 4's copy had put there, but changed on web2, which gets the new "non-standard port" line.

Here's what web2 got:

OUTPUT
web2 | CHANGED | rc=0 >>
# Ansible managed
# The bookshop site on web2.
server {
    listen 8080 default_server;
    listen [::]:8080 default_server;
    server_name bookshop;

    root /var/www/html;
    index index.html;

    location / {
        try_files $uri $uri/ =404;
    }

    access_log /var/log/nginx/bookshop.access.log;
}

And the ports:

OUTPUT
web2 | CHANGED | rc=0 >>
LISTEN 0      511          0.0.0.0:8080      0.0.0.0:*          
LISTEN 0      511             [::]:8080         [::]:*          
web1 | CHANGED | rc=0 >>
LISTEN 0      511          0.0.0.0:80        0.0.0.0:*          
LISTEN 0      511             [::]:80           [::]:*          

web2 is on 8080 now. Ask each for its page:

BASH
ansible web1 -m command -a "curl -s http://localhost"
ansible web2 -m command -a "curl -s http://localhost:8080"
ansible web2 -m command -a "curl -s -o /dev/null -w %{http_code} http://localhost:80"
OUTPUT
web1 | CHANGED | rc=0 >>
<h1>bookshop</h1>
<p>Served by web1 (192.168.34.133), on port 80.</p>
<p>Questions? ops@bookshop.example</p>

web2 | CHANGED | rc=0 >>
<h1>bookshop</h1>
<p>Served by web2 (192.168.34.134), on port 8080.</p>
<p>This server uses a non-standard port.</p>
<p>Questions? ops@bookshop.example</p>

web2 | FAILED | rc=7 >>
000non-zero return code

Each page true to its server: web2 mentions its odd port, web1 doesn't, from one template. And web2 no longer answers on 80. The variable, the config and the page finally agree.

Again: nothing changes, nothing reloads

BASH
ansible-playbook site.yml --limit web
OUTPUT
...
TASK [Write the bookshop site's config] ****************************************
ok: [web1]
ok: [web2]
...
TASK [Put up the bookshop's home page] *****************************************
ok: [web1]
ok: [web2]

PLAY RECAP *********************************************************************
web1                       : ok=13   changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0   
web2                       : ok=13   changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0   

No RUNNING HANDLER at all, and ok=13 instead of 14: the handler didn't run, because nothing notified it. nginx wasn't touched. Run this playbook every hour, and nginx only ever reloads when its config really changes.

A change that needs no reload

Change the contact address for one run, with -e from Lesson 4. It only appears on the home page:

BASH
ansible-playbook site.yml --limit web -e admin_email=help@bookshop.example --diff
OUTPUT
TASK [Put up the bookshop's home page] *****************************************
--- before: /var/www/html/index.html
+++ after: /Users/you/.ansible/tmp/ansible-local-41712owzrx6n2/tmpxme49h6q/index.html.j2
@@ -1,3 +1,3 @@
 <h1>bookshop</h1>
 <p>Served by web1 (192.168.34.133), on port 80.</p>
-<p>Questions? ops@bookshop.example</p>
+<p>Questions? help@bookshop.example</p>

changed: [web1]
...

PLAY RECAP *********************************************************************
web1                       : ok=13   changed=1    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0   
web2                       : ok=13   changed=1    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0   

The page changed; the config didn't, so nothing notified the handler, and nginx wasn't reloaded. A new page is served straight away anyway: nginx reads files from /var/www/html fresh on each request. Notify a handler only from the tasks that need it. (The next normal run puts ops@ back: that -e was for one run.)

A broken config, caught

Now break it. A port of 80800, which doesn't exist (ports stop at 65535):

BASH
ansible-playbook site.yml --limit web2 -e http_port=80800
OUTPUT
...
TASK [Write the bookshop site's config] ****************************************
changed: [web2]

TASK [Enable the bookshop site] ************************************************
ok: [web2]

TASK [Check nginx's config before anything reloads it] *************************
fatal: [web2]: FAILED! => {"changed": false, "cmd": ["nginx", "-t"], ..., "rc": 1, ..., "stderr": "2026/11/01 14:51:53 [emerg] 34705#34705: invalid port in \"80800\" of the \"listen\" directive in /etc/nginx/sites-enabled/bookshop:4\nnginx: configuration file /etc/nginx/nginx.conf test failed", ...}

PLAY RECAP *********************************************************************
web2                       : ok=10   changed=1    unreachable=0    failed=1    skipped=0    rescued=0    ignored=0   

nginx -t caught it: "invalid port in "80800" of the "listen" directive", on line 4 of the bookshop config. The task failed, so web2's run stopped there, and the handler never ran: handlers run at the end of a play, and this server never got there. Is the site still up?

BASH
ansible web2 -m command -a "curl -s -o /dev/null -w %{http_code} http://localhost:8080"
ansible web2 -m command -a "systemctl is-active nginx"
OUTPUT
web2 | CHANGED | rc=0 >>
200

web2 | CHANGED | rc=0 >>
active

Still serving, on 8080, the whole time. nginx never reread its config, so it's still running the last good one. Without the nginx -t task, the handler would have tried to reload with a broken config. (nginx would refuse that reload and keep running too, but a restarted handler, or the next reboot, would have taken the site down.)

But the broken file is still on disk:

BASH
ansible web2 -m command -a "grep listen /etc/nginx/sites-available/bookshop"
OUTPUT
web2 | CHANGED | rc=0 >>
    listen 80800 default_server;
    listen [::]:80800 default_server;

The site is up only because nginx hasn't reread its config. If the server rebooted now, nginx wouldn't start. A failed run can leave a server in a half-changed state, so fix it promptly. Here, that's just running the playbook properly:

BASH
ansible-playbook site.yml --limit web2
ansible web2 -m command -a "curl -s -o /dev/null -w %{http_code} http://localhost:8080"
OUTPUT
...
TASK [Write the bookshop site's config] ****************************************
changed: [web2]
...
TASK [Check nginx's config before anything reloads it] *************************
ok: [web2]
...
TASK [Put up the bookshop's home page] *****************************************
changed: [web2]

RUNNING HANDLER [Reload nginx] *************************************************
changed: [web2]

PLAY RECAP *********************************************************************
web2                       : ok=14   changed=3    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0   

web2 | CHANGED | rc=0 >>
200

The config back to 8080, nginx -t passing, the handler reloading, and the page answering. (The home page changed too, putting ops@ back from the earlier one-off.) For a template that's a complete config file, the template module can do better: validate: nginx -t -c %s tests the new file before it's put in place, so a bad one never reaches the disk at all. A single site file like this one can't be tested on its own, which is why this playbook checks the whole config after writing it.

In real systems

  • Most of a real playbook is templates and handlers. nginx, systemd units, cron jobs, app settings: write the file from a template, notify a handler to reload or restart, check before reloading. The shape you've just built is the shape of most server automation.
  • Restart only what has to restart. reloaded for anything that can reread its config, restarted only when it can't. And notify only from the tasks that affect that service, so unrelated changes never bounce it.
  • Kubernetes moved this idea into the cluster. A ConfigMap is a template's output; a Deployment notices the change and rolls new pods, one at a time, with a health check before each one takes traffic, like CivicGrid's /livez. Same idea: change the config, check it, then swap.

Spot the problem

Work out what's wrong before you read the explanation.

The handler that didn't run. A playbook updates nginx's config, which notifies Reload nginx. A later task in the same play, installing an unrelated package, fails on one server because of a network blip. Someone reruns the playbook, it goes through cleanly, and everyone moves on. That server is still serving the old config. Why? On the first run, the config task changed the file and notified the handler, but the play failed before the end, so the handler never ran. On the rerun, the config file was already right, so the config task said ok, notified nothing, and the handler had no reason to run. The new config sat on disk, unread. Two fixes: run with --force-handlers, which runs notified handlers even when a later task fails; or put a meta: flush_handlers task right after the config tasks, so the handler runs there and then, before anything else can fail.

Keep this

ToWrite or run
Put in a value{{ http_port }}
Include a line only sometimes{% if http_port != 80 %} ... {% endif %}
Say a file is managed{{ ansible_managed }}, in a comment
Write a file from a templateansible.builtin.template with src, dest, mode
Test a whole file before placing itvalidate: nginx -t -c %s
Tell a handler something changednotify: Reload nginx, on the task
A handlerhandlers:, then a task with the same name
Reread config, keep connectionsansible.builtin.service with state: reloaded
Check nginx's confignginx -t, with changed_when: false
Run handlers now, mid-play- ansible.builtin.meta: flush_handlers
Run handlers even after a failureansible-playbook site.yml --force-handlers
List listening portsss -ltn

Where you are now

Your web servers' config comes from variables, each server gets its own, nginx reloads only when its config really changes, and a broken config is caught before it can do damage. Lesson 6 makes playbooks smarter: loops, conditions, and handling failures on purpose, rather than just stopping.

Check yourself

  1. Three tasks notify Reload nginx in one run. How many times does it run?

  2. The second run shows no RUNNING HANDLER. Why?

  3. Why is the handler state: reloaded rather than state: restarted?

  4. With http_port=80800, nginx -t failed. Was web2's site still up?

  5. After that failed run, what was left on web2's disk?

  6. In Jinja2, what's the difference between {{ }} and {% %}?

Key terms

  • Template: a file with gaps, filled in for each server.
  • Jinja2: the template language Ansible uses: {{ }} for values, {% %} for logic.
  • ansible_managed: a variable holding a "this file is managed by Ansible" note, for comments.
  • Handler: a task that runs only when notified by a task that changed something; once per play, at the end.
  • notify: how a task tells a handler it changed something.
  • Reload and restart: reread config while running, or stop and start again.
  • validate: a command the template and copy modules run against a new file before putting it in place.

Go deeper