Before you read. A certificate needs replacing on fifty web servers. You have SSH access to all of them and a shell script that does it on one.
A
forloop over fifty hostnames will work, mostly. Three machines will be down. Two will have a slightly different path. One will half-succeed, and you will not know which one.What you actually want is to run it, see a report of what changed where, and run it again tomorrow without doing any of it twice.
That is Ansible, and the loop above is a fair description of what it replaces. The difference is not that it connects over SSH, your loop does that too. The difference is that each step describes a desired state rather than a command, so running it again on the machines that already succeeded does nothing, and the report tells you which of the fifty actually changed.
Some words you will need
- inventory
- The list of machines, optionally grouped. A file, or a script that generates one.
- module
- A unit of work that knows how to reach a desired state.
package,service,copy. - task
- One call to a module, with a name.
- play
- A set of tasks aimed at a set of hosts.
- playbook
- A YAML file containing one or more plays.
- fact
- Something Ansible discovered about a host, available as a variable.
- handler
- A task that runs only if something notified it, and only once.
- idempotent
- Reports
changed=0on the second run. The property that makes all of this safe.
What breaks without this
The for loop over hostnames fails partway and leaves you with no record of
which machines it reached.
You cannot safely re-run anything, so a failed run means working out by hand what happened and what did not.
There is no way to ask what would change. Every change is applied blind, on production, with rollback consisting of remembering what it looked like before.
And nothing is reviewable. A change to fifty servers should be a diff somebody approved, not a command somebody ran.
Agentless, and why that is the whole pitch
sshd they ran before Ansible existed, so there is no agent to roll out, nothing new listening, and nothing extra to keep patched across a fleet. Adding a machine costs one line in the inventory.Puppet, Chef, and Salt all install software on every managed machine that runs continuously and talks to a server. Ansible does not.
Ansible connects over SSH, copies a small Python program, runs it, collects the output as JSON, and deletes it. That is the entire transport.
What a managed machine needs:
| Requirement | Why |
|---|---|
| SSH access | The transport |
| Python 3 | The modules are Python |
| An account that can escalate | For anything privileged |
Nothing else. No agent, no daemon, no port, no certificate. Which means a machine you were given SSH access to five minutes ago is manageable now, and decommissioning a machine requires removing a line from a file rather than uninstalling anything.
The trade-offs are real and worth knowing, because “agentless is better” is not a complete thought:
It is push, not pull. Nothing happens unless somebody runs Ansible. A machine
that was off during the run stays unconfigured until the next one, where a
Puppet agent would catch up on its own. Continuous convergence is possible
with ansible-pull and is not the default mode.
It scales by forking. The control node opens a connection per host, in batches of five by default. Puppet’s agents each work independently, so its server load grows much more slowly.
Latency is per task, per host. A playbook with forty tasks against a host on
another continent pays the round trip forty times, which is why pipelining
and ControlPersist exist.
Check the connection first, which is what the ping module is for, despite
the name, it is not ICMP:
# Debian 13 (trixie), x86_64
$ cd /root/play; ansible --version | head -3; echo "--- an ad hoc command ---"; ansible all -i inventory -m ansible.builtin.ping
ansible [core 2.19.4]
config file = None
configured module search path = ['/root/.ansible/plugins/modules', '/usr/share/ansible/plugins/modules']
--- an ad hoc command ---
[WARNING]: Host 'localhost' is using the discovered Python interpreter at '/usr/bin/python3.13', but future installation of another Python interpreter could cause a different interpreter to be discovered. See https://docs.ansible.com/ansible-core/2.19/reference_appendices/interpreter_discovery.html for more information.
localhost | SUCCESS => {
"ansible_facts": {
"discovered_interpreter_python": "/usr/bin/python3.13"
},
"changed": false,
"ping": "pong"
}
"changed": false even here. Every module reports whether it changed
anything, and ping never does, it connects, confirms Python works, and
returns. That is why it is the standard first test: a SUCCESS means SSH, the
account, and the interpreter are all working.
The warning about interpreter discovery is worth reading rather than ignoring.
Ansible had to guess which Python to use, and it is telling you that the guess could
change. On managed machines, ansible_python_interpreter in the inventory removes
the guesswork.
Inventory and ad hoc commands
The inventory is the list of machines. At its simplest it is one host per line:
[web]
web01.example.com
web02.example.com
[db]
db01.example.com ansible_user=postgres
[production:children]
web
db
Groups are the useful part. web names two machines; production contains both
groups; all is built in. A play targets any of those names, so the same playbook
serves one machine or a hundred.
Ad hoc commands are for things not worth a playbook:
ansible all -i inventory -m ansible.builtin.ping
ansible web -i inventory -m ansible.builtin.service -a "name=nginx state=restarted" --become
ansible all -i inventory -a "uptime"
ansible all -i inventory -m ansible.builtin.package -a "name=curl state=present" --become
-a passes arguments to the module, and with no -m the default module runs a
command. --become escalates to root, which is sudo underneath and honours the
rules from lesson 42.
Ad hoc is for questions, playbooks are for changes. “What kernel is everything running” is a fine ad hoc command; “install and configure nginx” belongs in a file somebody can review.
A playbook
- name: Configure the web tier
hosts: all
gather_facts: true
tasks:
- name: A directory for the site exists
ansible.builtin.file:
path: /srv/site
state: directory
mode: "0755"
- name: The index page is in place
ansible.builtin.copy:
dest: /srv/site/index.html
content: "running on {{ ansible_distribution }} {{ ansible_distribution_version }}\n"
mode: "0644"
- name: A deploy user exists
ansible.builtin.user:
name: deploy
shell: /usr/sbin/nologin
state: present
Read the task names. They are written as statements of fact, “a deploy user exists”, rather than as commands, and that is the declarative habit made visible. A task called “create the deploy user” is a small lie the second time it runs.
state: present and state: directory are what the module aims at. Neither
says how. The user module works out whether that means running useradd, doing
nothing, or modifying an existing account.
{{ ansible_distribution }} is a fact, filled in per host, which is how one
playbook produces the right thing on a mixed estate.
The number that tells you it is working
The playbook has three tasks and runs against a machine where none of them has been done. Then it runs a second time, unchanged. What does changed= say on each run?
# Debian 13 (trixie), x86_64
$ cd /root/play; ansible-playbook -i inventory site.yml 2>/dev/null | tail -14
ok: [localhost]
TASK [A directory for the site exists] *****************************************
changed: [localhost]
TASK [The index page is in place] **********************************************
changed: [localhost]
TASK [A deploy user exists] ****************************************************
changed: [localhost]
PLAY RECAP *********************************************************************
localhost : ok=4 changed=3 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
# Debian 13 (trixie), x86_64
$ cd /root/play; ansible-playbook -i inventory site.yml >/dev/null 2>&1; echo "=== SECOND RUN ==="; ansible-playbook -i inventory site.yml 2>/dev/null | tail -8
=== SECOND RUN ===
ok: [localhost]
TASK [A deploy user exists] ****************************************************
ok: [localhost]
PLAY RECAP *********************************************************************
localhost : ok=4 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
changed=3 then changed=0. That is idempotence, demonstrated rather than
claimed, and it is the single most important output Ansible produces.
Read the two words carefully, because they mean different things:
| Word | Means |
|---|---|
changed |
The module did something. The machine is different now. |
ok |
The module checked and the state was already correct |
failed |
It could not reach the desired state |
skipped |
A when: condition was false |
unreachable |
Could not connect at all. Not the task’s fault. |
ok=4 on both runs and changed dropping to zero is the shape you want. The
fourth ok is the fact-gathering step, which always runs and never changes
anything.
A task that reports changed on every run is a bug, and it is the most
common defect in real playbooks. It usually means a command or shell
module was used where a proper module exists. Those cannot know whether they
need to run, so they always report changed. The fixes are a real module, or
creates: and removes: to tell command how to check.
Why it matters beyond tidiness: changed is what handlers trigger on. A task
that always reports changed restarts nginx on every run, forever, and nobody notices
until somebody asks why the graphs show a restart every thirty minutes.
Finding drift without changing anything
Somebody edits a file by hand. The next run will fix it, but you want to know first.
--check runs without making changes and --diff shows the content difference. A file managed by the playbook has been edited by hand. What does Ansible print?
# Debian 13 (trixie), x86_64
$ cd /root/play; ansible-playbook -i inventory site.yml >/dev/null 2>&1; echo "somebody edited it by hand" > /srv/site/index.html; echo "--- check mode: what has drifted ---"; ansible-playbook -i inventory site.yml --check --diff 2>/dev/null | sed -n "/index page/,/^$/p" | head -12
--- check mode: what has drifted ---
TASK [The index page is in place] **********************************************
--- before: /srv/site/index.html
+++ after: /srv/site/index.html
@@ -1 +1 @@
-somebody edited it by hand
+running on Debian 13.6
A unified diff, exactly like git diff, showing what is there and what
would replace it. Nothing was changed: --check guarantees that.
--check --diff together are the most useful thing in this lesson after the
changed count. They answer “how far has this estate drifted” without touching
anything, which makes them safe to run against production at any time,
including on a schedule as a monitoring check.
The caveat that matters: check mode is a simulation, and some tasks cannot
be simulated. A command module cannot know what its command would do, so by
default it is skipped in check mode. And a task whose outcome depends on an
earlier task’s change will report oddly, because the earlier change did not
actually happen. Check mode is excellent for configuration files and
misleading for long chains of dependent commands.
If you already administer Linux: handlers, and the restart that happens once instead of nine times
Nine tasks change nginx configuration files. The service needs restarting. Putting a restart in each task restarts it nine times; putting one at the end restarts it even when nothing changed.
A handler is a task that runs only when notified, and only once per play, regardless of how many tasks notified it:
tasks:
- name: The main config is in place
ansible.builtin.template:
src: nginx.conf.j2
dest: /etc/nginx/nginx.conf
notify: Reload nginx
- name: The site config is in place
ansible.builtin.template:
src: site.conf.j2
dest: /etc/nginx/conf.d/site.conf
notify: Reload nginx
handlers:
- name: Reload nginx
ansible.builtin.service:
name: nginx
state: reloaded
notify fires only when the task reports changed. So a run where both files
are already correct reloads nothing, and a run where either changed reloads
exactly once. That is the behaviour you would have to write by hand in a shell
script, and it is why the changed count is load-bearing rather than cosmetic.
Four things about handlers that are not obvious:
They run at the end of the play, not immediately. So a later task cannot
depend on the handler having run. meta: flush_handlers forces them to run at
that point, which is occasionally necessary, for example when a service must
be restarted before a subsequent task can talk to it.
They do not run if the play fails first. A failure partway through means
configuration files are changed and the service was never reloaded, which is a
genuinely awkward state. --force-handlers runs them anyway.
Names are the identifier, so a typo in notify silently notifies nothing.
Ansible warns about this in recent versions and did not always.
reloaded versus restarted is the same distinction as lesson 33: reload
re-reads configuration without dropping connections, restart does not. Use
reload where the service supports it, and note that Ansible’s service
module will not tell you if it does not. It will just restart.
The related mechanism worth knowing is serial, which controls how many hosts
a play runs against at once:
- hosts: web
serial: 2
max_fail_percentage: 25
That is a rolling deployment in two lines: two machines at a time, aborting if more than a quarter fail. Without it, a broken playbook reconfigures all fifty web servers simultaneously.
If you already administer Linux: roles, and the point at which a playbook stops being a file
A playbook works until it is four hundred lines and three teams need parts of it. A role is the unit of reuse, and it is a directory layout rather than a syntax.
roles/nginx/
├── tasks/main.yml # what to do
├── handlers/main.yml # what to notify
├── templates/nginx.conf.j2 # files with variables in them
├── files/ # files copied verbatim
├── vars/main.yml # variables that should not be overridden
├── defaults/main.yml # variables that should be
└── meta/main.yml # dependencies on other roles
Using it collapses the playbook to almost nothing:
- hosts: web
roles:
- nginx
- { role: monitoring, monitoring_port: 9100 }
defaults/ against vars/ is the distinction people get wrong, and it is
about precedence rather than about meaning. defaults/main.yml is the lowest
priority of anything in Ansible. It is a suggestion, overridable by inventory,
by the play, by the command line. vars/main.yml is very high priority and
hard to override. So anything a user of your role might reasonably want to
change belongs in defaults, and putting it in vars is how you write a role
nobody can adapt.
ansible-galaxy is the distribution mechanism, and both a blessing and a risk:
ansible-galaxy role install geerlingguy.nginx
ansible-galaxy collection install community.general
A community role saves a day and is code you did not review running as root on every server. Pin the version, read the tasks, and treat it exactly as you would treat adding a third-party repository from lesson 31, because the trust decision is the same one.
Collections are the modern packaging, and roles now usually live inside them.
community.general.timezone is a module inside the community.general collection;
ansible.builtin is the set that ships with the engine. Writing the fully qualified
name rather than the short one is worth the extra characters, because it makes it
obvious which of the thousands of modules you actually got.
The organising principle for a real estate is one role per service, plus
group_vars/ for the per-environment differences. Production and staging then run
identical roles with different variables, which is the only arrangement where
staging genuinely tests anything.
Facts
Before running tasks, Ansible interrogates each host and makes what it finds available as variables.
# Debian 13 (trixie), x86_64
$ cd /root/play; ansible all -i inventory -m ansible.builtin.setup -a "filter=ansible_distribution*" 2>/dev/null | head -12
localhost | SUCCESS => {
"ansible_facts": {
"ansible_distribution": "Debian",
"ansible_distribution_file_parsed": true,
"ansible_distribution_file_path": "/etc/os-release",
"ansible_distribution_file_variety": "Debian",
"ansible_distribution_major_version": "13",
"ansible_distribution_minor_version": "6",
"ansible_distribution_release": "trixie",
"ansible_distribution_version": "13.6",
"discovered_interpreter_python": "/usr/bin/python3.13"
},
That is a filtered fraction of what is collected. The full set runs to hundreds of values covering interfaces, addresses, mounts, memory, CPU, virtualisation, and the service manager.
Facts are how one playbook handles a mixed estate, which is the cross-family problem this whole track keeps meeting:
- name: Install the web server
ansible.builtin.package:
name: "{{ 'httpd' if ansible_os_family == 'RedHat' else 'apache2' }}"
state: present
ansible_os_family is the fact to reach for, because it groups Debian with
Ubuntu and RHEL with AlmaLinux and Rocky, which is nearly always the
distinction that matters, rather than the specific distribution.
Fact gathering costs a round trip per host, so gather_facts: false is a real
optimisation on a large estate when the play does not need them.
If you already administer Linux: templates, and why they beat editing files in place
The copy module puts a fixed file somewhere. The template module renders a
Jinja2 file first, and that difference is what lets one description serve an entire
estate.
# {{ ansible_managed }}
worker_processes {{ ansible_processor_vcpus }};
events {
worker_connections {{ nginx_worker_connections | default(1024) }};
}
http {
{% for site in nginx_sites %}
server {
listen {{ site.port }};
server_name {{ site.name }};
}
{% endfor %}
}
worker_processes is set from a fact, so every machine gets a value matching its
own CPU count without anybody maintaining a list.
| default(1024) is a filter, and the pattern to use for anything a role
exposes. It works with no configuration and can be overridden.
{{ ansible_managed }} expands to a warning comment naming the source file
and the template. It costs one line and it is the thing that stops somebody
editing the file by hand at 3am without realising it will be overwritten.
Why this beats lineinfile and sed-style editing, which is the real point:
A template does not care what the file contained before. Editing in place has to find something to edit, so it breaks when the file is a slightly different version, when a previous run already changed it, or when two tasks edit the same line. A template writes the whole file, so the result depends only on the variables, which is what makes it idempotent for free.
The diff is meaningful. --check --diff on a templated file shows exactly
what would change, because Ansible renders it and compares. On a lineinfile
task the diff is a single line with no context.
Three things worth knowing about the syntax:
{{ }} substitutes a value, {% %} is control flow, and {# #} is a comment that
does not appear in the output.
Whitespace control, {%- for -%}, strips the newline the tag would
otherwise leave. Without it, loops produce configuration files full of blank
lines, which is cosmetic until a format cares.
validate: runs a syntax check before installing the file, and it is the single
most valuable option on the module:
- name: The nginx config is in place
ansible.builtin.template:
src: nginx.conf.j2
dest: /etc/nginx/nginx.conf
validate: nginx -t -c %s
notify: Reload nginx
%s is the temporary path. If nginx -t fails, the real file is never
replaced, so a template with a typo produces a failed task rather than a web
server that will not start. visudo -cf %s and sshd -t -f %s do the same
job for the two files where getting it wrong locks you out.
Across distributions
Ansible runs from one machine and against many, so there are two questions.
| RHEL family | Debian family | |
|---|---|---|
| Control node package | ansible-core, or ansible for the full collection set |
The same |
| Managed node needs | Python 3, SSH | The same |
| Web server package | httpd |
apache2 |
ansible_os_family |
RedHat |
Debian |
| Escalation | sudo, wheel group |
sudo, sudo group |
ansible-core against ansible is worth understanding. ansible-core is the
engine plus the ansible.builtin modules; the ansible package adds several
thousand community modules bundled as collections. Start with ansible-core and add
collections deliberately with ansible-galaxy collection install, because bundling
everything makes it very hard to know what version of anything you are running.
Prove it
# Can I reach everything at all
ansible all -i inventory -m ansible.builtin.ping
# What would change, without changing it
ansible-playbook -i inventory site.yml --check --diff
# Is the playbook syntactically valid
ansible-playbook -i inventory site.yml --syntax-check
# Is it idiomatic and idempotent-looking
ansible-lint site.yml
# The real test: run it twice
ansible-playbook -i inventory site.yml
ansible-playbook -i inventory site.yml # changed must be 0
# What does Ansible think this host is
ansible host -i inventory -m ansible.builtin.setup | less
The two-run test is the one that matters and it is the one people skip. A
playbook that reports changed on the second run is not doing what its author
thinks, and no amount of linting will tell you.
What trips people up
1. changed on every run
Almost always a command or shell module where a real module exists. Those cannot
know whether they need to run.
Use the proper module, or give command a creates: or removes: so it can check.
The cost is not cosmetic: handlers fire on changed, so the service restarts every
run forever.
2. Forgetting --become
Ansible connects as an ordinary user. Anything privileged needs --become on the
command line or become: true in the play.
3. Expecting it to run on its own
Ansible is push-based. Nothing happens unless somebody runs it, so a machine that was off during the run stays unconfigured.
4. A typo in notify
Handlers are matched by name. A misspelled notify silently notifies nothing, and
the service is never reloaded.
5. Trusting check mode on command tasks
Check mode is a simulation. command cannot be simulated, so it is skipped, and
anything depending on its result reports misleadingly.
6. Secrets in the playbook
ansible-vault encrypt for values in the repository, or a secrets manager. A
password in a task is a password in Git, with everything lesson 55 said about that.
Work it through
A playbook that configures fifty web servers has started reporting
changed=2 on every single run, on every host, forever. Nothing is broken. Nginx
restarts every thirty minutes because the pipeline runs the playbook on a schedule
and a handler is notified each time.
Reason it out before reading on.
Find which two tasks, which the output already tells you, the tasks
reporting changed are named in the run. Say they are:
- name: Set the timezone
ansible.builtin.command: timedatectl set-timezone Europe/London
notify: Reload nginx
- name: Generate the config
ansible.builtin.shell: cat /etc/app/*.fragment > /etc/nginx/conf.d/app.conf
notify: Reload nginx
Both are command or shell, and that is the diagnosis. Neither module can
know whether the work is already done, so both report changed
unconditionally.
The first has a proper module:
- name: The timezone is Europe/London
community.general.timezone:
name: Europe/London
Which checks the current timezone and does nothing if it matches.
The second is more interesting, because concatenating fragments has no module. Two honest options:
- name: The app config is assembled
ansible.builtin.assemble:
src: /etc/app/fragments
dest: /etc/nginx/conf.d/app.conf
assemble exists for precisely this and compares the result before writing. Or, if
the shell really is necessary, make it checkable:
- name: The app config is assembled
ansible.builtin.shell: cat /etc/app/*.fragment > /tmp/app.conf.new
changed_when: false
- name: The app config is in place
ansible.builtin.copy:
src: /tmp/app.conf.new
remote_src: true
dest: /etc/nginx/conf.d/app.conf
notify: Reload nginx
Splitting “produce it” from “install it” lets the shell run every time reporting
no change, and lets copy decide whether anything is actually different. Only the
second notifies.
changed_when: false is the blunt instrument and worth naming as such. It
tells Ansible a task never changes anything, which is right for a genuinely
read-only command and a lie for anything else, and a lie that will hide a real
change later.
The point worth extracting: the changed count is not reporting, it is the
mechanism. Handlers, --check, and the whole idea of re-running safely all
rest on modules reporting honestly. A task that always says changed breaks
all three at once, and the visible symptom, a service restarting on a timer,
is several steps away from the cause.
Try it
Optional. Everything here works against localhost with no remote machines.
pip install --user ansible-core, or install it from your package manager.- Create an inventory containing
localhost ansible_connection=local. ansible all -i inventory -m ansible.builtin.ping.- Write the three-task playbook from this lesson and run it. Note
changed=3. - Run it again. Note
changed=0. - Edit one of the managed files by hand, then run with
--check --diff. - Confirm the file was not repaired, then run without
--check. - Add a task using
ansible.builtin.command: dateand run twice. Watch it reportchangedboth times. ansible all -i inventory -m ansible.builtin.setup | lessand look for three facts you could use.
Verification step. You have it when a second run of your playbook reports
changed=0 and you can explain every task that does not.
Check yourself
What does "agentless" mean in practice, what does a managed machine actually need, and what is the trade-off?
Ansible installs nothing on the machines it manages. It connects over SSH, copies a small Python program, runs it, collects JSON, and deletes it.
A managed host needs three things: SSH access, Python 3, and an account that can escalate for privileged tasks. No agent, no daemon, no listening port, no certificate.
Which means onboarding is immediate, a machine you were given access to five minutes ago is manageable now, and offboarding is deleting a line from a file rather than uninstalling software.
Three trade-offs, and they are real:
Push, not pull. Nothing happens unless somebody runs it. A machine that was powered off during the run stays unconfigured, where a Puppet agent would catch up on its own next cycle.
Scaling is by forking connections from the control node, five at a time by default. Puppet’s agents work independently, so its server load grows far more slowly with fleet size.
Latency multiplies. Each task is a round trip per host, so forty tasks against a
distant machine pay the round trip forty times. pipelining and SSH
ControlPersist exist to reduce it.
ansible-pull inverts the model, each machine fetches the repository and
applies it locally, which recovers continuous convergence at the cost of the
simplicity that made agentless attractive.
A playbook reports changed on every run. Why is that a bug rather than cosmetic, and what usually causes it?
Because changed is a mechanism, not a report. Handlers fire on it, so a
task that always claims to have changed something restarts the service on
every run, forever, and on a scheduled pipeline that means a restart every
thirty minutes that nobody connects to the playbook.
It also destroys the signal. changed=0 on a second run is how you know the
automation is safe to re-run; a playbook that never reaches zero cannot tell you
whether anything real happened.
The usual cause is command or shell where a proper module exists.
Those modules run something and have no way to know whether it needed running,
so they report changed unconditionally. ansible.builtin.command: useradd deploy will say changed every time, and fail every time after the first.
Three fixes, best first:
Use the real module. user:, package:, service:, timezone:, assemble:
all check current state before acting.
Give command a guard. creates: /path/that/appears or removes: /path/that/goes lets it skip when the work is already done.
changed_when: to define what counts as a change, for example
changed_when: result.stdout != ''. changed_when: false is the blunt
version and is honest only for genuinely read-only commands.
What do --check and --diff do together, and where is check mode misleading?
--check runs the playbook without making any changes, and --diff shows the
content difference for anything that would change. Together they answer “how
far has this estate drifted from its description” without touching anything,
which makes them safe against production at any time.
The output is a unified diff, the same format as git diff:
--- before: /srv/site/index.html
+++ after: /srv/site/index.html
@@ -1 +1 @@
-somebody edited it by hand
+running on Debian 13.6
Where it misleads is anything that cannot be simulated.
command and shell cannot be run in check mode, Ansible has no way to know
what an arbitrary command would do, so they are skipped by default. That means
a playbook whose real work happens in command tasks reports almost nothing
in check mode and looks reassuring.
And dependent tasks report wrongly. If task 3 only changes something because task 1 changed something, then in check mode task 1 did not actually change anything, so task 3’s prediction is based on a state that does not exist. Long chains of dependent tasks give unreliable check output.
So check mode is excellent for configuration files and templates, which is
most of what it is used for, and should be read sceptically for procedural
playbooks. check_mode: false on a specific read-only task lets it run for
real so later tasks can depend on its result.
What is a handler, and name two things about when it runs that are not obvious.
A handler is a task that runs only when notified, and only once per play no matter how many tasks notified it. It is how nine configuration file changes produce one service reload rather than nine.
notify fires only when the notifying task reports changed, which is why the
changed count has to be honest for any of this to work.
Two non-obvious things:
Handlers run at the end of the play, not at the point of notification. So a
later task cannot assume the handler has already run. If a subsequent task
needs to talk to the restarted service, meta: flush_handlers forces them to
run at that point.
Handlers do not run if the play fails first. A failure partway through leaves
the configuration files changed and the service never reloaded, a genuinely
awkward state, because the machine now has new config and old behaviour.
--force-handlers runs them regardless.
A third worth knowing: handlers are matched by name, so a typo in notify
silently notifies nothing. Recent Ansible warns; older versions did not, and
the symptom is a service that quietly never picks up its new configuration.
And state: reloaded against restarted is the same distinction as in the
systemd lesson, reload re-reads configuration without dropping connections.
The module will not tell you whether the service actually supports reload; it
will just restart.
What are facts, what do they cost, and which one should you usually branch on?
Facts are what Ansible discovers about each host before running tasks
(distribution, version, interfaces, addresses, memory, CPU, mounts,
virtualisation, service manager) exposed as variables like
ansible_distribution and ansible_default_ipv4.address.
They are what lets one playbook serve a mixed estate:
name: "{{ 'httpd' if ansible_os_family == 'RedHat' else 'apache2' }}"
Branch on ansible_os_family, not ansible_distribution. The family
groups Debian with Ubuntu and RHEL with AlmaLinux, Rocky, and CentOS Stream,
which is almost always the distinction that actually matters, since it is the
one that decides package names, service names, and file locations. Branching
on the specific distribution means adding a case every time somebody uses a
new rebuild.
The cost is a round trip per host, which is why gather_facts: false is a
real optimisation on a large estate for plays that do not need them. Fact
caching in ansible.cfg is the other answer when they are needed but not
fresh.
ansible -m setup prints them all, and -a "filter=ansible_distribution*"
narrows it, which is the fastest way to find the exact name of a fact you half
remember.
Custom facts are possible too, a file in /etc/ansible/facts.d/ on the
managed host appears under ansible_local, which is how you expose something
site-specific that no module knows about.
References
- Ansible documentation - Red Hat. Accessed 2026-08-08.
- How to build your inventory - Red Hat. Accessed 2026-08-08.
- Ansible playbooks - Red Hat. Accessed 2026-08-08.
- Validating tasks: check mode and diff mode - Red Hat. Accessed 2026-08-08.
- Ansible vault - Red Hat. Accessed 2026-08-08.
- Roles - Red Hat. Accessed 2026-08-08.
Every block above with a distribution and architecture header was captured by running the command on a Debian 13 (trixie) container managing itself over a local connection, so the playbook runs, the changed counts, and the drift diff are all real rather than illustrative. Blocks without a header are illustrative.