Ansible Templates and Safe Configuration Rollouts

Render per-host configuration from Jinja2 templates, validate it before the service reloads into it, and roll the change across a fleet one host at a time with serial.

Share
Ansible Templates and Safe Configuration Rollouts

The page you deployed in the last article was the same on every host, which is exactly what that job needed. Most real configuration earns its keep by varying: a config file carries the host's own name, an environment-specific port, a tuning value that suits the machine it runs on. Templates give you all of that from a single file in version control.

The second half of this article is where the series starts to feel like production work. Rolling a configuration change across a fleet is a controlled-change problem, and Ansible has purpose-built machinery for both halves of it: catching a broken config before the service reloads into it, and updating one host, proving it healthy, then letting the rest follow.

By the end, you will be able to:

  • Render per-host configuration from a Jinja2 template.
  • Validate configuration before a service reloads into it.
  • Roll a change across hosts one at a time with serial.
  • Use changed_when to stop command tasks claiming changes they did not make.
  • Grow the lab with a second web host and watch a staged rollout for real.

As in the rest of the series, run the ./lab commands as .\lab.ps1 if you are on Windows without WSL. Run this article in systemd mode:

./lab systemd

Nothing else is needed. The playbook here installs nginx itself, so it works whether or not you followed article 5.

Templates: Configuration with Variables Inside

A template is a file with Jinja2 expressions in it. Ansible renders it per host, substituting variables and facts, and ships the result. The lab keeps them next to the playbooks, at playbooks/templates/nginx_default_site.conf.j2:

