Module 01 — Fundamentals, Connectivity & Your First Playbook

Updated 20 August 2026

Module 01 · Fundamentals, Connectivity & Your First Playbook

This module assumes zero Ansible knowledge. It takes you from "what is this tool" to a working, idempotent playbook — in the order you actually need to learn it.

🧠 concept → 🧪 exercise → ✅ expected result (hidden) → 🎯 interview questions (answers hidden)


Part A · Understanding what Ansible is

A1 · The one-sentence definition

The analogy. Think of a restaurant chain with fifty branches, and tonight the same dish has to be cooked in every kitchen. You could fly to each branch and cook it yourself — slow, and you cannot be in two places at once. You could install a robot chef in every kitchen — expensive, and now you own fifty robots that need patching and repairing. Or you could pick up the phone, call each kitchen in turn, and read out the recipe.

That third option is Ansible. The phone line is SSH, the recipe is your playbook, and the kitchen's own oven is the target's Python. When the call ends, nothing of yours is left behind in the kitchen — which is exactly what people mean when they say Ansible is "agentless".

Ansible is a push-based, agentless, declarative-ish configuration management and orchestration engine.

Every word in that sentence is a separate interview answer:

TermMeaningWhy it matters
Push-basedThe control node starts the connection and pushes config out to the targetsOpposite of Puppet/Chef, where agents pull from a master every ~30 min
AgentlessNo daemon on managed nodes. SSH for Linux, WinRM/PSRP for WindowsNo agent to install, patch, monitor, or carry a CVE
Declarative-ishYou describe desired state, but tasks still execute top-down in orderNot a dependency graph like Terraform — order matters
OrchestrationCoordinates work across hosts in a defined sequenceRolling restarts, DB before app — a bash loop cannot do this

A2 · The architecture

The analogy. Think of a head office and its branch shops. The head office holds the manuals, the staff list and the phone — and it is the only place that needs any of them. A branch needs just two things to be useful: a working phone line, and someone on the end of it who speaks your language.

The head office is the control node. The phone line is SSH, and the person who understands you is Python. That two-item list is the complete requirement for a managed node. And notice what the branches never receive: a filing cabinet of their own. Nothing of yours sits on the server between runs, which is why adding your five-hundredth machine costs one line in a text file rather than an installation.

There are only two roles a machine can play.

Diagram source
flowchart TD
    A["🖥️ CONTROL NODE<br>ansible-core installed HERE only<br>Python 3.9+<br>Holds inventory, playbooks, roles"]
    A -->|"SSH · port 22"| B["web01<br>needs: sshd + Python"]
    A -->|"SSH · port 22"| C["web02<br>needs: sshd + Python"]
    A -->|"SSH · port 22"| D["db01<br>needs: sshd + Python"]
    A -->|"WinRM · port 5985"| E["win01<br>needs: PowerShell<br>no Python"]
    A -->|"network_cli"| F["switch01<br>no Python at all"]

Read the diagram carefully. Ansible exists on exactly one machine. The targets have nothing installed for Ansible's benefit — they have sshd because every Linux server already has sshd, and they have Python because every modern Linux distribution ships Python. That is the entire "agentless" claim, and it is why Ansible spreads through organisations faster than agent-based tools.

A control node cannot be native Windows. WSL2 works, native Windows does not. Managed nodes can be Windows, over WinRM. This exact asymmetry is a frequent trap question.

🎯 Interview questions — What Ansible is

Q. What is Ansible and what are its primary use cases?

An open-source automation engine used for configuration management, application deployment, and orchestration. It is agentless, connecting over SSH (Linux) or WinRM (Windows), and is driven by YAML playbooks.

Strong close: "It covers provisioning, config management and orchestration in one tool, which is why teams reach for it instead of maintaining a pile of shell scripts."

Q. Describe Ansible's agentless architecture.

No agent or special software is installed on the managed nodes. The control node opens an SSH connection, copies a self-contained Python module into a temporary directory, executes it with the target's own Python interpreter, reads the JSON result, and deletes the temp directory.

The operational argument to voice: nothing to install, patch, monitor or CVE-track across thousands of nodes, and no inbound port beyond SSH, which is already open.

Q. Agentless vs Puppet/Chef — when would you NOT pick Ansible?

Puppet and Chef are pull-based and agent-based: an agent on each node polls a master roughly every 30 minutes and re-applies state. Ansible is push-based and agentless.

Pick Puppet/Chef when you need continuous drift correction with no central scheduler, or at a scale where pushing to tens of thousands of nodes becomes the bottleneck.

Pick Ansible for orchestration, network devices, ad-hoc operations, and a far lower onboarding cost.

Q. Ansible vs Terraform?

Terraform is declarative provisioning with state — it creates infrastructure and records it in a state file, so it can compute a diff and destroy cleanly.

Ansible is configuration management — it configures things that already exist and keeps no state file.

The common production pattern: Terraform provisions, Ansible configures, joined by dynamic inventory. Ansible can create cloud resources, but with no state it cannot reliably destroy them or detect orphans.

Q. How does Ansible connect to Linux vs Windows vs network devices?

Linux — SSH, and the target needs Python.

Windows — WinRM or PSRP, with PowerShell-based modules; no Python needed on the target.

Network devices — connection: network_cli or httpapi, no Python on the device at all.

Bonus: the control node itself must be Linux, macOS or WSL2 — never native Windows.


A3 · Installing Ansible on the control node

The analogy. Think of buying a phone for the head office. You buy one, for the one desk that makes the calls. You do not buy a phone for every branch, because the branches already have one — that was the whole arrangement in A2.

So apt install ansible runs on exactly one machine: the one you work from. It never runs on the servers you are managing. If you ever catch yourself logging into a target server in order to install Ansible on it, stop — you have misread the architecture.

You install Ansible in one place only. Pick whichever matches your machine.

bash
# Ubuntu / Debian
sudo apt update && sudo apt install -y ansible

# RHEL / Rocky / AlmaLinux 9
sudo dnf install -y ansible-core

# macOS
brew install ansible

# Any OS, newest version, isolated from the system Python  (RECOMMENDED)
python3 -m venv ~/.venvs/ansible
source ~/.venvs/ansible/bin/activate
pip install ansible-core
ansible vs ansible-core — a distinction interviewers like.

ansible-core is the engine plus the ansible.builtin modules only.

ansible is that same engine bundled with ~85 community collections (AWS, Azure, Docker, Kubernetes, and so on).

In production you install ansible-core and declare the collections you actually need in a requirements.yml, so upgrades are controlled instead of surprising.

🧪 Exercise A3.1 — Install Ansible and read what it tells you
bash
ansible --version
Expected result — click to reveal
plain text
ansible [core 2.16.3]
  config file = None
  configured module search path = ['/home/zaeem/.ansible/plugins/modules', '/usr/share/ansible/plugins/modules']
  ansible python module location = /usr/lib/python3/dist-packages/ansible
  ansible collection location = /home/zaeem/.ansible/collections:/usr/share/ansible/collections
  executable location = /usr/bin/ansible
  python version = 3.11.2 (main, ...) [GCC 12.2.0]
  jinja version = 3.1.2
  libyaml = True

Three lines are worth understanding right now:

  • core 2.16.3 — the engine version. Modules come and go between versions, so this is the first thing you check when documentation and reality disagree.
  • config file = None — you have not created a configuration file yet. That is expected and fine; we cover configuration in Part C once you have felt the need for it.
  • python version — this is the control node's Python. The managed nodes use their own, which can be a different version entirely.
🧪 Exercise A3.2 — Prove Ansible is not on the managed nodes
bash
# Assuming you can already SSH to a server manually
ssh [email protected] 'which ansible; echo "exit code: $?"'
ssh [email protected] 'python3 --version'
Expected result — click to reveal
plain text
exit code: 1
Python 3.11.2

which ansible found nothing and returned exit code 1 — Ansible is absent from the target, exactly as designed. But Python is there, which is the only real dependency.

This is the entire agentless model demonstrated in two commands. Keep it in mind for the rest of the module: everything you write lives on the control node, and nothing is ever permanently installed on the targets.

🎯 Interview questions — Installation

Q. Where does Ansible need to be installed?

On the control node only. Managed nodes need nothing installed for Ansible's sake — just an SSH daemon and Python, both of which a normal Linux server already has.

Q. What is the difference between ansible and ansible-core?

ansible-core is the engine plus ansible.builtin modules. The ansible package is that engine bundled with a large set of community collections.

Production preference: install ansible-core and pin the collections you need in requirements.yml, so a collection upgrade is a deliberate act rather than a side effect of a package update.

Q. What are the requirements on a managed node?

SSH access and Python 3. That is the whole list.

For a bare host with no Python at all, bootstrap it using the raw module, which sends a literal SSH command and therefore needs no Python. Windows targets need WinRM and PowerShell instead, and network devices need neither.


A4 · Module names and collections — why ansible.builtin.command?

The analogy. Think of being told to "call Ahmed". In a small office with one Ahmed, that works perfectly and nobody thinks twice. In a company with four of them it quietly stops working — you ring the wrong person and may never find out. "Call Ahmed from accounts, at head office" removes the doubt for good, at the cost of being slightly longer to say.

copy is "Ahmed". ansible.builtin.copy is the full name with the department and the site attached. Ansible spent its first decade as the small office; when it split into hundreds of collections it became the large company overnight, and short names stopped being unique. That is the entire reason the long form exists.

Every module in this module is written as ansible.builtin.copy rather than plain copy. If you learned Ansible from older material, or you are reading a tutorial written before 2020, you will only have seen the short form. Here is why both exist.

What happened in Ansible 2.10

Before 2.10, Ansible was one monolithic package. Every module lived inside it with a single flat name:

yaml
# The old style - still valid, but ambiguous
- yum: name=nginx state=present
- copy: src=a dest=b

In 2.10 the project split in two:

  • ansible-core — the engine plus roughly 90 essential modules
  • Collections — everything else, moved out into independently versioned bundles distributed through Galaxy: amazon.aws, community.docker, kubernetes.core, ansible.posix, community.general, and hundreds more

Once modules could arrive from hundreds of separate sources, flat names stopped being unique. So every module gained a Fully Qualified Collection Name (FQCN).

The three terms, precisely

Tutorials use namespace, collection and Galaxy loosely, which is a large part of why FQCN looks arbitrary. They are three distinct things.

Collection — a distributable, versioned package of Ansible content.

It can contain modules, roles, and plugins (filter, lookup, callback, connection, inventory), plus playbooks and docs. It has a version number and can declare dependencies on other collections.

Think: an npm package, or a pip package — but for Ansible content.

Namespace — the owner prefix on a collection name.

It identifies who publishes and maintains it, and is reserved on Galaxy so two vendors cannot both ship a collection called aws.

Think: a GitHub organisation, or an npm scope like @angular.

Ansible Galaxy — the public registry at galaxy.ansible.com where collections and roles are published and downloaded from. ansible-galaxy is also the CLI that talks to it.

Think: PyPI, npmjs.com, or Docker Hub.

So amazon.aws means: the collection named aws, published under the amazon namespace, obtainable from Galaxy.

Namespaces you will meet constantly

NamespaceWho owns it, and what it means for you
ansibleThe Ansible core team. ansible.builtin ships inside ansible-core; ansible.posix and ansible.netcommon are separate installs
communityCommunity-maintained. community.general is a large grab-bag; community.docker, community.mysql, community.crypto. Good quality, but no vendor support contract
amazon / google / azureCloud vendor collections — amazon.aws, google.cloud, azure.azcollection
cisco / arista / junipernetworksNetwork vendor collections for their own devices
redhatRed Hat certified content, typically consumed through Automation Hub rather than Galaxy
kuberneteskubernetes.core — the k8s and helm modules

The ecosystem analogy that makes it click

ConceptAnsiblePythonJavaScript
The packagecollectionpackagepackage
The owner prefixnamespacescope, e.g. @angular
The registryGalaxyPyPInpmjs.com
The install CLIansible-galaxypipnpm
The dependency filerequirements.ymlrequirements.txtpackage.json
The private registryAutomation Hubprivate PyPIprivate npm registry

Reading an FQCN

javascript
ansible . builtin . command
   |         |         |
   |         |         +-- module name
   |         +-- collection
   +-- namespace

ansible.builtin is simply the collection name for the modules that ship inside ansible-core. So every module you already recognise belongs to it:

Old short nameFQCNShips in
copyansible.builtin.copyansible-core
serviceansible.builtin.serviceansible-core
yum / aptansible.builtin.yum / ansible.builtin.aptansible-core
authorized_keyansible.posix.authorized_keya separate collection
ec2_instanceamazon.aws.ec2_instancea separate collection
docker_containercommunity.docker.docker_containera separate collection
Notice the last three rows. authorized_key, ec2_instance and docker_container are not in core — you must ansible-galaxy collection install their collections before you can use them. The FQCN tells you at a glance where a module comes from, and therefore whether you need to install anything. That is genuinely useful information the short name simply does not carry.

Do short names still work?

Yes. - copy: is valid today and is not being removed. Unqualified names resolve through ansible.legacy, which covers the core modules plus any local library/ overrides you have written.

So why write the longer form?

  1. Name collisions. More than one collection can define a module called user, instance or container. With a short name, which one you get depends on collection search order — and that order changes the moment somebody installs a new collection. An FQCN is unambiguous permanently.
  2. It is the linted standard. ansible-lint has an fqcn rule that flags short names. Any team with CI will reject them in review.
  3. It signals currency in an interview. Writing ansible.builtin.package shows you learned Ansible after the 2.10 split. Writing bare yum: suggests a 2019 tutorial. Small, but it registers.

What is actually inside a collection

A collection is a directory tree with a fixed layout. This is what arrives when you install one:

plain text
ansible_collections/amazon/aws/
  galaxy.yml              <- name, version, dependencies, author (the manifest)
  plugins/
    modules/              <- modules        e.g. amazon.aws.ec2_instance
    inventory/            <- inventory plugins  e.g. amazon.aws.aws_ec2
    lookup/               <- lookup plugins     e.g. amazon.aws.aws_secret
    filter/               <- Jinja2 filters
    connection/           <- connection plugins e.g. amazon.aws.aws_ssm
  roles/                  <- roles shipped inside the collection
  playbooks/              <- ready-made playbooks
  meta/runtime.yml        <- minimum ansible-core version, module redirects
  docs/
This is why the dynamic inventory file in Part B is written as plugin: amazon.aws.aws_ec2. That is not a module — it is an inventory plugin living in the same collection. A collection bundles every kind of Ansible content for one problem domain, which is precisely why the unit exists.

Roles vs collections — a distinction people get wrong

Both are published on Galaxy, and they are not the same thing.

UnitWhat it is
RoleThe older unit. A packaged set of tasks, handlers, templates, files and defaults for one job — "install and configure nginx". It cannot contain modules or plugins. Installed with ansible-galaxy role install geerlingguy.nginx
CollectionThe modern unit. Can contain modules, plugins, roles and playbooks together, is versioned, and can declare dependencies. Installed with ansible-galaxy collection install amazon.aws
The one-line answer for an interview: a collection is a superset — it can contain roles, but a role cannot contain a collection. Roles are still perfectly valid and widely used; collections are the distribution and packaging format around them. (Roles themselves are Module 05.)

Galaxy vs Automation Hub

RegistryWhat it is
Ansible GalaxyThe free public registry. Anyone can publish. Community-maintained, no support contract — vet quality yourself
Red Hat Automation HubRed Hat certified collections, tested and supported under a subscription. Part of Ansible Automation Platform
Private Automation HubSelf-hosted, on-premises. Enterprises use it to mirror approved collections and publish internal ones, so production never pulls straight from the public internet
Worth saying in an interview: in a regulated environment you do not install collections from public Galaxy at run time. You mirror vetted versions into a Private Automation Hub (or an Artifactory repo), point ansible.cfg at it via galaxy_server_list, and pin versions in requirements.yml. That answer signals you have worked somewhere with a security review process.

The ansible-galaxy commands worth knowing

bash
# Consuming
ansible-galaxy collection install amazon.aws
ansible-galaxy collection install amazon.aws:==7.2.0     # pin an exact version
ansible-galaxy collection install -r requirements.yml    # the production way
ansible-galaxy collection list                           # what do I have?
ansible-galaxy role install geerlingguy.nginx            # the older role format

