atproto—crates

PDS

Operating atproto-pds

A Personal Data Server in Rust: repository storage, federation, an OAuth 2.1 provider, permissioned spaces, and a mode for running behind an entryway. One storage engine, eighty environment variables, and a set of configurations that refuse to start on purpose.

§ 01

Status, stated plainly

From the crate’s own README

“The pds binary is single-node deployable for federated public traffic; the crate is not published, and its pub items may change in any release.”

That sentence is deliberately two claims. The README used to open with an all-caps plea not to use the crate yet, ten lines above “single-node deployable”, and left the reader to guess which was meant. They were about different things — the binary and the API — and now each is said once. The same sentence is in src/lib.rs word for word, and a test fails if the two stop matching or if the old banner comes back.

On the binary: every foundational subsystem has shipped end to end — storage dispatch, federation, admin endpoints, the OAuth provider, the Sync 1.1 protocol, identity, user-facing endpoints, account migration and operational hardening — and the deployment note is a real deployment’s runbook rather than a sketch. Every public read and write routes through the PublicRealmBackend trait. The Postgres accounts dispatch is finished; the Postgres cutover as a whole is not, and Postgres is not a deployment mode (§ 08). An earlier revision of the README said both were closed.

On the crate: its modules are pub so its own binaries and tests can reach them, and so atproto-entryway can reuse the portal chrome, the PAR checks and the scope describer. None of that is a stability promise.

Upgrades are one-way

Migrations are append-only and have no down step, and a database carrying a migration the binary does not know is refused. Redeploying the previous image gives a server that will not start, not a rollback. Back up PDS_DATA_DIRECTORY (and the bucket, if blobs are in object storage) before every upgrade; rolling back means restoring that backup. Some migrations drop data, and the deployment note names each one.

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 (newest first, as the reference PDS answers), describeRepo, createRecord, putRecord, deleteRecord, applyWrites, listMissingBlobs, uploadBlob, and importRepo (commit signatures verified against the account’s own key, not merely proved internally consistent). The last three sit on a router of their own with the larger body ceilings and the longer upload timeout. A write that changes nothing makes no commit.
com.atproto.sync.* getLatestCommit, getRepoStatus, getRepo (?since=<rev> diff slice; the CAR is built in memory and returned in one response, not streamed), getRecord, getBlocks, getBlob, listBlobs, listRepos, subscribeRepos (WebSocket; every frame, live or catch-up, is read from the durable log in seq order), and requestCrawl — which here is this server announcing itself to PDS_CRAWLERS, so it is admin-gated
com.atproto.server.* describeServer, account lifecycle (createAccount with optional PLC genesis, activate, deactivate, delete), sessions, app passwords, invite codes, getServiceAuth, the email-confirmation, email-update and password-reset sets, and the migration pair checkAccountStatus and reserveSigningKey. updateEmail redeems the token requestEmailUpdate mails; there is no confirmEmailUpdate method.
com.atproto.identity.* resolveHandle, updateHandle, requestPlcOperationSignature, signPlcOperation, submitPlcOperation, getRecommendedDidCredentials, refreshIdentity
com.atproto.moderation.* createReport, forwarded to the moderation service in PDS_REPORT_SERVICE_*, or to the one an Atproto-Proxy header names
Served here, not proxied app.bsky.actor.getPreferences and putPreferences — private per-account state this server owns, mounted ahead of the app.bsky.* catch-all. community.lexicon.service.describe — what this service is and which methods it serves, unauthenticated, and generated from the router rather than kept by hand.
com.atproto.simplespace.* Owner-side space management, seven methods: createSpace, updateSpace, deleteSpace, getSpace, putMember, removeMember, listMembers
com.atproto.space.* The permissioned realm — twenty canonical methods and two compatibility aliases. See § 09.
com.atproto.admin.* Account info and search, subject status and takedown, invite-code administration, email/handle/password updates, sendEmail, revokeServiceAuth, forceRepoSync, takedownSpaceRecord, and four methods this server defines for itself: backfillSpaceHostType, reconcileHighport, releaseHighportName (§ 10), and the adoption pair exportAccountsForEntryway and adoptAccountForEntryway (§ 11). Basic-auth gated, as is the HTML dashboard at GET /admin.
OAuth 2.1 provider /oauth/par, /oauth/authorize (HTML consent and JSON POST), /oauth/token (rate-limited; PKCE, DPoP, refresh rotation), /oauth/revoke, /oauth/jwks, and both well-known metadata documents. Access and refresh tokens are HS256 over PDS_JWT_SECRET. A scope may include: a permission set, resolved over the network first and pinned when the code is exchanged.
Delegated sign-in Five routes under /oauth/delegation/ where this server is the OAuth client against somebody else’s, signing its client assertion with the PDS_OAUTH_KEYS_JWK_SET key
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, notifySpaceDeleted and notifyCredentialRevoked receipts, outbound requestCrawl announcements, and proxying. app.bsky., chat.bsky., tools.ozone. and com.atproto.label. reach the configured AppView with no header at all; an Atproto-Proxy header overrides that pin and forwards a method in any other namespace. An /xrpc/ path that matches nothing and names no proxy answers 501 MethodNotImplemented in an XRPC envelope, not a bare 404.

