Dormouse

Run the Dormouse server behind Tailscale

Everything Dormouse needs for phone notifications and remote control runs on hardware you own. This is the runbook.

You do not have to follow this by hand. Clone the repository, start Claude Code in it, and say read @SELF_HOST.md and walk me through it. It will run the checkpoints below with you, one at a time.

Installs the Dormouse coordinating server on the user's own laptop — or, to outlive its sleep, on an always-on tailnet box ("Keeping the relay up while the laptop sleeps") — reachable only from their tailnet at https://<laptop>.<tailnet>.ts.net. That is the whole self-host story today. One idempotent installer per platform:

OSInstallerServiceInstall root
macOSdeploy/local/install-macos.shLaunchAgent sh.dormouse.server~/Library/Application Support/Dormouse Server
Windowsdeploy/local/install-windows.ps1Scheduled Task \Dormouse Server%LOCALAPPDATA%\Dormouse Server
Linuxdeploy/local/install-linux.shsystemd user unit dormouse-server.service~/.local/share/dormouse-server

Pick the column that applies before the first command and stay on it — mixing them is the main way this runbook goes wrong. Each checkpoint that differs gives all three forms; the Mechanism map has the rest.

This runbook covers running the installer and finishing what it cannot — the passkey, the Host build, the backup — with no code for anyone to write or edit.

Prerequisites

  • A tailnet with MagicDNS and HTTPS certificates enabled, Tailscale running on this laptop and on the phone that will run Pocket. A tailnet-only origin is not reachable merely because the laptop is on the tailnet.

  • macOS, Windows or Linux. Each installer refuses the others. On a fourth OS, or Linux without systemd, design the native service manager with the user rather than translating LaunchAgent, Scheduled Task or unit-file commands blindly.

  • No installer runs privileged — root on macOS and Linux, elevated on Windows, all three refuse. Use an ordinary terminal (Invariants → "The install belongs to one user account").

  • On Linux, this account must be allowed to operate tailscaled — the local API socket is root-owned, so an unprivileged tailscale serve is refused. Preflight checks before the build and prints the fix, but never runs sudo; this is the only step of a Linux install needing root:

  sudo tailscale set --operator=$USER
  • On Linux, decide the availability shape before installing. The default is per-login like macOS and Windows: up from login to logout. A machine reached over SSH, or serving with nobody logged in, needs --linger. Switching later is loginctl enable-linger $USER / disable-linger, not a reinstall.

  • On Windows, one signed-in user at a time owns Tailscale. tailscaled serves its local API to a single interactive session, so a second signed-in profile fails every tailscale call with 401 Unauthorized: Tailscale already in use by <user>; that user must sign out or quit the tray app. Preflight detects it and names the account.

  • A Host build that can reach a *.ts.net origin. The shipped standalone and VS Code Hosts bake in the SaaS-only relay allowlist, so a self-host relay needs a local build of whichever Host the user runs:

  DORMOUSE_REMOTE_CONNECT_SRC='https://*.ts.net wss://*.ts.net' pnpm dogfood:standalone
  DORMOUSE_REMOTE_CONNECT_SRC='https://*.ts.net wss://*.ts.net' pnpm dogfood:vscode

Both bake it into their Node Host bundles, and the relay socket is in neither webview, so no webview CSP change widens the allowlist (docs/specs/server.md → "Where a Host may reach a relay server").

What the installer does

It builds the exact current checkout into a self-contained release, registers a per-login user agent restarted on exit (Mechanism map) running the Node server on 127.0.0.1:3100, and points tailscale serve --bg at it to terminate private HTTPS. Only under the current user's profile, with no administrator rights:

<install root>/
  bin/
    run-server            (run-server.ps1 on Windows)
    manage                (manage.ps1 + manage.cmd on Windows)
  config/
    server.env
  current    -> releases/<release-id>     (current.txt naming it, on Windows)
  previous   -> releases/<release-id>     (previous.txt, on Windows)
  releases/
    <release-id>/
      runtime/node        (runtime\node.exe on Windows)
      server/
      lib/dist-pocket/
      RELEASE
  run/
    enroll-offer.json
    server.json
  state/
    account.json
    hosts.json
    push-subscriptions.json
    vapid.json

