Docs · Publishing
Push a folder. Get a URL.
Publishing uploads your files, adds and pins them to IPFS, and writes a pointer record in your name's zone. Updates are instant and free, and the address of your site is the hash of your site — so nobody serving it, including us, can change a byte of it without the address changing.
What happens, stage by stage
Validate, then charge
Paths, extensions, sizes and the zip manifest are all checked before anything is written to disk or any credit is spent. A zip's declared sizes are re-checked against the real decompressed bytes.
Queue a job
The upload is stored and a
publish job is queued. A worker claims it with
SELECT … FOR UPDATE SKIP LOCKED, polling every few
seconds, retrying up to three times with backoff.
Add and pin
ipfs add -Q --cid-version
1 --pin — recursive for a folder. Add and pin are the same
command; pinning is the whole point, since IPFS keeps nothing alive on
its own.
Point the zone at it
Existing
A and TXT records at that label are removed
and replaced with the new pointer. The zone serial bumps and the zone
is re-signed.
The records you end up with
<label> TXT "ipfs=bafkrei...." # the content pointer
<label> A <gateway address> # only when a gateway IP is configured
There is no _dnslink record anywhere in this system — the
pointer is that TXT, in that form. Arweave publishes use
ar=<txid> in the same slot; when both exist,
ipfs= wins.
CIDs are v1 base32. A malformed pointer is ignored rather than guessed at.
How fast a publish goes live
Measured across eight consecutive claim-and-publish runs on the live tenant plane, each one claiming a brand-new name first — the true cold start, not a re-publish:
| Stage | Range |
|---|---|
Claim a new name (/api/identity/claim) | 0.50 – 0.67 s |
Publish accepted (/api/publish) | 0.34 – 0.51 s |
| Gateway URL serves the new page | 0.85 – 4.46 s |
| End to end | 1.5 – 5.0 s |
Site size barely moves it: a 95-byte page and a five-file, 700 KB site land in the same band. The variance is the job worker's poll interval, not the upload or the IPFS add.
The authoritative nameservers for a delegated TLD —
ns1/ns2.hns.one, which is what a
Handshake-aware client reaches after reading the TLD's NS
records from the chain — return the new TXT ipfs= pointer
immediately after a publish. Verified directly against port 53.
Any resolver that sets the DNSSEC OK (DO) bit will be told the name
does not exist. This is not caching and not a validation failure
downstream — our own authoritative server produces it. When DO is set,
nsd.py answers exclusively out of the signed zone file and
returns a signed NXDOMAIN for anything absent from it, with no
fall-through to the merged records. Tenant-written records are not in
that file, because signing reads the operator zone only and the tenant
plane holds no signing key.
Consequences, stated exactly: Wildroot resolves these names (its
pointer query sets DO=0, and it proves the delegation against the chain
instead), and the gateway URL works for everyone. A third-party
DNSSEC-aware resolver will not see the name. Setting CD
does not help — it suppresses validation at the recursive, and the
denial was manufactured upstream of it.
The API
| Endpoint | Method | What it does |
|---|---|---|
/api/publish/targets | GET | Which names this key or account may publish to, plus the current limits |
/api/publish | POST | Multipart upload; returns the FQDN, the native URL and the gateway URL |
/publish/ | POST | The HTML form. Session auth, works with no JavaScript, one file at a time |
Authentication
On app.hns.one the usable scheme is a signed Nostr
authorization event — NIP-98, which is a BIP-340 Schnorr signature over
secp256k1, made with your name's control key:
Authorization: Nostr <base64 of a kind-27235 event>
The event must carry a u tag equal to
https://app.hns.one plus the full request path, a method
tag matching the HTTP method, and a created_at within 60
seconds. The multipart body is not signed, so there is no
payload tag — which name you are publishing to is settled by
resolving the target against your key, not by the body.
Each authorization event is single-use. A replay is refused with
401 authorization event already used, so you sign per
request.
A name your key does not control returns 404, never
403 — confirming a name exists here would leak which names
other keys hold.
Request shape
# one file
-F name=alice.yourtld -F file=@index.html
# a zip, unpacked as a site
-F name=alice.yourtld -F file=@site.zip
# a folder, file by file
-F name=alice.yourtld \
-F files=@index.html -F files=@style.css \
-F paths_json='["index.html","style.css"]'
paths_json is a JSON array of relative paths in the same
order as the files parts. A site or zip publish must contain
an index.html at its root; a single-file publish need
not.
The API is curl-able, but you have to produce the signed header yourself — nothing in the published tooling mints a NIP-98 header for you, and each header works exactly once. In practice the shipped path is Wildroot's publish page, which does the signing in its main process so the secret key never crosses into page context.
If you are writing your own client: sign a kind-27235 event with the
tags above, base64 the JSON, and send it as the
Authorization header. That is the whole contract.
Limits
| Limit | Value |
|---|---|
| Single file | 25 MiB |
| Total per publish | 50 MiB |
| Files per publish | 2,000 |
| Relative path length | 400 characters |
| Free claims per TLD per owner | 5 |
| Cost, control-key path | free |
Allowed extensions are a fixed list of 32 web-safe types —
.html .htm .css .js .mjs .json .txt .md .markdown .xml .svg .png
.jpg .jpeg .gif .webp .avif .ico .pdf .woff .woff2 .ttf .otf .wasm
.webmanifest .map .mp3 .mp4 .webm .ogg .wav .vtt — matched by
extension, not by sniffing content. A .zip is handled
earlier, as "unpack me as a site".
Junk is skipped silently: dotfiles, __MACOSX,
Thumbs.db. Hostile paths — .. segments, control
characters, duplicates — are refused rather than sanitised.
The edge caps request bodies at 25 MB, so a publish between 25 and 50 MiB is rejected before the application sees it. Plan against 25 MB, not 50.
Updating, rolling back, and taking things down
Publishing again to the same name replaces the pointer records. The old content stays pinned, so an old CID keeps resolving; nothing in the product walks you back to it, but you can point a record at it by hand in the DNS editor.
The dashboard lists your ten most recent publications with their CIDs and timestamps.
No endpoint, no button, no job removes published content. The only way to stop serving a site is to delete its pointer record in the DNS editor, which asks you to confirm that you are removing the pointer to your published site.
Deleting the record stops us pointing at the content. It does not unpin it, here or on any other node that has fetched it. Content addressing cuts both ways: publish nothing you would need to be able to retract.
Durability
We pin on our own IPFS node at the moment of publishing, and garbage collection is not enabled — pins are held indefinitely, by design and by omission. Content also lives on any other node that has fetched it, including the one embedded in Wildroot.
We run no redundancy across independent pinning providers, and there
is no reconciliation between the several IPFS nodes in this system —
pushes only ever add. Your site survives exactly as long as some node
keeps it pinned. If the content matters, ipfs pin add
<cid> on a node of yours, or with a pinning service, and
you are no longer relying on us for durability at all.