Blossom Server

August 3, 2026 · View on GitHub

A content-addressed blob storage server implementing the Blossom protocol. Files are stored and retrieved by their SHA-256 hash. Built with Deno 2, Hono, and LibSQL.

Features

  • BUD-01 — Blob retrieval (GET/HEAD /:sha256) with range requests, ETag/304, and CORS
  • BUD-02 — Upload (PUT /upload), delete (DELETE /:sha256), and list (GET /list/:pubkey)
  • BUD-04 — Server-side mirror (PUT /mirror) with SSRF protection
  • BUD-05 — Media optimisation (PUT /media): image resize/convert via sharp, video transcode via ffmpeg
  • BUD-06 — Upload preflight (HEAD /upload) to check size, type, and pool availability before sending the body
  • BUD-08nip94 field in all blob descriptor responses
  • BUD-09 — Blob reports (PUT /report) accepting NIP-56 kind:1984 events
  • BUD-11 — Nostr-signed event authentication (kind 24242)
  • Zero-copy streaming uploads — no body buffering, SHA-256 computed in a dedicated worker pool
  • Content-addressed deduplication — re-uploading an existing hash skips the write
  • Configurable storage retention rules with MIME-type glob patterns and per-pubkey scoping
  • Automatic prune loop — expired blobs are removed on a configurable timer
  • Local filesystem and S3-compatible storage backends
  • Optional server-side rendered admin dashboard at /admin (Hono JSX)
  • Optional server-rendered landing page at /
  • Docker-ready with a single-stage Dockerfile and health check

Requirements

  • Docker + Docker Compose (recommended)
  • or Deno 2.x for running from source

Quick Start — Docker

# 1. Copy and edit the config
cp config.example.yml config.yml

# 2. Set at minimum: port, publicDomain, and storage backend
#    (see Configuration below)

# 3. Start the server
docker compose up --build

The server listens on port 3000 by default. Blob data and the SQLite database are stored in a named Docker volume (data). The config file is mounted read-only from the host.

Quick Start — From Source

# 1. Clone the repo
git clone https://github.com/hzrd149/blossom-server.git
cd blossom-server

# 2. Copy and edit the config
cp config.example.yml config.yml

# 3. Build the landing page client bundle
deno task build

# 4. Start in development mode (file-watching)
deno task dev

For production:

deno task build
deno task start

Quick Start — Nix

This repository includes a flake that builds the server reproducibly with a pinned Nixpkgs input and a fixed-output Deno dependency cache. The build also pre-builds the landing page client bundle into the package output. The pinned cache is currently provided for x86_64-linux.

# Build the package
nix build .#blossom-server

# Run the packaged server from the current directory
# Configuration still defaults to ./config.yml, and relative data paths are
# resolved from the directory where this command is run.
nix run .# -- ./config.yml

# Enter a development shell with Deno and ffmpeg on PATH
nix develop

The packaged wrapper includes ffmpeg/ffprobe on PATH for media handling. If your Nix install does not enable flakes globally, add --extra-experimental-features 'nix-command flakes' to the nix commands.

NixOS service

The flake exports nixosModules.default, which runs Blossom as a hardened systemd service with persistent state in /var/lib/blossom-server:

{
  inputs.blossom-server.url = "github:hzrd149/blossom-server";

  outputs = { nixpkgs, blossom-server, ... }: {
    nixosConfigurations.example = nixpkgs.lib.nixosSystem {
      system = "x86_64-linux";
      modules = [
        blossom-server.nixosModules.default
        {
          services.blossom-server = {
            enable = true;
            openFirewall = true;

            settings = {
              host = "0.0.0.0";
              publicDomain = "blobs.example.com";
              storage.backend = "local";
            };
          };
        }
      ];
    };
  };
}

For S3, Turso, or dashboard credentials, put environment variable placeholders in settings and provide a root-readable environment file. Secrets must not be written directly in settings, because the generated YAML is stored in the world-readable Nix store.

services.blossom-server = {
  environmentFile = "/run/secrets/blossom-server.env";

  settings.storage = {
    backend = "s3";
    s3 = {
      endpoint = "https://s3.example.com";
      bucket = "blossom";
      accessKey = "\${S3_ACCESS_KEY}";
      secretKey = "\${S3_SECRET_KEY}";
    };
  };
};