# Producing your own
ansible-galaxy collection init mycompany.platform        # scaffold the tree
ansible-galaxy collection build                          # produce a .tar.gz
ansible-galaxy collection publish mycompany-platform-1.0.0.tar.gz

Where collections land on disk

Searched in this order, first match wins:

  1. ./collections/ inside the project directory
  2. ~/.ansible/collections/ (the default install target)
  3. /usr/share/ansible/collections/

Override with collections_path in ansible.cfg or the ANSIBLE_COLLECTIONS_PATH environment variable. Option 1 is the good habit for a real project — collections install next to the playbook, so the project is self-contained and CI reproduces it exactly.

ansible.builtin vs ansible.legacy — the subtlety

They are not the same thing, and the difference matters exactly once:

  • ansible.builtin.copy — strictly the module shipped inside ansible-core. Nothing can override it.
  • ansible.legacy.copy — core's version, unless a local library/copy.py in your project overrides it.

Unqualified names resolve via ansible.legacy, which is why dropping a custom module into library/ can shadow a builtin one. If you ever write a custom module that replaces a core module, that is the mechanism — and it is a good detail to have ready if an interviewer pushes past the surface of FQCN.

🧪 Exercise A4.1 — See which collections you actually have
bash
ansible-galaxy collection list
ansible-doc -l | wc -l
ansible-doc -l | grep -c '^ansible\.builtin\.'
ansible-doc ansible.builtin.copy | head -5
Expected result — click to reveal

On a minimal ansible-core install:

plain text
# /usr/lib/python3/dist-packages/ansible_collections
Collection        Version
----------------- -------
ansible.builtin   2.16.3

On a full ansible package install you will instead see dozens of lines:

plain text
Collection                    Version
----------------------------- -------
amazon.aws                    7.2.0
ansible.builtin               2.16.3
ansible.posix                 1.5.4
community.docker              3.5.0
community.general             8.3.0
kubernetes.core               3.0.0
...

This is the practical difference between ansible-core and ansible that Part A3 described — now visible as a list. If amazon.aws does not appear here, then amazon.aws.ec2_instance will fail with "couldn't resolve module/action", and the fix is ansible-galaxy collection install amazon.aws, not a change to your playbook.

For a real project, pin them in a requirements.yml instead of installing ad-hoc:

yaml
---
collections:
  - name: amazon.aws
    version: "7.2.0"
  - name: community.docker
    version: "3.5.0"
bash
ansible-galaxy collection install -r requirements.yml

That file is what makes a playbook reproducible on someone else's machine and in CI — without it, a teammate installing today gets different collection versions than you did, and the playbook breaks for reasons that look like magic.

🎯 Interview questions — Collections & FQCN

Q. What is an FQCN and why does it exist?

Fully Qualified Collection Name — namespace.collection.module, for example ansible.builtin.copy or amazon.aws.ec2_instance.

It exists because Ansible 2.10 split the monolithic package into ansible-core plus independently versioned collections. Once modules can come from hundreds of sources, flat names are no longer unique, so the FQCN removes the ambiguity.

Q. Are short module names deprecated?

Not removed, and still functional — unqualified names resolve through ansible.legacy. But FQCN is the recommended style, ansible-lint flags short names via its fqcn rule, and any team with CI will expect the long form.

The real argument is collision safety: with a short name, which module you get depends on collection search order, and that changes when someone installs a new collection.

Q. What is the difference between ansible.builtin and ansible.legacy?

ansible.builtin is strictly the module shipped in ansible-core — nothing can override it.

ansible.legacy is core's version plus any local library/ override in your project, and it is what unqualified names resolve to.

That is precisely the mechanism that lets a custom module in library/ shadow a builtin one — useful when you need to patch a core module's behaviour without forking Ansible.

Q. Explain namespace, collection and Galaxy.

Collection — a versioned, distributable package of Ansible content: modules, plugins, roles and playbooks, with its own dependencies.

Namespace — the owner prefix on a collection name, reserved on Galaxy so two vendors cannot both publish aws. Like a GitHub org or an npm scope.

Ansible Galaxy — the public registry those collections are published to and downloaded from, and the name of the ansible-galaxy CLI.

So amazon.aws is the collection aws, published under the amazon namespace, obtained from Galaxy. The analogy that lands: collection is to Galaxy as package is to PyPI, and ansible-galaxy is pip.

Q. What is the difference between a role and a collection?

A role is the older unit: tasks, handlers, templates, files and defaults packaged for one job. It cannot contain modules or plugins.

A collection is the modern unit and a superset: it can contain modules, plugins, roles and playbooks together, is versioned, and can declare dependencies on other collections.

One line: a collection can contain roles; a role cannot contain a collection. Roles are not deprecated — collections are the packaging and distribution format around them.

Q. Galaxy vs Automation Hub — when would you use each?

Galaxy is the free public registry, open to anyone, community-maintained with no support contract.

Red Hat Automation Hub carries certified collections that are tested and supported under an Ansible Automation Platform subscription.

Private Automation Hub is the self-hosted version, which is what regulated environments actually use — you mirror vetted collection versions internally, point ansible.cfg at it with galaxy_server_list, and production never pulls from the public internet at run time.

Q. How do you manage collection dependencies in a real project?

A requirements.yml with pinned versions, installed via ansible-galaxy collection install -r requirements.yml, committed to the repository and installed as a step in CI.

Without pinning, a teammate or a CI runner installing next month gets different collection versions than you did, and the playbook breaks for reasons that look inexplicable. It is the same discipline as a lockfile in any other ecosystem.


Part B · Reaching your servers

Before Ansible can configure anything, two questions must be answered: which machines exist (the inventory) and how do I log into them (SSH). Everything in Part B is about those two questions. Get them right and the rest of Ansible is straightforward.

B1 · The inventory — telling Ansible what exists

The analogy. Think of the contacts app on your phone. Every contact has a name you recognise and a number the phone actually dials — two different things, and you almost never think about the number. You also sort people into groups: Family, Work, Cricket team. The same person sits happily in two groups and nobody finds that confusing.

An inventory is that contacts app. web01 is the name you recognise, ansible_host is the number that actually gets dialled, and web, db and production are the groups. The "group of groups" idea later in this section is just a Work group that contains Work — London and Work — Karachi.

The inventory is a file listing your managed nodes and organising them into groups.

INI format — quick to write, common in tutorials

plain text
[web]
web01.example.com
web02.example.com
web[03:05].example.com          # range expansion -> web03, web04, web05

[db]
db01.example.com ansible_host=10.0.2.11 ansible_port=2222

[prod:children]                 # a group made of other groups
web
db

[web:vars]
http_port=80

YAML format — what you should use in production

yaml
all:
  children:
    prod:
      children:
        web:
          hosts:
            web01.example.com:
            web02.example.com:
          vars:
            http_port: 80
        db:
          hosts:
            db01.example.com:
              ansible_host: 10.0.2.11
              ansible_port: 2222

Three groups you get for free, without writing them

  • all — every host in the inventory
  • ungrouped — hosts belonging to no group
  • localhost — always available, and implicitly runs locally rather than over SSH

Host patterns

PatternSelects
all or *everything in the inventory
web:dbunion — in web OR db
web:&prodintersection — in web AND prod
web:!web01exclusion — web except web01
~web\d+\.example\.comregex match, signalled by the leading tilde
web[0:2]positional slice — the first three hosts of the group
🧪 Exercise B1.1 — Write an inventory and interrogate it

Save as inventory/hosts.yml:

yaml
all:
  children:
    prod:
      children:
        web:
          hosts:
            web01:
            web02:
            web03:
        db:
          hosts:
            db01:
    staging:
      hosts:
        stg01:

Now run all four commands and predict each answer before you press enter:

bash
ansible-inventory -i inventory/hosts.yml --graph
ansible -i inventory/hosts.yml web --list-hosts
ansible -i inventory/hosts.yml 'web:!web01' --list-hosts
ansible -i inventory/hosts.yml 'prod:&web' --list-hosts
Expected result — click to reveal
plain text
@all:
  |--@prod:
  |  |--@web:
  |  |  |--web01
  |  |  |--web02
  |  |  |--web03
  |  |--@db:
  |  |  |--db01
  |--@staging:
  |  |--stg01
  |--@ungrouped:
plain text
hosts (3):
  web01
  web02
  web03
plain text
hosts (2):
  web02
  web03
plain text
hosts (3):
  web01
  web02
  web03

Two things to take away. First, --graph renders the nesting and is the fastest way to sanity-check a children: structure you have just written.

Second, prod:&web returned all three web hosts. That surprises people — but web sits inside prod, so every web host is also a prod host. Group membership is inherited downward, and that subtlety is exactly what an interviewer probes when they ask about intersections.

Notice you had to type -i inventory/hosts.yml four times. Hold that irritation — Part C fixes it.

🧪 Exercise B1.2 — Confirm a pattern before you trust it

You are about to restart a service on production web servers, excluding one node that is mid-investigation. Verify the pattern selects what you think it does — before running anything destructive.

bash
ansible -i inventory/hosts.yml 'prod:&web:!web02' --list-hosts
Expected result — click to reveal
plain text
hosts (2):
  web01
  web03

Patterns compose left to right: start with prod, intersect with web, then subtract web02.

Make this a reflex. --list-hosts costs nothing, connects to nothing, and changes nothing. Running it before any destructive command is the single cheapest habit in Ansible operations, and saying "I always dry-run the host pattern first" in an interview signals that you have actually operated this tool rather than only studied it.

🎯 Interview questions — Inventory

Q. What is an Ansible inventory?

The list of managed nodes Ansible targets, plus their grouping and connection variables. It can be INI or YAML, and it supports groups, nested groups via children, and both group-level and host-level variables.

The default location is /etc/ansible/hosts, overridden with -i on the command line or the inventory setting in ansible.cfg.

Q. Static vs dynamic inventory — when do you use each?

Static is a file you maintain by hand — fine for a small, stable estate.

Dynamic uses an inventory plugin that queries the real source of truth at runtime (AWS, Azure, VMware, a CMDB), so the host list can never go stale.

The clinching argument: the moment autoscaling exists, a static file is wrong within minutes. Anything cloud-based should be dynamic, with keyed_groups mapping instance tags to Ansible groups. (Covered fully in Module 09.)

Q. Explain web:db, web:&db and web:!db.

web:db is union — hosts in web or db.

web:&db is intersection — hosts in web and db.

web:!db is exclusion — hosts in web that are not in db.

They compose, so prod:&web:!web01 means "in prod and in web, except web01". Patterns work identically in ad-hoc commands, in --limit, and in the hosts: line of a play.

Q. What are the implicit groups?

all (every host), ungrouped (hosts in no group), and localhost (always available and implicitly local-connection — which is why hosts: localhost works even with a completely empty inventory).

Q. How would you inspect an inventory you have just inherited?

ansible-inventory -i <file> --graph for the group tree, --list for the full JSON including every resolved variable, and ansible <pattern> --list-hosts to confirm a pattern selects what you expect before running anything against it.


B2 · How Ansible authenticates — SSH from the ground up

The analogy. Think of letting a cleaner into your house. With a password, you meet them at the door every single time — fine once, impossible if they come daily while you are at work, and absurd if you own twenty houses. With a key, you hand it over once and the arrangement runs itself. And if you ever need to stop them, you change the lock rather than changing your routine.

That is why automation runs on SSH keys and not passwords: nobody is standing at the door at two in the morning to type a password for a scheduled job. Hold on to the picture, because the very next section asks the two questions that actually trip people up — whose key, and whose door.

Ansible has no login mechanism of its own. It uses plain OpenSSH, the same client you use manually. If ssh user@host works from your terminal, Ansible will work. If it does not, no amount of Ansible configuration will save you.

Debug rule that will serve you for your whole career: when Ansible reports UNREACHABLE, stop looking at Ansible. Open a terminal and run the plain ssh command by hand. The problem is almost always at the SSH layer, and the plain client gives you a far clearer error message.

The two authentication methods

Diagram source
flowchart TD
    A["Control node wants to run a task on web01"] --> B{"Which auth method?"}
    B -->|"Password"| C["Prompts for password<br>every single run<br>needs sshpass installed"]
    B -->|"SSH key"| D["Private key signs a challenge<br>no prompt, no secret sent<br>works unattended in CI"]
    C --> E["❌ Cannot automate<br>❌ Password in shell history or vault<br>✅ Fine for a one-off bootstrap"]
    D --> F["✅ The production answer<br>✅ Revocable per host<br>✅ Auditable"]

Method 1 — Password authentication

Useful only for the very first contact with a fresh machine, before you have installed a key.

bash
# Ansible needs the sshpass helper to type a password non-interactively
sudo apt install -y sshpass          # Debian/Ubuntu
sudo dnf install -y sshpass          # RHEL/Rocky

# -k / --ask-pass prompts for the SSH password
ansible -i inventory/hosts.yml web -m ansible.builtin.ping -u deploy -k

Or declared in the inventory — never do this in a real repository:

plain text
[web]
web01 ansible_host=10.0.1.15 ansible_user=deploy ansible_ssh_pass=NotARealPassword
A plaintext password in an inventory file is a security incident waiting to happen — it lands in Git, in CI logs, and in every developer's laptop backup. If you genuinely must use password auth, the password belongs in Ansible Vault (Module 07). Better still: use it once to install a key, then never again.

Method 2 — SSH key authentication (the real answer)

Diagram source
flowchart LR
    A["Control node<br>~/.ssh/id_ed25519<br>PRIVATE key"] -->|"1 · connect"| B["web01 sshd"]
    B -->|"2 · offer auth methods"| A
    A -->|"3 · present public key fingerprint"| B
    B --> C{"Listed in<br>~/.ssh/authorized_keys?"}
    C -->|"No"| D["❌ Permission denied publickey"]
    C -->|"Yes"| E["4 · send random challenge"]
    E --> F["5 · control node signs it<br>with the PRIVATE key"]
    F --> G["6 · sshd verifies with the PUBLIC key<br>✅ session established"]

The critical property: the private key never leaves the control node and is never transmitted. The server only ever sees a signature it can verify. That is why key auth is safe to automate and password auth is not.

🧪 Exercise B2.1 — Generate a key and install it
bash
# 1. Generate a modern key pair. ed25519 is smaller and faster than RSA.
ssh-keygen -t ed25519 -C "ansible-control-node" -f ~/.ssh/ansible_ed25519

# 2. Look at what you just made
ls -l ~/.ssh/ansible_ed25519*

# 3. Copy the PUBLIC key to the managed node (asks for the password once)
ssh-copy-id -i ~/.ssh/ansible_ed25519.pub [email protected]

# 4. Confirm you can now log in with no password
ssh -i ~/.ssh/ansible_ed25519 [email protected] 'hostname'
Expected result — click to reveal

Step 1:

plain text
Generating public/private ed25519 key pair.
Enter passphrase (empty for no passphrase):
Your identification has been saved in /home/zaeem/.ssh/ansible_ed25519
Your public key has been saved in /home/zaeem/.ssh/ansible_ed25519.pub
The key fingerprint is:
SHA256:xR3k9... ansible-control-node

Step 2 — note the permissions, they matter:

plain text
-rw-------  1 zaeem zaeem  399 Aug 14 10:22 /home/zaeem/.ssh/ansible_ed25519
-rw-r--r--  1 zaeem zaeem   96 Aug 14 10:22 /home/zaeem/.ssh/ansible_ed25519.pub

The private key is 0600 — readable only by you. If it is any more permissive, SSH refuses to use it entirely and prints "UNPROTECTED PRIVATE KEY FILE". This trips up people who copy keys around with a careless cp.

Step 3:

plain text
/usr/bin/ssh-copy-id: INFO: attempting to log in with the new key(s)...
[email protected]'s password:
Number of key(s) added: 1

Step 4 — no password prompt:

plain text
web01

What ssh-copy-id actually did: it appended the contents of your .pub file to ~/.ssh/authorized_keys on the target, and set the permissions correctly (700 on ~/.ssh, 600 on the file). You could do it by hand, but people routinely get the permissions wrong and then spend an hour debugging — sshd silently ignores an authorized_keys file with loose permissions.

