Objective
By the end of this lab you will have rendered a deliberately invalid nginx
configuration through a template task and proved, by checksum, that the
file on the managed node did not change and the running service was not
touched. You will also have run the same broken render without validate:
and seen the difference — which is a service that fails to reload and a host
that still serves the old config until the next restart, when it stops
serving anything.
Architecture
One managed node running nginx from the distribution package, and a role that owns one vhost file.
controller ──ssh──▶ node1 (Debian 12 / Ubuntu 24.04)
├── /etc/nginx/nginx.conf (package-owned)
├── /etc/nginx/sites-available/lab.conf <- managed
└── /etc/nginx/sites-enabled/lab.conf -> symlink
Requirements
- A controller with
ansible-core2.21.x. - One managed node with systemd as PID 1 and nginx installed from the
distribution package. A container is acceptable if you stop at the
validator and make no assertion about service state; the second half of
the lab — reload versus restart behaviour with a bad config — requires a
real service manager. Declare
B-nested. - SSH key access and
become. - Port 80 free on the node. The lab binds a vhost on
127.0.0.1:8080rather than:80so it does not collide with anything already served. - No out-of-band access requirement: this lab does not reconfigure SSH, the firewall or any network interface. It can stop nginx, which Cleanup restores.
Scenario
A colleague deployed a vhost change on Friday. The template rendered a
variable that was undefined in production, producing a server_name ;
line. nginx -t would have rejected it. The play did not run nginx -t;
it wrote the file and notified a reload.
The reload failed, so nginx carried on with its previously-loaded configuration and kept serving correctly. Nobody noticed. Nine days later the node rebooted, nginx failed to start, and the site went down for reasons that appeared entirely unrelated to a change made a week and a half earlier.
Tasks
Task 1: Capture the starting state
WORKDIR="$HOME/ansible-validate-lab"
mkdir -p "$WORKDIR"/{templates,files}
cd "$WORKDIR"
inventory.yml:
web:
hosts:
node1:
ansible_host: 192.0.2.11
vars:
ansible_user: operator
# capture.yml
- name: Capture the pre-lab nginx state
hosts: web
become: true
gather_facts: false
tasks:
- name: Record which sites are enabled
ansible.builtin.command: ls -la /etc/nginx/sites-enabled/
register: sites
changed_when: false
- name: Record the service state
ansible.builtin.command: systemctl is-active nginx
register: active
changed_when: false
failed_when: false
- name: Confirm the current configuration is valid before we start
ansible.builtin.command: nginx -t
register: pretest
changed_when: false
failed_when: false
- name: Save the capture to the controller
ansible.builtin.copy:
content: |
host: {{ inventory_hostname }}
nginx active: {{ active.stdout }}
nginx -t rc: {{ pretest.rc }}
sites-enabled:
{{ sites.stdout }}
dest: "{{ playbook_dir }}/pre-lab-nginx.txt"
mode: '0644'
delegate_to: localhost
become: false
$ ansible-playbook -i inventory.yml capture.ymlIf nginx -t rc is not 0 in the capture, stop. The node’s configuration is
already broken and nothing this lab shows you will be interpretable.
Task 2: Write the vhost template
templates/lab-vhost.conf.j2:
# Managed by Ansible. Local edits are overwritten.
# Source: {{ template_path | default('templates/lab-vhost.conf.j2') }}
# Rendered: for host {{ inventory_hostname }}
server {
listen 127.0.0.1:{{ vhost_port }};
server_name {{ vhost_server_name }};
access_log /var/log/nginx/{{ vhost_name }}.access.log;
error_log /var/log/nginx/{{ vhost_name }}.error.log;
root /var/www/{{ vhost_name }};
index index.html;
location / {
try_files $uri $uri/ =404;
}
}
Note the $uri in the try_files line. Jinja leaves $ alone — it is
not a Jinja sigil — so nginx variables pass through untouched. This is one
of the few places where nginx and Jinja syntax do not collide.
Task 3: The play, with validation
# deploy.yml
- name: Deploy the lab vhost
hosts: web
become: true
gather_facts: false
vars:
vhost_name: lab
vhost_port: 8080
vhost_server_name: lab.example.com
handlers:
- name: Reload nginx
ansible.builtin.systemd_service:
name: nginx
state: reloaded
tasks:
- name: Ensure the document root exists
ansible.builtin.file:
path: "/var/www/{{ vhost_name }}"
state: directory
mode: '0755'
- name: Write a placeholder index
ansible.builtin.copy:
content: "lab vhost\n"
dest: "/var/www/{{ vhost_name }}/index.html"
mode: '0644'
- name: Render the vhost, validating before activation
ansible.builtin.template:
src: lab-vhost.conf.j2
dest: "/etc/nginx/sites-available/{{ vhost_name }}.conf"
owner: root
group: root
mode: '0644'
backup: true
validate: 'nginx -t -c %s'
notify: Reload nginx
- name: Enable the vhost
ansible.builtin.file:
src: "/etc/nginx/sites-available/{{ vhost_name }}.conf"
dest: "/etc/nginx/sites-enabled/{{ vhost_name }}.conf"
state: link
notify: Reload nginx
The fix is to validate the whole configuration with the candidate file in place, which requires a two-step deploy: write to a staging path, validate the full tree, then move it into position.
- name: Render the vhost to a staging path
ansible.builtin.template:
src: lab-vhost.conf.j2
dest: "/etc/nginx/sites-available/{{ vhost_name }}.conf.candidate"
owner: root
group: root
mode: '0644'
register: candidate
- name: Validate the whole configuration with the candidate linked in
ansible.builtin.shell: |
set -euo pipefail
ln -sfn "/etc/nginx/sites-available/{{ vhost_name }}.conf.candidate" \
"/etc/nginx/sites-enabled/{{ vhost_name }}.conf.candidate"
trap 'rm -f "/etc/nginx/sites-enabled/{{ vhost_name }}.conf.candidate"' EXIT
nginx -t
args:
executable: /bin/bash
register: validation
changed_when: false
when: candidate.changed
- name: Promote the candidate only once the whole config validated
ansible.builtin.copy:
src: "/etc/nginx/sites-available/{{ vhost_name }}.conf.candidate"
dest: "/etc/nginx/sites-available/{{ vhost_name }}.conf"
remote_src: true
owner: root
group: root
mode: '0644'
backup: true
when: candidate.changed and validation.rc == 0
notify: Reload nginx
Task 4: Break the template deliberately and prove nothing moved
Take a checksum of the live file first. This is the evidence:
ansible -i inventory.yml web -b -m stat \
-a 'path=/etc/nginx/sites-available/lab.conf checksum_algorithm=sha256' \
| grep checksum
Record it. Now break the render by leaving vhost_server_name undefined:
ansible-playbook -i inventory.yml deploy.yml \
--extra-vars '{"vhost_server_name": ""}'
With an empty server name the template produces server_name ;, which
nginx rejects:
$ ansible-playbook -i inventory.yml deploy.yml --extra-vars '{"vhost_server_name": ""}'TASK [Validate the whole configuration with the candidate linked in] ***********
fatal: [node1]: FAILED! => {"changed": false, "cmd": "...", "rc": 1,
"stderr": "nginx: [emerg] invalid number of arguments in \"server_name\" directive in /etc/nginx/sites-enabled/lab.conf.candidate:6\nnginx: configuration file /etc/nginx/nginx.conf test failed"}
PLAY RECAP *********************************************************************
node1 : ok=4 changed=1 unreachable=0 failed=1Illustrative output
Now prove the claim rather than believing the recap:
# Same checksum as before?
ansible -i inventory.yml web -b -m stat \
-a 'path=/etc/nginx/sites-available/lab.conf checksum_algorithm=sha256' \
| grep checksum
# Is the configuration still valid?
ansible -i inventory.yml web -b -m command -a 'nginx -t'
# Is the service still serving?
ansible -i inventory.yml web -b -m uri \
-a 'url=http://127.0.0.1:8080/ return_content=yes'
The checksum is unchanged, nginx -t returns 0, and the vhost still
answers. The only trace of the failed run is a .candidate file, which the
trap in the validation step already unlinked from sites-enabled.
Task 5: Run the same break without validation, and compare
Comment out the validation and promote steps and go back to writing
directly to the live path with the original template task, minus
validate:.
ansible-playbook -i inventory.yml deploy-unvalidated.yml \
--extra-vars '{"vhost_server_name": ""}'
Observe the three-part failure:
# 1. The file on disk is now invalid
ansible -i inventory.yml web -b -m command -a 'nginx -t'
# 2. The reload handler failed - but nginx is still running
ansible -i inventory.yml web -b -m command -a 'systemctl is-active nginx'
# 3. And still serving, from the configuration it loaded before
ansible -i inventory.yml web -b -m uri -a 'url=http://127.0.0.1:8080/'
$ ansible -i inventory.yml web -b -m command -a 'nginx -t'node1 | FAILED | rc=1 >>
nginx: [emerg] invalid number of arguments in "server_name" directive in /etc/nginx/sites-enabled/lab.conf:6
nginx: configuration file /etc/nginx/nginx.conf test failedIllustrative output
That is the nine-day gap in the scenario. Every health check passes.
systemctl is-active says active. The site responds. And a reboot takes
it down.
Task 6: Restore and re-prove
ansible-playbook -i inventory.yml deploy.yml
ansible -i inventory.yml web -b -m command -a 'nginx -t'
ansible -i inventory.yml web -b -m systemd_service -a 'name=nginx state=restarted'
ansible -i inventory.yml web -b -m uri -a 'url=http://127.0.0.1:8080/'
The restart is the real test. A node whose config is valid restarts cleanly; the one from Task 5 would not have.
Validation
pre-lab-nginx.txtrecordsnginx -t rc: 0before the lab began.- With the staged-validation play and a valid render, the vhost is written,
enabled and reloaded, and
http://127.0.0.1:8080/returns the placeholder content. - With
vhost_server_nameempty, the play fails at the validation step, and the sha256 of/etc/nginx/sites-available/lab.confis identical to the value recorded before the failed run. - After the failed run,
nginx -tstill returns 0 and the vhost still answers. - With validation removed, the same break leaves
nginx -treturning 1 whilesystemctl is-active nginxstill reportsactive. - After Task 6,
nginx -treturns 0 and nginx survives an explicitstate: restarted. - Your notes table lists at least four validators and identifies which two cannot run against a temporary path.
Expected Outcome
ansible-validate-lab/
├── deploy.yml
├── deploy-unvalidated.yml
├── inventory.yml
├── pre-lab-nginx.txt
├── templates/lab-vhost.conf.j2
└── validators.md
On the node: a valid nginx configuration, a lab vhost serving on
127.0.0.1:8080, and nginx surviving a full restart. You have a
checksum-backed demonstration that a rejected render changed nothing, and
you have seen the alternative.
Troubleshooting
nginx: [emerg] "server" directive is not allowed here. Expected on a
fragment; this is Task 3’s lesson. Use the staged approach.
The validation step reports changed on every run. It is a shell task
that reads. Add changed_when: false — it is one of the honest uses,
because nginx -t cannot modify anything.
ln -sfn leaves a stale candidate symlink after a failure. The trap ... EXIT should remove it. If you see one, the shell exited in a way that
skipped the trap — check that executable: /bin/bash is set, since sh on
Debian is dash and handles set -o pipefail differently.
uri reports Connection refused. nginx is not listening on 8080.
Either the vhost is not enabled (ls /etc/nginx/sites-enabled/), or the
reload never happened because nothing reported changed.
backup: true created a file you did not expect. That is the point —
it writes lab.conf.<timestamp>~ beside the destination before
overwriting. Cleanup removes them; in production they are how you get the
previous version back without a git checkout.
The play fails with Destination directory /etc/nginx/sites-available does not exist. nginx was installed from a source that does not use the
Debian sites-available layout. Adjust the paths to
/etc/nginx/conf.d/lab.conf, and note that conf.d files are included by
the packaged nginx.conf on RHEL-family systems the same way.
Cleanup
This lab wrote configuration into /etc/nginx, created a document root,
and at one point left an invalid configuration on disk. All of it must
come back, and nginx must be left both valid and running.
# cleanup.yml
- name: Remove the lab vhost and restore nginx
hosts: web
become: true
gather_facts: false
vars:
vhost_name: lab
tasks:
- name: Remove the enabled symlink
ansible.builtin.file:
path: "/etc/nginx/sites-enabled/{{ vhost_name }}.conf"
state: absent
- name: Remove any leftover candidate symlink
ansible.builtin.file:
path: "/etc/nginx/sites-enabled/{{ vhost_name }}.conf.candidate"
state: absent
- name: Remove the vhost file, the candidate and every backup
ansible.builtin.shell: |
set -euo pipefail
rm -f /etc/nginx/sites-available/{{ vhost_name }}.conf
rm -f /etc/nginx/sites-available/{{ vhost_name }}.conf.candidate
rm -f /etc/nginx/sites-available/{{ vhost_name }}.conf.*~
args:
executable: /bin/bash
changed_when: true
- name: Remove the document root the lab created
ansible.builtin.file:
path: "/var/www/{{ vhost_name }}"
state: absent
- name: Remove the log files the lab created
ansible.builtin.file:
path: "{{ item }}"
state: absent
loop:
- "/var/log/nginx/{{ vhost_name }}.access.log"
- "/var/log/nginx/{{ vhost_name }}.error.log"
- name: Validate the restored configuration BEFORE restarting
ansible.builtin.command: nginx -t
register: post
changed_when: false
- name: Restart nginx only once the configuration validated
ansible.builtin.systemd_service:
name: nginx
state: restarted
when: post.rc == 0
Verify against the capture:
cd "$HOME/ansible-validate-lab"
cat pre-lab-nginx.txt
ansible -i inventory.yml web -b -m command -a 'ls -la /etc/nginx/sites-enabled/'
ansible -i inventory.yml web -b -m command -a 'systemctl is-active nginx'
The sites-enabled listing must match the pre-lab capture, and nginx must
report active. Only then:
mkdir -p "$HOME/ansible-lab-deliverables"
cp -a validators.md templates/lab-vhost.conf.j2 \
"$HOME/ansible-lab-deliverables/"
rm -rf "$HOME/ansible-validate-lab"
What You Learned
validate:on a config fragment fails for a real reason. A vhost is not a standalone nginx configuration, andnginx -t -c %scorrectly rejects it. The answer is to stage and validate the whole tree, not to delete the gate.- A rejected render leaves the destination byte-identical. You proved it by sha256, not by reading the recap.
- Without validation, the failure is invisible. Broken config on disk,
a failed reload,
systemctl is-activereportingactive, and the site still answering — until a reboot. reloadsurvives a bad config andrestartdoes not. The safer verb is also the one that hides the damage, which is why the gate matters more than the verb.validate:works on complete configurations and not on fragments. You have a table of four validators and know which two need staging.- Cleanup validates before restarting, for the same reason deployment does: deleting from a live configuration directory is a change like any other.