Learn variable management in Ansible from how to create them, the precedence hierarchy, group_vars and host_vars best practices, to leveraging ansible facts to build dynamic and efficient playbooks.

After episode 6, where we covered handlers — the mechanism that ensures a service only restarts when its configuration changes. You now have playbooks that are idempotent and production-safe. But let's be honest: the playbooks from previous episodes are still hardcoded. Port numbers, user names, directory paths — everything is written directly inside the file. Imagine having to manage 50 servers with different configurations; rewriting a playbook for every server certainly isn't a sensible option.
In episode 7, we'll cover two foundations that will fundamentally change how you write playbooks: Variables and Facts.
Variables let us separate data (port values, server names, etc.) from logic (the instructions being executed). This is the same principle as separation of concerns in programming: you don't write magic numbers directly into the code, but in separate configuration.
Meanwhile, Facts are data that Ansible collects from every managed node automatically, from OS family, CPU architecture, total RAM, to IP addresses. Think of facts as the biodata of every server. With facts, one identical playbook can behave differently on Ubuntu and Rocky Linux without writing a separate playbook for each OS.
This material isn't just theory. Understanding variable precedence and facts will prevent subtle bugs that are very hard to trace in production, like variables that "refuse to change" even after their value has been replaced.
A variable is a value holder that can be filled with any data: strings, numbers, booleans, lists, or dictionaries. In Ansible, variables are written in the typical YAML key: value format and called inside playbooks using the templating syntax {{ nama_variable }}.
The simplest analogy: a playbook is a recipe, and variables are the ingredients. The recipe stays the same, but if we swap the ingredients (say, sugar for stevia), the result changes. Same with playbooks: the task logic stays, but the values can be swapped per environment (development, staging, production).
The most commonly used variable call is the ansible.builtin.debug module to print a variable's value:
---
- name: Menampilkan nilai variable
hosts: localhost
gather_facts: false
vars:
app_name: blog
app_port: 8080
tasks:
- name: Tampilkan kombinasi variable
ansible.builtin.debug:
msg: "Aplikasi {{ app_name }} berjalan di port {{ app_port }}"The output looks roughly like this:
TASK [Tampilkan kombinasi variable] *******************************************
ok: [localhost] => {
"msg": "Aplikasi blog berjalan di port 8080"
}Notice variables are called with {{ ... }}. This is the expression syntax of Jinja2, the templating engine we'll cover in depth in episode 8. For this episode, just remember: anything inside {{ }} is evaluated and its result is inserted into the string.
Note
There are four basic Jinja2 syntaxes you'll encounter: {{ variable }} (expression, produces a value), {% if %} (statement, for logic), {# #} (comment, not rendered), and {% for %} (looping). In episode 7, our focus is {{ variable }}.
Ansible offers many ways to define variables. Let's go through them one by one from the most commonly used, starting with the broadest scope down to the most specific.
The simplest way is to define variables directly in the play using the vars: keyword. These variables apply to every task in that play.
---
- name: Install aplikasi dengan variable play
hosts: webservers
become: true
vars:
app_user: devops
app_port: 8080
app_workers: 4
tasks:
- name: Install paket yang dibutuhkan
ansible.builtin.apt:
name: "{{ app_user }}"
state: presentVariables can also be defined directly in the inventory file, either per host or per group. Examples are the connection variables like ansible_host, ansible_user, and ansible_ssh_private_key_file you already know from episode 3.
---
all:
children:
webservers:
hosts:
web01:
ansible_host: 10.10.10.11
ansible_port: 22
web02:
ansible_host: 10.10.10.12
vars:
ansible_user: devops
ansible_ssh_private_key_file: ~/.ssh/id_ed25519The most "urgent" way to fill variables is via the command line using the --extra-vars option (or its short form -e). This kind of variable always wins over all other methods, as we'll discuss in the precedence section.
ansible-playbook -i inventory.yml playbook.yml -e "app_port=9090"The most realistic use case for extra vars is when you want to override a specific value for just one execution without changing files — for example, doing maintenance with a different user, or deploying to a different environment with the same parameters.
Warning
Beware of using -e in CI/CD pipelines. Since -e wins over everything, one typo or wrong value on the command line will be applied directly to production without being overridable by any configuration file. Make sure extra var values are always controlled and reviewed.
This approach is the best practice recommended for real projects. Ansible automatically reads YAML files from the group_vars/ and host_vars/ directories located near the inventory file, and applies them to hosts based on their group names or host names.
Why is this practice recommended? Because it applies the "broad defaults, specific exceptions" principle. Common variables like the NTP server, timezone, and SSH user go into group_vars/all, group-specific variables into group_vars/<nama_group>, and single-host variables into host_vars/<nama_host>.
Here's an example of a commonly used directory layout:
inventory/
├── production/
│ ├── inventory.yml
│ ├── group_vars/
│ │ ├── all.yml # berlaku untuk semua host di production
│ │ └── webservers.yml # berlaku khusus group webservers
│ └── host_vars/
│ └── web01.yml # berlaku khusus host web01
└── staging/
├── inventory.yml
├── group_vars/
│ └── all.yml
└── host_vars/
└── web01.ymlNow let's fill those files with real examples:
---
ntp_server: pool.ntp.org
timezone: Asia/Jakarta
ansible_user: devops
ansible_ssh_private_key_file: ~/.ssh/id_ed25519---
nginx_port: 8080
docroot: /var/www/html
enable_https: true---
domain: web01.example.com
server_ip: 10.10.10.11
nginx_worker_processes: 4The benefit is strongly felt when the number of servers grows. You don't need to touch the playbook at all to adjust per-server configuration — just edit the relevant YAML file.
Tip
File names in group_vars/ and host_vars/ are case-sensitive and must match the group/host names in the inventory. A file named Webservers.yml won't be read for the webservers group. Also, the all.yml file in group_vars/ applies to all hosts without exception.
Now we get to the concept that most often confuses people: variable precedence. When the same variable is defined in many places, Ansible must decide which value to use. Ansible uses a fixed hierarchy rule: values from sources with higher priority override sources with lower priority.
Ansible actually documents more than 20 precedence levels. For practical daily needs, the following table summarizes the order from weakest (easiest to override) to strongest (hardest to override):
| Level | Variable Source | Example Usage |
|---|---|---|
| 1 (weakest) | Command line values | Regular CLI parameters (-u user), not -e |
| 2 | Role defaults | roles/<nama_role>/defaults/main.yml |
| 3 | Inventory group vars | [webservers:vars] inside the inventory file |
| 4 | group_vars/all | Global defaults in that environment |
| 5 | group_vars/<nama_group> | Group-specific configuration |
| 6 | host_vars/<nama_host> | Single-host exceptions |
| 7 | Host facts / cached set_facts | Data collected from the server |
| 8 | Play vars | The vars: keyword at the play level |
| 9 | Play vars_files & vars_prompt | Variable files & interactive prompts |
| 10 | Role vars | roles/<nama_role>/vars/main.yml |
| 11 | Block vars & task vars | Variables scoped to only that task |
| 12 | include_vars & set_fact | Variables loaded/set at runtime |
| 13 | Role/Include params | Parameters when calling a role/include |
| 14 (strongest) | Extra vars (-e) | One-off overrides from the CLI |
Important
The most important rule of thumb to remember: -e / --extra-vars always wins, and role defaults are the weakest. Between group_vars and host_vars, host_vars wins. And in general, variables defined more specifically (host > group) win over more general ones (all).
Let's practice with a case study. For example, the app_port variable is defined in several places at once:
# group_vars/all.yml → app_port: 8080
# group_vars/webservers.yml → app_port: 8081
# host_vars/web01.yml → app_port: 9090
# playbook.yml (vars:) → app_port: 9099
ansible-playbook -i inventory.yml playbook.yml # hasil: 9099 (play vars menang)
ansible-playbook -i inventory.yml playbook.yml -e app_port=7070 # hasil: 7070 (-e selalu menang)To verify which variable value Ansible actually uses, you can use the debug module with the var= syntax:
tasks:
- name: Periksa nilai app_port yang terpakai
ansible.builtin.debug:
var: app_portWarning
A common beginner mistake: defining a variable in group_vars/all, then "wondering" why the playbook doesn't use its value even though the playbook also defines a variable with the same name in vars:. This isn't a bug, but correct precedence behavior: play vars win over group vars. Always trace every variable definition location before blaming "why the value doesn't change".
Now we get to the second part of this episode: Ansible Facts. Facts are a collection of information about a managed node that Ansible gathers automatically before running tasks. This collection process is called gathering facts, and its results are stored in a special variable named ansible_facts.
Facts are collected using the ansible.builtin.setup module. This module gathers hundreds of data points: OS name, kernel version, CPU count, total RAM, IP addresses, architecture, even the hostname. You can run it manually to see the raw data:
ansible -i inventory.yml webservers -m setupThe output is very long, but here's a relevant excerpt for us:
To narrow down the huge output, you can use the filter parameter:
ansible -i inventory.yml webservers -m setup -a "filter=ansible_memtotal_mb"
ansible -i inventory.yml webservers -m setup -a "filter=ansible_default_ipv4"Facts are accessed inside playbooks using the ansible_facts['nama_fact'] syntax. Here's a table of the most commonly used facts in the real world:
| Fact | Example Value | Use Case |
|---|---|---|
ansible_facts['os_family'] | Debian, RedHat | Choosing a package manager / per-OS configuration |
ansible_facts['distribution'] | Ubuntu, Rocky | Detecting a specific distribution |
ansible_facts['default_ipv4']['address'] | 10.10.10.11 | The server's main IP for configuration |
ansible_facts['memtotal_mb'] | 2048 | Determining service tuning size |
ansible_facts['processor_vcpus'] | 2 | Determining the number of worker processes |
ansible_facts['architecture'] | x86_64 | Choosing a specific architecture package |
ansible_facts['hostname'] | web01 | Filling the server_name in configuration |
Note
Both ansible_facts['os_family'] and ansible_facts.os_family are valid in Ansible. The recommended modern style is the subscript syntax ansible_facts['os_family'] because it's more explicit and unambiguous.
Now let's see the power of facts: one playbook, many operating systems. This playbook installs NGINX but chooses the package manager based on os_family, while also printing server info:
---
- name: Menggunakan ansible facts di playbook
hosts: all
become: true
tasks:
- name: Install NGINX di keluarga Debian
ansible.builtin.apt:
name: nginx
state: present
update_cache: true
when: ansible_facts['os_family'] == "Debian"
- name: Install NGINX di keluarga RedHat
ansible.builtin.dnf:
name: nginx
state: present
when: ansible_facts['os_family'] == "RedHat"
- name: Tampilkan profil server
ansible.builtin.debug:
msg: "{{ ansible_facts['hostname'] }} dengan IP {{ ansible_facts['default_ipv4']['address'] }}, RAM {{ ansible_facts['memtotal_mb'] }} MB, {{ ansible_facts['processor_vcpus'] }} CPU, OS {{ ansible_facts['distribution'] }}"Facts can also be combined with regular variables for very dynamic configuration. A real example: generating NGINX worker processes based on the server's CPU count. We'll explore this further in episode 8 on templating.
Tip
Facts teach us an important mindset: don't hardcode values that can be obtained from the system itself. Writing max_workers: 4 in configuration is an old technique; using ansible_facts['processor_vcpus'] makes your playbook automatically adapt to every server's specs.
gather_facts: falseCollecting facts isn't a free process. Every time a playbook runs, Ansible sends the setup module to every host and waits for the results, which consumes time and resources. For many servers and lightweight playbooks, this cost is quite significant.
If your playbook doesn't need facts at all, turn off fact gathering with the gather_facts: false keyword:
---
- name: Playbook ringan tanpa gathering facts
hosts: all
gather_facts: false
tasks:
- name: Cek konektivitas
ansible.builtin.ping:Notice that gather_facts: false makes the playbook run faster, and this is strongly felt when running against hundreds of hosts at once.
As an illustration, let's compare:
---
- name: Playbook biasa dengan facts
hosts: all
# gather_facts default = true, akan ada TASK [Gathering Facts]
tasks:
- name: Cek konektivitas
ansible.builtin.ping:If you turn off facts but then need them in just one task, you can manually enable them only for that task using setup:
---
- name: Gathering facts on demand
hosts: all
gather_facts: false
tasks:
- name: Kumpulkan facts dulu
ansible.builtin.setup:
filter: "ansible_os_family"
- name: Baru gunakan facts
ansible.builtin.debug:
msg: "OS family server ini: {{ ansible_facts['os_family'] }}"Important
A good rule: enable gather_facts: false for all playbooks that don't use facts, especially health checks, static artifact deployments, or lightweight tasks. For playbooks that need facts, leave the default. In episode 15 on performance tuning, we'll cover fact caching so gathering doesn't have to be repeated.
In episode 7, we covered two important pillars that will make your playbooks truly production-grade: variables and facts. You now understand how to define variables at various levels, from the play, the inventory, to the group_vars/ and host_vars/ best practices. You also understand the precedence table from weakest (role defaults) to strongest (-e / extra vars), plus how to verify which variable value is used. Finally, we explored ansible facts: automatically collected system data, how to leverage them so one playbook runs across operating systems, and the gather_facts: false optimization for lightweight playbooks.
Key takeaways:
group_vars/all, group-specific ones in group_vars/<group>, and per-host exceptions in host_vars/<host>.-e always wins, host_vars wins over group_vars, and role defaults are the weakest.gather_facts if the playbook doesn't need it.In episode 8, we'll cover the topic that bridges variables and real configuration: Jinja2 Templating & Filters. We'll learn to create .j2 template files that produce dynamic configuration, combine variables and facts into a complete NGINX configuration file, and leverage built-in filters like default, to_json, to_yaml, and regex_replace. Prepare your mindset, because this is where your playbooks start feeling like real programs. Keep your enthusiasm up!