July 16th Virtual Panel: Rethinking IaC Pipelines for Control, Scale, and Sanity

➡️ Register Now

Ansible

Ansible Variable Precedence Explained: Order & Use Cases

ansible variable precedence

🚀 Level Up Your Infrastructure Skills

You focus on building. We’ll keep you updated. Get curated infrastructure insights that help you make smarter decisions.

Ansible is an agentless automation tool that uses YAML-based playbooks to manage system configurations, deployments, and orchestration across remote systems. 

One of its core features is the use of variables to customize tasks across different environments. These variables can be defined in multiple locations, each with its own level of precedence. 

Understanding how Ansible resolves variable conflicts is essential for ensuring predictable, reliable automation behavior.

What we’ll cover in this article:

  1. Ansible variables overview
  2. What is Ansible variable precedence?
  3. Understanding the order of variable precedence in Ansible
  4. Practical use cases of Ansible variable precedence
  5. Troubleshooting with variable precedence
  6. Best practices for Ansible variable precedence

Ansible variables overview

Variables in Ansible let you reuse values across tasks, making your workflows more flexible and easier to adapt to different environments. Instead of hardcoding things like app versions, hostnames, or file paths, you can assign them to variables. Using variables keeps your automation cleaner, safer, and easier to maintain.

These values can be set in inventory files, roles, or from the command line, simplifying configuration and management. 

When the same variable is defined in multiple places, Ansible uses precedence rules to decide which one to use, ensuring consistent behavior.

You can scope variables globally, per host, or per group, giving you precise control. 

They also work in conditionals and loops, helping reduce repetition. Tools like Ansible Vault, Azure Key Vault, or AWS Secrets Manager keep sensitive data like credentials or API keys secure. 

Read more: How to Use Different Types of Ansible Variables

What is Ansible variable precedence?

Ansible variable precedence determines which variable value takes effect when the same variable is defined in multiple places. 

Every time Ansible executes a task, it evaluates variables from different sources based on a specific hierarchy, choosing the value from the source with the highest priority. These sources include playbooks, roles, inventory files, or command-line parameters.

Without understanding the defined hierarchy, variables may unintentionally override each other, leading to configuration issues or deployment failures. 

For example, defining a variable in both a role default and at the command line will result in Ansible using the command-line value, since it ranks higher in precedence.

Properly managing variable precedence ensures clarity and reduces the risk of mistakes. It also gives you more control over your automation and simplifies playbook maintenance in complex infrastructure environments.

In the next section, we will dive into all the levels in Ansible precedence in priority order. 

Understanding the order of variable precedence in Ansible

In Ansible, variable precedence determines which variable value is used when multiple sources define the same variable. Higher precedence values always override lower ones when conflicts occur. 

The effective order (from highest to lowest precedence) is:

  1. Extra vars (always highest)
  2. Task vars (including loop and include vars)
  3. Block vars
  4. Play vars_prompt and vars_files
  5. Play vars
  6. Facts (gathered or set)
  7. Playbook group_vars and host_vars
  8. Inventory group_vars and host_vars
  9. Inventory file variables
  10. Role defaults
  11. Command line values

In this section, we will go over the order of precedence that is used in Ansible variables. We will start with the top priority source that will override any other source that is in play during your playbook run. 

For each source, we will provide an example to demonstrate further: 

1. Extra variables (command-line parameter)

Ansible variables specified within the --extra-vars (or -e) parameter have the highest precedence and override any other variables that may be defined elsewhere. 

For example, in the following playbook, we specified a Tomcat version variable, and we can pass in the value from the command line. No matter where else we may have specified the value for this variable, the value from the command line -e flag will override any other value. 

Playbook (deploy_tomcat.yml):  

---
- hosts: webservers
  tasks:
    - name: Install Tomcat from extra vars
      yum:
        name: "tomcat-{{ tomcat_version }}"

Command (using -e flag):

ansible-playbook deploy_tomcat.yml -e "tomcat_version=10.1.5"

2. Task parameters

Within each Ansible task, we can use the include_tasks directive to include tasks from another YAML file. In that YAML file, you can have tasks that use variables. The value for those variables needs to be passed in from the playbook level. This value has a higher precedence compared to other variable values. 

In this example, we are installing the Tomcat application, but it is a part of a separate task file. We are including that within our main playbook and passing in the value for the variable from the playbook level. 

Playbook (deploy_tomcat.yml): 

---
- hosts: webservers
  tasks:
    - name: Include Tomcat installation tasks
      include_tasks: install_tomcat.yml
      vars:
        tomcat_version: "10.1.4"

Included task install_tomcat.yml: 

