Report this

What is the reason for this report?

How To Create and Use Templates in Ansible Playbooks

Updated on February 27, 2026
How To Create and Use Templates in Ansible Playbooks

Introduction

Maintaining a separate config file per server or environment is the problem Ansible templates are designed to eliminate. By the end of this guide you will have built an Nginx landing page and an Nginx server block from Jinja2 templates, deployed them with the Ansible Template Module, and verified both with curl and service checks.

Key Takeaways

  • If the template task reports success but the service behavior does not change, the service almost certainly needs a handler-triggered reload or restart; the rendered file alone is not enough.
  • A single undefined variable in a template fails the entire playbook as soon as a host (or new inventory group) does not define that variable; use | default(...) for any value that might be missing.
  • When you run the play with become: yes, the template module writes files as root, so ownership is root unless you set owner/group on the task or in a follow-up step; Nginx on Ubuntu reads files owned by root, so this is fine for the paths used here.
  • Deploying many template/destination pairs via a single loop keeps the playbook DRY and makes it obvious what files are managed by Ansible.
  • Putting templates inside a role’s templates/ directory makes the role self-contained and reusable; the playbook only passes variables and includes the role.
  • Use the template module when content must vary per host or environment; use the copy module for identical or binary files, since copy does not process Jinja2. Run with --check --diff to preview the rendered file and diff before applying.

Prerequisites

Examples use an inventory file named inventory and user sammy; use values that match your setup. All paths assume an ansible-practice directory on the control node.

Jinja2 Template Syntax Overview

Templates let you keep one file and vary the output per host; the main trap is an undefined variable failing the whole play on a new host. The patterns below are the ones you need for the steps that follow. Ansible passes play variables, host/group vars, and facts into the template, so any variable in scope for the play is available. For each pattern, the reason you would use it is stated right after the snippet.

Variables. Double curly braces output a variable. If that variable is not defined anywhere for the host, the playbook fails at template render time.

{{ page_title }}
{{ app_port }}

Define every variable you reference in the template in the play’s vars, in group/host vars, or via set_fact; otherwise one new host without that variable will break the run.

Filters. The pipe applies a filter. The one you will use most is default: it supplies a value when the variable is undefined or empty so the playbook does not fail.

{{ page_title | default('Home') }}
{{ enable_ssl | default(false) | bool }}

Omitting | default(...) for optional config (e.g., an optional SSL block) causes an “undefined variable” failure the first time the play runs on a host or in a group where that variable was never set.

Conditionals. {% if %} ... {% endif %} (and {% elif %}, {% else %}) include or omit a block based on a condition. Use them for optional sections (e.g., SSL directives only when enable_ssl is true).

{% if enable_ssl %}
listen 443 ssl;
{% endif %}

If you forget to close with {% endif %}, Jinja2 reports a template syntax error and points to the line number; every {% if %} must have a matching {% endif %}.

Loops. {% for item in list %} ... {% endfor %} repeats a block for each element. Use this when the config is a list (e.g., upstream servers, allow/deny IPs).

{% for server in upstream_servers %}
server {{ server }};
{% endfor %}

The list must exist and be iterable; if upstream_servers is undefined, the playbook fails unless you guard with {% if upstream_servers is defined and upstream_servers %}.

Step 1: Creating a Basic Jinja2 Template

You need one template file that depends on two variables so that when you apply it in Step 2 you see exactly where those values come from and what happens if they are missing. The .j2 suffix is convention only; Ansible does not require it, but editors and linters recognize it. Put the file in a files/ directory next to your playbook so the task’s src: files/landing-page.html.j2 resolves: the template module looks for relative paths starting from the playbook’s directory (or the role’s templates/ directory when the task is inside a role).

Create a directory for non-playbook assets and the template file:

mkdir -p ~/ansible-practice/files
nano ~/ansible-practice/files/landing-page.html.j2

Paste the following. The template expects page_title and page_description to be set when the play runs; if either is missing, the template task will fail with an undefined variable error.

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>{{ page_title }}</title>
  <meta name="description" content="Created with Ansible">
</head>
<body>
    <h1>{{ page_title }}</h1>
    <p>{{ page_description }}</p>
