Ansible - Using the set_fact module

3 minute read

Refreshed June 2026: the original solved this with six set_fact tasks, one per OS and webserver combination, each gated by a when. The cleaner pattern now is to put the values in a data structure and resolve them with a single set_fact that indexes into it. Same module, far fewer tasks. Module names are shown with FQCN and the output below was produced on ansible-core 2.19.

Background

I was updating an Ansible role to install Cacti and wanted to let the user pick the back-end webserver: Apache, NGINX, or Lighttpd. Each choice, on each OS family, needs a different group, owner, web root, and service handler. Spreading those across a pile of user-defined variables got messy fast, so I reached for set_fact to compute them from one simple choice.

What I needed to resolve for any given run:

  • cacti_web_group and cacti_web_owner: the webserver’s group and user, which differ by OS and webserver.
  • cacti_web_root: the default document root.
  • cacti_webserver_handler: the service name to notify for a restart.

The Original Approach

The 2016 version of this post wrote one set_fact task per combination, six in total, each with a when like this:

- name: setting fact Debian nginx
  ansible.builtin.set_fact:
    cacti_web_group: "www-data"
    cacti_web_owner: "www-data"
    cacti_web_root: "/usr/share/nginx/html"
    cacti_webserver_handler: "nginx"
  when: >
    ansible_os_family == "Debian" and
    cacti_webserver_type == "nginx"

It works, but it scales badly. Three webservers across two OS families is six tasks. Add a webserver or an OS and you are writing more nearly identical blocks.

The Cleaner Way: Data Plus One Lookup

Put the same information in a nested dictionary keyed by OS family and webserver type, then let one set_fact task select the right branch. Define the data once, for example in vars/main.yml:

cacti_webserver_settings:
  Debian:
    apache2:  {web_group: www-data, web_owner: www-data, web_root: /var/www/html,           webserver_handler: apache2}
    lighttpd: {web_group: www-data, web_owner: www-data, web_root: /var/www,                 webserver_handler: lighttpd}
    nginx:    {web_group: www-data, web_owner: www-data, web_root: /usr/share/nginx/html,    webserver_handler: nginx}
  RedHat:
    apache2:  {web_group: apache,   web_owner: apache,    web_root: /var/www/html,           webserver_handler: httpd}
    lighttpd: {web_group: lighttpd, web_owner: lighttpd,  web_root: /srv/www,                webserver_handler: lighttpd}
    nginx:    {web_group: nginx,    web_owner: nginx,     web_root: /usr/share/nginx/html,   webserver_handler: nginx}

Then the entire set_facts.yml task file becomes one task:

---
- name: Resolve webserver settings for this OS and type
  ansible.builtin.set_fact:
    cacti_web_group: "{{ _s.web_group }}"
    cacti_web_owner: "{{ _s.web_owner }}"
    cacti_web_root: "{{ _s.web_root }}"
    cacti_webserver_handler: "{{ _s.webserver_handler }}"
  vars:
    _s: "{{ cacti_webserver_settings[ansible_os_family][cacti_webserver_type] }}"

The user still sets one variable to choose the webserver:

cacti_webserver_type: nginx   # apache2 | lighttpd | nginx

And the lookup resolves the rest. On a Debian host with nginx selected, the facts come out as:

TASK [Show resolved facts] *****************************************************
ok: [localhost] => {
    "msg": "group=www-data owner=www-data root=/usr/share/nginx/html handler=nginx"
}

Six tasks became one task and a block of data. Adding a webserver is now a new key in the dictionary, not another task.

Using The Resolved Facts

From here the resolved facts are used like any other variable. A task that sets permissions on the web root:

- name: config_cacti | setting site permissions
  ansible.builtin.file:
    path: "{{ cacti_web_root }}/cacti-{{ cacti_version }}"
    state: directory
    recurse: true
    owner: "{{ cacti_web_owner }}"
    group: "{{ cacti_web_group }}"

And a task that needs to notify the right service handler:

- name: debian | installing php
  ansible.builtin.apt:
    name: php
    state: present
  notify:
    - "restart {{ cacti_webserver_handler }}"

Wiring It Into The Role

The role’s main task file runs the resolver first, with include_tasks (the bare include from 2016 is deprecated):

---
- ansible.builtin.include_tasks: set_facts.yml
- ansible.builtin.include_tasks: debian.yml
  when: ansible_os_family == "Debian"
- ansible.builtin.include_tasks: redhat.yml
  when: ansible_os_family == "RedHat"
- ansible.builtin.include_tasks: users.yml
- ansible.builtin.include_tasks: database.yml
- ansible.builtin.include_tasks: config_cacti.yml
- ansible.builtin.include_tasks: templates.yml
  when: cacti_import_templates

An example playbook applying the role with Apache as the back end:

---
- hosts: all
  become: true
  vars:
    cacti_webserver_type: apache2
    pri_domain_name: vagrant.local
  roles:
    - role: ansible-apache2
      when: cacti_webserver_type == "apache2"
    - role: ansible-lighttpd
      when: cacti_webserver_type == "lighttpd"
    - role: ansible-nginx
      when: cacti_webserver_type == "nginx"
    - role: ansible-mariadb-mysql
    - role: ansible-cacti

Conclusion

set_fact is still the right tool for computing derived variables at run time. The shift since 2016 is to feed it from a data structure instead of writing one conditional task per case. See the set_fact module docs for the current reference.

Enjoy!

Comments