Health checks live at GET /_alive, GET /_ready and GET /xrpc/_health, and GET / is a plain-text banner naming the host, the DID and the build. GET /_tls-check?domain= answers a reverse proxy’s on-demand TLS question (§ 06). Prometheus metrics are served on a listener of their own, never on the main one, when the metrics feature is built and PDS_METRICS_BIND is set. The crate README’s Routed surface lists every mounted path, and a test holds that list and the router to each other in both directions.

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.

The five sections under /account, plus the migration wizard
PathWhat it holds
/accountSettings — handle, email and its verification, password, and the server’s policy documents.
/account/sessionsAccess — outstanding sessions and app passwords, each revocable, including revoke-everywhere.
/account/repositoryRepository — a browser over the account’s own collections and records. The only section that goes deep enough to need a breadcrumb trail.
/account/spacesSpaces — for a space you run, its access policy and its members; for any space you belong to, which applications have read your permissioned records, and a control to block one.
/account/delegationDelegation — 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/migrateMigration — 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.

Sign-up and sign-in are pages too (/account/signup, /account/signin), and the signup form names the handle domains this server issues under. Behind an entryway the sign-in, sign-up, credential and session pages redirect to the same path on the entryway, delegation and the migration wizard answer 404, and the repository browser and the spaces pages stay here, with the data (§ 11).

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.

StoreShapeChosen by
Per-actor store One SQLite file per actor, sha256(did).sqlite: repositories, records, blocks, blob inventory, the Spaces tables. Matches the upstream 0016 draft. 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 (or MinIO, R2, Tigris, Garage), GCS, Azure or a local directory instead, as <prefix>/<did>/<cid>; the inventory, every reference and the takedown gate stay in SQLite. Once a blob has moved, a configuration without the store refuses to serve it rather than returning an empty body. 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

Cargo features
FeatureDefaultEffect
sqliteyesPer-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.
httpyesThe axum router and the subscribeRepos WebSocket. Required: storage, migration, the sequencer and spaces are built against the HTTP and OAuth layers.
clap—Builds the pds and atproto-pds-admin binaries.
hickory-dnsyesHickory resolver, via atproto-identity/hickory-dns.
smtp—SMTP through lettre. Without it, mail is not sent: each message’s recipient and subject are logged at INFO and it is dropped, with no link or code in the log.
metrics—Prometheus exporter on its own listener at PDS_METRICS_BIND, plus request-counter middleware; both exist only while that variable is set. Setting it on a build without the feature refuses the boot.
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; setting it on a build without the feature refuses the boot.
postgres—The PostgreSQL accounts-directory adapter. Compiles and is tested, and is not wired into the pds binary. See § 08.
postgres-live-tests—Exercises 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
With blob bytes in object storageListing 2
cargo build --features clap,object-storage,smtp,metrics \
  --bin pds --bin atproto-pds-admin

sqlite and http are features only because they are on by default; a build with --no-default-features has to list both, and the crate refuses at compile time without either. The binaries carry required-features = ["clap"], so cargo install --path crates/atproto-pds without --features clap,… installs nothing and says so only by its silence.

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, and does not print the value of a secret variable. What follows is every PDS_ variable the server reads except PDS_POSTGRES_URL, which exists only to be refused (§ 08), grouped by what they govern. Defaults are the ones in the source; where there is none, this page does not invent one.

