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:

  1. Runs upload.Run against the root's state DB and bucket — the same pipeline dropbear sync invokes.
  2. Emits a structured tick_start record on stdout before the tick begins.
  3. Emits a tick_end record on stdout when the tick finishes (with the full dropbear sync --json envelope nested under a sync attribute), or a tick_skip record 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:

Platform Path
Linux $XDG_CONFIG_HOME/dropbear/daemon.toml (default ~/.config/dropbear/daemon.toml)
macOS ~/Library/Application Support/dropbear/daemon.toml
Windows %AppData%\dropbear\daemon.toml

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_format must be "json" or "text".
  • default_interval and any per-root interval must be ≥ 1s.
  • watcher_debounce must be ≥ 50ms (no "disable watcher" knob in this release).
  • head_poll_interval must be ≥ 1s; head_poll_max_interval must be ≥ head_poll_interval.
  • status_api_addr must resolve to a loopback host (127.0.0.1 or ::1); non-loopback hosts are rejected at startup (exit 64).
  • status_tick_history must be in [10, 10000].
  • Each [[roots]].path is required and must be unique. Relative paths are resolved against the directory containing daemon.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

Code Meaning
0 Normal shutdown
64 Config / usage error (no roots, bad TOML, unknown key)
66 A specified root path is missing or its identity does not load
73 Could not open the state database for a root
130 Second SIGINT/SIGTERM forced in-flight tick cancellation

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:

Field Type Notes
schema_version int Always 1 in this release.
event string One of "tick_start", "tick_end", "tick_skip".
root_id string Stable root_id from the root's root.toml.
tick_index int Zero-based per-root counter.
time string RFC3339 UTC timestamp (slog's default time attribute).
level string slog level (INFO, WARN, ERROR).
msg string Human-readable summary (tick_start, tick_end, tick_skip).

Event-specific fields:

Event Field Notes
tick_end duration_ms int — wall-clock ms
tick_end outcome "ok" or "error"
tick_end error string — present only when outcome="error"
tick_end sync object — nested envelope; identical shape to dropbear sync --json, including schema_version=1, manifest_id, uploaded, downloaded, etc.
tick_skip reason "offline" in this release.
all trigger "startup" (first tick), "watcher" (filesystem-watcher wakeup), "head-poll" (remote head poller wakeup), or "interval" (backstop timer).

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

Method Path Auth Notes
GET /v1/healthz no {"ok": true} — liveness only.
GET /v1/status yes {schema_version, pid, started_at, roots[]}.
GET /v1/roots yes Same roots[] without the envelope.
GET /v1/roots/{rootID} yes Per-root snapshot + most recent recent_ticks.
GET /v1/roots/{rootID}/ticks?limit=K yes Ring buffer slice; K clamped to [1, status_tick_history].
GET / no Embedded one-page web UI.

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/ignore at 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/ignore their vendor / build dirs (good practice regardless). The daemon does not pre-check the limit; an exhaustion error at startup logs and exits 73.
  • 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.Run walk. 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, or head_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; SIGHUP is 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:

Platform Path
Linux ~/.config/dropbear/env
macOS ~/Library/Application Support/dropbear/env

The file is mandatoryinstall 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_KEY and DROPBEAR_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

  • install exits 64 with "env file … not found". Create the env file at the canonical path with mode 0600 and populate both required keys; rerun.
  • install exits 64 with "mode … is too permissive". chmod 600 <path> and rerun.
  • install exits 64 with "missing required key". Add the named DROPBEAR_S3_* key to the env file.
  • install exits 64 with "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 install to refresh.
  • install succeeds but a second daemon refuses to start. A foreground dropbear daemon is already running and holds the status-API runtime file. Stop the foreground process first.
  • Windows. Not supported by this slice; install exits 64.

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:

  1. MkdirAll(path, 0755) — creates the root directory if needed.
  2. Validates the resolved [roots.init].remote block via remoteconfig.Parse (same validation path dropbear init uses).
  3. Writes root.toml and the default .dropbear/ignore (config.Init codepath — bit-for-bit identical to a hand-run dropbear init).
  4. Emits a single slog.Warn record:

    {
      "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

Failure Exit
[roots.init] missing a required field, malformed mode, unparseable endpoint, or any field empty after env expansion 64
MkdirAll fails (permissions, disk full) during bootstrap 65
config.Init rejects the remote (remoteconfig.Parse failure) or fails to write root.toml the diagnostic-derived code from config.ExitCodeFor

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.