# safekeeper Periodic backups of ATProto repos and blobs, driven by either a PDS allowlist or an explicit per-DID tracking list. Ships with a server-side-rendered dashboard so you can see what's been archived, how much disk it takes, and what the scheduler is up to. ## What it backs up 1. **Every account on every PDS in the PDS list.** The scheduler enumerates each enabled PDS via `com.atproto.sync.listRepos` and treats every returned DID as a target for that cycle. 2. **Every DID in the tracked-accounts list** — these are followed regardless of where they live. Use this to keep archiving someone after they migrate off a PDS in (1). A DID that appears in both sets is marked `discovery_source='both'` and only backed up once per cycle. ### Repo snapshots - Fetched via `com.atproto.sync.getRepo`, saved as a CAR file under `repos/{did}/{iso-timestamp}_{rev}.car`. - Skipped when the PDS reports the same `rev` as the last archived snapshot (no point duplicating a byte-identical repo). - Retention: keep every non-pruned snapshot from the last 7 days; for older snapshots, keep two per ISO week (earliest + latest in each bucket). - **Soft prune**: the CAR file is deleted, but the `repo_backup` row is kept with `pruned_at` stamped. This preserves a durable record of what was captured, even after the file is gone. ### Blobs - Listed via `com.atproto.sync.listBlobs` and fetched via `com.atproto.sync.getBlob`, saved under `blobs/{did}/{cid}`. - CIDs are content-addressed, so each (did, cid) pair is only ever fetched once. The diff against `blob_backup` on each cycle is a simple set subtraction. - Blobs are never deleted by safekeeper — once archived, they stay. ### Wiping an account Admins can permanently delete every artifact safekeeper has stored for a specific DID via the "wipe" button on each row of `/accounts`. The confirmation page requires typing the full DID (not the handle) to prevent accidents — a slip-of-the-mouse plus a wrong-row selection can't complete a wipe. A wipe: - deletes every CAR file under `repos/{did}/` and every blob under `blobs/{did}/` from storage, - hard-deletes the corresponding `repo_backup`, `blob_backup`, `tracked_account`, and `account` rows, - inserts the DID into `blocked_account` so future scheduler ticks ignore it even when it still appears in a configured PDS's `listRepos` response. To unblock (undo the block half — not the deletion), delete the row from `blocked_account` directly via SQLite. ### Frozen accounts If `com.atproto.sync.getRepoStatus` returns an account as `deactivated`, `deleted`, `suspended`, `takendown`, `desynchronized`, or `throttled`, safekeeper freezes the archive in place for that account: - No new repo snapshots are taken. - No blobs are fetched. - The retention pruner does **not** run for that account. The archive is preserved as-is until the account becomes `active` again. ## Tech stack - **Runtime**: Bun - **HTTP**: Elysia + `@elysiajs/html` (fully SSR, no client JS framework) - **DB**: SQLite via `bun:sqlite` and drizzle-orm (schema in `src/lib/database/schema.ts`, migrations in `drizzle/`) - **ATProto**: `@atproto/identity` for DID/handle resolution, `@atproto/api` for typed XRPC calls. Raw `fetch` is used for `getRepo` / `getBlob` to stream straight into storage. - **Styling**: Tailwind CSS v4, pre-compiled to `public/tailwind.css` ## Quick start ```sh bun install bun run db:migration:apply bun run dev ``` The dashboard is at http://localhost:3000. For production: ```sh bun run start ``` ## Proxmox LXC install `scripts/proxmox/` has two scripts in the [community-scripts.org](https://community-scripts.org) style, for personal use: - `safekeeper.sh` — runs on the Proxmox host. Creates a Debian 13 unprivileged LXC, packages this checkout as a tarball, pushes it into the CT, and runs the in-container installer. - `safekeeper-install.sh` — runs inside a Debian 12/13 LXC. Installs Bun, creates a `safekeeper` system user, deploys the source to `/opt/safekeeper`, writes `.env.local`, runs migrations, and wires up a systemd unit. Usage from the PVE host: ```sh git clone https://tangled.org/dummy.cafe/safekeeper cd safekeeper bash scripts/proxmox/safekeeper.sh ``` Every meaningful knob is an environment variable — `CTID`, `HOSTNAME`, `STORAGE`, `RAM`, `DISK`, `CORES`, `BRIDGE`, `NET`, `ADMIN_PASSWORD`, `SAFEKEEPER_PORT`, etc. See the header comment at the top of each script for the full list. The in-container script also stands alone — run it inside any existing Debian 12/13 container with `SOURCE_TAR=…` or `REPO_URL=…` set. ## Configuration Bun reads `.env` then `.env.local`, with the latter overriding the former. `.env` is committed to the repo and documents every key with a sensible default. Put real values (PDSes aside — those go in the DB) and secrets in `.env.local`, which is gitignored. | Key | Default | Notes | | --- | --- | --- | | `PORT` | `3000` | HTTP port for the panel. | | `STORAGE_BACKEND` | `local` | Storage backend. Only `local` is implemented today. | | `STORAGE_LOCAL_ROOT` | `./data/storage` | Where CARs and blobs go. | | `DATABASE_PATH` | `./data/safekeeper.db` | SQLite file for the control plane. | | `BACKUP_INTERVAL_MS` | `3600000` (1h) | How often the scheduler wakes up and considers work. | | `ADMIN_PASSWORD` | (empty) | Basic-auth password for the admin actions. See below. | ### Auth The panel is **publicly readable** by default — anyone can see the dashboard, the PDS list, tracked accounts, and per-account stats. Mutating actions (add/remove a PDS, add/remove a tracked account, trigger a backup) are protected by HTTP basic auth with `admin` as the username and `ADMIN_PASSWORD` as the password. If `ADMIN_PASSWORD` is empty, the auth gate is a no-op — fine for localhost-only deployments, unsafe otherwise. ## Project layout ``` src/ main.tsx Elysia entrypoint, error handler, route wiring error.ts Known error classes plugin/ auth.ts requireAdmin guard + isAuthed helper controller/ BackupController.ts POST /trigger-backup view/ layout/root.tsx Shared HTML layout, fmtBytes / fmtDate helpers page/ DashboardPage.tsx GET / — stats + recent runs PdsPage.tsx /pdses — PDS allowlist CRUD TrackedPage.tsx /tracked — per-DID tracking list AccountsPage.tsx /accounts — everyone we've seen lib/ env.ts t3-env schema over process.env database/ index.ts drizzle + bun:sqlite instance schema.ts tables storage/ index.ts Storage interface + factory local.ts LocalStorage implementation atproto/ client.ts Identity + XRPC wrappers backup/ targets.ts Build the per-cycle target set runner.ts One full cycle across all targets retention.ts Soft-prune per the retention policy scheduler.ts setInterval wrapper with mutex drizzle/ Generated SQL migrations public/tailwind.css Generated ``` ## Adding a new storage backend Every backup artifact is written through `Storage` in `src/lib/storage/index.ts`. To add S3 / WebDAV / whatever: 1. Create `src/lib/storage/.ts` exporting a class that implements `Storage`. 2. Add the backend name to the `STORAGE_BACKEND` zod enum in `src/lib/env.ts`. 3. Add the new case in the `storage()` factory in `src/lib/storage/index.ts`. No caller outside `src/lib/storage/` needs to change — everything goes through the interface (put/get/exists/delete/stat/list/sizeOf/usage). ## Scripts | Script | What it does | | --- | --- | | `bun run dev` | Rebuild Tailwind in watch mode + hot-reload the server. | | `bun run start` | Build Tailwind once, run the server. | | `bun run check` | Build Tailwind, run prettier, typecheck. | | `bun run db:push` | Push the schema to the DB without a migration. Dev only. | | `bun run db:migration:generate` | Generate a new SQL migration from the schema. | | `bun run db:migration:apply` | Apply pending migrations (`drizzle-kit migrate`). | | `bun run db:studio` | Launch Drizzle Studio for poking at the DB. | ## License MIT — see [LICENSE](./LICENSE). ## Credits - [mary-ext/boat](https://github.com/mary-ext/boat) — the one-shot browser archiver that served as the reference for how to pull a repo + blobs. - [atproto.com](https://atproto.com) — spec + lexicon docs.