Most configuration management is rendering the right config file per host.
The ansible.builtin.template module renders a Jinja2 template on the control node using the host's variables, then copies the result to the host (reporting changed only if the content differs). Templates conventionally live in templates/ with a .j2 suffix.
Jinja2 syntax:
{{ expression }}outputs a value.{% if %}...{% endif %},{% for x in list %}...{% endfor %}for logic.{# comment #}.{%-and-%}trim whitespace around a tag.
Filters transform values with |: default('x'), upper, join(', '), to_nice_yaml, to_json, b64encode, regex_replace, ipaddr-style network filters (in collections), selectattr/map for lists of dicts, combine for merging dicts, mandatory to fail if undefined. Tests check values: is defined, is succeeded, is version('2.0', '>=').
The same syntax works anywhere in playbooks, not only in template files: when conditions, task arguments, variable definitions.
Put {{ ansible_managed | comment }} at the top of generated files so people know not to edit them by hand.
Templating happens on the control node, so lookups (lookup('file', ...), lookup('env', ...)) read the control node's files and environment, not the target's.
Example
{# templates/upstream.conf.j2 #}
{{ ansible_managed | comment }}
upstream {{ app_name }} {
{% for host in groups['app'] %}
server {{ hostvars[host]['ansible_host'] | default(host) }}:{{ app_port }};
{% endfor %}
}
server {
listen 80;
server_name {{ server_names | join(' ') }};
{% if enable_gzip | default(false) %}
gzip on;
{% endif %}
location / { proxy_pass http://{{ app_name }}; }
}
{# task:
- ansible.builtin.template:
src: upstream.conf.j2
dest: /etc/nginx/conf.d/{{ app_name }}.conf
notify: Reload nginx #}Remember
- template renders on the control node, copies if changed
- Filters (|) and tests (is) transform and check values
- Lookups read the control node, not the target
Watch out for
- Expecting lookup('file', '/etc/hostname') to read the remote host's file.