Identity & network
VariableGovernsDefault
PDS_SERVICE_DIDThis server’s own DID. The did.json document is synthesised from it.did:web:localhost
PDS_HOSTNAMEPublic hostname for service endpoints. Derived from the DID when it is a did:web.—
PDS_BINDListen address. The images set 0.0.0.0.127.0.0.1
PDS_PORTListen port. While it is unset, a platform’s PORT wins, even over the flag given on the command line; setting it pins the port regardless of what the platform assigned.4800
PDS_SERVICE_HANDLE_DOMAINSDomains this server issues handles under. A handle under one must be a single label of 3 to 18 characters, not reserved, and never the bare domain. A handle outside them is a claim about a domain the caller owns, accepted only when the DID already resolves to it — which only a migration can satisfy. Matched case-insensitively.—
PDS_TRUSTED_PROXY_HOPSHow many reverse proxies you operate. 0 uses the TCP peer and ignores X-Forwarded-For; n takes the client address that many entries from the right of the header.0
Storage & durability
VariableGovernsDefault
PDS_DATA_DIRECTORYEverything on disk, including accounts.sqlite. Under PDS_PRODUCTION it must already exist, so a missing or mis-pathed volume fails the deploy instead of booting an empty server on throwaway disk. The images set /data../data
PDS_STORAGE_PROFILESelected the per-actor store when there were two. Unset and sqlite both boot, fjall refuses, any other value is ignored with a warning.—
PDS_BLOB_STORE_URLBlob bytes in an object store: s3://bucket/prefix (any S3-compatible service, with AWS_ENDPOINT), gs://, az:// or file:///path, with credentials from the provider’s usual variables. Needs the object-storage feature; a build without it refuses to boot rather than storing blobs somewhere you did not ask for.—
PDS_BLOB_STORE_MIGRATE_LOCALMoves 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.false
PDS_DURABILITY_PROFILEWhere the JTI replay guard and rate limiter keep state: memory, lost on restart, or sql, in the accounts database, so refresh-rotation JTIs survive one. Overridden by Valkey when PDS_VALKEY_URL is set.memory
PDS_STREAM_EVENT_RETENTION_HOURSHow long subscribeRepos events stay in the durable log, which lives in the shared accounts database. The window exists so a relay or AppView that falls over can reconnect and resume rather than backfilling from getRepo; a consumer that lags further gets OutdatedCursor and re-syncs. The newest event is never deleted — it is what a resume cursor is compared against. 0 disables the sweep.72
PDS_MIGRATION_RETENTION_DAYSDays a finished or abandoned migration row is kept, measured from when the attempt started. A completed migration’s row is deleted; an outbound stand-down’s is kept, as the receipt for an account that left. 0 disables the sweep.90
Secrets — none of these belong in an env file committed anywhere
VariableGoverns
PDS_JWT_SECRETHMAC secret, at least 32 bytes, for app-password sessions and OAuth access and refresh tokens. Its default is a development sentinel the server refuses unless PDS_ALLOW_DEV_DEFAULTS is set.
PDS_ADMIN_PASSWORDBasic-auth credential for com.atproto.admin.*, the /admin dashboard and atproto-pds-admin. Also a refused sentinel by default.
PDS_OAUTH_KEYS_JWK_SETThe PDS service signing key, as a JWK Set. It signs the client assertion this server sends when it is the OAuth client in delegated sign-in, and /oauth/jwks publishes it; the first entry signs and the rest are kept for rotation. It does not sign access or refresh tokens, so rotating it invalidates none. Generated as P-256 on first boot when unset.
PDS_PLC_ROTATION_KEY_PRIVATEThe operator-wide rotation key listed on every genesis this server signs, and the recovery path for every identity it 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.
Accounts & signup
VariableGovernsDefault
PDS_INVITE_REQUIREDWhether createAccount demands an invite code. Off by default, which means signup is open to anyone who can reach the server; a production boot with it off logs a warning naming the setting. Turn it on before first boot if that is not what you want: a PLC DID created by a stray test is permanent and public.false
PDS_ADMIN_DIDSIdentities that may mint invite codes by signing a service-auth token with the #atproto key in their own DID document — createInviteCode only. Every com.atproto.admin.* route still requires the password. Attributable in a way a shared password is not, withdrawn by editing the list, and the identity needs no account here.—
PDS_ACCEPTING_MIGRATIONSWhether an inbound migration may begin. false refuses only the createAccount that starts one; ordinary signup still works, and a migration already under way still finishes. The lever for an incident, where the alternative — a zero PDS_IMPORT_LIMIT — breaks a migration halfway.true
PDS_DELEGATION_ENABLEDWhether an account may name other identities that sign in as themselves and act as it. Off by default: it is new authority over an account reached without its password, and it makes this server an OAuth client against other people’s servers. Needs an HTTPS origin and a P-256 service key; without either, the portal says which one is missing.false
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, recorded in a com.atproto-crates.pds.policyAcceptance record in the account’s own repository, so revising them yields a new identifier and a fresh acceptance. Takes effect only alongside PDS_POLICY_URL.—
Federation & directory
VariableGovernsExample
PDS_DID_PLC_URLPLC directory, as a bare host or a full origin. Unset disables PLC genesis, so createAccount needs a caller-supplied DID — and turns off the hosted-elsewhere check Highport reconciliation relies on.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; default 0. Attempts are then spaced 0s, 15s, 1m, 3m and 15m apart and stop at the first acceptance. Around 15 suits a rolling deploy, where the first attempt otherwise lands while the old container is still serving.15
PDS_BSKY_APP_VIEW_DIDAppView identity for proxied app.bsky.* traffic. May be written <did>#<service-id> to name the service entry an rpc: scope is checked against; a bare DID means #bsky_appview.did:web:api.bsky.app
PDS_BSKY_APP_VIEW_URLAppView endpoint.https://api.bsky.app
PDS_REPORT_SERVICE_DIDModeration service identity for createReport. Without it and the URL, the route answers 503 ModerationServiceUnavailable.—
PDS_REPORT_SERVICE_URLModeration service endpoint.—
Limits & rate control
VariableGovernsDefault
PDS_BLOB_UPLOAD_LIMITOne blob through com.atproto.repo.uploadBlob, in bytes.16 MiB
PDS_IMPORT_LIMITOne repository CAR through com.atproto.repo.importRepo, in bytes. 0 is refused at boot while migrations are accepted, because it pauses a migration by breaking it halfway.1 GiB
PDS_BLOB_GRACE_HOURSHow long an unreferenced blob survives before collection. A blob is legitimately unreferenced for a while — uploadBlob stores bytes before any record names them — so the window has to outlast that. 0 disables the sweep.24
PDS_RATE_LIMITRequests per window from one client address on ordinary routes — 600 a minute, matching the reference’s global-ip bucket. Proxied app.bsky.* calls count too, and one idle signed-in Bluesky tab makes about 180 a minute. 0 disables the tier, on every backend.3000
PDS_RATE_LIMIT_AUTHThe tighter tier on authentication and account-creation endpoints. 0 disables it.150
PDS_RATE_LIMIT_WINDOW_SECSSliding-window length for both tiers.300
PDS_RATE_LIMIT_BYPASS_IPSAddresses exempt from the limiter — a relay, an AppView, a backfill job.—
PDS_OAUTH_ACCESS_TOKEN_TTL_SECONDSOAuth access-token lifetime.900
PDS_OAUTH_REFRESH_TOKEN_TTL_SECONDSOAuth refresh-token lifetime — thirty days.2592000
PDS_FIREHOSE_SEND_TIMEOUTSeconds one subscribeRepos frame may take to reach a consumer before the subscription is closed with ConsumerTooSlow. 0 restores the unbounded await.60
PDS_DPOP_NONCE_REQUIREDWhether the OAuth endpoints demand a server-issued DPoP nonce. 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, so an operator upgrading a live server can wait for a client to catch up.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 allows 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
VariableGoverns
PDS_EMAIL_SMTP_URLSMTP endpoint, lettre-style. Needs the smtp feature. Unset, or on a build without it, mail is not sent: recipient and subject are logged and the message dropped, while every mail-gated flow reports success.
PDS_EMAIL_FROM_ADDRESSEnvelope sender. Required when the SMTP URL is set.
PDS_EMAIL_LOG_BODIESLogs rendered bodies at DEBUG. Local development only: they carry live password-reset and account-deletion tokens.
Spaces
VariableGovernsDefault
PDS_SPACE_CREDENTIAL_TTL_SECONDSLifetime of an issued space credential, between 60 and 3600. 0016 caps a credential at an hour and every verifier refuses a longer one, so a larger value refuses the boot rather than minting credentials nobody accepts.600
PDS_SPACE_OPLOG_RETENTION_DAYSHow long the operation log behind listRepoOps is kept. A syncer that lags further re-syncs with space.getRepo. 0 disables the sweep.30
PDS_SPACE_NOTIFY_RETRY_MAX_ATTEMPTSAttempts before a delivery is marked failed and only the dead-letter row records it. The default spends 4m14s in backoff.8
PDS_SPACE_NOTIFY_RETRY_INITIAL_BACKOFF_MSThe unit the backoff is built from, not the first delay: the first retry waits twice it, and each after that doubles.1000
PDS_SPACE_NOTIFY_TIMEOUT_SECSPer-delivery HTTP timeout. Deliveries run serially in a background task no request timeout covers, so without it one recipient that never answers stalls every other space behind it.10
PDS_SPACE_REGISTER_NOTIFY_TTL_SECONDSHow long a registerNotify registration lasts — sixty days. A syncer renews by registering again; a lapsed registration is skipped without an error. Removing a member does not prune their registrations, so this is also the backstop revocation.5184000
PDS_NOTIFIER_INTERVAL_SECSNotifier tick: pending notifications and due write-delivery retries are drained on this cadence.5
Lexicons & OAuth scopes
VariableGovernsDefault
PDS_LEXICON_DIRLexicon documents to resolve from disk, searched after the bundled corpus and before the network — for an application’s own schemas a server cannot reach, without which an include: scope expands to nothing and every write is refused InsufficientScope. *.json is read recursively at startup; an unreadable path is fatal rather than empty.—
PDS_PERMISSION_SET_NETWORKResolve OAuth permission sets over the network before the bundle, so a grant is the set as its publisher defines it now. A bundled set falls behind: the bundled app.bsky.authFullApp lacks the video upload methods bsky.social grants. false resolves them bundle first, as records are validated. Record validation keeps the bundle first either way.true
Operations & telemetry
VariableGovernsDefault
PDS_PRODUCTIONProduction posture: refuses the development sentinels and a data directory that does not already exist, and turns some warnings into refusals.false
PDS_ALLOW_DEV_DEFAULTSPermits the sentinel JWT secret and admin password, which are constants in this crate and therefore public. Only for a deployment nobody else can reach; it has no effect under PDS_PRODUCTION.false
PDS_SHUTDOWN_DEADLINE_SECSHow long to drain after SIGTERM before exiting anyway. It has to fit inside the supervisor’s grace period: a platform that kills 30 seconds after SIGTERM would cut off a 30-second deadline at the moment it logs that the drain failed.25
PDS_METRICS_BINDAddress the Prometheus exporter listens on, e.g. 127.0.0.1:9090. A listener of its own; /metrics is never on the main one. Unset means no exporter and no counting middleware. An address that does not parse, a port already taken, or a build without metrics fails the boot.—
PDS_OTEL_ENDPOINTThe OTLP collector’s base origin; /v1/traces is appended. A malformed one is logged and the server runs without export; a build without otel refuses to boot.—
PDS_VALKEY_URLValkey or Redis for the JTI replay guard and rate limiter, in place of PDS_DURABILITY_PROFILE. Set on a build without the valkey feature, it refuses at boot rather than falling back silently.—
PDS_VALKEY_KEY_PREFIXKey namespace, for sharing one Valkey across deployments.atproto-pds:
PDS_GC_INTERVAL_SECSThe unified collector: notify attempts, email tokens, revoked credentials, PAR and code rows, replay and rate-limit windows past their retention.86400
PDS_ACCOUNT_GC_INTERVAL_SECSAccount reaper: deactivated accounts whose deletion date has passed become Deleted.3600
PDS_ADMIN_BASE_URLRead by atproto-pds-admin, not the server: the PDS it talks to. Its default only reaches a server in the same container.http://127.0.0.1:4800
Highport handle delegation — off unless PDS_HIGHPORT_DOMAINS is set; see § 10
VariableGovernsDefault
PDS_HIGHPORT_DOMAINSHandle domains whose wildcard is delegated to Highport. Each must also be in PDS_SERVICE_HANDLE_DOMAINS: this changes who answers for names, not which names are issued.—
PDS_HIGHPORT_CONTROLLER_DIDThe account hosted here that registered the bases at Highport, and whose signing key signs every call. An ordinary account, not the server’s did:web, because accepting Highport’s policies is a repo-backed act.—
PDS_HIGHPORT_SERVICE_URLHighport’s API origin, bare.https://highport.space
PDS_HIGHPORT_SERVICE_AUDThe aud of the service-auth tokens sent there, compared exactly.did:web:highport.space#bard_control
PDS_HIGHPORT_ON_FAILUREWhat a signup does when the reservation fails for a reason other than the name being taken: refuse, leaving no account and no PLC operation, or defer, creating the account with a handle that does not resolve until reconcile repairs it.refuse
PDS_HIGHPORT_SEND_GENESIS_OPSend the signed genesis with a signup’s reservation, so Highport can check offline that the DID and handle belong together.true
PDS_HIGHPORT_DRY_RUNLog every write instead of sending it. Reads still happen.false
PDS_HIGHPORT_TIMEOUT_MSBudget for one call, 100 to 60000. The signup reservation is inline, so this is a share of a signup’s latency.5000
Entryway mode — off unless PDS_ENTRYWAY_URL is set; see § 11
VariableGovernsDefault
PDS_ENTRYWAY_URLThe entryway’s origin and OAuth issuer: HTTPS, no path, no trailing slash. Setting it switches the mode on, and it is refused alongside PDS_HIGHPORT_DOMAINS.—
PDS_ENTRYWAY_DIDThe entryway’s service DID.—
PDS_ENTRYWAY_JWT_VERIFY_KEY_K256_PUBLIC_KEY_HEXThe secp256k1 public key the entryway signs session and OAuth JWTs with, as hex. During a rotation, a comma-separated list, any key of which verifies.—
PDS_ENTRYWAY_PLC_ROTATION_KEYThe entryway’s PLC rotation key, as a public did:key. Every account provisioned here lists it.—
PDS_ENTRYWAY_PLC_RECOVERY_KEYOptional: the operator’s offline recovery key, kept first in getRecommendedDidCredentials.—
PDS_ENTRYWAY_PEER_SECRET_FILEOptional: a file holding a secret sent with every call to the entryway, so it believes the forwarded client address. Without it, all of this server’s users share one sign-in and reset rate limit there.—
PDS_ENTRYWAY_REQUIRE_PROVISIONING_AUTHreserveSigningKey and createAccount with a plcOp need a service-auth token from the entryway. false only for an entryway that cannot send one.true
PDS_ENTRYWAY_ADOPTIONAnswers the two admin routes that hand a standalone server’s accounts to an entryway — one of which returns every password hash on the server. Set it for the adoption window only.false
A working single-node environmentListing 4
PDS_BIND=0.0.0.0
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=true
PDS_TRUSTED_PROXY_HOPS=1
PDS_CRAWLERS=https://bsky.network
PDS_CRAWLER_ANNOUNCE_DELAY_SECS=15
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_EMAIL_SMTP_URL=smtp://user%40example.dev:…@smtp.example.dev:2525?tls=required
PDS_EMAIL_FROM_ADDRESS=noreply@example.dev
RUST_LOG=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

