atprotocrates

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.

CI gates enforced on every push to main and every pull request
GateCommand
Formattingcargo fmt --all -- --check
Lintscargo clippy --workspace --all-targets -- -D warnings
Testscargo test --workspace

§ 02

What it serves

XRPC surface with default features
NamespaceMethods
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: getDelegationTokengetSpaceCredential. 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.

ProfileShapeChosen 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

Cargo features
FeatureDefaultEffect
sqliteyesPer-actor SQLite via sqlx.
fjallLSM backend for the per-actor store.
httpyesThe axum router and the subscribeRepos WebSocket.
clapBuilds the pds and atproto-pds-admin binaries.
hickory-dnsyesHickory resolver, via atproto-identity/hickory-dns.
smtpSMTP through lettre. Without it, email-issuing endpoints fall back to dev-only INFO logging.
metricsPrometheus exporter at /metrics plus request-counter middleware.
valkeyValkey/Redis JTI replay guard and sliding-window rate limiter. Wins over --durability-profile when PDS_VALKEY_URL is set.
otelOpenTelemetry OTLP HTTP/protobuf tracing. Activates when PDS_OTEL_ENDPOINT is set.
postgres-live-testsExercises the Postgres accounts adapter against a live instance. Keeps an unsupported adapter compiling and correct. See § 08.
Default SQLite profileListing 1
cargo build --features clap,smtp,metrics,hickory-dns \
  --bin pds --bin atproto-pds-admin
fjall profile — note the --no-default-featuresListing 2
cargo build --no-default-features \
  --features clap,fjall,smtp,metrics,hickory-dns \
  --bin pds --bin atproto-pds-admin
Run it with dev defaultsListing 3
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.

Identity & network
VariableGovernsExample
PDS_SERVICE_DIDThis server’s own DID. The did.json document is synthesised from it.did:web:pds1.example.dev
PDS_HOSTNAMEPublic hostname.pds1.example.dev
PDS_BINDListen address.0.0.0.0
PDS_PORTListen port.3000
PDS_SERVICE_HANDLE_DOMAINSSuffixes this server will issue handles under..pds1.example.dev,.example.dev
PDS_TRUSTED_PROXY_HOPSHow many X-Forwarded-For hops to trust when deriving client IP.
Storage & durability
VariableGovernsExample
PDS_DATA_DIRECTORYEverything on disk, including accounts.sqlite./var/lib/pds
PDS_STORAGE_PROFILESelects among the profiles compiled in.
PDS_DURABILITY_PROFILEDurability posture for the replay guard and rate limiter. Overridden by Valkey when PDS_VALKEY_URL is set.sql
Secrets — none of these belong in an env file committed anywhere
VariableGoverns
PDS_JWT_SECRETSigning secret for issued JWTs.
PDS_ADMIN_PASSWORDCredential for com.atproto.admin.* and the /admin dashboard.
PDS_ADMIN_DIDSIdentities 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_SETThe OAuth provider’s JWK set. Multi-key, which is what makes rotation possible.
PDS_PLC_ROTATION_KEY_PRIVATEThe 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.
Federation & directory
VariableGovernsExample
PDS_DID_PLC_URLPLC directory host.plc.directory
PDS_CRAWLERSRelays to announce to with requestCrawl. Unset means nothing ever discovers this server.https://bsky.network
PDS_CRAWLER_ANNOUNCE_DELAY_SECSHow 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_DIDApp View identity for proxied app.bsky.* traffic.did:web:api.bsky.app
PDS_BSKY_APP_VIEW_URLApp View endpoint.https://api.bsky.app
PDS_REPORT_SERVICE_DIDModeration service identity for reports.
PDS_REPORT_SERVICE_URLModeration service endpoint.
Limits & rate control
VariableGovernsDefault
PDS_BLOB_UPLOAD_LIMITOne blob through com.atproto.repo.uploadBlob.16 MiB
PDS_IMPORT_LIMITOne repository CAR through com.atproto.repo.importRepo.1 GiB
PDS_RATE_LIMITGeneral 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_AUTHCeiling on authentication endpoints specifically.
PDS_RATE_LIMIT_WINDOW_SECSSliding-window width.
PDS_RATE_LIMIT_BYPASS_IPSAddresses exempt from the limiter.
PDS_OAUTH_ACCESS_TOKEN_TTL_SECONDSAccess-token lifetime.
PDS_OAUTH_REFRESH_TOKEN_TTL_SECONDSRefresh-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.

Email, spaces & operations
VariableGoverns
PDS_EMAIL_SMTP_URLSMTP endpoint. Needs the smtp feature; without it, mail is logged rather than sent.
PDS_EMAIL_FROM_ADDRESSEnvelope sender.
PDS_EMAIL_LOG_BODIESLogs message bodies. Development only: these contain reset tokens.
PDS_SPACE_CREDENTIAL_TTL_SECONDSLifetime of an issued space credential.
PDS_SPACE_OPLOG_RETENTION_DAYSHow long space operation logs are kept.
PDS_SPACE_NOTIFY_RETRY_MAX_ATTEMPTSRetry ceiling for outbound space notifications.
PDS_SPACE_NOTIFY_RETRY_INITIAL_BACKOFF_MSFirst backoff interval for those retries.
PDS_PRODUCTIONProduction posture. Turns off development affordances.
PDS_ALLOW_DEV_DEFAULTSPermits insecure defaults. Never set alongside PDS_PRODUCTION.
PDS_INVITE_REQUIREDWhether 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_DIRDirectory 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_URLWhere this server’s policy documents are published. Shown to a holder before they agree.
PDS_POLICY_SET_IDIdentifier 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_URLBase URL the admin CLI targets.
PDS_METRICS_BINDSeparate bind address for the metrics exporter.
PDS_OTEL_ENDPOINTOTLP endpoint. Setting it activates tracing export.
PDS_VALKEY_URLValkey/Redis for the JTI replay guard and rate limiter.
PDS_VALKEY_KEY_PREFIXKey namespace, for sharing one Valkey across deployments.
PDS_GC_INTERVAL_SECSGeneral garbage-collection cadence.
PDS_ACCOUNT_GC_INTERVAL_SECSAccount-reaper cadence.
PDS_NOTIFIER_INTERVAL_SECSOutbound notifier tick.
A working single-node environmentListing 4
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.

Build the server imageListing 5
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.

Things that fail quietly
TrapWhat happensFix
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.

ModeWhere it stops
PostgreSQL accounts DB
postgres · 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 storage
s3 · 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.

com.atproto.simplespace.* — owner-side management, six methods
MethodDoes
createSpaceMint a space under the owner’s repository.
updateSpaceChange its metadata.
deleteSpaceRemove it, and notify members through notifySpaceDeleted.
addMemberAdmit an identity.
removeMemberRevoke it.
listMembersEnumerate the member list.
com.atproto.space.* — the permissioned realm, nineteen methods
GroupMethods
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:

Notifier and retention
VariableGoverns
PDS_NOTIFIER_INTERVAL_SECSHow often the notifier ticks.
PDS_SPACE_NOTIFY_RETRY_MAX_ATTEMPTSHow many times a failed delivery is retried before it lands in the DLQ.
PDS_SPACE_NOTIFY_RETRY_INITIAL_BACKOFF_MSThe first backoff interval; subsequent ones grow from it.
PDS_SPACE_OPLOG_RETENTION_DAYSHow 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.

Test matrixListing 6
# 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