Self-hosted exit and data preservation
Export every workspace, check the export against your database, and keep a readable copy of the database and the attachments bucket.
Use this page when you leave CodeHerder or archive a workspace. You export each workspace. You prove the export is complete. You keep a readable copy of the database and the attachments bucket. Then follow the departure checklist.
You already hold the database and the attachments bucket on a self-hosted install. The export is a second, portable copy. Keep both.
Export each workspace
A group owns no work. Export each leaf workspace. A human admin or owner runs the export. A plain member is refused.
- List the workspaces:
ch workspace list. - Stop work. The export is not a point-in-time snapshot. Run
ch instance spawn-pause freeze --root <workspace> --reason exit --yes. See incident containment. - Export one workspace:
ch workspace export <workspace> --out <slug>.zip. ExpectWrote workspace export to <slug>.zip. - Repeat for each workspace. A caller can run two exports in a row, then one every 10 minutes.
Each export writes one workspace.exported event. If the server cannot write the event, it fails the export and sends nothing.
What the export holds
The archive has one NDJSON file per table, one JSON object per line. It holds these classes:
- The workspace and its settings.
- Tasks, with fields, version history, outcomes, blocker notes, stage attempts and verification results.
- Task comments, messages, and session and sandbox metadata.
- Wiki pages, their revisions and links.
- The attachments list, and each attachment object that is ready, when object storage is on.
- Events, cost events and daily spend.
- Members, human records and invitations.
- Workflows and stages.
- Variable names, with scope and dates.
manifest.json is the last file. It lists every file with its row count, every omitted column, and every attachment object.
What the export leaves out
- Variable values. The archive holds the names of variables. It holds no value, secret or plain.
- Session transcripts. Transcripts stay on the device. See the departure checklist.
- Agent launch configurations.
- Attachment objects, when
CH_S3_BUCKETis not set. A row for an upload that never finished has no object.
You must note each secret value yourself before you destroy the KMS key. The export cannot recover one.
Check the export is complete
The check compares the row count of each table in the manifest with a count taken from the database. Run it for each workspace. You need unzip, jq and psql.
-
Read the counts from the export:
unzip -p <slug>.zip manifest.json | jq -r '.sections[] | "\(.table)\t\(.rows)"' | sort > export-counts.tsv -
Save this query as
counts.sql. It holds one count for each table in the export.
SELECT 'workspaces' AS table_name, count(*) FROM workspaces WHERE id = :'ws'
UNION ALL
SELECT 'workspace_settings' AS table_name, count(*) FROM workspace_settings WHERE workspace_id = :'ws'
UNION ALL
SELECT 'tasks' AS table_name, count(*) FROM tasks WHERE workspace_id = :'ws'
UNION ALL
SELECT 'task_definition_versions' AS table_name, count(*) FROM task_definition_versions WHERE workspace_id = :'ws'
UNION ALL
SELECT 'task_outcomes' AS table_name, count(*) FROM task_outcomes WHERE workspace_id = :'ws'
UNION ALL
SELECT 'task_blocker_notes' AS table_name, count(*) FROM task_blocker_notes WHERE workspace_id = :'ws'
UNION ALL
SELECT 'task_origins' AS table_name, count(*) FROM task_origins WHERE workspace_id = :'ws'
UNION ALL
SELECT 'task_schedules' AS table_name, count(*) FROM task_schedules WHERE workspace_id = :'ws'
UNION ALL
SELECT 'stage_attempts' AS table_name, count(*) FROM stage_attempts WHERE workspace_id = :'ws'
UNION ALL
SELECT 'stage_attempt_outcomes' AS table_name, count(*) FROM stage_attempt_outcomes WHERE workspace_id = :'ws'
UNION ALL
SELECT 'task_fields' AS table_name, count(*) FROM task_fields WHERE task_id IN (SELECT id FROM tasks WHERE workspace_id = :'ws')
UNION ALL
SELECT 'task_field_versions' AS table_name, count(*) FROM task_field_versions WHERE task_id IN (SELECT id FROM tasks WHERE workspace_id = :'ws')
UNION ALL
SELECT 'task_field_edit_history' AS table_name, count(*) FROM task_field_edit_history WHERE task_id IN (SELECT id FROM tasks WHERE workspace_id = :'ws')
UNION ALL
SELECT 'task_dependencies' AS table_name, count(*) FROM task_dependencies WHERE task_id IN (SELECT id FROM tasks WHERE workspace_id = :'ws')
UNION ALL
SELECT 'task_repos' AS table_name, count(*) FROM task_repos WHERE task_id IN (SELECT id FROM tasks WHERE workspace_id = :'ws')
UNION ALL
SELECT 'task_watchers' AS table_name, count(*) FROM task_watchers WHERE task_id IN (SELECT id FROM tasks WHERE workspace_id = :'ws')
UNION ALL
SELECT 'task_stage_overrides' AS table_name, count(*) FROM task_stage_overrides WHERE task_id IN (SELECT id FROM tasks WHERE workspace_id = :'ws')
UNION ALL
SELECT 'task_verification_results' AS table_name, count(*) FROM task_verification_results WHERE task_id IN (SELECT id FROM tasks WHERE workspace_id = :'ws')
UNION ALL
SELECT 'task_merge_refs' AS table_name, count(*) FROM task_merge_refs WHERE task_id IN (SELECT id FROM tasks WHERE workspace_id = :'ws')
UNION ALL
SELECT 'task_comments' AS table_name, count(*) FROM task_comments WHERE workspace_id = :'ws'
UNION ALL
SELECT 'messages' AS table_name, count(*) FROM messages WHERE workspace_id = :'ws'
UNION ALL
SELECT 'sessions' AS table_name, count(*) FROM sessions WHERE workspace_id = :'ws'
UNION ALL
SELECT 'sandboxes' AS table_name, count(*) FROM sandboxes WHERE workspace_id = :'ws'
UNION ALL
SELECT 'workspace_memories' AS table_name, count(*) FROM workspace_memories WHERE workspace_id = :'ws'
UNION ALL
SELECT 'workspace_memory_revisions' AS table_name, count(*) FROM workspace_memory_revisions WHERE workspace_id = :'ws'
UNION ALL
SELECT 'workspace_memory_links' AS table_name, count(*) FROM workspace_memory_links WHERE workspace_id = :'ws'
UNION ALL
SELECT 'attachments' AS table_name, count(*) FROM attachments WHERE workspace_id = :'ws'
UNION ALL
SELECT 'events' AS table_name, count(*) FROM events WHERE workspace_id = :'ws'
UNION ALL
SELECT 'cost_events' AS table_name, count(*) FROM cost_events WHERE workspace_id = :'ws'
UNION ALL
SELECT 'cost_spend_workspace_day' AS table_name, count(*) FROM cost_spend_workspace_day WHERE workspace_id = :'ws'
UNION ALL
SELECT 'workspace_memberships' AS table_name, count(*) FROM workspace_memberships WHERE workspace_id = :'ws'
UNION ALL
SELECT 'members' AS table_name, count(*) FROM members WHERE id IN (SELECT member_id FROM workspace_memberships WHERE workspace_id = :'ws') OR workspace_id = :'ws'
UNION ALL
SELECT 'humans' AS table_name, count(*) FROM humans WHERE member_id IN (SELECT member_id FROM workspace_memberships WHERE workspace_id = :'ws')
UNION ALL
SELECT 'workspace_invitations' AS table_name, count(*) FROM workspace_invitations WHERE workspace_id = :'ws'
UNION ALL
SELECT 'workspace_task_types' AS table_name, count(*) FROM workspace_task_types WHERE workspace_id = :'ws'
UNION ALL
SELECT 'workspace_task_type_versions' AS table_name, count(*) FROM workspace_task_type_versions WHERE workspace_id = :'ws'
UNION ALL
SELECT 'workspace_task_type_disables' AS table_name, count(*) FROM workspace_task_type_disables WHERE workspace_id = :'ws'
UNION ALL
SELECT 'workspace_task_type_relation_additions' AS table_name, count(*) FROM workspace_task_type_relation_additions WHERE workspace_id = :'ws'
UNION ALL
SELECT 'workspace_stages' AS table_name, count(*) FROM workspace_stages WHERE workspace_id = :'ws'
UNION ALL
SELECT 'workspace_stage_versions' AS table_name, count(*) FROM workspace_stage_versions WHERE workspace_id = :'ws'
UNION ALL
SELECT 'variables' AS table_name, count(*) FROM variables WHERE (scope_kind = 'workspace' AND scope_id = :'ws') OR (scope_kind = 'agent' AND scope_id IN (SELECT id FROM members WHERE workspace_id = :'ws'));
-
Run it against the database, with the workspace id:
psql -v ws=<workspace-id> -At -F$'\t' -f counts.sql | sort > db-counts.tsv -
Compare the two files:
diff export-counts.tsv db-counts.tsv. Expect no output. -
List each attachment the export left out:
unzip -p <slug>.zip manifest.json | jq '[.attachments[] | select(.included | not)]'. Expect[]when the bucket is on. -
Count the ready attachments in the database. Expect the same number as the attachment objects in the archive:
echo "SELECT count(*) FROM attachments WHERE workspace_id = :'ws' AND status = 'ready';" | psql -v ws=<workspace-id> -At unzip -p <slug>.zip manifest.json | jq '[.attachments[] | select(.included)] | length'
A line in diff means a table lost or gained rows after the export began. Stop work, export again and re-run the check. Do not destroy anything until the diff is empty.
Keep a readable copy of the database
- Take a readable copy:
pg_dump --format=plain --file=codeherder-<date>.sql <connection>. - Take a restorable copy:
pg_dump --format=custom --file=codeherder-<date>.dump <connection>. - Optional: write one table to CSV with
psql -c "\copy tasks TO 'tasks.csv' CSV HEADER". - Keep the four at-rest keys and the KMS key for as long as any copy needs its sealed values. See backup and recovery.
Keep a readable copy of the attachments bucket
- Set the bucket prefix:
P="s3:"; B="$P//$CH_S3_BUCKET". - Copy uploaded files:
aws s3 sync $B/attachments/ ./attachments/. - Copy embedded images:
aws s3 sync $B/images/ ./images/. - Copy the revocation journal:
aws s3 sync $B/revocation-journal/ ./revocation-journal/. Skip this if you setCH_REVOCATION_JOURNAL_DIR; copy that directory instead. - Count the objects:
find attachments images -type f | wc -l. Compare with the sum of theincludedcounts from each manifest, and with your database.
Rehearsal record
CodeHerder ran this procedure once on a scratch install before release.
| Item | Result |
|---|---|
| Date | 2026-10-01 |
| Server build | A pre-release build of the current export code |
| Install | Throwaway postgres:18, local server, fake S3 for the bucket |
| Seeded workspace | 2 tasks, 2 comments, 1 task title edit, 1 wiki page, 2 attachment rows (1 ready, 1 pending), 2 variables (1 sealed, 1 plain), 1 invited member |
| Export | ch workspace export wrote a zip of 43 files |
| Row counts | 41 tables in the manifest and 41 in the database |
diff export-counts.tsv db-counts.tsv |
No output |
| Attachments left out | []. The pending row has no object and is not in the manifest |
| Ready attachments | 1 in the database, 1 in the archive. The bytes matched the upload |
| Variable values in the archive | None. Both rows held names only |
Two stand-ins differed from a live install. The scratch install had no KMS, so the sealed variable was a row written by SQL. The test host had no unzip, so a small wrapper read the zip. Neither changes what the check compares.
Last updated