- name: Install Tomcat from include params
  yum:
    name: "tomcat-{{ tomcat_version }}"

3. Role (and include_role) params

There are different ways to specify variable values in Ansible roles. Adding the values of the variables used within a role on the playbook level has a higher precedence and will override the variable value specified in the vars files within the role. 

Example 1 – Standard role

Using a standard role to install a Tomcat application by passing in the value for the tomcat_version variable from the role parameter on the playbook level: 

Playbook (deploy_tomcat.yml): 

---
- hosts: webservers
  roles:
    - role: tomcat_role
      tomcat_version: "10.2.1"

Role (/roles/tomcat_role/tasks/main.yml):

- name: Install specific Tomcat version
  yum:
    name: "tomcat-{{ tomcat_version }}"

Example 2 – Using include_role

Another common practice in using roles is using the include_role directive. This is similar to using standard roles, but it is used under a regular Ansible task, and you include the role within that task. 

We have a Tomcat configuration file that uses a variable for max memory. We can pass this in from the playbook level, and it will override any variable value specified within the role itself. 

Playbook (deploy_tomcat.yml): 

---
- hosts: webservers
  tasks:
    - name: Include Tomcat role with custom parameters
      include_role:
        name: tomcat_role
      vars:
        tomcat_version: "10.2.2"
        tomcat_max_memory: "2048m"

Role tasks (/roles/tomcat_role/tasks/main.yml):

- name: Install Tomcat version
  yum:
    name: "tomcat-{{ tomcat_version }}"

- name: Set JVM max memory
  lineinfile:
    path: /etc/tomcat/tomcat.conf
    regexp: '^JAVA_OPTS='
    line: 'JAVA_OPTS="-Xmx{{ tomcat_max_memory }}"'
  notify: restart tomcat

4. set_fact/registered vars

The set_fact module allows you to set variable values as a separate task in your playbook. Another similar approach is to store the output of a playbook task into a variable within your tasks using the register parameter. Both of these approaches have a higher precedence than other variable values that are being passed in. 

In this example, we store the value of the variable for tomcat_version as a fact and pass that value into our Tomcat install task.

---
- hosts: webservers
  tasks:
    - name: Set tomcat_version fact
      set_fact:
        tomcat_version: "10.1.2"

    - name: Install Tomcat from set_fact
      yum:
        name: "tomcat-{{ tomcat_version }}"

In this example, we grab the command’s output and store it in a variable that is passed into another task in the playbook:

- hosts: webservers
  tasks:
    - name: Get installed Tomcat version
      shell: "rpm -q tomcat"
      register: tomcat_check

    - name: Print Tomcat version
      debug:
        msg: "Tomcat package installed: {{ tomcat_check.stdout }}"

    - name: Skip install if Tomcat is already present
      yum:
        name: "tomcat"
        state: present
      when: "'not installed' in tomcat_check.stdout"

5. Incude_vars

In Ansible, you can create a variable YAML file that contains a list of all the variables and their values. You can use the include_vars directive to pass the values from the variable file on to your playbook tasks.

In this example, we have a variable file (vars.yml) containing the variable value for the Tomcat version, which we are passing to the playbook using the include_vars directive. 

vars.yml:

tomcat_version: "10.1.2"

Playbook (tomcat_deploy.yml):

---
- hosts: webservers
  tasks:
    - name: Include Tomcat version variables
      include_vars: tomcat_version.yml

    - name: Install Tomcat from included vars
      yum:
        name: "tomcat-{{ tomcat_version }}"

6. Task vars

This is a standard approach: specify your variables only within the task itself instead of passing them in from a separate file or command line. 

Let’s install a Tomcat application by specifying the Tomcat version variable value right under the task. 

Playbook (tomcat_deploy.yml):

---
- hosts: webservers
  tasks:
    - name: Install Tomcat from task vars
      yum:
        name: "tomcat-{{ tomcat_version }}"
      vars:
        tomcat_version: "10.1.2"

7. Block vars

Ansible offers the option to specify blocks of tasks to which you want specific variables to apply. 

We can use the - block to list the tasks to install the Tomcat application and create a list of variables right under it. 

Playbook (tomcat_deploy.yml): 

---
- hosts: webservers
  tasks:
    - block:
        - name: Install Tomcat from block vars
          yum:
            name: "tomcat-{{ tomcat_version }}"
      vars:
        tomcat_version: "10.0.9"

8. Role vars

A common practice when using roles in Ansible is to list all of the variable values under the vars directory located in (/roles/your_role/vars/main.yml). This gets passed into any variables you have in your Ansible role. 

