operator guide #
This is the operator-facing guide for running ZDS: local startup,
configuration, containers, invite administration, account migration, and
release policy. The concrete pds.zat.dev Fly workflow is documented in the
production deployment runbook.
For account suspension, takedown, and test-account cleanup, see the
account takedown runbook. ZDS supports temporary
account deactivation and operator takedown for hosted repo subjects; suspended
and deleted remain protocol vocabulary without local operator workflows.
local checks #
Run these before committing:
just test
just smoke
git diff --check
zig zen
just smoke starts a local server, creates a session, writes records,
checks blob upload, verifies repo/sync endpoints, and asserts that known app
records use valid key shapes.
Run just smoke-permissioned as well when changing permissioned-data routes,
storage, OAuth space scopes, or the resident space browser.
benchmarks #
Local store benchmarks live under bench/:
just bench all
just bench write
just bench read
just bench repo
just bench blob
just bench blob-gc 512 65536
just bench http
just bench websocket-churn
just bench metastore 10 1000
just bench get-cid 10 1000
just bench write-profile 10 500
just bench run --records 1000
These measure repo writes, indexed reads, full repo CAR materialization, blob disk IO and collection, and selected HTTP route overhead against temporary SQLite/blobstore state. Treat the numbers as local trend data; compare runs on the same machine and optimize against the shape of the curve, not a single absolute value.
blob lifecycle #
Uploaded blobs begin untethered. ZDS gives an untethered upload 24 hours to be referenced by either a public repo record or a permissioned-space record. Once a blob is old enough, removing its final record reference makes it collectable. A reference from either record system keeps the blob.
Collection is bounded and automatic. A background worker starts with the
service and reclaims at most 256 eligible blobs per minute. Upload bodies are
streamed through a fixed 64 KiB buffer into unique temporary files without
holding a repo-write lane; only the
atomic promotion and metadata update are serialized with collection for the
same CID. Collection rechecks both record systems under the account write lane
before deleting metadata and disk bytes. The service logs deleted counts,
bytes, and failures. just bench blob-gc measures the deletion path against
temporary state.
Keep experimental permissioned-data probes separate from this baseline. Their
first job is to catch obviously bad access patterns in com.atproto.space.*,
not to expand the normal benchmark gate.
See bench/README.md for the Tranquil comparison target and the permissioned-data benchmark notes.
local server #
zig build run -- --port 2583 --db dev/zds.sqlite3
Use Caddy for local HTTPS when a client requires TLS:
caddy run --config dev/Caddyfile
configuration #
Common deployment settings:
ZDS_PUBLIC_URL: public PDS origin.ZDS_SERVER_DID: PDS service DID, usuallydid:web:<host>.ZDS_HOST: bind address. Default:127.0.0.1, or0.0.0.0in the container image.ZDS_PORT: bind port. Default:2583.ZDS_DB: SQLite database path. Default:dev/zds.sqlite3, or/data/zds.sqlite3in the container image.ZDS_DB_PATHis accepted as a compatibility alias whenZDS_DBis unset.ZDS_PLC_ROTATION_KEY: PDS rotation authority private key. Accepts the official PDS 32-byte secp256k1 hex form and the private multikey form emitted by PDS Moover. Newdid:plcaccounts use this as the PDS rotation authority.ZDS_RECOVERY_DID_KEY: optional recoverydid:keyreturned before the PDS rotation key in recommended DID credentials.ZDS_JWT_SECRET: stable secret for access and refresh JWTs.ZDS_DPOP_SECRET: stable secret for stateless DPoP nonce generation. If unset, ZDS usesZDS_JWT_SECRET. A separate value is recommended for production. Multi-node deployments must configure every node with the same effective DPoP secret so each node accepts the same nonce window.ZDS_HANDLE_DOMAINS: comma-separated leading-dot hosted-handle suffixes advertised bydescribeServer, for example.pds.example.com. Each suffix needs wildcard DNS and TLS (*.pds.example.com) routed to ZDS so hosted handles can answer/.well-known/atproto-did. ZDS treats configured suffixes as authoritative, exposes only active accounts through public handle resolution, and limits handle updates to 10 per five minutes and 50 per day. Do not include the bare PDS hostname.ZDS_MAIL_PROVIDER: email delivery provider. Default:comail. Supported:comail,resend.ZDS_EMAIL_FROM: sender address for account and PLC email tokens. The domain must be enrolled with the configured provider. Comail requires a bare email address such asnoreply@example.com, not a display-name form such asZDS <noreply@example.com>.ZDS_COMAIL_API_KEYandZDS_COMAIL_DID: Comail HTTP Send API credentials.ZDS_COMAIL_DIDis the atproto DID that enrolled the sending domain; ZDS sends it asX-Atmos-DID.ZDS_RESEND_API_KEY: Resend API key, used only whenZDS_MAIL_PROVIDER=resend. See comail for the default provider's identity model, request shape, and production troubleshooting notes.ZDS_BLOB_UPLOAD_LIMIT: upload body limit. Default:100000000.ZDS_BLOBSTORE_PATH: disk blobstore root.ZDS_CRAWLERS: comma-separated relay crawl targets.ZDS_OPERATOR_HANDLE: atproto handle of the person operating this PDS, shown on the public signup page as the contact for invite codes.ZDS_PLC_DIRECTORY: PLC directory origin. Default:https://plc.directory.ZDS_PROXY_SERVICE_URL,ZDS_PROXY_SERVICE_DID,ZDS_PROXY_SERVICE_ID: appview that receivesapp.bsky.*requests sent without anatproto-proxyheader, and the endpoint used when the header names this DID. Defaults:https://api.bsky.app,did:web:api.bsky.app,bsky_appview.ZDS_LOG_LEVEL:err,info, ordebug. Default:info.ZDS_DEBUG: set totrueas a shorthand forZDS_LOG_LEVEL=debug.ZDS_MAX_CONCURRENT_REPO_EXPORTS: maximum concurrent fullcom.atproto.sync.getRepoexports. Default:4. IncrementalgetRepo?since=...exports are not intended to consume this full-export backpressure budget.ZDS_ADMIN_TOKEN: bearer token for privileged local PDS administration, including invite-code minting.ZDS_INVITE_REQUIRED: set totrueto require invite codes for account creation.describeServerreflects this value.ZDS_PERMISSIONED_DATA: set totrueto opt into experimental permissioned-data routes. Default: disabled. ZDS exposes protocol reads and writes undercom.atproto.space.*, and baseline PDS-managed space management undercom.atproto.simplespace.*, tracking the lexicons published for the spaces alpha. Enable it if residents want to use alpha apps such as Bulletin. The implementation remains experimental: reader/group semantics should stay in application policy, whilesimplespaceonly provides the small required baseline. Usejust smoke-permissionedfor the experimental smoke lane.- Passkeys do not require a separate deployment secret. WebAuthn RP ID is derived from the public URL host.
PLC key lifecycle:
- Repo signing keys are for repository commits only.
- PLC rotation keys are for DID PLC operations only.
getRecommendedDidCredentialsfollows the official PDS shape: the account signing key is returned underverificationMethods.atproto; rotation keys are the optional recovery DID key followed by the configured PDS rotation key.- ZDS rejects new PLC operations that put the account signing key in
rotationKeys. - Existing early accounts can be repaired with
just plc-repair <did>after settingZDS_DBorZDS_DB_PATH,ZDS_PUBLIC_URL,ZDS_PLC_ROTATION_KEY, and optionalZDS_REPAIR_RECOVERY_DID_KEY,ZDS_REPAIR_RECOVERY_KEY, orZDS_RECOVERY_DID_KEY. The repair tool is intentionally separate from normal XRPC handling; it only uses the account signing key if the current public PLC state already authorizes that key as a rotation key, and submits a replacement operation containing the intended recovery/PDS rotation keys. TheZDS_REPAIR_RECOVERY_KEYform accepts a PDS Moover private multikey and derives the publicdid:keylocally.
When invites are required and the database has no accounts or invite codes, ZDS creates one bootstrap code and logs it during startup. Invite codes are stored as metadata rows and uses are recorded separately, matching the official PDS model.
container #
Images are published to ATCR:
docker pull atcr.io/zat.dev/zds:latest
docker run -p 2583:2583 \
-v zds-data:/data \
-e ZDS_PUBLIC_URL=https://pds.example.com \
-e ZDS_SERVER_DID=did:web:pds.example.com \
-e ZDS_JWT_SECRET='at-least-32-random-bytes-here' \
-e ZDS_DB=/data/zds.sqlite3 \
atcr.io/zat.dev/zds:latest
Set a random JWT secret and serve the configured public origin over HTTPS.
Publish commands:
just docker-publish
just docker-publish vX.Y.Z
just docker-publish-current
just docker-publish-release vX.Y.Z
releases #
ZDS uses SemVer tags (vMAJOR.MINOR.PATCH). Before v1.0.0, patch releases
are still appropriate for user-visible compatibility fixes and operator-safe
bug fixes. Minor releases are for meaningful protocol surface, storage shape,
or operator workflow changes. Major releases are reserved for explicit
incompatible production contracts once ZDS declares a stable surface.
The /release skill (.agents/skills/release/SKILL.md) walks this end to end,
including the changelog promotion, the spindle deploy watch, and the live
version check. Release checklist:
just test
just smoke-all
zig fmt --check build.zig build.zig.zon src bench tools
git diff --check
zig zen
git tag -a vX.Y.Z -m "vX.Y.Z"
git push origin main vX.Y.Z
just docker-publish-release vX.Y.Z
For protocol-parity or storage-behavior releases, also complete the benchmark stage checklist: revisit the Zat boundary, run matching Tranquil and official PDS probes where comparable, and document any remaining mismatch as an explicit decision or follow-up.
migration #
ZDS supports browser-based account migration flows such as PDS Moover:
- service-auth-gated
createAccountfor an existing DID - repo CAR import/export
- missing blob reporting and blob upload
- recommended DID credentials
- PLC operation email token, signing, and submission
- account activation/deactivation and status checks
Migration requires working public HTTPS, stable JWT secret, email delivery, blob storage, and writable SQLite storage.
account security #
The resident account surface lives at /account. The security section,
/account/manage, is for account-owner management of passkeys and app
passwords and requires the account password for management actions.
App-password revocation also revokes active sessions that were created with
that app password. /security and /passkeys are compatibility redirects.
conformance #
Useful external checks:
https://check.cirrus.earth/?target=<handle>for PDS and OAuth conformance.- bsky.app and pdsls.dev for browser-visible OAuth, proxy, repo, and blob behavior.
Treat browser failures as request/response evidence: inspect the failing XRPC, status code, response body, and relevant server logs before changing behavior.
Web drawings #
Operators can assign individual Doodl drawings to UI slots through
config/drawings.json and just drawings, then explicitly select the cached
manifest with -Dweb-drawings=src/internal/web/icons/generated.zig at build time.
Unconfigured builds use generic icons. Our Fly deployment opts in using
ZDS_WEB_DRAWINGS; its selections live in config/pds.zat.dev.drawings.json. See Doodl drawings
for the refresh and deployment workflow. Assets are bundled at build time;
startup and account requests do not contact drawing authors’ PDSes.
Grouped account access #
GET /xrpc/dev.zat.account.listAccess requires an account password session,
matching listSessions. kind=oauth|password selects app authorizations or
password credentials; state=authorized|past|all defaults to authorized.
search matches the app client ID or credential label, case-insensitively.
Without group, the response contains grouped summaries (key, name,
count, newest). With a returned group key, connections contains that
group's existing connection details. OAuth groups use the complete client ID;
password groups use authentication method and credential name.
Both modes default to 20 results and accept limit from 1 through 100.
Pass the returned cursor with the same filters for the next page; null ends
pagination. Cursors are non-secret offsets, not snapshot guarantees: changing
sessions between requests can shift pages. authorized and total always
count the entire signed-in account's selected kind, independently of filters
and pagination. The overview uses these totals; search results show per-group
counts. No JWT, refresh token, or OAuth family identifier is returned.
The app-access UI loads summaries first and connection pages only on expansion. "Authorized" describes unrevoked, unexpired credentials, not online activity. This endpoint does not revoke grants or change session lifetime policy.