Backup and restore
What to copy, and how to put it back. The owner has not chosen a backup schedule or a recovery procedure yet (PROJECT.md, “Open decisions”). PROJECT.md asks for SQLite, the worker’s private key, the host policy and the controller’s backups to be kept as one recovery set.
Verified on dev on 2026-10-05 on a production-style install in a throwaway container (install.md steps 1 to 12): a backup of the running site, then, after an upgrade and the way back to the previous images, a restore of that set, each step as written here. The site came back with the set’s administrators, settings and actions, and without what was changed after the backup; all three services were healthy and Caddy served the site. The parts this did not cover are marked below.
Run the commands from a folder project can enter, such as /. From root’s home, find run as project warns that
it can’t return to the folder it started in.
Placeholders, besides those in Install:
<backup-dir>: where finished sets are kept, outside/opt/ProjectStart; root:root, mode 0700.<staging-dir>: an empty folder outside/opt/ProjectStartthatprojectcan write; project:project, mode 0700. The SQLite copies are made there asproject.<set>: the name of one set, for example the date.
What to keep
Section titled “What to keep”| Path | What it holds | Secret |
|---|---|---|
/opt/ProjectStart/var/db/app.db |
projects, operations, history, administrators, sessions, sealed Git tokens and passwords, agents | yes, together with secret.key |
/opt/ProjectStart/var/db/secret.pub |
public sealing key | no |
/opt/ProjectStart/var/db/files |
one folder per agent: its chat database db/chat.db, kept files files/, shown pages sites/ |
may hold anything admins and agents put in a chat |
/opt/ProjectStart/var/worker/secret.key |
private key that opens every sealed secret in app.db |
yes |
/opt/ProjectStart/var/worker/remote.key |
the worker’s own key for remote hosts’ reverse connections; the hosts know it by its public key, which their install command carried | yes |
/opt/ProjectStart/var/worker/push.key |
the worker’s Web Push key; its public key is the setting push_public_key in app.db, which the browsers subscribed with |
yes |
/opt/ProjectStart/var/spool |
inputs the worker leaves for the controller, including short-lived Git credentials and agent logins | yes |
/opt/ProjectStart/ctl |
project container backups (backups/), journals of unfinished changes (journal/) |
yes: a container backup holds its Git credentials and agent logins |
/opt/ProjectStart/caddy |
project route files | no |
/opt/ProjectStart/etc |
host policy, agents.json, sentry.env |
sentry.env holds a DSN |
/opt/ProjectStart/deploy |
compose.yaml, .env with the image digests |
no |
Not needed: /opt/ProjectStart/var/db/files/uploads (the worker empties it when it starts), /opt/ProjectStart/var/run,
/opt/ProjectStart/var/ctl and /opt/ProjectStart/web (sockets only), /opt/ProjectStart/var/work (Git work).
SQLite files
Section titled “SQLite files”app.db and every chat.db are SQLite databases in WAL mode, written while the services run. Never copy them with
cp while web or worker run. Copy them with sqlite3, as project: run as root, SQLite can leave root-owned -wal
and -shm files that the services can’t write.
Backup
Section titled “Backup”The services keep running. Agent turns and operations go on during the copy, so the files may be a few seconds newer
or older than app.db. For a set taken at one instant, stop web and worker first
(docker compose -f /opt/ProjectStart/deploy/compose.yaml stop web worker); that ends every agent run.
-
Make the set’s folders:
Terminal window install -d -o root -g root -m 0700 <backup-dir>/<set>install -d -o project -g project -m 0700 <staging-dir>/<set> -
Copy
app.db:Terminal window runuser -u project -- sqlite3 /opt/ProjectStart/var/db/app.db "VACUUM INTO '<staging-dir>/<set>/app.db'" -
Copy the agents’ folders, then replace each copied chat database with a consistent copy. The folder
filesexists once the first agent has been made; skip this step on a site without one.Terminal window runuser -u project -- cp -a /opt/ProjectStart/var/db/files <staging-dir>/<set>/filesrunuser -u project -- rm -rf <staging-dir>/<set>/files/uploadsrunuser -u project -- find <staging-dir>/<set>/files -name 'chat.db-*' -type f -deletefor db in /opt/ProjectStart/var/db/files/*/db/chat.db; do[ -f "$db" ] || continueagent=${db#/opt/ProjectStart/var/db/files/}agent=${agent%%/*}runuser -u project -- sqlite3 "$db" ".backup '<staging-dir>/<set>/files/$agent/db/chat.db'"doneNot verified on dev: the test site had no agent (no Claude or Codex sign-in), so no chat database was copied.
-
Copy the keys and the spool:
Terminal window runuser -u project -- cp -p /opt/ProjectStart/var/db/secret.pub <staging-dir>/<set>/secret.pubrunuser -u project -- cp -p /opt/ProjectStart/var/worker/secret.key <staging-dir>/<set>/secret.keyrunuser -u project -- cp -p /opt/ProjectStart/var/worker/remote.key <staging-dir>/<set>/remote.keyrunuser -u project -- cp -p /opt/ProjectStart/var/worker/push.key <staging-dir>/<set>/push.keyrunuser -u project -- cp -a /opt/ProjectStart/var/spool <staging-dir>/<set>/spool -
Move the staged files into the set, and add the root-owned folders:
Terminal window mv <staging-dir>/<set> <backup-dir>/<set>/varcp -a /opt/ProjectStart/ctl <backup-dir>/<set>/ctlcp -a /opt/ProjectStart/caddy <backup-dir>/<set>/caddycp -a /opt/ProjectStart/etc <backup-dir>/<set>/etccp -a /opt/ProjectStart/deploy <backup-dir>/<set>/deploy -
Check each SQLite copy:
Terminal window sqlite3 -readonly <backup-dir>/<set>/var/app.db "PRAGMA integrity_check;"for db in <backup-dir>/<set>/var/files/*/db/chat.db; do [ -f "$db" ] || continue; sqlite3 -readonly "$db" "PRAGMA integrity_check;"; doneEach line must say
ok.
On dev, with the services running and a site of a few records, each step took under a second.
Restore
Section titled “Restore”Restores a whole set. Restore app.db, the agents’ folders, the keys and ctl together: the database names the
container backups in ctl/backups, and its sealed secrets open only with the secret.key of the same set.
The project containers in LXD are not part of a set. A restore puts the records back to the set’s time; the containers stay as they are now.
-
Stop all three services:
Terminal window docker compose -f /opt/ProjectStart/deploy/compose.yaml stop web worker controller -
Put the database and the agents’ folders back, removing the current WAL files first:
Terminal window rm -f /opt/ProjectStart/var/db/app.db-wal /opt/ProjectStart/var/db/app.db-shminstall -o project -g project -m 0660 <backup-dir>/<set>/var/app.db /opt/ProjectStart/var/db/app.dbinstall -o project -g project -m 0640 <backup-dir>/<set>/var/secret.pub /opt/ProjectStart/var/db/secret.pubrm -rf /opt/ProjectStart/var/db/filesWhen the set has a
var/filesfolder (it has none when the site had no agent), put it back:Terminal window cp -a <backup-dir>/<set>/var/files /opt/ProjectStart/var/db/fileschown -R project:project /opt/ProjectStart/var/db/files -
Put the keys and the spool back. The worker refuses a
secret.keythat group or others can read.Terminal window install -o project -g project -m 0600 <backup-dir>/<set>/var/secret.key /opt/ProjectStart/var/worker/secret.keyinstall -o project -g project -m 0600 <backup-dir>/<set>/var/remote.key /opt/ProjectStart/var/worker/remote.keyinstall -o project -g project -m 0600 <backup-dir>/<set>/var/push.key /opt/ProjectStart/var/worker/push.keyrm -rf /opt/ProjectStart/var/spoolcp -a <backup-dir>/<set>/var/spool /opt/ProjectStart/var/spoolchown -R project:project /opt/ProjectStart/var/spool -
Put the root-owned folders back:
Terminal window rm -rf /opt/ProjectStart/ctl /opt/ProjectStart/caddycp -a <backup-dir>/<set>/ctl /opt/ProjectStart/ctlcp -a <backup-dir>/<set>/caddy /opt/ProjectStart/caddyRestore
etcanddeployonly when the host policy or settings were lost; compare first withdiff -r <backup-dir>/<set>/etc /opt/ProjectStart/etcanddiff -r <backup-dir>/<set>/deploy /opt/ProjectStart/deploy. After an upgrade,deployalso holds.env.previous(and.env.rejectedafter the way back); the diff lists them, which is expected. -
Check the folders, then start in the usual order. Give the folder check the
projectaccount’s ids, as at the install; without them it checks production’s ids, 993 and 983, and stops on another host.Terminal window PS_UID="$(id -u project)" PS_GID="$(id -g project)" /usr/local/sbin/projectstart-install-foldersdocker compose -f /opt/ProjectStart/deploy/compose.yaml up -d --waitOn dev,
up -d --waittook 92 seconds. -
The route files in
/opt/ProjectStart/caddyare now those of the set. Reload Caddy so it serves them. Caddy 2.6.2 can stop during a reload; start it again then:Terminal window systemctl reload caddy || systemctl start caddy
Not verified on dev: that a sealed secret (a Git token, a password) opens after a restore. The test set held
none; the worker started on the set’s keys without an error. A restore onto another host, and container backups in
ctl/backups, were not covered either: the restore ran on the same host, and its one project had no backup.