chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
0
0
# 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:
```bash
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`.
## Mail
| 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](deploy-cloudflare.md#email).
## 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](permissioned-data.md) |
## 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](../packages/git/README.md) |
| `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](../packages/git-host/README.md)
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: true` stops 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: true` drops 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. `external` implies `guardOnly`.
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](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.
```bash
cp .env.example .env
```