In this example, we have variables located in /roles/tomcat_role/vars/main.yml that specify the version of Tomcat we want to deploy. The variable will be passed into the tomcat_role that is using it. 

The role itself downloads the Tomcat archive, but it needs the specific version to run. 

Role (roles/tomcat_role/vars/main.yml):

tomcat_version: "10.0.8"

Playbook (deploy_tomcat.yml):

---
- hosts: webservers
  roles:
    - tomcat_role

Role (/roles/tomcat_role/tasks/ main.yml):

- name: Download Tomcat archive
  get_url:
    url: "https://archive.apache.org/dist/tomcat/tomcat-{{ tomcat_version.split('.')[0] }}/v{{ tomcat_version }}/bin/apache-tomcat-{{ tomcat_version }}.tar.gz"
    dest: "/tmp/apache-tomcat-{{ tomcat_version }}.tar.gz"
  become: true

9. Play vars_files

vars_file allows you to import variables from an external YAML file into your playbook. This approach helps you separate the variables from your Ansible tasks, making the playbooks cleaner and easier to manage. 

In the following example, we are importing the variable for tomcat_version from the tomcatvars YAML file into our playbook by using the vars_files parameter. 

Playbook (deploy_tomcat.yml):

---
- hosts: webservers
  vars_files:
    - /tomcatvars.yml

  tasks:
    - name: Install specific Tomcat version
      yum:
        name: "tomcat-{{ tomcat_version }}"

tomcatvars.yml:

tomcat_version: "10.1.2"

10. Play vars_prompt

vars_prompt is used to get interactively prompted during playbook execution for the variable. This has a lower precedence than some of the other mechanisms for using variables, but it can be useful in certain scenarios where you want to pass in sensitive values or environment-specific values at runtime without hard-coding them into your playbook or files. 

In this playbook, we are using the vars_prompt directive to prompt the admin for the variable input during playbook execution. You can customize the prompt and enter an input value. 

Playbook (deploy_tomcat.yml):

---
- hosts: webservers
  vars_prompt:
    - name: "tomcat_version"
      prompt: "Enter the Tomcat version "
      private: no

  tasks:
    - name: Install the chosen Tomcat version
      yum:
        name: "tomcat-{{ tomcat_version }}"

Playbook execution:

ansible-playbook deploy_tomcat.yml

Output:

Enter the Tomcat version: 10.1.2

11. Play vars

The vars block in playbooks allows you to specify variables on the main playbook level, allowing the variables to pass into all of your tasks. This has a lower precedence compared to the other playbook vars methods. 

We are declaring the variable value at the playbook level. Therefore, any task in this playbook that uses that variable will receive the variable value. 

Playbook (deploy_tomcat.yml):

---
- hosts: webservers
  vars:
    tomcat_version: "10.1.2"

  tasks:
    - name: Install Tomcat
      yum:
        name: "tomcat-{{ tomcat_version }}"

12. Host facts (Ansible facts)

Host facts are variables that Ansible gathers from each host during execution. Data includes OS type/version, hostname, IP addresses, etc. However, the Ansible facts tend to have a medium precedence in the chain of variables. 

Here, we are using some of the automatically gathered fact variables Ansible provides, such as ansible_distribution, ansible_distribution_version, and ansible_os_family. This will provide us with host data that we can use within our playbook conditionals to execute the install task based on that information. 

Playbook (deploy_tomcat.yml):

---
- hosts: webservers
  tasks:
    - name: Display OS of Host
      debug:
        msg: "This server is running {{ ansible_distribution }} {{ ansible_distribution_version }}"

    - name: Install Tomcat based on OS family
      yum:
        name: "{{ 'tomcat' if ansible_os_family == 'RedHat' else 'tomcat9' }}"
        state: present

13.  Playbook host_vars

Playbook host_vars are variables that are specific to the host you run your playbook on, as you

have a separate file that is dedicated to each host in your inventory file. For playbook host_vars, the host_vars directory lives within the project level that contains the playbook (works differently when using inventory host_vars). 

This approach focuses more on declaring variables on an inventory level, which has a higher precedence compared to the other inventory variable methods we will cover next. 

In this example, we have two hosts that require different versions of Tomcat installed. We will create an inventory file containing those two hosts, and in that same directory, we will create a new directory for host_vars

This will include a YAML file for each of our hosts and store any specific variables they may require during playbook execution. This approach is useful if we want to have fine-grained control over the configuration of our hosts. 

Directory structure:

project/
├── deploy_tomcat.yml
├── inventory.ini
└── host_vars/
    ├── app1.yml
    └── app2.yml

myhosts.ini:

[webservers]
app1
app2

app1.yml:

tomcat_version: "10.1.2"