Logs: ~/Library/Logs/Dormouse Server/ on macOS, <install root>\logs on Windows, ~/.local/state/dormouse-server/logs on Linux. Service definition: ~/Library/LaunchAgents/sh.dormouse.server.plist, the Scheduled Task \Dormouse Server, or ~/.config/systemd/user/dormouse-server.service.

Before the first Host enrollment, run/enroll-offer.json holds the origin and a token that POST /api/host/enroll accepts in place of the setup password, and a Dormouse Host on this machine offers one-click enrollment from it (checkpoint 4, step 2). It expires after 24 hours; either credential path's first Host enrollment removes it, and no later run recreates it.

No installer will ever: run git pull, fetch, or switch branches; install a scheduled updater; ask for elevation; install or re-authenticate Tailscale; rewrite an origin that no longer matches the node's DNS name; or touch config/ and state/, which survive every update, prune, and uninstall.

Two Invariants set day-to-day expectations. An update is a short intentional restart: Host and Pocket WebSockets disconnect and reconnect. A per-login agent is unavailable while the laptop sleeps, is shut down, or has no logged-in user — normally fine, since there is then no local Dormouse Host to control either. Windows' at-logon LogonType=Interactive is what keeps the task free of a stored password; Linux alone opts out, with --linger (Prerequisites).

Definition of done

manage verify checks all of these locally and exits nonzero on any failure:

  • The service is registered and running, declares the run-at-load and restart-on-exit of the Mechanism map, and carries no credential — a definition it cannot read at all fails rather than passes. Plus what only the live system shows: macOS, loaded in gui/$UID with a plist that lints; Windows, task Running, no execution time limit, restarts on failure, unelevated, unstopped by battery or idle, bin\run-server.ps1 still carrying the supervision loop; Linux, unit known to the user manager, enabled, passing systemd-analyze --user verify.

  • Loopback /api/hello responds, the Pocket app is served, and the process holding the port belongs to the current release (Invariants → "A 200 does not say who answered"); Linux additionally requires systemctl --user is-active.

  • Port 3100 is bound only to 127.0.0.1, and the plaintext port is unreachable on the laptop's Tailscale IP.

  • tailscale serve proxies / to 127.0.0.1:3100 at the origin recorded in config/server.env, and tailscale funnel is off. A Funnel check that could not run fails too — only its exit status separates "off" from "unknown".

  • config/, state/, run/ and config/server.env are readable only by the installing user, by the per-platform means in the Mechanism map and Invariants. run/enroll-offer.json is held to the same standard while it is there; a spent offer is gone, and verify says so rather than failing.

  • The current release pointer resolves to a release with RELEASE metadata, and neither the service definition nor the run-server wrapper refers to the source checkout. The previous-release pointer is checked too: absent on a first install warns, naming the same release as current fails.

These cannot be proven from the laptop, and are the checkpoints below: the HTTPS origin answering from a second tailnet device and stopping when that device leaves the tailnet; the service manager restarting the server after a real kill; state surviving a reinstall from a newer checkout, with rollback returning the previous release; Pocket passkey setup and Host enrollment completing against this origin, and one command typed from the phone coming back with the laptop's own output; and the install root backed up somewhere off this laptop.

Checkpoint 1: preflight

The installer preflights and stops with a specific error, so do not re-run these by hand: OS and unprivileged session; the Tailscale CLI (on PATH, in the macOS app bundle invoked with TAILSCALE_BE_CLI=1, or under Program Files\Tailscale on Windows); backend state Running; the node's MagicDNS name and derived origin; tailnet HTTPS certificates; an origin disagreeing with an existing installation; the Git SHA and dirty status (it asks before installing a dirty worktree); and the Node and pnpm versions pinned in root package.json. Windows also names the account holding tailscaled's local API; Linux also checks a reachable systemd user manager, systemd 240 or newer, and that this account may operate tailscaled — that one before the build, since the refusal would otherwise surface only at Serve, after current had moved.

