Command Palette

Search for a command to run...

Hectal

Mission 0.3 · Stage 0 — Configuration Management with Ansible

Variables, Facts, and Jinja2 Templates

Goal: One playbook that configures dev and prod differently, with per-group variables and a templated Nginx config.

40 min Free 6 steps 2 break-it drills

By the end of this mission

  • Define variables in group_vars/host_vars and know which one wins
  • Use gathered facts (OS, memory, IPs) in tasks and templates
  • Render config files with Jinja2 loops and conditionals
  • Use when, loop, and register to control tasks

Part 1

Understand it first

Where variables come from

The same playbook should configure dev and prod; only the values differ. Put them in group_vars/<group>.yml (applies to every host in that group), group_vars/all.yml (defaults for everyone), and host_vars/<host>.yml (one machine). It's the same idea as Terraform's per-environment setup (Terraform course, Stage 6).

Ansible has a long PRECEDENCE list (over 20 levels). The practical rule: role defaults are weakest, then group_vars/all, then specific groups, then host_vars, then play vars, and -e extra vars on the command line beat everything. Keep each variable defined in ONE place and you rarely need the full list.

Facts

At the start of each play, Ansible runs the setup module on every host and collects FACTS: OS family, CPU count, memory, IP addresses, mounts. They're available as ansible_facts.*. Facts let one playbook adapt (use apt on Debian, dnf on RHEL; size worker processes to CPU count). Turn gathering off (gather_facts: false) for speed when you don't need them.

Jinja2 templates

The template module renders a .j2 file with Jinja2: {{ var }} inserts values, {% for %}/{% if %} add logic, and filters transform values (| default(80), | join(','), | to_nice_yaml). Keep logic in templates light; if a template needs heavy logic, the data model is usually wrong.

Part 2

Your project after this mission · 6 files change

shoplite-gitops/
  • ansible/
    • files/
      • shoplite.confdeleted
    • group_vars/
      • all.ymlnew
      • prod.ymlnew
    • templates/
      • shoplite.conf.j2new
    • inventory.inimodified
    • site.ymlmodified

Part 3

Build it, step by step

  1. 1

    Split hosts into environments

    Groups can contain groups with :children. Now web1 is in web and dev; web2 is in web and prod, and each picks up its environment's variables.

    ansible/inventory.iniwhole fileini
    [dev]
    web1
    
    [prod]
    web2
    
    [web:children]
    dev
    prod
    
    [web:vars]
    ansible_connection=community.docker.docker
  2. 2

    Defaults for everyone, overrides for prod

    Group files are named after the group. Prod gets two upstream app servers and a stricter body size; dev inherits the defaults.

    ansible/group_vars/all.ymlwhole fileyaml
    env: dev
    listen_port: 80
    client_max_body_size: 10m
    upstreams:
      - 127.0.0.1:8080
    admin_users: []
  3. 3

    Prod overrides

    Only the values that differ. Everything else still comes from all.yml.

    ansible/group_vars/prod.ymlwhole fileyaml
    env: prod
    client_max_body_size: 2m
    upstreams:
      - 10.20.10.21:8080
      - 10.20.10.22:8080
    admin_users:
      - amira
      - raj
  4. 4

    Turn the config into a template

    A loop renders one server line per upstream; facts size the worker count; a comment header warns humans not to hand-edit. ansible_managed is a built-in string for exactly that.

    ansible/templates/shoplite.conf.j2whole filejinja
    # {{ ansible_managed }}  (env: {{ env }})
    upstream shoplite_app {
    {% for u in upstreams %}
        server {{ u }};
    {% endfor %}
    }
    
    server {
        listen {{ listen_port }} default_server;
        client_max_body_size {{ client_max_body_size }};
        location /healthz { return 200 "ok {{ inventory_hostname }}\n"; }
        location / {
            proxy_pass http://shoplite_app;
            proxy_set_header Host $host;
        }
    }
  5. 5

    Use the template, a loop, a condition, and register

    template replaces copy. loop creates one user per list item. when skips the task entirely when the list is empty (dev). register saves a task's result into a variable, and debug prints it, which is the usual way to inspect what a module returned.

    ansible/site.ymlwhole fileyaml
    - name: Configure ShopLite web servers
      hosts: web
      become: true
    
      tasks:
        - name: Install nginx
          ansible.builtin.apt: { name: nginx, state: present, update_cache: true, cache_valid_time: 3600 }
    
        - name: Render the ShopLite site
          ansible.builtin.template:
            src: templates/shoplite.conf.j2
            dest: /etc/nginx/sites-available/default
            mode: "0644"
          notify: Reload nginx
    
        - name: Create admin users
          ansible.builtin.user:
            name: "{{ item }}"
            groups: sudo
            append: true
            shell: /bin/bash
          loop: "{{ admin_users }}"
          when: admin_users | length > 0
    
        - name: Check nginx version
          ansible.builtin.command: nginx -v
          register: nginx_version
          changed_when: false
    
        - name: Show what we're running
          ansible.builtin.debug:
            msg: "{{ inventory_hostname }} ({{ env }}, {{ ansible_facts.processor_vcpus }} vCPU) → {{ nginx_version.stderr }}"
    
        - name: Ensure nginx is running
          ansible.builtin.service: { name: nginx, state: started }
    
      handlers:
        - name: Reload nginx
          ansible.builtin.service: { name: nginx, state: reloaded }
  6. 6

    Run it and compare the two environments

    Same playbook, different results per group. ansible-inventory --host shows the final merged variables for a host, which is the fastest way to debug 'why does prod have this value?'.

    terminal
    $ ansible-playbook site.yml --diff
    ansible-inventory --host web2 | grep -A3 upstreams
    docker exec web2 grep server /etc/nginx/sites-available/default
    ── expected output ──
    TASK [Create admin users] ***
    skipping: [web1] => (item=...)
    changed: [web2] => (item=amira)
    changed: [web2] => (item=raj)
     
    TASK [Show what we're running] ***
    ok: [web1] => { "msg": "web1 (dev, 4 vCPU) → nginx version: nginx/1.24.0 (Ubuntu)" }
    ok: [web2] => { "msg": "web2 (prod, 4 vCPU) → nginx version: nginx/1.24.0 (Ubuntu)" }
    ...
    "upstreams": [
    "10.20.10.21:8080",
    "10.20.10.22:8080"
    server 10.20.10.21:8080;
    server 10.20.10.22:8080;

Checkpoint — you should now have

  • ✓web1's config has one upstream; web2's has two.
  • ✓Admin users exist only on prod.
  • ✓ansible-inventory --host <host> shows the merged variables you expect.

Part 4

Break it on purpose

Make each change, run the command, and read the error before revealing the diagnosis. Recognising these messages on sight is what makes you fast on a real team. Undo the change afterwards.

Break #1

Reference an undefined variable

In the template, change {{ client_max_body_size }} to {{ client_max_body }} and run.

terminal
$ ansible-playbook site.yml
── what you'll see ──
TASK [Render the ShopLite site] ***
fatal: [web1]: FAILED! => {"msg": "'client_max_body' is undefined"}

Break #2

Define the same variable in two places

Add client_max_body_size: 50m to host_vars/web2.yml and run with -e client_max_body_size=1m.

terminal
$ ansible-playbook site.yml -e client_max_body_size=1m --diff --limit web2
── what you'll see ──
- client_max_body_size 2m;
+ client_max_body_size 1m;

Part 5

Interview questions from this mission

01

What are Ansible facts and when would you disable fact gathering?

02

group_vars vs host_vars vs extra vars: which wins?

0/4 · 0%