Variables, loops and conditionals are what turn a fixed list of tasks into a playbook that adapts to each server. Variables hold the values that differ between hosts and environments, loops repeat a task over a list without copying it, and conditionals decide whether a task should run at all. In this tutorial you will work through each concept with small playbooks you can run against Ubuntu 24.04 servers, and finish with a playbook that combines all three to manage users and kernel settings.
Prerequisites
To follow this guide you need:
- A control node with Ansible installed and SSH key access to your servers, as described in the guide How to Install and Use Ansible on Ubuntu 24.04.
- One or two managed servers running Ubuntu 24.04, for example CubePath VPS instances. This guide uses
web1(web1_server_ip) andweb2(web2_server_ip). - A non-root user with
sudoprivileges on the servers (your_user). - Basic familiarity with YAML and with running
ansible-playbook.
Most examples only print values with the debug module, so they are safe to run on any server. Step 8 makes real changes.
Step 1 - Setting up the practice project
Create a project directory with the folders Ansible reads variables from:
mkdir -p ~/ansible-vars/{group_vars,host_vars}
cd ~/ansible-vars
Create the inventory:
nano inventory.ini
[webservers]
web1 ansible_host=web1_server_ip
web2 ansible_host=web2_server_ip
[all:vars]
ansible_user=your_user
And a minimal ansible.cfg so you do not have to pass the inventory every time:
nano ansible.cfg
[defaults]
inventory = inventory.ini
interpreter_python = auto_silent
Check that both hosts respond:
ansible all -m ping
Step 2 - Defining variables in playbooks and inventory files
Variable names may contain letters, numbers and underscores, and must start with a letter or underscore. app_port and nginx_worker_connections are valid; app-port and 2fa_enabled are not.
The simplest place to define a variable is the vars section of a play. Create a playbook that prints a few:
nano vars-basics.yml
---
- name: Variable basics
hosts: webservers
gather_facts: false
vars:
app_name: shop
app_port: 8080
tasks:
- name: Show the application settings
ansible.builtin.debug:
msg: "{{ app_name }} listens on port {{ app_port }} on {{ inventory_hostname }}"
Values are inserted with Jinja2 {{ }} expressions. When a value starts with {{, the whole string must be quoted, or YAML will try to parse it as a dictionary.
Run it:
ansible-playbook vars-basics.yml
TASK [Show the application settings] *******************************************
ok: [web1] => {
"msg": "shop listens on port 8080 on web1"
}
ok: [web2] => {
"msg": "shop listens on port 8080 on web2"
}
Values that belong to the infrastructure rather than to a playbook go in inventory variable files. Ansible automatically loads group_vars/<group>.yml for every host in a group and host_vars/<host>.yml for a single host.
Move the values into group variables:
nano group_vars/webservers.yml
---
app_name: shop
app_port: 8080
Then override the port for web2 only:
nano host_vars/web2.yml
---
app_port: 9090
Remove the vars: block (the vars: line and its two values) from vars-basics.yml, because play variables would take precedence over the inventory files. Run the playbook again:
ansible-playbook vars-basics.yml
ok: [web1] => {
"msg": "shop listens on port 8080 on web1"
}
ok: [web2] => {
"msg": "shop listens on port 9090 on web2"
}
The host variable wins over the group variable, which is exactly what you want for per-server exceptions.
Step 3 - Understanding variable precedence
When the same variable is defined in several places, Ansible uses the one with the highest precedence. The full list has more than 20 levels, but these are the ones you will meet most often, from lowest to highest:
| Precedence | Source |
|---|---|
| Lowest | Role defaults (roles/x/defaults/main.yml) |
group_vars/all | |
group_vars/<group> | |
host_vars/<host> | |
| Gathered facts | |
Play vars, vars_files | |
Role vars (roles/x/vars/main.yml) | |
Task vars | |
register and set_fact | |
| Highest | Extra vars (-e on the command line) |
Extra variables always win, which makes them useful for one-off overrides. Try it:
ansible-playbook vars-basics.yml -e app_port=7000
ok: [web1] => {
"msg": "shop listens on port 7000 on web1"
}
ok: [web2] => {
"msg": "shop listens on port 7000 on web2"
}
A practical rule: put sensible defaults low (role defaults or group_vars/all), environment and group differences in group_vars, exceptions in host_vars, and avoid defining the same variable in the playbook as well.
Values passed with -e key=value are strings. If you pass a boolean such as -e debug_mode=false, convert it in your conditions with the bool filter (debug_mode | bool), otherwise the non-empty string "false" counts as true.
Step 4 - Working with lists and dictionaries
Variables can hold lists and dictionaries, and these are what loops iterate over. Add structured data to the group variables:
nano group_vars/webservers.yml
---
app_name: shop
app_port: 8080
app_packages:
- nginx
- git
app_database:
host: 10.0.0.10
name: shop
port: 3306
Access items with brackets or dots. Brackets work for every key, including keys that contain dashes or collide with Python method names, so prefer them:
nano vars-structures.yml
---
- name: Lists and dictionaries
hosts: web1
gather_facts: false
tasks:
- name: Show the first package and the database host
ansible.builtin.debug:
msg: "First package: {{ app_packages[0] }}, database: {{ app_database['host'] }}:{{ app_database['port'] }}"
- name: Use a default for a variable that is not defined
ansible.builtin.debug:
msg: "Log level is {{ app_log_level | default('info') }}"
- name: Join a list into a string
ansible.builtin.debug:
msg: "Packages: {{ app_packages | join(', ') }}"
ansible-playbook vars-structures.yml
ok: [web1] => {
"msg": "First package: nginx, database: 10.0.0.10:3306"
}
ok: [web1] => {
"msg": "Log level is info"
}
ok: [web1] => {
"msg": "Packages: nginx, git"
}
The default filter avoids an undefined variable error when a value is optional.
Step 5 - Capturing results with register and set_fact
Every task returns data. register saves that data in a variable, so later tasks can use it. set_fact creates a new variable from an expression at runtime, for example from gathered facts.
nano vars-register.yml
---
- name: Register and set_fact
hosts: webservers
tasks:
- name: Check whether the Nginx configuration exists
ansible.builtin.stat:
path: /etc/nginx/nginx.conf
register: nginx_conf
- name: Show the registered result
ansible.builtin.debug:
msg: "nginx.conf exists: {{ nginx_conf.stat.exists }}"
- name: Calculate worker connections from the CPU count
ansible.builtin.set_fact:
nginx_worker_connections: "{{ ansible_facts['processor_vcpus'] * 1024 }}"
- name: Show the calculated value
ansible.builtin.debug:
var: nginx_worker_connections
This play gathers facts (the default), so ansible_facts contains the host's hardware and OS details. debug with var: prints a variable's value without writing a message.
ansible-playbook vars-register.yml
TASK [Show the registered result] **********************************************
ok: [web1] => {
"msg": "nginx.conf exists: False"
}
...
TASK [Show the calculated value] ***********************************************
ok: [web1] => {
"nginx_worker_connections": 2048
}
To see everything a module returns, print the whole registered variable with debug: var: nginx_conf. That is the fastest way to find the key you need for a condition.
Step 6 - Repeating tasks with loops
loop runs a task once per item, and the current item is available as item. Use it whenever you would otherwise copy a task several times.
Many modules accept a list directly, which is faster than a loop because it runs in a single call. For example, ansible.builtin.apt takes a list in name, so install packages like this rather than looping:
- name: Install application packages
ansible.builtin.apt:
name: "{{ app_packages }}"
state: present
Loops are the right tool when each item needs different parameters. Here is a playbook that loops over a list of dictionaries, over a dictionary, and retries a task until it succeeds:
nano loops.yml
---
- name: Loop examples
hosts: web1
gather_facts: false
vars:
app_users:
- name: deploy
shell: /bin/bash
- name: backup
shell: /usr/sbin/nologin
sysctl_settings:
vm.swappiness: 10
net.core.somaxconn: 1024
tasks:
- name: Loop over a list of dictionaries
ansible.builtin.debug:
msg: "User {{ item['name'] }} uses {{ item['shell'] }}"
loop: "{{ app_users }}"
loop_control:
label: "{{ item['name'] }}"
- name: Loop over a dictionary
ansible.builtin.debug:
msg: "{{ item.key }} = {{ item.value }}"
loop: "{{ sysctl_settings | dict2items }}"
- name: Number the items
ansible.builtin.debug:
msg: "{{ idx + 1 }}. {{ item }}"
loop:
- first
- second
loop_control:
index_var: idx
- name: Retry until a URL answers
ansible.builtin.uri:
url: https://www.ubuntu.com/
register: result
until: result.status == 200
retries: 5
delay: 5
What each loop shows:
loop_control.labelcontrols what Ansible prints for each item. Without it, the whole dictionary is printed on every iteration.dict2itemsturns a dictionary into a list of{key, value}pairs so it can be looped over.index_varexposes the item's position (starting at 0).until,retriesanddelayrepeat a single task, not a list, until the condition is true. This is how you wait for a service to come up.
ansible-playbook loops.yml
TASK [Loop over a list of dictionaries] ****************************************
ok: [web1] => (item=deploy) => {
"msg": "User deploy uses /bin/bash"
}
ok: [web1] => (item=backup) => {
"msg": "User backup uses /usr/sbin/nologin"
}
TASK [Loop over a dictionary] **************************************************
ok: [web1] => (item={'key': 'vm.swappiness', 'value': 10}) => {
"msg": "vm.swappiness = 10"
}
...
Step 7 - Running tasks conditionally with when
when takes a Jinja2 expression without {{ }} and runs the task only if it is true. Skipped tasks are shown as skipping.
nano conditionals.yml
---
- name: Conditional examples
hosts: webservers
tasks:
- name: Run only on Ubuntu 24.04 or newer
ansible.builtin.debug:
msg: "This is {{ ansible_facts['distribution'] }} {{ ansible_facts['distribution_version'] }}"
when:
- ansible_facts['distribution'] == 'Ubuntu'
- ansible_facts['distribution_major_version'] | int >= 24
- name: Run only on hosts with at least 2 GB of RAM
ansible.builtin.debug:
msg: "{{ inventory_hostname }} has {{ ansible_facts['memtotal_mb'] }} MB of RAM"
when: ansible_facts['memtotal_mb'] >= 2048
- name: Check whether Nginx is installed
ansible.builtin.stat:
path: /usr/sbin/nginx
register: nginx_binary
- name: Run only where Nginx is missing
ansible.builtin.debug:
msg: "Nginx is not installed on {{ inventory_hostname }}"
when: not nginx_binary.stat.exists
- name: Run only when a variable is defined
ansible.builtin.debug:
msg: "Maintenance window: {{ maintenance_window }}"
when: maintenance_window is defined
- A list under
whenmeans all conditions must be true (logical AND). Useorinside a single expression for alternatives. - Facts are strings or numbers depending on the fact.
distribution_major_versionis a string, so it is converted with| intbefore comparing. is definedandis not definedtest whether a variable exists, which avoids errors on optional values.- Facts are read from the
ansible_factsdictionary, which is the recommended way to access them.
ansible-playbook conditionals.yml -e maintenance_window="Sunday 03:00"
TASK [Run only on hosts with at least 2 GB of RAM] *****************************
ok: [web1] => {
"msg": "web1 has 3915 MB of RAM"
}
skipping: [web2]
The exact result depends on your servers. When when is combined with loop, the condition is evaluated for each item separately, as the next step shows.
Step 8 - Combining variables, loops and conditionals
This final playbook applies what you learned to a real task: it creates or removes system users defined in a variable, and applies kernel settings from a dictionary, with a per-host exception.
Add the data to the group variables:
nano group_vars/webservers.yml
---
app_name: shop
app_port: 8080
managed_users:
- name: deploy
groups: [www-data]
state: present
- name: olduser
state: absent
sysctl_settings:
vm.swappiness: 10
net.core.somaxconn: 1024
enable_sysctl_tuning: true
Disable the tuning on web2 by adding one line to its host variables:
nano host_vars/web2.yml
---
app_port: 9090
enable_sysctl_tuning: false
Create the playbook:
nano manage.yml
---
- name: Manage users and kernel settings
hosts: webservers
become: true
tasks:
- name: Create users that should exist
ansible.builtin.user:
name: "{{ item['name'] }}"
groups: "{{ item['groups'] | default([]) }}"
append: true
shell: /bin/bash
state: present
loop: "{{ managed_users }}"
loop_control:
label: "{{ item['name'] }}"
when: item['state'] == 'present'
- name: Remove users that should not exist
ansible.builtin.user:
name: "{{ item['name'] }}"
state: absent
remove: true
loop: "{{ managed_users }}"
loop_control:
label: "{{ item['name'] }}"
when: item['state'] == 'absent'
- name: Apply kernel settings
ansible.posix.sysctl:
name: "{{ item.key }}"
value: "{{ item.value }}"
sysctl_file: /etc/sysctl.d/90-ansible.conf
reload: true
loop: "{{ sysctl_settings | dict2items }}"
when: enable_sysctl_tuning | bool
Run it and enter your sudo password when asked:
ansible-playbook manage.yml -K
TASK [Create users that should exist] ******************************************
skipping: [web1] => (item=olduser)
changed: [web1] => (item=deploy)
skipping: [web2] => (item=olduser)
changed: [web2] => (item=deploy)
TASK [Remove users that should not exist] **************************************
skipping: [web1] => (item=deploy)
ok: [web1] => (item=olduser)
...
TASK [Apply kernel settings] ***************************************************
changed: [web1] => (item={'key': 'vm.swappiness', 'value': 10})
changed: [web1] => (item={'key': 'net.core.somaxconn', 'value': 1024})
skipping: [web2] => (item={'key': 'vm.swappiness', 'value': 10})
skipping: [web2] => (item={'key': 'net.core.somaxconn', 'value': 1024})
Verify the result on web1:
ansible web1 -m ansible.builtin.command -a "id deploy"
ansible web1 -m ansible.builtin.command -a "sysctl vm.swappiness"
web1 | CHANGED | rc=0 >>
uid=1001(deploy) gid=1001(deploy) groups=1001(deploy),33(www-data)
web1 | CHANGED | rc=0 >>
vm.swappiness = 10
On web2, sysctl vm.swappiness still shows the Ubuntu default of 60. To add a user or a setting later, you only edit the variables; the playbook stays the same.
Troubleshooting
'app_port' is undefined: the variable is not defined for that host. Check which files apply with ansible-inventory --host web1, and use the default filter for optional values.
YAML syntax errors on a line that uses {{ }}: a value that begins with {{ must be quoted, for example msg: "{{ app_name }}".
A condition is always true: the variable is probably a string. "false" from -e or "no" from an INI inventory is a non-empty string; use my_var | bool in the condition.
Loop items print as long dictionaries: add loop_control with a label to show only the field you care about.
To inspect values while developing, add -v to see task results, or use ansible -m ansible.builtin.debug -a "var=hostvars[inventory_hostname]" web1 to print every variable Ansible knows for a host.
Conclusion
You defined variables in plays, group_vars and host_vars, saw how precedence decides which value wins, captured task results with register and set_fact, repeated tasks with loop, and skipped them with when. Combining the three lets one playbook manage many servers while all differences live in variable files.
As next steps, move these tasks into a role with its defaults in defaults/main.yml, keep secrets in files encrypted with ansible-vault, and use templates to render configuration files from the same variables.