The corresponding environment file contains:

S3_ACCESS_KEY=example
S3_SECRET_KEY=secret

This works with runtime paths produced by secret managers such as sops-nix or agenix. The module defaults to 127.0.0.1:3000 and keeps the firewall closed, which is suitable when running behind a reverse proxy.

For a complete, runnable example, see the NixOS VM guide. It builds a QEMU VM from this flake and also shows how to activate the same configuration on an existing NixOS host with nixos-rebuild.

Pass a custom config path as the first argument:

deno task start /etc/blossom/config.yml

Configuration

Configuration is loaded from a YAML file (default: config.yml in the working directory). Environment variables can be substituted anywhere in the file using ${VAR_NAME} syntax.

Key Options

KeyDefaultDescription
port3000TCP port to listen on
host0.0.0.0Bind interface (127.0.0.1 for loopback-only behind a proxy)
publicDomain(Host header)Bare hostname this server is publicly reachable at, used in blob URLs and BUD-11 server-tag validation (no https://)
database.pathdata/sqlite.dbLocal SQLite database path
database.urlRemote libSQL/Turso URL (libsql://your-db.turso.io or http://localhost:8080)
storage.backendlocalStorage backend: local or s3
storage.local.dir./data/blobsDirectory for blob files (local backend)
storage.removeWhenNoOwnersfalseDelete blobs with no owners on each prune cycle, regardless of expiry rules
upload.enabledtrueEnable PUT /upload
upload.requireAuthtrueRequire Nostr auth for uploads
upload.maxSize2147483648Maximum upload size in bytes (2 GB)
upload.workers0Upload worker threads (0 = one per CPU core)
upload.requirePubkeyInRulefalseReject uploads unless the uploader's pubkey appears in a storage rule
mirror.enabledtrueEnable PUT /mirror (BUD-04)
mirror.connectTimeout30000Timeout (ms) to connect to the origin; 0 = no limit
mirror.bodyTimeout0Timeout (ms) for full body transfer from origin; 0 = no limit
delete.requireAuthtrueRequire Nostr auth for deletes
list.enabledfalseEnable GET /list/:pubkey (BUD-02); disabled by default
list.requireAuthfalseRequire Nostr auth for list requests
list.allowListOtherstrueAllow listing blobs belonging to a different pubkey
media.enabledfalseEnable PUT /media (BUD-05); requires ffmpeg for video
report.enabledtrueEnable PUT /report (BUD-09)
landing.enabledtrueEnable the landing page at /
landing.titleBlossom ServerPage title shown in <title> and <h1>
dashboard.enabledfalseEnable the admin dashboard at /admin

For all options with inline documentation, see config.example.yml.

S3 Storage Backend

storage:
  backend: s3
  s3:
    endpoint: https://s3.amazonaws.com
    bucket: my-blossom-bucket
    accessKey: "${S3_ACCESS_KEY}"
    secretKey: "${S3_SECRET_KEY}"
    region: us-east-1
    # Optional: redirect GET requests to this URL prefix instead of proxying
    # publicURL: https://my-bucket.s3.amazonaws.com/
    # Local buffer directory for uploads before committing to S3
    tmpDir: ./data/s3-tmp

Storage Retention Rules

Rules serve as both an upload allowlist and a retention policy. The first matching rule governs a blob's expiry. When the list is non-empty, blobs whose MIME type matches no rule are rejected with 415 Unsupported Media Type.

storage:
  rules:
    - type: "image/*"
      expiration: 1 month
    - type: "video/*"
      expiration: 1 week
    - type: "*"
      expiration: 1 week

Rules can be scoped to specific Nostr pubkeys (hex) to give certain users different retention:

storage:
  rules:
    - type: "image/*"
      expiration: 1 year
      pubkeys:
        - "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d"
    - type: "image/*"
      expiration: 1 month
    - type: "*"
      expiration: 1 week

Authentication (BUD-11)

All authenticated endpoints expect a Nostr-signed event in the Authorization header:

Authorization: Nostr <base64-encoded-JSON-event>

The event must be kind 24242 and include:

TagRequiredDescription
tYesVerb for this token: upload, delete, get, or list
expirationYesUnix timestamp after which the token is invalid
xNoOne or more SHA-256 hashes scoping the token to specific blobs
serverNoHostname(s) this token is valid for

Example event (before signing):

{
  "kind": 24242,
  "content": "Authorize upload",
  "tags": [
    ["t", "upload"],
    ["expiration", "1735689600"],
    ["server", "blobs.example.com"]
  ],
  "created_at": 1704067200
}

API Reference

Blob Endpoints (BUD-01 / BUD-02)

MethodPathAuthDescription
GET/:sha256[.ext]OptionalDownload a blob by its SHA-256 hash. Extension is advisory. Supports Range, If-None-Match.
HEAD/:sha256[.ext]OptionalSame as GET without the body.
PUT/uploadRequired*Upload a blob. Content-Length is required. Returns a BlobDescriptor.
HEAD/uploadRequired*Preflight check (BUD-06). Send X-Content-Length, X-Content-Type, X-SHA-256 to verify the server will accept the upload before sending the body.
DELETE/:sha256Required*Delete a blob. The file is removed when the last owner deletes it.
GET/list/:pubkeyOptional*List blobs uploaded by a pubkey. Disabled by default (list.enabled: false).

Mirror Endpoint (BUD-04)

MethodPathAuthDescription
PUT/mirrorRequired*Fetch a remote blob and store it locally. JSON body: { "url": "https://..." }. SSRF-guarded.

Media Endpoint (BUD-05)

MethodPathAuthDescription
PUT/mediaRequired*Upload an image or video; the server optimises/transcodes it and returns the optimised blob descriptor.
HEAD/mediaRequired*Preflight check for the media endpoint.

Report Endpoint (BUD-09)

MethodPathAuthDescription
PUT/reportNoneSubmit a NIP-56 kind:1984 Nostr event to flag a blob for operator review.

* Auth requirement is configurable per-endpoint via requireAuth in the config.

Response Format

Successful upload, mirror, and media responses return a BlobDescriptor:

{
  "sha256": "b1674191a88ec5cdd733e4240a81803105dc412d6c6708d53ab94fc248f4f553",
  "size": 184292,
  "type": "image/jpeg",
  "url": "https://blobs.example.com/b1674191a88ec5cdd733e4240a81803105dc412d6c6708d53ab94fc248f4f553.jpg",
  "uploaded": 1704067200,
  "nip94": {
    "tags": [
      ["url", "..."],
      ["x", "..."],
      ["size", "..."],
      ["m", "..."]
    ]
  }
}

Error Responses

All error responses use Content-Type: text/plain. The reason is included in both the response body and an X-Reason header.

Admin Dashboard

Enable the server-rendered admin dashboard (Hono JSX, no separate SPA) to manage blobs, users, rules, and reports:

dashboard:
  enabled: true
  username: admin
  password: "" # Auto-generated and logged to stdout on first startup if blank
  # Nostr relays used to look up kind:0 profiles in the user detail view
  lookupRelays:
    - wss://purplepag.es
    - wss://index.hzrd149.com
    - wss://indexer.coracle.social

The dashboard is available at http://localhost:3000/admin and is protected by HTTP Basic Auth. It provides pages for:

  • Blobs — browse, search, and force-delete blobs
  • Users — list uploaders with Nostr profile metadata lookup
  • Rules — view active storage retention rules
  • Reports — review and dismiss BUD-09 blob reports

Development

# Build the landing page client bundle (output: public/client.js)
# Required before running `deno task dev` or `deno task start` when the
# landing page is enabled.
deno task build

# Start with file-watching
deno task dev

# Run the test suite
deno task test

# Run a single test file
deno test --env-file=.env --allow-net --allow-read --allow-write --allow-env --allow-ffi --allow-sys tests/unit/auth.test.ts

# Lint
deno lint

# Format
deno fmt

Migrating from the Legacy Node.js Server

If you have an existing database from the original Node.js blossom-server, the migration script imports all blob metadata atomically:

deno task migrate-from-legacy

The script reads the legacy SQLite database, imports all records into the Deno server's schema, and performs an atomic file swap. Blob files on disk are left untouched.

License

MIT