Ansible Playbooks for Your VMware Lab

Automate configuration across multiple Linux VMs using Ansible playbooks.

Introduction

30 Second Summary

Imagine you need to check a setting on every server in your company. With two servers, you SSH in twice and type the same command. With twenty servers, you start making mistakes. With two hundred, you give up and hope nothing breaks.

In this project, you will install Ansible on one of your lab VMs and use it to push configuration to multiple hosts simultaneously. You will write playbooks that enforce consistent packages, firewall rules, and Active Directory group resolution across your entire VMware lab.

What You'll Build

A functioning Ansible control node on your Cockpit server that manages your lab VMs through progressively more complex playbooks.

By the end of this project, you'll have:

  • An inventory file mapping your real lab network (10.1.9.0/24) into logical groups that Ansible can target.
  • A facts-gathering playbook that reads system information from all hosts without changing anything.
  • An enforcement playbook that ensures packages and firewall rules are identical across every managed host, demonstrating idempotency.
  • A validation playbook that confirms SSSD and AD group resolution works identically on every box.
  • Secret Mission: Write a playbook with handlers that only restarts a service when a configuration file actually changes.

Are there any prerequisites?

This project assumes you already have a VMware Workstation environment running with an ESXi host, a domain controller with LDAP configured, and three AlmaLinux 9 VMs at 10.1.9.101, 10.1.9.102, and 10.1.9.103. You should also have a Linux admin AD group already created on your domain controller.

Install Ansible and Configure SSH Access

To automate configuration across your lab, you need a single machine that acts as your command center. This machine is called the control node. It is the only place Ansible needs to be installed.

In this step, you will install Ansible on your Cockpit server (10.1.9.103), set up SSH key-based authentication so Ansible can reach your managed nodes without passwords, and create a dedicated project directory.

In this step, get ready to:
  • Install Ansible on your Cockpit server (10.1.9.103).
  • Set up SSH key-based authentication to your managed nodes (.101 and .102).
  • Create your project directory for all automation files.
Install Ansible on the Cockpit server (.103)

Your Cockpit server at 10.1.9.103 becomes the control node. The managed nodes (.101 and .102) do not need Ansible installed. They only need SSH and Python, both of which are already present on AlmaLinux 9.

What is a control node vs a managed node?

The control node is the one machine where Ansible is installed. You sit here, write playbooks here, and run commands from here.

A managed node is any machine Ansible reaches out to and configures. These just accept instructions over SSH. They do not know or care that Ansible exists.

  • SSH into your Cockpit server by running this command:
ssh [[ADMIN_USER="your AD admin username"]]@10.1.9.103

What does this command do?

This opens a secure shell connection to your Cockpit server at 10.1.9.103. Replace the username with the AD (Active Directory) account you use to log into your lab machines.

Once connected, you need to enable the EPEL repository so your system can find the Ansible package.

  • Enable the EPEL (Extra Packages for Enterprise Linux) repository by running:
sudo dnf install epel-release -y

Using an internal Nexus proxy instead?

If your Sonatype Nexus server at 10.1.9.101 is configured as a yum proxy, you can point your repo config there instead of fetching directly from EPEL. Use whichever package source your lab environment provides.

  • Install Ansible on your control node by running:
sudo dnf install ansible-core -y

Why ansible-core and not ansible?

The ansible-core package contains the engine and built-in modules. The full ansible package bundles extra community collections you may not need. Starting with ansible-core keeps your install lean. You can add collections later as needed.

  • Verify the installation by running:
ansible --version
  • You should see output showing the ansible-core version number, the Python interpreter path, and the config file location.

Command not found?

Make sure the EPEL repository was enabled successfully before installing ansible-core. Run sudo dnf repolist to confirm you see an epel entry in the list.

If the install completed but the command is not found, try opening a new shell session or running hash -r to refresh your command cache.

Why is my ansible --version command not working?

Set up SSH key-based authentication to managed nodes

Ansible connects to managed nodes over SSH. Since your hosts are AD-joined, you can authenticate with your AD credentials. However, SSH keys eliminate password prompts and are the enterprise standard for automation.

What are SSH keys?

SSH keys are a pair of files: a private key (stays on YOUR machine, never share it) and a public key (you copy it to machines you want to access). When you connect, the two halves match up and prove your identity without a password.

Think of it like a lock and key. You put your lock (public key) on every server door. Only your key (private key) can open those locks.

  • Generate an SSH key pair on your control node by running:
ssh-keygen -t ed25519 -C "ansible-control-node"

