AnsibleXVII · Templates and Jinja2Templates and Jinja2
Where validate is not enough
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
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:
| Configuration | The fragment problem |
|---|---|
/etc/nginx/conf.d/*.conf | server blocks outside http |
/etc/sudoers.d/* | visudo -cf handles these well — a genuine exception |
/etc/systemd/system/*.d/*.conf | Drop-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:
- 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 changedThat 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.
- 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: reloadedFour 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:
- 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 nginxThe 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
Q1. A role templates a drop-in containing a bare nginx server block into conf.d/. Why does validate: nginx -t -c %s fail?
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?
Q3. A hand-built render-check-activate sequence needs which of these to be safe? Select all that apply.
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.