Deployment
Production: node M5M36 (Mac Studio, M5 Max, 18 CPU cores, 36 GB, macOS 27.0), deployed 2026-11-10 with
the cluster orchestrator mld from the gateway M1M32. The original plan is in
deployment-plan.md; node choice in node-selection.md; network
path in network-architecture.md.
What runs where
| Item | Value |
|---|---|
| Account | the existing cluster operator macOS account (not root, not a dedicated account — see security.md) |
| Code | ~/apps/claude-remote on M5M36 — a git clone, origin = the cluster git server (claude-remote.git). This directory is the source of truth. |
| Data directory | ~/claude-remote-data (0700): run/ (socket, hook token, tmux config), tasks/, setup-token until the administrator exists |
| Workspace root | ~/workspace (label workspace) |
| Backups | ~/backups/claude-remote; passphrase ~/.config/claude-remote/backup.pass |
| Database | PostgreSQL 17 (Homebrew service), loopback, role and database claude_remote, extension pgcrypto |
| Public route | https://claude.spboucher.ai → Caddy on the gateway BHS64 → WireGuard → M5M36:8620 |
| Manifest | ~/dispatch/apps/claude-remote.json on M1M32 (holds the generated secrets; not in git) |
| Tools on the node | Node 26, pnpm 12, PM2 7, tmux 3.8, ripgrep, git, Claude Code CLI 2.1.322 (/opt/homebrew/bin/claude) |
PM2 processes
| Name | Command (cwd ~/apps/claude-remote) |
Notes |
|---|---|---|
claude-remote-runtime |
node apps/runtime/dist/main.js |
autorestart, kill_timeout 8 s, memory restart at 1500 MB. Stopping it never stops tmux sessions |
claude-remote-server |
node apps/server/dist/main.js |
autorestart, memory restart at 1500 MB; listens on 0.0.0.0:8620; applies pending migrations at start |
claude-remote-backup |
/bin/zsh deploy/backup.sh |
cron_restart 35 3 * * *, no autorestart; only DATABASE_URL, CR_DATA_DIR, HOME, PATH in its environment |
mld heal (every 5 minutes, from the gateway) restarts missing PM2 processes and re-checks the tunnel
route. The node logs in automatically and restarts after a power loss (cluster harden policy).
The mld manifest (deploy/make-manifest.py)
Run on the gateway; it prints the manifest JSON to stdout:
python3 make-manifest.py [--node M5M36] [--port 8620] [--domain claude.spboucher.ai] \
[--agentd-node M4M64a] [--agentd-url URL] > ~/dispatch/apps/claude-remote.jsonWhat it does:
- Secrets. Generates
CR_RUNTIME_TOKENandCR_SECRET_KEY(32 random bytes, hex) and a database password, or reuses the values found in the existing manifest (--existing, default~/dispatch/apps/claude-remote.json) so that a redeploy keeps them. The agentd token is reused or read over SSH from the agentd node. Secrets are never printed anywhere else. - Environment. One environment block shared by the runtime and server processes (table below).
- Placement. Pinned to the node (
placement.pin), never the gateway. sync_excludes.node_modules/,.next/,out/,dist/,data/,.env*, test output.post_synchook (runs on the node in the app directory): creates the data, workspace and backup directories (data 0700); on a first deployment creates the role, the database andpgcrypto; writes.env(0600);pnpm install --frozen-lockfile;pnpm -r --workspace-concurrency=1 build; checks that the three build outputs exist; runsnode apps/server/dist/migrate.js.- Health.
health_path/api/v1/health;tunnel.domainfor the public route.
Standard sequence (as executed on 2026-11-10): generate the manifest → mld stage <dir> claude-remote →
mld deploy claude-remote (first without the public route, verified over the LAN) → restore drill →
mld tunnel route claude-remote → public verification → mld adopt claude-remote.
After adoption, mld deploy refuses to overwrite a node directory whose git HEAD differs from the staging
copy or that has uncommitted changes. mld stage was needed only to create the application.
Environment variables
[S] server, [R] runtime, [S+R] both (values must match). Full list with comments: .env.example.
| Variable | Scope | Production value / default | Purpose |
|---|---|---|---|
NODE_ENV |
S | production |
enforces https origin and the required secrets |
CR_PORT, CR_HOST |
S | 8620, 0.0.0.0 (default 127.0.0.1) |
listener |
CR_PUBLIC_ORIGIN |
S | https://claude.spboucher.ai |
exact origin: cookies, Origin checks, WebAuthn RP ID |
CR_TRUSTED_PROXIES |
S | loopback + the gateway's tunnel address | peers whose X-Forwarded-For is honoured |
DATABASE_URL |
S | generated | PostgreSQL connection |
CR_SECRET_KEY |
S | generated (64 hex) | AES-256-GCM key for TOTP secrets — losing it invalidates enrolled TOTP |
CR_WEB_DIR |
S | ~/apps/claude-remote/apps/web/out |
static export |
CR_NODE_ALIAS |
S | M5M36 |
this host's alias in agentd |
CR_AGENTD_URL, CR_AGENTD_TOKEN |
S | optional | cluster telemetry; unset ⇒ host-only view |
CR_LOCAL_MODELS |
S | unset | JSON array of local model endpoints (none exist) |
CR_SKILLS_REGISTRY_URL |
S | https://skills.spboucher.ai |
read-only search adapter |
CR_DATA_DIR |
S+R | ~/claude-remote-data |
persistent state |
CR_WORKSPACE_ROOTS |
S+R | workspace=~/workspace (absolute path) |
comma list label=/abs/path |
CR_RUNTIME_SOCKET |
S+R | $CR_DATA_DIR/run/runtime.sock |
IPC socket (path ≤ 103 bytes) |
CR_RUNTIME_TOKEN |
S+R | generated (≥ 32 chars) | IPC bearer token |
CR_EXEC_HOSTS |
S+R | M3U96a,M3U96b |
node aliases allowed as remote task hosts |
CR_TERM_BUFFER_BYTES |
S+R | 4 MiB | WebSocket backpressure high-water mark |
CR_LOG_LEVEL |
S+R | info |
pino level |
CR_CLAUDE_BIN |
R | /opt/homebrew/bin/claude |
Claude Code CLI |
CR_MAX_SESSIONS |
R | 12 |
live terminal sessions |
CR_TMUX_SOCKET |
R | claude-remote |
tmux -L name |
CR_SHELL |
R | unset ⇒ $SHELL (/bin/zsh) |
login shell for sessions and tasks |
CR_REPLAY_LINES |
R | 5000 (max 20000) | history lines replayed on attach |
CR_INSTALL_CLAUDE_HOOKS |
R | 1 |
merge the cr-hook entries into ~/.claude/settings.json. Never set on a development machine |
CLAUDE_CONFIG_DIR |
R | unset ⇒ ~/.claude |
Claude Code configuration directory |
CR_EXEC_HOSTS is kept from the previous manifest by make-manifest.py (default empty); it was set to
the two compute nodes after their owner-approved SSH authorisation.
First-run steps (owner)
ssh M5M36 'cat ~/claude-remote-data/setup-token'- Open
https://claude.spboucher.ai/setup/and create the administrator (password ≥ 12 characters). - Enrol a passkey and/or TOTP (mandatory).
- Create a project, start a Claude session, run
/logininside it. - Copy
~/.config/claude-remote/backup.passfrom the node to a safe place.
Updating — remote-first, on the node
The node directory is the source of truth. Do not edit a laptop copy and redeploy.
ssh M5M36
cd ~/apps/claude-remote
git status # must be clean before you start
# … edit …
pnpm install --frozen-lockfile # only if dependencies changed
pnpm -r build # the manifest uses: pnpm -r --workspace-concurrency=1 build
set -a && . ./.env && set +a # migrate.js needs DATABASE_URL
node apps/server/dist/migrate.js # idempotent; the server also migrates at start
pm2 restart claude-remote-runtime claude-remote-server
curl -fsS http://127.0.0.1:8620/api/v1/health
git add -A && git commit -m "…" && git push origin main # on the nodeEffects of the restart: browsers lose their WebSockets and re-attach by themselves; terminal sessions and
running tasks are untouched (see session-persistence.md). Restart only what
changed: a web- or server-only change needs claude-remote-server alone.
Before a larger change, run the gate where the toolchain is available: scripts/ci.sh.
Changing configuration: edit the manifest on the gateway (or regenerate it with make-manifest.py, which
keeps the secrets) and redeploy through mld, so that .env and the PM2 environment stay consistent.
Editing .env on the node alone does not change the environment PM2 gives to the two main processes.
Rollback and removal
- Code:
git revert/git checkout <commit>on the node, rebuild, restart. Migrations are forward-only (there are no down migrations). - Remove the service:
mld retire claude-remote(removes the route and the processes). Data stays in~/claude-remote-data, PostgreSQL and~/backups/claude-remote.
Limitations / not verified
- Single node; no staging environment. Tests run on a development machine, not on the node.
- No hosted CI: the git server is a bare-repository host.
scripts/ci.shis a manual gate. - A full redeploy from scratch through
mldon a second node has not been rehearsed (see the disaster recovery runbook in backup-recovery.md). - Moving the application (
mld move) loses live sessions and requires re-authenticating Claude Code on the new node; the manifest's placement note says so. - Claude Code on M5M36 is not authenticated yet and no administrator exists (owner steps above).
- The exact
mldsub-command names above are those of the cluster's orchestrator as documented for the cluster; this repository contains only the manifest generator, notmlditself.
Codex Remote instance (second instance on the same node)
The same code base deployed a second time on M5M36 as Codex Remote (https://codex.spboucher.ai),
driving the OpenAI Codex CLI instead of Claude Code. The two instances are independent: stopping, updating
or restoring one does not touch the other. They share only the code repository, the node's PostgreSQL
server and the workspace root ~/workspace.
| Item | Claude Remote | Codex Remote |
|---|---|---|
| mld application / manifest | claude-remote / ~/dispatch/apps/claude-remote.json |
codex-remote / ~/dispatch/apps/codex-remote.json |
| Code on the node | ~/apps/claude-remote |
~/apps/codex-remote |
| Data directory | ~/claude-remote-data |
~/codex-remote-data |
| Port, public route | 8620, claude.spboucher.ai |
8630, codex.spboucher.ai |
| Database role and database | claude_remote |
codex_remote |
tmux socket (-L) |
claude-remote |
codex-remote |
| PM2 processes | claude-remote-runtime, -server, -backup |
codex-remote-runtime, -server, -backup |
| Backups, passphrase | ~/backups/claude-remote, ~/.config/claude-remote/backup.pass, 03:35 |
~/backups/codex-remote, ~/.config/codex-remote/backup.pass, 03:50 |
| Agent CLI | /opt/homebrew/bin/claude |
/opt/homebrew/bin/codex |
Environment differences, all produced by make-manifest.py --agent codex (everything else in the table
"Environment variables" above applies with the Codex names substituted):
| Variable | Scope | Codex Remote value | Purpose |
|---|---|---|---|
CR_AGENT |
S+R | codex |
which agent CLI this instance drives (default claude) |
CR_AGENT_BIN |
R | /opt/homebrew/bin/codex |
the Codex CLI; replaces CR_CLAUDE_BIN, which is not emitted |
CR_INSTALL_AGENT_HOOKS |
R | 1 |
install the runtime's notify entry in ~/.codex/config.toml; replaces CR_INSTALL_CLAUDE_HOOKS, which is not emitted |
CR_TMUX_SOCKET |
R | codex-remote |
separate tmux server, so the two instances never see each other's sessions |
CR_APP_NAME |
backup process only | codex-remote |
makes deploy/backup.sh use the Codex paths and save the Codex configuration |
The Claude manifest is unchanged by the --agent option: make-manifest.py without it (or with
--agent claude) produces exactly the previous output.
Steps
-
DNS first. Create the record
A codex.spboucher.ai → 51.161.112.61(the gateway BHS64). -
Codex CLI on the node.
/opt/homebrew/bin/codexmust exist on M5M36 (the manifest points at that path). The other tools are those of the Claude instance. -
Generate the manifest on the gateway (same script, same rules as above; a first run generates new secrets, later runs reuse those in
~/dispatch/apps/codex-remote.json):bashpython3 make-manifest.py --agent codex [--node M5M36] [--port 8630] [--domain codex.spboucher.ai] \ [--agentd-node M4M64a] [--agentd-url URL] > ~/dispatch/apps/codex-remote.jsonCR_AGENTD_URLandCR_EXEC_HOSTSare not copied from the Claude manifest: pass--agentd-url, and setCR_EXEC_HOSTSin the manifest if remote task hosts are wanted, before deploying. -
Role and database. Nothing to do by hand on a first deployment: because a new database password was generated, the manifest's
post_synchook contains the commands that create the rolecodex_remote, the databasecodex_remoteandpgcrypto, and they run on the node duringmld deploy. As for Claude Remote, a manifest regenerated from an existing one reusesDATABASE_URLand does not contain them — so do not regenerate between step 3 and the first successful deployment, or create the role and database by hand (see backup-recovery.md, runbook step 3, with the Codex names). -
Stage and deploy, the same sequence as for Claude Remote:
mld stage <dir> codex-remote(a checkout of the same repository) →mld deploy codex-remote --node M5M36(first without the public route, verified over the LAN:curl -fsS http://127.0.0.1:8630/api/v1/healthon the node) →mld tunnel route codex-remote→ public verification (https://codex.spboucher.ai/api/v1/health) →mld adopt codex-remote. -
First-run steps (owner).
ssh M5M36 'cat ~/codex-remote-data/setup-token'- Open
https://codex.spboucher.ai/setup/and create the administrator (password ≥ 12 characters). The administrator, second factors, projects and settings of Codex Remote are its own (separate database); passkeys enrolled onclaude.spboucher.aido not apply (different WebAuthn RP ID). - Enrol a passkey and/or TOTP (mandatory).
- Create a project, open a shell session of the application and run
codex loginin it. - Copy
~/.config/codex-remote/backup.passfrom the node to a safe place.
Codex hook (notify)
With CR_INSTALL_AGENT_HOOKS=1 the runtime installs its notify entry in ~/.codex/config.toml
($CODEX_HOME if set). If that file already contains a different notify, the runtime refuses to
overwrite it and reports the fact in the runtime /health; sessions still work, without the hook-driven
status updates. To resolve it, remove or merge the existing notify by hand and restart
codex-remote-runtime.
Updating
Same remote-first procedure as above, in ~/apps/codex-remote, restarting codex-remote-runtime /
codex-remote-server and checking port 8630. The two directories are two clones of one repository: a
change committed and pushed from one must be pulled, rebuilt and restarted in the other for both instances
to run the same code.
Limitations / not verified (Codex Remote)
- As of 2026-11-10 the Codex instance has not been deployed: the steps above are the Claude sequence
with the Codex names. The generated manifest was checked by reading it, not by running it through
mld. - How
mld adopttreats a second application directory cloned from the same repository (claude-remote.git) has not been tried. codex loginfrom a remote terminal has not been exercised on the node.- Removing the instance:
mld retire codex-remote; data stays in~/codex-remote-data, thecodex_remotedatabase and~/backups/codex-remote. Not rehearsed.