SynfraCore
Synfracore
Start Learning
Navigation

Academies

Platform

RoadmapsLabsCertificationsInterviewPYQsAI AssistantCareer
Start Learning Free🗺️ Learning Roadmaps

AnsibleIntermediate

Real-world patterns, best practices, and deeper topics

✍️
Written by senior engineers. Reviewed for technical accuracy.· Updated 2025 · SynfraCore Ansible Team
Expert Content

Ansible — Intermediate

Variables

Three ways to define variables in Ansible:

1. Inline in Playbook (vars)

yaml
---
- name: Working with variables
  hosts: node1
  vars:
    http_port: 80
    app_user: deploy
    packages:
      - nginx
      - python3
      - git
  tasks:
    - name: Show HTTP port
      debug:
        msg: "HTTP port is {{ http_port }}"
    - name: Install packages
      yum:
        name: "{{ item }}"
        state: present
      loop: "{{ packages }}"

2. External Variable Files (vars_files)

yaml
# myvars.yml
port_nums: [21, 22, 23, 25, 80, 443]
users:
  bob:
    username: bob
    uid: 1122
    shell: /bin/bash
  lisa:
    username: lisa
    uid: 2233
    shell: /bin/sh
yaml
# variables-playbook.yml
---
- name: Working with variables
  hosts: node1
  vars_files: myvars.yml          # load variables from file
  tasks:
    - name: Show 2nd item in port_nums
      debug:
        msg: "SSH port is {{ port_nums[1] }}"     # array index

    - name: Show UID of bob
      debug:
        msg: "UID of bob is {{ users.bob.uid }}"  # dictionary access

vars_files preprocesses and loads variables at playbook start.

include_vars loads variables dynamically at task execution time:

yaml
- name: Load variables dynamically
  include_vars: myvars.yml

3. Registered Variables (capture task output)

yaml
- name: Save contents of /etc/os-release
  command: cat /etc/os-release
  register: os_release            # save command output to variable

- name: Display the result
  debug:
    msg: "OS release: {{ os_release.stdout }}"

- name: Detect CentOS
  debug:
    msg: "Running CentOS ..."
  when: os_release.stdout.find('CentOS') != -1   # use register in when

Ansible Facts

Ansible automatically gathers facts (system information) from managed nodes before running tasks. Access them via ansible_facts:

bash
# See all facts for a host
ansible node1 -m setup

# Filter specific facts
ansible node1 -m setup -a "filter=ansible_distribution"
ansible node1 -m setup -a "filter=ansible_memory_mb"
ansible node1 -m setup -a "filter=ansible_interfaces"
yaml
# Use facts in playbooks
- name: Show OS info
  debug:
    msg: "{{ ansible_facts['distribution'] }} {{ ansible_facts['distribution_version'] }}"
    # OR shorter: {{ ansible_distribution }} {{ ansible_distribution_version }}

- name: Only run on Ubuntu hosts
  debug:
    msg: "This is an Ubuntu Server."
  when: ansible_facts['distribution'] == "Ubuntu"

Play output showing when + facts in action:

TASK [Only run on Ubuntu hosts]
skipping: [node1]    # CentOS node — skipped
skipping: [node2]    # CentOS node — skipped
skipping: [node3]    # CentOS node — skipped
ok: [node4] => {     # Ubuntu node — ran!
    "msg": "This is an Ubuntu Server."
}

PLAY RECAP
node1: ok=1  changed=0  skipped=1
node4: ok=2  changed=0  skipped=0

Creating Custom Facts

Add permanent custom facts to managed nodes:

bash
# 1. Create a facts file on control node
[admin@control plays]$ cat cool.fact
[fun]
kiwi=fruit
matrix=movie
octopus='8 legs'
# Notice: facts filename must have .fact extension
yaml
# custom-facts.yml — deploy the facts file to managed nodes
---
- name: Adding custom facts to node1
  hosts: node1
  tasks:
    - name: Create the facts.d directory
      file:
        path: /etc/ansible/facts.d
        owner: admin
        mode: '0775'
        state: directory

    - name: Copy cool.fact to the facts.d directory
      copy:
        src: cool.fact
        dest: /etc/ansible/facts.d

    - name: Display custom fact
      debug:
        msg: "kiwi is a {{ ansible_local.cool.fun.kiwi }}"
        # ansible_local → custom facts → filename → section → key

Loops

Basic Loop

yaml
- name: Create multiple users
  user:
    name: "{{ item }}"
    state: present
  loop:
    - alice
    - bob
    - charlie

- name: Install multiple packages
  yum:
    name: "{{ item }}"
    state: present
  loop: "{{ packages }}"   # loop over a variable list

Loop over Dictionaries

yaml
# Cannot directly loop over a dictionary in Ansible — need dict2items filter
vars:
  employee:
    name: "Jordan Rivera"
    title: "Platform Engineer"
    company: "Acme Corp"

tasks:
  - name: Print each key-value pair
    debug:
      msg: "{{ item.key }}: {{ item.value }}"
    loop: "{{ employee | dict2items }}"
    # Output:
    # name: Jordan Rivera
    # title: Platform Engineer
    # company: Acme Corp

