The playbook run ends green, the WireGuard unit is active, the handshake works, and the new peer still cannot reach anything. Watching a playbook report success while the server runs yesterday's configuration is a specific kind of frustrating, because nothing in the output is wrong. Ansible did exactly what it was asked: it ensured the unit was started, and the unit was already started. Applying a rewritten configuration file is a different task, and it only happens if something tells the service to reread it.

Watch what a second run actually reports

Run the playbook twice with the changed configuration in place and compare the recap. The second run should show the unit task as ok rather than changed, because an already-running service is left alone by design. That is idempotence working correctly, and it is also the reason the new file never reaches the kernel.

Add a check run with diff output to see which tasks would touch anything at all:

ansible-playbook -i inventory site.yml --check --diff
systemctl show wg-quick@wg0 -p ActiveEnterTimestamp -p NRestarts
stat -c '%y %n' /etc/wireguard/wg0.conf

The three timestamps tell the story before you read a single log line. If the configuration file is newer than the moment the unit became active, the running interface was built from an older file, and every symptom downstream follows from that.

This is not an Ansible defect. The same behaviour appears with any configuration management tool that separates "make sure it is running" from "make sure it is running this". The distinction looks pedantic until it costs you an afternoon.

Started is a state, not a reload

state: started asserts a condition about the unit. It does not promise that the service reread its inputs, and it cannot know that a file it never opened has changed. For a wg-quick deployment the inputs include the interface configuration, the addresses, the routes, the DNS entries and any post-up hooks.

That is why an updated file can contain a new peer, a different address or a changed routing directive while the live interface keeps serving the old set. The handshake still succeeds because the keys and endpoints that were loaded are still valid. That is exactly what makes the failure hard to see, and why people chase DNS for an hour before checking a timestamp.

Two other module options are commonly mistaken for a fix. daemon_reload makes systemd reread unit files, which is irrelevant to a WireGuard configuration file. state: reloaded asks the unit to run its reload action, and whether that action does what you need depends entirely on the unit in question. Neither one is a substitute for knowing which inputs the running process actually read.

Install the file, then notify a handler

The pattern that keeps idempotence and still applies changes is the ordinary one: the task that writes the file notifies a handler, and the handler runs only when the file actually changed. A second run with no edits does nothing at all, which is what you want.

- name: write the interface configuration
  ansible.builtin.template:
    src: wg0.conf.j2
    dest: /etc/wireguard/wg0.conf
    owner: root
    mode: "0600"
  notify: apply wg0

- name: ensure the interface unit is enabled
  ansible.builtin.systemd_service:
    name: wg-quick@wg0
    enabled: true
    state: started

- name: apply wg0
  listen: apply wg0
  ansible.builtin.systemd_service:
    name: wg-quick@wg0
    state: restarted

Handlers run at the end of the play, after the tasks that notified them, so a task later in the same play may still see the old interface. If ordering matters, flush the handlers explicitly with meta: flush_handlers before the task that depends on the new configuration.

Use restart rather than reload unless you have verified the unit's reload action. The upstream wg-quick unit does support one, and it synchronises WireGuard configuration, but it does not re-run address, route, DNS or hook setup. A reload after adding a route directive therefore appears to succeed and changes nothing.

Compare the live device with the file

The check that closes this class of bug is a comparison between what the kernel holds and what the file says. Two commands produce comparable output, and the difference is the drift:

sudo wg showconf wg0
sudo wg-quick strip wg0
sudo diff <(sudo wg showconf wg0) <(sudo wg-quick strip wg0)

wg showconf prints the live device, including the private key, so treat that output as a secret and do not paste it into a ticket. wg-quick strip prints the file with wg-quick-only keys removed, which is why the two are comparable at all.

A difference in the peer list means the file was never applied. A difference limited to keys or endpoints you expected to change means something applied it partially. No difference at all means the configuration is current and the fault is elsewhere, which is itself a useful result.

Pay the restart cost deliberately

Restarting the interface drops the tunnel, and if that tunnel carries your administrative session, the restart ends the session that requested it. Put the handler behind a change window, or run the playbook from a management path that does not depend on the interface being restarted.

The cost is why people reach for reload. That is a reasonable instinct, and it becomes correct once you know which changes a reload actually applies. For peer and key changes on the upstream wg-quick unit it is enough; for addressing, routing and hooks it is not. Sorting that out once is cheaper than discovering it during a change window.

The systemd_service module documentation spells out the difference between started, restarted and reloaded, and it is worth reading once rather than inferring from a green recap. Everything in this walkthrough follows from that one distinction.

Sɛbɛn minnu bɛ tali kɛ