app2.yml:

tomcat_version: "10.1.1"

Playbook deploy_tomcat.yml:

---
- hosts: webservers
  tasks:
    - name: Install host-specific Tomcat version
      yum:
        name: "tomcat-{{ tomcat_version }}"
        state: present

14. Inventory host_vars

Inventory host_vars are slightly different than playbook host_vars. They require you to define the variables within a host_vars file in a separate inventory directory within your project, which includes the separate YAML files for each host’s variable values.

This example is similar to the previous example using playbook host_vars, as the key difference is the directory under which the host_vars are located. 

Directory structure (host_vars directory is within the inventory directory):

inventory/
├── myhosts.ini
├── host_vars/
│   ├── app1.yml
│   └── app2.yml
deploy_tomcat.yml

myhosts.ini: 

[webservers]
app1
app2

app1.yml:

tomcat_version: "10.1.2"

app2.yml: 

tomcat_version: "10.1.1"

Playbook deploy_tomcat.yml:

---
- hosts: webservers
  tasks:
    - name: Install host-specific Tomcat version
      yum:
        name: "tomcat-{{ tomcat_version }}"
        state: present

15. Inventory file or script host_vars

This approach allows you to declare variables in-line for each host in the inventory file. You can do this either in the INI file or the YAML-formatted file without using the host_vars file. This is a very common practice when hard-coding variable values for each host in the inventory file. 

Below, we will store the variable name and value in the inventory file. Our playbook will now be able to utilize the variable values to install the Tomcat application.  

Directory structure: 

project/
├── deploy_tomcat.yml
└── inventory.ini

Inventory file (inventory.ini):

[webservers]
web1 ansible_host=192.168.1.10 tomcat_version=10.1.1
web2 ansible_host=192.168.1.11 tomcat_version=10.1.2

Playbook deploy_tomcat.yml:

---
- name: Install Tomcat using inventory file host vars
  hosts: webservers
  gather_facts: no

  tasks:
    - name: Install the correct Tomcat version per host
      yum:
        name: "tomcat-{{ tomcat_version }}"
        state: present

    - name: Confirm installation
      debug:
        msg: "Tomcat {{ tomcat_version }} installed on {{ inventory_hostname }}"

16. Playbook group_vars

This method allows you to define group level variables in Ansible. group_vars is a directory where you define variables for host groups. 

For this group_vars to be considered a playbook group_vars, the directory has to be on the same level as your playbook file, and the directory will include a YAML file with all of your variable names and values. Once you trigger your playbook, the specified variables retrieve their appropriate values from the file located in group_vars. 

In this example, we are adding a group_vars directory under the project we are working with that includes the playbook. Within that group_vars directory, we will include the variables associated with our playbook.

Directory structure:

project/
├── deploy_tomcat.yml
├── group_vars/
│   └── app-servers.yml
└── inventory.ini

Inventory file:

[webservers]
web1
web2

app-servers.yml: 

tomcat_version: "10.1.0"
tomcat_port: 8080

Playbook deploy_tomcat.yml:

---
- name: Deploy Tomcat using playbook group_vars
  hosts: webservers
  gather_facts: no

  tasks:
    - name: Install Tomcat version from group_vars
      yum:
        name: "tomcat-{{ tomcat_version }}"
        state: present

    - name: Display Tomcat configuration
      debug:
        msg: "Tomcat {{ tomcat_version }} will run on port {{ tomcat_port }}"

17. Inventory group_vars

Inventory group_vars works the same way as playbook groups_vars, but this method requires the group_vars directory to be under the inventory directory (the same concept as using playbook and inventory host_vars) instead of the same level as the playbook. This has a lower precedence than playbook group_vars

Below, we will install Tomcat and display the admin user of the Tomcat application. We will retrieve the variables listed in the group_vars directory under the inventory directory. 

Directory structure:

project/
├── deploy_tomcat.yml
├── inventory/
│   ├── inventory_hosts.ini
│   └── group_vars/
│       └── app-servers.yml

inventory_hosts.ini file:

[webservers]
web1
web2

app-servers.yml: 

tomcat_version: "10.1.1"
tomcat_user: "tomcat_user"

Playbook deploy_tomcat.yml: 

---
- name: Install Tomcat using inventory group_vars
  hosts: webservers
  gather_facts: no

  tasks:
    - name: Install Tomcat
      yum:
        name: "tomcat-{{ tomcat_version }}"
        state: present

    - name: Display Tomcat Admin
      debug:
        msg: "Tomcat is managed by user {{ tomcat_user }}"

18. Playbook group_vars/all