# Common mistake: loop: "{{ employee }}" causes error:
# "Invalid data passed to 'loop', it requires a list"
# Fix: always use dict2items to convert dict to list first

Loop with Multiple Values

yaml
- name: Create users with full details
  user:
    name: "{{ item.username }}"
    uid: "{{ item.uid }}"
    shell: "{{ item.shell }}"
  loop:
    - { username: alice, uid: 1001, shell: /bin/bash }
    - { username: bob,   uid: 1002, shell: /bin/sh }
    - { username: carol, uid: 1003, shell: /bin/bash }

Conditionals (when)

yaml
# Simple condition
- name: Start httpd on CentOS only
  service:
    name: httpd
    state: started
  when: ansible_facts['distribution'] == "CentOS"

# NOT condition
- name: Skip Ubuntu hosts
  debug:
    msg: "Not an Ubuntu host"
  when: ansible_facts['distribution'] != "Ubuntu"

# Multiple conditions (AND)
- name: Run only on CentOS 8
  debug:
    msg: "CentOS 8 host"
  when:
    - ansible_facts['distribution'] == "CentOS"
    - ansible_facts['distribution_major_version'] == "8"

# Multiple conditions (OR)
- name: Run on CentOS or RHEL
  yum:
    name: httpd
    state: present
  when: >
    ansible_facts['distribution'] == "CentOS" or
    ansible_facts['distribution'] == "RedHat"

# Using registered variable in when
- name: Check if file exists
  stat:
    path: /etc/nginx/nginx.conf
  register: nginx_conf

- name: Backup if exists
  copy:
    src: /etc/nginx/nginx.conf
    dest: /etc/nginx/nginx.conf.bak
    remote_src: yes
  when: nginx_conf.stat.exists

Jinja2 Templates

Jinja2 templates generate dynamic config files by substituting variables at runtime.

bash
# Create template: nginx.conf.j2
# .j2 extension indicates Jinja2 template
jinja2
# nginx.conf.j2
server {
    listen {{ http_port }};
    server_name {{ server_name }};

    location / {
        root {{ web_root }};
        index index.html;
    }
}
yaml
# Use template module in playbook
- name: Deploy nginx config
  template:
    src: nginx.conf.j2
    dest: /etc/nginx/conf.d/mysite.conf
  notify: Reload nginx

Jinja2 with SELinux status example (from book):

yaml
---
- name: Check SELinux Status
  hosts: all
  tasks:
    - name: Get SELinux status
      command: getenforce
      register: selinux_status

    - name: Create selinux.out using Jinja2
      template:
        src: selinux.j2
        dest: /tmp/selinux.out
jinja2
{# selinux.j2 — Jinja2 template #}
SELinux Status on {{ inventory_hostname }}: {{ selinux_status.stdout }}

{% if selinux_status.stdout == "Enforcing" %}
SELinux is ACTIVE — good security posture
{% elif selinux_status.stdout == "Permissive" %}
SELinux is permissive — logging but not enforcing
{% else %}
SELinux is DISABLED — consider enabling for production
{% endif %}

Output from running the playbook:

PLAY RECAP
node1: ok=3  changed=1  (SELinux: Enabled)
node2: ok=3  changed=1  (SELinux: Disabled)
node3: ok=3  changed=1  (SELinux: Enabled)
node4: ok=3  changed=1  (SELinux: Missing — uses AppArmor instead)

Error Handling with Blocks

Blocks group tasks and allow structured error handling similar to try/catch:

yaml
tasks:
  - name: Handle errors gracefully
    block:
      - name: Run uptime
        command: uptime

      - name: Run a bad command
        command: blabla           # this will fail

      - name: This task will NOT run
        debug:
          msg: "I won't run because the above failed"

    rescue:                       # runs when block fails (like catch)
      - name: Runs when the block failed
        debug:
          msg: "Block failed; let's try to fix it here ..."

    always:                       # always runs (like finally)
      - name: Always runs
        debug:
          msg: "This always runs regardless of success or failure"
yaml
# ignore_errors: yes — continue even if task fails
tasks:
  - name: Handling error example
    block:
      - name: run a command
        command: uptime

      - name: run a bad command
        command: blabla
        ignore_errors: yes        # this task fails but playbook continues

      - name: This task WILL run
        debug:
          msg: "I run because the above errors were ignored."

    rescue:                       # rescue does NOT run because errors were ignored
      - name: This will NOT run
        debug:
          msg: "Errors were ignored! ... not going to run."

Import vs Include

import is static — processed at parse time (before execution):

yaml
- name: Reboot the servers
  reboot:
    msg: Server is rebooting ...

- name: Run first playbook
  import_playbook: first-playbook.yml    # static — loaded at parse time

include is dynamic — processed at execution time:

yaml
- name: Run first playbook
  include: first-playbook.yml           # dynamic — loaded when reached

Key difference: import is evaluated before the playbook runs. include is evaluated as the playbook is running. Use import for most cases. Use include when the file to include is determined at runtime (variable-based).

Note: You can only import a playbook at the play level — you cannot import a play within another play.

Share:
Join our Community
Daily tips, job alerts, interview help — join engineers learning together
Up Next
🚀
AnsibleAdvanced
Production patterns, performance, security hardening
Also Worth Exploring
← Back to all Ansible modules
FundamentalsAdvanced