🧪 Exercise B2.2 — Point Ansible at your key
yaml
# inventory/hosts.yml
all:
  children:
    web:
      hosts:
        web01:
          ansible_host: 10.0.1.15
        web02:
          ansible_host: 10.0.1.16
      vars:
        ansible_user: deploy
        ansible_ssh_private_key_file: ~/.ssh/ansible_ed25519
bash
ansible -i inventory/hosts.yml web -m ansible.builtin.ping
Expected result — click to reveal
plain text
web01 | SUCCESS => {
    "changed": false,
    "ping": "pong"
}
web02 | SUCCESS => {
    "changed": false,
    "ping": "pong"
}

The connection variables you just used, and the rest of the family:

  • ansible_host — the real IP or DNS name. The inventory name (web01) becomes just a friendly label, which is what lets you write readable playbooks against IP-addressed servers.
  • ansible_user — the account to log in as
  • ansible_ssh_private_key_file — which private key to present
  • ansible_port — non-standard SSH port
  • ansible_connectionssh (default), local, winrm, docker, network_cli
  • ansible_python_interpreter — override when the target has Python somewhere unusual

🎯 Interview questions — SSH & authentication

Q. How does Ansible authenticate to managed nodes?

Through standard OpenSSH — either password authentication (which needs sshpass and the -k flag) or, in any real environment, SSH public key authentication.

The rule to state plainly: if plain ssh user@host does not work from the control node, Ansible cannot work either. Ansible adds no authentication layer of its own.

Q. Why is key-based authentication preferred over passwords?

The private key never leaves the control node — the server only receives a signature it verifies against the stored public key, so no secret crosses the wire.

It works unattended, which is what makes CI/CD and scheduled runs possible at all. It is revocable per host by editing authorized_keys, and it leaves an auditable trail of which key was used.

Password auth requires sshpass, means storing a plaintext credential somewhere, and cannot be rotated cleanly.

Q. Ansible reports UNREACHABLE. Walk me through your debugging.
  1. Reproduce with plain SSH first — ssh -vvv user@host. The native client gives a much better error than Ansible does.
  2. Check the obvious layer: is the host up, is port 22 reachable, is DNS resolving, is a security group or firewall in the way.
  3. Check identity: right ansible_user, right key, and is the public key actually in the target's authorized_keys.
  4. Check permissions — ~/.ssh must be 700, authorized_keys 600, private key 600. sshd silently ignores files that are too permissive, which produces a confusing "denied" with no explanation.
  5. Check host key verification — a rebuilt server with a new host key fails until known_hosts is updated.
  6. Then, and only then, run ansible ... -vvv to see the exact SSH command Ansible constructed.

Say this out loud: UNREACHABLE means the module never ran at all, so it is never a playbook bug.

Q. Where would you store an SSH password or key passphrase if you had to?

In Ansible Vault, encrypted at rest and decrypted at run time with a vault password supplied by CI or a password file outside the repository. Never in plaintext in the inventory, never in Git.

Better still, avoid the question: use key auth with ssh-agent holding the passphrase, or on cloud infrastructure skip SSH keys entirely and use AWS SSM Session Manager, which authenticates through IAM and leaves no key to manage.


B2.5 · Which user is which — the concept that breaks most beginners

The analogy. Think of visiting an office building, where three different identities are involved in a single visit. There is you — the person walking in, carrying your own keys and wallet. There is the name on the visitor pass you sign in as, which reception must already have on its list, or you are not getting past the desk no matter what you are carrying. And there is the escort who badges you into the server room, because a visitor pass was never going to open that door.

You are the local user running the ansible command — that is what decides which ~/.ssh folder gets read. The visitor pass is ansible_user, an account that must already exist on the target. The escort is become. The cleaner's key from B2 is only the first of the three, which is why people end up standing outside a door insisting "but I have a key" when the real problem was that reception never had their name.

This is the single most misunderstood part of Ansible connectivity, and it costs people hours. There are three separate user identities in play on every single task, they are frequently different people, and confusing them produces errors that look like they are about keys when they are actually about accounts.

The three identities

#IdentityWhat it controls
1️⃣The local user running ansible on the control nodeWhich ~/.ssh/ directory is read, which ~/.ansible.cfg applies, where ~/.ansible/tmp and collections live. You never declare this — it is simply whoever you are logged in as
2️⃣The remote user ansible_user / remote_user / -uThe account Ansible logs into on the managed node. This account must already exist there, and your public key must be in its authorized_keys
3️⃣The become user become_user, default rootWho the task actually executes as after privilege escalation. Requires the remote user to have sudo rights on that host
javascript
CONTROL NODE                                    MANAGED NODE web01
──────────────────────────────                  ──────────────────────────────

1️⃣ zaeem  ← whoami                              2️⃣ deploy  ← ansible_user
   │                                                │       must EXIST on this host
   │ reads /home/zaeem/.ssh/ansible_ed25519         │
   │ reads /home/zaeem/.ansible.cfg                 │ checks /home/deploy/.ssh/authorized_keys
   │                                                │        for the matching PUBLIC key
   └──────────── SSH ──────────────────────────────►│

become: true
                                                 3️⃣ root  ← become_user
                                                    needs a sudoers rule for deploy
Say it in one sentence: the key lives in the local user's home on the control node, and its public half must be in the remote user's home on the managed node. Two different users, two different machines, two different home directories. Almost every "Permission denied (publickey)" is one of those four things being wrong.

Identity 1 — the local user, and the trap it sets

Ansible reads configuration and keys from the home directory of whoever runs the command. Nothing about this is configurable — it follows $HOME.

PathWhat lives there
~/.ssh/private keys, known_hosts, config
~/.ansible.cfguser-level configuration
~/.ansible/collections/collections installed with ansible-galaxy
~/.ansible/tmp/local temp working files
The sudo ansible-playbook trap.

You generate a key as zaeem, ssh-copy-id it, confirm ssh deploy@web01 works — then run sudo ansible-playbook site.yml because "it needs root" and get Permission denied (publickey).

Why: sudo changed your local user to root, so $HOME became /root. Ansible looked for /root/.ssh/ansible_ed25519, which does not exist. Your key was never involved.

The fix: do not sudo the ansible-playbook command. Root on the control node is irrelevant — you want root on the managed node, and that is what become: true is for. Run as yourself; escalate remotely.

Identity 2 — the remote user

The account Ansible logs into on the target. Three things must be true, and all three are separate failure modes:

  1. The account exists on that managed node
  2. Your public key is in that account's ~/.ssh/authorized_keys — not root's, not another user's
  3. The permissions are right: ~ at most 755, ~/.ssh 700, authorized_keys 600. sshd silently ignores files that are too permissive, giving you a denial with no stated reason

Where the remote user comes from, highest priority first:

plain text
1. -u / --user on the command line
2. ansible_user set on the HOST in the inventory
3. ansible_user set on the GROUP in the inventory
4. remote_user in ansible.cfg
5. ANSIBLE_REMOTE_USER environment variable
6. the User directive in ~/.ssh/config
7. the local username you are currently logged in as   <- the silent default
Item 7 is why beginners get confused. Set nothing, and Ansible tries to log in as you. If you are zaeem locally and there is no zaeem account on the server, you get Permission denied — and nothing in the message tells you it tried the wrong username. Always set ansible_user or remote_user explicitly.

Different accounts on different machines is completely normal, and the inventory handles it per host or per group:

yaml
all:
  children:
    web:
      hosts:
        web01:
          ansible_host: 10.0.1.15
        web02:
          ansible_host: 10.0.1.16
      vars:
        ansible_user: deploy              # both web hosts use deploy

    legacy:
      hosts:
        old01:
          ansible_host: 10.0.9.9
          ansible_user: ec2-user          # this ONE host is different
          ansible_ssh_private_key_file: ~/.ssh/legacy_rsa

    appliance:
      hosts:
        fw01:
          ansible_user: admin
          ansible_port: 2222

Identity 3 — the become user

After login, become escalates. For that to work, the remote user needs a sudoers entry on that host — which is a property of the server, not of Ansible.

bash
# On the managed node, as root, this must exist for passwordless automation:
cat /etc/sudoers.d/deploy
# deploy ALL=(ALL) NOPASSWD:ALL

Without it you get Missing sudo password, which is a different failure from a login failure — SSH already succeeded at that point.

The full failure map

ErrorWhich identityActual cause
Permission denied (publickey)1️⃣ or 2️⃣Wrong local $HOME (often from sudo), wrong ansible_user, key not in that user's authorized_keys, or permissions too loose
Invalid/incorrect username2️⃣The account does not exist on that host — you probably fell through to the local-username default
Missing sudo password3️⃣Login worked; the remote user has no NOPASSWD sudoers rule. Supply -K or fix sudoers
sudo: a password is required3️⃣Same as above, surfaced by the target's sudo instead
UNPROTECTED PRIVATE KEY FILE1️⃣The private key on the control node is not 0600
Works manually, fails in CI1️⃣The CI job runs as a different local user with a different $HOME — your key is not there
🧪 Exercise B2.5.1 — Print all three identities in one run
bash
# 1 — who am I on the CONTROL node?
whoami; echo "HOME=$HOME"

# 2 — who does Ansible log in as on the MANAGED node?
ansible web01 -m ansible.builtin.command -a "id -un"

# 3 — who does the task execute as after escalation?
ansible web01 -m ansible.builtin.command -a "id -un" --become

# and confirm which key was actually offered
ansible web01 -m ansible.builtin.ping -vvv 2>&1 | grep -o 'IdentityFile=[^]*'
Expected result — click to reveal
plain text
zaeem
HOME=/home/zaeem
plain text
web01 | CHANGED | rc=0 >>
deploy
plain text
web01 | CHANGED | rc=0 >>
root
plain text
IdentityFile="/home/zaeem/.ssh/ansible_ed25519"

All three identities, visible at once. You are zaeem locally, Ansible logs in as deploy, and tasks execute as root after become. The key came from /home/zaeem/.ssh/ — the local user's home.

That last grep is worth committing to memory. When authentication fails, -vvv plus grep IdentityFile tells you immediately whether Ansible even reached for the key you think it did — which instantly separates "wrong key" from "key not installed on the target".

🧪 Exercise B2.5.2 — Reproduce the sudo trap deliberately
bash
ansible web01 -m ansible.builtin.ping           # works
sudo ansible web01 -m ansible.builtin.ping      # watch it fail
Expected result — and why — click to reveal
plain text
web01 | SUCCESS => {"changed": false, "ping": "pong"}
plain text
web01 | UNREACHABLE! => {
    "changed": false,
    "msg": "Failed to connect to the host via ssh: [email protected]: Permission denied (publickey).",
    "unreachable": true
}

Nothing about the key changed. Nothing about the server changed. The only difference is that sudo set $HOME=/root, so Ansible looked in /root/.ssh/ — where your key does not exist. Confirm it:

bash
sudo ansible web01 -m ansible.builtin.ping -vvv 2>&1 | grep -o 'IdentityFile=[^]*'
# IdentityFile="/root/.ssh/id_rsa"     <- not your key at all

The rule to carry away: never sudo the ansible or ansible-playbook command. Root on the control node buys you nothing — Ansible only needs to read your files and open an SSH connection, both of which your own account can already do. When you need root, you need it on the target, and become: true is how you get it.

The same trap in CI: a pipeline that runs as gitlab-runner or jenkins reads that user's ~/.ssh. "It works on my laptop but fails in the pipeline" is nearly always this, and the fix is to load the key into the job's own user — usually via ssh-agent seeded from a CI secret.

🎯 Interview questions — User identity & access

Q. Which user does Ansible connect as, and what decides it?

The remote user, resolved in this priority order: -u on the command line, then ansible_user on the host, then ansible_user on the group, then remote_user in ansible.cfg, then ANSIBLE_REMOTE_USER, then the User directive in ~/.ssh/config, and finally — if nothing is set — the local username of whoever is running the command.

That last fallback is a common source of confusing failures, so set it explicitly.

Q. Your key works with plain ssh but sudo ansible-playbook fails with publickey denied. Why?

sudo changed $HOME to /root, so Ansible read /root/.ssh/ instead of your own ~/.ssh/, and your key was never offered.

The underlying misconception is thinking Ansible needs root on the control node. It does not — it only reads local files and opens an SSH connection. Root is needed on the managed node, and become: true provides it.

The same mechanism explains "works on my laptop, fails in CI": the runner executes as a different local user with a different home directory.

Q. Distinguish remote_user, become_user and the local user.

Local user — whoever runs ansible on the control node. Determines which ~/.ssh, ~/.ansible.cfg and collections are used. Never declared; it is just your login.

remote_user — the account Ansible logs into on the managed node. Must exist there, and your public key must be in its authorized_keys.

become_user — who tasks run as after escalation, default root. Requires the remote user to hold a sudoers rule on that host.

Q. Different servers need different login accounts. How do you handle that?

Set ansible_user per host or per group in the inventory — group vars for the common case, host vars for exceptions. ansible_ssh_private_key_file can vary the same way when the estate has several key pairs.

The cleaner long-term answer is to standardise on one automation service account across the estate, created at image-build time, so the inventory does not have to encode historical accidents.

Q. You get Missing sudo password. What is wrong, and what is NOT wrong?

SSH and authentication succeeded — this failure is at the escalation stage, not the connection stage. The remote user has no passwordless sudo rule on that host.

Fix by supplying -K to prompt for it, or by deploying /etc/sudoers.d/<user> with NOPASSWD:ALL — using validate: visudo -cf %s, because a malformed sudoers file locks everyone out of sudo on that machine.

Q. What are the correct permissions for key-based auth, and why does it fail silently?

On the control node the private key must be 0600 — anything looser and SSH refuses to use it, printing UNPROTECTED PRIVATE KEY FILE.

On the managed node the remote user's home must be no more permissive than 755, ~/.ssh must be 700, and authorized_keys 600.

The silence is deliberate: sshd will not tell a remote party why it rejected them, since that would leak information to an attacker. So it just says Permission denied (publickey). To see the real reason you must read /var/log/auth.log or journalctl -u sshd on the server, which is exactly what a strong troubleshooting answer mentions.


B3 · Scaling SSH access — 3 servers vs 3,000

The analogy. Think of handing out spare keys. For three neighbours you walk round and give them out by hand — ten minutes, nothing to plan, and any "process" you invented would cost more than the job. For a new block of three thousand flats nobody walks round: the locks are fitted and the keys cut at the factory, before a single resident moves in.

At small scale you run ssh-copy-id by hand; at large scale the key is baked into the machine image, so it is already there the first time the server boots. The tower block solved the key-distribution problem by never having one — and in an interview, the valuable half of the answer is knowing which of those two situations you are being asked about.

ssh-copy-id per host is fine for a handful of machines and completely impractical beyond that. The right approach depends on scale.

Diagram source
flowchart TD
    A{"How many managed nodes?"}
    A -->|"1 – 10"| B["Manual<br>ssh-copy-id per host<br>+ ~/.ssh/config aliases"]
    A -->|"10 – 100"| C["One-time bootstrap playbook<br>authorized_key module<br>run once with password auth"]
    A -->|"100+ or cloud"| D["Bake the key into the image<br>cloud-init / Packer / AMI<br>key exists before first boot"]
    A -->|"Cloud, no keys at all"| E["AWS SSM Session Manager<br>auth via IAM<br>nothing to rotate"]

Small estate — ~/.ssh/config is your friend

javascript
# ~/.ssh/config
Host web01
    HostName 10.0.1.15
    User deploy
    IdentityFile ~/.ssh/ansible_ed25519

Host bastion
    HostName bastion.example.com
    User zaeem

# Reach private subnets through a jump host
Host 10.0.*.*
    User deploy
    IdentityFile ~/.ssh/ansible_ed25519
    ProxyJump bastion

# Reuse one TCP connection for many sessions - a large speed win
Host *
    ControlMaster auto
    ControlPath ~/.ssh/cm-%r@%h:%p
    ControlPersist 10m

Ansible reads this file automatically, because it shells out to the real ssh binary. Anything you can express in ~/.ssh/config — jump hosts, per-host keys, connection multiplexing — Ansible inherits for free. That is a genuinely useful thing to know and many people do not.

Medium estate — bootstrap the keys with Ansible itself

