Docs · Self-hosting
Run the pieces yourself.
Three of the services here are things you can run instead of trusting us with: the oblivious-DNS target, the Handshake-aware Nostr pieces, and the atproto PDS. This page is what each one actually is, how to run it, and — first — where the source currently lives.
The ODoH target, the Nostr resolver, the relay and the PDS packaging are all in a private working tree. There is no public repository for any of them, and we are not going to link one that does not exist.
One consequence worth naming: our own relay's metadata advertises a GitHub URL for its source. That URL does not resolve. It is a Go module path that was written before publication and it should not be treated as a location.
Publishing is planned and licensing is the blocker — the browser's upstream lineage, the mail server we operate but do not ship, and the PDS image we wrap all need attribution settled first. Until then, this page documents mechanism you can reimplement or ask us for, not a clone URL.
The ODoH target
Oblivious DNS over HTTPS splits a lookup between two parties: a relay that sees your address but not your query, and a target that sees your query but not your address. It only works if the two are run by different people.
Ours is Cloudflare's odoh-server-go with a single patch
making the upstream nameservers configurable — that one change is what
makes it a Handshake target rather than another ICANN one. It answers
today:
curl -s https://odoh.hns.one/health # ok
curl -sI https://odoh.hns.one/.well-known/odohconfigs # 200
curl -sI https://odoh.hns.one/proxy # 404 - on purpose
Suite: X25519-HKDF-SHA256 / HKDF-SHA256 / AES-128-GCM. Access logging is off.
Running one
The build clones upstream, applies the patch and produces a static binary. It runs under systemd behind nginx, with these settings:
| Variable | Meaning |
|---|---|
SEED_SECRET_KEY |
32 bytes of hex. The ODoH keypair is derived from it — keep it stable, because clients cache the derived config for about an hour, and keep it secret |
PORT | loopback port, e.g. 8054 |
ODOH_NAMESERVERS |
a Handshake-aware recursive resolver, e.g.
127.0.0.1:5350 |
TARGET_INSTANCE_NAME |
your public hostname |
The upstream binary is a target and a relay in one process on one port. If you leave the relay route reachable, your box can relay to its own target — which sees the address and the query together, which is exactly what ODoH exists to prevent. Return 404 for the proxy path in your web server. Ours does; check yours.
Never pair your own relay with your own target. We run no relay at all, for this reason. If you run both, tell your users plainly that the separation does not hold.
A signed feed of which relays and targets are which, generated from the
public dnscrypt lists and carrying its own verification result, is at
https://hns.one/.well-known/odoh-feed.json. The MIT-licensed
client library defaults to the /dns-query target path and
fetches /.well-known/odohconfigs before its first
request.
The Handshake→Nostr resolver
This is a library and a command-line tool, not a server. It takes any of
six identity forms — alice.exist,
_@alice.exist, alice@hns.one,
npub1…, nprofile1…,
nostr:npub1… — and returns a proof object rather than
just a pubkey.
The proof has two independent layers, and the interesting field is
binding:
mutualThe name's zone points at the key, DNSSEC-validated up to the on-chain DS, and a signed profile event points back at the name. Both directions established.hns-controlThe name asserts the key; the key has not acknowledged the name.nip05-compatibleOnly the HTTPS NIP-05 route was available.noneNothing proved. It fails closed — an unsynced node yields "not proved", never a false positive.
The real resolution backend imports the browser's Handshake core — the SPV proof client, the DNS query layer and the DNSSEC validator — from a sibling directory. Lifted out on its own, the offline commands work and the resolve command does not, unless you point it at that tree or inject your own backend. Anyone packaging this needs to know that before they try.
The relay
wss://social.hns.one is an ordinary Nostr relay that also
understands Handshake names — written in Go on the khatru framework with
a badger event store. It advertises NIPs 1, 9, 11, 42, 45 and 70, and its
NIP-11 document is served at the same host to an
application/nostr+json request.
Always-on limits: a content size cap, and rejection of events dated more than fifteen minutes in the future — those sort to the top of every client's feed and stay there.
The relay has a mode that admits only events from Handshake names, and it is off. It must stay off: the suffix check matches a substring of the URL it builds, so a crafted value both passes admission and makes the relay fetch an attacker-chosen address — one request per event. That is a server-side request forgery and it is not fixed.
There are also no resource limits. A single connection issuing many subscriptions took the relay from about 15 MB to 116 MB of memory on an empty store. Put it behind something that limits connections.
One more, from experience: raise your proxy's read and send timeouts to an hour. The usual 60-second default closes long-lived websockets mid-conversation, and every client reports that as the relay dropping them.
In August 2026 the relay answered nothing from the internet for about a day. The process was healthy; its web-server route had been added by hand on the box and a routine deploy removed it. A route that exists only on the machine is one deploy away from not existing. Both the unit and the vhost are now carried in the deploy set. Worth copying the lesson, not just the config.
The PDS
pds.hns.one runs the official Bluesky Personal Data Server
image, unmodified and version-pinned, with two things wrapped around it:
a small reverse proxy that enforces a per-account storage quota the
upstream server does not implement, and portable packaging — compose
file, systemd units, a daily quota audit, and TLS either from the bundled
web server or from a DNS-01 hook.
curl -s https://pds.hns.one/xrpc/_health
{"version":"0.4.5027"}
curl -s https://pds.hns.one/xrpc/com.atproto.server.describeServer
Signup is invite-gated, and there is no phone verification. The quota
gate reads the server's own blob table read-only and returns
413 BlobQuotaExceeded before a body is forwarded, so the
cap is enforced without patching upstream.
Standing one up
An init script generates the three secrets — a JWT secret, an admin password, and a secp256k1 rotation key — writes them to a 0600 env file, and refuses to overwrite an existing one. Then the stack comes up with compose, and an account-creation script mints an invite and uses it in one step.
It holds the PLC rotation key. That key cannot be recovered, and
losing it strands every did:plc account the server ever
created. Copy it somewhere offline the moment it is generated.
The obvious configuration — service handles under
.hns.one — does not work for dotted names. The PDS
forces service-domain handles to a single label, so a name like
matt.w3 is refused as invalid characters. Production
therefore issues temporary handles under
.pds.hns.one and the real dotted handle is established
afterwards through /.well-known/atproto-did. Our own
example configuration still shows the naive value; use the sub-domain
one.
Also: the two email settings are both-or-neither. Set only one and the server refuses to start with a "partial email config" error. Leaving both blank is fine — accounts work, but self-serve password reset and email verification do not exist.
Sizing
Disk is the binding constraint, not memory. Memory idles around 250 MB and peaks near a gigabyte; two gigabytes works and four is comfortable. At the default 256 MiB per-account quota, 250 accounts need roughly 98 GB, plus about 6 GB of fixed overhead. Halve the quota and it is about 55 GB; quadruple it and it is about 350 GB.
Off-box backups are written but not wired into a schedule. Outbound mail is not configured, so password reset does not work. And the DANE pin for this host has not been re-issued since it moved to its own machine — see DANE.
The node underneath
Both the ODoH target and the Nostr resolver need a Handshake node. An SPV node is enough and is far cheaper than a full one: headers only, tens of megabytes on disk, and a sync measured in minutes rather than days.
| Measured here | Rate | Time to sync |
|---|---|---|
| Full node with transaction and address indexes | ~1 block/s | ~4 days |
| SPV, headers only, no wallet | 300–500 blocks/s | ~15 minutes |
| SPV with a wallet attached | ~77 blocks/s | ~75 minutes |
Two ports matter and they answer different questions: the recursive resolver, which is what an ODoH target should point at, and the root server, which is authoritative for the Handshake root and answers with delegations. Asking the wrong one produces plausible, wrong answers — it once made every registry-run name look like ordinary hosting.
The "safe" argument. The name-lookup RPCs take a second argument that defaults to false, and on that path an SPV node always returns null. Miss it and every name on earth reports as having no state — which reads as "you own nothing" rather than "I did not look".
The header-mismatch livelock. After a reorg, checkpointed header sync can wedge permanently and it cannot be configured away — the option is overwritten internally for SPV. The recovery is to stop the node, delete the chain directory (not the wallet directory) and restart. If it wedges a second time you have a real fork, not a glitch.
Two views of the same question. Your wallet knows a transfer landed immediately; the name-state lookup answers from a tree that commits every 36 blocks, up to about six hours behind. Ask the wallet "did my transfer land", never the name lookup.