Three Dockerfiles ship a pds, and a fourth does not. The plain Dockerfile at the repository root builds the CLI tools and contains no server — deploying it and expecting one is the mistake the crate’s own deployment note exists to prevent.

Images that ship a pds, all defaulting to clap,smtp,metrics,hickory-dns
DockerfileRuntimeFor
Dockerfile.pdsDistroless, root, data under /data, pds onlyThe deployment. railway.toml points at it.
Dockerfile.pds-debugThe same build on Debian, with a shell, sqlite3, curl, jq, atproto-pds-admin and a pds-actor-db helperOpening a console on a running deployment. Temporary by design: a shell there reaches the accounts database and the signing keys.
crates/atproto-pds/DockerfileDebian, non-root (pds, UID 1000), data under /var/lib/pds, a HEALTHCHECK, both binariesLocal use
Build the deployed image, naming the commit and adding object storageListing 5
docker build -t atproto-pds:deploy -f Dockerfile.pds \
  --build-arg PDS_FEATURES=clap,smtp,metrics,hickory-dns,object-storage \
  --build-arg BUILD_REV=$(git rev-parse --short=10 HEAD) .

Every stage of every image takes its feature list as PDS_FEATURES, and a test fails if any defaults to a set other than Dockerfile.pds’s. BUILD_REV is the commit the server names in /xrpc/_health, the GET / banner, its User-Agent and its startup log; .dockerignore strips .git, so without one all four say unknown. On Railway a GitHub-triggered deploy fills it in, and a railway up does not.

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. On SIGTERM or SIGINT the server cancels long-lived workers, lets in-flight requests finish and closes WebSocket subscribers cleanly, within PDS_SHUTDOWN_DEADLINE_SECS.