What does this command do?

  • -t ed25519 specifies the key type. Ed25519 is modern, fast, and secure.
  • -C "ansible-control-node" adds a comment to the key so you can identify it later.
  • The command will prompt you for a file location and a passphrase.
  • Press Enter three times to accept the default file path and set no passphrase (acceptable for a lab environment).

Now copy your public key to each managed node so they trust connections from your control node.

  • Copy your public key to the Nexus server (.101) by running:
ssh-copy-id [[ADMIN_USER="your AD admin username"]]@10.1.9.101
  • Enter your AD password when prompted. This is the last time you will need it for this host.
  • Copy your public key to the Nessus server (.102) by running:
ssh-copy-id [[ADMIN_USER="your AD admin username"]]@10.1.9.102
  • Enter your AD password when prompted.

Verify that passwordless access works to both managed nodes.

  • Test the connection to .101 by running:
ssh [[ADMIN_USER="your AD admin username"]]@10.1.9.101 "hostname"
  • Test the connection to .102 by running:
ssh [[ADMIN_USER="your AD admin username"]]@10.1.9.102 "hostname"
  • Both commands should return the hostname of each machine without asking for a password. If they do, your keys are working.

Still being asked for a password?

Check that the ssh-copy-id command completed successfully on the target host. A common issue is incorrect permissions on the remote user's home directory or the .ssh folder.

On the managed node, verify that ~/.ssh/authorized_keys exists and contains your public key. Also confirm that the ~/.ssh directory has permissions 700 and authorized_keys has permissions 600.

Why is SSH still asking me for a password after I copied my key?

Create your project directory

All your Ansible files for this project will live in a dedicated directory. This keeps your playbooks, inventory, and configuration organized in one place.

  • Create and move into your project directory by running these commands:
mkdir ~/ansible-lab
cd ~/ansible-lab
  • Confirm you are in the right directory by running pwd. You should see /home/your-username/ansible-lab in the output.

Your control node is ready. Ansible is installed, SSH keys let you reach both managed nodes without passwords, and you have a clean workspace. Next up, you will build an inventory file that maps your lab network and run your first Ansible commands.

Build Your Inventory and Run Ad-Hoc Commands

You have Ansible installed and passwordless SSH access working to both managed nodes.

Now you need to tell Ansible which machines to manage. You do this with an inventory file that maps your lab network into named groups. Once the inventory exists, you can fire commands at every host simultaneously from a single terminal.

In this step, get ready to:
  • Create an inventory file mapping your lab network.
  • Test connectivity to all hosts with ad-hoc commands.
  • Run informational commands across multiple hosts simultaneously.
Create the inventory file

The inventory tells Ansible which machines to manage and how to group them. Think of it as a class roster. The teacher (Ansible) needs to know who is in the class, what group they belong to, and where to find them.

What is an inventory?

An inventory is a file that lists all your managed hosts, their IP addresses, and how they are grouped. Without it, Ansible does not know which machines exist or how to reach them.

Groups let you target multiple machines at once. Instead of naming each host individually every time, you say "run this on all lab_servers" and Ansible handles the rest.

  • Create the inventory file by running:
nano inventory.ini
  • Type or paste the following contents into the file:
[lab_servers]
nexus   ansible_host=10.1.9.101
nessus  ansible_host=10.1.9.102

[lab_servers:vars]
ansible_user=[[ADMIN_USER="your AD username from SSH key setup"]]

What does this file do?

  • [lab_servers] creates a group called "lab_servers." You target this group to hit all machines in it at once.
  • nexus ansible_host=10.1.9.101 gives the machine a friendly name ("nexus") and maps it to its real IP address.
  • [lab_servers:vars] sets variables that apply to every host in the lab_servers group.
  • ansible_user tells Ansible which username to SSH in as on every host.
  • Save the file by pressing Ctrl+O, then Enter to confirm the filename.
  • Exit nano by pressing Ctrl+X.
  • Verify your inventory by running:
ansible-inventory -i inventory.ini --list

You should see a JSON representation showing your two hosts grouped under lab_servers with their IP addresses and the ansible_user variable listed.

Seeing a "unable to parse" error?

  • Check for typos in the group header. It must be exactly [lab_servers] with square brackets.
  • Make sure each host is on its own line with the format name ansible_host=IP.
  • Confirm the :vars section header matches the group name exactly: [lab_servers:vars].

Help me fix my inventory.ini file

How is this like Git remotes?

The inventory is like your .git/config file that lists all your remotes. Each remote has a name (nexus, nessus) and an address (the IP). Groups like [lab_servers] are like having a "push to all remotes at once" shortcut.

Run your first ad-hoc command