Playbook group_vars/all follows the same concept as playbook group_vars with the directory of group_vars being on the same level as the playbook file but this method uses a special file called all.yml, this is a reserved file in Ansible and applies all the variables listed in this file to all of the hosts in your inventory file. 

Directory structure: 

app/
├── deploy_tomcat.yml
├── group_vars/
│   └── all.yml
└── inventory.ini

19. Inventory group_vars/all

Inventory group_vars/all follows the same concept as inventory group_vars, with the group_vars directory under the inventory directory. It also uses the special Ansible file called all.yml and applies the variables listed here to all of the hosts in the inventory file. 

Directory structure: 

app/
├── deploy_tomcat.yml
├── inventory/
│   ├── hosts.ini
│   └── group_vars/
│       └── all.yml

20. Inventory file or script group vars

Previously, we covered using the inventory file with the script host_vars, which passed in variables to each specific host. However, the script group_vars method with the inventory file applies the variables to all of the hosts. Therefore, group_vars falls lower in precedence compared to host_vars

Let’s use group_vars to pass in Tomcat variables into our playbook run; this will apply the variables to all of the hosts listed in our inventory file. 

Directory structure:

project/
├── deploy_tomcat.yml
└── inventory.ini

inventory.ini file:

[webservers]
web1
web2

[webservers:vars]
tomcat_port=8080
tomcat_user=webgroup_admin

Playbook deploy_tomcat.yml: 

---
- hosts: webservers
  gather_facts: no

  tasks:
    - name: Show group-level Tomcat vars
      debug:
        msg: "Tomcat runs on port {{ tomcat_port }} as {{ tomcat_user }}"

21. Role defaults

One of the lowest precedence variables is role defaults. Under every Ansible role, there is a defaults directory (/roles/your_role/defaults/main.yml) that contains default variable values that a playbook execution can always fallback to in case no variable value is provided to the run.

Directory structure:

project/
├── deploy_tomcat.yml
└── roles/
    └── tomcat/
        ├── defaults/
        │   └── main.yml
        └── tasks/
            └── main.yml

Default variables (/roles/your_role/defaults/main.yml):

tomcat_version: "9.0.72"
tomcat_port: 8080

Role tasks (/roles/your_role/tasks/main.yml):

- name: Install Tomcat
  yum:
    name: "tomcat-{{ tomcat_version }}"
    state: present

- name: Configure Tomcat port
  template:
    src: server.xml.j2
    dest: "/etc/tomcat/server.xml"

Playbook (deploy_tomcat.yml):

---
- name: Deploy Tomcat using role defaults
  hosts: webservers
  roles:
    - role: tomcat

22. Command line values

Command line values are not variables; they are the parameter values passed in via the command line for the ansible-playbook cmdlet. These can include parameters such as -u, which is used to pass in a username, or -k, which prompts the admin to enter an SSH password. 

If you need to pass in variables through the command line, you can use  --extra-vars (which has the highest precedence in Ansible variables), but the regular parameters of the ansible-playbook command have the lowest precedence. 

In the example below, we are running the ansible-playbook command against a playbook file with the standard command-line parameters. 

ansible-playbook deploy_tomcat.yml -i inventory.ini -u ansibleadmin --private-key ~/.ssh/id_rsa --become

This is the complete list of the order of precedence with Ansible variables. Now we will cover a couple of practical use cases to demonstrate the importance of the order of precedence in your workflows. 

Practical use cases of Ansible variable precedence

The use cases below involve Ansible variables where understanding precedence is key, since multiple files may define the same variable.

Scenario 1: Deploy Tomcat across different environments

You usually deploy applications through Dev, Staging, and Prod to avoid production disruptions. To control versions, you can define default variables across environments and use Ansible’s precedence rules to manage updates.

In this example, we host the app on Tomcat and first deploy the latest version to Dev using higher-precedence variables. Other environments retain a default version set during each playbook run.

Ansible Roles handle the deployment. The default Tomcat version in /roles/tomcat/defaults/main.yml ensures consistency, unless overridden per environment. Currently, only Production has this version set.

tomcat_version: "10.0.0"

For Dev and Staging, we’ll assume they’ve already been upgraded to a newer Tomcat version that is not yet in production. We’ll set this version in their group_vars, which takes precedence over Role defaults and overrides them.

group_vars/dev.yml, this is a separate Dev YAML file dedicated to Dev environment variables only. We will specify the new version in this file:

tomcat_version: "10.0.1"

group_vars/staging.yml, the same applies for the Staging environment, as we will specify a new version for Staging as well: 

tomcat_version: "10.0.1"

We plan to deploy version 10.0.1 to Production, but a newer Tomcat version (10.0.2) was just released, which we want to test in Dev. 

