Standalone #
A node process behind a reverse proxy. The "I have a VPS" path.
Deploy #
On the machine that has the signing key:
$ CORPORA_SIGNING_KEY=$(pass corpora/signing-key) \
corpora build --lexicons ./lexicons --out ./dist --did did:web:lexicon.example.com
$ rsync -a --delete server.js package.json dist/ user@host:/srv/lexicon-authority/
On the VPS:
$ cd /srv/lexicon-authority && npm install --omit=dev
$ sudo cp corpora.service /etc/systemd/system/
$ sudo systemctl enable --now corpora
Deploying is rsync plus systemctl restart. There is no database to migrate and no cache to
warm — the process reads dist/data.js at startup and never touches disk again.
npm install pulls corpora's build-time dependencies (@atproto/repo and friends) even though
the serving path imports nothing. They are unused here; if that bothers you, copy corpora's
src/serve and src/node directories in by hand and import them by relative path, or use the
container template, whose runtime stage does exactly that.
The unit file #
corpora.service runs under DynamicUser=yes with an empty capability set and a read-only
filesystem. That is easy to justify for this workload: the process holds no key, keeps no state,
opens no outbound connections, and does not need to write anything. If it is ever compromised
there is nothing in its address space that is not already public.
Two lines carry non-obvious reasons and are commented in the file: IPAddressAllow=localhost
filters inbound connections and so must change if you move off loopback, and
MemoryDenyWriteExecute is deliberately absent because V8's JIT will not start under it.
TLS #
did:web requires HTTPS on the exact hostname the DID names, and resolvers fetch
/.well-known/did.json with redirect: 'error'. A redirect on that path — apex to www, adding
a trailing slash, an HSTS-adjacent rewrite — is not a warning, it is a failed resolution.
Caddy #
lexicon.example.com {
reverse_proxy 127.0.0.1:2583
}
Certificates are automatic. Caddy's http-to-https redirect is fine: resolvers request https://
in the first place, so nothing follows it.
nginx #
server {
listen 443 ssl;
http2 on;
server_name lexicon.example.com;
ssl_certificate /etc/letsencrypt/live/lexicon.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/lexicon.example.com/privkey.pem;
location / {
# No URI part after the port, so nginx forwards the request line unchanged and
# the query string survives. XRPC dispatches on the query string, so a rewrite
# that drops it turns every getRecord into the same request.
proxy_pass http://127.0.0.1:2583;
}
}
Do not add proxy_cache without also checking proxy_cache_key. The default,
$scheme$proxy_host$request_uri, includes the query string and is correct; a hand-written key
using $uri is not, and the failure is a 200 with the wrong lexicon in it. See
DEPLOYING.
Cost #
The most operational work of the three shells: a machine to patch, a certificate to renew, a unit to watch, and one region. In exchange it is the shell with no vendor in it at all — the failure modes are ones you can inspect directly, and moving to either of the others later is a rebuild of nothing and a DNS change.