gleam-pds #
An AT Protocol Personal Data Server, written in
Gleam on the BEAM. The package and module name is
gleam_pds, since Gleam names can't contain hyphens.
It is a real, federating PDS: accounts created on it are ingested by the
official bsky.network relay and indexed by the Bluesky appview. A running
instance serves pds.readifur.gay.
Everything lives in one OTP application and one SQLite file: no Postgres, no Redis, no sidecars. It is small enough to read end to end.
Status #
Working and deployed, but young. Treat it as a hobbyist/self-hoster PDS rather than something to put other people's accounts on:
- Blob bytes are stored inline in SQLite, so the database file is the entire server. Back it up (Litestream or equivalent) before relying on it.
- There is no blob garbage collection yet (
blob_refsexists but is unswept). - Without
GLEAM_PDS_ROTATION_KEYset, PLC operations fall back to reusing the account signing key as the rotation key. Set it (see below). - Test coverage is thin: crypto and repo units only, no HTTP or federation tests.
What works #
Repo & sync: canonical MST built through a single shared builder, dag-cbor
blocks, P-256 signed commits, getRepo CARs from a full reachability walk,
applyWrites, swap-commit/swap-record enforcement, blobs with raw-codec
(bafkrei…) CIDs.
Firehose: durable firehose_events sequencer that survives restarts,
subscribeRepos over WebSocket with cursor backfill, #commit / #account /
#identity / #sync / #info frames, throttled requestCrawl to relays on
write.
Identity: did:plc creation and updates, did:web for the server itself,
wildcard subdomain handles resolved via /.well-known/atproto-did,
resolveHandle / resolveDid / resolveIdentity / updateHandle,
signPlcOperation / submitPlcOperation.
Accounts & auth: password login by handle, DID, or email; app passwords (scoped so they cannot manage the account); passkeys/WebAuthn with challenge, origin, and counter verification; deactivate / activate / delete with cascade; per-IP and per-credential rate limiting; optional Cloudflare Turnstile on registration, verified server-side before any account work.
OAuth: authorization-code flow with mandatory PAR and S256 PKCE;
client-metadata documents fetched and validated at PAR time (registered
redirect URIs, declared grants and scopes, dpop_bound_access_tokens, with
the spec's http://localhost dev-client exception); DPoP-bound tokens with
server-issued nonces (stateless, rotating every 5 minutes) and the standard
use_dpop_nonce retry flow on both the token endpoint and resource requests;
login_hint prefill on the authorize form.
Anything under app.bsky.* or chat.bsky.* that is not stored locally is
proxied to the appview.
Layout #
src/
gleam_pds.gleam entry point: config, migrations, firehose, HTTP+WS
gleam_pds/router.gleam every route, and where rate limits are applied
gleam_pds/config.gleam environment configuration (fails fast on a weak secret)
gleam_pds/db.gleam schema + migrations; ALL SQLite access goes through here
gleam_pds/ratelimit.gleam fixed-window limits over an ETS table
gleam_pds/crypto.gleam JWT, P-256, PBKDF2, CIDs, DPoP
gleam_pds/firehose.gleam sequencer actor + subscribeRepos WebSocket
gleam_pds/plc.gleam did:plc registration and operations
gleam_pds/xrpc/ com.atproto.* handlers (server, repo, sync, identity, account)
gleam_pds/oauth/ OAuth 2.1 authorization server
gleam_pds/passkey/ WebAuthn registration and login
gleam_pds/web/ landing/login/register pages, response helpers
*_ffi.erl Erlang FFI: CBOR, MST, crypto, WebAuthn, PLC, rate limits
One rule worth knowing before you touch the FFI: Erlang FFI modules must not
talk to SQLite directly. sqlight's connection is an opaque
{esqlite3, Ref} that esqlite3:q/2 rejects. Go through the ffi_* helpers in
gleam_pds/db.gleam (gleam_pds@db:ffi_get_block/2 and friends) instead.
Running it #
Requires Gleam 1.15+ and Erlang/OTP 27+.
gleam deps download
GLEAM_PDS_SECRET=$(openssl rand -hex 32) gleam run
gleam test
Configuration #
| Variable | Default | Meaning |
|---|---|---|
GLEAM_PDS_SECRET |
none | Required. HMAC key for session/OAuth JWTs. Must be ≥ 16 chars and not a known-weak value; the server refuses to boot otherwise. |
GLEAM_PDS_ROTATION_KEY |
unset | Dedicated PLC rotation key: a 32-byte P-256 private key as 64 hex chars (openssl rand -hex 32). Accounts list only this key in their DID's rotationKeys, so a compromised signing key cannot rewrite the DID. Keep a copy somewhere safe; losing it means losing rotation control. |
GLEAM_PDS_HOSTNAME |
gleam-pds.exe.xyz |
Public hostname of the PDS; also its did:web identity. |
GLEAM_PDS_HANDLE_DOMAIN |
$GLEAM_PDS_HOSTNAME |
Domain new handles are issued under (alice.example.com). |
GLEAM_PDS_PUBLIC_URL |
https://$GLEAM_PDS_HOSTNAME |
Service endpoint published in DID documents. |
GLEAM_PDS_PORT |
8000 |
Listen port. |
GLEAM_PDS_DB_PATH |
gleam_pds.db |
SQLite file. |
GLEAM_PDS_SIGNUPS_DISABLED |
false |
true makes createAccount return 403. |
GLEAM_PDS_RATELIMIT_DISABLED |
false |
true disables rate limiting (local dev / load tests). |
GLEAM_PDS_TURNSTILE_SITE_KEY |
unset | Cloudflare Turnstile sitekey; renders the captcha widget on the registration page. Unset disables the captcha. |
GLEAM_PDS_TURNSTILE_SECRET_KEY |
unset | Matching Turnstile secret; createAccount verifies tokens against Cloudflare's siteverify directly. Keep it out of source and inject it like GLEAM_PDS_SECRET. |
Handle DNS #
For wildcard handles, point *.example.com at the server and get a wildcard
TLS certificate (DNS-01, via a _acme-challenge CNAME). Each handle is then
served from alice.example.com/.well-known/atproto-did.
Deploying #
The included Dockerfile produces a ~67 MB image, and fly.example.toml
deploys it to Fly.io with a persistent volume at /data. Copy it to fly.toml
and set your own app name and hostnames; the real fly.toml is gitignored so
deployment details stay out of the repo.
cp fly.example.toml fly.toml
fly secrets set GLEAM_PDS_SECRET=$(openssl rand -hex 32)
fly secrets set GLEAM_PDS_ROTATION_KEY=$(openssl rand -hex 32)
fly deploy
Keep auto_stop_machines = 'off': this is a stateful server, and stopping it
drops every firehose subscriber. To type-check a change without releasing it,
fly deploy --build-only --remote-only.
gleam-pds.example.service is a systemd unit for running it outside a
container; copy it to gleam-pds.service and adjust the user and paths.
Rate limits #
Applied in router.gleam, counted in memory per node (they reset on restart
and are not shared between machines).
| Endpoint | Limit | Keyed by |
|---|---|---|
createAccount |
3 / 10 min, 10 / day | client IP |
createSession, passkey login, /oauth/token |
30 / 5 min, 300 / day | client IP |
refreshSession |
60 / 5 min | credential |
Repo writes (createRecord, putRecord, deleteRecord, applyWrites, uploadBlob) |
60 / min, 1500 / hour | credential |
Exceeding one returns 429 RateLimitExceeded with a Retry-After header. The
client IP is taken from fly-client-ip, falling back to the last
x-forwarded-for entry; behind a different proxy, check that assumption.
Credentials and scopes #
A session created from the account password carries scope
com.atproto.access; one created from an app password carries
com.atproto.appPass. App-password sessions can read and write repo content
but are refused (403) on account management: creating or revoking app
passwords, deleting/deactivating/activating the account, changing the handle,
signing PLC operations, and registering passkeys. Refreshing a session
preserves its scope, so an app password cannot escalate itself.
Deactivated accounts can still sign in (the response carries
active: false), but every endpoint other than getSession,
getAccountStatus, activateAccount, and deleteAccount returns
400 AccountDeactivated.
License #
Apache 2.0, same as Gleam itself.