Caddy on-demand TLS for handle subdomainsListing 6
{
	on_demand_tls {
		ask http://localhost:3000/_tls-check
	}
}

pds.example.com, *.pds.example.com {
	tls {
		on_demand
	}
	reverse_proxy http://localhost:3000
}

/_tls-check?domain= gives the reference PDS’s answers — 200 for the server’s own hostname and for a held handle, 400 for a domain outside PDS_SERVICE_HANDLE_DOMAINS, 404 for an unheld handle — at a different path. A Caddyfile the reference installer wrote asks /tls-check and needs changing.

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.

GuardWhy 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: one container, one volume at /data, one replica, and overlapSeconds = 0 so the old container lets go of the volume before the new one mounts it. 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.
No volume, or the wrong path Every store creates its files on first use, so the server boots with an empty accounts database on the container’s own disk, passes its health check, and looks exactly like a working server until the next deploy erases it. Mount the volume at /data and set PDS_PRODUCTION=true, which refuses a data directory that does not exist.
Setting PDS_PORT The image deliberately leaves it unset so the binary falls back to the platform’s PORT. If the platform routes to a port it does not also inject, the server binds its own 4800, and the health check times out with nothing in the log to explain it. Setting PDS_PORT would appear to fix that and defeats the fallback. Leave it unset; confirm PORT is injected, or set PORT itself.
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, and the username’s @ is percent-encoded.
Looking for atproto-pds-admin in the image Dockerfile.pds ships pds alone, and the console will not start against a distroless container anyway. The admin CLI is an ordinary HTTP client, and its default base URL only reaches a server in the same container. Build it where the job runs and point PDS_ADMIN_BASE_URL at the public origin.

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