Establish with the user what the script cannot:

  • This checkout is the one they want installed. Show git status --short, the branch, and the SHA. Never pull or switch branches on their behalf; the installer installs exactly what is checked out.

  • Their phone runs Tailscale and is signed in to the same tailnet.

  • Port 3100 is free. Unchecked by the installer, and a stale process there would let the post-install health check pass against the wrong server (pnpm dev:server and pnpm dev:pocket-server use 3000):

  # macOS
  lsof -nP -iTCP:3100 -sTCP:LISTEN
  # Windows
  Get-NetTCPConnection -State Listen -LocalPort 3100 -ErrorAction SilentlyContinue
  # Linux
  ss -lntp 'sport = :3100'

Checkpoint 2: install

With the user's approval:

# macOS
./deploy/local/install-macos.sh
# Windows, from an ordinary (not elevated) PowerShell
.\deploy\local\install-windows.ps1
# Linux, as the ordinary user who will own the install (no sudo).
# Add --linger only if the service must outlive logout.
./deploy/local/install-linux.sh

It prints each step; read that with the user rather than summarizing. Its confirmations — a dirty worktree, a mismatched pnpm, repointing an already-claimed Serve root path — are decisions, and it refuses to assume an answer with no terminal. Tailscale may open a browser consent flow the first time Serve requests a certificate; that one is the user's to click. A first install ends by pointing at manage show-password; do not run that yet.

Checkpoint 3: verify

# macOS
"$HOME/Library/Application Support/Dormouse Server/bin/manage" verify
# Windows
& "$env:LOCALAPPDATA\Dormouse Server\bin\manage.cmd" verify
# Linux — the installer prints the exact path; this is the default when
# XDG_DATA_HOME is unset.
"$HOME/.local/share/dormouse-server/bin/manage" verify

Expect every check to pass and the command to exit 0. manage status gives the same picture without the pass/fail framing.

Then, from another tailnet-connected device: request https://<laptop>.<tailnet>.ts.net/api/hello, open the Pocket application at the same origin, then temporarily leave Tailscale on that device and confirm the origin becomes unreachable.

Kill the server process once and confirm the service manager restarts it:

# macOS — launchd restarts within a second or two
pkill -f 'Dormouse Server/current/server/dist/index.js'
"$HOME/Library/Application Support/Dormouse Server/bin/manage" status
# Windows — run-server.ps1's supervision loop restarts after its 10s throttle,
# so wait ~15s before reading status.
Get-CimInstance Win32_Process -Filter "Name='node.exe'" |
  Where-Object { $_.CommandLine -like '*Dormouse Server*' } |
  ForEach-Object { Stop-Process -Id $_.ProcessId -Force }
& "$env:LOCALAPPDATA\Dormouse Server\bin\manage.cmd" status
# Linux — Restart=always with RestartSec=10, so wait ~15s before reading status.
systemctl --user kill --signal=SIGKILL dormouse-server.service
"$HOME/.local/share/dormouse-server/bin/manage" status

On Linux, also prove the availability shape you chose. Without --linger: log out fully, confirm the service is gone (loginctl shows no session and the origin stops answering), then log back in and confirm it returns on its own. With --linger: it keeps answering across a logout, and loginctl show-user $USER -p Linger reports yes.

Restart the laptop only with the user's approval; otherwise say plainly that the run-at-load trigger and registered service were verified but the reboot test skipped. After a real login or reboot, confirm the process and the background Serve mapping both return without rerunning the installer.

Checkpoint 4: first-run setup

The server has no account, no passkey, and no enrolled Host. Same sequence as docs/specs/server.md → "Running it", run against the tailnet origin, with the installer's password.

The Host comes first: a passkey is registered only off a code an enrolled Host displays (docs/specs/server.md → Setup tokens and the pairing QR).

  1. The setup password. Needed only if the step-2 offer card is gone or the Host is elsewhere: have the user run manage show-password in their own terminal, which warns before printing. Never ask for the value, and never print it into the conversation.

  2. The Host. On this same machine, launch the standalone or VS Code build made with DORMOUSE_REMOTE_CONNECT_SRC (Prerequisites) and open Settings → Remote control — the sliders icon at the far right of the baseboard. While the offer is unspent, its card enrolls in one click with no setup password; the typed form behind "Enroll with a different server…" covers a server elsewhere or a spent offer (docs/specs/server.md, "Remote control, in the Settings dialog"). Enrollment persists in the Host service's own store (SECURITY.md → "Credentials at rest"), so later launches connect on their own; the section then shows the server, the relay connection and the paired-device count.

