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.
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
- 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
Split hosts into environments
Groups can contain groups with
:children. Nowweb1is inwebanddev;web2is inwebandprod, 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
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
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
Turn the config into a template
A loop renders one
serverline per upstream; facts size the worker count; a comment header warns humans not to hand-edit.ansible_managedis 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
Use the template, a loop, a condition, and register
templatereplacescopy.loopcreates one user per list item.whenskips the task entirely when the list is empty (dev).registersaves a task's result into a variable, anddebugprints 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
Run it and compare the two environments
Same playbook, different results per group.
ansible-inventory --hostshows 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 --diffansible-inventory --host web2 | grep -A3 upstreamsdocker 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.
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.
Part 5
Interview questions from this mission
What are Ansible facts and when would you disable fact gathering?
group_vars vs host_vars vs extra vars: which wins?