AnsibleXLI · Service and Application DeploymentService and Application Deployment
Service state, enablement and restart discipline
What you'll learn
- Treat state and enabled as two independent decisions and set both deliberately
- Choose systemd_service over the generic service module and justify the choice
- Trigger daemon_reload only when a unit file has actually changed
- Place meta flush_handlers so a validation gate runs against the restarted service
Prerequisites
Verified against ansible-core 2.21.x · ansible (community package) 14.x · Python (controller) 3.12+ · ansible-lint 26.x · Molecule 26.x · Ubuntu 24.04 LTS · Debian 12 (Bookworm) · RHEL / Rocky / AlmaLinux 9.x · 2026-08-11
A service task asks two questions that people routinely conflate: is it running now, and will it come back after a reboot. Ansible keeps them as separate parameters because they are separate decisions, and every combination of the two is a legitimate state that some host somewhere is deliberately in.
state and enabled are independent
state | enabled | What it means |
|---|---|---|
started | true | running now, running after a reboot — the normal case |
started | false | running now, gone after a reboot — a service being decommissioned, or one started by hand |
stopped | true | not running now, back after a reboot — the state a failed maintenance leaves behind |
stopped | false | not running, not coming back — properly disabled |
The two middle rows are where the damage lives. started with
enabled: false is the shape of a service that survives every check
until the host reboots — which, in this course, is the night of the
patch window.
- name: Ensure the application service is running and will survive a reboot
ansible.builtin.systemd_service:
name: myapp
state: started
enabled: trueOmitting enabled does not mean false. It defaults to null, which
means leave it as it is. That is the correct default for a module
that must not surprise you, and it is also why “the service was running
but did not come back after the reboot” is such a common finding: the
role only ever expressed state: started, and the host was never
enabled by anything.
systemd_service against the generic service
ansible.builtin.service is the portable abstraction, and it is a
weaker tool in a way that matters.
The parameter lists diverge exactly where the interesting operations
are. systemd_service has daemon_reload, daemon_reexec, masked,
scope and no_block. service has none of them; it carries
runlevel, pattern, sleep and arguments instead — parameters
shaped by SysV init.
The check-mode difference is sharper still. Read from ansible-doc on
ansible-core 2.21.3:
| Module | check_mode | diff_mode |
|---|---|---|
ansible.builtin.systemd_service | full | none |
ansible.builtin.service | N/A | N/A |
N/A on the generic module means the guarantee depends on whichever
implementation it dispatched to — the same leak the package module has
in the patching part, arriving from the same direction. A --check run
of a deployment role built on service carries a guarantee that varies
by host and does not say so.
daemon_reload, and doing it only when needed
systemd caches unit files. Writing a new .service file changes nothing
until the manager re-reads it, and until then systemctl restart runs
the old unit definition — the old ExecStart, the old environment,
the old resource limits.
The symptom is memorable: you fix a unit file, restart the service, watch it come up with the behaviour you just corrected, and conclude the file is wrong. It is not; it has not been read.
- name: Install the application unit file
ansible.builtin.template:
src: myapp.service.j2
dest: /etc/systemd/system/myapp.service
owner: root
group: root
mode: '0644'
notify:
- Reload systemd
- Restart myapp
# handlers/main.yml
- name: Reload systemd
ansible.builtin.systemd_service:
daemon_reload: true
- name: Restart myapp
ansible.builtin.systemd_service:
name: myapp
state: restartedTwo things make that correct.
daemon_reload is notified, not unconditional. A daemon_reload: true task in the main task list runs on every play, on every host,
reporting changed each time — which turns every run yellow and makes
the run output stop meaning anything.
Handler order comes from the handler file, not the notify list.
Handlers run in the order they are defined, not the order they are
notified. Reload systemd is defined before Restart myapp, so it runs
first. Reversing the definitions would restart the service against the
stale unit definition and then reload the manager, which is the same bug
with extra steps.
daemon_reload and daemon_reexec are different operations.
daemon_reload re-reads unit files; daemon_reexec re-executes the
systemd manager binary itself, which is what you do after upgrading
systemd, and is not part of an application deployment.
masked is a stronger statement than disabled
masked: true symlinks the unit to /dev/null, and a masked unit
cannot be started at all — not by systemctl start, not by a
dependency, not by socket activation.
That is the right tool for a genuinely retired service, and a foot-injury waiting to happen if it is left behind:
- name: Retire the legacy sync daemon
ansible.builtin.systemd_service:
name: legacy-sync
state: stopped
enabled: false
masked: trueRestart only through a handler
The rule is simple and the reason is worth stating: a state: restarted
task in the main task list restarts the service on every run,
whether or not anything changed.
On a single host that is a few seconds of downtime nobody notices. On a rolling deployment across 200 hosts it is 200 unnecessary service interruptions, and it destroys the property the whole part depends on — that a run against an already-converged fleet is a no-op.
# Restarts on every run, converged or not.
- name: Restart the application
ansible.builtin.systemd_service:
name: myapp
state: restarted
# Restarts only when something notified it.
- name: Write the application configuration
ansible.builtin.template:
src: myapp.conf.j2
dest: /etc/myapp/myapp.conf
mode: '0640'
notify: Restart myappstate: started in the main task list is the complement and is
correct: it is idempotent, it asserts the service is running, and it
does nothing when it already is. started asserts; restarted
commands.
flush_handlers before a validation gate
Handlers run at the end of the play by default. A validation task placed after the configuration change therefore runs before the restart, and validates the old process.
The result is the worst kind of green: the gate passed, the service was never restarted at the time it passed, and the new configuration went live afterwards with nothing checking it.
- name: Write the application configuration
ansible.builtin.template:
src: myapp.conf.j2
dest: /etc/myapp/myapp.conf
mode: '0640'
validate: '/usr/bin/myapp --check-config %s'
notify: Restart myapp
- name: Apply any pending restart now, before we check anything
ansible.builtin.meta: flush_handlers
- name: Prove the service is serving the configuration we just wrote
ansible.builtin.uri:
url: 'http://{{ ansible_host | default(inventory_hostname) }}:8080/healthz'
return_content: true
register: health
retries: 20
delay: 3
until: health.json.config_generation | default(0) | int >= expected_generation | int
changed_when: falsemeta: flush_handlers is one of the choices the meta module accepts,
alongside end_play, end_batch, end_host, clear_facts,
reset_connection and the rest. It dispatches every pending handler at
that point in the task list.
Under serial, handlers already flush at the end of each batch rather
than at the end of the run — so the failure this fixes is narrower than
it first appears, but it is exactly the failure a validation gate placed
after the change runs into.
Knowledge check
Knowledge check · 4 questions
Q1. A deployment role sets state: started on its service task and never mentions enabled. What is the effect?
Q2. A role writes a new unit file to /etc/systemd/system/myapp.service and restarts the service, without daemon_reload. What happens?
Q3. Which statements about handlers in a deployment role are correct? Select all that apply.
Q4. ansible.builtin.service and ansible.builtin.systemd_service offer the same check-mode guarantee, so choosing between them is purely a portability question.
Passing score: 75%. Answers are checked in this browser.