</body>
</html>

Save and close. Do not run anything yet; the next step applies this template from a playbook and defines the two variables there.

What to expect. The file on disk is still Jinja2 source. After you apply it in Step 2, the rendered file on the host will contain the literal values of page_title and page_description (e.g., “My Landing Page”) in place of the {{ }} expressions.

Step 2: Applying a Template in a Playbook

The template module renders the file on the control node and copies the result to the host. The src path is relative to the playbook directory, so src: files/landing-page.html.j2 means Ansible reads ~/ansible-practice/files/landing-page.html.j2 (or the same path relative to wherever the playbook lives). You install Nginx, write the rendered HTML to the default index path, open port 80, then verify with curl. The template task runs with become: yes, so the file is written as root; on Ubuntu the Nginx process reads that path as root, so no ownership change is needed.

Create the playbook:

nano ~/ansible-practice/playbook-11.yml

Add the content below. The ufw task uses the community.general collection; install it with ansible-galaxy collection install community.general if you use Ansible 2.10+ with collections. If your setup uses the built-in ufw module, replace community.general.ufw with ufw.

---
- hosts: all
  become: yes
  vars:
    page_title: My Landing Page
    page_description: This is my landing page description.
  tasks:
    - name: Install Nginx
      apt:
        name: nginx
        state: latest

    - name: Apply Page Template
      ansible.builtin.template:
        src: files/landing-page.html.j2
        dest: /var/www/html/index.nginx-debian.html

    - name: Allow all access to tcp port 80
      community.general.ufw:
        rule: allow
        port: '80'
        proto: tcp

Run the playbook. Use -K if you need to supply the become password:

ansible-playbook -i inventory playbook-11.yml -u sammy -K

Expected output:

BECOME password:

PLAY [all] *********************************************************************

TASK [Gathering Facts] *********************************************************
ok: [203.0.113.10]

TASK [Install Nginx] **********************************************************
changed: [203.0.113.10]

TASK [Apply Page Template] ****************************************************
changed: [203.0.113.10]

TASK [Allow all access to tcp port 80] ****************************************
changed: [203.0.113.10]

PLAY RECAP *********************************************************************
203.0.113.10 : ok=4    changed=3    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0

What to expect. The rendered file at /var/www/html/index.nginx-debian.html contains plain HTML with “My Landing Page” and your description text. If the template task failed with “The task includes an option with an undefined variable”, one of page_title or page_description is missing from vars (or was overridden with an undefined value).

Verify Nginx is active and the page is served:

ansible all -i inventory -u sammy -m shell -a "systemctl is-active nginx" -b

Expected output:

203.0.113.10 | SUCCESS | rc=0 >>
active

Then fetch the page (replace the IP with your host’s):

curl -s http://203.0.113.10/ | head -20

You should see the HTML with “My Landing Page” as the title and the description paragraph. If you get “Connection refused”, Nginx may not be running or port 80 may be blocked; check systemctl status nginx and firewall rules.

To see the rendered file without writing to the host, run the playbook with --check --diff. Ansible will show the diff that would be applied and report “changed” for the template task without modifying the target file. Use this to spot wrong or empty variables before deploying.

To use a different page_title or page_description per host, define the variable in host_vars or group_vars (e.g., host_vars/203.0.113.10.yml or group_vars/webservers.yml) instead of or in addition to play vars. Host and group vars override play vars for that host, so each host can get its own value when the same play runs across the inventory.

Step 3: Using Variables, Loops, and Conditionals in Templates

Rendering a config file is not enough: Nginx only picks up changes after a reload or restart. You add a handler and notify so that when the template task changes the file, the handler runs and Nginx reloads. This step adds an Nginx server block template that uses a variable for the server name, an optional SSL block (conditional), and an optional upstream block (loop), then deploys it and triggers the reload.

Create the server block template:

nano ~/ansible-practice/files/nginx-server-block.conf.j2

Add the following. It uses enable_ssl | default(false) | bool so the playbook does not fail when enable_ssl is not set; the upstream block is only rendered when upstream_servers is defined and non-empty.

