PDS
Operating atproto-pds
A Personal Data Server in Rust: repository storage, federation, an OAuth 2.1 provider, and permissioned spaces. Two storage profiles, sixty-two environment variables, and three configurations 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 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. Every public read and write routes 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 (commit signatures verified against the account’s own key, not merely proved internally consistent), listMissingBlobs |
| com.atproto.sync.* | getLatestCommit, getRepoStatus, getRepo (CAR streaming, ?since=<rev> diff slice), getBlocks, listRepos, subscribeRepos (WebSocket; frames are drained from the durable log in seq order, and the broadcast bus is only a wakeup) |
| com.atproto.server.* | Account lifecycle (createAccount with optional PLC genesis, activate, deactivate, delete), sessions, app passwords, invite codes, service auth, the full email-confirmation and password-reset set, and the migration pair checkAccountStatus and reserveSigningKey |
| com.atproto.identity.* | resolveHandle, updateHandle, requestPlcOperationSignature, signPlcOperation, submitPlcOperation, getRecommendedDidCredentials, refreshIdentity |
| com.atproto.simplespace.* | Owner-side space management: createSpace, updateSpace, deleteSpace, getSpace, addMember, removeMember, listMembers |
| com.atproto.space.* | The permissioned realm — twenty-one methods. Records: createRecord, putRecord, deleteRecord, applyWrites, getRecord, listRecords (keys only), getBlob, listBlobs. Spaces: getSpace, listSpaces. Sync: getRepo, getLatestCommit, getRepoState, listRepos, listRepoOps. Credentials: getDelegationToken → getSpaceCredential. Notification: registerNotify and unregisterNotify, 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.
The account portal
Not every surface is XRPC. Before the portal, a PDS could be operated but not used without a client application: no way to sign in to the server itself, no way to change an email address or password, no way to see which app passwords and OAuth grants were outstanding or to end them. Pointing a browser at the host produced an empty 404. The portal is what an account holder can do with only a browser, and it is server-rendered HTML with no JavaScript at all.
| Path | What it holds |
|---|---|
| /account | Settings — handle, email and its verification, password, and the server’s policy documents. |
| /account/sessions | Access — outstanding sessions and app passwords, each revocable, including revoke-everywhere. |
| /account/repository | Repository — a browser over the account’s own collections and records. The only section that goes deep enough to need a breadcrumb trail. |
| /account/spaces | Spaces — space settings and membership. |
| /account/delegation | Delegation — the identities that may act as this account. Present only when PDS_DELEGATION_ENABLED is set; otherwise the section says which prerequisite is missing rather than offering a control that cannot work. |
| /account/migrate | Migration — ten inbound screens behind a cookie-bound migration row, and five outbound steps behind a portal session. The only pages served to visitors who have no account here. |
The session is server-side rather than a JWT, and deliberately: the portal is the page that revokes credentials, so its own credential has to be revocable in the same breath. A stateless cookie that outlived “sign out everywhere” would be the one thing that button could not reach.
§ 03
Storage
One store for repositories, and an optional second place for blob bytes. The per-actor store is SQLite and is not a build decision any more; where blobs live is a runtime one.
| Store | Shape | Chosen by |
|---|---|---|
| Per-actor store | One SQLite file per actor: repositories, records, blocks, blob inventory, the Spaces tables. Matches the upstream 0016 draft exactly. | Always. There is no alternative to select. |
| Accounts database | One shared SQLite at PDS_DATA_DIRECTORY/accounts.sqlite, holding cross-account state and the firehose log. |
Always. |
| Blob bytes | In the per-actor store by default. With object-storage, the bytes go to S3, GCS, Azure or a local directory instead; the inventory and every reference stay in SQLite. |
PDS_BLOB_STORE_URL, on a build with the feature. |
The fjall profile is gone
An earlier release carried a second per-actor store built on fjall, selected with PDS_STORAGE_PROFILE. It was never wired into a deployment and has been removed.
PDS_STORAGE_PROFILE=fjall now refuses to boot rather than being ignored: a server that had been running that profile holds its repositories in a keyspace this binary cannot read, and starting anyway would serve every account as empty. Unset the variable, or set it to sqlite.
§ 04
Building
| Feature | Default | Effect |
|---|---|---|
| sqlite | yes | Per-actor and accounts SQLite via sqlx. Required. |
| object-storage | — | Blob bytes in S3, GCS, Azure or a local directory, via object_store. Selected at runtime with PDS_BLOB_STORE_URL. |
| 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 --features clap,object-storage,smtp,metrics \
--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 | Selected the per-actor store when there were two. SQLite is the only one now: unset and sqlite both boot, fjall refuses, any other value is ignored with a warning. | — |
| PDS_BLOB_STORE_URL | Blob bytes in an object store: s3://bucket/prefix (any S3-compatible service, with AWS_ENDPOINT), gs://, az:// or file:///path. Needs the object-storage feature; a build without it refuses to boot rather than storing blobs somewhere you did not ask for. Only the bytes move — the inventory, the references and the takedown gate stay in the per-actor store. | s3://pds-blobs/blobs |
| PDS_BLOB_STORE_MIGRATE_LOCAL | Moves blobs already in the per-actor store out to the object store, in the background after boot. Blobs not yet moved are served from where they are, and an interrupted move resumes on the next boot. | true |
| PDS_DURABILITY_PROFILE | Durability posture for the replay guard and rate limiter. Overridden by Valkey when PDS_VALKEY_URL is set. | sql |
| PDS_STREAM_EVENT_RETENTION_HOURS | How long subscribeRepos events stay in the durable log. The window exists so a relay or AppView that falls over can reconnect and resume rather than backfilling from getRepo; three days covers a weekend outage, and a consumer that lags further gets OutdatedCursor and re-syncs, which is the documented path rather than a failure. The newest event is never deleted regardless of age — it is what a resume cursor is compared against, and an empty log would make every valid cursor read as FutureCursor. | 72 |
| PDS_MIGRATION_RETENTION_DAYS | Days a finished or abandoned migration row is kept; 0 disables the sweep. Measured from when the attempt started, not when it finished — a row that never finishes is the one worth pruning, and a window on the completion stamp would keep every abandoned attempt forever. A completed migration’s row is deleted; an outbound stand-down’s is not, because it is the receipt for an account that left and the operator has nothing else that says where it went. | 90 |
| 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_BLOB_GRACE_HOURS | How long an unreferenced blob survives before collection. A blob is legitimately unreferenced for a while — uploadBlob stores bytes before any record names them, and replacing an image drops the old reference and adds the new one as two steps — so the window has to outlast both. | 24 |
| 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. | — |
| PDS_FIREHOSE_SEND_TIMEOUT | How long one subscribeRepos frame may take to reach a consumer before the subscription is closed with ConsumerTooSlow. | 60s |
| PDS_DPOP_NONCE_REQUIRED | Whether the OAuth endpoints demand a server-issued DPoP nonce. On by default; with it off a proof is replayable for its whole freshness window with only the JTI guard in the way. The escape hatch exists because turning it on changes what clients must do — one that does not handle a use_dpop_nonce challenge and retry starts failing — so an operator upgrading a live server can wait for a client to catch up instead of choosing between a conformant server and a working one. | true |
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_SPACE_NOTIFY_TIMEOUT_SECS | Per-delivery HTTP timeout for the notifier, default 10. Deliveries run serially in a background task — the one outbound path on the server no request timeout covers — so without this bound a recipient that accepts the connection and never answers stalls every other space’s notifications behind it. |
| PDS_SPACE_REGISTER_NOTIFY_TTL_SECONDS | How long a registerNotify subscription lasts, default 60 days. The 0016 draft requires the host to report an expiry and says it may outlast the space credential that created the registration, but names no duration; this is host policy. |
| PDS_ACCEPTING_MIGRATIONS | Whether an inbound account migration may adopt a DID on this server. On by default. |
| PDS_DELEGATION_ENABLED | Whether this server can authenticate a delegate against another server’s OAuth. Off by default: delegation is new authority over an account reached without its password, and it also puts this server in the role of an OAuth client against other people’s servers. Needs an HTTPS origin and a P-256 OAuth key; without either, the portal’s Delegation section says which one is missing rather than offering a control that cannot work. |
| PDS_SHUTDOWN_DEADLINE_SECS | How long to wait for in-flight requests and background workers after SIGTERM before exiting anyway, default 25. This has to fit inside the supervisor’s grace period rather than match it: a platform that sends SIGKILL 30 seconds later kills the process at the exact moment a 30-second deadline expires, losing both the warning that says the drain failed and the telemetry flush after it. |
| 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 | Address the Prometheus exporter listens on, e.g. 127.0.0.1:9090. It binds a listener of its own and /metrics is never mounted on the main one, so where you bind it is where it is reachable. An address that does not parse, or a port already taken, fails the boot. Unset means no exporter. |
| PDS_OTEL_ENDPOINT | OTLP endpoint. Setting it activates tracing export. |
| PDS_VALKEY_URL | Valkey/Redis for the JTI replay guard and rate limiter. Setting it on a build without the valkey feature refuses at boot rather than silently falling back to the SQL default. |
| 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.
What the server does to protect itself
Four things sit outside the handlers, and an operator sees all of them in the logs before they see them anywhere else.
| Guard | Why it is there |
|---|---|
| Request deadline | Hyper adds none of its own. Without one, a client can open a connection, announce a Content-Length, and then send a byte a minute — holding the task, the socket and a partially buffered body for as long as it likes. Ten thousand such connections cost a few kilobytes a second to maintain, and nothing reclaims them. |
| Concurrency cap with load-shed | A ceiling on requests in flight. Past it the server sheds rather than queues, so overload degrades into refusals instead of unbounded latency. |
| Panic floor | A panicking handler returns a response instead of killing the connection. |
| Blob collection | Nothing used to delete a blob. Replaced avatars and deleted videos stayed for the life of the deployment, and an account could grow the shared volume without bound by writing records and deleting them. Unreferenced blobs are now collected after PDS_BLOB_GRACE_HOURS. |
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
One backend exists in the source tree, compiles behind a Cargo feature, and has tests, but is deliberately not wired into the pds binary. Two further configurations are real and ask for something the binary you built cannot do. All three 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. |
Object storage on a build without itobject-storage · PDS_BLOB_STORE_URL |
Supported, and wired in — but only if the feature is compiled in. Setting the URL on a binary built without object-storage refuses at boot rather than quietly keeping blobs in the per-actor store. See § 03. |
The fjall per-actor storePDS_STORAGE_PROFILE=fjall |
Removed. A deployment that ran it holds its repositories in a keyspace this binary cannot read, so the value is refused rather than ignored — booting anyway would serve every account as empty. Export each repository with a build that still has fjall, import it into a SQLite deployment, and unset the variable. |
Valkey on a build without itvalkey · PDS_VALKEY_URL |
Supported, but only if the feature is compiled in. Setting the URL on a binary built without valkey now refuses at boot instead of leaving the durability profile at its in-memory default — which used to fail later, and further away, as a confusing complaint about memory durability not being allowed in production. |
They refuse at boot, on purpose
PDS_POSTGRES_URL and PDS_BLOB_STORE_URL used to be parsed and silently ignored, so an operator who configured either 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. The valkey check follows the same rule, and deliberately runs ahead of the production gate so the error names the variable you actually set. Object storage has since been implemented, so its refusal now means only that the feature is missing from the build.
§ 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. |
| getSpace | Read one space’s metadata. Named in both namespaces; this is the owner’s view. |
| 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, listBlobs |
| Spaces | getSpace, listSpaces |
| Sync | getRepo, getLatestCommit, getRepoState, listRepos, listRepoOps |
| Credentials | getDelegationToken, getSpaceCredential |
| Notification | registerNotify, unregisterNotify, 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. Five 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_NOTIFY_TIMEOUT_SECS | Per-delivery HTTP timeout. Deliveries run serially, so an unbounded one stalls every other space behind 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
A member-sync notifyMembership route was removed in the 0016 re-alignment, along with getMemberState and getMemberOplog; only notifyWrite and notifySpaceDeleted are served. The name survives in migration comments, the notifier’s dead-letter schema and a test comment, which is the only place you will meet it. The crate README no longer lists it.
Membership is the check that matters
A space endpoint has two gates and they answer different questions. Scope authorisation asks what an OAuth token is permitted to do — and returns early for anything that is not OAuth, because an app-password session carries no scopes and is full-authority over its own account. Membership asks whether this caller belongs to this space at all. Only the second one is about the space.
Three paths were relying on the first alone and now perform the second: createRecord (the only space write that lacked it, while putRecord, deleteRecord and applyWrites all had it), and the sync reads getRepoState and listRepoOps. If you are implementing the other side of this protocol, it is worth checking which of your own endpoints assert membership rather than merely authenticating.
notifySpaceDeleted had a related problem in its audience binding: the handler took aud from the unverified payload and handed it back as the expected audience, comparing a claim against itself. The audience is now decided from verified claims.
§ 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 object-storage suite
cargo test -p atproto-pds --features http,clap,object-storage
# 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