Ad-hoc commands are one-off Ansible actions you run directly from the command line without writing a playbook file. Think of them as quick questions you shout across the room versus writing a formal email.

  • Test connectivity to all hosts by running this command:
ansible lab_servers -i inventory.ini -m ansible.builtin.ping

What does this command do?

  • ansible is the CLI tool.
  • lab_servers is the target group from your inventory.
  • -i inventory.ini points to your roster file.
  • -m ansible.builtin.ping tells Ansible which module to use. This is an Ansible connectivity test, not a network ping.

What is a module?

A module is a single-purpose tool Ansible uses to do one specific thing. ansible.builtin.ping tests connectivity. ansible.builtin.dnf installs packages. You pick the right tool for the job.

The ansible.builtin part is called the FQCN (Fully Qualified Collection Name). It specifies exactly which collection the module comes from, like using a first and last name to avoid confusion.

You should see output like this for both hosts:

nexus | SUCCESS => {
    "changed": false,
    "ping": "pong"
}
nessus | SUCCESS => {
    "changed": false,
    "ping": "pong"
}

What should I see?

  • SUCCESS means Ansible reached the host and the module ran correctly.
  • "changed": false means nothing was modified on the remote machine. This was just a check.

Not seeing SUCCESS for both hosts?

  • If you see "UNREACHABLE," confirm you can still SSH to the host manually with ssh your-admin-user@10.1.9.101.
  • If you see "Permission denied," double check the ansible_user value in inventory.ini matches the username you used for SSH key setup.

Help me troubleshoot my Ansible ping failure

  • Gather facts about your hosts by running:
ansible lab_servers -i inventory.ini -m ansible.builtin.setup -a "filter=ansible_os_family"

This returns the OS family for each host (should show "RedHat" for AlmaLinux 9). The -a flag passes arguments to the module, in this case filtering to only show the OS family fact.

Run an ad-hoc command that does something

The ping module just confirms connectivity. Now try running real informational commands that hit both machines simultaneously.

  • Check how long each server has been running by running:
ansible lab_servers -i inventory.ini -m ansible.builtin.command -a "uptime"
  • Count how many packages are installed on each host by running:
ansible lab_servers -i inventory.ini -m ansible.builtin.shell -a "rpm -qa | wc -l"

Why does this matter?

Notice how one command hits both machines simultaneously and shows you the results side by side. With 2 servers it saves a minute. With 200 servers it saves your sanity.

This is the first taste of why Ansible exists in enterprise environments. You run one command from one terminal and get consistent results from every machine in the group.

How is this like Git?

Ad-hoc commands are like running git status. Quick, informational, no commitment. You would not write a whole script just to check the status of your repo.

✔️ Awesome, I've got everything!

Great. Double check you have inventory.ini saved in your ~/ansible-lab directory.

ⓧ I'd like to double check the full code

Here is the complete inventory.ini file for comparison:

[lab_servers]
nexus   ansible_host=10.1.9.101
nessus  ansible_host=10.1.9.102

[lab_servers:vars]
ansible_user=your-admin-user

Your inventory is set and both hosts are responding. Next up, you will write your first playbook to gather system facts from all hosts in a single run.

Write Your First Playbook (Read-Only Facts Gathering)

Ad-hoc commands are great for quick checks, but they vanish the moment you close your terminal. If you need to run the same set of checks tomorrow, you have to remember and retype every command.

A playbook solves this by capturing your automation steps in a reusable file. In this step, you will write a read-only playbook that gathers system facts from both managed hosts without changing anything on them.

In this step, get ready to:
  • Create a facts-gathering playbook in YAML format.
  • Run the playbook and review system information from both hosts.
  • Preview changes safely with check mode.
Create a facts-gathering playbook

A playbook is a YAML file where you write down all the automation steps you want Ansible to perform. Instead of typing ad-hoc commands one at a time, you put everything in a file and run it all at once.

What is a playbook?

Think of it this way: ad-hoc commands are like texting someone one instruction at a time. A playbook is like sending them a full checklist in one message.

The checklist is reusable, you can hand it to someone else, and you can run it again tomorrow without remembering what you typed last time.

What is YAML?

YAML (Yet Another Markup Language) is the format playbooks are written in. It uses indentation (spaces) to show structure, kind of like an outline for a book report.

Main topics are on the left, sub-topics are indented. If your indentation is wrong, Ansible will throw an error, so be careful with spaces.

  • Create your playbook file by running:
nano ~/ansible-lab/gather-facts.yml
  • Paste the following contents into the file:
