Introduction
Template automation keeps one source while producing configuration from each host's inputs. You will render files from variables and facts, transform a header with a built-in Jinja filter, choose a conditional branch, and load validated nginx configuration through a handler. Actual HTTP requests and a rejected invalid candidate will show the difference between source text, managed files and live service behavior.
Render Host Variables and Facts into Managed Content
In this step, you will create one page template and render distinct real files from the same source on both targets.
The prepared nodes have nginx installed as a prerequisite. Your work is to render and apply configuration, rather than install the package again. Templates are rendered on the controller using each target's variable context and then transferred to managed destinations.
Enter the controller project and create a templates directory. mkdir -p creates the directory if needed without failing when it already exists:
cd /home/labex/project
mkdir -p templates
Define typed host inputs. The environment and listening port differ, and the unquoted maintenance_mode values are YAML Booleans:
cat > host_vars/web1.yml <<'YAML'
environment_label: development
service_port: 8081
maintenance_mode: true
YAML
cat > host_vars/web2.yml <<'YAML'
environment_label: production
service_port: 8082
maintenance_mode: false
YAML
Jinja expressions inside double braces insert values into text. The .j2 suffix is a helpful convention for template sources, rather than a requirement on the managed destination filename. Create one source that uses the current inventory name, environment variable and discovered distribution fact:
cat > templates/page.txt.j2 <<'JINJA'
host={{ inventory_hostname }}
environment={{ environment_label }}
distribution={{ ansible_facts.distribution }}
JINJA
Use the template module for rendering. Copy would normally transfer source bytes without interpreting these Jinja expressions. Gather facts before consuming the distribution value, and declare explicit managed ownership and permissions:
cat > templates.yml <<'YAML'
- name: Render host-specific website content
hosts: all
gather_facts: true
become: true
tasks:
- name: Prepare the rendered site directory
ansible.builtin.file:
path: /var/www/template-site
state: directory
owner: root
group: root
mode: "0755"
- name: Render each host's website content
ansible.builtin.template:
src: templates/page.txt.j2
dest: /var/www/template-site/index.html
owner: root
group: root
mode: "0644"
YAML
Render both managed pages:
ansible-playbook templates.yml --syntax-check
ansible-playbook templates.yml
Read the actual target files:
ansible all -m ansible.builtin.command -a 'cat /var/www/template-site/index.html'
Each page should identify its own host, the matching development/production environment and distribution=Ubuntu. The controller still has one reusable source, while the two managed outputs differ.
Add a Built-In Filter and a Conditional Configuration Branch
In this step, you will render a valid nginx configuration that changes HTTP behavior according to each host's inputs.
A Jinja filter transforms a value using the pipe symbol. upper is a built-in filter that converts text to uppercase; it is not a custom plugin. Jinja control statements use percent-brace delimiters. The if/else/endif branch below selects one maintenance header based on the Boolean input.
Create a complete nginx configuration template. Port, environment header and maintenance header all come from per-host data. The root and index settings serve the page you already rendered:
cat > templates/nginx.conf.j2 <<'JINJA'
events {}
http {
server_tokens off;
server {
listen {{ service_port }};
root /var/www/template-site;
index index.html;
add_header X-Environment "{{ environment_label | upper }}" always;
{% if maintenance_mode %}
add_header X-Maintenance "enabled" always;
{% else %}
add_header X-Maintenance "disabled" always;
{% endif %}
}
}
JINJA
Append configuration and service tasks plus the named reload handler to the existing playbook. The template task's validate command tests a temporary rendered candidate (%s) before replacing the managed configuration. A changed valid configuration notifies the handler; the ordinary service task keeps nginx running.
cat >> templates.yml <<'YAML'
- name: Render and validate the nginx candidate before replacement
ansible.builtin.template:
src: templates/nginx.conf.j2
dest: /etc/nginx/nginx.conf
owner: root
group: root
mode: "0644"
validate: /usr/sbin/nginx -t -c %s
notify: Reload nginx
- name: Keep the rendered site service running
ansible.builtin.service:
name: nginx
state: started
handlers:
- name: Reload nginx
ansible.builtin.service:
name: nginx
state: reloaded
YAML
Apply the complete playbook:
ansible-playbook templates.yml
The configuration task should change both hosts and trigger the reload handler. Inspect web1 on port 8081 and web2 on port 8082. curl -i includes response headers together with the body:
ansible web1 -m ansible.builtin.command -a 'curl --fail --silent --show-error -i http://127.0.0.1:8081/'
ansible web2 -m ansible.builtin.command -a 'curl --fail --silent --show-error -i http://127.0.0.1:8082/'
Web1 should return X-Environment: DEVELOPMENT and X-Maintenance: enabled; web2 should return X-Environment: PRODUCTION and X-Maintenance: disabled. Both bodies retain the correct per-host page. Header dates vary; compare the data-driven values and successful status.
Change Inputs and Reject an Invalid Candidate
In this step, you will update persistent input without editing the templates, observe the actual live result, and prove that validation protects working configuration.
Change web1's saved environment to staging and its maintenance mode to false, keeping its existing port:
cat > host_vars/web1.yml <<'YAML'
environment_label: staging
service_port: 8081
maintenance_mode: false
YAML
Apply the same source templates:
ansible-playbook templates.yml
Web1's page and configuration should change, notifying its reload handler; web2 should remain unchanged. Inspect web1 again:
ansible web1 -m ansible.builtin.command -a 'curl --fail --silent --show-error -i http://127.0.0.1:8081/'
Its body should show environment=staging, with X-Environment: STAGING and X-Maintenance: disabled. The templates stayed reusable as the input changed.
Now test candidate rejection. cp saves the valid controller source so it can be restored. Replace only the nginx template with deliberately invalid text:
cp templates/nginx.conf.j2 templates/nginx.conf.j2.good
printf 'invalid nginx configuration;\n' > templates/nginx.conf.j2
The next playbook run is expected to fail candidate validation. The shell's || runs the message only after a nonzero exit, allowing this intentional failure exercise to continue:
ansible-playbook templates.yml || printf 'Expected candidate validation rejection\n'
Read the task failure: nginx should reject the temporary candidate. The managed working configuration must remain in place, so both live services should still respond correctly:
ansible web1 -m ansible.builtin.command -a 'curl --fail --silent --show-error -i http://127.0.0.1:8081/'
ansible web2 -m ansible.builtin.command -a 'curl --fail --silent --show-error -i http://127.0.0.1:8082/'
mv restores the saved valid source to its original filename. Run the complete playbook once more:
mv templates/nginx.conf.j2.good templates/nginx.conf.j2
ansible-playbook templates.yml
Both hosts should report changed=0, failed=0 and unreachable=0, with no reload handler scheduled. Validation protected the live configuration without making a copy of an invalid candidate the desired end state.
Summary
You rendered host variables and facts with the template module, used the built-in upper filter and a Boolean Jinja branch, and applied validated configuration through a handler. Changed input produced different live output without rewriting the sources. An invalid candidate was rejected before replacement, and restored valid templates converged without extra changes.


