A self-hosted Blossom server with a UI on top: and a generic client for any Blossom server. One Go binary: run your own content-addressed blob store for a trusted group of npubs, and browse your blobs across every server you declare, from a server-rendered interface.
Sibling to torii ("a relay with a UI on top"): same shape, same sumi-e identity, same stances, keyless (your nsec never touches the daemon), private-by-default, thin, dependency-light, ~zero JS.
It is a Go rewrite of Bloom, a TypeScript
Blossom client, rebuilt in Go for a single static binary, native crypto, and
consistency with the Go-based infrastructure in the same ecosystem. Because it
targets the same on-relay formats, it stays interoperable with Bloom: the same
bloom-folder: folder lists, the same private-list format, and byte-for-byte-
compatible private-blob encryption, so your folders and files move between the
two without conversion. The Go packages carry // Ports …ts attributions to
their Bloom origins.
- Built-in Blossom server: built on
khatru/blossom, the same production-proven Blossom module haven uses, over a content-addressed filesystem store, gated to a trusted allowlist of npubs (BUD-01/02/04: upload / get / list / delete / mirror). - Keyless sign-in: a NIP-07 browser extension (Alby, nos2x, …) or a NIP-46
bunker; your key never touches the daemon. Any whitelisted npub can sign in,
the
owner_npubis the admin. The session persists across restarts and reconnects without re-approval. - Multi-member: whitelist several npubs, each with an isolated space (their own files, folders, and settings; identical uploads are not deduped across members). The owner admin section adds/removes members, sets per-member storage quotas, and removes with keep-or-purge of their files. Deletion is refcounted, so removing a shared blob never affects another member's copy.
- Cross-server library: lists your blobs across every server in your
kind-10063list, deduped by hash, each showing where it lives (local and/or how many servers), corrected with HEAD-verified placement. The built-in store is folded in in-process. - Encrypted drive: files and folders are one encrypted event kind (
30630), private by default, so relays and Blossom servers hold only opaque ciphertext and never learn your folder structure. Id-based parents make folder move and rename O(1) (no per-file rewrite); folders carry a color and emoji. Create, move, rename, merge (unioning same-named subfolders), download a folder's public files as a zip, and recursively delete, all as node edits, with drill-down navigation and folder-tree pickers. The library is defined by this drive index rather than reassembled from separate events; a one-way, non-destructive Bloom Migration imports a legacy library, and an Unindexed view surfaces blobs not yet in the drive so nothing looks lost. The drive key and nodes, which otherwise live only on relays, are kept in a durable local store (and backed up to peers): reads are served from that local store first, so the library paints instantly even right after a restart, and the relays are reconciled in the background, with any relay that dropped an event re-seeded from the local copy, so a relay prune or loss can't take the index or your private-file keys with it. The drive is the default storage model: a fresh instance starts on an empty drive, and a pre-drive library is brought in via a one-time, non-destructive Bloom import. Private files additionally encrypt the blob with a per-file key before upload (Blossom only ever stores ciphertext); decrypt-and-serve with Range support for media seeking. - Settings follow your account (NIP-78): layout, sort, upload and share defaults, wallpaper, pinned favorites, and Blossom-server display names sync through an encrypted app-data record keyed to your npub, so a new device inherits them after sign-in. A per-account toggle keeps them device-local instead. Pin any file or folder to a dedicated Pinned section in the sidebar.
- Upload / delete / bulk actions: store on the built-in server and replicate to your declared servers via your signer, with an optional downscale-on-upload that caps a large image's longest edge before it's stored (off by default; Settings → Uploads); multi-select for batch delete, copy, move, and zip download.
- Share links: mint a link to a file, or a whole folder, that anyone can view
in a browser, or restrict it to a specific npub (they sign in with that Nostr
key). Server-mediated so it can actually expire or be revoked: for a private
file hanami records the key at creation and
/s/<id>decrypts-and-serves (so Blossom and relays still only ever see ciphertext); a public file is proxied as-is; a folder share is a browsable snapshot of its subtree. A "Shared" view in the sidebar lists every link with its audience, expiry, and age, and revokes any. A Settings → Sharing pane holds the instance controls (public-read toggle, a master switch) and per-folder "don't allow sharing". Works while your instance is online at a public URL. - Durability: keep every file on at least N locations. Placement is
HEAD-verified (BUD-12 marks
/listoptional and servers under-report it) and trusted as the source of truth, kept fresh by a background scrub that runs by default, so the "x of y synced" status and per-server sidebar counts are right without a manual repair; a check-&-repair panel tops up under-replicated files. - Federation peers: a private backup tier of your own other instances, kept out of your public kind:10063 list. Each instance has a persistent node key and, in the background (no sign-in, no signer prompts), streams a copy of its blobs to every peer it has a URL for, staying the sole bridge (a peer never learns your public servers, so it's NAT/Tailscale-friendly), private files replicate as ciphertext. A peer's node key authorizes its backups on its own, so the member list stays humans-only. Settings → Peers manages them, with live backup status.
- Inline preview: images (proxied, resized thumbnails) and text / Markdown / code, rendered with an owned CommonMark-subset renderer (no extra dependencies). Videos get grid posters captured client-side (no ffmpeg) and cached locally, with a play badge; public posters are shared by hash, private ones cached per-owner.
- Make it yours: name your Blossom servers (they show throughout the UI instead of bare hostnames, stored as a NIP-78 event that follows your npub) and set any image as the desktop wallpaper behind the window.
Download a release binary for your platform (Linux, macOS, or Windows;
amd64/arm64) from the Releases page,
verify it against SHA256SUMS, then:
cp config.example.toml config.toml # then set owner_npub to your npub (you become the admin)
./hanami -config config.toml # → http://127.0.0.1:8787Or run from source (requires Go ≥ 1.26, no separate build step):
cp config.example.toml config.toml # then set owner_npub to your npub
go run ./cmd/hanami # → http://127.0.0.1:8787Open http://127.0.0.1:8787 and sign in with your NIP-07 browser extension or a
bunker://… URI. The owner_npub is the admin and is always a member; add more
npubs to members to let others in.
New to Nostr? You need a key and a signer that holds it. Create one with a browser
extension (Alby, nos2x) or a bunker signer
(Signet, Amber on Android), copy your npub1…
into owner_npub, and sign in with that same signer. hanami never sees your secret key.
Pull the pre-built image (published to GitHub Container Registry on every release, multi-arch linux/amd64 + linux/arm64):
cp config.docker.toml.example config.docker.toml # add your owner_npub; keep data_dir = "/data"
docker run -d --name hanami \
-p 127.0.0.1:8787:8787 \
-v hanami-data:/data \
-v "$PWD/config.docker.toml:/etc/hanami/config.toml:ro" \
ghcr.io/letdown2491/hanami:latest # or pin a version, e.g. :0.12.4Open http://127.0.0.1:8787. The image runs as a non-root user and stores blobs +
the index in the hanami-data volume.
Or build from source with Compose (handy for local hacking):
cp config.docker.toml.example config.docker.toml # add your npub
docker compose up -d --build # → http://127.0.0.1:8787Both bind to loopback by default. Publish it wider (a LAN or Tailscale IP) only
behind a TLS reverse proxy — see DEPLOY.md.
On first boot the log may warn that the dashboard is served over plaintext HTTP. With the
loopback-published port above that is expected and safe — hanami can't see the host-side
-p 127.0.0.1:… mapping, only its own 0.0.0.0 bind inside the container. It matters only
if you publish the container to a non-loopback address without a TLS proxy.
listen_addr = "127.0.0.1:8787"
data_dir = ".data"
service_url = "http://127.0.0.1:8787"
owner_npub = "npub1youownernpub…" # the instance admin (manages members + quotas)
members = [] # whitelisted npubs; each gets an isolated space
allow_public_reads = true
redundancy_target = 3
# scrub_interval_min = 180 # background durability scrub default; 0/unset = default, negative = off (owner can change it live in Settings)
# max_blob_size = 100000000 # per-upload byte cap (default 100 MB)
# max_concurrent_uploads = 8 # cap on in-flight ingestions (peak memory ≈ this × max_blob_size)
# restrict_servers_to_public = false # true = reject member server URLs on loopback/LAN/CGNAT (public instances)
# request_timeout_sec = 1800 # whole-request read+write deadline; 0/unset = 30 min, negative = off (rely on your reverse proxy)
# bootstrap_relays = ["wss://relay.primal.net", "wss://purplepag.es", "wss://nos.lol"] # relays used only to find a user's NIP-65 relay list; set to ones your host can reach
# nip11_icon_url = "https://cdn.example/icon.png" # image URL advertised in the public NIP-11 doc (defaults to a CDN so directory crawlers don't hit your server)
# nip11_banner_url = "https://cdn.example/banner.jpg"
# Federation peers (private backup tier; also addable live in Settings → Peers):
# peer_quota = 10000000000 # global cap (bytes, 10 GB here) on what a peer may back up here; 0 = unlimited
# [[peers]]
# url = "https://backup.example" # where this instance backs up to (outbound)
# npub = "npub1peernodekey…" # the peer's node key, whose backups we accept (inbound)
# name = "Backup box" # optional label
# quota = 5000000000 # optional per-peer inbound cap (bytes, 5 GB here); overrides peer_quotaowner_npub: the instance admin. Always a member, and the only key that can manage the whitelist and per-member storage quotas (Settings → Members).members: the whitelisted npubs. Each can sign into the dashboard with its own isolated space and upload/delete/list on the built-in Blossom server over the wire (BUD-11). Empty = owner-only.allow_public_reads: whenfalse,GET /<sha>is member-gated too (a fully private instance). Blobs are content-addressed, so public reads are the common default.redundancy_target: keep each file on at least this many locations (built-in store + declared servers); drives the durability status and repair.[[peers]]: federation peers, a private backup tier of your own other instances, out of the public kind:10063 list. Each entry has aurlthis instance backs up to and/or annpub(the peer's node key) whose backups this instance accepts;nameis optional. Config peers are pinned; more can be added live in Settings → Peers. This instance's own node key (shown there) is what a peer whitelists to accept your backups. A peer's inbound backups can be capped:peer_quotasets a global default (bytes; 0 = unlimited) and a per-peerquotaoverrides it (also editable in Settings → Peers).- The node key itself lives in
<data_dir>/instance_key(0600), auto-generated on first launch. To use a specific identity instead, write it there before first launch as either raw hex or a bech32nsec1…. The same key can drive a FIPS mesh transport (FIPS_NSEC); hanami accepts any peer URL, including a<npub>.fipshost (those are auto-allowed, noallowed_hostsentry needed).
- The node key itself lives in
scrub_interval_min: how often the background durability scrub HEAD-verifies placement and tops up under-replicated files (keeps the "synced" counts fresh without a manual repair).0/unset uses a sensible default; a negative value disables it. This is only the seed default: the owner can change the interval, or turn it off, live in Settings > Storage (persisted, no restart).bootstrap_relays: the relays used only to find a signed-in user's NIP-65 relay list (kind:10002); once found, reads go to the user's own relays, and a user's discovered servers/relays persist across restarts. Set this to relays your host can actually reach that hold your list, so discovery doesn't depend on hardcoded relays that may be dead or unreachable from where hanami runs. Unset uses a built-in default.nip11_icon_url/nip11_banner_url: the image URLs advertised in the public NIP-11 relay document (what relay-directory crawlers like nostr.watch read). These default to a CDN, not your own server, so those crawlers don't put traffic on your instance. Set them to your own CDN if you want custom directory branding, or to yourservice_url+/icon//bannerif you'd rather self-host and accept the crawler traffic. (The sign-in card always shows the built-in hanami banner, served same-origin as its CSP requires.)request_timeout_sec: a finite whole-request read+write deadline so no reverse proxy is strictly required to bound a slow client.0/unset is 30 minutes (generous for a large upload/download on a slow link); a negative value disables it to rely on your reverse proxy.- For a public instance set
allowed_hostsandtrusted_proxiesand put it behind a TLS-terminating reverse proxy, see deploy/PUBLIC-DEPLOYMENT.md. Otherwise bind to loopback and reach it over Tailscale/Tor.
A peer needs a URL you can reach. If both instances are behind NAT with no
public address, FIPS (a separate mesh daemon, never
bundled) can give each a stable <npub>.fips address to back up over. hanami
just speaks HTTP to that hostname; FIPS does the routing at the OS layer.
The trick is that one identity drives both: hanami's node key is the FIPS
node identity, so <npub>.fips is the same npub your peers already authorize.
On each box:
-
Install FIPS (github.com/jmcorgan/fips) and point it at hanami's key. FIPS's
node.identity.nsecconfig field accepts raw hex and always overrides its auto-generatedfips.key, and hanami's key is hex, so in/etc/fips/fips.yaml:node: identity: nsec: "<paste the contents of your hanami data_dir/instance_key>"
chmod 600 /etc/fips/fips.yaml(it now holds a secret) and restart FIPS. Confirmfipsctl show status | jq .npubmatches the node key shown in Settings → Peers. -
Bind hanami to the mesh:
listen_addr = "[::]:8443"..fipshosts are auto-allowed, so noallowed_hostsentry is needed. -
Add the peer in Settings → Peers with URL
http://<their-npub>.fips:8443and authorize its node key: the normal peer flow, just over a.fipsURL. FIPS handles discovery (a Nostr relay for internet peers, or mDNS on a LAN).
A containerized sidecar starter (identity injected from hanami's key file) is in deploy/fips-sidecar/: experimental, verify against your FIPS release.
go test ./... # unit + in-process integration (client ↔ server, bunker ↔ relay)
go test -race ./... # data-race check (hanami's own code)The crypto is vector-verified: private-blob and passphrase-envelope decryption run against golden vectors produced by WebCrypto, and NIP-44 runs the official paulmillr/nip44 reference set, so the on-wire formats stay byte-compatible with existing data.
See CHANGELOG.md for notable changes.
Contributions and bug reports are welcome, see CONTRIBUTING.md. Please report security vulnerabilities privately, see SECURITY.md, not as a public issue.
MIT © 2026 Letdown2491