PDS Operator#
Self-hosted admin dashboard for an atproto PDS: account list/search/takedown/enable/password-reset, moderation-labeler flags with Bluesky DM alerts, relay sync status, invite codes, passkey sign-in, and an audit log. Talks to the PDS and relay only over their public HTTPS APIs, no SSH/DB access required.


How it works#
The server keeps a local SQLite copy of everything in server/data.sqlite. A background
syncer does a full pass against the PDS on startup and every 15 minutes. Between passes
it stays subscribed to the PDS firehose and each labeler's label stream, so handle
changes, takedowns, and new labels land within seconds. Stream cursors survive restarts,
and dead sockets are detected and reconnected. Requests to outside services (avatars,
label backfill) are throttled and retried with backoff when rate limited. The SQLite
file is a rebuildable cache. The only data worth backing up is server/audit.log.
Setup#
cd web && npm install
cd ../server && npm install
npm run setup # interactive, writes server/.env
The wizard asks for the PDS, relay, and DM alert config, generates a session secret, and can start the server and mint a passkey enrollment link in one go. It can also generate a fly.toml and print the fly.io deploy commands (optional, any host works). The operator password is optional. Leave it empty for passkey-only sign-in, where enrollment links from the CLI are the only way in:
npm run enroll # prints a single-use link + QR code, expires in 15 minutes
Run it again any time to enroll another device, or as recovery if all passkeys are lost
(shell access is the break-glass). To configure by hand instead, copy .env.example to
server/.env and generate the hash with
node -e "console.log(require('bcryptjs').hashSync('<password>', 10))".
Other PDS implementations#
The reference PDS authenticates admin calls with PDS_ADMIN_PASSWORD over basic auth.
For implementations without an admin password, like
tranquil-pds, set
PDS_ADMIN_IDENTIFIER to the handle of an account with admin rights and put that
account's password in PDS_ADMIN_PASSWORD. The dashboard then signs in as that account
and sends bearer tokens instead. Sign-in is headless, so use an app password or keep 2FA
off that account.
Labelers#
server/labelers.json (gitignored, copy server/labelers.json.example):
[
{
"name": "skywatch blue",
"did": "did:plc:e4elbtctnfqocyfcml6h2lf7",
"labels": ["platform-manipulation", "engagement-abuse"]
}
]
labels is the watchlist of label values that flag an account. Empty or omitted means
every label from that labeler counts. The endpoint is resolved from the labeler's DID
document and label names come from its published definitions. Changing the file requires
a restart.
Ozone quirk: queryLabels silently returns nothing past 20 uriPatterns per request,
so backfill batches are capped at 20.
DM alerts#
Set the four NOTIFY_* / DASHBOARD_URL values in server/.env: sender handle, an app
password created with DM access, recipient handle, and the dashboard URL for deep
links. The recipient has to accept DMs from the sender (follow them, or allow DMs from
everyone). Unset = disabled.
Dev#
cd web && npm run dev # vite dev server, proxies /api to :8787
cd server && npm run dev # fastify API on :8787
Production#
cd web && npm run build # writes web/dist
cd server && npm run build && NODE_ENV=production npm start
The server serves web/dist alongside the API, so only the server process runs in
production.
- Run behind a TLS-terminating reverse proxy. HTTPS is required for passkeys (WebAuthn won't run in a non-secure context), and passkeys are per-hostname, so enroll fresh ones on the production domain.
NODE_ENV=productionenables the secure session cookie, binds to 127.0.0.1 (override withHOST), and refuses weakSESSION_SECRETs (openssl rand -hex 32).trustProxyis on:req.ip(login rate limiting) comes from X-Forwarded-For. Correct behind the proxy, spoofable if you expose the port directly.- Set
DASHBOARD_URLto the real URL andchmod 600 server/.env. - Back up
server/audit.log(AUDIT_LOG_PATHoverrides the location). - Run under a process supervisor. Sessions are in-memory, so a restart signs you out.
Docker#
cp .env.example .env # fill in per the comments
cp compose.example.yaml compose.yaml
docker compose up -d --build
docker compose exec app node dist/cli.js enroll # passkey enrollment link (see Setup)
- The dashboard listens on
127.0.0.1:8787, reachable only from the host. In production, put a TLS-terminating reverse proxy in front (HTTPS is required for passkeys). If the proxy is another compose service, remove theports:block and point it atapp:8787. - Data persists in the
app-datavolume across rebuilds and updates. To back up the audit log, do:docker compose cp app:/data/audit.log ., or you can switch to a bind mount (see comment incompose.example.yaml). - To watch your own labelers, edit
server/labelers.json(copy the example) then rebuild, or bind mount it (commented line in the compose file) so a restart is enough. - Use the enroll command above, not
npm run enroll. dev tooling isn't in the image.