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) and web2 (web2_server_ip).
  • A non-root user with sudo privileges 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:

PrecedenceSource
LowestRole 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
HighestExtra 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.label controls what Ansible prints for each item. Without it, the whole dictionary is printed on every iteration.
  • dict2items turns a dictionary into a list of {key, value} pairs so it can be looped over.
  • index_var exposes the item's position (starting at 0).
  • until, retries and delay repeat 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 when means all conditions must be true (logical AND). Use or inside a single expression for alternatives.
  • Facts are strings or numbers depending on the fact. distribution_major_version is a string, so it is converted with | int before comparing.
  • is defined and is not defined test whether a variable exists, which avoids errors on optional values.
  • Facts are read from the ansible_facts dictionary, 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.