Skip to main content
RunBook Academy

AnsibleXLI · Service and Application DeploymentService and Application Deployment

Service state, enablement and restart discipline

Advanced⏱ ~24 minansible-playbook

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

Not yet marked complete on this device.

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

stateenabledWhat it means
startedtruerunning now, running after a reboot — the normal case
startedfalserunning now, gone after a reboot — a service being decommissioned, or one started by hand
stoppedtruenot running now, back after a reboot — the state a failed maintenance leaves behind
stoppedfalsenot 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.

Service impact possibleboth decisions, stated
- name: Ensure the application service is running and will survive a reboot
ansible.builtin.systemd_service:
  name: myapp
  state: started
  enabled: true

Omitting 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:

Modulecheck_modediff_mode
ansible.builtin.systemd_servicefullnone
ansible.builtin.serviceN/AN/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.

Configuration changereload the manager only when the unit file changed
- 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: restarted

Two 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:

Service impact possibleretiring a service properly
- name: Retire the legacy sync daemon
ansible.builtin.systemd_service:
  name: legacy-sync
  state: stopped
  enabled: false
  masked: true

Restart 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.

Service impact possiblethe difference, side by side
# 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 myapp

state: 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.

Service impact possibleforcing the restart to happen before the check
- 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: false

meta: 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

  1. Q1. A deployment role sets state: started on its service task and never mentions enabled. What is the effect?

  2. Q2. A role writes a new unit file to /etc/systemd/system/myapp.service and restarts the service, without daemon_reload. What happens?

  3. Q3. Which statements about handlers in a deployment role are correct? Select all that apply.

  4. 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.