Series: Ansible Like a Pro
- Part 1: Why Ansible Won Config Management — Config management landscape, why Ansible won, the architecture of ansible-navigator and execution environments
- Part 2: Building a Real Execution Environment — Pinned and private collections, Python dependency conflicts, the
additional_build_stepsescape hatch, a local registry for sharing - Part 3 ← You are here — Inventories, your first real playbook, the navigator TUI versus
stdout, artifacts and replay, and the first-week mistakes - Part 4: Roles, Reuse, and Keeping It Honest — Planned
Two parts of plumbing. That’s what you’ve sat through so far. Part 1 was the why, Part 2 was the container the why runs in. Neither of them configured a single server.
This is the part where we actually use the thing. We’re going to build a inventory that tells the truth, write a playbook worth reading, run it through navigator in both of its modes, and then — because I like you — I’m going to save you the mistakes I made in my first week, so you can go make completely original ones instead.
The inventory is the contract
Before a single task runs, Ansible needs to know what it’s running against. That’s the inventory. Most tutorials hand you a one-liner like hosts: all and a two-line INI file, and then you carry that habit into a job and wonder why nobody can tell which servers are production.
An inventory is not a list of hostnames. It’s a contract between you and the playbook. The playbook says “configure the webservers”; the inventory defines what a webserver is. If those two things drift apart, nothing errors out — you just configure the wrong machines. Silent and fast. The worst combination.
Here’s a inventory file I’d actually put my name on:
# inventory/production.yml
---
all:
children:
webservers:
hosts:
web01.prod.internal:
web02.prod.internal:
dbservers:
hosts:
db01.prod.internal:
monitoring:
hosts:
mon01.prod.internal:
production:
children:
webservers:
dbservers:
monitoring:
Three things worth pointing out.
Groups describe role, not location. web01 isn’t in a group called rack-4 or vmware-host-17. It’s in webservers, because that’s what the playbook cares about. When the hardware moves — and it will — your inventory shouldn’t have to.
The production group exists so you can aim at everything at once. children means “this group contains these other groups.” Run against production and you hit all three child groups. Run against webservers and you hit two machines. Both are one flag away.
Hostnames are real DNS names, not IPs. If web01.prod.internal doesn’t resolve, that’s a problem you want to surface in the inventory, not paper over with a raw address that stops meaning anything the day it’s reassigned.
Then, next to that file, you want group_vars:
inventory/
├── production.yml
└── group_vars/
├── all.yml
├── webservers.yml
└── dbservers.yml
group_vars/webservers.yml holds the variables that are true for every webserver — which nginx version, which ports, what the document root is. all.yml holds the things true everywhere, like the timezone and the NTP servers. This is how you avoid a playbook with forty hardcoded values in it. Variables live with the inventory, because the inventory is where the truth about machines lives, and “which nginx a webserver runs” is truth about machines.
People put this off. Don’t. Retrofitting variables into a playbook that grew organically is miserable, I have done it, it took a week.
A playbook worth reading
Here’s the first playbook. It’s small, but it’s built the way I’d build a real one, and every piece is doing deliberate work.
# site.yml
---
- name: Web tier baseline
hosts: webservers
become: true
tasks:
- name: Install nginx
ansible.builtin.package:
name: nginx
state: present
- name: Deploy nginx config from template
ansible.builtin.template:
src: templates/nginx.conf.j2
dest: /etc/nginx/nginx.conf
owner: root
group: root
mode: "0644"
backup: true
notify: reload nginx
- name: Ensure nginx is running and enabled at boot
ansible.builtin.service:
name: nginx
state: started
enabled: true
handlers:
- name: reload nginx
ansible.builtin.service:
name: nginx
state: reloaded
Walk it with me, because the details are the whole thing.
The play has a name. “Web tier baseline.” When this file grows into five plays and someone opens it at 2am during an incident, the name is the difference between orientation and guesswork. Every play gets a name. Every task gets a name. Not task 1. A sentence that says what happens when it runs. This is documentation you get for free — the TUI, the logs, the artifacts, all of it surfaces those names. An unnamed task is a wasted one.
hosts: webservers points at the contract. This play runs against the group we defined. If the inventory is wrong, this is where it’s wrong, and it’ll be wrong visibly, in the play header.
become: true at the play level. Installing packages and writing to /etc needs root. Setting it once at the top beats sprinkling it on individual tasks, which is how you end up with a play where two tasks run as root and five don’t and nobody remembers why. If you need a task to not escalate, say become: false on that task, explicitly, out loud.
Fully-qualified module names. ansible.builtin.package, not package. Inside an EE with a dozen collections installed, short names resolve by search order, and search order is not something you want to be surprised by at scale. Spell it out. Your future self is not as sharp as you think.
The notify / handler pair is the part people get wrong first. notify: reload nginx doesn’t reload nginx. It arms a reload. If the template task changes something, the handler runs at the end of the play; if the task makes no change — the config on the server already matches — the handler doesn’t run at all. That’s the entire point. Change config, reload once. No change, no reload. A first-week mistake is writing “reload nginx” as a normal task after the template task, which reloads on every run, connection storm and all, forever.
One subtlety: handlers run at the end of the play, not immediately after the task that notified them. Usually fine. Occasionally — say, a config change followed by a health check — you need the reload to happen now. ansible.builtin.meta: flush_handlers forces pending handlers to run at that point. Rare, but the day you need it, you’ll be glad it exists.
Two ways to run it
Alright. Inventory, playbook. Time to let navigator off the leash.
ansible-navigator run site.yml -i inventory/production.yml --mode stdout
--mode stdout behaves like old ansible-playbook: it runs, it prints, it exits. Everything scrolls past in your terminal. This is the mode for scripts, cron jobs, and CI pipelines, because a TUI opening inside a pipeline is a special kind of hang.
Drop the flag and you get the other mode:
ansible-navigator run site.yml -i inventory/production.yml
As I said in Part 2, navigator defaults to interactive mode, and the TUI is where it stops being a fancy wrapper and starts being the actual tool.
There’s also a third mode, called — no, hold on. There isn’t a third mode. I was thinking of the settings editor, which is a different thing entirely, :-commands and whatnot, and honestly I’ve confused myself, let’s just say there are two modes and move on.
In the TUI, the play header shows you host counts and pass/fail at a glance. :0 opens the first play, :1 the second. Arrow keys walk the task list, Enter opens a task and shows you the per-host result with the actual JSON. Press :st and the current view flips to raw stdout — same output the other mode would have printed — so you never actually lose the scroll-back. :doc ansible.builtin.template pulls up module documentation without leaving the run. :quit gets you out.
Which mode you live in is a preference thing but here’s mine: stdout for anything automated, TUI for anything I’m actively debugging or learning from. The TUI’s killer feature is that task-level JSON — when a template task fails on one host out of fifty, you can walk down to that one host and read the exact error inline instead of re-running the whole play with -vvv and drowning.
Artifacts: the feature nobody told you about
Every navigator run writes an artifact — a JSON file with the complete result of every task on every host — into ./artifact/:
artifact/
└── 2026-08-15-10-42-07/
└── site.yml-2026-08-15 10:42:07.123456+00:00.json
Nobody reads these the day everything works. The day something breaks — or worse, the day something half-broke and you only noticed an hour later — that artifact is the flight recorder. What ran, in what order, on which hosts, with what result, what changed. All of it, sitting in a file, no re-run required.
And you can replay one:
ansible-navigator replay artifact/2026-08-15-10-42-07/site.yml-*.json --mode stdout
Replay walks you back through a finished run without touching a single server. I’ve settled more “what exactly did the automation do to that box last night” arguments with a replayed artifact than with any amount of history output. Commit artifact/ to .gitignore, but don’t delete it — disk is cheap, forensics are not.
The first-week mistakes
These are in rough order of how soon they’ll get you.
Running the default mode in a script. Fresh install, you write a deploy script with ansible-navigator run ..., and the script hangs, because a TUI just opened and is patiently waiting for input from a pipe that will never speak. Every automated invocation gets --mode stdout. Non-negotiable.
Pointing at the wrong inventory. Navigator reads its defaults from ansible-navigator.yml, and if ansible.cfg says one thing and your -i flag says another, the flag wins — but only if you remember the flag. My rule: the inventory is always explicit on the command line for anything touching production. The one time you trust a default is the one time the default is the lab inventory from Tuesday.
--check exists, use it before you mean it. ansible-navigator run site.yml -i inventory.yml --mode stdout --check --diff runs the whole playbook without changing anything, and --diff shows you what would change, unified-diff style. Make this reflexive. The playbook that’s going to touch forty production boxes gets a --check pass first, every time, and you read the diff instead of skimming it.
Your SSH agent is not automatic. Navigator forwards the agent socket into the EE, but if you never ran ssh-add, there’s nothing to forward. Symptom: the playbook hangs on authentication or fails with permission denied, even though ssh web01 works fine from your laptop — because your laptop has the key and the container has nothing. ssh-add before you run. I lost forty-five minutes to this the first week and I’ve watched three other people lose the same forty-five minutes since.
Forgetting that facts cost time. Every play gathers facts by default — OS, IPs, mounts, the lot — before the first task runs. Useful, and not free. Against a handful of hosts you’ll never notice. Against a few hundred, gather_facts: false on plays that don’t need host facts (a lot of them don’t) is the difference between a forty-second run and a four-minute one.
Trusting green. A green (ok) task means “no change was needed.” It does not mean “the server is correct.” If your template produces the same wrong config it produced last time, the run is green all day. The output color tells you about change, not correctness — correctness is what your tests and your health checks are for.
What you’ve got now
By the end of this you should have:
- A inventory built on groups that describe role, with variables in
group_varsinstead of hardcoded in plays - A first playbook that names everything, uses fully-qualified modules, and reloads services through handlers instead of brute force
- A working grip on both navigator modes —
stdoutfor automation, the TUI for learning and debugging — plus:doc,:st, and task-level JSON diving - Artifacts turned on by default and the knowledge that
replayexists - The six first-week landmines either stepped on already or mapped in advance
This is the point where the tooling stops being the story. You have an execution environment, a real inventory, and a playbook that runs the way a professional’s runs.
Next in the series: Part 4: Roles, Reuse, and Keeping It Honest — the playbook grows up: splitting it into roles, sharing them without copy-paste, and ansible-lint as the teammate that never gets tired of your inconsistent naming.
This series assumes you’ve been through Parts 1 and 2 — there’s an execution environment built, pushed to a registry, and an ansible-navigator.yml pointing at it. If words like handler or template task are landing cold, the docs will catch you up; if --check --diff is news to you, good — that one’s a career upgrade on its own.