Dropbox: the tray icon, and 10 days of syncing nothing
Edit on GitHubmodules/home/apps/dropbox.nix. The ~/Dropbox folder synced (the Obsidian vault plus documents).
A conscious exception to the "modules/home/ does not install" rule: services.dropbox is a USER SERVICE
(systemd --user), not a package in environment.systemPackages. The home-manager module already
brings dropbox-cli and starts the daemon, so here it is only ENABLED.
Why the official client and not Maestral: home-manager has an official maintained module for this one. Maestral was archived upstream, has no module, and has a known bug on NixOS where it loses its config on logout (nixpkgs#307898).
The intended use is only Obsidian .md notes and documents (the free plan, 2 GB): no binaries, no
large files. The restic repo does NOT come here.
First use or RE-linking: the daemon OPENS the browser by itself, at the session's browser (see "The browser the daemon opens" below). If you would rather do it by hand:
dropbox-hm status # copy the link and authorize, ONCERun it once. Measured on 25/08/2026: every status call on an unlinked daemon mints a NEW
nonce, so re-running it to "check whether it worked" invalidates the URL still open in the browser.
To follow a relink without disturbing it, watch the files instead: instance1/sync/ reappearing
and sync_history.db moving is the link completing.
Do NOT use plain dropbox status; see the wrapper below. The client downloads its own binary into
~/.dropbox-dist (state, outside Nix), which is the imperative part Dropbox imposes; the rest is
declared.
The incident this file exists not to repeat
Discovered on 11/08/2026: the daemon had been active (running) for 6 h, with not one error line,
and UNLINKED since ~01/08 (unlink.db rewritten; the hostkeys and sync_history.db frozen on
31/07). Ten days syncing NOTHING, with the service declaring itself healthy.
Two independent causes, each with its own remedy:
- systemd did NOT notice the daemon dying →
ExitType/Restart - NOTHING noticed the "alive but unlinked" → the healthcheck timer
Why dropbox-hm exists
The daemon runs with its OWN HOME (~/.dropbox-hm), a decision of the home-manager module and not
of this repo. And the CLI only finds the daemon's socket if it inherits the SAME HOME: in a normal
shell, dropbox status answers "Dropbox isn't running!" with the daemon alive right next to it.
That LIE is what makes diagnosing by hand go wrong, and it is how the incident above went
unnoticed. The wrapper does not reinstall dropbox-cli; it references the module's SAME store
path, the way autostart does with LocalSend, so it does not break rule 4.
The tray icon, and what it costs
The home-manager module pins an EMPTY DISPLAY= on the unit, on purpose: to it, this is a
headless daemon. The price is having no tray icon, MEASURED on 11/08/2026 on the live process:
zero Wayland or X11 fds open, and the SNI watcher listing LocalSend, Sunshine and Discord WITHOUT
Dropbox. It was never the bar's fault.
That default is INVERTED here, because the icon is wanted. It is enough for the daemon to inherit
the session's display for it to register dropbox_client_<pid> in the watcher immediately
(measured). And the bar already knows how to draw it: the image://icon/<name>?path=<dir> that
Dropbox publishes has its own handling in modules/home/desktop/quickshell/bar/Bar.qml.
DISPLAY=:0 and WAYLAND_DISPLAY=wayland-1 are NOT hardcoded: the socket name belongs to the
session, not to the host, and tomorrow it is wayland-2. autostart.lua already does
systemctl --user import-environment WAYLAND_DISPLAY …, so the user manager HAS both variables,
and the remedy is only to STOP zeroing DISPLAY and let the unit inherit it.
mkForce on Environment and not one more list item: it is a LIST, and a second DISPLAY=… entry
would depend on the module's merge ORDER to beat upstream's empty one, and nobody guarantees that
order. Rewriting the whole list is deterministic.
The cost, stated explicitly: inheriting the display requires being STARTED by the graphical
session. After= alone would not solve it, because what brings graphical-session.target up is
the compositor's exec-once, OUTSIDE the default.target transaction, so at boot the service
started earlier and fell back into headless. That is what was seen: dropbox and quickshell both at
07:11:42.
So Install becomes graphical-session.target, with mkForce because the module declares
default.target and KEEPING both would be worse than not touching it (at boot default.target
would bring the daemon up first, headless and iconless, and the session afterwards would not
restart an already-active unit).
The consequence is honest: WITH NO SESSION THERE IS NO SYNC. On this machine that is cheap
(autologin, always on), but it is a real trade, and it inherits the trap already noted in
modules/home/desktop/polkit-agent.nix (home-manager#8547): if graphical-session.target ever goes
inactive, the sync stops with it. That is why the watcher also LOGS at warning level and does not
only notify.
The browser the daemon opens
nixpkgs hardcodes Firefox into this package in two places, nixpkgs:pkgs/by-name/dr/dropbox/package.nix:
firefox-bin in targetPkgs (a whole browser inside the FHS sandbox) and export BROWSER=firefox
as the first line of the launcher the unit runs.
That is not cosmetic. The daemon's HOME is ~/.dropbox-hm, so it never reads the user's
x-scheme-handler/https, and the relink page lands in a browser that has never seen this account:
the flow dead-ends and the person falls back to copying URLs by hand, into the nonce trap above.
Confirmed on 25/08/2026 by the Firefox profile the daemon had created inside its own HOME, at
~/.dropbox-hm/.cache/mozilla/firefox/.
The override reaches it through .override, since the package comes from callPackage:
runScriptis rebuilt withsubstitute --replace-failover theBROWSERline.--replace-failand not--replace: a nixpkgs bump that touches that line must BREAK THE BUILD, not silently hand the browser back to Firefox.firefox-binis filtered out oftargetPkgs. With the browser now outside the sandbox it is 316 MB of dead closure, andnix-store -qRon the new toplevel confirms it is gone.dropbox-clitakes the FHS package as an ARGUMENT, so it is overridden too. Without that the CLI would keep starting the stock daemon and the whole override would be decorative.
The browser is referenced as the same Zen derivation modules/home/packages.nix installs, not by name on
PATH, so it is the store path that is guaranteed and not the search order (rule 4). ldd says Zen
resolves entirely under /nix, which the sandbox binds, so it runs from in there.
Proven on the 06/10/2026 unlink: Zen comes up from inside the bwrap. The unit's journal shows
zen-beta started by dropbox-start on 06/10 and again on 08/10, at the first start after boot.
Block 1: making systemd notice the daemon dying
The home-manager module delivers Type=forking plus PIDFile plus Restart=on-failure, and
systemd logs on every start:
Supervising process 1806 which is not our child. We'll most likely not notice when it exits.That is not noise: it is systemd SAYING that its Restart=on-failure is decorative. The CLI's
dropbox start does a double fork and the PIDFile's process is not systemd's child, so the death
does not arrive as a SIGCHLD, and the service would stay active with the daemon already dead.
ExitType=cgroup changes the criterion: the unit is alive while THERE IS a process in its cgroup,
and the cgroup is something systemd really controls (the daemon does not escape it; checked in
systemctl status, PIDs 1797 and 1806 both inside). It requires systemd >= 250; this machine is
on 260.
Restart=always and not on-failure, and here the choice is the OPPOSITE of
modules/home/desktop/autostart.nix, on purpose: there, closing an app by hand is a decision to respect;
here a stopped sync daemon is always a defect, including when what stopped it was
dropbox-hm stop. An explicit systemctl --user stop dropbox still stops it for real, since an
explicit stop never triggers Restart.
StartLimitIntervalSec=300 plus StartLimitBurst=5 so a real crash-loop blows the limit and the
unit DIES visibly in systemctl --user --failed, instead of restarting silently forever. That is
the lesson of Spotify's 4145 starts.
Block 2: noticing the "alive but not syncing"
Block 1 covers a DEAD daemon. The failure mode that cost 10 days is another one: the daemon alive,
active, exit 0, a clean log, and unlinked. No process metric sees that, so the only way is to ASK
it, just like Sunshine's active probe.
The watcher is tied to graphical-session.target because the remedy is a notification: with no
session there is no notification daemon to receive it. The Dropbox daemon itself is NOT tied to
the session (it stays 24/7 with linger); only the warning needs somebody in front of the screen.
Four details in the script:
timeout 30, because the CLI BLOCKS waiting for the daemon's socket: a hung daemon would hang the timer with it, and a stuck timer warns about nothing, which is exactly the failure mode the script exists to cover.caseand NEVERgrep -q: withwriteShellApplication's pipefail the grep exits on the first match, the producer dies of SIGPIPE, and the pipeline returns an ERROR despite the match. Same trap as inmodules/home/network/mega.nix. The matched texts come from the official CLI: "To link this computer to a Dropbox account, visit the following url" and "Dropbox isn't running!".- Anti-spam of 12 h per state, the same idiom as
disk-watch. Without it an unlink would become a notification every 30 min and the person would learn to ignore it, and an alarm that tires is an alarm that does not protect. <4>is systemd's level prefix (warning). Without it the line would come out atinfoand the unit'sLogLevelMax=warningwould SWALLOW it. It logs ALWAYS and not only when the toast fails, because if the graphical session is exactly what broke there is no notification daemon left.
It only NOTIFIES, it never restarts. What keeps the daemon up is block 1; two owners for the same automation is rule 15. And in the unlink case restarting would solve nothing, because relinking requires authorizing in the BROWSER.
Only the timer triggers it, and that is why the service carries NO Install. It used to also
be WantedBy=graphical-session.target, and that made the oneshot fire at session start, before the
daemon finished its handshake and before there was any notification daemon to receive the toast.
The result was a FALSE alarm in every boot, three minutes ahead of the real probe:
07:11:23 dropbox-link-watch[2180]: Dropbox is down, dead
07:11:23 dropbox-link-watch[2241]: Failed to show notification: ...ServiceUnknown
07:14:21 dropbox-link-watch[12004]: Dropbox UNLINKED, nothing is syncing, unlinkedThe OnActiveSec below exists to cover exactly that transient, and the WantedBy on the service
went over its head. An alarm that cries at every boot is the alarm that tires, which is the same
thing the 12 h anti-spam is there to prevent.
The timer: OnActiveSec = 3min so the daemon can come up and handshake with the cloud before
the first probe, otherwise the transient startup state would become a false alarm.
OnUnitActiveSec = 30min is plenty of resolution: an unlink does not resolve itself, and the
damage grows in days (10, in the incident), not in minutes.
Second unlink, 06/10/2026
Found on 08/10/2026, three days in. Unlike the first incident, the watcher did its job: it logged
Dropbox UNLINKED 3 minutes after the 06/10 boot and on every probe after that. What failed was
acting on it, not detecting it.
What the state files prove:
sync_history.dblast moved at 05/10 13:05, on a mid-session restart of the unit.instance1/hostkeysandinstance_db/hostkeyswere REWRITTEN at 06/10 07:18, the first start after the next boot. A new hostkey is a new device identity: the client threw the old link away locally, so the unlink happened at startup, not mid-session.
What it was NOT, each one checked:
- Hostname or machine-id:
ex-b560m-v5since the cutover,/etc/machine-idfrom 01/08. - The SIGKILL at shutdown: the daemon logs "Error reading events from display: Broken pipe"
when the session goes away,
dropbox stopanswers "Dropbox isn't running!", and systemd kills what is left. That happens on EVERY shutdown, including the ones that stayed linked (01/10 and 04/10 twice). - The keyring sealed in the TPM on 04/10:
login.keyringlast changed 04/10 14:13, and the link survived the whole day of 05/10 after it. No libsecret reference turned up in the client's libraries either. line 3: run: command not foundin the unit's log: upstream's start script calls the activation-script helperrun, which does not exist in a unit. Every generation has that line, including the one running now; themkdirit skips is redundant because the dirs exist.
The root cause is still OPEN. The client's own logs (~/.dropbox-hm/.dropbox/logs/) are
encrypted, so the local side ends here. The next place to look is the account's device list
(dropbox.com, Settings, Security): a session revoked server side, or the free plan's 3-device
limit, would both look exactly like this from the machine.
Relinking took a daemon restart
Three authorizations in the browser did nothing: the state files did not move and status kept
answering "Waiting to be linked". One of them was refused by the page as expired. After
systemctl --user restart dropbox (the daemon had been up since 07:11), the FIRST URL the fresh
daemon minted linked within a minute: instance1/sync/ appeared, sync_history.db moved, and
status went Indexing..., then Up to date.
Why the old daemon ignored the authorizations is not known. A plausible suspect is the pile of
nonces it had minted by then (one for the browser it opened at boot, one per status call), but
that was not proven. The recipe that worked:
systemctl --user restart dropbox
dropbox-hm status # ONCE, after it leaves "Starting...", then authorize that URLTo verify, watch instance1/sync_history.db and instance1/sync/, never a second status before
the link completes. And not the bare instance1/ directory: it survives an unlink, so its
presence proves nothing.