Module 01 — Fundamentals, Connectivity & Your First Playbook
Updated 20 August 2026
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
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:
| Term | Meaning | Why it matters |
|---|---|---|
| Push-based | The control node starts the connection and pushes config out to the targets | Opposite of Puppet/Chef, where agents pull from a master every ~30 min |
| Agentless | No daemon on managed nodes. SSH for Linux, WinRM/PSRP for Windows | No agent to install, patch, monitor, or carry a CVE |
| Declarative-ish | You describe desired state, but tasks still execute top-down in order | Not a dependency graph like Terraform — order matters |
| Orchestration | Coordinates work across hosts in a defined sequence | Rolling restarts, DB before app — a bash loop cannot do this |
A2 · The architecture
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.
🎯 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
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.
# 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-coreansible-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
ansible --version✅ Expected result — click to reveal
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 = TrueThree 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
# 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
exit code: 1
Python 3.11.2which 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?
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:
# The old style - still valid, but ambiguous
- yum: name=nginx state=present
- copy: src=a dest=bIn 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.
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.
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.
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
| Namespace | Who owns it, and what it means for you |
|---|---|
| ansible | The Ansible core team. ansible.builtin ships inside ansible-core; ansible.posix and ansible.netcommon are separate installs |
| community | Community-maintained. community.general is a large grab-bag; community.docker, community.mysql, community.crypto. Good quality, but no vendor support contract |
| amazon / google / azure | Cloud vendor collections — amazon.aws, google.cloud, azure.azcollection |
| cisco / arista / junipernetworks | Network vendor collections for their own devices |
| redhat | Red Hat certified content, typically consumed through Automation Hub rather than Galaxy |
| kubernetes | kubernetes.core — the k8s and helm modules |
The ecosystem analogy that makes it click
| Concept | Ansible | Python | JavaScript |
|---|---|---|---|
| The package | collection | package | package |
| The owner prefix | namespace | — | scope, e.g. @angular |
| The registry | Galaxy | PyPI | npmjs.com |
| The install CLI | ansible-galaxy | pip | npm |
| The dependency file | requirements.yml | requirements.txt | package.json |
| The private registry | Automation Hub | private PyPI | private npm registry |
Reading an FQCN
ansible . builtin . command
| | |
| | +-- module name
| +-- collection
+-- namespaceansible.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 name | FQCN | Ships in |
|---|---|---|
| copy | ansible.builtin.copy | ansible-core |
| service | ansible.builtin.service | ansible-core |
| yum / apt | ansible.builtin.yum / ansible.builtin.apt | ansible-core |
| authorized_key | ansible.posix.authorized_key | a separate collection |
| ec2_instance | amazon.aws.ec2_instance | a separate collection |
| docker_container | community.docker.docker_container | a separate collection |
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?
- 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.
- It is the linted standard. ansible-lint has an fqcn rule that flags short names. Any team with CI will reject them in review.
- 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:
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/Roles vs collections — a distinction people get wrong
Both are published on Galaxy, and they are not the same thing.
| Unit | What it is |
|---|---|
| Role | The 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 |
| Collection | The modern unit. Can contain modules, plugins, roles and playbooks together, is versioned, and can declare dependencies. Installed with ansible-galaxy collection install amazon.aws |
Galaxy vs Automation Hub
| Registry | What it is |
|---|---|
| Ansible Galaxy | The free public registry. Anyone can publish. Community-maintained, no support contract — vet quality yourself |
| Red Hat Automation Hub | Red Hat certified collections, tested and supported under a subscription. Part of Ansible Automation Platform |
| Private Automation Hub | Self-hosted, on-premises. Enterprises use it to mirror approved collections and publish internal ones, so production never pulls straight from the public internet |
The ansible-galaxy commands worth knowing
# 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.gzWhere collections land on disk
Searched in this order, first match wins:
- ./collections/ inside the project directory
- ~/.ansible/collections/ (the default install target)
- /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
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:
# /usr/lib/python3/dist-packages/ansible_collections
Collection Version
----------------- -------
ansible.builtin 2.16.3On a full ansible package install you will instead see dozens of lines:
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:
---
collections:
- name: amazon.aws
version: "7.2.0"
- name: community.docker
version: "3.5.0"ansible-galaxy collection install -r requirements.ymlThat 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
B1 · The inventory — telling Ansible what exists
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
[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=80YAML format — what you should use in production
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: 2222Three 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
| Pattern | Selects |
|---|---|
| all or * | everything in the inventory |
| web:db | union — in web OR db |
| web:&prod | intersection — in web AND prod |
| web:!web01 | exclusion — web except web01 |
| ~web\d+\.example\.com | regex 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:
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:
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
@all:
|--@prod:
| |--@web:
| | |--web01
| | |--web02
| | |--web03
| |--@db:
| | |--db01
|--@staging:
| |--stg01
|--@ungrouped:hosts (3):
web01
web02
web03hosts (2):
web02
web03hosts (3):
web01
web02
web03Two 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.
ansible -i inventory/hosts.yml 'prod:&web:!web02' --list-hosts✅ Expected result — click to reveal
hosts (2):
web01
web03Patterns 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
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.
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.
# 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 -kOr declared in the inventory — never do this in a real repository:
[web]
web01 ansible_host=10.0.1.15 ansible_user=deploy ansible_ssh_pass=NotARealPasswordMethod 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
# 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:
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-nodeStep 2 — note the permissions, they matter:
-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.pubThe 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:
/usr/bin/ssh-copy-id: INFO: attempting to log in with the new key(s)...
[email protected]'s password:
Number of key(s) added: 1Step 4 — no password prompt:
web01What 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
# 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_ed25519ansible -i inventory/hosts.yml web -m ansible.builtin.ping✅ Expected result — click to reveal
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_connection — ssh (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.
- Reproduce with plain SSH first — ssh -vvv user@host. The native client gives a much better error than Ansible does.
- Check the obvious layer: is the host up, is port 22 reachable, is DNS resolving, is a security group or firewall in the way.
- Check identity: right ansible_user, right key, and is the public key actually in the target's authorized_keys.
- 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.
- Check host key verification — a rebuilt server with a new host key fails until known_hosts is updated.
- 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
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.
The three identities
| # | Identity | What it controls |
|---|---|---|
| 1️⃣ | The local user running ansible on the control node | Which ~/.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 / -u | The 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 root | Who the task actually executes as after privilege escalation. Requires the remote user to have sudo rights on that host |
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 deployIdentity 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.
| Path | What lives there |
|---|---|
| ~/.ssh/ | private keys, known_hosts, config |
| ~/.ansible.cfg | user-level configuration |
| ~/.ansible/collections/ | collections installed with ansible-galaxy |
| ~/.ansible/tmp/ | local temp working files |
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:
- The account exists on that managed node
- Your public key is in that account's ~/.ssh/authorized_keys — not root's, not another user's
- 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:
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 defaultDifferent accounts on different machines is completely normal, and the inventory handles it per host or per group:
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: 2222Identity 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.
# On the managed node, as root, this must exist for passwordless automation:
cat /etc/sudoers.d/deploy
# deploy ALL=(ALL) NOPASSWD:ALLWithout 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
| Error | Which identity | Actual 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 username | 2️⃣ | The account does not exist on that host — you probably fell through to the local-username default |
| Missing sudo password | 3️⃣ | Login worked; the remote user has no NOPASSWD sudoers rule. Supply -K or fix sudoers |
| sudo: a password is required | 3️⃣ | Same as above, surfaced by the target's sudo instead |
| UNPROTECTED PRIVATE KEY FILE | 1️⃣ | The private key on the control node is not 0600 |
| Works manually, fails in CI | 1️⃣ | 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
# 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
zaeem
HOME=/home/zaeemweb01 | CHANGED | rc=0 >>
deployweb01 | CHANGED | rc=0 >>
rootIdentityFile="/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
ansible web01 -m ansible.builtin.ping # works
sudo ansible web01 -m ansible.builtin.ping # watch it fail✅ Expected result — and why — click to reveal
web01 | SUCCESS => {"changed": false, "ping": "pong"}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:
sudo ansible web01 -m ansible.builtin.ping -vvv 2>&1 | grep -o 'IdentityFile=[^]*'
# IdentityFile="/root/.ssh/id_rsa" <- not your key at allThe 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
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
# ~/.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 10mAnsible 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
# 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 %sansible-playbook -i inventory/hosts.yml bootstrap-keys.yml -u root -kLarge 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:
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
# 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
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:
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
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.
# 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)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| Flag | Meaning |
|---|---|
| -u / --user | SSH login user — who you are |
| -b / --become | escalate privileges after login |
| -k / --ask-pass | prompt for the SSH password |
| -K / --ask-become-pass | prompt for the sudo password |
🧪 Exercise B4.1 — See the difference between login and escalation
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
web01 | CHANGED | rc=0 >>
uid=1001(deploy) gid=1001(deploy) groups=1001(deploy)web01 | CHANGED | rc=0 >>
uid=0(root) gid=0(root) groups=0(root)web01 | FAILED | rc=1 >>
cat: /etc/shadow: Permission deniedweb01 | 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
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.
ansible -i inventory/hosts.yml all -m ansible.builtin.pingSo 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
ansible -i inventory/hosts.yml all -m ansible.builtin.ping -o✅ Expected result — and how to read each failure — click to reveal
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:
- raw: apt-get update && apt-get install -y python3Then 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
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"]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
ansible web01 -m ansible.builtin.ping -vvv 2>&1 | grep -iE 'ESTABLISH|SSH:|EXEC'✅ Expected result — click to reveal
<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:
- ESTABLISH SSH CONNECTION → step 4
- 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.
- mkdir -p ... umask 77 → step 5, creating the temp directory with restrictive permissions
- /usr/bin/python3 .../AnsiballZ_ping.py → step 6, the target's Python running the pushed module
- 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
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
/home/deploy/.ansible/tmp/ansible-tmp-1755166234.12-8891-273:
AnsiballZ_ping.py#!/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-encodedThere 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
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"]A sane production baseline
[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🧪 Exercise C1.1 — Delete the -i flag from your life
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
web01 | SUCCESS => {
"changed": false,
"ping": "pong"
}ansible [core 2.16.3]
config file = /home/zaeem/ansible-lab/ansible.cfgNo -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
# 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
CONFIG_FILE() = /home/zaeem/ansible-lab/ansible.cfg
DEFAULT_STDOUT_CALLBACK(/home/zaeem/ansible-lab/ansible.cfg) = yamlforks 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:
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
ANSIBLE_FORKS=50 ansible-config dump --only-changed | grep -i forks✅ Expected result — click to reveal
DEFAULT_FORKS(env: ANSIBLE_FORKS) = 50Note 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
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.
ansible <pattern> -m <module> -a "<arguments>"| Flag | Meaning |
|---|---|
| -m | module to run — defaults to command if omitted |
| -a | module arguments |
| -b | become — privilege escalation |
| -o | condensed one-line-per-host output |
| --limit | narrow the host pattern further |
| --check | dry run — report what would change, change nothing |
| -f | forks, for this run only |
🧪 Exercise C2.1 — The four ad-hoc commands you will genuinely use at work
# 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
web01 | SUCCESS => {"changed": false, "ping": "pong"}
web02 | SUCCESS => {"changed": false, "ping": "pong"}web01 | SUCCESS => {"ansible_facts": {"ansible_distribution": "Ubuntu", "ansible_distribution_major_version": "22", "ansible_distribution_version": "22.04"}, "changed": false}web01 | CHANGED | rc=0 | (stdout) /dev/root 39G 8.1G 31G 21% /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
ansible all -m ansible.builtin.package -a "name=htop state=present" -b --check -o✅ Expected result — click to reveal
web01 | CHANGED => {"changed": true, ...} <-- htop is MISSING here, would be installed
web02 | SUCCESS => {"changed": false, ...} <-- htop already present, nothing to doNothing 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 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.
-m package -a "name=nginx state=present"Run 1: changed
Run 2..N: ok
The module checks first, acts only if needed.
-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 breaks | Consequence |
|---|---|
| Dry runs | --check cannot predict what a shell command would do, so it skips it — your dry run looks clean and tells you nothing |
| Handlers | A task that always reports changed fires its handler on every run — so nginx restarts every single time, forever, for no reason |
| Drift detection | In CI, a clean run should report changed=0. Non-idempotent tasks make that signal permanently useless |
| Auditing | When 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
# 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🧪 Exercise C3.1 — Run the same command twice and watch one of them lie
# 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:
web01 | CHANGED => {"changed": true, "checksum": "f572d396...", "dest": "/tmp/good.txt", "size": 6}copy, second run — nothing to do:
web01 | SUCCESS => {"changed": false, "checksum": "f572d396...", "dest": "/tmp/good.txt", "size": 6}shell, first run:
web01 | CHANGED | rc=0 >>shell, second run — identical file, still claims it changed:
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.
# 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:
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:
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
D1 · The YAML you need first
YAML is like writing a shopping list with indentation:
Fruit:
- apples
- bananasThe 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.
| Rule | Detail |
|---|---|
| Spaces only | A tab character is a hard parse error. Configure your editor to expand tabs |
| Indentation is structure | 2 spaces per level by convention. Alignment defines nesting — there are no braces |
| key: value | The space after the colon is mandatory |
| • item is a list | Dash, space, then the item |
| --- | Optional document start marker at the top of the file |
| # | Comment to end of line |
# 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: startedThe four quoting traps that will actually bite you
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
# 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"ansible-playbook broken.yml --syntax-check✅ Expected result — and how to read a YAML error — click to reveal
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
^ hereLearn 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 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"| HThe 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
--- # 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: restartedLine-by-line, the parts that are not obvious
🧪 Exercise D2.1 — Inspect a playbook without running it
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
playbook: site.ymlplaybook: site.yml
play #1 (web): Configure web tier TAGS: []
pattern: ['web']
hosts (2):
web01
web02playbook: 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
ansible-playbook site.yml
ansible-playbook site.yml # the second run is the lesson✅ Expected result — click to reveal
First run — nothing exists yet:
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=0Second run — the whole point of Ansible:
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=0Three things to notice:
- TASK [Gathering Facts] appeared without you writing it — that is gather_facts: true running the setup module.
- 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.
- 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
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 |
|---|---|
| 1 | A handler runs only if the notifying task reported changed |
| 2 | Handlers run at the end of the play, not at the point of notification |
| 3 | A handler runs once, however many tasks notified it — notifications are deduplicated |
| 4 | Handlers run in definition order, not notification order. This surprises people |
| 5 | If any task in the play fails, remaining handlers are skipped — unless you pass --force-handlers |
🧪 Exercise D3.1 — Prove rule 3 and rule 4
---
- 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
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
---
- 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
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:
- 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: falseTASK [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
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 BTask-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.
| Setting | Effect |
|---|---|
| forks: 25 | How many hosts are worked on in parallel. Default 5 — far too low for real fleets |
| strategy: linear | The default. Barrier between every task |
| strategy: free | No barrier. Each host races through the whole play independently |
| serial: 2 | Process hosts in batches of 2 through the entire play — this is how rolling deploys are built |
| run_once: true | Run this one task on a single host only, e.g. a database migration |
🧪 Exercise D4.1 — Watch the barrier, then remove it
---
- 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):
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:
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 5sThe 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
--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.
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
# Change a value in files/nginx.conf, then:
ansible-playbook site.yml --check --diff --limit web01✅ Expected result — click to reveal
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=1Nothing 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
---
- 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.txtansible-playbook checkmode.yml --check✅ Expected result — click to reveal
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=1Read 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"| ENGFour files on one machine, and nothing installed anywhere else. That is the whole of Module 01.
E2 · Production practice
| Habit | Why |
|---|---|
| Log in as an unprivileged service account, escalate with become | Direct 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 boot | Password auth cannot be automated cleanly and cannot be rotated safely |
| Pin ansible-core and collection versions in requirements.yml | Playbooks break silently when a collection upgrades underneath them |
| Run ansible-lint and yamllint in CI | Catches unnamed tasks, missing FQCN, shell where a module exists, unquoted file modes |
| forks 25–50 plus pipelining: true | Default forks=5 turns a 200-host run into 40 sequential rounds |
| Never host_key_checking = False on long-lived prod hosts | You are switching off MITM protection — pre-seed known_hosts instead |
| Dynamic inventory backed by the cloud API | Static files are wrong within minutes of any autoscaling event |
| --syntax-check, --list-tasks, then --check --diff on one host | The cheapest possible way to catch a destructive mistake |
| validate: on every config file that can lock you out | sshd, sudoers and nginx configs can end your afternoon |
| Playbooks in Git, executed from CI or AWX, never from a laptop | Reproducibility, and a record of who ran what and when |
E3 · Capstone exercise
Brief. Write a single playbook that, for the web group:
- Installs nginx using a portable module — it must work on both Ubuntu and RHEL
- Deploys a config file that is validated before it goes live
- Restarts nginx only if the config actually changed
- Confirms nginx is listening on port 80 — and this check must report ok, never changed
- Runs cleanly twice in a row, with changed=0 on the second run
- Survives --check --diff without errors
✅ Model answer — attempt it first, then click
---
- 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: restartedThe three things most people miss:
- 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.
- changed_when: false on the verification — a check must never pollute the changed count, or requirement 5 fails on the second run.
- package, not yum — requirement 1 explicitly said portable. Reaching for yum is the most common wrong answer.
Verify all six requirements:
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 6E4 · Official documentation — what to bookmark and how to read it
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
| Link | What it is for |
|---|---|
| Documentation home | Root 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 collections | Every module, alphabetically. The page you will open most often |
| ansible.builtin collection | The modules shipped with core: copy, template, service, package, user, file, lineinfile, command, shell, stat, uri, git |
| Collections index | Everything outside core — amazon.aws, community.docker, kubernetes.core, ansible.posix, community.general |
| Playbook keywords | Every key you can legally use at play, block and task level. Answers "can I put become there?" definitively |
| Configuration settings | Every ansible.cfg option, its default, and its matching ANSIBLE_* environment variable |
| Special (magic) variables | inventory_hostname, groups, hostvars, ansible_play_hosts and the rest |
| CLI tool reference | Every flag for ansible, ansible-playbook, ansible-inventory, ansible-config, ansible-vault, ansible-galaxy, ansible-doc |
| Ansible Galaxy | Community roles and collections, and where ansible-galaxy install pulls from |
| ansible-lint · Molecule | Linting and role testing — the tooling that turns playbooks into reviewed code. Modules 11 |
| Tips and tricks / best practices | Official guidance on repo layout, naming and directory structure |
| Ansible Community Forum | Where 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:
- Synopsis — one paragraph on what it does. Confirms you have the right module.
- 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.
- Parameters — the table you will spend most time in. Watch three things: whether a parameter is required, its default, and its choices.
- Examples — copy-paste-ready YAML. Usually the fastest route to a working task.
- 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.
The offline alternative: ansible-doc
The same documentation ships with Ansible, so you never strictly need a browser:
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.
ansible-doc -l | grep -iE 'line|file' | head -20
ansible-doc -s ansible.builtin.lineinfile✅ Expected result — click to reveal
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- 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
Setup and version
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 wayInventory
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 destructiveConnectivity and troubleshooting
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 keyAd-hoc operations
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 workingRunning playbooks
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 debuggingDocumentation, offline
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 pluginsansible-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-hostFour commands, none of which change anything, and together they catch most destructive mistakes before they happen.
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:
- Spacelift — 50+ Top Ansible Interview Questions & Answers for 2026
- GeeksforGeeks — Top 50+ Ansible Interview Questions and Answers
- Vinsys — Top 30 Ansible Interview Questions and Answers 2026
- K21 Academy — Ansible Interview Questions & Answers 2026
- Hirist — Top 25+ Ansible Interview Questions and Answers 2026
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>