Blossom file server
Media hosting on its own hostname: content-addressed uploads, local or S3 storage, signed by kind-24242 auth.
Overview
nostrfy can act as a Blossom blob server: clients upload files addressed by their SHA-256 hash, and the relay serves them back. Like the REST API, it lives on a dedicated hostname on the same port.
Configuration
[blossom]
host = "media.example.com" # required — enables the feature
storage = "local" # "local" or "s3"
local_path = "./data/images" # local storage root
max_upload_bytes = 20971520 # 20 MiB
min_free_bytes = 33554432 # refuse uploads when the disk has less free space
restrict_uploads = false # only allow-listed pubkeys may upload
# For S3 / Cloudflare R2:
s3_endpoint = "https://<account>.r2.cloudflarestorage.com"
s3_region = "auto"
s3_bucket = "nostr-media"
s3_access_key = "..."
s3_secret_key = "..."Point media.example.com at the same port in your reverse proxy, then restart. GET /
on that host answers with the Blossom server info document. With storage = "s3" the
endpoint must be HTTPS unless the host is loopback (e.g. a local MinIO for testing).
Storage layout
Both backends use the <npub1...> hierarchy, keyed by the file's SHA-256:
- local — files under
<local_path>/<npub1...>/<sha256> - s3 / R2 — objects
<npub1...>/<sha256>in the configured bucket
Blob bytes never touch the relay database — LMDB holds only the sha256 → owner mapping and the upload allowlist.
Endpoints
| Method | Path | Auth | Description |
|---|---|---|---|
GET | / | — | Blossom server info |
GET / HEAD | /<sha256>[.ext] | — | Fetch / probe a blob (byte ranges, 206) |
PUT | /upload | kind 24242 (t=upload, x=sha256, expiration) | Upload a blob — 201 new, 200 already exists |
HEAD | /upload | kind 24242 (t=upload, x=sha256, expiration) | BUD-06 pre-flight — would the upload be accepted? |
PUT | /media | kind 24242 (t=media, x=sha256, expiration) | BUD-05 media upload (stored verbatim) |
HEAD | /media | kind 24242 (t=media, x=sha256, expiration) | BUD-05 pre-flight — would the upload be accepted? |
GET | /list/<pubkey> | kind 24242 (t=list, expiration) | Blobs uploaded by the requesting pubkey (cursor + limit) |
DELETE | /<sha256> | kind 24242 (t=delete, x=sha256, expiration) | Delete a blob (uploader only) |
Security notes
- User-uploaded bytes are served with
X-Content-Type-Options: nosniff. - HTML/SVG/XML/JavaScript additionally get
Content-Disposition: attachmentand a sandbox CSP, so the media origin cannot be used for stored XSS. - Tokens are accepted in the spec's base64url (no padding) form and in the padded standard form (BUD-11).
- The
X-SHA-256header is verified against the actual bytes — a mismatch returns 409. - Files are served with ETag, Cache-Control: immutable and the stored content type.
- A pubkey banned with NIP-86
banpubkeyis refused on every endpoint.
Example
# Server info
curl https://media.example.com/
# Upload (auth event from your Blossom client, e.g. via nak or the nostr-tools blossom helper)
curl -X PUT -H "Authorization: Nostr <auth>" -H "Content-Type: image/png" --data-binary @photo.png https://media.example.com/upload
# Fetch
curl https://media.example.com/<sha256>
# List your own uploads (auth event with t=list; the path pubkey must be yours)
curl -H "Authorization: Nostr <auth>" https://media.example.com/list/<pubkey-hex>
# Delete (auth event with t=delete and x=<sha256>)
curl -X DELETE -H "Authorization: Nostr <auth>" https://media.example.com/<sha256>Restricting uploads
Set restrict_uploads = true in the [blossom] section:
[blossom]
host = "media.example.com"
restrict_uploads = trueThe allowlist lives in the relay database (LMDB), managed with dedicated commands — no restart needed, the daemon reloads automatically:
nostrfy blossom allow npub1... # allow a pubkey (npub1... or hex)
nostrfy blossom deny npub1... # revoke a pubkey
nostrfy blossom list # show the list and restrict_uploadsUploads from unlisted pubkeys are rejected with 403.
Backups and migration
Back up both the configured blob storage and database.path to preserve the complete
inventory and authorization state. The sha256 → owner mapping is persisted in LMDB, so restarts are
instant and no in-memory index or startup scan is needed — lookups read the mapping directly from
the database. An automatic one-time migration rebuilds the mapping from legacy blobs on the first
start after an upgrade; a marker skips later restarts.