A build without the *.ts.net allowlist refuses outright, before any credential leaves the machine, and both card and form render that refusal verbatim: the expected symptom of a stock build, not a server problem.

  1. The phone, and only then the code. On the phone, open https://<laptop>.<tailnet>.ts.net in Safari and confirm it leads with Scan a setup code. For push, add Pocket to the Home Screen and pair inside the installed app (docs/specs/pocket-app.md → Installable web app owns why, and covers the phone's camera). A setup code is live for five minutes, so that first load — bundle, service worker, Home Screen install — must not happen inside the window. With the phone waiting on that screen, press Set up a phone in Settings → Remote control; scanning or pasting the code creates the passkey and signs them in, bound to this exact origin, with no password typed on the phone.

  2. A real session. The scan runs straight into pairing: read the two digits off the phone, type them into the modal on the laptop, and approve — the last thing anyone does. The phone answers its own biometric prompt and lands on the machine's terminal. (Connect, on the Hosts row, is for later sessions.) Only now have HTTPS proxying, the WebSocket upgrade, and the security flow been exercised together.

  3. State. Confirm account.json, hosts.json and vapid.json — plus push-subscriptions.json if push was enabled — now exist in state/. Record ownership and checksums without printing contents; checkpoint 5 checks them against a reinstall.

Checkpoint 5: updating, rollback, uninstall

Updating is choosing a checkout and rerunning the same command:

git -C <checkout> log --oneline -1     # decide deliberately what to install
./deploy/local/install-macos.sh        # or .\deploy\local\install-windows.ps1
                                       # or ./deploy/local/install-linux.sh

Prove it once, while the user is watching:

  1. Rerun the installer from the same or a newer checkout.

  2. Confirm the release changed as expected and that the state/ checksums from checkpoint 4 and config/server.env are unchanged.

  3. Run manage rollback, confirm the previous release comes back healthy, then return to the desired release.

manage uninstall removes the service definition and installed code, keeps config and state and reports where they are, and keeps manage itself — which is what makes step 3 possible. manage purge is the separate, irreversible deletion, behind a typed confirmation phrase and never part of a reinstall. Run them in that order; purge finishes by printing the single command that clears whatever is left: the install root, plus the log directory on Linux and macOS, where it sits outside that root.

Checkpoint 6: limits and backup

Make these explicit: the relay is down while the laptop sleeps, is shut down, has Tailscale disconnected, or is logged out; the installer does not follow main, so updates happen only when the user reruns it; the HTTPS origin is tied to the laptop's Tailscale node name, so renaming or re-enrolling that node means redoing the passkey and every Host enrollment, and the installer stops rather than rewriting the origin; and Tailscale network policy still controls which tailnet members reach the laptop — review existing grants if the tailnet has other users.

Confirm the install root, especially config and state, is covered by an encrypted backup off the laptop — Time Machine, File History, Déjà Dup/restic/borg. Check the coverage rather than assuming it: %LOCALAPPDATA% is excluded from File History's default library set and from OneDrive's Known Folder Move, and ~/.local/share from dotfile-oriented backup rules, so on both the install root is very likely unprotected until added explicitly. A second directory on the same disk is not a backup; these files hold Host bearer credentials and a VAPID private key. Rehearse a small restore without overwriting live state.

Official references

Troubleshooting boundaries