---
- name: Gather and display system information
  hosts: lab_servers
  gather_facts: true

  tasks:
    - name: Display the operating system
      ansible.builtin.debug:
        msg: "{{ inventory_hostname }} runs {{ ansible_distribution }} {{ ansible_distribution_version }}"

    - name: Display the IP address
      ansible.builtin.debug:
        msg: "{{ inventory_hostname }} has IP {{ ansible_default_ipv4.address }}"

    - name: Show total memory in MB
      ansible.builtin.debug:
        msg: "{{ inventory_hostname }} has {{ ansible_memtotal_mb }} MB RAM"

What does this playbook do?

Here is what each section means:

  • --- marks the start of a YAML document. Every playbook starts with this.
  • - name: Gather and display system information is the play's human-readable name. A play is a section of the playbook that targets a specific group of hosts.
  • hosts: lab_servers tells this play which machines to target (your inventory group from inventory.ini).
  • gather_facts: true tells Ansible to scan each machine for system info (OS, IP, RAM) before running tasks. This info becomes available as variables.
  • tasks: starts the list of actions to perform. Tasks run top to bottom, in order.
  • ansible.builtin.debug is a module that prints a message to your screen. It does not change anything on the remote machine.
  • The {{ }} curly braces are Jinja2 templates. They get replaced with real values at runtime. For example, ansible_distribution becomes "AlmaLinux" and ansible_memtotal_mb becomes the RAM in megabytes.
  • Save the file by pressing Ctrl+O then Enter, and exit nano with Ctrl+X.
  • Confirm the file exists by running:
ls ~/ansible-lab/gather-facts.yml

You should see gather-facts.yml listed in the output.

File not showing up?

  • Make sure you saved with Ctrl+O before exiting nano. If you pressed Ctrl+X without saving, the file was not written.
  • Check that you are in the ~/ansible-lab directory by running pwd.

Help me troubleshoot my gather-facts.yml file not being created.

Run the playbook

Your playbook is ready. Time to run it against both managed hosts and see system facts reported side by side.

  • Run the playbook by executing this command:
ansible-playbook -i inventory.ini gather-facts.yml

What should I see?

You will see output like this:

PLAY [Gather and display system information] ************************************

TASK [Gathering Facts] **********************************************************
ok: [nexus]
ok: [nessus]

TASK [Display the operating system] *********************************************
ok: [nexus] => {
    "msg": "nexus runs AlmaLinux 9.x"
}
ok: [nessus] => {
    "msg": "nessus runs AlmaLinux 9.x"
}

PLAY RECAP **********************************************************************
nexus    : ok=4    changed=0    unreachable=0    failed=0
nessus   : ok=4    changed=0    unreachable=0    failed=0

The key details to look for:

  • PLAY [name] shows which play is running.
  • TASK [name] shows each task as it executes.
  • ok means the task ran successfully.
  • PLAY RECAP is the summary. ok=4 means 4 tasks completed. changed=0 means nothing was modified on the remote machines.
  • Verify that both hosts show SUCCESS for each task and changed=0 in the PLAY RECAP section.

This playbook only reads information. It does not modify anything on the remote machines.

Seeing unreachable or failed hosts?

  • Check that your inventory.ini is in the same directory where you are running the command. Run pwd to confirm you are in ~/ansible-lab.
  • Verify passwordless SSH still works by running ssh your-admin-user@10.1.9.101 "hostname". If it asks for a password, your SSH keys need to be re-copied.
  • Check for YAML indentation errors in gather-facts.yml. YAML uses spaces only, not tabs.

Help me debug my Ansible playbook that is failing with unreachable hosts.

Run in check mode (dry run)

Check mode runs the playbook in "what if" mode. Ansible goes through every task and tells you what it WOULD change, without actually touching anything.

Why use check mode on a read-only playbook?

For this read-only playbook the output is identical to a normal run (since it was not going to change anything anyway). But check mode becomes critical when you write playbooks that install packages or modify configs.

Building the habit now means you will always preview before you apply on playbooks that make real changes.

  • Run your playbook in check mode by adding the --check flag:
ansible-playbook -i inventory.ini gather-facts.yml --check
  • Confirm the output looks the same as your previous run, with ok=4 and changed=0 for both hosts.

When does check mode really matter?

In the next step, you will write a playbook that installs packages and manages firewall rules. Running that playbook with --check first lets you see exactly what would change before committing to it.

✔️ Awesome, I've got everything!

Great. Double check you have saved gather-facts.yml and confirmed both runs (normal and check mode) show changed=0.

ⓧ I'd like to double check the full code

Here is the complete gather-facts.yml file:

---
- name: Gather and display system information
  hosts: lab_servers
  gather_facts: true

  tasks:
    - name: Display the operating system
      ansible.builtin.debug:
        msg: "{{ inventory_hostname }} runs {{ ansible_distribution }} {{ ansible_distribution_version }}"

    - name: Display the IP address
      ansible.builtin.debug:
        msg: "{{ inventory_hostname }} has IP {{ ansible_default_ipv4.address }}"

    - name: Show total memory in MB
      ansible.builtin.debug:
        msg: "{{ inventory_hostname }} has {{ ansible_memtotal_mb }} MB RAM"

You just wrote and ran your first Ansible playbook. Next up, you will write an enforcement playbook that actually changes things on your managed hosts, ensuring packages and firewall rules are consistent across all of them.

Write an Enforcement Playbook

Your facts-gathering playbook proved that Ansible can reach both hosts and read their system information. That was read-only. Now it is time to write a playbook that actually changes things.

In this step, you will write a playbook that enforces a desired state across all managed hosts. Instead of saying "install vim," you are saying "make sure vim is present." If it already is, Ansible does nothing. If it is missing, Ansible installs it. This concept is called idempotency, and it is the single most important idea in Ansible.

In this step, get ready to:
  • Write an enforcement playbook that ensures packages and firewall rules are consistent across all hosts.
  • Preview changes safely with check mode before applying them.
  • Demonstrate idempotency by running the playbook a second time and seeing zero changes.
Write a playbook that enforces a desired state

Your playbook will ensure specific packages are installed and the firewall allows SSH on all managed hosts. You describe WHAT the machine should look like, and Ansible figures out what needs to happen to make it match.

What is idempotency?

Idempotency means running the same thing twice produces the same result. If you run a playbook that says "make sure vim is installed" and vim is already there, Ansible reports "ok" and moves on. It does not try to install vim again.

This is what makes Ansible safe to run repeatedly. In a real environment, you would run this playbook on a schedule. If an admin manually changes something, the next run detects the drift and fixes it automatically.

The firewall task in this playbook uses a module from the ansible.posix collection. Since you installed ansible-core (the minimal package), this collection is not bundled and needs to be installed separately.

  • Install the ansible.posix collection by running this command:
ansible-galaxy collection install ansible.posix

What is a collection?

A collection is a bundle of related Ansible modules. The ansible.posix collection contains modules for Linux-specific features like firewall management with firewalld.

Connection error when installing?

The ansible-galaxy command downloads from galaxy.ansible.com by default. If your lab restricts internet access, you will need to configure a local Galaxy server or download the collection tarball manually.

help me troubleshoot ansible-galaxy collection install failing in my lab environment

  • Create the enforcement playbook by running:
nano ~/ansible-lab/enforce-baseline.yml
  • Type the following contents into the file:
---
- name: Enforce baseline configuration on all lab servers
  hosts: lab_servers
  become: true

  tasks:
    - name: Ensure essential packages are installed
      ansible.builtin.dnf:
        name:
          - vim-enhanced
          - tmux
          - tree
          - firewalld
        state: present

    - name: Ensure firewalld is running and enabled
      ansible.builtin.service:
        name: firewalld
        state: started
        enabled: true

    - name: Ensure SSH is allowed through the firewall
      ansible.posix.firewalld:
        service: ssh
        permanent: true
        immediate: true
        state: enabled

What does this playbook do?

become: true means "run these tasks as root." Installing packages and managing firewalls require admin privileges. This is Ansible's way of running sudo behind the scenes.

state: present means "make sure this package exists on the system." If already installed, Ansible skips it. If missing, Ansible installs it.

state: started means "make sure this service is currently running." The enabled: true ensures it starts automatically on boot. These are separate concerns.

The ansible.posix.firewalld module manages firewall rules. permanent: true makes the rule persist across reboots, and immediate: true applies it right now without waiting for a restart.

  • Save the file by pressing Ctrl+O, then Enter, then Ctrl+X to exit nano.
  • Confirm the file exists by running:
ls ~/ansible-lab/

You should see enforce-baseline.yml listed alongside gather-facts.yml and inventory.ini.

File not showing up?

Make sure you saved correctly in nano. If you accidentally closed without saving, run the nano command again and re-enter the contents.

Check for YAML indentation errors by looking at the spacing. YAML uses exactly 2 spaces per indentation level. Tabs will cause errors.

help me fix my enforce-baseline.yml file

✔️ Awesome, I've got everything!

Great. Double check you have saved your file before moving on.

ⓧ I'd like to double check the full code

Here is the complete enforce-baseline.yml file for comparison:

---
- name: Enforce baseline configuration on all lab servers
  hosts: lab_servers
  become: true

  tasks:
    - name: Ensure essential packages are installed
      ansible.builtin.dnf:
        name:
          - vim-enhanced
          - tmux
          - tree
          - firewalld
        state: present

    - name: Ensure firewalld is running and enabled
      ansible.builtin.service:
        name: firewalld
        state: started
        enabled: true

    - name: Ensure SSH is allowed through the firewall
      ansible.posix.firewalld:
        service: ssh
        permanent: true
        immediate: true
        state: enabled
Preview with check mode, then apply

Check mode runs the playbook in "what if" mode. Ansible goes through every task and tells you what it WOULD change, without actually touching anything on the remote machines.

  • Preview what would change by running this dry run command:
ansible-playbook -i inventory.ini enforce-baseline.yml --check --diff

What do these flags do?

The --check flag tells Ansible to simulate the run without making changes. The --diff flag shows the specific differences that would be applied, similar to git diff.

You will see output indicating which packages would be installed and which services would be started. Nothing actually changes yet.

  • When you are satisfied with the preview, apply the playbook for real by running:
ansible-playbook -i inventory.ini enforce-baseline.yml

Watch the output. Tasks that made changes show "changed" in yellow. Tasks where the system already matched show "ok" in green.

Seeing permission denied errors?

Make sure your SSH user has passwordless sudo configured on the managed nodes. The become: true directive requires the remote user to elevate privileges without a password prompt.

If you need to supply a sudo password, add the --ask-become-pass flag to your ansible-playbook command.

help me fix permission errors when running my enforcement playbook

Run it again to see idempotency in action

The real power of Ansible shows when you run the same playbook a second time. Since the system already matches the desired state, there should be nothing to do.

  • Run the enforcement playbook again by running:
ansible-playbook -i inventory.ini enforce-baseline.yml

This time everything should show "ok" and the PLAY RECAP should report changed=0 for both hosts. The system already matches the desired state, so there is nothing to change.

Why does this matter in enterprise?

You can run the same playbook 100 times and it only makes changes when something has drifted from your desired state. If an admin manually uninstalls a package or changes a firewall rule, the next playbook run detects the drift and fixes it automatically.

With 2 servers this saves a minute. With 200 servers it saves your sanity. This guaranteed consistency is the core enterprise value of Ansible.

Still seeing changed tasks on the second run?

If a task keeps reporting "changed" on repeated runs, the module or task may not be truly idempotent. Check that you are using state: present (not state: latest) for packages, and state: started (not state: restarted) for services.

help me understand why my Ansible playbook is not idempotent

Both hosts are now identically configured. Next up, you will write a validation playbook that confirms your AD group resolution works the same way on every machine.

Validate AD Group Resolution Across All Hosts

In the previous step, you proved that Ansible can enforce a desired state and skip changes when nothing has drifted. That is powerful for packages and firewall rules.

Now you will tackle a harder problem: verifying that Active Directory group resolution works identically on every host. If one box has a misconfigured SSSD or a rogue local group shadowing your AD group, this playbook catches it instantly across all managed nodes in a single command.

In this step, get ready to:
  • Write a validation playbook that checks SSSD and AD group resolution.
  • Run the playbook and confirm both hosts resolve the AD group identically.
  • Add a local group shadowing check that catches configuration drift.
Write a validation playbook for SSSD and AD groups

Without Ansible, checking whether your AD group resolves correctly means SSH-ing into each box one at a time and running getent group. With 2 hosts that is annoying. With 200 hosts it is impossible to track manually.

  • Create the validation playbook by running:
nano ~/ansible-lab/validate-ad-groups.yml
  • Paste the following content into the editor:
---
- name: Validate AD group resolution on all hosts
  hosts: lab_servers
  become: true

  vars:
    ad_group_name: "your-linux-admin-group"

  tasks:
    - name: Check that SSSD service is running
      ansible.builtin.service:
        name: sssd
        state: started
      register: sssd_status

    - name: Verify AD group resolves via getent
      ansible.builtin.command:
        cmd: "getent group {{ ad_group_name }}"
      register: group_result
      changed_when: false

    - name: Display group resolution result
      ansible.builtin.debug:
        msg: "Group '{{ ad_group_name }}' resolves on {{ inventory_hostname }}: {{ group_result.stdout }}"

    - name: Fail if group does not resolve
      ansible.builtin.fail:
        msg: "AD group '{{ ad_group_name }}' does NOT resolve on {{ inventory_hostname }}. Check SSSD configuration."
      when: group_result.rc != 0

What does this playbook do?