# Managed by Ansible - do not edit by hand
server {
    listen 80;
{% if enable_ssl | default(false) | bool %}
    listen 443 ssl;
    ssl_certificate     {{ ssl_cert_path | default('/etc/ssl/certs/ssl-cert-snakeoil.pem') }};
    ssl_certificate_key {{ ssl_key_path | default('/etc/ssl/private/ssl-cert-snakeoil.key') }};
{% endif %}
    server_name {{ server_name }};

    location / {
        root /var/www/html;
        index index.nginx-debian.html;
    }
{% if upstream_servers is defined and upstream_servers %}
    location /api {
        proxy_pass http://backend;
    }
}
upstream backend {
{% for host in upstream_servers %}
    server {{ host }};
{% endfor %}
}
{% else %}
}
{% endif %}

The closing } for the server { } block is inside the conditional because when upstream_servers is set we must close the server block before emitting the upstream backend { } block; when it is not set we only need to emit that closing brace. In Nginx, upstream is a top-level directive: it must appear outside any server { } block. The server block then references the upstream by name (e.g., proxy_pass http://backend). So the template is structured to output either server { ... } followed by upstream backend { ... }, or just server { ... }, with braces in the correct places. Do not move the upstream block inside the server block or Nginx will reject the config.

Replace playbook-11.yml with the following so it includes all vars, tasks, and the handlers block at play level (same indentation as tasks):

---
- hosts: all
  become: yes
  vars:
    page_title: My Landing Page
    page_description: This is my landing page description.
    server_name: "{{ ansible_hostname }}.example.com"
    enable_ssl: false
  tasks:
    - name: Install Nginx
      ansible.builtin.apt:
        name: nginx
        state: latest

    - name: Apply Page Template
      ansible.builtin.template:
        src: files/landing-page.html.j2
        dest: /var/www/html/index.nginx-debian.html

    - name: Allow all access to tcp port 80
      community.general.ufw:
        rule: allow
        port: '80'
        proto: tcp

    - name: Deploy Nginx server block config
      ansible.builtin.template:
        src: files/nginx-server-block.conf.j2
        dest: /etc/nginx/conf.d/server-block.conf
      notify: Reload Nginx

  handlers:
    - name: Reload Nginx
      ansible.builtin.service:
        name: nginx
        state: reloaded

Run the playbook:

ansible-playbook -i inventory playbook-11.yml -u sammy -K

Expected output (only the new task and handler may show as changed):

...
TASK [Deploy Nginx server block config] ****************************************
changed: [203.0.113.10]

RUNNING HANDLER [Reload Nginx] ***********************************************
changed: [203.0.113.10]

PLAY RECAP *********************************************************************
203.0.113.10 : ok=5    changed=2    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0

What to expect. The file /etc/nginx/conf.d/server-block.conf contains a single server { } block with server_name set to the host’s name and no SSL or upstream section (because enable_ssl is false and upstream_servers is not set). If you had added a handler but did not use notify, the file would be updated but Nginx would not reload and the new config would not apply until a manual reload or restart.

Verify the generated config:

ansible all -i inventory -u sammy -b -m shell -a "cat /etc/nginx/conf.d/server-block.conf"

Expected output:

# Managed by Ansible - do not edit by hand
server {
    listen 80;
    server_name ubuntu-server.example.com;

    location / {
        root /var/www/html;
        index index.nginx-debian.html;
    }
}

Step 4: Deploying Multiple Templates in a Single Playbook

A single template task can deploy multiple files by looping over a list of source/destination pairs. You add a maintenance page template and deploy it together with the landing page so you see exactly which files are managed and avoid repeating the task.

Create the maintenance template:

nano ~/ansible-practice/files/maintenance.html.j2

Add:

<!doctype html>
<html lang="en">
<head><meta charset="utf-8"><title>{{ maintenance_title }}</title></head>
<body><h1>{{ maintenance_title }}</h1><p>{{ maintenance_message }}</p></body>
</html>

In the playbook you can replace the single “Apply Page Template” task with the loop below, or keep both; the following loop deploys two templates. Ensure page_title and page_description remain in play vars for the landing template. Task-level vars are in scope for every loop iteration, so maintenance_title and maintenance_message below are available when rendering both the landing and the maintenance template; the landing template does not reference them, so there is no collision here. To avoid accidental variable leakage between templates (e.g., a variable you add for one template affecting another), put per-template variables in the loop item dict or in play vars instead of in the task vars block.

When the loop grows beyond two templates or when you need strict variable isolation, put all variables for each template in the loop item. Each template then references {{ item.title }} and {{ item.description }} (or whatever keys you define). With this pattern, each template only has access to the variables in its own loop item, so adding a new template and its variables cannot affect the rendering of any other template in the loop. Example:

    - name: Deploy multiple HTML templates (per-item vars)
      ansible.builtin.template:
        src: "{{ item.src }}"
        dest: "{{ item.dest }}"
      loop:
        - src: files/landing-page.html.j2
          dest: /var/www/html/index.nginx-debian.html
          title: My Landing Page
          description: This is my landing page description.
        - src: files/maintenance.html.j2
          dest: /var/www/html/maintenance.html
          title: Under Maintenance
          description: We will be back shortly.

Templates must use {{ item.title }} and {{ item.description }} (or your chosen keys) so each iteration only sees its own data. The maintenance template shown above uses {{ maintenance_title }} and {{ maintenance_message }}, which match the task-level vars pattern; to use the per-item dict pattern, update that template to reference {{ item.title }} and {{ item.description }} instead so it matches the loop item keys. For the two templates in this step, the simpler task-level vars example below is sufficient; use the per-item pattern when you have more than two templates or need isolation.

    - name: Deploy multiple HTML templates
      ansible.builtin.template:
        src: "{{ item.src }}"
        dest: "{{ item.dest }}"
      loop:
        - { src: files/landing-page.html.j2, dest: /var/www/html/index.nginx-debian.html }
        - { src: files/maintenance.html.j2, dest: /var/www/html/maintenance.html }
      vars:
        maintenance_title: Under Maintenance
        maintenance_message: We will be back shortly.

Run the playbook:

ansible-playbook -i inventory playbook-11.yml -u sammy -K

Expected output:

TASK [Deploy multiple HTML templates] ******************************************
changed: [203.0.113.10] => (item={'src': 'files/landing-page.html.j2', 'dest': '/var/www/html/index.nginx-debian.html'})
changed: [203.0.113.10] => (item={'src': 'files/maintenance.html.j2', 'dest': '/var/www/html/maintenance.html'})

What to expect. Both files exist on the host; the default site still shows the landing page and /maintenance.html shows the maintenance content. If the task fails on one item, the error message will name the template and the variable that is undefined (e.g., if you forgot maintenance_title in the task’s vars).

Verify both files and the maintenance page:

ansible all -i inventory -u sammy -b -m shell -a "ls -la /var/www/html/index.nginx-debian.html /var/www/html/maintenance.html"

Expected output:

-rw-r--r-- 1 root root 312 Oct 15 14:22 /var/www/html/index.nginx-debian.html
-rw-r--r-- 1 root root 156 Oct 15 14:22 /var/www/html/maintenance.html
curl -s http://203.0.113.10/maintenance.html

Expected output: HTML containing “Under Maintenance” and “We will be back shortly.”

Step 5: Organizing Templates in Ansible Roles

Roles package tasks, templates, and handlers so the playbook only sets variables and includes the role. Ansible looks for roles in a roles/ directory next to the playbook (or in paths listed in roles_path). Templates live in the role’s templates/ directory and are referenced by filename only (e.g., src: landing-page.html.j2); the module looks inside that role’s templates/ folder. Variables you set in the play’s vars override the role’s defaults/main.yml when both define the same key.

Create the role layout and copy the templates:

mkdir -p ~/ansible-practice/roles/webserver/{tasks,templates,handlers}
cp ~/ansible-practice/files/landing-page.html.j2 ~/ansible-practice/roles/webserver/templates/
cp ~/ansible-practice/files/nginx-server-block.conf.j2 ~/ansible-practice/roles/webserver/templates/

Create the role tasks:

nano ~/ansible-practice/roles/webserver/tasks/main.yml

Add:

---
- name: Install Nginx
  ansible.builtin.apt:
    name: nginx
    state: latest

- name: Deploy landing page template
  ansible.builtin.template:
    src: landing-page.html.j2
    dest: /var/www/html/index.nginx-debian.html

- name: Deploy Nginx server block
  ansible.builtin.template:
    src: nginx-server-block.conf.j2
    dest: /etc/nginx/conf.d/server-block.conf
  notify: Reload Nginx

- name: Allow tcp port 80
  community.general.ufw:
    rule: allow
    port: '80'
    proto: tcp

Create the handler:

nano ~/ansible-practice/roles/webserver/handlers/main.yml

Add:

---
- name: Reload Nginx
  ansible.builtin.service:
    name: nginx
    state: reloaded

Create a playbook that uses the role:

nano ~/ansible-practice/playbook-webserver.yml

Add:

---
- hosts: all
  become: yes
  vars:
    page_title: My Landing Page
    page_description: This is my landing page description.
    server_name: "{{ ansible_hostname }}.example.com"
    enable_ssl: false
  roles:
    - webserver

Run the playbook:

ansible-playbook -i inventory playbook-webserver.yml -u sammy -K

Expected output:

PLAY [all] *********************************************************************

TASK [Gathering Facts] *********************************************************
ok: [203.0.113.10]

TASK [webserver : Install Nginx] ***********************************************
ok: [203.0.113.10]

TASK [webserver : Deploy landing page template] *******************************
ok: [203.0.113.10]

TASK [webserver : Deploy Nginx server block] **********************************
ok: [203.0.113.10]

TASK [webserver : Allow tcp port 80] *****************************************
ok: [203.0.113.10]

PLAY RECAP *********************************************************************
203.0.113.10 : ok=5    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0

What to expect. The role runs all tasks; if you already applied the same config in earlier steps, most tasks show ok. Templates are now owned by the role; to change defaults per environment, add roles/webserver/defaults/main.yml or override vars in the play or in host/group vars.

Verify the service and page:

ansible all -i inventory -u sammy -b -m shell -a "systemctl is-active nginx"
curl -s http://203.0.113.10/ | grep -o '<title>.*</title>'

Expected: active and <title>My Landing Page</title> (or whatever you set for page_title).

Troubleshooting Common Template Errors

Follow each scenario in order: match the error message, run the command or condition that reproduces it, apply the fix, then run the verification to confirm.

Undefined variable.

Error you see:

fatal: [203.0.113.10]: FAILED! => {"msg": "The task includes an option with an undefined variable. The error was: 'page_title' is undefined..."}

Cause: the template contains {{ page_title }} (or another variable) but that variable is not defined for the host (not in play vars, not in group/host vars, not set by set_fact).

Fix: define the variable in the play’s vars or in group/host vars. For optional content, use a default in the template so the playbook does not depend on the variable being set:

{{ page_title | default('Home') }}

Verification: run the playbook again; the template task should succeed. Confirm the rendered file on the host contains the variable’s value (e.g., the page title). This command returns the title tag and fails if the file is missing or the variable was not rendered:

ansible all -i inventory -u sammy -b -m shell -a "grep -o '<title>.*</title>' /var/www/html/index.nginx-debian.html"

Expected output: <title>My Landing Page</title> (or whatever you set for page_title). If the variable was still undefined, the template task would have failed before writing; this check confirms the fix by showing the rendered value.

Template task succeeds but the service ignores the new config.

Symptom: the template task reports changed, but the running service (e.g., Nginx) still uses the old config or serves old content.

Cause: the template module only writes the file; it does not reload or restart the service. Nginx (and most daemons) read config at startup or when sent a reload signal.

Fix: add a handler that reloads or restarts the service and trigger it from the template task with notify: Reload Nginx (or the handler name). Ensure the handler runs (handlers run at the end of the play; if the task is not reported as changed, the handler will not run).

Verification: change a variable that affects the rendered config (e.g., server_name), run the playbook, then check that the running process is using the new config:

ansible all -i inventory -u sammy -b -m shell -a "nginx -t && systemctl reload nginx"
curl -s -I http://203.0.113.10/

Jinja2 syntax error (unclosed block or invalid delimiter).

Error you see:

fatal: [203.0.113.10]: FAILED! => {"msg": "template error while templating string: expected token 'end of statement block', got 'endif'. ..."}

Cause: a typo or missing delimiter in the template, e.g., {% if %} without {% endif %}, or {{ without }}.

Fix: open the template and ensure every {% if %} has {% endif %}, every {% for %} has {% endfor %}, and every {{ has a matching }}. The error message usually includes a line number or snippet.

Verification: run the playbook again; the template task should succeed. Optionally run with --check --diff to see the rendered result without writing:

ansible-playbook -i inventory playbook-11.yml -u sammy -K --check --diff

Rendered file has wrong content or is empty.

Symptom: the file on the host does not match what you expect, or key sections are missing.

Cause: a variable is empty or undefined and you did not use default, or a conditional is false so a block was omitted. Running without checking the diff makes it easy to miss.

Fix: add | default('') or | default(false) for optional variables. For conditionals, ensure the variable is set and has the expected type (e.g., use | bool for flags). Run with --check --diff to see the exact diff that would be applied:

ansible-playbook -i inventory playbook-11.yml -u sammy -K --check --diff

Verification: inspect the rendered file on the host and confirm the values:

ansible all -i inventory -u sammy -b -m shell -a "cat /etc/nginx/conf.d/server-block.conf"

Permission or ownership problem (service cannot read the file).

Symptom: the template task succeeds but the service fails to start or logs “permission denied” or “cannot open file”.

Cause: with become: yes, the template module writes the file as root. If the service runs as a different user (e.g., www-data), it may not be able to read the file depending on permissions, or you may need a specific owner for the path.

Fix: set owner and group on the template task to the user the service runs as, or add a separate task that sets ownership after the template is applied:

- name: Deploy Nginx server block
  ansible.builtin.template:
    src: nginx-server-block.conf.j2
    dest: /etc/nginx/conf.d/server-block.conf
    owner: root
    group: root
    mode: '0644'
  notify: Reload Nginx

Verification: confirm ownership and that the service starts:

ansible all -i inventory -u sammy -b -m shell -a "ls -la /etc/nginx/conf.d/server-block.conf && systemctl is-active nginx"

Best Practices for Ansible Templates

Ordered by operational impact: the first practice prevents the most common failure after following this tutorial.

Use the template module when content must vary per host or environment; use the copy module when the file is identical everywhere. The template module processes Jinja2 and writes the rendered text to the host; it is for text files only. The copy module copies the file as-is and does not process {{ }} or {% %}. If you have no variables in the file, copy is simpler and avoids accidental interpretation of curly braces as Jinja2.

Use defaults for every optional variable in the template. If a variable might not be set for some hosts or groups, the playbook will fail with an undefined variable error as soon as it runs on a host where that variable is missing. Adding | default('') or | default(false) | bool in the template makes the playbook robust across inventories and environments. Without it, adding a new host or group often breaks the run.

Trigger a handler to reload or restart the service after templating a config file. The template module only writes the file; it does not signal the service. If you forget notify, the rendered config will not be used until the next manual reload or reboot. The most common “it didn’t work” report after a template change is missing or unrun handlers.

Run with --check --diff before applying to production. You see the exact content that would be written and can spot wrong or empty variables and broken conditionals without changing the host. Without this, mistakes show up only after the play has run and may require debugging on the server.

Keep templates in the role’s templates/ directory and reference them by name. That keeps the role self-contained and reusable; playbooks stay short and only pass variables. Scattering templates in ad-hoc paths or duplicating them per environment makes it unclear what is managed by Ansible and increases drift.

Use one template per logical config file and avoid complex logic inside it. Move list building and heavy logic into playbook tasks (set_fact, vars files) and pass simple variables into the template. Complex Jinja2 in the template is hard to test and debug; the official docs and linters are geared toward simple expressions.

Store templates in version control with the playbooks and roles that use them. Commit messages that describe what changed in the template make it easier to trace config changes and roll back. If you need environment-specific behavior, prefer variables and conditionals in one template over separate template files per environment.

FAQ

What is a template in Ansible?

A template is a text file (usually .j2) that mixes static text with Jinja2 expressions (variables, filters, conditionals, loops). The template module renders it on the control node using play variables and facts, then writes the resulting text to a path on the target host. Use it when the content must differ per host or environment. For a file that is identical on every host and has no variables, use the copy module instead; copy does not process Jinja2, so {{ }} would be copied literally. Template is for text only; for binary files use copy.

How do I use Jinja2 variables in Ansible templates?

Put the variable in double curly braces: {{ variable_name }}. The variable must be defined somewhere the play can see: play vars, host_vars, group_vars, or set_fact. To give different values per host, put the variable in host_vars/<hostname>.yml or group_vars/<group>.yml; those override play vars for that host or group. For optional values, add a default so the playbook does not fail when the variable is unset:

{{ my_var | default('fallback') }}

Can I use loops inside Ansible templates?

Yes. Use Jinja2 {% for item in list %} ... {% endfor %} in the template to repeat a block; the list comes from a playbook variable. You can also loop in the playbook: run the template module in a loop over a list of src/dest pairs to deploy multiple files in one task.

How do I debug template rendering errors in Ansible?

Run with --check --diff first: you see the exact content that would be written without changing the host, so you can spot wrong or empty variables and broken conditionals. For undefined variable errors, define the variable or use | default(...) in the template. For syntax errors, check that every {% if %} has {% endif %} and every {% for %} has {% endfor %}; the error message usually points to the line. Use -vvv for more detail:

ansible-playbook -i inventory playbook-11.yml -u sammy -K --check --diff -vvv

What are best practices for organizing Ansible templates?

Put templates that belong to a role in that role’s templates/ directory and reference them by filename (e.g., src: landing-page.html.j2). Ansible resolves that path relative to the role’s templates/ folder. Use a consistent suffix like .j2 so editors and linters recognize Jinja2. Keep one template per logical config file and drive differences with variables and conditionals instead of duplicate templates. Store templates in the same repo as the playbooks and roles. For different values per host, use host_vars or group_vars rather than separate template files per environment.

Conclusion

You built an Nginx landing page and server block from Jinja2 templates, applied them with the template module and a loop, and moved the setup into a role. That gives you one place to change config and content per host or environment.

Further Reading

To deepen your understanding of Ansible, templates, and playbook organization, check out the following DigitalOcean tutorials and documentation:

Thanks for learning with the DigitalOcean Community. Check out our offerings for compute, storage, networking, and managed databases.

Learn more about our products

Tutorial Series: How To Write Ansible Playbooks

Ansible is a modern configuration management tool that doesn’t require the use of an agent software on remote nodes, using only SSH and Python to communicate and execute commands on managed servers. This series will walk you through the main Ansible features that you can use to write playbooks for server automation. At the end, we’ll see a practical example of how to create a playbook to automate setting up a remote Nginx web server and deploy a static HTML website to it.

About the author(s)

Erika Heidi
Erika Heidi
Author
Developer Advocate
See author profile

Dev/Ops passionate about open source, PHP, and Linux. Former Senior Technical Writer at DigitalOcean. Areas of expertise include LAMP Stack, Ubuntu, Debian 11, Linux, Ansible, and more.

Vinayak Baranwal
Vinayak Baranwal
Editor
Technical Writer II
See author profile

Building future-ready infrastructure with Linux, Cloud, and DevOps. Full Stack Developer & System Administrator. Technical Writer @ DigitalOcean | GitHub Contributor | Passionate about Docker, PostgreSQL, and Open Source | Exploring NLP & AI-TensorFlow | Nailed over 50+ deployments across production environments.

Still looking for an answer?

Was this helpful?


This textbox defaults to using Markdown to format your answer.

You can type !ref in this text area to quickly search our full set of tutorials, documentation & marketplace offerings and insert the link!

Creative CommonsThis work is licensed under a Creative Commons Attribution-NonCommercial- ShareAlike 4.0 International License.
Join the Tech Talk
Success! Thank you! Please check your email for further details.

Please complete your information!

The developer cloud

Scale up as you grow — whether you're running one virtual machine or ten thousand.

Get started for free

Sign up and get $200 in credit for your first 60 days with DigitalOcean.*

*This promotional offer applies to new accounts only.