Case study: galr (self-hosted gallery)

Building a zero-dependency image gallery that runs as a single Node.js process with embedded SQLite.

Context

Most self-hosted gallery software pulls in PostgreSQL, Redis, and a background worker queue before you’ve seen a single photo. I wanted something that runs as a single process you can start and forget: point it at a directory, get a gallery. No external database, no message queue, no container orchestration.

Also wanted to test Node.js’s --experimental-sqlite flag — native SQLite support without C++ bindings. If it worked, the deployment story becomes trivially simple.

Problem

Build a private gallery that handles large collections (thousands of images), supports flexible organisation by category and collection, and lets you share specific collections via time-limited links without requiring the recipient to log in. Invite-only registration — no public sign-up endpoint. Everything self-contained, no external services, no telemetry.

Approach

1. Single-process architecture

Everything runs in one Node.js process: HTTP server (Hono), database (node:sqlite), template rendering (Hono JSX), thumbnail generation (Sharp), file scanning. This works because the gallery is read-heavy — once images are scanned and thumbnails generated, almost everything is served from SQLite or the filesystem cache. The tradeoff is no horizontal scaling, but the use case (small team, private collection) doesn’t need it.

node:sqlite in WAL mode gives concurrent reads without blocking and ACID writes for metadata updates. No npm dependency, no connection management, synchronous API that fits naturally in Hono’s request handlers.

2. Lazy thumbnail generation

Thumbnails generate on first access, not during scan. Two tiers: 400px covers for grid view, 1200px for lightbox. Scanning 10,000 images takes about 10 seconds (just reads filenames). Generating 10,000 thumbnails takes 20 minutes and most collections will never be viewed — there’s no point pre-generating them. First view of an un-thumbnailed collection is slower; all subsequent views are instant from the filesystem cache.

3. Server-side rendering

No client framework. Hono JSX renders HTML on the server; the client gets ~10KB of vanilla JS for the lightbox, keyboard shortcuts, and search typeahead. Faster initial loads, works without JavaScript, simpler to reason about. The mental model is just: request → query → template → HTML.

4. Secure sharing and invite-only registration

Sharing a collection generates a cryptographically random token (16 bytes from crypto.randomBytes) stored with a collection ID and an expiry timestamp. The recipient gets a URL, the server sets an HTTP-only cookie on first visit, and access is scoped to that collection for the token’s lifetime. No account needed, links expire automatically.

Invites work the same way — admin generates a single-use token displayed as a QR code, recipient scans it, creates an account, token is marked used and deleted. No public registration endpoint at all.

5. Organisation model

Category → collection → images, which mirrors the filesystem. Adding photos is: drop files into a folder and trigger a rescan. Tags on both categories and collections for cross-cutting filters. Per-user star ratings stored per collection, with average and count denormalised onto the collections row to avoid joins in the common case.

What’s missing

  • EXIF extraction — camera, lens, and settings data is sitting in the files but not surfaced in the UI.
  • Bulk editing — tagging and rating one collection at a time gets tedious for large imports.
  • Soft delete — collections are deleted immediately with no recovery path.
  • Multi-user permissions — currently all users have full access. Viewer-only roles would be useful for shared family collections.

Stack

LayerTech
ServerNode.js + Hono
Databasenode:sqlite (native, no npm dep)
RenderingHono JSX (server-side)
ThumbnailsSharp
AuthSession cookies + bcrypt