Nostr Agent Onboarding · start here · index · source:
nostr-dev/docs/sources/blossom.md· snapshot 2026-10-10Paths such as
~/Documents/…,~/Production Environment/…,repos/…and services onlocalhostrefer to the author's workstation and are not available to you — read them as worked examples of a setup you can recreate.
Blossom — binary blob storage for Nostr
Spec home: https://github.com/hzrd149/blossom
Status: running locally as part of the ngit-relay container at
http://localhost:8081. See configs/local-services.json → always_on.ngit_relay_container.services.blossom
for the full descriptor.
What Blossom is
Blossom = "Blobs Stored Simply on Mediaservers." A protocol for storing and serving arbitrary binary blobs, addressed by their sha256, with Nostr keys for authentication. Specs are called BUDs ("Blossom Upgrade Documents").
The blob — not the URL — is the identity. The same blob is the same on every server, and any server can serve it. Mirroring is a sha256 copy, no rewriting of references.
Why it pairs with Nostr
Nostr events carry e, p, a references — pointers into the event graph.
Blossom plays the same role for content-addressed binary data: an image,
a video, an audio file, a tarball, anything. A Nostr event with a
url / imeta tag pointing at https://<host>/<sha256> is a Blossom
reference.
The clean division of labor: - Relays store events (signed JSON, small, structured). - Blossom servers store blobs (binary, large, unstructured), keyed by hash. - Pubkeys authenticate writes to both.
BUD-01 — server requirements (the surface area)
| Method | Path | Purpose | Auth |
|---|---|---|---|
GET |
/<sha256> |
fetch blob bytes | none |
HEAD |
/<sha256> |
check existence + size | none |
PUT |
/upload |
upload a blob | kind:24242 event in Authorization: Nostr <base64> header |
GET |
/list/<pubkey-hex> |
list blobs uploaded by pubkey | none |
DELETE |
/<sha256> |
delete a blob | kind:24242 event |
The auth event (kind 24242) carries:
- t tag = the action (upload, delete, list, etc.)
- x tag = the sha256 of the blob being acted on (for upload/delete)
- expiration tag = unix timestamp after which the auth is no longer valid
Spec: https://github.com/hzrd149/blossom/blob/master/buds/01.md
The BUDs you will actually meet
| BUD | What |
|---|---|
| 01 | Server requirements + blob retrieval (GET/HEAD /<sha256>) |
| 02 | Upload and management (PUT /upload, GET /list/<pubkey>, DELETE /<sha256>). /list returns { url, sha256, size, type, uploaded } per blob. |
| 03 | User Server List — the user's kind 10063, ["server", "https://…"] tags. This is how a client (or an nsite gateway) discovers where someone's blobs live. |
| 04 | Mirroring — PUT /mirror; ask one server to pull a blob from another by URL. Cheap redundancy, same sha256. |
| 05 | Media optimisation (PUT /media) — server-side transcode; returns a different sha256. |
| 06 | Upload requirements (HEAD /upload) — ask before you send, so a server can refuse on size/type without the body. |
| 09 | Blob report |
Note BUD-03 is the server list, not mirroring — a common mix-up, and it matters because 10063 is what the nsite gateway reads.
How to use the local Blossom server
From a shell with nak
NSEC=$(jq -r .nsec /dev/shm/nostr-dev-session/keys.json) # ephemeral key
# upload
nak blossom --sec "$NSEC" -s http://localhost:8081 upload ./some-file.png
# list our blobs
nak blossom --sec "$NSEC" -s http://localhost:8081 list
# fetch (no auth needed)
curl -s http://localhost:8081/<sha256> -o some-file.png
# delete
nak blossom --sec "$NSEC" -s http://localhost:8081 delete <sha256>
From TypeScript
Working example: sdk/examples/blossom-upload.ts. Run with:
cd ~/Documents/nostr-dev/sdk
NOSTR_SECRET_KEY=$(jq -r .nsec /dev/shm/nostr-dev-session/keys.json) \
npm run blossom-upload -- ./some-file.png
It computes the sha256, builds and signs a kind:24242 auth event, PUTs the blob, then verifies via HEAD + GET + list.
Public servers: what each one actually does
Blossom servers are not interchangeable. They differ on which MIME types
they accept and — critically — on whether they preserve the Content-Type you
uploaded with. That type is recorded at upload, keyed by sha256, and is what an
nsite gateway forwards to the browser, so a server that rewrites it silently
breaks stylesheets and service workers.
Measured 2026-09-21 by uploading the same probe files from a throwaway key and reading back what each server serves.
| Server | Accepts | Content-Type fidelity | Notes |
|---|---|---|---|
https://nostr.download |
everything probed | faithful — text/css, text/javascript, application/wasm all preserved |
The default first server for anything web. Serves 200 direct. |
https://blossom.primal.net |
everything probed | rewrites text/* → text/plain; application/* passes through |
Fine as an availability mirror, never as the type-of-record. 302-redirects blob GETs. |
https://azzamo.media (= https://blossom.azzamo.media) |
media only | faithful (image/png preserved) |
See below. |
https://blossom.band |
— | — | Rejects fresh-key uploads outright: 400 on text, 415 on wasm. |
https://cdn.satellite.earth |
— | — | 500 on upload and GET. Dead. |
https://inner.sebastix.social |
(invite) | faithful | API is at the root path; …/blossom/ is an HTML catch-all that 200s for everything and false-positives every dedup check. |
http://localhost:8081 (ngit-relay) |
everything probed | faithful | The local server. 100 MB/file, 50 GB total. |
azzamo.media — the owner's media server
azzamo.media and blossom.azzamo.media are the same host. It self-describes
as Azzamo Blossom 1.0.0, BUD-01/02/04/05/06/09, upload/media/mirror
enabled, discovery off. It is first in the owner's kind 10063
(npub1t6jxfqz9…, published by Amethyst), which is what their phone uploads
through.
Two things to know before reaching for it:
- Uploads are open to any key, not just the owner's — a throwaway key was
accepted. (Contrast
relay.azzamo.net, which ispayment_required+restricted_writes. The Blossom side is not gated the same way.) So "available via the Constant keypair" is true, and it is also available via any other key you sign with. - It is a media server and refuses everything else, by MIME, with HTTP 415:
| Probe | Result |
|---|---|
.png |
accepted, served as image/png |
.html |
415 — "MIME type text/html is not allowed for security reasons" |
.js |
415 — "MIME type text/javascript is not allowed for security reasons" |
.css |
415 — "MIME type text/css is not supported" |
.txt |
415 — "MIME type text/plain is not supported" |
.wasm |
415 — "MIME type application/wasm is not supported" |
So: use azzamo.media for owner-signed media — images, video, audio, the
things kind 10063 exists for, and the side-channel's file drops. Never list
it as an nsite server: an nsite is mostly HTML/CSS/JS/wasm and every one of
those uploads will 415. For nsites the list stays
nostr.download first, blossom.primal.net second — see
../patterns/nsite-publishing.md §4.
Stale entry in the owner's live 10063. It lists
blossom.azzamo.media/, thencdn.satellite.earth/, thenblossom.primal.net. The middle one returns 500 on everything — a dead fallback in a live media list. Worth republishing that 10063 from Amethyst without it. (Do not rewrite it with tooling —nsyte --publish-server-listand friends would clobber it; see../patterns/nsite-publishing.md§3.)
Quirk to know
The upload response from the local Blossom server returns
"url":"http://localhost:3334/<sha256>" — that's the internal khatru port
inside the container, not the external nginx port. Don't follow that URL;
use your configured base URL (http://localhost:8081) for subsequent
GET/HEAD/DELETE. The TS example handles this correctly by computing the
sha256 client-side and using the configured server URL.
Limits (configured in repos/ngit-relay/.env)
NGIT_BLOSSOM_MAX_FILE_SIZE_MB=100
NGIT_BLOSSOM_MAX_CAPACITY_GB=50
NGIT_OWNER_NPUB in the same file is treated as the privileged owner whose
limits don't apply. By default it's set to a placeholder npub
(npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr). If you
want the workspace's ephemeral session key to be the owner, edit .env and
restart with sudo docker compose up -d. Don't bother — for local dev the
default 100MB / 50GB caps are plenty.
When to use Blossom (vs alternatives)
- Use Blossom when: you need to attach binary content (images, video, files, archives, blobs) to Nostr events and want the references to be content-addressed and mirrorable across servers without breaking links.
- Use a CDN / object store when: the binary content has nothing to do with Nostr identity (you're just hosting static assets for a web app).
- Use IPFS when: you specifically want a global content-addressed network with active replication and existing tooling. Blossom is intentionally simpler — sha256 + HTTP, no DHT.
Related work and clients
- hzrd149's
blossom-server(TypeScript reference impl): https://github.com/hzrd149/blossom-server blossom-audit— audit a server's BUD compliance:npx blossom-audit audit http://localhost:8081 bitcoin --sec <nsec>bloss(Go),blossom-server-rs(Rust) — alternative implementations- Most modern Nostr clients (Amethyst, Damus, Coracle) already speak Blossom for image/video uploads.
How this informs design
When designing a Nostr feature that involves binary content:
- Don't store binary data inside events. Events are gossiped, indexed, replicated — fat events are expensive everywhere.
- Store the binary in Blossom; reference it by sha256 in the event.
- Publish to multiple Blossom servers for redundancy; the sha256 stays the same so consumers can fall back from one server to another.
- Consider mirroring (BUD-04) if you operate a relay — it's cheap to serve a blob you already have, and it improves the network's resilience.
- Pick the server for the content type. Media →
azzamo.media(the owner's) orblossom.primal.net. Anything a browser has to interpret — HTML, CSS, JS, wasm →nostr.downloadfirst, because it is the only public server measured to preserve those types faithfully.