Deploying #
Three shells serve the same dist/. None of them holds a key, keeps state, or computes anything
at request time, so the choice between them is entirely a choice about what you want to operate.
Facts marked verified were checked on 2026-08-03, either against a real corpora build or
against current vendor documentation. Where this file contradicts a number in
ARCHITECTURE, this file is the later measurement.
The loop is the same everywhere #
corpora build your machine or a release job. Needs the signing key.
<deploy> wrangler deploy | docker push | rsync. Needs no key.
corpora verify against the live URL. Needs nothing.
The middle step is the only one that differs between shells, and it is the cheap one. Working
templates: examples/worker, examples/container,
examples/standalone.
Choosing #
| Worker | Container | Standalone | |
|---|---|---|---|
| You operate | nothing | an image and a scheduler | a machine |
| Deploy is | wrangler deploy |
build, push, roll | rsync + systemctl restart |
| TLS | issued and renewed for you | your ingress' problem | your problem |
| Regions | 330+ | one, unless you pay for more | one |
| Requires | the domain's DNS at Cloudflare | a container platform | a VPS |
| Fails when | Cloudflare does | your host or scheduler does | the box does |
The Worker is the default recommendation and the reason the project exists in this shape: a script that replicates globally, holds no key, and has no origin behind it removes the failure mode described in the README outright rather than relocating it.
The other two are not consolation prizes. A container is the right answer if your other services are containers and you want one deployment story; standalone is the right answer if you object to depending on Cloudflare at all, which is a coherent position for a project about not depending on any one server.
Cloudflare Worker #
See examples/worker/README.md for the deploy steps. Two things
belong here rather than there.
The size limits, checked #
ARCHITECTURE records these as "3 MiB compressed free plan and 10 MiB on paid (reported)". Checked against Cloudflare's platform limits documentation on 2026-08-03, the numbers are right and the units are not, and there is a third limit the note omits:
| Limit | Workers Free | Workers Paid |
|---|---|---|
| Worker size after gzip | 3 MB | 10 MB |
| Worker size before compression | 64 MB | 64 MB |
| Worker startup time | 1 second | 1 second |
MB, not MiB — a 4.9% difference that matters to nobody at this scale, but the record should be
right. The uncompressed ceiling is the one worth knowing about, because data.js embeds byte
payloads as base64 and base64 is a third larger than what it encodes.
The startup limit is the one to actually watch. Cloudflare measures the time to parse and execute
global scope, and data.js decodes every payload at module load. It is nowhere near a second
today, and it is the number that would move first if a namespace grew by two orders of magnitude.
wrangler deploy reports startup_time_ms; wrangler deploy --dry-run --outdir <dir> reports
the gzipped size without deploying.
What it actually measures (verified) #
Measured against the live lexicon.atcr.io deployment on 2026-08-04, rebuilding its bundle from
what the authority serves: 28 lexicons, 42,013 bytes of record JSON, 71,268 bytes of proof CARs,
and a 36,835 byte repo CAR. That is a 231,214 byte data.js, and bundled with the serving
core and gzipped, 53,319 bytes — 1.78% of the free-plan limit.
Real lexicons are larger and less uniform than the synthetic ones this document used to quote: records range from 641 to 4,231 bytes against a 1,500 byte mean, and proofs average 2,562 rather than the 2,100 the synthetic models. Expect a real build to exceed the synthetic estimate.
ARCHITECTURE's "well under 1% of the free limit" does not hold at this shape; "comfortably
under 2%" does, with the binding limit being the gzipped one.
test/worker/bundle-size.test.js guards a quarter of the free limit, using deliberately
incompressible synthetic payloads.
These numbers move with every deploy and are a snapshot, not a contract. What the tests actually hold fixed is the budget, not the measurement.
There is no plausible lexicon namespace that does not fit. That is what makes "embed in the script, not in static assets" a free choice rather than a trade.
Container and standalone #
Both run corpora/serve behind node:http. The operational difference from the Worker is that
something must terminate TLS and stay up, and the operational difference between the two is
whether that something is your scheduler or you.
The container image contains no third-party code at all —
examples/container/README.md explains how, and why the
signing key stays out of the image build even though BuildKit's --mount=type=secret would keep
it out of the layers.
Changing your mind later #
The README says changing where you run this "is a CNAME change". That is true of the case it means and worth stating precisely, because the case it does not mean is expensive.
The question is whether the hostname changes, not whether the shell changes.
Same hostname, different shell: DNS and TLS, no rebuild #
dist/ does not mention the shell it will be served by. Moving lexicon.example.com from a
container to a Worker or back changes no byte of it. What you actually do:
- Stand the new shell up on a temporary name and check it with
corpora verify. - Provision TLS for the real hostname on the new host, before the flip. This is the step people forget, and it is the one that hard-fails resolution.
- Repoint DNS.
corpora verify --origin https://lexicon.example.comagain.
Nothing here needs the signing key, and no cached copy anywhere becomes invalid, because the signature binds records to the DID rather than to a host.
One correction to "a CNAME change": it is not a CNAME for the Worker direction. A Workers
Custom Domain requires an active Cloudflare zone in the same account, and Cloudflare creates a
proxied record it manages. You cannot point a CNAME at a Worker from an external DNS provider —
requests arriving for a hostname Cloudflare has no route for are rejected. So moving to the
Worker shell means moving the domain's nameservers to Cloudflare. Keeping DNS elsewhere and
delegating only lexicon.example.com needs a partial (CNAME) setup, which is Business plan and
above, or a subdomain setup, which is Enterprise (both verified 2026-08-03).
Moving away from the Worker is genuinely just DNS: delete the Custom Domain, add an A or CNAME record for the new host, and delete the Advanced Certificate Cloudflare created, which it does not remove for you.
Different hostname: a re-sign and a DNS migration #
Under did:web the hostname is the DID. did:web:lexicon.example.com moving to
did:web:lex.example.net is a new identity:
- Every record is re-signed. The commit carries
did, and record URIs areat://<did>/<collection>/<nsid>, so every proof CAR changes. - Every
_lexicon.*TXT record changes, one per authority domain — six of them for atcr.io. See RESOLUTION for why there is one per depth level. - Auth Servers holding a cached permission set never notice; those resolving cold get the new signer. Content-identical cutovers are safe, which is ROADMAP's whole argument.
This is a republish, not a move. It needs the signing key and it needs DNS changes in two places.
Is the service endpoint baked into the signed bytes? (verified) #
No — but changing it still needs the key.
did.json carries the #atproto_pds service entry with serviceEndpoint, and did.json is not
part of the repository, so nothing signs it. Rebuilding the same 28 lexicons with the same key and
only --service changed produced:
| Changed | Byte-identical |
|---|---|
did.json, describeRepo.json, data.js, manifest.json |
repo.car, proofs/*.car, records/*.json, commit.json, list/*.json |
The commit CID and MST root did not move. That is the design claim from RESOLUTION — "the signature binds records to the DID, not to a hostname" — confirmed by construction.
The practical consequence runs the other way, though: serviceEndpoint lives in data.js, and
data.js is emitted by corpora build, which derives the public key from the private one. So
even a change that invalidates nothing requires the signing key to be present. Budget for that
when you plan a move. Two builds of identical inputs are byte-for-byte identical (verified), so
the rebuild itself is a non-event.
TLS #
did:web resolution is an HTTPS fetch of https://<host>/.well-known/did.json, and the
TypeScript resolvers issue it with redirect: 'error' (reported, consistently, by two research
passes). Three requirements follow, and all three fail as an unexplained invalid_scope on
somebody else's login rather than as an error you see.
- HTTPS, with a certificate valid for the exact hostname the DID names. Not the apex, not
www, not a wildcard you forgot to renew. - No redirect on that path. Apex-to-www, a trailing-slash normaliser, a marketing
redirect rule with a broad match — any of them ends resolution. HTTP-to-HTTPS redirects are
fine, because resolvers request
https://and never follow the hop. - A chain that Node's fetch trusts. The consumer is an Auth Server, usually undici in Node, not a browser. A certificate that works in Chrome because Chrome patched around a chain problem is not evidence.
A dedicated subdomain for the authority makes all three easy, which is the practical argument for ARCHITECTURE's "dedicated subdomain" recommendation on top of the identity argument.
The query-string cache-key trap #
XRPC dispatches on the query string. com.atproto.sync.getRecord?rkey=A and ?rkey=B are the
same path and different records, so a cache that drops the query string collapses every lexicon
in the namespace onto whichever one was requested first and serves it with a 200, a correct
content type, and a body that parses. Verification then fails on lexicon.id === rkey — if the
consumer checks, and only once the wrong entry is cached, and only for a subset of users.
Every CDN in front of any shell needs the full query string in its cache key. What that means concretely:
Cloudflare, zone cache (in front of a container or standalone origin). The default is
correct: the standard cache key is scheme, host, path and query string, and the Caching Level
"Standard" is documented as "Delivers a different resource each time the query string changes".
Leave it there. The setting that breaks this is Caching Level "Ignore query string" —
"Delivers the same resource to everyone independent of the query string" — reachable three ways,
from the zone-wide Caching Level, from a Page Rule, and from a Cache Rule's Cache Key. On
Enterprise, the fuller control is Cache Rules → Cache Key → Query String, where
the safe choice is "All query string parameters" (custom_key.query_string.include: "*").
Do not set "Ignore query string" or an allow-list; an allow-list that omits rkey fails the same
way.
Cloudflare Workers. Not affected by any of the above: zone cache configuration does not apply
to a Worker, and Workers Caching keys on entrypoint, path and query string, ctx.props, and
Worker version. Turning it on is safe ("cache": { "enabled": true } in wrangler.jsonc).
CloudFront. The trap is the default here. The managed CachingOptimized policy — the one the
console recommends — is documented as "Query strings included in the cache key: None", as are
CachingOptimizedForUncompressedObjects and UseOriginCacheControlHeaders. Use
UseOriginCacheControlHeaders-QueryStrings, or a custom cache policy with all query strings
included.
nginx. proxy_cache_key defaults to $scheme$proxy_host$request_uri, and $request_uri
includes the query string, so the default is correct. A hand-written key using $uri is not.
Likewise proxy_pass http://host:port; with no URI part forwards the request line unchanged; a
proxy_pass with a path, or a rewrite that drops the arguments, does not.
Varnish and Fastly hash req.url including the query string by default. A vcl_hash that
normalises query strings away, or a "sort and strip" recipe copied for cache-hit ratio, breaks it.
M4 exists partly for this. corpora verify must request two
different NSIDs through the live URL and fail if the responses match, because that is the only
way to catch a misconfigured cache from outside: every individual response looks healthy.
After every deploy #
$ corpora verify --origin https://lexicon.example.com
A successful deploy is not a resolving authority. The four things that look fine and are not:
- A redirect on
/.well-known/did.json.curlfollows it by default and shows you a working document. Resolvers do not follow it at all. Check withcurl -sIand read the status. - A cache collapsing on the query string. Every response is a valid 200; only comparing two of them reveals it.
- A missing
_lexiconTXT record for a deeper authority domain. Publishing succeeded, the record is served correctly, and the NSID is unresolvable because nothing points at the DID. See ROADMAP for three real instances. - The wrong
dist/. A shell deployed against a stale build serves an olderrevwith perfectly valid signatures.manifest.jsonis not served — it is build metadata and no shell routes it — so compare against something that is:com.atproto.sync.getLatestCommitreturns thecidandrev, andmanifest.json'scommitCidandrevare what they should equal. Or setCORPORA_DIDon the container health check so it asserts identity as well as liveness.
And, once per publish, out of band: publish manifest.json's didDocHash and mstRoot
somewhere that is not the serving host — a git tag, the source repository, a release note.
did:web has no operation log, so this is the cheap compensating control that makes tampering
detectable; ARCHITECTURE argues for it.
Anyone can then check the live authority against the published value:
$ corpora verify --origin https://lexicon.example.com --expect-did-doc sha256-<the published hash>
didDocHash covers the DID document as served — compact JSON.stringify, no trailing
newline — not the pretty-printed dist/did.json on disk. Hashing the file gives a different
answer; see BUILD.