yaml
# bootstrap-keys.yml  —  run ONCE with password auth, never again
---
- name: Install the Ansible SSH key on new hosts
  hosts: new_servers
  become: true
  tasks:
    - name: Ensure the deploy user exists
      ansible.builtin.user:
        name: deploy
        shell: /bin/bash
        create_home: true

    - name: Install the control node public key
      ansible.posix.authorized_key:
        user: deploy
        state: present
        key: "{{ lookup('file', '~/.ssh/ansible_ed25519.pub') }}"
        exclusive: false

    - name: Allow passwordless sudo for deploy
      ansible.builtin.copy:
        content: "deploy ALL=(ALL) NOPASSWD:ALL\n"
        dest: /etc/sudoers.d/deploy
        mode: "0440"
        validate: visudo -cf %s
bash
ansible-playbook -i inventory/hosts.yml bootstrap-keys.yml -u root -k
This is a superb interview story. "We onboarded new servers with a one-time bootstrap playbook run under password auth, which created the service account, installed the key, and configured sudoers — after which every subsequent run was key-based and unattended." It shows you understand the chicken-and-egg problem of automating access to machines you cannot yet access.

Large or cloud estate — the key should already be there

At real scale you never install keys after the fact. The key is present before the machine finishes booting:

  • cloud-init / user-data — inject authorized_keys at first boot
  • Golden images — bake the key in with Packer, so every instance from that AMI is reachable immediately
  • Terraform key_name on the instance resource — the cloud provider installs it for you
  • AWS SSM Session Manager — skip SSH entirely; amazon.aws.aws_ssm connects through IAM, so there is no key to rotate, no port 22 to expose, and access is controlled by IAM policy

Handling the key passphrase — ssh-agent

A passphrase-protected key is more secure, but you cannot type a passphrase in a CI job. ssh-agent holds the decrypted key in memory for the session:

bash
eval "$(ssh-agent -s)"
ssh-add ~/.ssh/ansible_ed25519      # type the passphrase once
ssh-add -l                          # confirm it is loaded
ansible web -m ansible.builtin.ping # no prompt
🧪 Exercise B3.1 — Prove that Ansible inherits ~/.ssh/config
bash
# Add an alias to ~/.ssh/config
cat >> ~/.ssh/config <<'EOF'
Host myserver
    HostName 10.0.1.15
    User deploy
    IdentityFile ~/.ssh/ansible_ed25519
EOF

# Use the ALIAS as the inventory name, with no ansible_host, no ansible_user, no key path
printf '[test]\nmyserver\n' > inventory/alias-test.ini

ansible -i inventory/alias-test.ini test -m ansible.builtin.ping
Expected result — click to reveal
plain text
myserver | SUCCESS => {
    "changed": false,
    "ping": "pong"
}

Look at how little the inventory contained — one word. No IP, no user, no key path. Ansible shelled out to ssh myserver, and OpenSSH resolved the alias, the hostname, the user and the key from ~/.ssh/config.

When this is the right tool: jump hosts and bastions. Putting ProxyJump bastion in ~/.ssh/config is far cleaner than threading ansible_ssh_common_args through your inventory, and it keeps working when you SSH manually too.

When it is the wrong tool: anything a teammate or CI runner also needs. ~/.ssh/config lives on one laptop and is invisible to everyone else. Shared connection details belong in the inventory, which is in Git. Knowing which to use for which case is the senior answer.

🎯 Interview questions — SSH at scale

Q. How do you distribute SSH keys to 500 new servers?

You do not distribute them after the fact — you make sure the key is present before the machine is reachable. Inject it via cloud-init/user-data, bake it into a golden image with Packer, or let Terraform's key_name install it at instance creation.

For an existing estate you cannot rebuild, run a one-time bootstrap playbook using password auth that creates the service account, installs the key with the authorized_key module, and configures sudoers. Every run after that is key-based.

At cloud scale the better answer is to remove keys from the picture entirely — AWS SSM Session Manager authenticates through IAM, so there is nothing to distribute or rotate.

Q. How does Ansible reach servers in a private subnet?

Through a bastion / jump host. Either ProxyJump bastion in ~/.ssh/config, or in the inventory:

plain text
ansible_ssh_common_args: '-o ProxyCommand="ssh -W %h:%p -q deploy@bastion"'

Alternatives worth naming: run the control node inside the VPC (a CI runner or AWX instance on a private subnet), or use SSM which needs no inbound network path at all.

Q. Your key has a passphrase but the playbook runs in CI. How?

ssh-agent — load the key once with ssh-add and the agent serves it for the session without re-prompting. In CI, start an agent in the job and add the key from a secret store.

Cleaner alternatives: a dedicated passphrase-less deploy key with tightly scoped permissions and a short lifetime, or short-lived SSH certificates from a CA such as HashiCorp Vault, which expire automatically and remove the rotation problem entirely.

Q. Does Ansible respect ~/.ssh/config?

Yes — Ansible shells out to the system ssh binary, so host aliases, ProxyJump, IdentityFile, and ControlMaster multiplexing all apply automatically.

The caveat worth adding: ~/.ssh/config is per-user and per-machine, so it is invisible to teammates and CI runners. Use it for your own convenience; put anything the team depends on in the inventory, which is version-controlled.


B4 · Privilege escalation — become

The analogy. Think of your office badge — this is the escort from B2.5, now looked at properly. The badge gets you through the front door and no further; the server room needs a supervisor to swipe you in. The obvious question is why the building does not simply hand everyone a master key on day one.

Logging in over SSH is the front door. become is the supervisor's swipe. Buildings refuse to issue master keys because signing in as yourself and being escalated only when the job needs it means the building knows exactly who went where — and removing one person does not mean re-keying the whole site. That is precisely why production servers disable direct root login and expect you to become instead.

Logging in and being root are two different things. become handles the second.

yaml
# In the inventory
[web:vars]
ansible_user=deploy          # WHO you log in as
ansible_become=true          # escalate after login
ansible_become_method=sudo   # how
ansible_become_user=root     # to whom (default: root)
bash
ansible web -m ansible.builtin.package -a "name=htop state=present" --become
ansible web -m ansible.builtin.package -a "name=htop state=present" -b -K   # -K prompts for the sudo password
FlagMeaning
-u / --userSSH login user — who you are
-b / --becomeescalate privileges after login
-k / --ask-passprompt for the SSH password
-K / --ask-become-passprompt for the sudo password
Lowercase -k is SSH, uppercase -K is sudo. They are two entirely different credentials and mixing them up produces a confusing failure. Interviewers ask this precisely because it separates people who have run the tool from people who have read about it.
🧪 Exercise B4.1 — See the difference between login and escalation
bash
ansible web01 -m ansible.builtin.command -a "id"          # who am I after login?
ansible web01 -m ansible.builtin.command -a "id" --become # who am I after escalation?
ansible web01 -m ansible.builtin.command -a "cat /etc/shadow"           # should fail
ansible web01 -m ansible.builtin.command -a "head -1 /etc/shadow" -b    # should work
Expected result — click to reveal
plain text
web01 | CHANGED | rc=0 >>
uid=1001(deploy) gid=1001(deploy) groups=1001(deploy)
plain text
web01 | CHANGED | rc=0 >>
uid=0(root) gid=0(root) groups=0(root)
plain text
web01 | FAILED | rc=1 >>
cat: /etc/shadow: Permission denied
plain text
web01 | CHANGED | rc=0 >>
root:$6$xyz...:19700:0:99999:7:::

Read the progression. You log in as deploy — an unprivileged account. --become runs sudo on the target and the same command now executes as uid=0. Ansible never logs in as root directly.

Why this design matters, and it is worth stating in an interview: permitting direct root SSH login is a standard compliance finding in any audited environment. The accepted pattern is to log in as a named, unprivileged service account — which produces an audit trail of who connected — and escalate through sudo, which is separately logged. become is Ansible honouring that convention rather than working around it.

Also note the FAILED above is genuinely different from UNREACHABLE: the connection succeeded and the module ran; it was the command that was denied.

🎯 Interview questions — Privilege escalation

Q. What is become and how does it differ from remote_user?

remote_user (or -u) is the account you log in as over SSH. become is privilege escalation after login, by default sudo to root.

Related keys: become_method (sudo, su, doas, runas for Windows), become_user (the target identity, default root), and -K when sudo itself needs a password.

The best-practice statement: log in as an unprivileged service account and escalate via become, rather than permitting root SSH — direct root login is a standard audit finding.

Q. What is the difference between -k and -K?

-k prompts for the SSH login password. -K prompts for the sudo/become password. They are different credentials for different stages of the connection, and a run may legitimately need both.

Q. How do you avoid a sudo password prompt in automation?

Grant the service account passwordless sudo through a file in /etc/sudoers.d/, deployed by Ansible itself with validate: visudo -cf %s so a malformed file can never be written.

Scope it as tightly as the environment demands — full NOPASSWD:ALL is common for a dedicated automation account, but a hardened environment restricts it to specific command paths.

Never the alternative of storing the sudo password in plaintext; if you truly need one, it goes in Vault.


B5 · First contact — the connectivity test

The analogy. Think of the start of a phone call, when you say "can you hear me?" and wait. It costs two seconds and saves you from talking to nobody for five minutes. Notice how much one short reply tells you: the line connected, the right person picked up, and you both speak the same language.

That is ansible all -m ping — network, authentication and Python on the far end, all confirmed in one go. One warning worth carrying into an interview: despite the name, this is not the network ping you already know, and nothing ICMP happens. It is a full SSH login that runs a tiny module which replies pong.

You now have an inventory and working SSH. Time for the canonical first command.

bash
ansible -i inventory/hosts.yml all -m ansible.builtin.ping
ping is not ICMP. It is an Ansible module that opens an SSH session, pushes a tiny Python module, executes it on the target, and expects {"ping": "pong"} back.

So a successful ping proves three things simultaneously: the network path and SSH work, authentication works, and a usable Python exists on the target. That is why it is the universal first command — and why "is it ICMP?" is asked in almost every junior screen.

🧪 Exercise B5.1 — Read every kind of failure
bash
ansible -i inventory/hosts.yml all -m ansible.builtin.ping -o
Expected result — and how to read each failure — click to reveal
plain text
web01 | SUCCESS => {"changed": false, "ping": "pong"}
web02 | UNREACHABLE!: Failed to connect to the host via ssh: [email protected]: Permission denied (publickey).
db01  | UNREACHABLE!: Failed to connect to the host via ssh: ssh: connect to host 10.0.2.11 port 22: Connection timed out
stg01 | FAILED! => {"msg": "The module failed to execute correctly, you probably need to set the interpreter"}

Four different outcomes, four different root causes:

  • SUCCESS — network, auth and Python all confirmed working.
  • Permission denied (publickey) — the network path is fine, sshd answered. Your public key is not in that host's authorized_keys, or the wrong ansible_user is set, or the permissions on ~/.ssh are too loose for sshd to trust the file.
  • Connection timed out — nothing answered at all. Host down, wrong IP, security group or firewall blocking port 22, or no route from the control node into that subnet. Not an Ansible problem in any sense.
  • FAILED with an interpreter message — SSH and auth succeeded; the module ran and could not find a usable Python. Fix with ansible_python_interpreter: /usr/bin/python3.11, or bootstrap Python using the raw module.

Internalise the distinction: UNREACHABLE means the module never executed — debug at the network or auth layer. FAILED means it executed and returned an error — debug the target's state or your arguments. Confusing the two sends people down hour-long dead ends.

🎯 Interview questions — Connectivity

Q. What does ansible all -m ping actually do?

It is not ICMP. It opens an SSH connection, pushes the ping module, executes it with the remote Python interpreter, and expects {"ping": "pong"} in return.

It is therefore a single combined test of three things: network/SSH reachability, authentication, and a working Python on the target.

Q. What is the difference between UNREACHABLE and FAILED?

UNREACHABLE — the connection could not be established. SSH refused, wrong key, DNS failure, firewall, host down. The module never executed, and the host is dropped from the remainder of the play.

FAILED — the connection worked and the module ran, but returned an error result.

The practical consequence: unreachable hosts are debugged at the infrastructure layer, failed hosts at the playbook or target-state layer.

Q. A host has no Python. How do you manage it?

Bootstrap it with the raw module, which sends a literal SSH command and requires no Python:

plain text
- raw: apt-get update && apt-get install -y python3

Then run everything else normally. raw and script are the only modules that work without Python on the target — which is also why raw is what you use on stripped-down appliances and network gear.


B6 · Under the hood — what actually happened during that ping

The analogy. Think of ordering a takeaway. You phone the restaurant, read out the order, they cook it, they tell you it is ready, and you hang up. Nothing of yours stays in their kitchen — the same point the fifty branches made at the top of this module, now shrunk down to a single task.

Ansible repeats that phone call for every task: connect, send the instructions, run them, report back, clean up, disconnect. The ten steps below are that call written out precisely, and the one to pause on is that the restaurant cooks with its own oven — the module runs under the target's Python, not yours. That is why "it works on my laptop" is never an argument, and why a library missing on the target breaks a task that looks perfectly fine in your editor.

You have now run a real command against a real server. This is the right moment to open the box, because you can watch every step happen.

Diagram source
flowchart TD
    S1["1 · Parse inventory, vars and arguments<br>build the target host list"] --> S2["2 · Select the module<br>e.g. ansible.builtin.ping"]
    S2 --> S3["3 · Build the AnsiballZ payload<br>module code + your arguments<br>zipped and base64-encoded into ONE file"]
    S3 --> S4["4 · Open or reuse the SSH connection<br>ControlPersist multiplexing"]
    S4 --> S5["5 · Copy the payload to a temp dir on the target<br>~/.ansible/tmp/ansible-tmp-EPOCH-PID/"]
    S5 --> S6["6 · Execute it with the TARGET's Python<br>optionally wrapped in sudo via become"]
    S6 --> S7["7 · Module prints JSON to stdout<br>{changed: true, rc: 0}"]
    S7 --> S8["8 · Control node parses that JSON<br>deletes the temp dir"]
    S8 --> S9["9 · Render ok / changed / failed / skipped<br>optionally capture with register"]
    S9 --> S10{"More tasks?"}
    S10 -->|"Yes"| S2
    S10 -->|"No"| S11["✅ Play complete"]
Highest-value item in this module. "Walk me through what happens when you run a task" is asked in almost every mid-level and senior interview, and a precise answer immediately separates you from candidates who only know the YAML.

The single detail most candidates miss: step 6 says the target's Python, not the control node's. That is the entire reason a managed node needs Python at all, and it explains why a target running an ancient Python can fail on a module that works elsewhere.

🔍 Modules that break this pattern — worth knowing
  • raw — sends a literal SSH command. No Python, no temp file, no JSON. Used to bootstrap Python onto a bare host.
  • script — copies a local script to the target and runs it.
  • shell / command — still use the Python wrapper, but are not idempotent by themselves.
  • connection: local or delegate_to: localhost — executes on the control node instead. Every cloud module works this way: amazon.aws.ec2_instance, community.docker.*, uri. They talk to an API, so there is no target to SSH into.
🧪 Exercise B6.1 — Watch the whole lifecycle in the SSH trace
bash
ansible web01 -m ansible.builtin.ping -vvv 2>&1 | grep -iE 'ESTABLISH|SSH:|EXEC'
Expected result — click to reveal
plain text
<web01> ESTABLISH SSH CONNECTION FOR USER: deploy
<web01> SSH: EXEC ssh -o ControlMaster=auto -o ControlPersist=60s -o 'IdentityFile="/home/zaeem/.ssh/ansible_ed25519"' -o User=deploy 10.0.1.15 '/bin/sh -c ...'
<web01> EXEC /bin/sh -c 'echo ~deploy && sleep 0'
<web01> EXEC /bin/sh -c '( umask 77 && mkdir -p "` echo /home/deploy/.ansible/tmp `"&& mkdir "` echo /home/deploy/.ansible/tmp/ansible-tmp-1755166234.12-8891-273 `" ...'
<web01> EXEC /bin/sh -c '/usr/bin/python3 /home/deploy/.ansible/tmp/ansible-tmp-1755166234.12-8891-273/AnsiballZ_ping.py && sleep 0'
<web01> EXEC /bin/sh -c 'rm -f -r /home/deploy/.ansible/tmp/ansible-tmp-1755166234.12-8891-273/ > /dev/null 2>&1 && sleep 0'