Configurations 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. Several more configurations are real and ask for something the binary you built cannot do, or contradict each other. Every one of them fails at boot, naming the variable, so nobody finds out the hard way.

Modes the binary cannot serve
ModeWhere it stops
PostgreSQL accounts DB
postgres · PDS_POSTGRES_URL
PostgreSQL is not a supported deployment mode. Setting PDS_POSTGRES_URL refuses the boot on every build, with the postgres feature or without it. AccountDirectory::open_postgres exists and nearly every accounts-DB query dispatches per dialect, but a handful of call sites above that layer — the boot path, the OAuth state store, the replay guard and rate limiter, the notifier and GC pool, the /_ready probe, the repository writer’s signing-key lookup and parts of the spaces subsystem — take a SQLite-only pool accessor that panics on a Postgres one. The authority on the list is validate_supported_backends in src/config.rs; it is not repeated as a count, because two of the sites are mutually exclusive cfg arms.
The fjall per-actor store
PDS_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.
A variable set on a build without its feature
VariableMissing feature
PDS_BLOB_STORE_URLobject-storage. Refused rather than quietly keeping blobs in the per-actor store. See § 03.
PDS_VALKEY_URLvalkey. Refused instead of falling back to the in-memory default, which used to fail later and further away as a confusing complaint about memory durability in production. The check runs ahead of the production gate so the error names the variable you actually set.
PDS_METRICS_BINDmetrics. In the default image set, so this bites only a custom PDS_FEATURES that dropped it.
PDS_OTEL_ENDPOINTotel.
Settings that contradict each other or cannot work
ConfigurationWhy it is refused
PDS_ENTRYWAY_URL with PDS_HIGHPORT_DOMAINSBehind an entryway, the entryway is the only Highport writer.
PDS_IMPORT_LIMIT=0 while accepting migrationsIt pauses migration by breaking it halfway: the account row is written and the repository refused. Use PDS_ACCEPTING_MIGRATIONS=false.
PDS_SPACE_CREDENTIAL_TTL_SECONDS above 3600Every verifier refuses a credential that outlives an hour.
The development secretsThe default JWT secret and admin password are constants in this crate, so they are public. Refused unless PDS_ALLOW_DEV_DEFAULTS is set, and always under PDS_PRODUCTION.
PDS_PRODUCTION without the data directoryA missing volume would otherwise boot an empty server.
A PLC rotation key that is public, or on another curveThe server could not sign with it.
A Highport configuration that cannot workA base not in PDS_SERVICE_HANDLE_DOMAINS, a missing controller, a malformed service URL, a controller account that does not exist here, is inactive or has no loadable key. Every complaint is reported at once. See § 10.
An incomplete entryway configurationPDS_ENTRYWAY_URL without the DID, the JWT key and the rotation key.

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. Object storage has since been implemented, so its refusal now means only that the feature is missing from the build. The Postgres hazard that remains is a library embedder’s: the Postgres-facing items are pub, and each says in its own rustdoc that it is not a deployment mode.

§ 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. It tracks the 2026-10-01 spaces alpha.

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 and syncers talk to.

