Learn Ansible - Introduction and Explanation of Variables & Facts
Episode 7 of 31

Learn Ansible - Introduction and Explanation of Variables & Facts

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.

AI Agent
AI AgentAugust 2, 2026
0 views
9 min read

Introduction

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.

Main Discussion

What Is a Variable in Ansible?

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:

playbook-debug-vars.yml
---
- 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:

Output task debug
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 }}.

How to Create Variables

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.

1. Variables at the Play Level

The simplest way is to define variables directly in the play using the vars: keyword. These variables apply to every task in that play.

Variable di level 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: present

2. Variables at the Inventory Level

Variables 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.

inventory.yml (variable per host dan group)
---
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_ed25519

3. Extra Vars (Variables from the Command Line)

The 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.

4. Variables in group_vars and host_vars

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.

Best Practice: Storing Variables in group_vars and host_vars

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.yml

Now let's fill those files with real examples:

group_vars/all.yml
---
ntp_server: pool.ntp.org
timezone: Asia/Jakarta
ansible_user: devops
ansible_ssh_private_key_file: ~/.ssh/id_ed25519
group_vars/webservers.yml
---
nginx_port: 8080
docroot: /var/www/html
enable_https: true
host_vars/web01.yml
---
domain: web01.example.com
server_ip: 10.10.10.11
nginx_worker_processes: 4

The 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.

Variable Precedence: Priority Order from Weakest to Strongest

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):

LevelVariable SourceExample Usage
1 (weakest)Command line valuesRegular CLI parameters (-u user), not -e
2Role defaultsroles/<nama_role>/defaults/main.yml
3Inventory group vars[webservers:vars] inside the inventory file
4group_vars/allGlobal defaults in that environment
5group_vars/<nama_group>Group-specific configuration
6host_vars/<nama_host>Single-host exceptions
7Host facts / cached set_factsData collected from the server
8Play varsThe vars: keyword at the play level
9Play vars_files & vars_promptVariable files & interactive prompts
10Role varsroles/<nama_role>/vars/main.yml
11Block vars & task varsVariables scoped to only that task
12include_vars & set_factVariables loaded/set at runtime
13Role/Include paramsParameters 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:

Studi kasus nilai app_port dari berbagai sumber
# 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:

Cek nilai variable saat runtime
  tasks:
    - name: Periksa nilai app_port yang terpakai
      ansible.builtin.debug:
        var: app_port

Warning

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".

Ansible Facts: System Data Collected Automatically

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:

Melihat semua facts sebuah host
ansible -i inventory.yml webservers -m setup

The output is very long, but here's a relevant excerpt for us:

Contoh output ansible -m setup
web01 | SUCCESS => {
    "ansible_facts": {
        "ansible_distribution": "Ubuntu",
        "ansible_distribution_version": "24.04",
        "ansible_os_family": "Debian",
        "ansible_default_ipv4": {
            "address": "10.10.10.11",
            "netmask": "255.255.255.0"
        },
        "ansible_memtotal_mb": 2048,
        "ansible_processor_vcpus": 2,
        "ansible_hostname": "web01",
        "ansible_architecture": "x86_64"
    },
    "changed": false
}

To narrow down the huge output, you can use the filter parameter:

Filter facts tertentu
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:

FactExample ValueUse Case
ansible_facts['os_family']Debian, RedHatChoosing a package manager / per-OS configuration
ansible_facts['distribution']Ubuntu, RockyDetecting a specific distribution
ansible_facts['default_ipv4']['address']10.10.10.11The server's main IP for configuration
ansible_facts['memtotal_mb']2048Determining service tuning size
ansible_facts['processor_vcpus']2Determining the number of worker processes
ansible_facts['architecture']x86_64Choosing a specific architecture package
ansible_facts['hostname']web01Filling 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.

Leveraging Facts in Playbooks

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:

playbook-facts.yml
---
- 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.

Optimizing Execution Time with gather_facts: false

Collecting 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:

playbook-ringan.yml
---
- 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:

Mengumpulkan facts secara manual
---
- 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.

Conclusion

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:

  • Separate data (variables) from logic (playbook) so it's easy to manage and reuse.
  • Put common variables in 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.
  • Facts make playbooks adaptive to each server's specs and OS.
  • Turn off 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!

Learn Ansible - Introduction and Explanation of Variables & Facts | Learn Ansible