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 #
- Every account on every PDS in the PDS list. The scheduler enumerates
each enabled PDS via
com.atproto.sync.listReposand treats every returned DID as a target for that cycle. - 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 underrepos/{did}/{iso-timestamp}_{rev}.car. - Skipped when the PDS reports the same
revas 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_backuprow is kept withpruned_atstamped. This preserves a durable record of what was captured, even after the file is gone.
Blobs #
- Listed via
com.atproto.sync.listBlobsand fetched viacom.atproto.sync.getBlob, saved underblobs/{did}/{cid}. - CIDs are content-addressed, so each (did, cid) pair is only ever fetched
once. The diff against
blob_backupon 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 underblobs/{did}/from storage, - hard-deletes the corresponding
repo_backup,blob_backup,tracked_account, andaccountrows, - inserts the DID into
blocked_accountso future scheduler ticks ignore it even when it still appears in a configured PDS'slistReposresponse.
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:sqliteand drizzle-orm (schema insrc/lib/database/schema.ts, migrations indrizzle/) - ATProto:
@atproto/identityfor DID/handle resolution,@atproto/apifor typed XRPC calls. Rawfetchis used forgetRepo/getBlobto 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 asafekeepersystem 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:
- Create
src/lib/storage/<backend>.tsexporting a class that implementsStorage. - Add the backend name to the
STORAGE_BACKENDzod enum insrc/lib/env.ts. - Add the new case in the
storage()factory insrc/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.