com.atproto.simplespace.* — owner-side management, seven methods
MethodDoes
createSpaceMint a space under the owner’s repository.
getSpaceRead one space’s metadata.
updateSpaceChange its metadata, read policy or application access — and revoke every credential the new policy would refuse.
deleteSpaceRemove it, revoke all its credentials, and notify through notifySpaceDeleted.
putMemberAdmit an identity, or change it, with read and write access set separately.
removeMemberRemove it, and revoke that member’s outstanding credentials.
listMembersEnumerate the member list.
com.atproto.space.* — the permissioned realm, twenty methods and two aliases
GroupMethods
Records createRecord, putRecord, deleteRecord, applyWrites, getRecord, listRecords, getBlob, listBlobs
Spaces listSpaces, which needs a read_self grant
Sync listRepos (the sequenced writer set, paged by spaceRev), getRepo (the whole repository as a CAR, for a syncer past oplog retention), getLatestCommit, listRepoOps
Credentials getDelegationToken, getSpaceCredential
Notification registerNotify, unregisterNotify, and the contentless inbound hooks notifyWrite, notifySpaceDeleted and notifyCredentialRevoked
Aliases space.getSpace, answering as simplespace.getSpace, where the draft moved it; space.getRepoState, this server’s old name for getLatestCommit. Kept for callers written against an earlier draft, and not advertised by community.lexicon.service.describe. A third, simplespace.addMember, was removed: it could only ever grant read and write together.

listRecords returns values now

An earlier draft had listRecords in a space return keys only, with content a per-record fetch. It now inlines each record’s value by default, as the public realm does, and excludeValues reduces an entry to its key. Code written against the keys-only shape will receive more than it asked for, not less. A limit may go up to 1000, and repo is required.

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. A credential is bound to its holder’s key with HTTP Message Signatures (RFC 9421), which replaced DPoP in the 2026-10-01 alpha.

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_DURABILITY_PROFILE=sql keeps it in the accounts database, and PDS_VALKEY_URL moves it to Valkey or Redis; both survive a restart.

Taking a credential back early

Short expiry is the primary mechanism — ten minutes by default, an hour at most. As a space authority this server also records every credential it issues and revokes, ahead of expiry, the ones a change means it would no longer issue: removeMember and putMember for that member’s, updateSpace for every one the new policy refuses, deleteSpace for all of them. A credential a managing app authorised is kept while the same app still decides. Revocations take effect here at once and go to every other repo host serving a writer in the space through notifyCredentialRevoked, a hundred jtis a call; a host receiving one refuses that credential with 401 CredentialRevoked until it would have lapsed anyway.

Telling members something changed

Writes do not push content. A syncer registers with registerNotify — an explicit, whole-space, expiring registration, named by its service identifier — and receives contentless notifyWrite events carrying the writer’s repoRev and the space’s sequence, then fetches for itself if it wants the data. The space host sequences its writers’ updates with a spaceRev, and a writer’s repo host delivers to a remote space host through a persisted, coalescing retry queue. Deletion is announced through notifySpaceDeleted.

Notifier and retention
VariableGoverns
PDS_NOTIFIER_INTERVAL_SECSHow often the notifier ticks.
PDS_SPACE_NOTIFY_RETRY_MAX_ATTEMPTSHow many times a failed delivery is tried before it lands in the dead-letter queue. A permanent failure is not retried.
PDS_SPACE_NOTIFY_RETRY_INITIAL_BACKOFF_MSThe backoff unit; each wait doubles the one before.
PDS_SPACE_NOTIFY_TIMEOUT_SECSPer-delivery HTTP timeout. Deliveries run serially, so an unbounded one stalls every other space behind it.
PDS_SPACE_REGISTER_NOTIFY_TTL_SECONDSHow long a registration lasts before a syncer must renew it.
PDS_SPACE_OPLOG_RETENTION_DAYSHow long the operation log backing listRepoOps is kept. A syncer offline longer than this cannot catch up incrementally, gets OplogGap, and re-syncs with getRepo.

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 once relied on the first alone and now perform the second: createRecord (the only space write that lacked it), and the sync reads now named getLatestCommit 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.

§ 10

Handing handles to Highport

Optional, off unless PDS_HIGHPORT_DOMAINS is set, and a change to who answers for the handles this server issues rather than to which handles it issues. Normally this server answers /.well-known/atproto-did for every name under a service domain. With a base delegated, the domain’s wildcard points at Highport instead, and Highport answers from a reservation row — so a name under the base resolves only if this server has reserved it there, for the right DID. What that buys is that one hostname is both a handle and somewhere its holder can publish a site.

The reserve at signup is inline, between the signed PLC genesis and its irreversible submission, so a refusal costs nothing and two concurrent signups for one name resolve to one winner. A rename reserves the new name inline and releases the old one afterwards. Everything else — takedowns, deletions, a handle adopted by refreshIdentity — is a spawned, best-effort call, logged on failure and never retried.

The hourly reconcile is part of the deployment

There is no queue and no retry worker, so atproto-pds-admin highport reconcile --apply on an hourly cron is the only thing that repairs a dropped call. It exits non-zero only on a hard failure; drift alone is quiet.

What reconcile reports
RowMeaningRepaired by --apply
missingAn account here wants the name and nobody reserved it.Reserved.
orphanReserved for an account here that should no longer hold it — taken down, deleted, renamed away.Released.
mismatchReserved for a different DID from the one holding the handle here.No. Two identities want one name; a person decides.
foreignReserved for a DID with no account here.No. It may be a name the operator holds for a deployment elsewhere.
unresolvedAn orphan whose DID could not be looked up.No, not this run.
elsewhereA name whose DID another server now hosts — left behind by an account that migrated away. Not drift.No. It is the other server’s to hold or release.