None of the three service managers runs the user's interactive shell or PowerShell startup files, so a PATH that works in a terminal proves nothing about any of them.

  • The service works only while the source checkout exists: an installer bug — the release must be self-contained — not a reason to keep the checkout around. manage verify checks it directly.

  • The service loops or will not start: macOS — plutil -lint the plist, launchctl print gui/$UID/sh.dormouse.server, and ~/Library/Logs/Dormouse Server. Windows — Get-ScheduledTaskInfo -TaskName 'Dormouse Server' for LastTaskResult, Export-ScheduledTask -TaskName 'Dormouse Server' for the definition, and <install root>\logs, where run-server.ps1 timestamps each start and exit into server.err.log (a crash loop is a run of those lines). Linux — systemctl --user status dormouse-server.service, journalctl --user -u dormouse-server.service -n 50, and ~/.local/state/dormouse-server/logs.

  • The task shows Ready rather than Running after a reboot: the at-logon LogonType=Interactive trigger fires on interactive sign-in, not at boot — the LaunchAgent's per-login limitation, not a fault.

  • Every tailscale call returns 401 Unauthorized: Tailscale already in use by <user> (Windows): another signed-in profile owns tailscaled's local API (Prerequisites); quser lists the sessions, and elevating does not bypass it.

  • install-windows.ps1 refuses because the session is elevated: use an ordinary PowerShell (Prerequisites).

  • systemctl --user fails with DBUS_SESSION_BUS_ADDRESS (Linux): no user manager for this uid, usually a shell reached with su. Use machinectl shell $USER@ or a fresh SSH session; preflight refuses rather than installing a unit nothing will start.

  • tailscale serve is refused for a non-root user (Linux): grant the operator role (Prerequisites). Preflight checks it before building, so a late hit means the check regressed — and since the release is already installed and running, finish with manage serve rather than reinstalling.

  • The service disappears at logout (Linux): the documented per-login default, not a fault — --linger is the opt-out (Prerequisites); make the availability change explicit in checkpoint 6 if you take it.

  • /api/hello answers but the unit is not active (Linux): something else holds port 3100 and the install correctly refuses to claim it. ss -lntp 'sport = :3100' names that process — unless it cannot see it, as under WSL with networkingMode=mirrored, where the listener may be a Windows process (a Windows Dormouse Server install does exactly this). Stop it, or install on a host not sharing loopback.

  • The HTTPS URL returns 502: check the loopback health endpoint first, then tailscale serve status; service and Serve configuration have separate lifecycles, and manage serve re-applies a mapping a dev session repointed.

  • Port 3100 is visible on the LAN or the Tailscale IP: stop. Confirm DORMOUSE_BIND_HOST=127.0.0.1 in config/server.env. Tailscale access control is not a reason to expose the plaintext backend.

  • The installer stops on an origin mismatch: it is refusing to invalidate the registered passkey and every enrolled Host. Establish whether the node was renamed or re-enrolled, then restore the old name or plan the re-enrollment.

  • Pocket loads but passkey setup fails: compare the browser URL byte-for-byte with DORMOUSE_ORIGIN in config/server.env; confirm HTTPS and the node hostname.

  • A Host cannot connect while Pocket can: that Host build almost certainly lacks the *.ts.net DORMOUSE_REMOTE_CONNECT_SRC setting.

  • State disappears: verify the absolute state path for this platform's install root and the installed config. Never initialize a new account until the old state is located or restored.

Keeping the relay up while the laptop sleeps

A per-login agent is down whenever its machine is — fine until the user controls a Host that is not this laptop.

That needs no new machinery: the phone reaches the origin and the Host dials out to it, so the relay need not run on the laptop. Run the Linux installer with --linger on any always-on tailnet machine — a spare box, a NUC, a small VM — and that node's own MagicDNS name becomes the origin:

./deploy/local/install-linux.sh --linger

Lingering is what makes it survive logout and come back at boot (Prerequisites); manage verify reports which mode is live. Two things follow, both the same ones any origin change brings: DORMOUSE_ORIGIN becomes that machine's name, so the passkey and every Host enrollment are redone against it — a deliberate migration, not an upgrade path, which is why the installers refuse to rewrite an origin; and the Host still needs a build whose baked allowlist admits *.ts.net (Prerequisites). That machine needs the same backup story as any other install (checkpoint 6): config/ and state/ hold Host bearer credentials and a VAPID private key.

A managed cloud deployment would buy a stable origin independent of any one machine's name — the one thing the above does not give — and belongs with the multi-tenant work in docs/specs/server.md ## Future, not with a single-user install.