Daemon
dropbear daemon is a long-running supervisor that will respond to file system events and periodically poll the bucket for changes. It calls the same sync pipeline as the one-shot dropbear sync on a fixed interval, per root, in a single process. Background operation belongs in a daemon; everything else in this release builds on the process model this subcommand locks in.
This page covers what the daemon does, the two invocation modes, the on-disk config file, the tick log envelope, and jq recipes for filtering the stream.
What it does
For each supervised root, the daemon runs an independent tick loop. Each tick:
- Runs
upload.Runagainst the root's state DB and bucket — the same pipelinedropbear syncinvokes. - Emits a structured
tick_startrecord on stdout before the tick begins. - Emits a
tick_endrecord on stdout when the tick finishes (with the fulldropbear sync --jsonenvelope nested under asyncattribute), or atick_skiprecord if the root went offline mid-flight.
SIGINT and SIGTERM shut the daemon down cleanly. The first signal stops scheduling new ticks; the in-flight tick on each root is allowed to finish, then the process exits 0. A second signal during shutdown cancels the in-flight tick contexts and exits 130 within one second.
Invocation modes
Positional args (manual testing)
dropbear daemon --interval=1s /path/to/root
dropbear daemon --interval=30s /path/to/rootA /path/to/rootB
Useful for kicking the tyres without committing a config file. With one or more positional <root> arguments, the daemon supervises exactly those roots and ignores any [[roots]] block in daemon.toml. It still reads log_format and default_interval from the file if present, and looks up each positional root in the file to pick up its specific interval.
Config-file driven (installation)
With no positional args, the daemon reads its config file at:
The default-location file is optional but required when no positional args are supplied.
Pass -c, --config <path> to read a config file from an explicit location instead. An explicit path is treated as required: a missing file produces exit 64 rather than falling back to defaults.
dropbear daemon -c /etc/dropbear/daemon.toml
dropbear daemon --config ./local-daemon.toml /path/to/extra-root
daemon.toml shape
log_format = "json" # "json" | "text" | "pretty"; default "json"
log_level = "info" # "debug" | "info" | "warn" | "error"; default "info"
default_interval = "5m" # Go duration; default "5m"; minimum "1s"
watcher_debounce = "500ms" # Go duration; default "500ms"; minimum "50ms"
head_poll_interval = "10s" # Go duration; default "10s"; minimum "1s"
head_poll_max_interval = "120s" # Go duration; default "120s"; must be ≥ head_poll_interval
status_api = true # bool; default true. Set false to disable the local status HTTP listener.
status_api_addr = "127.0.0.1:0" # string; default loopback IPv4 ephemeral. Host MUST be 127.0.0.1 or ::1.
status_api_require_auth = true # bool; default true. Set false ONLY when an external proxy handles auth.
status_tick_history = 100 # int; default 100; range [10, 10000]. Per-root tick-history ring capacity.
[[roots]]
path = "/home/me/work"
interval = "30s" # optional per-root override; falls back to default_interval
[[roots]]
path = "/home/me/archive"
Validation:
log_formatmust be"json"or"text".default_intervaland any per-rootintervalmust be ≥1s.watcher_debouncemust be ≥50ms(no "disable watcher" knob in this release).head_poll_intervalmust be ≥1s;head_poll_max_intervalmust be ≥head_poll_interval.status_api_addrmust resolve to a loopback host (127.0.0.1or::1); non-loopback hosts are rejected at startup (exit 64).status_tick_historymust be in[10, 10000].- Each
[[roots]].pathis required and must be unique. Relative paths are resolved against the directory containingdaemon.toml. - Unknown top-level keys and unknown keys inside
[[roots]]are rejected at startup (exit 64).
Precedence for the effective per-root interval: --interval CLI flag > per-root interval > default_interval > built-in default 5m.
Exit codes
Startup is all-or-nothing: if any selected root fails to open, the daemon exits without starting any per-root loop and emits no tick_start records.
Tick log envelope
The daemon emits one JSON object per line on stdout. Every record carries:
Event-specific fields:
When log_format = "text" is set, the same attributes are rendered in slog.TextHandler's single-line key=value form. When log_format = "pretty" is set, records render as a coloured header line followed by indented attribute lines — convenient for interactive runs:
18:54:56 WARN local-change
root=slice2
tick=42
path=hello.mom
status=modified
Colour is enabled automatically when stdout is a TTY. Stderr is reserved for process-level errors (config validation failures, panics surfaced outside a tick); routine tick records never land there.
jq recipes
Filter to one root:
dropbear daemon /a /b /c | jq -c 'select(.root_id == "my-laptop-photos")'
Show only successful tick outcomes with their manifest IDs:
dropbear daemon /root | jq -c 'select(.event=="tick_end" and .outcome=="ok") | {time, manifest: .sync.manifest_id, dur: .duration_ms}'
Watch for offline skips:
dropbear daemon /root | jq -c 'select(.event=="tick_skip")'
Total bytes uploaded across the session:
dropbear daemon /root | jq -s '[.[] | select(.event=="tick_end" and .outcome=="ok") | .sync.uploaded.bytes] | add'
Looking at the daemon
When status_api = true (the default) the daemon binds a read-only HTTP/JSON listener on 127.0.0.1 (ephemeral port by default) and writes a runtime handoff file at os.UserConfigDir()/dropbear/runtime/daemon.json (mode 0600). The file carries {schema_version, pid, url, token, started_at}. Clean shutdown removes it; a crashed daemon leaves it behind by design — dropbear daemon status checks the recorded PID before sending the token anywhere.
The simplest consumer is the bundled CLI:
$ dropbear daemon status
pid 4321 started 2026-05-28T10:30:00-04:00 schema 1
work online 3s ago via watcher (147ms) ~/work
archive online 2m ago via interval (1.2s) ~/archive
Exit codes: 0 daemon running and every root is healthy; 1 daemon running but at least one root reports online=false or a recent error; 2 daemon not running (file missing, PID dead, or unreachable). Pass --json to skip the human formatter and emit the raw response envelope.
For the browser, every endpoint except GET / and GET /v1/healthz requires Authorization: Bearer <token>. The bundled one-page UI receives the token via URL fragment (http://127.0.0.1:NNNN/#t=<token>), reads location.hash once, clears the fragment, and attaches the bearer header to every fetch. Fragments are never sent in the HTTP request by browsers, so the token does not land in server logs or Referer headers. Use dropbear daemon status --ui to print the URL with the token fragment already assembled — e.g. open "$(dropbear daemon status --ui)" on macOS, xdg-open "$(dropbear daemon status --ui)" on Linux.
For server installs that put the API behind a reverse proxy (e.g. nginx adding TLS in front), set status_api_addr = "127.0.0.1:9876" in daemon.toml to pin the port. The bind is still loopback-only; the proxy is responsible for whatever external-facing authentication makes sense in that environment. When the proxy handles auth (e.g. nginx auth_basic), set status_api_require_auth = false and the daemon skips its own bearer-token gate — the proxy can then forward unauthenticated requests upstream. With auth disabled the runtime file's token is empty, daemon status --ui prints the URL without a #t= fragment, and the embedded HTML omits the Authorization header on its fetches. Only disable when (a) the bind is loopback-only and (b) the proxy is the only path that can reach the listener.
Endpoints (all return application/json; charset=utf-8 with schema_version=1):
The API is read-only by design. There is no "sync now," no config edit, no remote start/stop. Set status_api = false to disable it entirely (no listener, no runtime file, no per-root tick history retained in memory).
How the daemon decides to sync
Each supervised root runs its own tick loop. Between ticks the loop blocks on four signals at once: (a) shutdown (gating context cancellation), (b) a wakeup from the per-root filesystem watcher, (c) a wakeup from the per-root remote head poller, or (d) the interval timer firing. Whichever fires first drives the next tick.
Filesystem watcher (fsnotify-based): recursively subscribes to every non-ignored directory under the root at startup. When a file changes, it coalesces fsnotify events with a watcher_debounce quiet period (default 500ms) and emits a single wakeup.
Remote head poller: periodically LISTs roots/<rootID>/devices/ in the bucket, filters for <deviceID>/head.json entries, and compares each remote head's ETag (or size, when ETag is empty) against an in-memory last-seen map. Any change emits a wakeup. The cadence is adaptive: it starts at head_poll_interval (default 10s), doubles after every consecutive no-change poll up to head_poll_max_interval (default 120s), and snaps back to base on any observed change. The first poll after startup is silent (populates the map without emitting a wakeup — the startup tick already consumed initial remote state). LIST errors log at warn and don't crash, don't mutate state, and don't advance the backoff.
Interval: the backstop. Now a ceiling since last activity — the timer restarts from zero after every tick completes (regardless of which trigger fired it). A busy root effectively never hits the backstop; a quiet root scans once per interval as a safety net for events that the watcher or poller dropped.
Tick records carry a trigger attribute identifying the cause:
"startup"— the first tick after process start."watcher"— the loop unblocked because the filesystem watcher emitted a wakeup."head-poll"— the loop unblocked because the head poller observed a remote head change."interval"— the loop unblocked because the interval expired without any activity.
Caveats
- Ignored directories are not watched until restart. The watcher loads
.dropbear/ignoreat startup; a directory matched at that moment (e.g.node_modules) is not subscribed at all. If you later remove the ignore rule, writes under that directory will not trigger wakeups until you restart the daemon — but the interval backstop still picks them up at the next scheduled scan. - Inotify watch limits (Linux). The default per-user limit is 8192 watches. Trees with more directories than that should
.dropbear/ignoretheir vendor / build dirs (good practice regardless). The daemon does not pre-check the limit; an exhaustion error at startup logs and exits73. - A watcher open failure aborts startup. Same all-or-nothing semantics as state-open failures: every already-opened handle is closed and the daemon exits without entering any tick loop.
What this slice does NOT do
These are explicit non-goals for v0.3 slices 2–3; later slices add them:
- No per-event scoped scans — a wakeup still triggers a full
upload.Runwalk. Scoped scans are real work for a future slice. - No hot reload of
.dropbear/ignore; restart to pick up changes. - No per-root override of
watcher_debounce,head_poll_interval, orhead_poll_max_interval; all three are process-wide. - No persistent storage of last-seen remote head ETags across daemon restarts. A restart causes one no-op tick per root as the poller re-primes its map.
- No bucket-side event notifications. Support is uneven across S3-compatible backends (great on AWS/MinIO, partial on Ceph/SeaweedFS/R2/B2, absent on DigitalOcean Spaces/Wasabi/Storj). Client-side polling is the portable choice; per-backend event adapters are a future optimisation.
- (Earlier non-goal: "no systemd / launchd unit files" — shipped in slice 5; see Installing as a user service below.)
- No reload-without-restart;
SIGHUPis unmapped. - No auto-discovery beyond the config file or positional args.
- Windows is untested. fsnotify itself supports Windows, and these slices use no Unix-specific syscalls; other dropbear subsystems have not been validated there. Reports welcome.
Installing as a user service
dropbear daemon install writes the platform-appropriate user-level service file, optionally drops a starter daemon.toml, and activates the service. It is opt-in — no other command lays down a service file.
Environment for the daemon
systemd and launchd do not inherit your interactive shell environment, so the daemon needs S3 credentials handed to it explicitly. dropbear daemon install reads them from a single canonical file:
The file is mandatory — install refuses to write a service file without it (exit 64). It must satisfy:
- Mode
0600(group/other bits clear). - Define non-empty values for both
DROPBEAR_S3_ACCESS_KEYandDROPBEAR_S3_SECRET_KEY.
The pre-flight parser accepts both KEY=VALUE and export KEY=VALUE, strips a matched pair of single or double quotes around values, ignores blank lines and # comments. Recommended shape:
DROPBEAR_S3_ACCESS_KEY=AKIA...
DROPBEAR_S3_SECRET_KEY=...
The rendered service file loads this file at start: systemd uses EnvironmentFile=; launchd wraps the binary in sh -c 'set -a; . <env-file>; set +a; exec <binary> daemon', which exports every assignment into the daemon's environment. The same env-file format works on both platforms.
uninstall does not touch the env file. install never writes to it either — populating credentials is on you.
Linux (systemd user unit)
dropbear daemon install
Writes ~/.config/systemd/user/dropbear.service, then runs systemctl --user enable --now dropbear.service. The unit runs as the invoking user, restarts on failure (Restart=on-failure, RestartSec=5s), and inherits stdout/stderr into the user journal.
Inspect with:
systemctl --user status dropbear
journalctl --user -u dropbear -f
macOS (LaunchAgent)
dropbear daemon install
Writes ~/Library/LaunchAgents/net.tfks.dropbear.plist, creates ~/Library/Logs/dropbear/, and runs launchctl bootstrap gui/$UID …. KeepAlive = {SuccessfulExit = false} restarts the daemon on crash but not after intentional shutdown.
Inspect with:
launchctl print gui/$UID/net.tfks.dropbear
tail -F ~/Library/Logs/dropbear/out.log ~/Library/Logs/dropbear/err.log
--no-enable
dropbear daemon install --no-enable
Writes the service file but skips activation. The activation command is printed to stdout for the user to run by hand. Useful for inspecting the file before letting systemd/launchd touch it.
Starter daemon.toml
If os.UserConfigDir()/dropbear/daemon.toml does not exist, install writes a commented-out starter showing every knob at its default plus an example [[roots]] block. An existing daemon.toml is never modified — install is safe to rerun after a binary upgrade.
Upgrades
Replace the binary and rerun dropbear daemon install. The new os.Executable() path is rendered into the unit file. systemd / launchd pick up the change on next start.
Uninstall
dropbear daemon uninstall
Stops and removes the user-level service. Idempotent — re-running on an already-uninstalled host exits 0. Does NOT remove daemon.toml, the runtime file, the state SQLite, or ~/Library/Logs/dropbear/.
Exit codes
0— success (including idempotent uninstall of missing file).64— validation/usage error (unsupported platform, empty paths).65— write failure.66— activation or deactivation failure. The unit/plist file is left on disk so you can inspect and rerun.
Known failure modes
installexits64with "env file … not found". Create the env file at the canonical path with mode 0600 and populate both required keys; rerun.installexits64with "mode … is too permissive".chmod 600 <path>and rerun.installexits64with "missing required key". Add the namedDROPBEAR_S3_*key to the env file.installexits64with "value for … is empty". The key is present but its value is empty after stripping quotes/whitespace. Fill it in.- Service fails to start after upgrade. The unit file references the binary's old path. Rerun
dropbear daemon installto refresh. installsucceeds but a second daemon refuses to start. A foregrounddropbear daemonis already running and holds the status-API runtime file. Stop the foreground process first.- Windows. Not supported by this slice;
installexits64.
Self-bootstrapping roots
After dropbear daemon install, the recommended path to a working sync is to extend daemon.toml with a [roots.init] block per root and restart the service. The daemon will create the root directory if absent, initialise its identity, and start syncing — no out-of-band dropbear init invocation required.
[[roots]]
path = "~/Dropbear"
[roots.init]
root_id = "dropbear-personal"
device_id = "$DROPBEAR_DEVICE_ID"
mode = "bidirectional"
[roots.init.remote]
bucket = "my-bucket"
endpoint = "https://abc.r2.cloudflarestorage.com"
region = "auto"
Every string field — including path — runs through tilde + $VAR/${VAR} expansion at parse time. An unset env var that leaves a field empty is rejected with exit 64; nothing fails silently. The device_id env-var pattern is the load-bearing reason this works: a daemon.toml you share via dotfiles stays portable across machines because the per-host identifier comes from the environment, not the file.
root.toml is authoritative once it exists
[roots.init] is consulted only when the root directory has no root.toml yet. The moment root.toml is on disk — whether written by the bootstrap, by a previous dropbear init, or shipped in via backup — the daemon ignores [roots.init] entirely. There is no reconcile loop, no diff, no warning. If you need to change a device_id or remote endpoint on an already-bootstrapped root, edit root.toml directly (or rerun dropbear init --force from the CLI); changing [roots.init] after the fact has no effect.
What the bootstrap does
On daemon startup, for each [[roots]] entry whose root.toml is absent and which carries a [roots.init] block:
MkdirAll(path, 0755)— creates the root directory if needed.- Validates the resolved
[roots.init].remoteblock viaremoteconfig.Parse(same validation pathdropbear inituses). - Writes
root.tomland the default.dropbear/ignore(config.Initcodepath — bit-for-bit identical to a hand-rundropbear init). -
Emits a single
slog.Warnrecord:{ "time": "…", "level": "WARN", "msg": "bootstrapped root", "root": "/Users/e/Dropbear", "root_id": "dropbear-personal", "device_id": "laptop-erik", "mode": "bidirectional" }WARN (not INFO) so the record surfaces even in production deployments that suppress info-level chatter. Bootstrap is rare and mutates on-disk state; the record gives you one verifiable trace per host per root.
Failure modes
All-or-nothing: bootstrap failure for any root aborts startup before any per-root tick loop starts. Partial bootstraps are not a state the daemon can be in.