AnsibleXVII · Templates and Jinja2Templates and Jinja2
Validate before it becomes live
What you'll learn
- Use validate: with the correct %s placeholder on template, copy, lineinfile and blockinfile
- State exactly what happens to the destination when validation fails
- Choose the right validator command for nginx, sshd, haproxy and sudoers
- Explain why validation does not run under check mode
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
Everything in this part so far can produce a file the service cannot parse: a whitespace mistake, a missing variable, a conditional that included the wrong block. Without validation, the sequence is
render → write over the live file → notify → reload → service fails to start
and the host is now down, with the configuration that broke it in place and the previous configuration gone.
validate: changes the order so the check happens before the
replacement. It is one line of YAML, it is available on template,
copy, lineinfile and blockinfile — verified on 2.21.3, the
parameter and its documentation text are identical across all four — and
it is the most valuable line in this part.
What it does, precisely
The documented behaviour, quoted from ansible-doc ansible.builtin.template
on 2.21.3:
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
%swhich 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.
Three separate facts in three sentences, each with consequences.
%s is the temporary file, and it is mandatory. Ansible substitutes
the path of the staged file for %s before running the command. Omit
%s and the module fails outright with
validate must contain %s: <your command> — read in the 2.21.3 copy
module source, which checks for the literal %s before running
anything. It does not silently validate the wrong file.
The validator runs on the managed node, against the staged file, not against the destination. That is what makes the check meaningful: whatever the validator says is about the content that is about to be installed.
No shell. The command is executed directly, not through /bin/sh.
Pipes, redirections, &&, globs, $VAR expansion and backticks do not
work. A validator that needs any of those needs a wrapper script on the
managed node, invoked as the validate command.
The real validators
- name: Deploy the nginx site configuration
ansible.builtin.template:
src: site.conf.j2
dest: /etc/nginx/conf.d/site.conf
owner: root
group: root
mode: '0644'
validate: nginx -t -c %s
notify: Reload nginx- name: Deploy the SSH daemon configuration
ansible.builtin.template:
src: sshd_config.j2
dest: /etc/ssh/sshd_config
owner: root
group: root
mode: '0600'
validate: /usr/sbin/sshd -t -f %s
notify: Reload sshd- name: Deploy the load balancer configuration
ansible.builtin.template:
src: haproxy.cfg.j2
dest: /etc/haproxy/haproxy.cfg
mode: '0644'
validate: haproxy -c -f %s
notify: Reload haproxy
- name: Deploy a sudoers drop-in
ansible.builtin.template:
src: deploy-sudoers.j2
dest: /etc/sudoers.d/deploy
owner: root
group: root
mode: '0440'
validate: /usr/sbin/visudo -cf %sWhat happens when validation fails
The task fails. The destination is not touched. The service keeps running the configuration it already had.
The failure result carries the validator’s own output, which is usually the most useful part:
$ ansible-playbook -i inventories/prod site.yml --limit web01.example.comTASK [Deploy the nginx site configuration] *************************************
fatal: [web01.example.com]: FAILED! => {
"changed": false,
"exit_status": 1,
"msg": "failed to validate",
"stderr": "nginx: [emerg] unknown directive \"proxy_pas\" in /tmp/tmpk3n8f1:14\nnginx: configuration file /tmp/tmpk3n8f1 test failed",
"stdout": ""
}
PLAY RECAP *********************************************************************
web01.example.com : ok=3 changed=0 unreachable=0 failed=1 skipped=0 rescued=0 ignored=0Read the recap: changed=0. Nothing was written, so nothing was
notified, so no handler ran. The whole downstream chain is correctly
inert.
The stderr names the temporary path rather than the destination, which
looks odd the first time. It is correct — that genuinely is the file the
validator examined — and the line number in it maps to the rendered
output, which is what you want when debugging a template.
validate does not run under --check
This is the limitation most likely to catch you out, and it follows directly from the implementation.
In the 2.21.3 copy module, the entire replacement sequence — backup,
validate, atomic move — sits inside if not module.check_mode. A check
run predicts the file difference and then stops. The validator is never
invoked.
So this is true and worth stating plainly: --check --diff tells you
what the file will become; it does not tell you the service will accept
it. A dry run of a play that would deploy a broken configuration
reports a clean diff and a changed prediction, and gives no hint that
the real run will fail validation.
The corollary is the canary. Run the play for real against one host with
--limit, and let validation actually execute there. That is the only
cheap way to find out that the rendered file is unparseable before it
reaches the fleet.
Knowledge check
Knowledge check · 4 questions
Q1. In validate: nginx -t -c %s, what does %s refer to?
Q2. A template renders an nginx config with a typo. The task has validate: nginx -t -c %s. What is the state of the host after the run?
Q3. Which statements about validate: are accurate on ansible-core 2.21? Select all that apply.
Q4. Adding validate: to a role slows down every run, because the validator executes on every host each time the play runs.
Passing score: 75%. Answers are checked in this browser.