atprotocol blob server
README.md

blobserv #

A small HTTP service that serves atproto blobs from the PDS that holds them, checked against their CID as they stream. Point a hostname at it — say blob.example.com — and every blob your apps reference is fetchable at a stable, forever-cacheable URL, from any origin. It keeps no state, reads no configuration, and needs nothing but Node 26.

GET /<did-or-handle>/<cid>
curl -I https://blob.example.com/did:plc:rwypz6edxmzyhob2k3rsl2fw/bafkreig4lk2zfr4fez3ustaj5ujgcjlpwr4zzdqqou534u36h7ygd6q26m
curl -I https://blob.example.com/pipobscure.com/bafkreig4lk2zfr4fez3ustaj5ujgcjlpwr4zzdqqou534u36h7ygd6q26m

Once it is published as a bundle, the self-served archive lives here (latest always names the newest release):

https://blob.pipobscure.com/pipobscure.com/blobserv/latest/blobserv.nzip

URL format #

  • /<did-or-handle>/<cid> — the blob. The handle resolves to its DID (the _atproto TXT record, then /.well-known/atproto-did, with the DID required to claim the handle back), the DID to its document (plc.directory or did:web), and the document names the PDS. The blob comes from com.atproto.sync.getBlob there and is streamed through.
  • /<owner>/<repo>/<tag|latest>/<file> — a tangled release artifact. It resolves the repo's record in the owner's PDS, the tag in the appview's index, and the artifact record the owner published, and serves the blob that record carries, checked like any other. latest names the newest artifact of that file name.

The bytes are checked against the CID. The CID is a sha2-256 of the bytes, so the URL fixes them exactly: they are hashed as they stream, and the last chunk is held back until the hash matches. A PDS that serves anything else gets its response cut off. That is what makes it honest to say immutable.

Caching: blobs carry Cache-Control: public, max-age=31536000, immutable and ETag: "<cid>"; a matching If-None-Match is a 304 answered without asking the PDS; non-canonical paths (upper-case handle, upper-case CID) 301 to the canonical one. Errors: 404 (no such account or blob yet) caches for 5 minutes, 400 (a path that can never be a blob) for a day, 502 (the PDS or a DID host failed) never.

Safe to embed anywhere: Content-Security-Policy: default-src 'none'; sandbox, nosniff, and types a browser would run rather than show (HTML, SVG, JavaScript, PDF, …) get Content-Disposition: attachment. Cross-origin use is open (Access-Control-Allow-Origin: *, Cross-Origin-Resource-Policy: cross-origin). Range requests are left to nginx (see below). Blobs over 128 MiB are refused (--max-bytes).

Nothing is fetched from an address that is not public: every connection is checked after DNS and on every redirect, and only https is used.

Install #

Needs Node ≥ 26.11.1 — nothing else, no configuration. Two ways in:

As a bundle (the published archive, once blobserv is released):

bundle install @pipobscure.com/blobserv

From the repo:

git clone https://tangled.org/pipobscure.com/blobserv && cd blobserv
npm install && npm run release && npm run sign   # -> build/blobserv.nzip
sudo bundle install --system ./build/blobserv.nzip

Either way the command is blobserv. Then make it a service:

sudo blobserv install               # writes /etc/systemd/system/blobserv.service, enables, starts
sudo blobserv install --port=9000   # other options: --host, --max-bytes
sudo blobserv install --print       # just print the unit
sudo blobserv uninstall             # stop, disable, remove the unit
journalctl -u blobserv

The unit runs blobserv as a DynamicUser on a read-only system with no capabilities and nothing writable, listening on 127.0.0.1:8971. The installer refuses to proceed if that user could not read the node or the archive it is pointed at — which is the one way this step usually fails:

  • Node must be world-readable, not under a home directory or a group-restricted tree (nvm, fnm, a 0750 /opt/svc/node). Copy it somewhere public and install with that node: cp -a /opt/svc/node /opt/node && chmod 755 /opt/node. bundle install --system puts both where every user can read them.

nginx #

blobserv listens on loopback; nginx sits in front and adds a disk cache (20 GB, entries unused for 30 days dropped), which is also what answers range requests — with proxy_cache on, nginx fetches the whole blob once and serves every range of it from its cache. Generate the site and put it in the http context:

blobserv nginx blob.example.com > /etc/nginx/conf.d/blob.example.com.conf
mkdir -p /var/cache/nginx/blobserv && chown www-data /var/cache/nginx/blobserv  # nginx's user, before first use
nginx -t && systemctl reload nginx

The generated site has per-name listen 443 and Let's Encrypt certificate paths. Two things to adapt to your box:

  • SNI stream router: if 443 on the host is a TCP TLS-SNI router to a loopback HTTPS listener with PROXY protocol, change the site to listen 127.0.0.1:8443 ssl proxy_protocol; (+ the v6 equivalent), with set_real_ip_from 127.0.0.1; real_ip_header proxy_protocol;, and point it at the router's HTTPS port.
  • Wildcard certificate: if your zone already holds a *.example.com lineage, reuse its cert paths instead of running certbot per name.

The cache follows blobserv's Cache-Control — blobs stay, errors do not — and adds X-Cache so you can see hits.

Upgrade / uninstall #

bundle update --system blobserv     # replaces the archive in place
sudo systemctl restart blobserv     # runs the new one
sudo blobserv uninstall             # stop, disable, remove the unit

blobserv install again rewrites the unit (with any new options) and restarts the service. nginx needs nothing between versions. Removing the cache directory is all that is left behind.

Developing #

npm install
npm run check                       # tsc and the tests
npm start                           # serve on 127.0.0.1:8971 from the source
node --no-warnings src/main.ts serve --port=0 --allow-private   # a PDS on localhost

The sources are erasable-syntax TypeScript that node runs as they are, from disk and from inside the archive; tsc only checks. The one runtime dependency is multiformats, for reading CIDs.

License #

EUPL-1.2