PDS
Operating atproto-pds
A Personal Data Server in Rust: repository storage, federation, an OAuth 2.1 provider, and permissioned spaces. Two storage profiles, fifty-two environment variables, and two documented modes that refuse to start on purpose.
§ 01
Status, stated plainly
From the crate’s own README
“EXPERIMENTAL — FOR THE LOVE OF GOD DON’T USE THIS YET.”
Both that warning and the paragraph under it are true, which is the thing to understand before you decide anything. The server is single-node deployable for federated public traffic. Every foundational subsystem has shipped end to end: storage-profile dispatch, federation gaps, admin endpoints, the OAuth provider, the Sync 1.1 protocol, identity gaps, user-facing endpoints, account migration, and operational hardening. Both heavy storage-layer batches are closed. The fjall public-realm sweep routes every public read and write through the PublicRealmBackend trait, and the Postgres accounts cutover routes every accounts-DB call through a runtime-dispatch AccountPool.
What that adds up to: a server that works, under CI gates, and has not yet accumulated the operational history that would justify putting somebody else’s account on it.
| Gate | Command |
|---|---|
| Formatting | cargo fmt --all -- --check |
| Lints | cargo clippy --workspace --all-targets -- -D warnings |
| Tests | cargo test --workspace |
§ 02
What it serves
| Namespace | Methods |
|---|---|
| com.atproto.repo.* | getRecord, listRecords, describeRepo, createRecord, putRecord, deleteRecord, applyWrites, importRepo, listMissingBlobs |
| com.atproto.sync.* | getLatestCommit, getRepoStatus, getRepo (CAR streaming, ?since=<rev> diff slice), getBlocks, listRepos, subscribeRepos (broadcast-channel WebSocket with poll fallback for catch-up) |
| com.atproto.server.* | Account lifecycle (createAccount with optional PLC genesis, activate, deactivate, delete), sessions, app passwords, invite codes, service auth, and the full email-confirmation and password-reset set |
| com.atproto.identity.* | resolveHandle, updateHandle, requestPlcOperationSignature, signPlcOperation, submitPlcOperation, getRecommendedDidCredentials, refreshIdentity |
| com.atproto.simplespace.* | Owner-side space management: createSpace, updateSpace, deleteSpace, addMember, removeMember, listMembers |
| com.atproto.space.* | The permissioned realm — nineteen methods. Records: createRecord, putRecord, deleteRecord, applyWrites, getRecord, listRecords (keys only), getBlob. Spaces: getSpace, listSpaces. Sync: getRepo, getLatestCommit, getRepoState, listRepos, listRepoOps. Credentials: getDelegationToken → getSpaceCredential. Notification: registerNotify, plus the contentless notifyWrite and notifySpaceDeleted inbound hooks |
| com.atproto.admin.* | Account info and search, subject status and takedown, invite-code administration, email/handle/password updates, revokeServiceAuth, forceRepoSync, takedownSpaceRecord, plus an HTML operator dashboard at GET /admin |
| OAuth 2.1 | /oauth/par, /oauth/authorize (HTML consent and JSON POST), /oauth/token (rate-limited; PKCE, DPoP, refresh rotation), /oauth/revoke (RFC 7009), /oauth/jwks, and both well-known metadata documents |
| Identity discovery | /.well-known/atproto-did for handles on this server’s own domain; /.well-known/did.json synthesised from PDS_SERVICE_DID rather than read off disk |
| Federation | Inbound contentless notifyWrite receipts, outbound requestCrawl announcements, and default-pin Atproto-Proxy routing for app.bsky.* with per-request header override |
Health checks live at GET /_alive, GET /_ready and GET /xrpc/_health. Prometheus metrics appear at GET /metrics when the metrics feature is compiled in.
§ 03
Storage profiles
Two backends for the per-actor store, mutually exclusive and chosen at compile time. This is a build decision, not a runtime one.
| Profile | Shape | Chosen by |
|---|---|---|
| SQLite | One SQLite file per actor. Matches the upstream 0016 draft exactly. | Default. Plain cargo build. |
| fjall | One fjall Database per data directory, one Keyspace per logical table. Lower overhead on a single host. |
--no-default-features --features fjall,… |
The cross-account accounts database is separate and independent of that choice. It is a single shared SQLite at PDS_DATA_DIRECTORY/accounts.sqlite, and it is the only supported accounts backend.
§ 04
Building
| Feature | Default | Effect |
|---|---|---|
| sqlite | yes | Per-actor SQLite via sqlx. |
| fjall | — | LSM backend for the per-actor store. |
| http | yes | The axum router and the subscribeRepos WebSocket. |
| clap | — | Builds the pds and atproto-pds-admin binaries. |
| hickory-dns | yes | Hickory resolver, via atproto-identity/hickory-dns. |
| smtp | — | SMTP through lettre. Without it, email-issuing endpoints fall back to dev-only INFO logging. |
| metrics | — | Prometheus exporter at /metrics plus request-counter middleware. |
| valkey | — | Valkey/Redis JTI replay guard and sliding-window rate limiter. Wins over --durability-profile when PDS_VALKEY_URL is set. |
| otel | — | OpenTelemetry OTLP HTTP/protobuf tracing. Activates when PDS_OTEL_ENDPOINT is set. |
| postgres-live-tests | — | Exercises the Postgres accounts adapter against a live instance. Keeps an unsupported adapter compiling and correct. See § 08. |
cargo build --features clap,smtp,metrics,hickory-dns \
--bin pds --bin atproto-pds-admin
cargo build --no-default-features \
--features clap,fjall,smtp,metrics,hickory-dns \
--bin pds --bin atproto-pds-admin
PDS_DATA_DIRECTORY=./.pds-data \
PDS_SERVICE_DID=did:web:pds.example.com \
PDS_JWT_SECRET=$(openssl rand -hex 32) \
PDS_ADMIN_PASSWORD=$(openssl rand -hex 16) \
cargo run --features clap,smtp,metrics,hickory-dns --bin pds
§ 05
Configuration
Configuration is environment plus flags; pds --help is authoritative. What follows is the full set of PDS_ variables the server reads, grouped by what they govern. Where a default is documented in the crate it is given; where it is not, this page does not guess.
| Variable | Governs | Example |
|---|---|---|
| PDS_SERVICE_DID | This server’s own DID. The did.json document is synthesised from it. | did:web:pds1.example.dev |
| PDS_HOSTNAME | Public hostname. | pds1.example.dev |
| PDS_BIND | Listen address. | 0.0.0.0 |
| PDS_PORT | Listen port. | 3000 |
| PDS_SERVICE_HANDLE_DOMAINS | Suffixes this server will issue handles under. | .pds1.example.dev,.example.dev |
| PDS_TRUSTED_PROXY_HOPS | How many X-Forwarded-For hops to trust when deriving client IP. | — |
| Variable | Governs | Example |
|---|---|---|
| PDS_DATA_DIRECTORY | Everything on disk, including accounts.sqlite. | /var/lib/pds |
| PDS_STORAGE_PROFILE | Selects among the profiles compiled in. | — |
| PDS_DURABILITY_PROFILE | Durability posture for the replay guard and rate limiter. Overridden by Valkey when PDS_VALKEY_URL is set. | sql |
| Variable | Governs |
|---|---|
| PDS_JWT_SECRET | Signing secret for issued JWTs. |
| PDS_ADMIN_PASSWORD | Credential for com.atproto.admin.* and the /admin dashboard. |
| PDS_ADMIN_DIDS | Identities allowed to administer by signing a service-auth token with the #atproto key in their own DID document. Attributable in a way a shared password is not, and withdrawn by editing the list rather than rotating a secret every holder has a copy of. They need no account here. |
| PDS_OAUTH_KEYS_JWK_SET | The OAuth provider’s JWK set. Multi-key, which is what makes rotation possible. |
| PDS_PLC_ROTATION_KEY_PRIVATE | The operator’s recovery path for every identity this server issues. One value, the private did:key a key generator prints — P-256 (z42t…) or K-256 (z3vL…). A public key, or any other curve, is refused at boot. |
| Variable | Governs | Example |
|---|---|---|
| PDS_DID_PLC_URL | PLC directory host. | plc.directory |
| PDS_CRAWLERS | Relays to announce to with requestCrawl. Unset means nothing ever discovers this server. | https://bsky.network |
| PDS_CRAWLER_ANNOUNCE_DELAY_SECS | How long to wait before the first announcement. Retries run at 0s, 15s, 1m, 3m and 15m and stop at the first acceptance; the delay just moves attempt one to when a rolling deploy is actually reachable. | 15 |
| PDS_BSKY_APP_VIEW_DID | App View identity for proxied app.bsky.* traffic. | did:web:api.bsky.app |
| PDS_BSKY_APP_VIEW_URL | App View endpoint. | https://api.bsky.app |
| PDS_REPORT_SERVICE_DID | Moderation service identity for reports. | — |
| PDS_REPORT_SERVICE_URL | Moderation service endpoint. | — |
| Variable | Governs | Default |
|---|---|---|
| PDS_BLOB_UPLOAD_LIMIT | One blob through com.atproto.repo.uploadBlob. | 16 MiB |
| PDS_IMPORT_LIMIT | One repository CAR through com.atproto.repo.importRepo. | 1 GiB |
| PDS_RATE_LIMIT | General request ceiling. Proxied app.bsky.* calls count against it too, because this server forwards them rather than the client reaching an AppView directly. | — |
| PDS_RATE_LIMIT_AUTH | Ceiling on authentication endpoints specifically. | — |
| PDS_RATE_LIMIT_WINDOW_SECS | Sliding-window width. | — |
| PDS_RATE_LIMIT_BYPASS_IPS | Addresses exempt from the limiter. | — |
| PDS_OAUTH_ACCESS_TOKEN_TTL_SECONDS | Access-token lifetime. | — |
| PDS_OAUTH_REFRESH_TOKEN_TTL_SECONDS | Refresh-token lifetime. | — |
Size the proxy to match
The application enforces both body ceilings itself and refuses oversized requests as XRPC errors, so a client sees the same error shape it sees everywhere else instead of a bare 413 text/plain. That only holds if your reverse proxy is configured to allow at least as much as you set here. Otherwise the proxy rejects first and the operator-facing error is its, not the PDS’s.
| Variable | Governs |
|---|---|
| PDS_EMAIL_SMTP_URL | SMTP endpoint. Needs the smtp feature; without it, mail is logged rather than sent. |
| PDS_EMAIL_FROM_ADDRESS | Envelope sender. |
| PDS_EMAIL_LOG_BODIES | Logs message bodies. Development only: these contain reset tokens. |
| PDS_SPACE_CREDENTIAL_TTL_SECONDS | Lifetime of an issued space credential. |
| PDS_SPACE_OPLOG_RETENTION_DAYS | How long space operation logs are kept. |
| PDS_SPACE_NOTIFY_RETRY_MAX_ATTEMPTS | Retry ceiling for outbound space notifications. |
| PDS_SPACE_NOTIFY_RETRY_INITIAL_BACKOFF_MS | First backoff interval for those retries. |
| PDS_PRODUCTION | Production posture. Turns off development affordances. |
| PDS_ALLOW_DEV_DEFAULTS | Permits insecure defaults. Never set alongside PDS_PRODUCTION. |
| PDS_INVITE_REQUIRED | Whether account creation demands an invite code. Leave it on from first boot: a PLC DID created by a stray test is permanent and public. |
| PDS_LEXICON_DIR | Directory of lexicon documents served by this instance. *.json read recursively at startup, each keyed by its own id. Editing a file needs a restart, and an unreadable path is fatal rather than empty — a typo would otherwise leave a server that looks configured and resolves exactly as it did before. |
| PDS_POLICY_URL | Where this server’s policy documents are published. Shown to a holder before they agree. |
| PDS_POLICY_SET_ID | Identifier for an immutable set of those documents, so revising them yields a new identifier and a fresh acceptance rather than silently re-pointing an old one. Takes effect only alongside PDS_POLICY_URL. |
| PDS_ADMIN_BASE_URL | Base URL the admin CLI targets. |
| PDS_METRICS_BIND | Separate bind address for the metrics exporter. |
| PDS_OTEL_ENDPOINT | OTLP endpoint. Setting it activates tracing export. |
| PDS_VALKEY_URL | Valkey/Redis for the JTI replay guard and rate limiter. |
| PDS_VALKEY_KEY_PREFIX | Key namespace, for sharing one Valkey across deployments. |
| PDS_GC_INTERVAL_SECS | General garbage-collection cadence. |
| PDS_ACCOUNT_GC_INTERVAL_SECS | Account-reaper cadence. |
| PDS_NOTIFIER_INTERVAL_SECS | Outbound notifier tick. |
PDS_BIND=0.0.0.0
PDS_PORT=3000
PDS_DATA_DIRECTORY=/var/lib/pds
PDS_PRODUCTION=true
PDS_SERVICE_DID=did:web:pds1.example.dev
PDS_HOSTNAME=pds1.example.dev
PDS_DID_PLC_URL=plc.directory
PDS_DURABILITY_PROFILE=sql
PDS_INVITE_REQUIRED=false
PDS_CRAWLERS=https://bsky.network
PDS_BSKY_APP_VIEW_DID=did:web:api.bsky.app
PDS_BSKY_APP_VIEW_URL=https://api.bsky.app
PDS_SERVICE_HANDLE_DOMAINS=.pds1.example.dev,.example.dev
PDS_SPACE_CREDENTIAL_TTL_SECONDS=7200
PDS_NOTIFIER_INTERVAL_SECS=5
PDS_ADMIN_BASE_URL=http://127.0.0.1:3000
RUST_LOG=info,atproto_pds=info
# PDS_JWT_SECRET, PDS_ADMIN_PASSWORD, PDS_OAUTH_KEYS_JWK_SET and the
# PLC rotation key come from your secret store, not from this file.
§ 06
Running it
Two Dockerfiles sit at the repository root and they build different things. Dockerfile.pds builds the server. The plain Dockerfile builds the CLI tools and does not contain a PDS — deploying that one and expecting a server is the mistake the crate’s own deployment note exists to prevent.
docker build -t atproto-pds:dev -f Dockerfile.pds .
The image runs as non-root, and everything durable — the accounts database, each actor’s own store, blobs, the key store — lives under PDS_DATA_DIRECTORY. There is no second service to run and no external database to provision.
Front it with a reverse proxy that terminates TLS, permits request bodies at least as large as your configured ceilings, and passes WebSocket upgrades through for subscribeRepos. Shutdown is coordinated: on SIGTERM or SIGINT the server cancels long-lived workers, lets in-flight requests finish, and closes WebSocket subscribers cleanly, so a rolling restart drains rather than severs.
One instance, and it is a correctness setting
SQLite is the durable store and its volume attaches to a single instance. Two replicas either fail to start or, worse, run against separate copies of the same repositories — both signing commits into divergent histories under one DID. There is no read replica to scale onto either. A replica count above one is a data-loss setting here, not a throughput one.
§ 07
Deploying on a platform
The workspace carries railway.toml and a deployment note written against a real Railway deployment. The platform specifics are Railway’s; the traps below are not, and most of them cost somebody an afternoon.
| Trap | What happens | Fix |
|---|---|---|
| The wrong Dockerfile | You deploy the CLI-tools image and it contains no server. | Build Dockerfile.pds. |
Setting PDS_PORT |
The image deliberately leaves it unset so the binary falls back to the platform’s PORT. Setting it defeats the fallback, the server binds its own default, the platform routes elsewhere, and the health check times out with nothing in the log to explain it. |
Leave it unset; confirm PORT is injected. |
No PDS_CRAWLERS |
The server is completely healthy — writes commit, putRecord returns a real commit CID — and every AppView shows an empty profile, because nothing was ever told to crawl you. |
Set it. Watch for requestCrawl: announced. |
Default PDS_TRUSTED_PROXY_HOPS |
Behind a CDN every request arrives from the proxy’s address, so the rate limiter sees one client and throttles all of your users as a single caller. | Set it to the number of proxies you operate — usually 1. Inflating it lets a caller forge their address. |
| SMTP on 465 or 587 | Cloud platforms commonly block outbound mail ports. The server sends nothing while every other check — domain verified, SPF and DKIM live, credentials good — passes. | Port 2525, scheme smtp://, and ?tls=required appended. All three change together. |
Announcing is publication
A relay that accepts your announcement ingests your repository, and so does anyone consuming the firehose. Records may stay cached and indexed after you delete them. Every account created against the real PLC directory is permanent and public for the same reason — the directory is an append-only log, and a DID created by a stray test cannot be withdrawn.
§ 08
Modes that refuse to start
Two backends exist in the source tree, compile behind Cargo features, and have tests. They are deliberately not wired into the pds binary. They are documented here so nobody finds out the hard way.
| Mode | Where it stops |
|---|---|
PostgreSQL accounts DBpostgres · PDS_POSTGRES_URL |
AccountDirectory::open_postgres exists and 57 of 59 accounts-DB query sites already dispatch per dialect. Thirteen production call sites do not: the OAuth state store, the JTI replay guard, the rate-limit SQL backend, the GC loop, the notifier, the sequencer, four files of the spaces subsystem, and the repository writer’s signing-key lookup all take a SQLite-only pool accessor that panics on a Postgres pool. |
S3 blob storages3 · PDS_BLOB_STORE_URL |
HybridS3BlobStorage is complete and implements BlobStorage. Nothing constructs it. |
Setting either one refuses at boot
Both used to be parsed and silently ignored, so an operator who configured Postgres or S3 believed they had it and got neither. A documented mode that does not work is worse than an absent one; a mode that fails loudly at startup is neither.
§ 09
Spaces
This is the second PDS implementation anywhere to serve permissioned data spaces, and the first in Rust. That makes it useful in a way its maturity does not otherwise justify: interoperability work on the 0016 Permissioned Data draft needs a second implementation to test against, and this is it.
A public repository is public by construction — the MST leaks its key set even to a reader who cannot decrypt the records. A space is the permissioned alternative: membership gates reads, and the commit structure is built so a leaked commit does not become evidence of what it contained. The concept page covers the primitives; this section is what the server actually exposes.
Two namespaces
The split matters. simplespace is the owner’s control plane — who exists, who is a member. space is the data plane, and it is the one members talk to.
| Method | Does |
|---|---|
| createSpace | Mint a space under the owner’s repository. |
| updateSpace | Change its metadata. |
| deleteSpace | Remove it, and notify members through notifySpaceDeleted. |
| addMember | Admit an identity. |
| removeMember | Revoke it. |
| listMembers | Enumerate the member list. |
| Group | Methods |
|---|---|
| Records | createRecord, putRecord, deleteRecord, applyWrites, getRecord, listRecords, getBlob |
| Spaces | getSpace, listSpaces |
| Sync | getRepo, getLatestCommit, getRepoState, listRepos, listRepoOps |
| Credentials | getDelegationToken, getSpaceCredential |
| Notification | registerNotify, notifyWrite, notifySpaceDeleted |
listRecords returns keys, not records
In the public realm listRecords hands back record values. In a space it returns keys only. Reading content is a per-record fetch, because the enumeration and the contents are separately authorised — which is the whole point of a permissioned realm, and a difference that will surprise anyone porting public-realm code across.
Getting in: the two-step exchange
A member does not authenticate to a space with their ordinary session. They exchange it. getDelegationToken issues a delegation token, and getSpaceCredential trades that for a short-lived space credential — both JWTs, defined in atproto-space as DelegationToken and SpaceCredential.
The exchange is replay-protected by a JTI guard. In its default form that guard is in-memory, which means it is per-process and it forgets on restart. Setting PDS_VALKEY_URL moves it to Valkey or Redis, where it survives both.
Telling members something changed
Writes do not push content. A recipient registers with registerNotify and then receives contentless notifyWrite events — just { space, repo, rev } — and fetches for itself if it wants the data. Deletion is announced the same way through notifySpaceDeleted.
Outbound delivery is retried with backoff and has a dead-letter queue behind it. Four variables govern delivery, and how far behind a member may fall before catching up stops being incremental:
| Variable | Governs |
|---|---|
| PDS_NOTIFIER_INTERVAL_SECS | How often the notifier ticks. |
| PDS_SPACE_NOTIFY_RETRY_MAX_ATTEMPTS | How many times a failed delivery is retried before it lands in the DLQ. |
| PDS_SPACE_NOTIFY_RETRY_INITIAL_BACKOFF_MS | The first backoff interval; subsequent ones grow from it. |
| PDS_SPACE_OPLOG_RETENTION_DAYS | How long the operation log backing listRepoOps is kept. A member offline longer than this cannot catch up incrementally. |
notifyMembership is gone
The crate README still lists a member-sync notifyMembership route under federation. It does not exist: it was removed in the 0016 re-alignment and only notifyWrite is served. The column survives in the notifier’s dead-letter schema and in a test comment, which is the only place you will meet the name.
§ 10
Administration & tests
atproto-pds-admin covers invite-code issuance, account inspection, takedown and the rest of the operational surface; the same operations are available over com.atproto.admin.* and through the HTML dashboard at GET /admin.
# Default profile — SQLite, http, clap
cargo test -p atproto-pds --features http,clap
# Adds the fjall storage-parity suite
cargo test -p atproto-pds --features http,clap,fjall
# Postgres accounts adapter (see § 08 before you rely on it)
cargo test -p atproto-pds --features http,clap,postgres
# Live Postgres round-trip; skips and reports OK when the DSN is unset
PDS_POSTGRES_TEST_URL=postgres://pds:pds@127.0.0.1:5432/pds_live \
cargo test -p atproto-pds --features postgres-live-tests \
--test feature_postgres_live