chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
Configuration
createServer takes an options object. The example servers map environment
variables onto those options, and this page documents that mapping. The Node
example is examples/node/index.js. The container entrypoint is
docker/server.js. A deployment that imports @pdsjs/node as a library passes
the options directly and reads no environment at all.
Required
| Variable | Description |
|---|---|
PDS_PASSWORD | Password for legacy auth and OAuth consent |
JWT_SECRET | Secret that signs JWTs |
HOSTNAME | Public hostname of the PDS. The container reads PDS_HOSTNAME |
The container exits when any of the three is absent. The Node example starts without them, which suits local development and no deployment.
None of the three has a safe default. A PDS that generates its own signing secret issues tokens anyone can forge. A PDS with no account password accepts every password. A PDS that guesses its hostname serves a DID document nobody resolves.
Generate the signing secret once and keep it:
openssl rand -hex 32 > jwt-secret
A fresh secret on every restart signs out every user.
Server
| Variable | Default | Description |
|---|---|---|
PORT | 3000 | Server port |
PDS_DB_PATH | ./pds.db | SQLite database path. The container uses /data/pds.db |
PDS_BLOBS_DIR | ./blobs | Blob storage directory. The container uses /data/blobs |
PDS_ACCOUNT_UI | true | Set to false to serve no account interface |
PDS_DB_PATH and PDS_BLOBS_DIR apply to the Node and Deno servers. The
Cloudflare Worker takes its storage from bindings instead.
Federation
| Variable | Default | Description |
|---|---|---|
PLC_URL | https://plc.directory | PLC directory URL |
RELAY_URL | none | Relay that receives requestCrawl |
APPVIEW_URL | none | AppView that receives proxied app.bsky.* requests |
APPVIEW_DID | none | AppView DID. Service auth tokens name it as the audience |
Set both AppView variables or neither. The PDS signs a service auth token for
APPVIEW_DID and sends it to APPVIEW_URL.
| Variable | Default | Description |
|---|---|---|
PDS_EMAIL_TRANSPORT | none | smtp sends mail to an SMTP host. log prints it to the terminal. Applies to the Node and Deno servers |
PDS_SMTP_HOST | none | SMTP host, for the smtp transport |
PDS_SMTP_PORT | none | SMTP port, for the smtp transport |
PDS_EMAIL_FROM | none | Sender address. For the Node/Deno smtp transport, an override that defaults to no-reply@localhost. On Cloudflare, the sender mail is sent as; absent, no-reply@HOSTNAME is used |
Without a sender the PDS stores an address and confirms nothing. It then issues no confirmation code, and a session stays the only authorization for a PLC identity operation.
The docker compose stack runs Mailpit, so PDS_EMAIL_TRANSPORT=smtp in local
development shows sent mail in a browser.
The Cloudflare Worker has no transport switch: the EMAIL binding is the
sender. Mail is enabled only when the binding is present and a sender
resolves — PDS_EMAIL_FROM, else no-reply@HOSTNAME. The From address's
domain must be onboarded for Email Sending. See
Deploy: Cloudflare Workers.
Backups
| Variable | Default | Description |
|---|---|---|
PDS_BACKUP_DIR | none | Directory that receives scheduled backups |
PDS_BACKUP_S3_BUCKET | none | S3-compatible bucket that receives scheduled backups |
PDS_BACKUP_S3_ENDPOINT | none | Bucket endpoint |
PDS_BACKUP_S3_REGION | us-east-1 | Bucket region, where the provider needs one |
PDS_BACKUP_S3_ACCESS_KEY | none | Bucket access key |
PDS_BACKUP_S3_SECRET_KEY | none | Bucket secret key |
A directory is the simple case. Point it at a different disk than the database. The S3 path needs no SDK, and it works with Bunny, Backblaze B2, MinIO, R2, and AWS. With neither set, the backups page reports backups as unavailable.
Features
Boolean switches are named PDS_ENABLE_<FEATURE>; variables that carry a
value are named PDS_<FEATURE>_<PROPERTY>.
| Variable | Default | Description |
|---|---|---|
PDS_ENABLE_SPACES | false | Enable permissioned data |
Experimental features
Experiments are exempt from semver: any PDS_EXPERIMENTAL_* variable, and
anything under the server adapters' experimental option, may change shape
or disappear in any release, including patches. A feature graduates by
moving to the stable names above, keeping the experimental names as aliases
for one more release.
| Variable | Default | Description |
|---|---|---|
PDS_EXPERIMENTAL_QUERIES | false | Serve dev.pdsjs.query.run over this repo's dev.pdsjs.query.def records. A definition names a live view over one collection, with equality filters and one sort field; the run endpoint returns the whole view as JSON in one request. Queries read the public repo only |
PDS_EXPERIMENTAL_GIT_HTTP | false | Serve hosted git repositories to stock git over read-only smart HTTP under /git/. See the git package |
PDS_EXPERIMENTAL_GIT_BROWSE | true | File browsing of hosted git repositories on the account page. Set to false to turn it off |
PDS_EXPERIMENTAL_SITE_DOMAIN | none | Apex domain static sites serve under. Setting it enables the feature |
PDS_EXPERIMENTAL_SITE_APEX_NAME | home | Site served at the apex of the site domain |
Git hosting outside the PDS
Git hosting does not have to live in the PDS. The git-host package
is a separate service that reads the account's records and blobs over the
public XRPC surface, serves git smart HTTP, the file browser, the
dev.pdsjs.git.* methods and the browser app from its own origin, and
indexes collaboration records off Jetstream. The PDS stays the data store
and the identity provider; pushes go to the PDS exactly as before.
The Node adapter moves the reads out with experimental: { git: { external: true } }. Two halves of the enforcement question deserve their own names:
external: truestops the PDS serving git entirely. Branch protection loses its enforcement point, because the only place a write veto means anything is the server that holds the record, and that is no longer a git server.guardOnly: truedrops the routes and keeps the write guard. Protected branches still refuse force pushes and deletion at the record write, and the external forge can say what it likes.externalimpliesguardOnly.
On Cloudflare the embedded path is already opt-in through the
PDS_EXPERIMENTAL_GIT_* variables; an external forge needs nothing of the
PDS but a running one.
Static sites
Sites are off without a site domain, and the Node and Deno servers take one as
the experimental: { sites: { domain } } option instead. A site's record key
is a DNS label and that
label is the subdomain it answers on, so blog serves at blog.<site domain>.
The site domain has to differ from HOSTNAME. The PDS's own hostname is
checked first and never reaches the site handler, so a site domain equal to it
serves the PDS and no site. Keeping them apart is also what keeps
repo-controlled HTML off the origin holding the account session cookie.
The hostnames have to reach the server. On Cloudflare that means proxied DNS
records and Worker routes; one wildcard of each covers every site so a new
deploy needs no configuration. Routes only intercept proxied traffic, so a
DNS-only record bypasses the Worker silently. Universal SSL covers a single
wildcard level, so *.example.com needs no extra certificate but
*.sites.example.com does. deploy-cloudflare.md has
the full route and DNS shape.
www with no site named www deployed redirects to the apex, path and query
kept.
Sites serve over HTTPS only. Responses carry a year of HSTS, and on Cloudflare a plain-HTTP request is redirected before it reaches the server, because a custom domain answers on both schemes.
Every servable site hostname also answers /.blobs/<cid> with that blob
from the repo, immutable and sandboxed, so a client-rendered site can show
repo media without a manifest entry per file. The same bytes are already
public at com.atproto.sync.getBlob.
Files serve with Cache-Control: public, max-age=60, must-revalidate unless
the deploy set a per-file value (pdsjs-site deploy --cache-control). Content
is addressed by hash, so fingerprinted assets can safely take
public, max-age=31536000, immutable. On Cloudflare, site responses are also
held in the edge cache for their max-age, so repeat requests are answered at
the edge instead of crossing to the Durable Object's region.
Example file
.env.example in the repository root holds a template for the Node server.
cp .env.example .env