# {{ ansible_managed }}
server {
    listen 80 default_server;
    server_name {{ inventory_hostname }};
    root {{ web_root }};
    server_tokens off;
    add_header X-Served-By "{{ inventory_hostname }}";

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

Three kinds of content are mixed here, and telling them apart is the skill:

  • {{ inventory_hostname }} and {{ web_root }} are Jinja2. Ansible replaces them at render time, per host. On beacon the server_name line becomes server_name beacon;.
  • $uri is nginx syntax. It means nothing to Jinja2 and passes through untouched, because Jinja2 only reacts to its own delimiters.
  • {{ ansible_managed }} renders to a short banner. On the host the first line reads # Ansible managed, which tells the next person who opens the file not to hand-edit it.

playbooks/templates/health.html.j2 does the same for the page itself, using facts gathered from each host:

<!doctype html>
<title>{{ inventory_hostname }} health</title>
<p>ok</p>
<p>{{ inventory_hostname }} running {{ ansible_facts['distribution'] }} {{ ansible_facts['distribution_version'] }}</p>
<p>
  <small>Built by <a href="https://alishaikh.me/" rel="author">Ali Shaikh</a>.</small>
</p>

The Playbook: Install, Render, Validate, Reload

The lab ships the playbook at playbooks/40_templates_and_handlers.yml:

---
# Article 6: Jinja2 templates, validate-before-reload, serial rollout.
#
# Handlers normally wait until the end of the play. When a later task must
# observe post-reload state (here the URI check), call meta: flush_handlers
# first so validate + reload run before verification.
- name: Roll out templated web configuration safely
  hosts: web
  become: true
  gather_facts: true
  serial: 1

  vars:
    web_root: /var/www/html

  tasks:
    # This play stands on its own. It installs and starts nginx before
    # templating the configuration, so it works against a host that has never
    # run playbook 30. Both tasks are idempotent, so on a host that has run it
    # they report ok and change nothing.
    - name: Install nginx
      ansible.builtin.apt:
        name: nginx
        state: present
        update_cache: true

    - name: Ensure nginx is running
      ansible.builtin.service:
        name: nginx
        state: started

    - name: Render the nginx site configuration
      ansible.builtin.template:
        src: templates/nginx_default_site.conf.j2
        dest: /etc/nginx/sites-available/default
        owner: root
        group: root
        mode: "0644"
      notify:
        - Validate nginx configuration
        - Reload nginx

    - name: Render the health page
      ansible.builtin.template:
        src: templates/health.html.j2
        dest: "{{ web_root }}/health.html"
        owner: root
        group: root
        mode: "0644"

    - name: Apply pending nginx handlers before verification
      ansible.builtin.meta: flush_handlers

    - name: Verify the service still answers
      ansible.builtin.uri:
        url: http://127.0.0.1/health.html
        status_code: 200
      become: false

  handlers:
    - name: Validate nginx configuration
      ansible.builtin.command:
        cmd: nginx -t
      changed_when: false

    - name: Reload nginx
      ansible.builtin.service:
        name: nginx
        state: reloaded

The safety mechanisms, in the order they fire:

Validation before reload. The template task notifies two handlers. Handlers run in the order they are defined in the handlers: section, so nginx -t runs before the reload. If the rendered config is broken, nginx -t exits non-zero, the handler fails, and the reload never happens. The service keeps running on its old, working configuration. This is the same idea as the validate: option we used on sudoers in article 4, expressed as a handler chain because nginx validates whole configurations rather than single files.

changed_when: false. Command tasks cannot know whether they changed anything, so Ansible reports them as changed every time. A validation check changes nothing, ever, and saying so keeps the recap honest. Its sibling failed_when does the same job for failure, which matters for commands whose exit codes do not follow convention.

Flush handlers before verification. Handlers wait until the end of the play, or of the current serial batch, unless you force them earlier. Without meta: flush_handlers, the URI task would run against the nginx process still serving the old config, and validate and reload would fire afterwards. Flushing puts the steps in the order the words imply: render, validate, reload, then prove the host still answers.

serial: 1. Without it, Ansible runs each task across all hosts at once, so every web server takes the new config in the same instant. With serial: 1, the entire play runs to completion on one host before the next one starts, and a failure stops the rollout there. A bad change costs you one host instead of the tier.

Add a Second Web Host

A staged rollout is far easier to see with a second web host in play, so grow the estate:

./lab add-host titan web
./lab systemd

add-host writes the compose and inventory files; the second command starts the new container. Use ./lab systemd while your estate is in systemd mode, because ./lab up would put the existing hosts back into the default mode.

Hosts added this way keep sshd as PID 1 even in systemd mode, so the fleet is now deliberately mixed: beacon on systemd, titan on sshd.

./lab ansible web -m ansible.builtin.setup -a "filter=ansible_service_mgr"
titan | SUCCESS => {
    "ansible_facts": {
        "ansible_service_mgr": "sshd"
    },
    "changed": false
}
beacon | SUCCESS => {
    "ansible_facts": {
        "ansible_service_mgr": "systemd"
    },
    "changed": false
}

One playbook covers both, because the service module resolves the init system per host. A mixed estate is also closer to most real fleets than a uniform one.

titan needs nothing else before the rollout. The playbook installs nginx itself:

TASK [Install nginx] ***********************************************************
changed: [titan]

TASK [Ensure nginx is running] *************************************************
changed: [titan]

TASK [Render the nginx site configuration] *************************************
changed: [titan]

PLAY RECAP *********************************************************************
titan                      : ok=8    changed=5    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0

That is a brand-new web server, from empty container to a templated, validated, serving nginx, in one command.

Roll It Out

One host first, then the fleet. Preview and apply against beacon alone:

./lab play playbooks/40_templates_and_handlers.yml --check --diff --limit beacon
./lab play playbooks/40_templates_and_handlers.yml --limit beacon

The check run shows the rendered result before it lands, hostname and all:

TASK [Render the health page] **************************************************
--- before: /var/www/html/health.html
+++ after: /root/.ansible/tmp/.../health.html.j2
@@ -1,8 +1,7 @@
 <!doctype html>
 <title>beacon health</title>
 <p>ok</p>
+<p>beacon running Ubuntu 24.04</p>

changed: [beacon]

RUNNING HANDLER [Validate nginx configuration] *********************************
skipping: [beacon]

Note the validation handler skipping. Check mode leaves command tasks alone by design, so config validation is one of the answers the real run gives you.

Then apply, and confirm from your machine:

curl -i http://127.0.0.1:8080/health.html
HTTP/1.1 200 OK
Server: nginx
Content-Type: text/html
X-Served-By: beacon

The X-Served-By header came from the template. Server: nginx with no version number is server_tokens off doing its job.

Now the full group:

./lab play playbooks/40_templates_and_handlers.yml
PLAY [Roll out templated web configuration safely] *****************************

TASK [Render the nginx site configuration] *************************************
changed: [beacon]

RUNNING HANDLER [Validate nginx configuration] *********************************
ok: [beacon]

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

TASK [Verify the service still answers] ****************************************
ok: [beacon]

PLAY [Roll out templated web configuration safely] *****************************

TASK [Render the nginx site configuration] *************************************
changed: [titan]

RUNNING HANDLER [Validate nginx configuration] *********************************
ok: [titan]

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

TASK [Verify the service still answers] ****************************************
ok: [titan]

PLAY RECAP *********************************************************************
beacon                     : ok=6    changed=3    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0
titan                      : ok=6    changed=3    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0

Read the shape of that output. The PLAY header appears twice. With serial: 1, Ansible finishes the whole play on beacon, verification included, before titan starts. That repeated header is the rollout being staged, visible in the log.

Run it again and both hosts report changed=0, with no handler section at all.

Prove It Rendered Per Host

beacon is the host that publishes a port to your machine, so ask both hosts directly and see the two renders side by side:

./lab ansible web -m ansible.builtin.command -a "curl -s -i http://127.0.0.1/health.html"
titan | CHANGED | rc=0 >>
X-Served-By: titan
<title>titan health</title>
<p>titan running Ubuntu 24.04</p>

beacon | CHANGED | rc=0 >>
X-Served-By: beacon
<title>beacon health</title>
<p>beacon running Ubuntu 24.04</p>

One template, one playbook, two different files on two different hosts.

Watch the Safety Net Catch a Bad Change

Break the template on purpose. Edit nginx_default_site.conf.j2 and delete the semicolon after default_server, then run against beacon:

./lab play playbooks/40_templates_and_handlers.yml --limit beacon
TASK [Render the nginx site configuration] *************************************
changed: [beacon]

RUNNING HANDLER [Validate nginx configuration] *********************************
[ERROR]: Task failed: Module failed: The command exited with a non-zero return code.
fatal: [beacon]: FAILED! => {"cmd": ["nginx", "-t"], "rc": 1, "stderr":
"nginx: [emerg] invalid parameter \"server_name\" in /etc/nginx/sites-enabled/default:4
nginx: configuration file /etc/nginx/nginx.conf test failed"}

PLAY RECAP *********************************************************************
beacon                     : ok=3    changed=1    unreachable=0    failed=1    skipped=0    rescued=0    ignored=0

The play stopped at validation. The reload never ran, so check the site:

curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8080/health.html
./lab ansible beacon -m ansible.builtin.command -a "systemctl is-active nginx"

Still 200, still active. The broken file is sitting in sites-available and nginx never read it. Note where nginx points the finger: sites-enabled/default, the symlink to the file the template wrote.

Fix the template, run again, and the play goes green. Had this been a fleet run, serial: 1 would have stopped the rollout at this first host and left the rest on the old config.

Rollback is the same move in reverse: revert the template in Git and run the playbook again.

Tuning the Batch Size

serial: 1 is the cautious extreme. The setting also takes counts and percentages, and can step up:

serial:
  - 1
  - "25%"
  - "100%"

One host first, then a quarter of the fleet, then everything, with the rollout halting if any batch fails. Two hosts are enough to show you the mechanism, and this is the exact shape teams use to roll changes across large production fleets.

Common Problems

Template Renders the Wrong Value

Print what Ansible thinks the variable is, for one host:

./lab ansible beacon -m ansible.builtin.debug -a "var=inventory_hostname"
beacon | SUCCESS => {
    "inventory_hostname": "beacon"
}

Most template surprises are variable precedence surprises: the value came from a different group_vars file than you expected.

Jinja2 Syntax Error

The task fails at render time, on the control node, before anything ships. Read the error's line number against the template. Nothing on the host changed.

Handler Order Looks Wrong

Handlers run in the order defined under handlers:, not the order of the notify: list. Keep validation defined above reload. If a later task must observe post-handler state, call meta: flush_handlers before it.

The New Host Will Not Answer

add-host writes files but does not start the container. Run ./lab systemd (or ./lab up if your estate is in the default mode) afterwards, then ./lab ping.

Clean Up

Keep titan if you have enjoyed the bigger estate, and the next article is happy either way. To remove it:

./lab remove-host titan

What You Learnt

  • Templates render per-host configuration from variables and facts, and ansible_managed marks the result as automation-owned.
  • A validate handler defined before a reload handler means a broken config never reaches a running service.
  • meta: flush_handlers moves handlers earlier so a mid-play check tests the reloaded service rather than the old process.
  • changed_when and failed_when make command tasks tell the truth about what they did.
  • serial turns "change everything at once" into a staged rollout that stops on failure, and the repeated PLAY header is how you see it working.
  • Check mode cannot validate a config, because command tasks are skipped in a dry run.
  • A playbook that installs what it needs can onboard a brand-new host in one command.

Next, the final article turns these playbooks into something a team can own: secrets encrypted with Vault, tasks reorganised into roles, and CI that checks the repo on every push.

Keep Reading

If you are new to the series, start with Set Up an Ansible Practice Lab in Under 10 Minutes to build the lab this article runs on. Deploy nginx with Ansible introduces the handlers and health check this article builds on, and Write Your First Real Ansible Playbook covers the check mode and idempotence habits used throughout. The lab lives on GitHub and has a home at CloudSprocket Labs, with a quick start, FAQ and the full article series.

You can find more DevOps and automation articles on the homepage at alishaikh.me.

Official Documentation

If you want to dig deeper into what we used today, these official guides are the best places to start:

  • Templates: the template module documents validate, backups and the variables available while rendering.
  • Templating: the templating guide covers filters, tests and whitespace control, which is where most template polish happens.
  • Jinja2: the Jinja2 template documentation is the reference for the language itself, including loops and conditionals inside a config file.
  • Handlers: the handlers guide explains notification, ordering and flush_handlers.
  • Rolling updates: the strategies guide covers serial, batch sizes and max_fail_percentage.
  • Error handling: the error handling guide is where changed_when, failed_when and blocks are documented together.