Periodic backups of ATProto repos and blobs safekeeper.dummy.cafe
atproto backup
README.md

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 #

bun install
bun run db:migration:apply
bun run dev

The dashboard is at http://localhost:3000.

For production:

bun run start

Proxmox LXC install #

scripts/proxmox/ has two scripts in the 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:

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/<backend>.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.

Credits #

  • mary-ext/boat — the one-shot browser archiver that served as the reference for how to pull a repo + blobs.
  • atproto.com — spec + lexicon docs.