Skip to main content
RunBook Academy

AnsibleXVII · Templates and Jinja2Templates and Jinja2

Where validate is not enough

Advanced⏱ ~22 minansible-playbook

What you'll learn

  • Identify the three cases where the validate parameter cannot help
  • Build a render, check, activate, notify sequence by hand
  • Keep that hand-built sequence idempotent so it does not restart the service every run
  • Explain why a parseable configuration is not a correct one

Prerequisites

Verified against ansible-core 2.21.x · ansible (community package) 14.x · Python (controller) 3.12+ · ansible-lint 26.x · Molecule 26.x · Ubuntu 24.04 LTS · Debian 12 (Bookworm) · RHEL / Rocky / AlmaLinux 9.x · 2026-08-11

Not yet marked complete on this device.

validate: is the right default and it has three limits. Knowing them is what separates using it from trusting it.

Limit one: the validator needs the whole tree

nginx -t -c %s parses the temporary file as a complete configuration. That works when the file you are templating is the whole configuration. It does not work when you are templating a fragment that is included by a larger file.

A drop-in under /etc/nginx/conf.d/ contains a bare server { ... } block. Handed to nginx -t -c on its own, that is not a valid nginx configuration at all — a server block must live inside http. The validator rejects a file that is perfectly correct in its real position.

The mirror image is worse: a fragment can be individually valid and break the assembled configuration. Two drop-ins that each define server_name www.example.com are both fine alone and produce a conflict warning together. validate sees one file and cannot know about the other.

The same shape appears everywhere:

ConfigurationThe fragment problem
/etc/nginx/conf.d/*.confserver blocks outside http
/etc/sudoers.d/*visudo -cf handles these well — a genuine exception
/etc/systemd/system/*.d/*.confDrop-in overrides are meaningless without the base unit
/etc/haproxy/conf.d/*Backends referenced from a frontend in another file

Limit two: valid and wrong

A configuration can parse perfectly and be operationally incorrect. This is the one that produces the confusing outage.

proxy_pass http://192.0.2.99:8080; is impeccable nginx. nginx -t approves it. The reload succeeds. And if nothing is listening on that address, every request returns 502.

The validator’s contract is parseable, not correct. It checks grammar. It does not check that the backend exists, that the certificate matches the hostname, that the port is the one the application listens on, or that the upstream group is not empty because a hostvars lookup came back empty under --limit.

Limit three: there is no offline checker

Plenty of services have none. A validator does not exist for most application configuration files, many agent configs, and anything whose “check” requires connecting to something.

Some services offer a partial one. Some offer a check that starts the service, which is not a check. And for a JSON or YAML file, the useful check is often structural rather than semantic:

Configuration changea structural validator where no real one exists
- name: Deploy the application configuration
ansible.builtin.template:
  src: app.json.j2
  dest: /etc/app/app.json
  owner: root
  group: app
  mode: '0640'
  validate: python3 -c "import json,sys; json.load(open(sys.argv[1]))" %s
notify: app config changed

That is worth having. A template that renders a trailing comma into a JSON file is a real and common defect, and this catches it. Be clear with yourself about what it proves.

Building the sequence by hand

When validate: cannot do the job, the sequence it implements can be built explicitly: render to a staging path → check → activate → notify.

Service impact possiblerender, check, activate, notify
- name: Deploy a validated nginx drop-in
hosts: webservers
become: true
tasks:
  - name: Render the drop-in to a staging path
    ansible.builtin.template:
      src: site.conf.j2
      dest: /etc/nginx/staging/site.conf
      owner: root
      group: root
      mode: '0644'
    register: staged

  - name: Validate the whole configuration with the drop-in included
    ansible.builtin.command:
      argv:
        - /usr/sbin/nginx
        - -t
        - -c
        - /etc/nginx/nginx.staging.conf
    changed_when: false
    when: staged.changed

  - name: Activate the validated drop-in
    ansible.builtin.copy:
      src: /etc/nginx/staging/site.conf
      dest: /etc/nginx/conf.d/site.conf
      remote_src: true
      owner: root
      group: root
      mode: '0644'
    when: staged.changed
    notify: Reload nginx

handlers:
  - name: Reload nginx
    ansible.builtin.systemd_service:
      name: nginx
      state: reloaded

Four design decisions in that play, each of which is the difference between it working and it being a liability.

register: staged and when: staged.changed. Without these, the validator runs on every host on every run and the activation task runs on every run. Guarding both on the staging render’s own changed result means a converged host does nothing at all — which is the same conditional discipline handlers use, applied one level down.

changed_when: false on the validator. It is a read-only probe. Let it report changed and it contaminates the recap and, if you ever add a notify nearby, the handler chain.

remote_src: true on the activation. The staged file already exists on the managed node; without this, copy would look for the source on the controller.

notify on the activation task, not the render. The render happens before the check. Notifying there would queue a reload for a configuration that has not been validated yet — and if a later task fails, force_handlers would then apply it. The notification belongs on the task that puts the file in the live path.

Keeping the sequence honest

The trap above is worth stating on its own, because it is the failure this pattern actually produces in the field.

Run 1: the template renders new content to staging, reports changed, validation fails, the task errors, the play stops for that host. Run 2: the template renders the same content to staging, which already matches, so it reports ok. staged.changed is false. The validator is skipped. The activation is skipped. The play is green and the configuration was never activated.

That is the never-fired-handler failure from Part XVI, reconstructed from the same conditional habits that were supposed to prevent it.

Two ways to close it, and the second is better:

Service impact possibleguard on the destination, not the staging file
- name: Render the drop-in to a staging path
ansible.builtin.template:
  src: site.conf.j2
  dest: /etc/nginx/staging/site.conf
  mode: '0644'

- name: Read the checksum of the staged and live files
ansible.builtin.stat:
  path: '{{ item }}'
register: files
loop:
  - /etc/nginx/staging/site.conf
  - /etc/nginx/conf.d/site.conf

- name: Validate and activate when the live file differs from the staged one
when: files.results[0].stat.checksum != files.results[1].stat.checksum | default('')
block:
  - name: Validate the assembled configuration
    ansible.builtin.command:
      argv: [/usr/sbin/nginx, -t, -c, /etc/nginx/nginx.staging.conf]
    changed_when: false

  - name: Activate the validated drop-in
    ansible.builtin.copy:
      src: /etc/nginx/staging/site.conf
      dest: /etc/nginx/conf.d/site.conf
      remote_src: true
      mode: '0644'
    notify: Reload nginx

The condition now asks the question that actually matters — does the live file differ from what we intend? — rather than did we write a new staging file this run? A change that failed validation on run 1 is still pending on run 2, gets validated again, and fails again. Loudly, every run, until somebody fixes it.

Knowledge check

Knowledge check · 4 questions

  1. Q1. A role templates a drop-in containing a bare nginx server block into conf.d/. Why does validate: nginx -t -c %s fail?

  2. Q2. A template renders proxy_pass http://192.0.2.99:8080; where nothing is listening. nginx -t passes, the reload succeeds, and the site returns 502. What does this demonstrate?

  3. Q3. A hand-built render-check-activate sequence needs which of these to be safe? Select all that apply.

  4. Q4. The hand-built sequence should be the default, because it validates more thoroughly than the validate parameter.

Passing score: 75%. Answers are checked in this browser.