A serverless AT Protocol lexicon authority
atproto lexicon
README.md

Container #

The Node shell in an image, for anywhere that runs containers: Fly, Railway, Cloud Run, Nomad, Kubernetes, a box with docker compose.

The signing key is not part of the image build #

$ CORPORA_SIGNING_KEY=$(pass corpora/signing-key) \
    corpora build --lexicons ./lexicons --out ./dist --did did:web:lexicon.example.com

$ npm pack /path/to/corpora         # corpora is not on npm; see below
$ npm install                       # once, to produce package-lock.json
$ docker build -t lexicon-authority .
$ docker run -p 2583:2583 lexicon-authority

Corpora is not published to npm, and this is the one template where that needs care: npm ci runs inside the image, so a file: dependency pointing at a sibling directory is outside the build context and cannot resolve. Pack a tarball next to the Dockerfile, depend on it as "corpora": "file:./corpora-0.1.0.tgz", and add it to the COPY alongside package.json:

COPY package.json package-lock.json corpora-0.1.0.tgz ./

Verified: the image builds, HEALTHCHECK reports healthy, and corpora verify passes against the running container.

corpora build runs on your machine, or in the job that has the key. docker build then copies the finished dist/ in as ordinary build context. The key is not an ENV, not an ARG, not a mount, and not in the builder's environment.

Why not build inside the image with a BuildKit secret #

The obvious alternative is a build stage that runs corpora build itself:

RUN --mount=type=secret,id=signing_key \
    CORPORA_SIGNING_KEY=$(cat /run/secrets/signing_key) \
    npx corpora build --lexicons ./lexicons --out ./dist --did did:web:lexicon.example.com

That is genuinely safe against the layer-leak problem — --mount=type=secret exposes the file only for the lifetime of the RUN and it is never committed, unlike ARG and ENV, which are recorded in the image history where docker history will read them back to you.

It is still the worse default, for a reason that has nothing to do with layers:

  • It couples image rebuilds to key access. Base image CVEs are the usual reason to rebuild, and they arrive far more often than lexicon changes. Under this layout every one of them needs the signing key present. Under the prebuilt layout you rebuild the image whenever you like and reach for the key only when a lexicon actually changed.
  • It makes every build environment a key-handling environment. The key is exposed to the builder — a CI runner, a colleague's laptop, a remote BuildKit daemon — instead of to one deliberate offline step. PUBLISHING argues for using the key rarely and on purpose; a Dockerfile that needs it argues the opposite.
  • It makes the image non-reproducible in the way that matters. Two builds of the same Dockerfile produce two different signed commits unless the build is bit-for-bit deterministic. Prebuilding makes dist/ the fixed input, so the image is a pure function of it.

Use the secret-mount form only if your deployment genuinely cannot carry a dist/ into the build context — and if you do, note that docker build --secret still puts the key on the builder.

.dockerignore denies everything by default and re-allows four paths, so a *.key sitting in this directory cannot reach the daemon even by accident.

What is in the image #

Distroless: no shell, no package manager, no busybox. The final stage copies three things — corpora's src/serve, corpora's src/node, and your dist/.

That is not a size optimisation, it is the architecture showing through. src/serve imports nothing and src/node imports node: builtins only, so the runtime image contains no third-party code at all. npm ci installs @atproto/repo and its dependency tree because corpora build needs them, and none of it is copied forward.

Runs as nonroot (uid 65532) with no capabilities and nothing writable. It needs no volumes, no environment except PORT, and no network egress.

If you want a shell for debugging, swap the runtime base for node:22-alpine and add USER node. You lose the "no third-party code" property and gain a package manager inside your serving tier; for a process that holds no key and no state, that is a poor trade, but it is a one-line change.

Health check #

HEALTHCHECK fetches /.well-known/did.json and checks it parses as a DID document, rather than pinging a synthetic /healthz. If that endpoint is not answering correctly the authority is down, whatever the process thinks. Set CORPORA_DID to also assert the served id matches the DID you expect, which catches deploying the wrong dist/.

Kubernetes ignores Docker HEALTHCHECK. The equivalent:

readinessProbe:
  httpGet: { path: /.well-known/did.json, port: 2583 }
  periodSeconds: 30

TLS #

The container serves plain HTTP. Something in front must terminate TLS for the exact hostname the DID names, and must not redirect: did:web resolvers fetch with redirect: 'error', so an apex-to-www or http-to-https hop on that path breaks resolution outright.

If that something is a CDN, read DEPLOYING first. A CDN configured to drop query strings from its cache key will serve the wrong lexicon with a valid-looking 200.

Cost #

You are operating a process: restarts, image updates, TLS renewal, and one region unless you run several. In exchange the deployment is portable and the artifact is a container image, which matters if your other services already are.