Backup and recovery
Source: deploy/backup.sh, deploy/restore.sh, deploy/make-manifest.py.
Status on 2026-11-10: the first nightly-format backup was produced on M5M36 (about 20 KB, the database being almost empty) and a restore drill into a scratch database succeeded (schema restored, row counts printed). Backups are not yet copied off the node.
What is backed up
deploy/backup.sh runs as the PM2 process claude-remote-backup every night at 03:35 (and can be run by
hand). It writes ~/backups/claude-remote/claude-remote-YYYYmmdd-HHMMSS.tar.gz.enc (mode 0600) and keeps
the 14 most recent archives (KEEP).
| In the archive | Content |
|---|---|
db.dump |
pg_dump --format=custom --no-owner --no-privileges of the claude_remote database: administrator account (password hash, sealed TOTP secret, passkey public keys), auth sessions (token hashes), projects, terminal session metadata and lifecycle events, tasks and artifact metadata, cluster inventory and metrics, notifications, audit trail, settings, migration history |
task-meta.tar |
tasks/<id>/meta.json of every task (not the logs) |
claude-config.tar |
~/.claude/settings.json, ~/.claude/skills, ~/.claude/commands, ~/.claude/agents (those that exist) |
created_at |
UTC timestamp |
Encryption: openssl enc -aes-256-cbc -pbkdf2 -iter 200000 -salt with the passphrase file
~/.config/claude-remote/backup.pass (0600), created with 48 random bytes on the first run.
Without that file a backup cannot be restored.
What is NOT backed up
| Item | Where it lives | How it is recovered |
|---|---|---|
Project files (~/workspace) |
node disk | from their git remotes; uncommitted or unpushed work is lost with the node |
Application secrets (CR_SECRET_KEY, CR_RUNTIME_TOKEN, database password, agentd token) |
the mld manifest on the gateway (~/dispatch/apps/claude-remote.json) and .env on the node |
from the gateway manifest — make-manifest.py reuses them |
| Backup passphrase | ~/.config/claude-remote/backup.pass on the node |
only from the copy the owner made |
| Claude Code credentials | macOS Keychain / Claude Code's own files | /login again |
Claude conversation transcripts (~/.claude/projects) |
node disk | not recoverable |
MCP server configuration and API keys (~/.claude.json) |
node disk | re-enter through the MCP page |
Global ~/.claude/CLAUDE.md, plugins |
node disk | rewrite / reinstall |
| Node SSH key | ~/.ssh on the node |
generate a new one, re-authorise |
| Task logs, command files | ~/claude-remote-data/tasks |
not recoverable |
| Running processes, terminal history | tmux memory | not recoverable (see session-persistence.md) |
| Setup token, hook token, sockets | data directory | regenerated |
Restore
deploy/restore.sh <archive.tar.gz.enc> <target DATABASE_URL> [--with-claude-config]- Decrypts with
$PASS_FILE(default~/.config/claude-remote/backup.pass), prints the archive date. pg_restore --no-owner --no-privileges --exit-on-errorinto the target, which must exist and be empty.- Prints the row counts of
users,projects,terminal_sessions,audit_events. --with-claude-configadditionally extractsclaude-config.tarinto$HOME(overwrites the listed files).- It does not restore
task-meta.tar; extract it by hand into$CR_DATA_DIRif task metadata on disk matters.
Restore drill (do this periodically; it does not touch production)
ssh M5M36
cd ~/apps/claude-remote
createdb cr_restore_drill
deploy/restore.sh "$(ls -1t ~/backups/claude-remote/*.enc | head -1)" postgres://localhost/cr_restore_drill
dropdb cr_restore_drillExpected output: archive created at: …, four name=count lines, restore ok.
Recommended and not yet done
- Copy the archives to another machine. They currently stay on M5M36, next to the database they
protect. For example a nightly
rsync -a ~/backups/claude-remote/ <other-node>:backups/claude-remote/towards a node the M5M36 key can reach. Not configured. - Keep the passphrase somewhere else (owner step 5 of the first-run list), and not next to the off-node copies.
- Protect the gateway manifest: it is the only other copy of
CR_SECRET_KEY.
Recovery scenarios
| Scenario | Procedure |
|---|---|
| A process crashed | PM2 restarts it; mld heal restarts missing processes within 5 minutes. Nothing to restore |
| Host rebooted | Processes come back; live sessions become RECOVERABLE, running tasks LOST. Restart sessions from the UI |
| Bad deployment | on the node: git checkout <good commit>, rebuild, restart (deployment.md). Migrations are forward-only: if a migration must be undone, restore the database from the previous night |
| Database corrupted or data deleted by mistake | stop claude-remote-server; create an empty database; deploy/restore.sh <archive> <url>; point DATABASE_URL at it (or drop and recreate claude_remote and restore into it); start the server. Changes since the backup are lost |
| All second factors lost | see "Break-glass" below |
| Node lost | disaster-recovery runbook below |
Disaster recovery runbook: M5M36 lost → new node
Prerequisites that must exist outside M5M36: a backup archive, the backup passphrase, the gateway manifest. If the archive or the passphrase is missing, the database cannot be recovered: deploy fresh, run the first-run setup again, and re-create projects by cloning.
- Choose and prepare the node (criteria in node-selection.md): always-on Mac
Studio, tunnel peer, Node, pnpm, PM2. Install
tmux,ripgrep,postgresql@17(started as a Homebrew service) and the Claude Code CLI (npm install -g @anthropic-ai/claude-code). - Regenerate the manifest on the gateway for the new node, keeping the secrets:
python3 make-manifest.py --node <NEW> > ~/dispatch/apps/claude-remote.json.new, review, then replace the manifest. The generator reusesCR_SECRET_KEY,CR_RUNTIME_TOKEN,DATABASE_URLandCR_EXEC_HOSTSfrom the existing file. - Create the database by hand on the new node. Because
DATABASE_URLis reused, the manifest'spost_synchook does not contain the role/database creation commands (they are emitted only when a new password is generated). Create the roleclaude_remotewith the password from the manifest'sDATABASE_URL, the databaseclaude_remoteowned by it, andcreate extension pgcrypto. - Deploy the code from the git server (
claude-remote.git) throughmldwith the new manifest (mld deploy claude-remote --node <NEW>), then adopt the directory again. The build and the migrations run on the node; the server starts with an empty schema. - Restore the database. Copy the archive and the passphrase file to the node, then:
pm2 stop claude-remote-server claude-remote-runtime;dropdb claude_remote && createdb -O claude_remote claude_remote(pluspgcrypto);PASS_FILE=<path> deploy/restore.sh <archive> "$DATABASE_URL" --with-claude-config;pm2 start claude-remote-runtime claude-remote-server. The restoredschema_migrationstable makes the next migration run a no-op unless newer migrations exist. With an administrator in the database, the server deletes the setup token at start. - Public route.
mld deployrepointshttps://claude.spboucher.aito the new node; DNS does not change. Checkhttps://claude.spboucher.ai/api/v1/health. - Log in. Password and passkeys work as before (the WebAuthn RP ID is the unchanged hostname). TOTP
works only if
CR_SECRET_KEYis the original one. - Re-establish what is not in the backup: run
/loginin a Claude session; generate a new SSH key on the node, authorise it on the git server (git-shell only) and on theCR_EXEC_HOSTSnodes, and remove the lost node's key from thoseauthorized_keys; re-enter MCP servers and their API keys; rewrite the sanitized globalCLAUDE.md. The runtime re-installs its hook entries by itself. - Projects. Project rows are restored but their directories are not. Re-clone each repository into
the same relative path under
~/workspace(the project table stores root label + relative path). - Sessions and tasks. Sessions that were live at backup time become
RECOVERABLE; because the conversation transcripts were not backed up, restarting them with--resumeis not expected to find the conversation — close them and start new sessions. Tasks that were running becomeLOST. - Re-enable backups (the PM2 process is part of the manifest), restore the passphrase file at
~/.config/claude-remote/backup.passso that old and new archives share one passphrase, run a backup and a restore drill. - Revoke all browser sessions from Settings → Security if the lost node's disk may be read by someone else, and change the password.
Break-glass: all second factors lost
There is no recovery code and no reset endpoint. The login logic sends an account with no second factor to enrolment after a correct password, so the manual procedure on the node is:
-- psql "$DATABASE_URL" (password must still be known)
UPDATE users SET totp_secret_enc = NULL, totp_last_counter = NULL;
DELETE FROM devices;
UPDATE auth_sessions SET revoked_at = now() WHERE revoked_at IS NULL;then log in with the password and enrol a new factor immediately. Anyone with shell access to the node account can do this, which is consistent with the trust model in security.md. A lost password has no supported procedure other than restoring a backup or re-running setup on an empty database.
Limitations / not verified
- The disaster-recovery runbook has not been rehearsed on a second node. Steps 2–5 follow from reading
make-manifest.pyandrestore.sh; step 4's exactmldinvocation depends on the orchestrator. - The break-glass SQL is derived from the login code path and was not exercised.
- Only the restore drill into a scratch database was actually performed (once, with an almost empty database).
- AES-256-CBC provides confidentiality but no integrity check; a corrupted archive is detected only when
tarorpg_restorefails. - There is no monitoring of backup success: a failing nightly run is visible only in
pm2 logs claude-remote-backupand by the absence of a new file. - Recovery point objective is up to 24 hours for metadata; project files have no backup beyond git.
Codex Remote instance
The second instance (see deployment.md, "Codex Remote instance") is backed up by the same
two scripts, selected by the environment variable CR_APP_NAME (default claude-remote). Every name
derives from it; CR_AGENT (claude | codex) selects which agent configuration is saved and, when
unset, is inferred (codex-remote ⇒ codex, anything else ⇒ claude). With neither variable set the
scripts behave exactly as described above.
| Claude Remote (default) | Codex Remote (CR_APP_NAME=codex-remote) |
|
|---|---|---|
| PM2 process, schedule | claude-remote-backup, 03:35 |
codex-remote-backup, 03:50 |
| Archives | ~/backups/claude-remote/claude-remote-*.tar.gz.enc |
~/backups/codex-remote/codex-remote-*.tar.gz.enc |
| Passphrase | ~/.config/claude-remote/backup.pass |
~/.config/codex-remote/backup.pass (a different passphrase; copy it too) |
| Database dumped | claude_remote |
codex_remote (whatever DATABASE_URL designates) |
| Task metadata | ~/claude-remote-data/tasks/*/meta.json |
~/codex-remote-data/tasks/*/meta.json |
| Agent configuration member | claude-config.tar |
codex-config.tar |
| Secrets (not in the archive) | ~/dispatch/apps/claude-remote.json on the gateway |
~/dispatch/apps/codex-remote.json on the gateway |
The manifest sets CR_APP_NAME=codex-remote in the environment of codex-remote-backup. Retention
(KEEP, 14) applies per instance: each run prunes only the archives carrying its own prefix.
What is and is not backed up (Codex)
codex-config.tar holds, relative to the Codex home ($CODEX_HOME if set, otherwise ~/.codex), an
explicit allow-list — each item only if it exists:
config.tomlAGENTS.mdprompts/
Never in the archive:
| Item | Where it lives | How it is recovered |
|---|---|---|
Codex credentials (auth.json) |
Codex home | codex login again |
| Codex session rollouts, history, sqlite state, logs | Codex home | not recoverable |
auth.json, *.sqlite*, *.db, *.jsonl and *.log are additionally excluded by name, so such a file
placed inside prompts/ is not archived either. config.toml is saved as it is: if API keys were written
into it (for example in an MCP server's env table), they are in the archive — encrypted, like the rest.
The other rows of "What is NOT backed up" above apply with the Codex names (~/workspace is shared by the
two instances and is in neither backup).
Restore (Codex)
CR_APP_NAME=codex-remote deploy/restore.sh <archive.tar.gz.enc> <target DATABASE_URL> [--with-codex-config]PASS_FILEdefaults to~/.config/codex-remote/backup.pass.--with-codex-configextractscodex-config.tarinto the Codex home ($CODEX_HOMEor~/.codex, created if missing), overwritingconfig.toml,AGENTS.mdand the files ofprompts/. A restoredconfig.tomlcarries thenotifyentry it had at backup time.--with-agent-configis accepted by both instances and means the configuration of that instance's agent. Passing the other agent's option (--with-claude-configto the Codex instance or the reverse) is refused before anything is restored.
Restore drill, as above with the Codex names:
ssh M5M36
cd ~/apps/codex-remote
createdb cxr_restore_drill
CR_APP_NAME=codex-remote deploy/restore.sh "$(ls -1t ~/backups/codex-remote/*.enc | head -1)" postgres://localhost/cxr_restore_drill
dropdb cxr_restore_drillThe recovery scenarios and the disaster-recovery runbook apply to Codex Remote with the names substituted
(make-manifest.py --agent codex, role and database codex_remote, codex-remote-* processes,
CR_APP_NAME=codex-remote … restore.sh … --with-codex-config, codex login instead of /login).
Limitations / not verified (Codex)
- Tested on a development machine only, with a temporary
HOME, a stubpg_dump/pg_restore/psqland a fake~/.codex: the archive containedconfig.toml,AGENTS.md,prompts/and notauth.json; the restore put them back. No backup or restore of a realcodex_remotedatabase has been performed.