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://. That is the whole self-host story today. One idempotent installer per platform:
| OS | Installer | Service | Install root |
|---|---|---|---|
| macOS | deploy/ | LaunchAgent sh. | ~/ |
| Windows | deploy/ | Scheduled Task \Dormouse Server | %LOCALAPPDATA%\Dormouse Server |
| Linux | deploy/ | systemd user unit dormouse- | ~/. |
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 unprivilegedtailscale serveis refused. Preflight checks before the build and prints the fix, but never runssudo; this is the only step of a Linux install needing root:
sudo tailscale set --operator=$USEROn 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
--. Switching later islinger loginctl enable-/linger $USER disable-, not a reinstall.linger On Windows, one signed-in user at a time owns Tailscale.
tailscaledserves its local API to a single interactive session, so a second signed-in profile fails everytailscalecall with401 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
*.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:ts. net
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:vscodeBoth bake it into their Node Host bundles, and the relay socket is in neither webview, so no webview CSP change widens the allowlist (docs/ → "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., and points tailscale serve -- 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.jsonLogs: ~/ on macOS, <install root>\logs on Windows, ~/. on Linux. Service definition: ~/, the Scheduled Task \Dormouse Server, or ~/..
Before the first Host enrollment, run/ holds the origin and a token that POST / 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 -- (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/with a plist that lints; Windows, task$UID Running, no execution time limit, restarts on failure, unelevated, unstopped by battery or idle,bin\run-still carrying the supervision loop; Linux, unit known to the user manager,server. ps1 enabled, passingsystemd-.analyze -- user verify Loopback
/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 requiresapi/ hello systemctl --.user is- active Port 3100 is bound only to
127., and the plaintext port is unreachable on the laptop's Tailscale IP.0. 0. 1 tailscale serveproxies/to127.at the origin recorded in0. 0. 1:3100 config/, andserver. env tailscale funnelis off. A Funnel check that could not run fails too — only its exit status separates "off" from "unknown".config/,state/,run/andconfig/are readable only by the installing user, by the per-platform means in the Mechanism map and Invariants.server. env run/is held to the same standard while it is there; a spent offer is gone, andenroll- offer. json verifysays so rather than failing.The current release pointer resolves to a release with
RELEASEmetadata, and neither the service definition nor therun-wrapper refers to the source checkout. The previous-release pointer is checked too: absent on a first install warns, naming the same release asserver currentfails.
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_, 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.. 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 --, the branch, and the SHA. Never pull or switch branches on their behalf; the installer installs exactly what is checked out.short 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:serverandpnpm dev:pocket-use 3000):server
# 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.shIt 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-; 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" verifyExpect 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://, 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" statusOn Linux, also prove the availability shape you chose. Without --: 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 --: it keeps answering across a logout, and loginctl show- 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/ → "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/ → Setup tokens and the pairing QR).
The setup password. Needed only if the step-2 offer card is gone or the Host is elsewhere: have the user run
manage show-in their own terminal, which warns before printing. Never ask for the value, and never print it into the conversation.password The Host. On this same machine, launch the standalone or VS Code build made with
DORMOUSE_(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 (REMOTE_ CONNECT_ SRC docs/, "Remote control, in the Settings dialog"). Enrollment persists in the Host service's own store (specs/ server. md SECURITY.→ "Credentials at rest"), so later launches connect on their own; the section then shows the server, the relay connection and the paired-device count.md
A build without the *. 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.
The phone, and only then the code. On the phone, open
https://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 (<laptop>. <tailnet>. ts. net docs/→ 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.specs/ pocket- app. md 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.
State. Confirm
account.,json hosts.andjson vapid.— plusjson push-if push was enabled — now exist insubscriptions. json 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.shProve it once, while the user is watching:
Rerun the installer from the same or a newer checkout.
Confirm the release changed as expected and that the
state/checksums from checkpoint 4 andconfig/are unchanged.server. env 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 ~/. 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
Dormouse server runtime and state contract:
docs/specs/ server. md Dormouse trust model:
docs/specs/ remote- security- model. md Host installations:
docs/,specs/ standalone. md docs/specs/ vscode. md
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 verifychecks it directly.The service loops or will not start: macOS —
plutil -the plist,lint launchctl print gui/, and$UID/ sh. dormouse. server ~/. Windows —Library/ Logs/ Dormouse Server Get-forScheduledTaskInfo - TaskName 'Dormouse Server' LastTaskResult,Export-for the definition, andScheduledTask - TaskName 'Dormouse Server' <install root>\logs, whererun-timestamps each start and exit intoserver. ps1 server.(a crash loop is a run of those lines). Linux —err. log systemctl --,user status dormouse- server. service journalctl --, anduser - u dormouse- server. service - n 50 ~/..local/ state/ dormouse- server/ logs The task shows
Readyrather thanRunningafter a reboot: the at-logonLogonType=Interactivetrigger fires on interactive sign-in, not at boot — the LaunchAgent's per-login limitation, not a fault.Every
tailscalecall returns401 Unauthorized: Tailscale already in use by <user>(Windows): another signed-in profile ownstailscaled's local API (Prerequisites);quserlists the sessions, and elevating does not bypass it.install-refuses because the session is elevated: use an ordinary PowerShell (Prerequisites).windows. ps1 systemctl --fails withuser DBUS_(Linux): no user manager for this uid, usually a shell reached withSESSION_ BUS_ ADDRESS su. Usemachinectl shell $USER@or a fresh SSH session; preflight refuses rather than installing a unit nothing will start.tailscale serveis 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 withmanage serverather than reinstalling.The service disappears at logout (Linux): the documented per-login default, not a fault —
--is the opt-out (Prerequisites); make the availability change explicit in checkpoint 6 if you take it.linger /answers but the unit is not active (Linux): something else holds port 3100 and the install correctly refuses to claim it.api/ hello ss -names that process — unless it cannot see it, as under WSL withlntp 'sport = :3100' 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, andmanage servere-applies a mapping a dev session repointed.Port 3100 is visible on the LAN or the Tailscale IP: stop. Confirm
DORMOUSE_inBIND_ HOST=127. 0. 0. 1 config/. Tailscale access control is not a reason to expose the plaintext backend.server. env 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_inORIGIN config/; confirm HTTPS and the node hostname.server. env A Host cannot connect while Pocket can: that Host build almost certainly lacks the
*.ts. net DORMOUSE_setting.REMOTE_ CONNECT_ SRC 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 -- 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 --lingerLingering 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_ 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 *. (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/ ## Future, not with a single-user install.