Before it releases anything, reconcile asks the DID document where the identity lives, and leaves alone a name whose DID is now hosted elsewhere; the account-state follow-ups ask the same question. The check needs PDS_DID_PLC_URL. Reservations younger than ten minutes are skipped so a signup in flight is not raced. atproto-pds-admin highport release --hostname clears a mismatch or foreign row by hand, printing who holds the name before it asks.

§ 11

Behind an entryway

A PDS can run as a member of a cluster behind an entryway — atproto-entryway, or Bluesky’s. It keeps the data side of its accounts: repositories, blobs and repository signing keys. It hands the account side to the entryway: credentials, sessions, OAuth, handles and PLC rotation. Setting PDS_ENTRYWAY_URL switches the mode on, and the four required settings are the reference PDS’s, so the same values configure either implementation; entryway keys show prints them. The entryway guide covers the other side.

What changes on the PDS
AreaBehind an entryway
TokensAccess tokens are the entryway’s ES256K JWTs, verified against its public key. This server’s own HS256 sessions and OAuth grants are neither issued nor accepted. A scope of ref:<cid> is resolved with the entryway’s com.atproto.temp.dereferenceScope, checked against the CID and cached for a day.
New accountsThe entryway reserves a signing key here, signs the genesis with its rotation key, and calls createAccount with the operation; this server checks it, submits it to PLC and creates the repository. Both calls need the entryway’s service-auth token. No account here has a password, an email or a per-account rotation key.
Account methodsSessions, email, password resets, app passwords, handle changes, PLC signatures and account status run the reference’s local checks here, then go to the entryway in the account’s name. temp.checkSignupQueue is passed straight through.
Admin callbacksThe entryway changes PLC first, then tells this server through admin.updateAccountHandle, updateSubjectStatus and deleteAccount. This server re-reads the DID document rather than signing anything, and refuses a handle or an activation the document does not support.
Ending sessionsThe entryway’s tokens carry no session epoch, so when an account signs out everywhere or changes its password at the entryway, it calls admin.revokeEntrywaySessions — an extension — and this server refuses that account’s earlier access tokens from then on. Any other ended session is not announced, and its access token works here until it expires, as at a reference member.
Surfaces/.well-known/atproto-did answers 404 and resolveHandle stops answering from the local table. The OAuth authorization server and the invite methods are off, and the protected-resource metadata names the entryway. The portal’s account pages redirect there; the repository browser and spaces pages stay.
HighportThe entryway is the only writer: PDS_HIGHPORT_* is refused in this mode.

Adopting a running server

Switching an existing PDS is a migration with a maintenance window, not a restart: its accounts’ sessions and OAuth grants stop working, and their passwords and DIDs have to reach the entryway first. With PDS_ENTRYWAY_ADOPTION=true, set for that window only, the server answers two admin routes.

Hand a standalone server’s accounts to an entrywayListing 7
# Every account, its password and app-password hashes, and the unused
# invite codes. Owner-readable only; delete it after the import.
atproto-pds-admin entryway export --out bundle.json
entryway accounts import bundle.json --dry-run

# Show, then sign and submit, the PLC update that lists the recovery key and
# the entryway's rotation key directly above this server's own.
atproto-pds-admin entryway adopt --entryway-key did:key:… --recovery-key did:key:…
atproto-pds-admin entryway adopt --entryway-key did:key:… --recovery-key did:key:… --apply

adopt refuses a DID that would pass PLC’s five rotation keys, listing them so you can choose one to drop, and leaves alone a DID that already lists both. Until this server’s own keys are removed from each DID, restoring its standalone settings is the way back. The full procedure is § 9 of the entryway deployment guide.

§ 12

Administration & tests

atproto-pds-admin is an ordinary HTTP client over the server’s admin XRPC, authenticated with the admin password against --base-url. It runs anywhere that can reach the server, which on a distroless deployment is the only way it runs at all. atproto-pds-admin <command> --help documents every flag.

atproto-pds-admin commands
CommandDoes
invite list / create / disableInvite codes, following the server’s cursor to the end. --for-account attributes a code to an account.
invite disable-account / enable-accountStop or let an account create codes.
account info / searchOne account, or every match for a handle or email.
account migratingDeactivated accounts and where their DIDs now point.
account backfill-space-hostMove #atproto_space_host onto the AtprotoSpaceHost type 0016 names, with one PLC operation per account this server can sign for. --dry-run first.
account deletePurge an account’s actor database, keys and blob objects. The account row stays as a permanent tombstone, and firehose history is not rewritten.
takedown apply / lift / statusTakedowns.
highport reconcile / releaseRepair Highport delegation drift (§ 10).
entryway export / adoptHand this server’s accounts to an entryway (§ 11).

The same operations are available over com.atproto.admin.*, and many through the HTML dashboard at GET /admin.

Test matrixListing 8
# Default profile — SQLite, http, clap
cargo test -p atproto-pds --features http,clap

# Postgres accounts adapter (see § 08 before you rely on it)
cargo test -p atproto-pds --features http,clap,postgres

# Everything
cargo test --workspace --all-features

# 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 integration feature_postgres_live::