Map each line onto the diagram above:

  1. ESTABLISH SSH CONNECTION → step 4
  2. SSH: EXEC ssh -o ControlMaster=auto... → step 4, and note it shows you the exact ssh command Ansible built, including which key it chose. When authentication misbehaves, this line is the answer.
  3. mkdir -p ... umask 77 → step 5, creating the temp directory with restrictive permissions
  4. /usr/bin/python3 .../AnsiballZ_ping.py → step 6, the target's Python running the pushed module
  5. rm -f -r ... → step 8, cleaning up so nothing persists

Six lines contain the entire agentless architecture. Run this once and the model stops being abstract.

🧪 Exercise B6.2 — Catch the AnsiballZ payload before it is deleted
bash
ANSIBLE_KEEP_REMOTE_FILES=1 ansible web01 -m ansible.builtin.ping -vvv 2>&1 | grep AnsiballZ
ssh web01 'ls -R ~/.ansible/tmp/ | head'
ssh web01 'head -25 ~/.ansible/tmp/*/AnsiballZ_ping.py'
Expected result — click to reveal
plain text
/home/deploy/.ansible/tmp/ansible-tmp-1755166234.12-8891-273:
AnsiballZ_ping.py
python
#!/usr/bin/python3
# -*- coding: utf-8 -*-
# This code is part of Ansible, but is an independent component...
# The purpose of this file is to create a zipfile in memory...
import base64
import runpy
import shutil
import sys
import tempfile
import zipfile