This playbook checks AD group resolution on every host simultaneously. Here is what each new keyword does:

  • vars: defines reusable variables. Instead of typing your group name in multiple places, you define it once and reference it with {{ ad_group_name }} everywhere.
  • register: group_result captures the command output into a variable so later tasks can reference it.
  • changed_when: false tells Ansible this command is read-only. Without it, Ansible marks every command module run as "changed" since it cannot tell whether a shell command modified something.
  • when: group_result.rc != 0 is a conditional. rc stands for "return code." Linux commands return 0 for success and non-zero for failure. This line only runs the fail task if the previous command failed.
  • ansible.builtin.fail halts the playbook with a clear error message, telling you exactly which host has the problem.
  • Replace your-linux-admin-group in the vars section with the actual AD group name you configured for your Linux admin group.
  • Save the file by pressing Ctrl+O, then Enter, then Ctrl+X to exit nano.
  • Confirm the file exists by running:
ls ~/ansible-lab/validate-ad-groups.yml

You should see validate-ad-groups.yml listed in the output.

File not showing up?

  • Make sure you pressed Ctrl+O (letter O, not zero) to write the file before exiting nano.
  • Check that the file path matches exactly. Run ls ~/ansible-lab/ to see all files in the directory.

Help me figure out why my file didn't save in nano.

Run the validation playbook

This is where the enterprise value becomes obvious. One command checks AD group resolution on every host simultaneously.

  • Run the validation playbook by executing:
ansible-playbook -i inventory.ini validate-ad-groups.yml

What should I see?

If everything is configured correctly, you will see output like this:

TASK [Display group resolution result] ******************************************
ok: [nexus] => {
    "msg": "Group 'your-linux-admin-group' resolves on nexus: your-linux-admin-group:*:12345:user1,user2"
}
ok: [nessus] => {
    "msg": "Group 'your-linux-admin-group' resolves on nessus: your-linux-admin-group:*:12345:user1,user2"
}

Both hosts resolve the group identically. The PLAY RECAP at the bottom should show ok for all tasks and failed=0 for both hosts.

Seeing a failure on one host?

  • If the fail task triggers, it means getent group could not resolve your AD group on that host. Check that SSSD is properly configured and the host is joined to the domain.
  • If you see a permission error, make sure your user has sudo privileges on the managed hosts. The become: true directive requires sudo access.
  • If the SSSD task itself fails, the service may not be installed or configured on that host.

Help me debug why my AD group is not resolving on one of my Ansible managed hosts.

Add a check for local group shadowing

There is a subtle problem that can cause inconsistent behavior across hosts. If someone creates a local Linux group with the same name as your AD group on one machine, that machine resolves the local group instead of the AD group.

Why does local group shadowing happen?

On SSSD-joined hosts, /etc/nsswitch.conf typically has group: files sss which means local /etc/group is checked before AD. A local group with the same name wins.

This playbook addition catches that scenario instantly across all hosts.

  • Open the validation playbook again by running:
nano ~/ansible-lab/validate-ad-groups.yml
  • Scroll to the bottom of the tasks: list (after the "Fail if group does not resolve" task).
  • Add the following two tasks at the end:
    - name: Check for local group shadowing
      ansible.builtin.shell:
        cmd: "grep '^{{ ad_group_name }}:' /etc/group"
      register: local_group_check
      changed_when: false
      failed_when: false

    - name: Warn if local group shadows AD group
      ansible.builtin.debug:
        msg: "WARNING: Local group '{{ ad_group_name }}' exists in /etc/group on {{ inventory_hostname }}. This shadows the AD group!"
      when: local_group_check.rc == 0

What do these new keywords do?

  • failed_when: false tells Ansible not to treat a non-zero return code as a failure. Normally, if grep finds nothing (returns code 1), Ansible would halt the playbook. This override says "it is fine if grep finds nothing. That is actually the good outcome."
  • when: local_group_check.rc == 0 means the warning only displays if grep DID find a match. A return code of 0 from grep means "found it," which in this case is the bad scenario.
  • Save the file by pressing Ctrl+O, then Enter, then Ctrl+X to exit nano.
  • Run the updated playbook by executing:
ansible-playbook -i inventory.ini validate-ad-groups.yml

If no local group exists (correct behavior), the warning task is skipped. If a local group DOES exist, you get a clear warning telling you which host has the problem.

Why does this matter in enterprise?

This playbook is something you would run after onboarding new servers, after AD changes, or as a regular compliance check. It replaces manual "log into each box and check" with an automated, repeatable, documented audit.

With 2 servers it saves a minute. With 200 servers it saves your sanity.

Playbook fails with a YAML syntax error?

  • YAML is whitespace-sensitive. Make sure the new tasks are indented with exactly 4 spaces to align with the existing tasks above them.
  • Check that there are no tab characters. YAML requires spaces only.
  • Verify the when: line is indented to the same level as register: and changed_when: in the task above it.

