The short answer
If your self-updating daemon keeps offering the same update on Linux but never actually applies it, the download is probably fine and the restart is silently failing. The usual cause is that the updater calls the system systemctl manager (which needs root) while the service is really installed as a systemctl --user unit. Detect which manager owns the unit before restarting, and swap the new binary in with an atomic rename.
The symptom
I ship a small Go daemon with a signed auto-update mechanism: a cryptographically signed manifest plus a SHA-256-verified binary. On Windows it updated itself cleanly. On one Linux host it looped forever. Every check reported “update available” and offered the same update, applied nothing, and came back next cycle to offer it again. Nothing crashed, nothing logged an obvious error, and the running version never changed.
The environment
The daemon runs on Linux as a non-root user with no passwordless sudo. The service is managed by systemd, and on this host it was installed as a user unit under ~/.config/systemd/user/ rather than as a system-wide service. The same updater binary is shipped to both system and user installs, which turns out to be the whole problem.
What I confirmed was already working
Before touching the restart logic I checked the parts that are easy to get wrong. The daemon had downloaded the new binary and verified its SHA-256 against the signed manifest, staging it as a .new file right next to the running binary. So the signature check, the download, and the hash verification were all fine. The file on disk was the correct, verified next version. The failure was entirely in the apply-and-restart step that runs after verification.
That narrowed things down a lot. When “update available” repeats forever, the instinct is to distrust the download or the manifest, but here the staged .new file proved the acquisition path was healthy. Only the swap and restart were broken.
The verified root cause: system manager vs –user manager
The updater called systemctl stop and systemctl start against the system manager. That requires root. The service was actually installed as a systemctl --user unit owned by a non-root user with no passwordless sudo, so the system-level restart could not act on it. The command did not bring the service down and back up on the new binary, so the daemon kept running the old version and kept re-offering the update on the next check.
This is a classic cross-platform self-update divergence. The exact same code path worked on Windows because there it drives sc to control the service, and that path had the right privileges and the right target. Ship one updater to two platforms and the half you test less carefully is the half that quietly does nothing.
The gear I run for this
Hardware from my own homelab, relevant to this guide — direct Amazon links.
Affiliate links — I earn a small commission at no extra cost to you. Browse my full homelab store →
The second trap: ETXTBSY
Once I fixed the manager detection, the swap itself failed. Copying the new binary over the currently-running executable returned ETXTBSY (“text file busy”). Linux refuses to open a running executable for writing, so a plain cp over the live binary cannot work while the process is up.
The fix is to stop writing into the busy file. Use mv, an atomic rename, to swap the new file into place. A rename replaces the directory entry without opening the running executable for writing, so the busy-file restriction never applies. The old inode stays alive for the still-running process until it exits, and the next start picks up the new binary.
The fix
Two changes. First, detect whether the service is a user unit or a system unit before calling systemctl, and drive the matching manager:
systemctl --user restart myapp.service # user service, no sudo needed
sudo systemctl restart myapp.service # system service
The user unit lives at ~/.config/systemd/user/myapp.service. If the daemon is running as a user service, systemctl --user acts on it with no sudo at all, which matches the non-root, no-passwordless-sudo reality of the host.
Second, always replace the running binary with an atomic rename rather than copying over it:
mv myapp.new myapp # atomic swap, never cp over the running file
With the correct manager and an atomic swap, the restart actually takes effect, the daemon comes back on the new binary, and the update loop stops because there is no longer an update to offer.
The companion gotcha: cached embedded UI
After the service finally self-updated, the browser still showed the old web UI until a hard refresh. The single-page-app HTML is compiled into the Go binary with //go:embed, so the new binary genuinely served new markup, but the browser had cached the pre-update page and kept rendering it.
For any embed-compiled UI this is worth designing around up front. Bump a cache-busting asset or version string on each release, or send no-cache headers on the app shell, so clients pick up UI changes as soon as the service updates instead of appearing frozen on the old page.
Reusable lessons
- Detect user-vs-system before calling systemctl. A cross-platform self-updater cannot assume the service is a system unit. Work out which manager owns it and call that one.
- Never open a running executable for writing. Expect
ETXTBSYand rename the new file into place instead of copying over the live binary. - With signed anti-downgrade manifests, “rollback” means forward-fix. When the manifest refuses older versions, recovery is shipping a corrected newer version, not restoring the old manifest.
When this may not apply
If your service genuinely runs as a system unit with root, plain sudo systemctl restart is the correct call and there is nothing to detect. The trap is specifically shipping one updater to both system and --user installs without detecting which one you are actually on. If you only ever install one way, and you install it the way your updater assumes, you will never hit this.
Reusable checklist
- When an update loops, confirm the staged
.newfile exists and its hash verifies. If it does, the download is fine and the bug is in apply-and-restart. - Check whether the unit is a
--userunit (look under~/.config/systemd/user/) or a system unit before choosing your restart command. - Call the matching manager:
systemctl --user restartfor user units,sudo systemctl restartfor system units. - Swap the binary with
mv, nevercpover the running file, to avoidETXTBSY. - For
//go:embedUIs, add cache-busting or no-cache headers so clients see the new UI after an update. - Test the update path on every platform you ship to, not just the one you develop on.
Takeaway
A self-updater that “sees” updates but never applies them is almost always failing after verification, and on Linux the first place to look is whether you are restarting the same systemd manager that actually owns your service.
AI assisted with drafting this article; the code, environment, and debugging are from my real project.