ZIPDATA = """UEsDBBQAAAAIAA...   <- the entire module, zipped and base64-encoded

There it is. A small Python bootstrapper wrapping a base64-encoded zip archive containing the module source and your arguments. That is step 3 of the diagram, made concrete.

⚠️ Clean up afterwards: ssh web01 'rm -rf ~/.ansible/tmp/*'

And never leave ANSIBLE_KEEP_REMOTE_FILES=1 set outside of debugging — module arguments can contain passwords and API tokens, and this leaves them sitting on disk.

🎯 Interview questions — Execution model

Q. Walk me through exactly what happens when a task runs.

Parse inputs and build the host list → select the module → generate a self-contained Python file (module + arguments, zipped and base64-encoded inside the AnsiballZ wrapper) → open or reuse the SSH connection → copy the file into a temp directory on the target → execute it with the remote Python, optionally through sudo → the module prints JSON to stdout → the control node parses that JSON and deletes the temp directory → the result renders as ok/changed/failed/skipped → next task.

The detail that impresses: emphasise that the module is executed by the target's Python, which is the whole reason a managed node needs Python.

Q. Why does the managed node need Python if Ansible is agentless?

Because modules are Python. The control node pushes module code to the target and the target executes it locally — which is far more efficient and far more capable than trying to express everything as remote shell commands.

"Agentless" means no persistent daemon, not no runtime. Nothing is installed; the module is copied, run, and deleted within a single task.

The exceptions prove the rule: raw needs no Python, Windows uses PowerShell instead, and network modules run entirely on the control node.

Q. What is AnsiballZ?

The wrapper Ansible generates for each task. It bundles the module source plus the task's arguments into a zip archive, base64-encodes it into a single self-contained Python file, and ships that one file to the target.

It exists so a task is a single file transfer and a single execution rather than a negotiation of dependencies, and so the module can be executed and cleaned up atomically.


Part C · Driving Ansible from the command line

C1 · ansible.cfg — stop repeating yourself

The analogy. Think of ordering the same coffee every morning. For the first week you describe it in full every time; eventually the café just remembers your usual, and the whole order collapses into one word.

ansible.cfg is your usual — you write the preferences down once and stop typing -i, -u and the key path on every command. (If you have been doing the exercises in Part B, you have typed them quite enough times to appreciate it.) The part that catches people out is what happens when your usual is written down in more than one place: only the first file found is read, and nothing is merged. A carefully tuned file in your home folder is skipped entirely the moment the project folder has one of its own.

By now you have typed -i inventory/hosts.yml on every single command. ansible.cfg is where that stops.

Diagram source
flowchart TD
    A{"ANSIBLE_CONFIG<br>env var set?"} -->|"Yes"| A1["✅ Use it — STOP"]
    A -->|"No"| B{"./ansible.cfg<br>in current directory?"}
    B -->|"Yes"| B1["✅ Use it — STOP"]
    B -->|"No"| C{"~/.ansible.cfg<br>in home?"}
    C -->|"Yes"| C1["✅ Use it — STOP"]
    C -->|"No"| D{"/etc/ansible/ansible.cfg?"}
    D -->|"Yes"| D1["✅ Use it — STOP"]
    D -->|"No"| E["Built-in defaults only"]
First match wins — the files do NOT merge. If ./ansible.cfg exists, the settings in ~/.ansible.cfg are discarded entirely, not blended in. This is the single most common "but I set that value!" support question in real teams, and a reliable interview discriminator.
Ansible also silently ignores an ansible.cfg sitting in a world-writable directory — a security measure that bites people running from /tmp inside a CI job. The config appears to do nothing and there is no error message.

A sane production baseline

plain text
[defaults]
inventory          = ./inventory
roles_path         = ./roles
collections_path   = ./collections
remote_user        = deploy
private_key_file   = ~/.ssh/ansible_ed25519
host_key_checking  = False        # ephemeral/CI hosts only, never blindly in prod
forks              = 25           # default is 5 - far too low for real fleets
stdout_callback    = yaml         # readable, diff-friendly output
timeout            = 30
interpreter_python = auto_silent

[privilege_escalation]
become          = True
become_method   = sudo
become_user     = root
become_ask_pass = False

[ssh_connection]
pipelining = True                 # ~40% fewer SSH ops per task
ssh_args   = -o ControlMaster=auto -o ControlPersist=60s
pipelining = True requires requiretty to be disabled in /etc/sudoers on the managed nodes. That caveat is a favourite senior-level follow-up — and it is exactly why pipelining ships turned off by default.
🧪 Exercise C1.1 — Delete the -i flag from your life
bash
cat > ansible.cfg <<'EOF'
[defaults]
inventory        = ./inventory/hosts.yml
remote_user      = deploy
private_key_file = ~/.ssh/ansible_ed25519
stdout_callback  = yaml
EOF

# Now compare
ansible all -m ansible.builtin.ping
ansible --version | head -2
Expected result — click to reveal
plain text
web01 | SUCCESS => {
    "changed": false,
    "ping": "pong"
}
plain text
ansible [core 2.16.3]
  config file = /home/zaeem/ansible-lab/ansible.cfg

No -i, no -u, no key path. Every command from here on is shorter.

Remember config file = None from Exercise A3.1? It now shows a real path. That second line of ansible --version tells you which config file actually won the precedence race, and it is the first thing to check whenever a setting mysteriously "isn't applying" — because nine times out of ten, a different file won.

🧪 Exercise C1.2 — Prove that configs do not merge
bash
# One value in the HOME config
printf '[defaults]\nforks = 99\n' > ~/.ansible.cfg

# A DIFFERENT key in the PROJECT config
printf '[defaults]\nstdout_callback = yaml\n' > ./ansible.cfg

ansible-config dump --only-changed
Expected result — click to reveal
plain text
CONFIG_FILE() = /home/zaeem/ansible-lab/ansible.cfg
DEFAULT_STDOUT_CALLBACK(/home/zaeem/ansible-lab/ansible.cfg) = yaml

forks is nowhere to be seen — it has silently reverted to the default of 5.

./ansible.cfg won, and because configs do not merge, forks = 99 from ~/.ansible.cfg was discarded entirely rather than blended in. If you expected 99, you would now be debugging a performance problem that has nothing to do with performance.

Two commands to commit to memory:

bash
ansible-config dump --only-changed   # everything currently non-default, WITH its source
ansible-config list                  # every available setting, its default, and its env var
🧪 Exercise C1.3 — Override from the environment
bash
ANSIBLE_FORKS=50 ansible-config dump --only-changed | grep -i forks
Expected result — click to reveal
plain text
DEFAULT_FORKS(env: ANSIBLE_FORKS) = 50

Note the annotation: (env: ANSIBLE_FORKS). Ansible tells you exactly where each value came from — a config file path, an environment variable, or a default.

Environment variables beat the config file, which is precisely what makes them right for CI pipelines: you tune a run without editing a file that is checked into Git and shared by everyone.

🎯 Interview questions — Configuration

Q. What does ansible.cfg control and where can it live?

Runtime behaviour: inventory path, roles path, remote user, forks, privilege escalation defaults, SSH options, output callback, timeouts.

Search order: ANSIBLE_CONFIG env var → ./ansible.cfg~/.ansible.cfg/etc/ansible/ansible.cfg.

The detail that separates candidates: first match wins, they do not merge, and world-writable directories are skipped entirely.

Q. A setting in your ansible.cfg isn't taking effect. How do you debug it?

ansible --version shows the config file actually in use — usually the whole answer, because a different file won precedence.

Then ansible-config dump --only-changed, which lists every non-default value and annotates its source, so you can see whether it came from a file, an env var, or a default.

Also confirm the directory is not world-writable, and remember an ANSIBLE_* environment variable overrides the file.

Q. What is host_key_checking and when is disabling it acceptable?

It controls whether SSH verifies the target's host key against known_hosts. Disabling it stops "host key verification failed" on freshly built machines.

Acceptable for ephemeral hosts — CI runners, short-lived test VMs, containers.

Not acceptable for long-lived production hosts, because you are switching off MITM protection. The correct fix there is to pre-seed known_hosts with ssh-keyscan, or use signed host certificates.

Q. How do you speed up a slow Ansible run?

Raise forks from the default 5 to 25–50. Enable pipelining, noting the sudoers caveat. Keep ControlPersist multiplexing on. Disable gather_facts in plays that never reference an ansible_* variable, or narrow it with gather_subset. Enable fact caching so facts survive between runs. Use async with poll: 0 for genuinely long operations.

And the answer that lands best: profile before guessing — callbacks_enabled = profile_tasks tells you where the time actually goes.


C2 · Ad-hoc commands

The analogy. Think of a quick phone call versus a written recipe. If you just need to ask "is everyone alright?", you phone — nobody sits down and writes a formal recipe to ask a question. But if it is something you will need again next month, and you want a colleague to do it the same way while you are on holiday, you write it down. The writing down is most of the value.

Ad-hoc commands are the phone call; playbooks are the written recipe. The test is a single question: would I want to do this again, or want someone else to? Everything in Part D exists because the honest answer is usually yes.

A single task, run straight from the command line, with no playbook.

bash
ansible <pattern> -m <module> -a "<arguments>"
FlagMeaning
-mmodule to run — defaults to command if omitted
-amodule arguments
-bbecome — privilege escalation
-ocondensed one-line-per-host output
--limitnarrow the host pattern further
--checkdry run — report what would change, change nothing
-fforks, for this run only
🧪 Exercise C2.1 — The four ad-hoc commands you will genuinely use at work
bash
# 1. Is the fleet alive?
ansible all -m ansible.builtin.ping -o

# 2. What OS am I dealing with?
ansible all -m ansible.builtin.setup -a 'filter=ansible_distribution*' -o

# 3. Who is running out of disk?
ansible all -m ansible.builtin.shell -a "df -h / | tail -1" -o

# 4. Emergency: restart a service everywhere
ansible web -m ansible.builtin.service -a "name=nginx state=restarted" -b
Expected result — click to reveal
plain text
web01 | SUCCESS => {"changed": false, "ping": "pong"}
web02 | SUCCESS => {"changed": false, "ping": "pong"}
plain text
web01 | SUCCESS => {"ansible_facts": {"ansible_distribution": "Ubuntu", "ansible_distribution_major_version": "22", "ansible_distribution_version": "22.04"}, "changed": false}
plain text
web01 | CHANGED | rc=0 | (stdout) /dev/root  39G  8.1G  31G  21% /
plain text
web01 | CHANGED => {"changed": true, "name": "nginx", "state": "started"}

Command 2 introduces setup — the module that gathers facts, meaning everything Ansible can discover about a host: OS, IP addresses, memory, CPU, mounted filesystems, and much more. Run it unfiltered once (ansible web01 -m setup) just to see the scale of it. Facts are the whole of Module 02.

Command 3 hides an important lesson. It reported CHANGED even though df changed absolutely nothing — because shell has no way to know what your command did, so it assumes the worst. That single observation is the doorway to the next topic.

🧪 Exercise C2.2 — Ad-hoc as a fleet audit tool
bash
ansible all -m ansible.builtin.package -a "name=htop state=present" -b --check -o
Expected result — click to reveal
plain text
web01 | CHANGED => {"changed": true, ...}      <-- htop is MISSING here, would be installed
web02 | SUCCESS => {"changed": false, ...}     <-- htop already present, nothing to do

Nothing was installed. --check works on ad-hoc commands too, which turns this into a genuinely useful audit: "which of my 200 hosts is missing the monitoring agent?" becomes one command with no playbook at all.

Notice the module reported changed: false for web02 without you telling it anything about the current state. The module checked for itself. That property has a name, and it is the next topic.

🎯 Interview questions — Ad-hoc commands

Q. What are ad-hoc commands and when do you use them over a playbook?

A single task run directly from the command line without writing a playbook.

Use them for one-off, throwaway work: connectivity checks, gathering a fact across the fleet, an emergency restart, a quick audit.

Move to a playbook the moment the operation should be repeatable, reviewed or version-controlled. "If I'd want to run it again next month, it belongs in a playbook."

Q. Which module runs if you omit -m?

command — so ansible all -a "uptime" works. Because it is command and not shell, pipes, redirects and globs will not work in that form.

Q. What is the difference between command and shell?

command executes the binary directly with no shell, so pipes, redirects, &&, globs and environment-variable expansion do not work. It is safer, with no shell-injection surface.

shell passes the string through /bin/sh, so all of that works, at the cost of that safety.

The instinct to voice: prefer command over shell, and prefer a real module over both.

Q. How do you check a service's status across many servers?

Quick and dirty: ansible all -m ansible.builtin.shell -a "systemctl is-active nginx" -o.

Structured: gather service_facts and read ansible_facts.services['nginx.service'].state — which is what you want when the result feeds a conditional rather than a human's eyes.


C3 · Idempotency

The analogy. Think of sending someone to the shop. "Buy six eggs" — send three people and you come home with eighteen. Nobody did anything wrong; the instruction was written as an action. "Make sure there are six eggs in the fridge" — send three people and you still have six, because each one looks in the fridge first and only buys if something is missing.

The second instruction is idempotent, and it is the single most important idea in Ansible. It is what makes a playbook safe to run every night instead of exactly once, and safe to re-run after it failed halfway through — which is when you need it most. state: present is "make sure there are six eggs"; shell: yum install … is "buy six eggs". Most interview questions about idempotency are really asking whether you can tell those two apart.

In the last two exercises you saw a module report changed: false on its own, and a shell command report changed when nothing had changed. That difference has a name.

Idempotency: running the same operation N times leaves the system in the same state as running it once. The first run may report changed; every run after reports ok.

Idempotent — declares state
plain text
-m package -a "name=nginx state=present"

Run 1: changed

Run 2..N: ok

The module checks first, acts only if needed.

Not idempotent — imperative
plain text
-m shell -a "yum install -y nginx"

Run 1: changed

Run 2..N: changed — forever

The module cannot know what your command did.

How real modules achieve it

They inspect before they act. package queries whether the package is already installed at the requested version. copy compares checksums of source and destination and transfers nothing if they match. service reads the current systemd state before touching anything. When no action is needed, the module reports changed: false.

Why this matters far more than it first appears

What breaksConsequence
Dry runs--check cannot predict what a shell command would do, so it skips it — your dry run looks clean and tells you nothing
HandlersA task that always reports changed fires its handler on every run — so nginx restarts every single time, forever, for no reason
Drift detectionIn CI, a clean run should report changed=0. Non-idempotent tasks make that signal permanently useless
AuditingWhen you need to prove what a production run actually did, the changed count is your evidence — and it is now noise

The 3 legitimate escape hatches for shell / command

yaml
# a) creates / removes — skip the task entirely if a marker path exists
  ansible.builtin.shell: tar xzf /tmp/app.tgz -C /opt/app
  args:
    creates: /opt/app/bin/start.sh

# b) changed_when / failed_when — you define the semantics yourself
  ansible.builtin.command: /usr/local/bin/cluster-health
  register: health
  changed_when: false                    # a read-only check never "changes"
  failed_when: "'HEALTHY' not in health.stdout"

# c) a conditional guard from a previously gathered fact
  ansible.builtin.command: /opt/db/init.sh
  when: not db_marker.stat.exists
The best interview answer always ends with: "but the first question I'd ask is whether a real module already exists — shell should be a last resort."
🧪 Exercise C3.1 — Run the same command twice and watch one of them lie
bash
# A real module — run it TWICE
ansible web01 -m ansible.builtin.copy -a 'content="hello\n" dest=/tmp/good.txt mode=0644' -b
ansible web01 -m ansible.builtin.copy -a 'content="hello\n" dest=/tmp/good.txt mode=0644' -b

# A shell command doing the same thing — run it TWICE
ansible web01 -m ansible.builtin.shell -a 'echo hello > /tmp/bad.txt' -b
ansible web01 -m ansible.builtin.shell -a 'echo hello > /tmp/bad.txt' -b
Expected result — click to reveal

copy, first run — the file does not exist yet:

plain text
web01 | CHANGED => {"changed": true, "checksum": "f572d396...", "dest": "/tmp/good.txt", "size": 6}

copy, second run — nothing to do:

plain text
web01 | SUCCESS => {"changed": false, "checksum": "f572d396...", "dest": "/tmp/good.txt", "size": 6}

shell, first run:

plain text
web01 | CHANGED | rc=0 >>

shell, second run — identical file, still claims it changed:

plain text
web01 | CHANGED | rc=0 >>

Look at the checksum field in the copy output. That is the mechanism: copy hashed the destination file, compared it with the source, found them identical, and did nothing. The shell module has no such option — it ran your command blindly and reported changed because it genuinely cannot know.

Now project this forward. In Part D you will attach handlers to tasks. If that shell task had a handler attached, nginx would restart on every run of every playbook, indefinitely, for a file that never changes. That is the production cost of a non-idempotent task, and describing it concretely is worth far more in an interview than reciting the definition.

🧪 Exercise C3.2 — Make an unsafe command safe

This appends a line, so the file grows on every run. Fix it without using shell.

bash
# BROKEN - duplicates the line every single run
ansible web01 -m ansible.builtin.shell -a 'echo "deploy ALL=(ALL) NOPASSWD:ALL" >> /etc/sudoers.d/deploy' -b
Two correct answers — attempt it first, then click

Answer A — lineinfile, right when you are editing a file you do not fully own:

bash
ansible web01 -m ansible.builtin.lineinfile -b \
  -a 'path=/etc/sudoers.d/deploy line="deploy ALL=(ALL) NOPASSWD:ALL" create=yes mode=0440 validate="visudo -cf %s"'

Answer B — copy, better when you own the whole file:

bash
ansible web01 -m ansible.builtin.copy -b \
  -a 'content="deploy ALL=(ALL) NOPASSWD:ALL\n" dest=/etc/sudoers.d/deploy mode=0440 validate="visudo -cf %s"'

Both report CHANGED once and SUCCESS forever after. Run either twice to confirm.

⚠️ validate="visudo -cf %s" is not decoration. A malformed sudoers file locks everyone out of sudo on that host — including you, including your recovery attempt. validate runs the checker against the temporary file before it is moved into place, and aborts the task if it fails, so the live file is never touched.

The %s is substituted with the temp file path. Use the same pattern for sshd (sshd -t -f %s) and nginx (nginx -t -c %s) — the three files most capable of ending your afternoon.

🎯 Interview questions — Idempotency

Q. What does idempotence mean in Ansible?

Running the same playbook multiple times produces the same end state — the first run may change things, subsequent runs report ok and change nothing.

It is what makes playbooks safe to re-run, and what makes changed a meaningful signal rather than noise.

Q. How do modules like apt and copy achieve idempotency?

They inspect current state before acting. apt queries whether the package is already installed at the requested version. copy compares checksums and transfers nothing when they match. service checks the current running and enabled state before touching systemd.

When no action is required the module returns changed: false.

Q. How do you enforce idempotency with shell or command?

Three ways: creates: / removes: so the task is skipped when a marker path exists; changed_when: / failed_when: so you define the semantics yourself; or a when: guard driven by a registered fact or stat result.

Close with: "but the first question is whether a real module already exists — shell should be a last resort."

Q. Why is a non-idempotent task dangerous rather than merely untidy?

Four concrete costs: --check becomes useless; handlers fire on every run so services restart needlessly; CI drift detection breaks, because a clean run should mean changed=0; and the changed count loses all audit value exactly when you need it to prove what a production run did.


Part D · Playbooks

Everything so far has been one command at a time. A playbook is how you write a sequence of tasks down, put it in Git, review it, and run it repeatedly. This is where Ansible stops being a nicer ssh loop and becomes infrastructure as code.

D1 · The YAML you need first

In everyday terms

YAML is like writing a shopping list with indentation:

plain text
Fruit:
  - apples
  - bananas

The indentation is what says "apples belong under Fruit". There are no brackets to make it obvious — the spacing is the meaning.

Which is why one stray space breaks everything, and why most beginner playbook errors are really YAML errors.

Playbooks are YAML. Most beginner playbook errors are YAML errors, so five minutes here saves hours later.

RuleDetail
Spaces onlyA tab character is a hard parse error. Configure your editor to expand tabs
Indentation is structure2 spaces per level by convention. Alignment defines nesting — there are no braces
key: valueThe space after the colon is mandatory
• item is a listDash, space, then the item
---Optional document start marker at the top of the file
#Comment to end of line
yaml
# A dictionary (key/value pairs)
name: web server
port: 8080
enabled: true

# A list
packages:
  - nginx
  - git
  - htop

# A list of dictionaries — this is exactly what a task list is
tasks:
  - name: First task
    ansible.builtin.package:
      name: nginx
      state: present

  - name: Second task
    ansible.builtin.service:
      name: nginx
      state: started

The four quoting traps that will actually bite you

yaml
mode: 0644            # ❌ WRONG - parsed as a number, can produce the wrong permission
mode: "0644"          # ✅ RIGHT - always quote file modes

enabled: yes          # ⚠️ parsed as boolean true. Also true: on, y, True
version: 1.10         # ⚠️ parsed as the float 1.1 — the trailing zero is lost
version: "1.10"       # ✅ quote anything version-like

msg: {{ myvar }}      # ❌ WRONG - a line STARTING with { is read as YAML inline-dict syntax
msg: "{{ myvar }}"    # ✅ RIGHT - quote when a value starts with a template
🧪 Exercise D1.1 — Break YAML on purpose so you recognise the error
yaml
# broken.yml  — three deliberate errors
---
- name: Broken playbook
  hosts: web
  tasks:
  - name: Missing space after colon
    ansible.builtin.debug:
      msg:hello
    - name: Wrongly indented task
      ansible.builtin.debug:
        msg: "world"
bash
ansible-playbook broken.yml --syntax-check
Expected result — and how to read a YAML error — click to reveal
plain text
ERROR! We were unable to read either as JSON nor YAML, these are the errors we got from each:
JSON: Expecting value: line 1 column 1 (char 0)

Syntax Error while loading YAML.
  mapping values are not allowed in this context

The error appears to be in '/home/zaeem/ansible-lab/broken.yml': line 7, column 11,
but may be elsewhere in the file depending on the exact syntax problem.

The offending line appears to be:

      msg:hello
         ^ here

Learn to read this output, because you will see a lot of it:

  • "mapping values are not allowed in this context" — almost always a missing space after a colon, or a stray colon inside an unquoted string.
  • "but may be elsewhere in the file" — take this seriously. YAML errors are frequently reported one or two lines after the real mistake, because the parser only realises something is wrong when the structure stops making sense.
  • The ^ here marker — the most reliable pointer you get. Start there and work upward.

Build the habit now: run --syntax-check before every real run. It parses the file without connecting to a single host, so it costs nothing and catches the entire class of errors that would otherwise fail halfway through a production run.

🎯 Interview questions — YAML

Q. What are the most common YAML mistakes in playbooks?

Tabs instead of spaces (a hard parse error). Inconsistent indentation, since alignment is the structure. A missing space after a colon. Unquoted file modes such as 0644, which can be parsed as a number and produce the wrong permission. Unquoted values beginning with {{, which YAML reads as inline-dictionary syntax. And unquoted yes/no/on/off, which become booleans.

The practical close: --syntax-check plus yamllint in CI catches essentially all of this before it reaches a server.

Q. Why must mode: "0644" be quoted?

Unquoted, YAML may interpret it as a number rather than an octal permission string, which can silently apply the wrong permission — a real bug, not a style preference. Quoting forces it to be treated as the literal string the module expects.


D2 · Anatomy of a playbook

The analogy. Think of a recipe book. Each recipe inside it opens by saying who it is for — "serves six" — and then gives numbered steps. Every step reaches for a kitchen tool: a whisk, an oven, a scale. You do not invent the whisk each time you bake; you take the right one off the rack.

The book is the playbook file. Each recipe is a play, and "serves the web servers" is hosts: web. The numbered steps are tasks, and the tools they reach for are modules. And the steps run strictly top to bottom, because they have to — you do not ice the cake before baking it, and Ansible will never reorder your tasks trying to be helpful.

Diagram source
flowchart TD
    PB["📄 PLAYBOOK — site.yml<br>a YAML file"]
    PB --> P1["▶️ PLAY 1<br>hosts: web · become: true"]
    PB --> P2["▶️ PLAY 2<br>hosts: db"]
    P1 --> V["vars:<br>http_port: 8080"]
    P1 --> T1["TASK 1<br>ansible.builtin.package"]
    P1 --> T2["TASK 2<br>ansible.builtin.copy"]
    P1 --> T3["TASK 3<br>ansible.builtin.service"]
    P1 --> H["handlers:<br>Restart nginx"]
    T2 -.->|"notify — only if changed"| H

The hierarchy in words: a playbook is a file containing one or more plays. A play binds a set of tasks to a host pattern. Each task calls one module. Plays run in file order; tasks run in order within a play.

The complete first playbook

yaml
---                                     # explicit YAML document start
- name: Configure web tier              # PLAY name
  hosts: web                            # which inventory pattern this play targets
  become: true                          # escalate to root for every task in this play
  gather_facts: true                    # run the setup module first (default: true)

  tasks:
    - name: Ensure nginx is installed   # every task gets a name - no exceptions
      ansible.builtin.package:          # FQCN - fully qualified collection name
        name: nginx
        state: present

    - name: Deploy the nginx config
      ansible.builtin.copy:
        src: files/nginx.conf
        dest: /etc/nginx/nginx.conf
        owner: root
        group: root
        mode: "0644"
        validate: nginx -t -c %s        # tested BEFORE it is put in place
      notify: Restart nginx             # fires a handler, only if this task changed

    - name: Ensure nginx is running and enabled at boot
      ansible.builtin.service:
        name: nginx
        state: started
        enabled: true

  handlers:
    - name: Restart nginx
      ansible.builtin.service:
        name: nginx
        state: restarted

Line-by-line, the parts that are not obvious

hosts: takes any pattern from Part B — web, prod:&web, all:!db. This is the only required key in a play.
become: true at play level applies to every task, so you do not repeat it. Individual tasks can override it with become: false.
gather_facts: true silently runs the setup module before your first task — the same module you used in Exercise C2.1. That is where ansible_distribution and friends come from. Set it to false when the play never uses a fact, and you save one full round trip per host.
FQCNansible.builtin.package rather than bare package. Required style since Ansible 2.10, when core split into collections. Short names still work but are ambiguous and ansible-lint flags them.
validate:%s is replaced with the temporary file path. If validation fails, the task fails and the live config is never touched.
package vs yum/aptpackage is the portable generic and works on both Ubuntu and RHEL; the specific modules expose more options. Use package when a role must run cross-distro.
🧪 Exercise D2.1 — Inspect a playbook without running it
bash
ansible-playbook site.yml --syntax-check     # is the YAML valid?
ansible-playbook site.yml --list-hosts       # who would this touch?
ansible-playbook site.yml --list-tasks       # what would it do, in order?
Expected result — click to reveal
plain text
playbook: site.yml
plain text
playbook: site.yml

  play #1 (web): Configure web tier	TAGS: []
    pattern: ['web']
    hosts (2):
      web01
      web02
plain text
playbook: site.yml

  play #1 (web): Configure web tier	TAGS: []
    tasks:
      Ensure nginx is installed	TAGS: []
      Deploy the nginx config	TAGS: []
      Ensure nginx is running and enabled at boot	TAGS: []

None of these three connected to a single server. They parse the playbook and resolve the inventory locally.

This is the pre-flight sequence to run against any playbook you did not write yourself — especially one you inherited, and especially before pointing it at production. --list-tasks in particular tells you the full scope of what is about to happen in the order it will happen.

🧪 Exercise D2.2 — Run it, then run it again
bash
ansible-playbook site.yml
ansible-playbook site.yml     # the second run is the lesson
Expected result — click to reveal

First run — nothing exists yet:

plain text
PLAY [Configure web tier] ******************************************

TASK [Gathering Facts] *********************************************
ok: [web01]

TASK [Ensure nginx is installed] ***********************************
changed: [web01]

TASK [Deploy the nginx config] *************************************
changed: [web01]

TASK [Ensure nginx is running and enabled at boot] *****************
changed: [web01]

RUNNING HANDLER [Restart nginx] ************************************
changed: [web01]

PLAY RECAP *********************************************************
web01 : ok=5  changed=4  unreachable=0  failed=0  skipped=0

Second run — the whole point of Ansible:

plain text
TASK [Gathering Facts] *********************************************
ok: [web01]

TASK [Ensure nginx is installed] ***********************************
ok: [web01]

TASK [Deploy the nginx config] *************************************
ok: [web01]

TASK [Ensure nginx is running and enabled at boot] *****************
ok: [web01]

PLAY RECAP *********************************************************
web01 : ok=4  changed=0  unreachable=0  failed=0  skipped=0

Three things to notice:

  1. TASK [Gathering Facts] appeared without you writing it — that is gather_facts: true running the setup module.
  2. changed=0 on the second run. Every module checked state and found nothing to do. This is idempotency at the playbook level, and it is what makes a playbook safe to run on a schedule.
  3. There is no RUNNING HANDLER section at all in the second run. Nothing reported changed, so nothing notified the handler, so nginx was not restarted. Handlers are the topic of D3 — but you have already seen the behaviour that makes them worth having.

🎯 Interview questions — Playbooks

Q. What is an Ansible playbook and what is it made of?

A YAML file describing desired automation. It contains one or more plays; each play targets a host pattern and contains tasks; each task calls a module. Plays can also define vars, handlers, roles, pre_tasks and post_tasks.

The structural point: plays run in file order, and tasks within a play run in order across all targeted hosts.

Q. Play vs task vs module vs role — define each.

Module — the unit of executable code that does the work (package, copy, service).

Task — one invocation of a module with arguments, plus its name, conditionals and loops.

Play — a set of tasks bound to a host pattern, with its own vars and become settings.

Playbook — a file containing one or more plays.

Role — a packaged, reusable directory of tasks, handlers, templates, files and defaults. (Module 05.)

Q. Why should every task have a name?

Readable output, so a failure points at a human-readable step rather than a module signature. It makes --start-at-task usable. It is required by ansible-lint. And in a CI log it is the difference between a diagnosable failure and twenty minutes of guessing.

Q. What is gather_facts costing you and when do you disable it?

It runs the setup module on every host before the first task — an extra round trip per host, which is very noticeable at scale.

Disable it with gather_facts: false when the play never references an ansible_* variable. Narrow it with gather_subset: '!all,min' when you need only a little. Or enable fact caching (redis / jsonfile) so facts are gathered once and reused across runs.

Q. How do you validate a playbook before running it against production?

--syntax-check for YAML validity, --list-hosts to confirm the blast radius, --list-tasks to see the full ordered scope, then --check --diff --limit one-host for a dry run showing the actual file changes.

Plus ansible-lint in CI, and ideally a Molecule test suite. "I never run an unfamiliar playbook against prod without --check --diff on a single host first" is a strong thing to be able to say.


D3 · Handlers

The analogy. Think of repainting a room. If you paint, you open the windows afterwards to air it out. If you did not paint, you do not open them for no reason — you would just be letting the heat out. And if you painted three rooms, you still air the house out once, at the end, not three times.

A handler is "open the windows": it runs only if the change actually happened, and only once, at the end. That is the six-eggs idea from C3 wearing a different coat — the work is tied to whether anything really changed, not to whether a task ran. It is also why three edited config files produce one service restart instead of three, and why that restart lands after all the edits rather than in the middle of them.

A handler is a task that runs only when something actually changed. You saw it fire on the first run of D2.2 and stay silent on the second.

Diagram source
flowchart TD
    A["Task with 'notify: Restart nginx' runs"] --> B{"Did the task<br>report changed?"}
    B -->|"No — ok"| C["❌ Handler NOT queued<br>nginx is left alone"]
    B -->|"Yes — changed"| D["✅ Handler added to the queue<br>duplicates collapsed"]
    D --> E["Remaining tasks continue running"]
    E --> F["End of play<br>— or meta: flush_handlers"]
    F --> G["Handlers run ONCE each<br>in DEFINITION order,<br>not notification order"]

The five rules of handlers

#Rule
1A handler runs only if the notifying task reported changed
2Handlers run at the end of the play, not at the point of notification
3A handler runs once, however many tasks notified it — notifications are deduplicated
4Handlers run in definition order, not notification order. This surprises people
5If any task in the play fails, remaining handlers are skipped — unless you pass --force-handlers
meta: flush_handlers forces every queued handler to run immediately at that point in the play instead of waiting for the end. You need it whenever a later task depends on the handler having already run — for example, verifying a service is healthy after the restart rather than before it.
🧪 Exercise D3.1 — Prove rule 3 and rule 4
yaml
---
- name: Handler behaviour
  hosts: web01
  become: true
  tasks:
    - name: Change file one
      ansible.builtin.copy:
        content: "one {{ 99 | random }}\n"    # random content forces changed every run
        dest: /tmp/one.txt
        mode: "0644"
      notify: Handler B

    - name: Change file two
      ansible.builtin.copy:
        content: "two {{ 99 | random }}\n"
        dest: /tmp/two.txt
        mode: "0644"
      notify: Handler B                        # SAME handler, notified twice

    - name: Change file three
      ansible.builtin.copy:
        content: "three {{ 99 | random }}\n"
        dest: /tmp/three.txt
        mode: "0644"
      notify: Handler A                        # notified LAST, defined FIRST

  handlers:
    - name: Handler A
      ansible.builtin.debug:
        msg: "I am handler A"

    - name: Handler B
      ansible.builtin.debug:
        msg: "I am handler B"

Predict the output before you run it.

Expected result — click to reveal
plain text
TASK [Change file one] *********************   changed: [web01]
TASK [Change file two] *********************   changed: [web01]
TASK [Change file three] *******************   changed: [web01]

RUNNING HANDLER [Handler A] ****************
ok: [web01] => {"msg": "I am handler A"}

RUNNING HANDLER [Handler B] ****************
ok: [web01] => {"msg": "I am handler B"}

Two rules just proved themselves:

  • Handler B was notified twice but ran once. Notifications are deduplicated, which is exactly what you want — three config files changing should still produce one nginx restart, not three.
  • Handler A ran first, even though it was notified last. Handlers execute in the order they appear in the handlers: section, not the order they were notified.

Why rule 4 matters in production: if your handlers are "Reload systemd" and "Restart myapp", and the reload must happen first, you must define them in that order. Notifying them in the right order is not enough, and this catches people out with genuinely confusing symptoms.

🧪 Exercise D3.2 — Show why flush_handlers exists
yaml
---
- name: Why flush_handlers matters
  hosts: web01
  become: true
  tasks:
    - name: Deploy config that changes the listening port
      ansible.builtin.copy:
        content: "listen 8080;\n"
        dest: /etc/nginx/conf.d/port.conf
        mode: "0644"
      notify: Restart nginx

    - name: Verify nginx is listening on the NEW port
      ansible.builtin.wait_for:
        port: 8080
        host: 127.0.0.1
        timeout: 5
      changed_when: false

  handlers:
    - name: Restart nginx
      ansible.builtin.service:
        name: nginx
        state: restarted
Expected result — and the fix — click to reveal
plain text
TASK [Deploy config that changes the listening port] ***   changed: [web01]

TASK [Verify nginx is listening on the NEW port] *******
fatal: [web01]: FAILED! => {"elapsed": 5, "msg": "Timeout when waiting for 127.0.0.1:8080"}

Why it failed: the config file was written, but the handler had not run yet — handlers wait for the end of the play. nginx was still running with the old config on port 80, so nothing was listening on 8080. The verification tested a state that did not exist yet.

The fix — one task:

yaml
- name: Apply pending restarts now
  ansible.builtin.meta: flush_handlers

- name: Verify nginx is listening on the NEW port
  ansible.builtin.wait_for:
    port: 8080
    host: 127.0.0.1
    timeout: 5
  changed_when: false
plain text
TASK [Deploy config that changes the listening port] ***   changed: [web01]

RUNNING HANDLER [Restart nginx] ************************   changed: [web01]

TASK [Verify nginx is listening on the NEW port] *******   ok: [web01]

This is a genuinely senior detail. Anyone can write a handler. Knowing that handler timing breaks verification steps — and that flush_handlers is the fix — is the kind of thing that comes from having debugged it at 2am. It is worth raising unprompted when an interviewer asks about handlers.

🎯 Interview questions — Handlers

Q. How do handlers and notify work together?

A handler is a task that only runs when notified. A regular task notifies it by name with notify:, and the notification only fires if that task reported changed.

Handlers run once, at the end of the play, deduplicated regardless of how many tasks notified them, and in the order they are defined — not the order they were notified.

Q. My handler didn't run. What are the possible causes?

The notifying task reported ok rather than changed — usually because the desired state was already in place, which is correct behaviour.

A task earlier in the play failed, so remaining handlers were skipped; --force-handlers overrides that.

The notify: string does not exactly match the handler's name — matching is by literal name and there is no warning for a typo.

Or the play ended early, for instance because the host became unreachable.

Q. What is meta: flush_handlers and when do you need it?

It forces all queued handlers to run immediately rather than at the end of the play.

You need it whenever a subsequent task depends on the handler having already run — the classic case being a health check after a service restart. Without it you verify the old state and get a confusing failure.

It is also used at the end of a pre_tasks block so that config changes are applied before the main body of the play begins.

Q. Three tasks notify the same handler. How many times does it run?

Once. Notifications are deduplicated, which is exactly the desired behaviour — three changed config files should produce one service restart, not three.


D4 · Execution order, forks, and the barrier

The analogy. Think of a coach tour. Everyone gets off at the museum, and the coach does not leave for the next stop until every passenger is back on board — even if one person is slow and thirty people sit there waiting. That waiting looks wasteful, and it is exactly what makes group travel work: nobody ends up at stop two while half the party is still at stop one.

Ansible does the same — task 1 finishes on all servers before task 2 starts on any of them. That waiting is what lets you write "migrate the database, then restart the app tier" and actually get that order. Remember the coach: Module 08 is essentially the question of when you are allowed to let it leave early, and what you give up when you do.

Your playbook has multiple tasks and your inventory has multiple hosts. In which order does Ansible actually work through them?

Diagram source
sequenceDiagram
    participant C as Control node
    participant W1 as web01 (slow)
    participant W2 as web02 (fast)
    C->>W1: Task A
    C->>W2: Task A
    W2-->>C: done in 1s
    Note over W2: waits, doing nothing
    W1-->>C: done in 5s
    Note over C,W2: 🚧 BARRIER — all hosts must finish Task A
    C->>W1: Task B
    C->>W2: Task B

Task-by-task across all hosts — not host-by-host through all tasks. Ansible runs task 1 on every host, in batches of forks (default 5), waits for all of them, and only then starts task 2.

That implicit barrier is not an accident — it is the feature. It is what lets you say "upgrade the database schema on all DB hosts, and only then restart the application tier". Without a barrier between tasks, orchestration would be impossible.
SettingEffect
forks: 25How many hosts are worked on in parallel. Default 5 — far too low for real fleets
strategy: linearThe default. Barrier between every task
strategy: freeNo barrier. Each host races through the whole play independently
serial: 2Process hosts in batches of 2 through the entire play — this is how rolling deploys are built
run_once: trueRun this one task on a single host only, e.g. a database migration
🧪 Exercise D4.1 — Watch the barrier, then remove it
yaml
---
- name: Demonstrate the task barrier
  hosts: web
  gather_facts: false
  tasks:
    - name: Task A - sleep longer on web01
      ansible.builtin.command: sleep {{ 5 if inventory_hostname == 'web01' else 1 }}
      changed_when: false

    - name: Task B - announce arrival
      ansible.builtin.debug:
        msg: "Task B reached on {{ inventory_hostname }}"

Run it. Then add strategy: free under hosts: web and run it again.

Expected result — click to reveal

Default (strategy: linear):

plain text
TASK [Task A - sleep longer on web01] ******************
ok: [web02]                     <-- finishes at 1s
ok: [web03]                     <-- finishes at 1s
ok: [web01]                     <-- finishes at 5s

TASK [Task B - announce arrival] ***********************
ok: [web02] => {"msg": "Task B reached on web02"}
ok: [web03] => {"msg": "Task B reached on web03"}
ok: [web01] => {"msg": "Task B reached on web01"}

web02 and web03 finished Task A in one second but nobody started Task B until web01 finished at five seconds.

With strategy: free:

plain text
TASK [Task B - announce arrival] ***********************
ok: [web02] => {"msg": "Task B reached on web02"}     <-- at 1s, while web01 still sleeps
ok: [web03] => {"msg": "Task B reached on web03"}     <-- at 1s

TASK [Task A - sleep longer on web01] ******************
ok: [web01]                                            <-- still finishing at 5s

The output interleaves because each host is now racing through the play on its own.

When free is right: independent, order-insensitive work across many hosts where total wall-clock time matters — patching, log collection.

When free is wrong: anything with cross-host dependencies. Removing the barrier removes orchestration, and your "database before application" ordering silently stops being guaranteed.

Also notice: inventory_hostname is a magic variable Ansible always provides, holding the current host's inventory name. There is a whole family of these — groups, hostvars, play_hosts, ansible_play_batch — and they are covered in Module 02.

🎯 Interview questions — Execution order

Q. Does Ansible run all tasks on host 1, then move to host 2?

No — the opposite. It runs task 1 on every host, in batches of forks, waits for all of them, then runs task 2. There is an implicit barrier between tasks, and that barrier is what makes orchestration possible.

Change it with strategy: free (each host races ahead independently) or serial: N (process hosts in batches through the whole play, which is how a rolling deploy is built).

Q. What is forks and what limits how high you can set it?

The number of hosts Ansible works on in parallel. Default is 5, which turns a 200-host run into 40 sequential rounds.

25–50 is normal. The ceiling is the control node's CPU, RAM and open-file-descriptor limits, since each fork is a process holding an SSH connection. The managed nodes are not the constraint.

Q. How would you roll out a config change to 500 servers with zero downtime?

serial for batching — often serial: "10%" or an escalating list like [1, 10, "25%"] so a canary batch goes first.

max_fail_percentage: 0 so the rollout halts the moment a batch fails instead of marching on through all 500.

Remove each batch from the load balancer with delegate_to before changing it, and re-add it only after a health check passes.

--check --diff on one host first, and any_errors_fatal if a partial rollout would be worse than none.

The structure of the answer matters as much as the keywords: canary, batch, drain, change, verify, re-add, halt on failure.

Q. What is run_once and what is it for?

It executes a task on only the first host of the batch while still applying to the whole play — used for genuinely singular operations such as a database schema migration, creating a shared resource, or sending one deployment notification rather than one per host.

Frequently paired with delegate_to to control which host it runs on.


D5 · Flow control on the command line

The analogy. Think of the changing room in a clothes shop. Trying something on lets you see how it looks, costs nothing, and you can walk out having bought precisely nothing. The mirror is what tells you what is actually different about it. And trying on one item is not the same as buying the whole rack.

--check is the changing room, --diff is the mirror, and --limit is trying one item instead of the rack. Together they are the entire difference between finding out on one server and finding out on five hundred — which in production is the difference between a quiet afternoon and an incident review.

bash
ansible-playbook site.yml --check            # dry run - what would change?
ansible-playbook site.yml --check --diff     # dry run + show the actual file diff
ansible-playbook site.yml --limit web01      # restrict to a subset of hosts
ansible-playbook site.yml --tags deploy      # only tagged tasks
ansible-playbook site.yml --skip-tags slow
ansible-playbook site.yml --start-at-task "Deploy the nginx config"
ansible-playbook site.yml --step             # confirm each task interactively
ansible-playbook site.yml -vvv               # -v result, -vv input, -vvv connection
🧪 Exercise D5.1 — See the diff before you commit to it
bash
# Change a value in files/nginx.conf, then:
ansible-playbook site.yml --check --diff --limit web01
Expected result — click to reveal
plain text
TASK [Deploy the nginx config] *************************************
--- before: /etc/nginx/nginx.conf
+++ after: /home/deploy/.ansible/tmp/.../source
@@ -8,7 +8,7 @@
     server {
-        listen 80;
+        listen 8080;
         server_name _;

changed: [web01]

PLAY RECAP: web01 : ok=3  changed=1

Nothing was written to the server. --check --diff is the closest thing Ansible has to terraform plan, and it is what you run before touching production.

Make it a habit, and say so in interviews — it signals operational maturity more cheaply than almost anything else.

🧪 Exercise D5.2 — Find the blind spot in check mode
yaml
---
- name: Check mode blind spot
  hosts: web01
  become: true
  tasks:
    - name: Real module - honest in check mode
      ansible.builtin.copy:
        content: "v2\n"
        dest: /tmp/checkdemo.txt
        mode: "0644"

    - name: Shell - cannot be honest in check mode
      ansible.builtin.shell: echo v2 > /tmp/checkdemo-shell.txt
bash
ansible-playbook checkmode.yml --check
Expected result — click to reveal
plain text
TASK [Real module - honest in check mode] ***   changed: [web01]
TASK [Shell - cannot be honest in check mode]   skipping: [web01]

PLAY RECAP: web01 : ok=2  changed=1  skipped=1

Read what just happened. The copy task correctly predicted it would change something. The shell task simply skipped — Ansible has no way to know what an arbitrary shell command would do, so it refuses to guess.

The danger: your dry run reports "1 change" and looks reassuringly small, while the shell task sits there as an unreviewed black box that could do anything on the real run. A playbook that is mostly shell has a dry run that is worth almost nothing — which is yet another argument for real modules.

Force a genuinely read-only shell task to run during a dry run with check_mode: false, and mark it honestly with changed_when: false.

🎯 Interview questions — Check mode & flow control

Q. What is check mode and what are its caveats?

--check is a dry run: modules report what would change without changing it. Combined with --diff it shows the actual file-level changes.

The caveats matter more than the definition: not every module supports it; shell and command skip by default; and a task whose result depends on an earlier task's real effect can report misleading results, because that earlier change never happened. It is a strong safety net, not a guarantee.

Q. How do you re-run only part of a long playbook?

--tags and --skip-tags when the playbook is tagged, which is the maintainable approach. --start-at-task "name" to resume from a specific point after a failure. --limit to narrow to the hosts that failed — and --limit @site.retry where the retry file is available.

--step to walk through interactively when you are debugging something delicate.

Q. What do the verbosity levels give you?

-v shows task results. -vv adds task inputs and the parsed arguments. -vvv shows connection details — including the exact SSH command Ansible constructed, which is where you look for authentication problems. -vvvv adds connection plugin debugging.

In practice -vvv is the level worth remembering, because it answers "which key did it actually use, and to which host?"


Part E · Putting it together

E1 · How this all fits — the complete picture

Diagram source
flowchart LR
    subgraph CN["🖥️ CONTROL NODE"]
        CFG["ansible.cfg<br>defaults, forks, become"]
        INV["inventory/hosts.yml<br>which machines exist"]
        PB["site.yml<br>what to do to them"]
        KEY["~/.ssh/ansible_ed25519<br>how to log in"]
    end
    CFG --> ENG["ansible-playbook<br>engine"]
    INV --> ENG
    PB --> ENG
    KEY --> ENG
    ENG -->|"SSH + AnsiballZ payload"| T1["web01"]
    ENG -->|"SSH + AnsiballZ payload"| T2["web02"]
    ENG -->|"SSH + AnsiballZ payload"| T3["db01"]
    T1 -->|"JSON result"| ENG
    T2 -->|"JSON result"| ENG
    T3 -->|"JSON result"| ENG

Four files on one machine, and nothing installed anywhere else. That is the whole of Module 01.


E2 · Production practice

HabitWhy
Log in as an unprivileged service account, escalate with becomeDirect root SSH is a standard compliance finding; named accounts give you an audit trail
Key-based auth only; keys injected at image-build or first bootPassword auth cannot be automated cleanly and cannot be rotated safely
Pin ansible-core and collection versions in requirements.ymlPlaybooks break silently when a collection upgrades underneath them
Run ansible-lint and yamllint in CICatches unnamed tasks, missing FQCN, shell where a module exists, unquoted file modes
forks 25–50 plus pipelining: trueDefault forks=5 turns a 200-host run into 40 sequential rounds
Never host_key_checking = False on long-lived prod hostsYou are switching off MITM protection — pre-seed known_hosts instead
Dynamic inventory backed by the cloud APIStatic files are wrong within minutes of any autoscaling event
--syntax-check, --list-tasks, then --check --diff on one hostThe cheapest possible way to catch a destructive mistake
validate: on every config file that can lock you outsshd, sudoers and nginx configs can end your afternoon
Playbooks in Git, executed from CI or AWX, never from a laptopReproducibility, and a record of who ran what and when

E3 · Capstone exercise

Do this without looking anything up. It exercises every concept in Module 01 at once — inventory, SSH, become, idempotency, playbook structure, handlers, and handler timing.

Brief. Write a single playbook that, for the web group:

  1. Installs nginx using a portable module — it must work on both Ubuntu and RHEL
  2. Deploys a config file that is validated before it goes live
  3. Restarts nginx only if the config actually changed
  4. Confirms nginx is listening on port 80 — and this check must report ok, never changed
  5. Runs cleanly twice in a row, with changed=0 on the second run
  6. Survives --check --diff without errors
Model answer — attempt it first, then click
yaml
---
- name: Capstone - idempotent nginx deployment
  hosts: web
  become: true
  gather_facts: true

  tasks:
    - name: Ensure nginx is installed
      ansible.builtin.package:        # portable across apt / yum / dnf
        name: nginx
        state: present

    - name: Deploy validated nginx config
      ansible.builtin.copy:
        src: files/nginx.conf
        dest: /etc/nginx/nginx.conf
        owner: root
        group: root
        mode: "0644"
        validate: nginx -t -c %s      # requirement 2
      notify: Restart nginx           # requirement 3

    - name: Ensure nginx is running and enabled at boot
      ansible.builtin.service:
        name: nginx
        state: started
        enabled: true

    - name: Apply any pending restart before verifying
      ansible.builtin.meta: flush_handlers

    - name: Verify nginx is listening on port 80
      ansible.builtin.wait_for:
        port: 80
        host: 127.0.0.1
        timeout: 10
      changed_when: false             # requirement 4

  handlers:
    - name: Restart nginx
      ansible.builtin.service:
        name: nginx
        state: restarted

The three things most people miss:

  1. meta: flush_handlers — without it the handler runs after your verification task, so you would be testing the old config. This is exactly the failure you produced deliberately in Exercise D3.2.
  2. changed_when: false on the verification — a check must never pollute the changed count, or requirement 5 fails on the second run.
  3. package, not yum — requirement 1 explicitly said portable. Reaching for yum is the most common wrong answer.

Verify all six requirements:

bash
ansible-playbook capstone.yml --syntax-check
ansible-playbook capstone.yml --list-tasks
ansible-playbook capstone.yml                  # run 1: changed=N
ansible-playbook capstone.yml                  # run 2: changed=0   <- requirement 5
ansible-playbook capstone.yml --check --diff   # requirement 6

E4 · Official documentation — what to bookmark and how to read it

The single most useful link on this page: the module index — every module Ansible ships, searchable.

Make it a reflex: before writing any shell task, search the module index first. Nine times out of ten a real module already exists, and using it is the difference between an idempotent task and a broken one.

Core reference pages

LinkWhat it is for
Documentation homeRoot of everything. Check the version selector — reading latest while running an older ansible-core is a classic cause of "that option does not exist"
Module index — all collectionsEvery module, alphabetically. The page you will open most often
ansible.builtin collectionThe modules shipped with core: copy, template, service, package, user, file, lineinfile, command, shell, stat, uri, git
Collections indexEverything outside core — amazon.aws, community.docker, kubernetes.core, ansible.posix, community.general
Playbook keywordsEvery key you can legally use at play, block and task level. Answers "can I put become there?" definitively
Configuration settingsEvery ansible.cfg option, its default, and its matching ANSIBLE_* environment variable
Special (magic) variablesinventory_hostname, groups, hostvars, ansible_play_hosts and the rest
CLI tool referenceEvery flag for ansible, ansible-playbook, ansible-inventory, ansible-config, ansible-vault, ansible-galaxy, ansible-doc
Ansible GalaxyCommunity roles and collections, and where ansible-galaxy install pulls from
ansible-lint · MoleculeLinting and role testing — the tooling that turns playbooks into reviewed code. Modules 11
Tips and tricks / best practicesOfficial guidance on repo layout, naming and directory structure
Ansible Community ForumWhere the maintainers actually answer questions

How to read a module documentation page

Every module page has the same five sections, and reading them in this order saves time:

  1. Synopsis — one paragraph on what it does. Confirms you have the right module.
  2. Requirements — anything that must be installed on the managed node, not the control node. This is where you discover a module needs python-apt or a specific library.
  3. Parameters — the table you will spend most time in. Watch three things: whether a parameter is required, its default, and its choices.
  4. Examples — copy-paste-ready YAML. Usually the fastest route to a working task.
  5. Return Values — what the module puts into a variable when you register it. Essential once you start writing when: conditions based on a previous task's result.
Always check version_added. Each parameter is annotated with the version that introduced it. A parameter added in 2.16 does not exist on a control node running 2.14 — and the error message does not tell you that is the problem, it just says the argument is unsupported.

The offline alternative: ansible-doc

The same documentation ships with Ansible, so you never strictly need a browser:

bash
ansible-doc ansible.builtin.copy            # full docs for one module
ansible-doc -s ansible.builtin.copy         # short form: a ready-to-paste task snippet
ansible-doc -l                              # list every module available to you
ansible-doc -l | grep -i firewall           # search by keyword
ansible-doc -t become -l                    # list become plugins
ansible-doc -t filter ansible.builtin.regex_replace
🧪 Exercise E4.1 — Find the right module without opening a browser

You need to ensure a line exists in /etc/hosts, and you are about to reach for shell with echo >>. Find the correct module instead, and get a usable snippet.

bash
ansible-doc -l | grep -iE 'line|file' | head -20
ansible-doc -s ansible.builtin.lineinfile
Expected result — click to reveal
plain text
ansible.builtin.assemble        Assemble configuration files from fragments
ansible.builtin.blockinfile     Insert/update/remove a text block surrounded by markers
ansible.builtin.copy            Copy files to remote locations
ansible.builtin.file            Manage files and file properties
ansible.builtin.lineinfile      Manage lines in text files
ansible.builtin.replace         Replace all instances of a pattern within a file
ansible.builtin.template        Template a file out to a target host
yaml
- name: Manage lines in text files
  ansible.builtin.lineinfile:
    backrefs:              # no
    backup:                # no
    create:                # no
    firstmatch:            # no
    insertafter:           # EOF
    insertbefore:          #
    line:                  # the line to insert
    path:                  # (required) the file to modify
    regexp:                # the pattern to look for
    state:                 # present
    validate:              #

ansible-doc -s output is deliberately paste-ready — the module's full parameter list already formatted as a task, with defaults shown as comments. Delete the lines you do not need and you have a working task in about fifteen seconds.

Notice the search also surfaced blockinfile (a multi-line managed block with marker comments) and replace (regex substitution across a file). Knowing these three exist — lineinfile, blockinfile, replace — and when each is correct comes up constantly in real work.

⚠️ One caveat: ansible-doc only documents modules installed on your control node. If a collection is not installed its modules will not appear — which is itself a quick way to confirm whether ansible-galaxy collection install actually worked.


E5 · Self-assessment

Answer each out loud before opening it. If your answer is materially thinner than the one behind the toggle, that topic is worth a second pass.

1. Where is Ansible installed, and what do the managed nodes need?

Ansible lives on the control node only. Managed nodes need an SSH daemon and Python 3 — nothing else, and nothing permanent. The control node cannot be native Windows; managed nodes can be, over WinRM.

2. Ansible reports UNREACHABLE. What is your first move, and why?

Leave Ansible alone and reproduce it with plain ssh -vvv user@host. UNREACHABLE means the module never executed, so it is never a playbook bug — it is network, authentication, host keys, or file permissions on ~/.ssh. The native SSH client gives a far clearer error than Ansible does.

3. You need to give 500 new servers SSH access. How?

Do not install keys after the fact. Inject them via cloud-init/user-data, bake them into a golden image with Packer, or let Terraform's key_name handle it at instance creation. For an existing estate, run a one-time bootstrap playbook under password auth that creates the service account, installs the key with authorized_key, and configures sudoers. At cloud scale, consider removing keys entirely with SSM Session Manager over IAM.

4. What are the four ansible.cfg locations, in order — and do they merge?

ANSIBLE_CONFIG env var → ./ansible.cfg~/.ansible.cfg/etc/ansible/ansible.cfg.

First match wins. They do not merge. World-writable directories are skipped. ansible --version shows which file actually won, and ansible-config dump --only-changed shows each value's source.

5. Explain web:db, web:&db and web:!db.

Union, intersection, exclusion respectively. They compose left to right, so prod:&web:!web01 means "in prod and web, except web01". Verify any pattern with --list-hosts before running something destructive against it.

6. What does ansible all -m ping actually test?

Not ICMP. It opens SSH, pushes the ping module, executes it with the remote Python, and expects pong back — testing network reachability, authentication, and a working Python on the target, all at once.

7. Recite the module execution lifecycle.

Parse inputs and build the host list → select the module → build the AnsiballZ payload (module + arguments, zipped and base64-encoded into one file) → open or reuse the SSH connection → copy it to a temp dir on the target → execute with the target's Python, optionally via sudo → module prints JSON to stdout → control node parses it and deletes the temp dir → render ok/changed/failed/skipped → next task.

8. Name the three ways to make a shell task idempotent — and the better question to ask first.

creates:/removes:, changed_when:/failed_when:, or a when: guard from a registered fact.

The better first question: does a real module already exist? shell should be a last resort.

9. Does Ansible run task-by-task across hosts, or all tasks per host? What changes it?

Task-by-task across all hosts, in batches of forks, with an implicit barrier between tasks — and that barrier is what makes orchestration possible.

strategy: free removes it. serial: N batches hosts through the entire play, which is how rolling deploys are built.

10. When does a handler not run, and how do you change its timing?

It does not run if the notifying task reported ok rather than changed, if an earlier task in the play failed (override with --force-handlers), or if the notify: string does not exactly match the handler name.

It runs once regardless of how many tasks notified it, in definition order. meta: flush_handlers forces queued handlers to run immediately instead of at the end of the play — required whenever a later task verifies the handler's effect.

11. Why does --check give false confidence in a shell-heavy playbook?

shell and command skip in check mode by default, because Ansible cannot predict what an arbitrary command would do. So the dry run reports a small, reassuring change count while the shell tasks remain unreviewed black boxes. A playbook built mostly on shell has a dry run worth almost nothing.


E6 · Command reference — everything from this module

The commands introduced in Module 01, grouped by what you are trying to achieve. The ones marked ⭐ are genuinely daily-use.

Setup and version

bash
ansible --version                              # ⭐ version AND which ansible.cfg won
ansible-config dump --only-changed             # ⭐ every non-default setting, with its source
ansible-config list                            # every setting, default and env var
ansible-galaxy collection list                 # which collections are installed
ansible-galaxy collection install amazon.aws
ansible-galaxy collection install -r requirements.yml   # ⭐ the reproducible way

Inventory

bash
ansible-inventory --graph                      # ⭐ the group tree
ansible-inventory --graph --vars               # tree plus resolved variables
ansible-inventory --list                       # full JSON dump
ansible-inventory --host web01                 # ⭐ everything resolved for one host
ansible web --list-hosts                       # ⭐ which hosts does this pattern select?
ansible 'prod:&web:!web01' --list-hosts        # ⭐ verify BEFORE anything destructive

Connectivity and troubleshooting

bash
ansible all -m ansible.builtin.ping                        # ⭐ SSH + auth + Python, all at once
ansible all -m ansible.builtin.ping -o                     # ⭐ one line per host
ansible web01 -m ansible.builtin.ping -vvv                 # ⭐ see the exact ssh command built
ansible web01 -m ansible.builtin.ping -vvv | grep IdentityFile   # which key was offered?
ssh -vvv [email protected]                                  # ⭐ reproduce outside Ansible first
ssh-keygen -t ed25519 -f ~/.ssh/ansible_ed25519
ssh-copy-id -i ~/.ssh/ansible_ed25519.pub [email protected]
ssh-add ~/.ssh/ansible_ed25519                             # load a passphrase-protected key

Ad-hoc operations

bash
ansible all -m ansible.builtin.setup                       # dump every fact
ansible all -m ansible.builtin.setup -a 'filter=ansible_distribution*'   # ⭐ filtered
ansible all -m ansible.builtin.shell -a "uptime" -o        # ⭐ quick check across the fleet
ansible all -m ansible.builtin.shell -a "df -h /" -o       # ⭐ who is out of disk?
ansible web -m ansible.builtin.service -a "name=nginx state=restarted" -b
ansible all -m ansible.builtin.package -a "name=htop state=present" -b --check -o   # ⭐ audit
ansible web01 -m ansible.builtin.command -a "id" -b        # confirm become is working

Running playbooks

bash
ansible-playbook site.yml --syntax-check       # ⭐ parse only, touches nothing
ansible-playbook site.yml --list-hosts         # ⭐ blast radius
ansible-playbook site.yml --list-tasks         # ⭐ full ordered scope
ansible-playbook site.yml --check --diff       # ⭐ the closest thing to terraform plan
ansible-playbook site.yml --limit web01        # ⭐ one host first, always
ansible-playbook site.yml --tags deploy
ansible-playbook site.yml --skip-tags slow
ansible-playbook site.yml --start-at-task "Deploy the nginx config"
ansible-playbook site.yml --step               # confirm each task interactively
ansible-playbook site.yml -vvv                 # ⭐ connection-level debugging

Documentation, offline

bash
ansible-doc ansible.builtin.copy               # ⭐ full module docs
ansible-doc -s ansible.builtin.copy            # ⭐ paste-ready task skeleton
ansible-doc -l                                 # every module available
ansible-doc -l | grep -i firewall              # ⭐ find the right module
ansible-doc -t filter -l                       # list Jinja2 filters
ansible-doc -t become -l                       # list become plugins
The pre-flight sequence worth building into muscle memory, before running anything unfamiliar against production:
bash
ansible-playbook site.yml --syntax-check
ansible-playbook site.yml --list-hosts
ansible-playbook site.yml --list-tasks
ansible-playbook site.yml --check --diff --limit one-host

Four commands, none of which change anything, and together they catch most destructive mistakes before they happen.


Next — Module 02 · Variables, Facts & the 22-level precedence ladder.

You have already met facts (ansible_distribution in Exercise C2.1) and magic variables (inventory_hostname in D4.1). Module 02 covers where variables can be defined, and the exact rules that decide which definition wins.

Official reading ahead of it: Using Variables and Discovering variables: facts and magic variables.

📚 Sources for the interview questions

Question selection cross-referenced against publicly published 2026 Ansible interview question sets:

Answers were rewritten and deepened rather than reproduced — published versions are usually correct but shallow, and the added operational detail is what actually differentiates a candidate in the room.

</callout>

Spotted a mistake or want something added? Send me a note.