Help me fix a YAML syntax error in my Ansible playbook.

✔️ Awesome, I've got everything!

Great. Double check you have saved validate-ad-groups.yml and that your last playbook run shows both hosts passing all tasks.

ⓧ I'd like to double check the full code

Here is the complete validate-ad-groups.yml with all six tasks:

---
- name: Validate AD group resolution on all hosts
  hosts: lab_servers
  become: true

  vars:
    ad_group_name: "your-linux-admin-group"

  tasks:
    - name: Check that SSSD service is running
      ansible.builtin.service:
        name: sssd
        state: started
      register: sssd_status

    - name: Verify AD group resolves via getent
      ansible.builtin.command:
        cmd: "getent group {{ ad_group_name }}"
      register: group_result
      changed_when: false

    - name: Display group resolution result
      ansible.builtin.debug:
        msg: "Group '{{ ad_group_name }}' resolves on {{ inventory_hostname }}: {{ group_result.stdout }}"

    - name: Fail if group does not resolve
      ansible.builtin.fail:
        msg: "AD group '{{ ad_group_name }}' does NOT resolve on {{ inventory_hostname }}. Check SSSD configuration."
      when: group_result.rc != 0

    - name: Check for local group shadowing
      ansible.builtin.shell:
        cmd: "grep '^{{ ad_group_name }}:' /etc/group"
      register: local_group_check
      changed_when: false
      failed_when: false

    - name: Warn if local group shadows AD group
      ansible.builtin.debug:
        msg: "WARNING: Local group '{{ ad_group_name }}' exists in /etc/group on {{ inventory_hostname }}. This shadows the AD group!"
      when: local_group_check.rc == 0

Secret mission

Handlers - Only Restart When Config Changes

Your enforcement playbook restarts services every single run, whether the config changed or not. That causes unnecessary disruptions. Write a smarter playbook that uses handlers to only restart SSSD when the configuration file actually changes.

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely on your on-premises VMware lab, so there are no ongoing costs.

Resources you used:

  • ansible-core installed on 10.1.9.103.
  • SSH public keys distributed to 10.1.9.101 and 10.1.9.102.
  • Playbook files in ~/ansible-lab/ on 10.1.9.103.

Keep everything running

No action needed. Choose this if you are still actively building on your Ansible lab or plan to add more playbooks soon.

  • Your VMs are on-premises with no ongoing cloud cost.
  • Ansible and your playbooks remain ready to use whenever you SSH back into 10.1.9.103.
  • Your SSH keys stay distributed, so passwordless access continues working.

Pause - I'll come back to this later

Shut down your VMs in VMware Workstation to free up system memory. Everything stays installed and configured for when you return.

  • In VMware Workstation, right-click each VM (10.1.9.101, 10.1.9.102, 10.1.9.103) and select Shut Down Guest.
  • When you are ready to resume, power the VMs back on and SSH into 10.1.9.103. Your ~/ansible-lab/ directory and SSH keys will still be in place.

Delete - I don't want to use this again

Remove all project resources and start fresh. Follow these steps to undo everything this project created.

  • Remove ansible-core from your control node by running:
sudo dnf remove ansible-core -y
  • Remove the playbook files by running:
rm -rf ~/ansible-lab
  • Remove your SSH public key from each managed node. SSH into 10.1.9.101 and 10.1.9.102 and edit ~/.ssh/authorized_keys to delete the line containing ansible-control-node (the comment you used when generating the key).
ssh your-admin-user@10.1.9.101 "sed -i '/ansible-control-node/d' ~/.ssh/authorized_keys"
ssh your-admin-user@10.1.9.102 "sed -i '/ansible-control-node/d' ~/.ssh/authorized_keys"
  • Remove the SSH key pair from your control node by running:
rm -f ~/.ssh/id_ed25519 ~/.ssh/id_ed25519.pub

Nice Work!

Nice Work!

Nice work! You have built a functioning Ansible control node that manages your VMware lab through progressively complex playbooks.

You've learned how to:

  • Installed and configured Ansible on a control node with SSH key-based access, managing multiple hosts across your VMware lab network simultaneously.
  • Wrote enforcement playbooks that guarantee package and firewall consistency across all hosts, demonstrating idempotency by showing zero changes on repeated runs.
  • Validated SSSD and Active Directory group resolution across all managed hosts in a single command, catching configuration drift and local group shadowing instantly.
  • Secret Mission: Implemented handlers so services only restart when configuration actually changes, preventing unnecessary disruptions.

Ready to quiz yourself?