To do this, we’ll override Dev’s current group_vars using a higher-precedence Ansible variable. In this case, we’ll use --extra-vars to ensure Dev gets the specific version for testing.

ansible-playbook deploy_tomcat.yml —-extra-vars "tomcat_version=10.0.2"

This way, we ensure the Production version never gets accidentally modified during playbook execution, we properly test the latest versions, and we roll them out carefully through each environment in a clean manner by going through Dev, Staging, and Production. 

Scenario 2: Provision Linux users across different environments

Organizations often use separate environments like Dev, Staging, and Production to manage infrastructure and access according to security and operational requirements. Ansible’s variable precedence helps provision users with environment-specific SSH keys, sudo access, and permissions in a clean and flexible way. 

In this example, the user_mgmt role defines default user settings in defaults/main.yml, creating a limited-access fallback account only if no higher-precedence variables are provided.

Default variable values (/roles/user_mgmt/defaults/main.yml):

users:
  - name: "defaultuser"
    shell: "/bin/bash"
    state: "present"
    sudo: false

Now we’ll configure environment-specific settings. Each environment, like Dev or Production, will have different users and keys. We’ll define variables in group_vars to override defaults and control each environment separately.

group_vars/dev.yml:

users:
  - name: "devadmin"
    shell: "/bin/bash"
    sudo: true
    ssh_key: "ssh-rsa AAAAB3SDJNfSMCgdfds3432"
  - name: "devread"
    shell: "/bin/sh"
    sudo: false
    ssh_key: "ssh-rsa AAAAB3DSGLGJOM2403fdgw3"

group_vars/production.yml:

users:
  - name: "prodadmin"
    shell: "/bin/bash"
    sudo: true
    ssh_key: "ssh-rsa AAAAB3gfd4SDGJHIOjhggdfg3"

We’ve separated Dev and Production variables under a shared role. Dev has both read-only and admin users, while Production has only a privileged admin. Playbooks now load the correct user settings from group_vars, overriding role defaults.

If a consultant needs temporary Dev access, instead of editing group_vars, we can use --extra-vars during the playbook run. This method has the highest precedence in Ansible and lets us override the user list just for that execution.

ansible-playbook deploy_users.yml -i inventory/hosts.ini --limit dev -e 'users=[{"name":"tempadmin","sudo":true,"ssh_key":"ssh-rsa AAAAdfdKJGIWgfSdsdfdg"}]'

Using --extra-vars ensures the tempadmin user is created on Dev servers without changing files or affecting other environments. This method overrides group_vars, enabling safe access testing and permission management without impacting Production.

Scenario 3: Controlling CI/CD pipeline behavior using variable precedence

In a typical CI/CD pipeline, stages like Build, Test, and Deploy guide the application through a structured release process. Each serves a distinct role — Build compiles code, Test runs quality checks, and Deploy pushes changes live.

To standardize behavior across these stages, we define default variable values in the playbook. We then adjust behavior dynamically using Ansible’s variable precedence. This allows the playbook to react contextually. For instance, enabling dry-run mode in Build, verbose logging in Test, and database migrations in Deploy.

Instead of managing separate playbooks or complex conditionals, we can override defaults with higher-precedence inputs, like --extra-vars, during pipeline execution. In this setup, we’ll use a role to define defaults, ensuring consistent behavior unless explicitly overridden.

deploy_app.yml playbook

---
- name: Deploy application
  hosts: app_servers
  become: true
  gather_facts: false

  vars:
    ansible_check_mode: "{{ dry_run | default(false) }}"

  roles:
    - deploy

Deploy role (roles/deploy/defaults/main.yml): 

- name: Show current stage behavior
  debug:
    msg: >
      Running with dry_run={{ dry_run }},
      verbose_logging={{ verbose_logging }},
      run_migrations={{ run_migrations }}

- name: Set log level based on verbosity flag
  lineinfile:
    path: /opt/myapp/config.properties
    regexp: '^log.level='
    line: "log.level={{ 'DEBUG' if verbose_logging else 'INFO' }}"
  when: not dry_run

- name: Deploy application package
  shell: "echo 'Deploying app...'"
  when: not dry_run

- name: Run database migrations
  shell: "echo 'Running DB migrations...'" 
  when: run_migrations and not dry_run

Default variable values (/roles/deploy/defaults/main.yml):

dry_run: false
verbose_logging: false
run_migrations: false

In our pipeline, each CI/CD stage can selectively override specific variables by using the --extra-vars parameter. This allows us to provide only the values needed for a given stage, keeping configurations concise and focused.

