SPB Git

Repository Public

deployment.md 15.7 KB

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

bash
python3 make-manifest.py [--node M5M36] [--port 8620] [--domain claude.spboucher.ai] \
        [--agentd-node M4M64a] [--agentd-url URL] > ~/dispatch/apps/claude-remote.json

What it does:

  • Secrets. Generates CR_RUNTIME_TOKEN and CR_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_sync hook (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 and pgcrypto; writes .env (0600); pnpm install --frozen-lockfile; pnpm -r --workspace-concurrency=1 build; checks that the three build outputs exist; runs node apps/server/dist/migrate.js.
  • Health. health_path /api/v1/health; tunnel.domain for 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)

  1. ssh M5M36 'cat ~/claude-remote-data/setup-token'
  2. Open https://claude.spboucher.ai/setup/ and create the administrator (password ≥ 12 characters).
  3. Enrol a passkey and/or TOTP (mandatory).
  4. Create a project, start a Claude session, run /login inside it.
  5. Copy ~/.config/claude-remote/backup.pass from 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.

bash
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 node

Effects 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.sh is a manual gate.
  • A full redeploy from scratch through mld on 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 mld sub-command names above are those of the cluster's orchestrator as documented for the cluster; this repository contains only the manifest generator, not mld itself.

# 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

  1. DNS first. Create the record A codex.spboucher.ai → 51.161.112.61 (the gateway BHS64).

  2. Codex CLI on the node. /opt/homebrew/bin/codex must exist on M5M36 (the manifest points at that path). The other tools are those of the Claude instance.

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

    bash
    python3 make-manifest.py --agent codex [--node M5M36] [--port 8630] [--domain codex.spboucher.ai] \
            [--agentd-node M4M64a] [--agentd-url URL] > ~/dispatch/apps/codex-remote.json

    CR_AGENTD_URL and CR_EXEC_HOSTS are not copied from the Claude manifest: pass --agentd-url, and set CR_EXEC_HOSTS in the manifest if remote task hosts are wanted, before deploying.

  4. Role and database. Nothing to do by hand on a first deployment: because a new database password was generated, the manifest's post_sync hook contains the commands that create the role codex_remote, the database codex_remote and pgcrypto, and they run on the node during mld deploy. As for Claude Remote, a manifest regenerated from an existing one reuses DATABASE_URL and 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).

  5. 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/health on the node) → mld tunnel route codex-remote → public verification (https://codex.spboucher.ai/api/v1/health) → mld adopt codex-remote.

  6. First-run steps (owner).

    1. ssh M5M36 'cat ~/codex-remote-data/setup-token'
    2. 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 on claude.spboucher.ai do not apply (different WebAuthn RP ID).
    3. Enrol a passkey and/or TOTP (mandatory).
    4. Create a project, open a shell session of the application and run codex login in it.
    5. Copy ~/.config/codex-remote/backup.pass from 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 adopt treats a second application directory cloned from the same repository (claude-remote.git) has not been tried.
  • codex login from a remote terminal has not been exercised on the node.
  • Removing the instance: mld retire codex-remote; data stays in ~/codex-remote-data, the codex_remote database and ~/backups/codex-remote. Not rehearsed.
Files main
Menu

Theme