Ansible Custom Facts — Create Local Facts for Host-Specific Data
Introduction
Ansible gathers system facts automatically (OS, IP, memory, etc.), but you often need host-specific data that doesn't come from the system — application version, deployment environment, business unit, or custom health metrics. Custom facts let you define this data on each host and use it in playbooks just like built-in facts.
Local Facts (fact.d)
Place .fact files in /etc/ansible/facts.d/ on the remote host. Ansible reads them during gather_facts and makes them available as ansible_local.
INI Format
# /etc/ansible/facts.d/app.fact
[general]
version=2.5.1
environment=production
deployed_by=ansible
[database]
host=db01.example.com
port=5432
name=myapp_production
- name: Use local facts
hosts: all
tasks:
- name: Show app version
ansible.builtin.debug:
msg: "App v{{ ansible_local.app.general.version }} in {{ ansible_local.app.general.environment }}"
- name: Show database config
ansible.builtin.debug:
msg: "DB: {{ ansible_local.app.database.host }}:{{ ansible_local.app.database.port }}"
JSON Format
{
"app_name": "myapp",
"version": "2.5.1",
"features": ["api", "websocket", "caching"],
"limits": {
"max_connections": 1000,
"timeout_seconds": 30
}
}
Save as /etc/ansible/facts.d/app.fact — Ansible detects JSON automatically.
- name: Use JSON fact
ansible.builtin.debug:
msg: "Features: {{ ansible_local.app.features | join(', ') }}"
Executable Fact Scripts
Make the .fact file executable — Ansible runs it and parses the JSON output:
#!/bin/bash
# /etc/ansible/facts.d/health.fact (chmod +x)
LOAD=$(cat /proc/loadavg | cut -d' ' -f1)
MEM_PCT=$(free | awk '/Mem:/ {printf "%.1f", $3/$2*100}')
DISK_PCT=$(df / | awk 'NR==2 {print $5}' | tr -d '%')
UPTIME=$(cat /proc/uptime | cut -d' ' -f1 | cut -d'.' -f1)
cat <<EOF
{
"load": $LOAD,
"memory_percent": $MEM_PCT,
"disk_percent": $DISK_PCT,
"uptime_seconds": $UPTIME,
"healthy": $([ $(echo "$LOAD < 4" | bc) -eq 1 ] && echo "true" || echo "false")
}
EOF
- name: Check host health from custom fact
ansible.builtin.debug:
msg: "{{ inventory_hostname }}: load={{ ansible_local.health.load }}, healthy={{ ansible_local.health.healthy }}"
Deploy Custom Facts with Ansible
- name: Set up custom facts
hosts: all
become: true
tasks:
- name: Create facts directory
ansible.builtin.file:
path: /etc/ansible/facts.d
state: directory
mode: '0755'
- name: Deploy application fact
ansible.builtin.template:
src: templates/app.fact.j2
dest: /etc/ansible/facts.d/app.fact
mode: '0644'
- name: Deploy health check script
ansible.builtin.copy:
src: files/health.fact
dest: /etc/ansible/facts.d/health.fact
mode: '0755'
- name: Re-read facts after deploying
ansible.builtin.setup:
filter: ansible_local
- name: Verify custom facts
ansible.builtin.debug:
var: ansible_local
Template for Dynamic Facts
{# templates/app.fact.j2 #}
[deployment]
version={{ app_version }}
environment={{ env }}
deployed_at={{ ansible_date_time.iso8601 }}
deployed_by=ansible
host={{ inventory_hostname }}
[config]
workers={{ ansible_processor_vcpus }}
port={{ app_port | default(8080) }}
Custom Facts Module (set_fact)
For facts that don't need to persist on disk:
- name: Compute dynamic facts
ansible.builtin.set_fact:
is_primary: "{{ inventory_hostname == groups['db_cluster'][0] }}"
memory_tier: "{{ 'large' if ansible_memtotal_mb > 16384 else 'medium' if ansible_memtotal_mb > 4096 else 'small' }}"
app_url: "https://{{ inventory_hostname }}.{{ domain }}"
Local Facts vs set_fact
| Feature | Local Facts (facts.d) | set_fact |
|---|---|---|
| Stored on | Remote host disk | In-memory |
| Persists across runs | ✅ | ❌ (unless cacheable) |
Needs gather_facts | ✅ | ❌ |
| Namespace | ansible_local.* | Top-level |
| Dynamic computation | Via executable scripts | Via Jinja2 |
| Use case | Host identity, app version | Runtime calculations |
Practical Patterns
Application Version Tracking
# After deployment, update the version fact
- name: Deploy application
ansible.builtin.unarchive:
src: "app-{{ version }}.tar.gz"
dest: /opt/app/
- name: Update version fact
ansible.builtin.copy:
content: |
[app]
version={{ version }}
deployed={{ ansible_date_time.iso8601 }}
dest: /etc/ansible/facts.d/app.fact
become: true
# Next playbook run can check: ansible_local.app.app.version
Server Role Classification
# /etc/ansible/facts.d/role.fact
# [server]
# role=webserver
# tier=frontend
# datacenter=us-east-1
- name: Configure based on server role
ansible.builtin.include_role:
name: "{{ ansible_local.role.server.role }}"
when: ansible_local.role is defined
Troubleshooting
| Issue | Solution |
|---|---|
ansible_local is empty | Create /etc/ansible/facts.d/ and add .fact files |
| Fact file not parsed | Check file extension is .fact (not .json or .ini) |
| Executable fact fails | Check chmod +x, valid JSON output, and shebang line |
| Stale facts after deploy | Run ansible.builtin.setup: filter=ansible_local to re-read |
| Permission denied | Facts directory needs to be readable by the Ansible user |
Best Practices
- Use
facts.dfor persistent host identity — app version, environment, role - Use
set_factfor runtime calculations — computed values, conditionals - Deploy facts with Ansible — template
.factfiles during provisioning - Re-read after changes —
setup: filter=ansible_localafter updating facts - Executable facts for dynamic data — health metrics, service status
- Namespace with sections —
[app],[database],[network]for organization
Conclusion
Custom facts extend Ansible's knowledge of your hosts beyond OS-level data. Local fact files in /etc/ansible/facts.d/ persist across runs and provide host identity (version, role, environment). Executable fact scripts compute dynamic data like health metrics. Together with set_fact for runtime calculations, custom facts give you a complete data layer for intelligent automation decisions.