# Self-hosted exit and data preservation

Source: https://codeherder.com/docs/self-host-exit/

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](https://codeherder.com/docs/self-host-departure/).

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.

1. List the workspaces: `ch workspace list`.
2. 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](https://codeherder.com/docs/self-host-incident-containment/).
3. Export one workspace: `ch workspace export <workspace> --out <slug>.zip`. Expect `Wrote workspace export to <slug>.zip`.
4. 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](https://codeherder.com/docs/self-host-departure/#what-a-device-keeps).
- **Agent launch configurations.**
- **Attachment objects, when `CH_S3_BUCKET` is 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`.

1. Read the counts from the export: `unzip -p < slu g >.zip manifest.json | jq -r '.sections[] | "\(.table)\t\(.rows)"' | sort > export-counts.tsv`
2. 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'));
```

1. Run it against the database, with the workspace id: `psql -v ws= < workspace-i d > -At -F $'\t' -f counts.sql | sort > db-counts.tsv`
2. Compare the two files: `diff export-counts.tsv db-counts.tsv`. Expect no output.
3. List each attachment the export left out: `unzip -p <slug>.zip manifest.json | jq '[.attachments[] | select(.included | not)]'`. Expect `[]` when the bucket is on.
4. 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-i d > -At unzip -p < slu g >.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

1. Take a readable copy: `pg_dump --format=plain --file=codeherder-<date>.sql <connection>`.
2. Take a restorable copy: `pg_dump --format=custom --file=codeherder-<date>.dump <connection>`.
3. Optional: write one table to CSV with `psql -c "\copy tasks TO 'tasks.csv' CSV HEADER"`.
4. Keep the four at-rest keys and the KMS key for as long as any copy needs its sealed values. See [backup and recovery](https://codeherder.com/docs/self-host-backups/).

## Keep a readable copy of the attachments bucket

1. Set the bucket prefix: `P="s3:"; B="$P//$CH_S3_BUCKET"`.
2. Copy uploaded files: `aws s3 sync $B/attachments/ ./attachments/`.
3. Copy embedded images: `aws s3 sync $B/images/ ./images/`.
4. Copy the revocation journal: `aws s3 sync $B/revocation-journal/ ./revocation-journal/`. Skip this if you set `CH_REVOCATION_JOURNAL_DIR`; copy that directory instead.
5. Count the objects: `find attachments images -type f | wc -l`. Compare with the sum of the `included` counts 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.
