Skip to main content
RunBook Academy

AnsibleXVII · Templates and Jinja2Templates and Jinja2

Templates that survive review

Intermediate⏱ ~19 minansible-playbookansible-config

What you'll learn

  • Add a managed header that tells an operator on the host where the file came from
  • Use ansible_managed correctly, and know its deprecation status
  • Recognise when a template has grown past what a reviewer can verify
  • Move logic out of a template and into variables or the inventory

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

Not yet marked complete on this device.

Two audiences read a template’s output, and they are not the same people.

The reviewer reads the template in a pull request and has to decide whether the change is safe. The operator reads the rendered file on a host at three in the morning and has to work out where it came from and whether editing it will help.

Everything in this lesson serves one of those two.

The header, for the operator

A generated file that does not say it is generated will be edited by hand. Not out of carelessness — because there is nothing on the file to suggest otherwise, and editing the config in front of you is the obvious move during an incident.

That edit survives until the next Ansible run and then vanishes, which is worse than either alternative. The change appeared to work, the problem appeared to be fixed, and it silently came back hours later.

Read-only / Safethe header worth writing
# {{ ansible_managed }}
# Template: roles/webserver/templates/site.conf.j2
# Source of truth: git.example.com/infra/ansible
#
# Local edits to this file are overwritten on the next Ansible run.
# To change it, change the template or the variables and deploy.

server {
  listen {{ listen_port }};
  ...
}

ansible_managed is a variable made available to template, whose default value is the string Ansible managed. Verified on 2.21.3 — ansible-config dump reports DEFAULT_MANAGED_STR(default) = Ansible managed.

Read-only / Safesetting the header as a variable
# group_vars/all.yml
ansible_managed: >-
This file is managed by Ansible. Local edits are overwritten.
Source: git.example.com/infra/ansible

Comment discipline

The header is for provenance. Comments in the body are for the operator who needs to understand a value.

Two rules that hold up:

Comment the non-obvious value, not the syntax. A comment saying # set the worker count above worker_processes is noise. A comment saying # 4 = one per core on the c5.xlarge fleet; see INFRA-2291 is the reason the number is what it is, and it is the thing nobody can reconstruct.

Render the reason, not the variable name. A rendered file saying worker_processes 8; with no context makes the reader open the role. worker_processes {{ workers }}; # from group_vars/webservers.yml is a Jinja comment away from telling them where to change it — though note that {# #} comments are removed from the output, so a comment you want the operator to see must be a comment in the target file’s own syntax.

The size limit, for the reviewer

Here is the failure this section is about. A template starts at 40 lines. A new environment needs one thing different, so an {% if %} appears. Then a second environment, then a special case for one host, then a nested condition inside a loop. Eighteen months later it is 300 lines with six levels of nesting, and nobody can say what it renders for a given host without running it.

At that point the template has stopped being reviewable. A reviewer faced with a three-line diff inside a nested conditional cannot answer “which hosts does this affect”, which means the review is a formality.

Moving logic out

The general move is: the template renders, the variables decide.

Read-only / Safebefore - the decision is in the template
{% if env == 'prod' %}
worker_processes 16;
worker_connections 4096;
{% elif env == 'staging' %}
worker_processes 4;
worker_connections 1024;
{% else %}
worker_processes 2;
worker_connections 512;
{% endif %}
Read-only / Safeafter - the decision is in group_vars
worker_processes {{ nginx_worker_processes }};
worker_connections {{ nginx_worker_connections }};

The values move to group_vars/prod.yml, group_vars/staging.yml and the role’s defaults/main.yml. What you gain is not brevity — it is that variable precedence now handles the layering, one mechanism instead of two, and a reviewer looking at a tuning change sees a one-line diff in a file named after the environment it affects.

Knowledge check

Knowledge check · 4 questions

  1. Q1. A team sets ansible_managed to a string containing the render timestamp. What is the consequence?

  2. Q2. What is the current guidance for setting the managed header on ansible-core 2.21?

  3. Q3. Which are signals that a template has grown past what a reviewer can verify? Select all that apply.

  4. Q4. Moving a four-branch environment conditional out of a template and into group_vars replaces one mechanism with an existing one, rather than adding complexity.

Passing score: 75%. Answers are checked in this browser.