For instance, during the Build stage, we typically want to avoid making any real changes. To achieve this, we override the dry_run variable via --extra-vars, enabling Ansible’s check mode. This mode executes all tasks without applying changes, allowing us to verify the playbook’s behavior safely.

ansible-playbook deploy_app.yml -i inventory/hosts.ini --extra-vars "dry_run=true"

In the Test stage, we want to allow the playbook to run normally, but we want to capture additional logs during the testing process. 

For that, we will use --extra-vars again, but this time we will override the verbose_logging variable while ensuring the other defaults remain unchanged:

ansible-playbook deploy_app.yml -i inventory/hosts.ini --extra-vars "verbose_logging=true"

Lastly, in the Deploy stage, we want to apply the changes fully and run database migrations. To do this, we only need to set the run_migrations variable to true. 

This will trigger the database migration block in the playbook while allowing the other default behaviors to continue as expected:

ansible-playbook deploy_app.yml -i inventory/hosts.ini --extra-vars "run_migrations=true"

With this method, we can enable the use of a single playbook across all CI/CD stages, with behavior that adapts dynamically to the execution context. By taking advantage of Ansible’s variable precedence and using --extra-vars to override values as needed, we eliminate code duplication and reduce the overhead of managing multiple playbooks.

Troubleshooting with variable precedence

When working with Ansible, it’s easy to lose track of where variables are defined and how they’re used across playbooks, roles, and other components. That’s why following a standardized approach and best practices for managing variables is crucial, which we’ll explore next.

If you’ve already started using variables throughout your Ansible setup, you may encounter issues where a variable value is not what you expected. This often happens when variables are defined in multiple places, making it tricky to trace their source.

Take a simple example: deploying a Tomcat application with a variable like app_version. You might define a default version (e.g., 1.0.0) in /roles/app/defaults/main.yml, but during a playbook run, a different value gets passed, leaving you wondering where it came from.

app_version: "1.0.0"

We also have a group variable set for app_version in the environments inventory group set to another version in /inventory/group_vars/prod.yml

app_version: "1.2.0"

We also have a host variable version set to a different version in /inventory/host_vars/app01.yml.

app_version: "1.3.0"

Lastly, we are passing in version 2.0.0 from the command line with the --extra-vars parameter.

ansible-playbook deploy_app.yml -i inventory/hosts.ini --limit app01 --extra-vars "app_version=2.0.0"

For this example, we only wanted the command run to set this version to Dev and not Production. However, we realized Production also received this version, and now we need to figure out where the issue is. 

We have a few methods we can follow to troubleshoot and pinpoint the issue. We want to be able to pull up all the different Ansible precedents that are in play for this particular job. In order to do this, we can do the following:

1. Run the playbook with verbose logging

Enabling verbose on your Ansible playbook run allows you to see what variables are being loaded and their values during runtime, preventing you from going through each file and trying to find out where the variable might be passing in from. 

There are different levels when using verbose logging:

Verbose level 1 (-v)

This will show you task-level outputs, which include changes, skipped tasks, and command results.

ansible-playbook deploy_app.yml -i inventory/hosts.ini --limit app01 --extra-vars "app_version=2.0.0" -v

You would receive a similar output as the following when applying a single -v flag with the ansible-playbook command: 

TASK [deploy : debug current app_version] **************************************
ok: [app01] => (item=None) => {
    "msg": "Deploying application version: 2.0.0"
}

TASK [deploy : Set log level based on verbosity flag] **************************
changed: [app01]

As you can see, the version being displayed is the value we passed in using –extra-vars (2.0.0), which had the highest precedence. 

Verbose level 2-3 (-vv or -vvv)

This will display all loaded variables, lookup values, evaluated conditions, included files, and variable overrides in playbooks and roles. We want to focus on this most of the time when troubleshooting variables. 

ansible-playbook deploy_app.yml -i inventory/hosts.ini --limit  app01 --extra-vars "app_version=2.0.0" -vvv

Here is a sample output of running this command with the -vvv flag, which would display the location where the variable values are coming from and also give you the set value for the variable, see below:

Loaded vars from: roles/deploy/defaults/main.yml
  app_version = 1.0.0

Loaded vars from: inventory/group_vars/prod.yml
  app_version = 1.2.0

Loaded vars from: inventory/host_vars/app01.yml
  app_version = 1.3.0

Loaded extra vars (command line):
  app_version = 2.0.0

The other levels, or verbose logging, don’t display much of the variable values and location; instead, it gives you more logging details about connection-level debugging and SSH outputs, which is useful when debugging connectivity or authentication problems.

2. Create a debug task directly in your playbook

In Ansible, we also have the option to use the debug module to display the variable values during your playbook run and see the value in the output. You can structure your playbook task as follows:

- name: Show current application version
  debug:
    msg: "Deploying version: {{ app_version }}"

And receive an output as this:

TASK [Show current application version] ****************************************
ok: [app01] => {
    "msg": "Deploying version: 2.0.0"
}

This can also be used to display multiple variable values during your playbook run as follows:

- name: Print all deployment flags
  debug:
    msg: |
      dry_run = {{ dry_run }}
      verbose_logging = {{ verbose_logging }}
      run_migrations = {{ run_migrations }}

Giving you the following output:

TASK [Print all deployment flags] **********************************************
ok: [app01] => {
    "msg": "dry_run = False\nverbose_logging = True\nrun_migrations = False"
}

3. Variable isolation by testing without overrides:

In order to fully isolate the issue, you can exclude the --extra-vars parameter during your playbook run to focus more on the other variable precedence in your workflow and find out what values might be getting loaded in from.

This removes the highest precedence method from being used and allows you to work directly with the other methods you have in use, as the following example:

ansible-playbook deploy_app.yml -i inventory/hosts.ini -v

Once you have eliminated the --extra-vars method, you can begin by reintroducing group_vars one at a time and start testing with host_vars being commented out. This gives you the ability to eliminate the other variable methods incrementally, one by one, to see where the issue lies. 

However, if there are multiple sources bringing forth the same value, then you should look into cleaning up your variable strategy. It is very important to have a clean variable strategy so you can avoid any variable conflicts and unexpected deployments. 

Best practices for Ansible variable precedence

In this section, we will go over a couple of best practices you can follow when using Ansible variables across your playbooks and roles. 

  • Use role defaults for fallbacks – Set safe default variable values in roles (e.g., roles/user_mgmt/defaults/main.yml) to prevent missing-var failures; defaults carry the lowest precedence and can always be overridden
  • Define each variable in one place – Declare each variable only once (e.g., app_version) to avoid shadowing across group_vars, host_vars, play_vars, or extra_vars, and keep a single source of truth
  • Keep environment-specific variables in group_vars – Store Dev, Staging, and Prod values in their respective files (group_vars/dev.yml, etc.) so environment overrides are isolated and transparent
  • Use debug tasks to identify variable values – Add debug tasks such as msg: "Deploying version: {{ app_version }}" to confirm the value loaded at runtime and quickly spot precedence issues
  • Avoid overusing --extra-vars in production deployments – Reserve extra_vars for testing; in production, they can inadvertently override higher-priority variables and cause drift
  • Create a variable layering model – Follow a clear hierarchy: role defaults as the base, group_vars for environment overrides, host_vars only when necessary, and extra_vars for ad-hoc overrides to avoid surprises during playbook runs

Read more: 50 Ansible Best Practices to Follow [Tips & Tricks]

How can Spacelift help you with Ansible projects?

Spacelift’s vibrant ecosystem and excellent GitOps flow are helpful for managing and orchestrating Ansible. By introducing Spacelift on top of Ansible, you can easily create custom workflows based on pull requests and apply any necessary compliance checks for your organization.

Another advantage of using Spacelift is that you can manage infrastructure tools like Ansible, Terraform, Pulumi, AWS CloudFormation, and even Kubernetes from the same place and combine their stacks with building workflows across tools.

Our latest Ansible enhancements solve three of the biggest challenges engineers face when they are using Ansible:

  • Having a centralized place in which you can run your playbooks
  • Combining IaC with configuration management to create a single workflow
  • Getting insights into what ran and where

Provisioning, configuring, governing, and even orchestrating your containers can be performed with a single workflow, separating the elements into smaller chunks to identify issues more easily.

Would you like to see this in action, or just get a tl;dr? Check out this video showing you Spacelift’s Ansible functionality:

ansible product video thumbnail

If you want to learn more about using Spacelift with Ansible, check our documentation, read our Ansible guide, or book a demo with one of our engineers.

Key points

Ansible variable precedence is essential for maintaining clean, scalable, and reliable automation workflows. It defines how and where variables can be overridden, allowing for environment-specific settings, ad-hoc changes, and controlled defaults. 

Understanding this hierarchy helps avoid conflicts, supports flexible deployments, and simplifies troubleshooting. Best practices include isolating environment values in group_vars, using defaults in roles, and limiting --extra-vars use in production. Mastering precedence ensures consistent, adaptable, and maintainable Ansible operations.

Manage Ansible better with Spacelift

Managing large-scale playbook execution is hard. Spacelift enables you to automate Ansible playbook execution with visibility and control over resources, and seamlessly link provisioning and configuration workflows.

Learn more