Case study: galr (self-hosted gallery)

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

Context

I wanted a private image gallery for browsing large photo collections without the complexity of most gallery solutions. Most gallery software is either cloud-hosted (privacy concerns) or requires multiple services (PostgreSQL, Redis, workers, message queues). I wanted something that runs as a single process you can start and forget.

Also wanted to test Node.js’s new experimental --experimental-sqlite flag—native SQLite support without C++ bindings or dependencies. If it worked, deployment would be trivial: copy the binary, point it at a photo directory, and it works.

Problem

Build a gallery that:

  • Runs as a single process (no external dependencies)
  • Handles large collections (thousands of images)
  • Supports flexible organization (category → collection → images hierarchy)
  • Provides a responsive browsing experience (lightbox, keyboard shortcuts, search)
  • Allows secure sharing (time-limited links without requiring login)
  • Works for private collections (invite-only registration, session-based auth)

Constraints

  • No external database: SQLite only, embedded in the Node.js process
  • No client framework: Server-side rendering, minimal client JS
  • No complex deployment: Single Docker container or bare Node.js binary
  • Privacy-first: self-hosted, no external services, no telemetry
  • Performance: must handle thousands of images without slowdown

Approach

1) Single-process architecture

The key decision: everything in one process.

What runs in the process:

  • HTTP server (Hono)
  • Database (node:sqlite with DatabaseSync)
  • Template rendering (Hono JSX)
  • Thumbnail generation (sharp)
  • File scanning (fs.readdir recursively)
  • Session management (in-memory store)

Why this works:

  • Gallery is read-heavy: once images are scanned and thumbnails generated, almost everything is served from SQLite or filesystem cache
  • Single-user or small-team use case (not serving thousands of concurrent users)
  • Easier to deploy: no connection strings, no separate database startup, no orchestration

Trade-offs:

  • Can’t scale horizontally (but don’t need to)
  • If the process crashes, everything goes down (but restarts are instant)
  • Memory footprint grows with session count (but still <200MB for typical usage)

2) Embedded SQLite with node:sqlite

Node.js 24 introduced --experimental-sqlite, providing synchronous SQLite access without C++ bindings.

Benefits:

  • No better-sqlite3 or node-sqlite3 dependencies (reduces attack surface)
  • Synchronous API works naturally with Hono’s request handlers
  • WAL mode for concurrent reads without blocking
  • Transactional writes (ACID guarantees for metadata updates)

Schema design:

categories (id, name, path, tags, last_scan) -- e.g., "2024"
collections (id, category_id, name, path, cover_id, tags, notes, avg_rating, view_count) -- e.g., "2024-08-Paris"
images (id, collection_id, filename, path, width, height, size, is_cover)
ratings (collection_id, user_id, stars)
favorites (entity_type, entity_id, user_id)
shares (token, collection_id, expires_at)
users (id, username, password_hash)
sessions (sid, user_id, expires_at)

Why this schema:

  • Denormalized where it helps (avg_rating, view_count stored on collections to avoid joins)
  • Indexed appropriately (path, collection_id, token for fast lookups)
  • Foreign keys enforce referential integrity (cascade deletes)

3) Server-side rendering with Hono JSX

Instead of a client framework (React, Vue, Svelte), the server renders HTML pages usingHono JSX.

Benefits:

  • Faster initial page loads (no JS bundle to download)
  • Works without JavaScript (degrades gracefully)
  • Simpler mental model (request → query → template → HTML)
  • No build step for server code (just run Node.js)

Example route:

app.get('/category/:id', (c) => {
  const category = db.query('SELECT * FROM categories WHERE id = ?').get(id);
  const collections = db.query('SELECT * FROM collections WHERE category_id = ?').all(id);
  return c.html(<CategoryPage category={category} collections={collections} />);
});

Client JS is minimal:

  • Lightbox controls (prev/next, slideshow, zoom)
  • Live search typeahead
  • Keyboard shortcuts
  • All vanilla JS, <10KB uncompressed

4) Thumbnail generation strategy

Lazy generation:

  • Thumbnails created on first access (not during scan)
  • Two tiers: cover thumbnails (400px) for grid view, gallery thumbnails (1200px) for lightbox
  • Sharp generates JPEG with 80% quality (good balance of size vs clarity)

Caching:

  • Thumbnails stored in ./thumbnails/ directory
  • Configurable TTL (default: 30 days)
  • Cleanup cron can purge old thumbnails to reclaim disk space

Why lazy instead of eager:

  • Scanning 10,000 images takes ~10 seconds (just reads filenames)
  • Generating 10,000 thumbnails takes ~20 minutes (CPU-intensive)
  • Most collections never get viewed, so don’t waste time pre-generating
  • First load is slower (wait for thumbnail), but subsequent loads are instant

5) Flexible organization with tags and ratings

Category/collection hierarchy:

  • Categories are top-level folders (e.g., “2024”, “Travel”, “Family”)
  • Collections are subfolders (e.g., “2024-08-Paris”, “2024-12-Tokyo”)
  • Images are files within collections

This mirrors filesystem structure, so adding photos is just: drop files into folders and rescan.

Tags:

  • Both categories and collections can have tags
  • Filter by tag on category list (“show only Travel categories”)
  • Filter by tag on category page (“show only Beach collections”)
  • Tags stored as comma-separated strings (simple, good enough for small datasets)

Ratings:

  • Per-user 1–5 star ratings on collections
  • Average rating + vote count displayed everywhere
  • Top Picks page ranks by avg_rating × vote_count × view_count (Wilson score would be overkill)

Notes:

  • Freeform text annotation per collection
  • Markdown-safe rendering (escapes HTML, preserves line breaks)

6) Secure sharing with time-limited tokens

Problem: Want to share a collection with someone without requiring them to create an account.

Solution: Generate a random token, associate it with a collection ID and expiration timestamp.

Flow:

  1. Admin clicks “Share” on a collection
  2. Server generates random token (crypto.randomBytes(16).toString(‘hex’))
  3. Server creates share record: (token, collection_id, expires_at = now + 7 days)
  4. Server returns URL: https://gallery.example.com/share/{token}
  5. Recipient visits link → server sets galr_share cookie with token → serves collection
  6. Images served via /image/{id} endpoint (checks cookie for valid share token)
  7. After 7 days, share expires and link stops working

Security:

  • Token is cryptographically random (not guessable)
  • Cookie is HTTP-only (can’t be read by client JS)
  • Shares expire automatically (no manual cleanup needed)
  • Shared views hide admin controls (editing, tags, ratings)

7) Invite-only registration with QR codes

Problem: Want to add users without exposing public registration.

Solution: Admin generates single-use invite tokens displayed as QR codes.

Flow:

  1. Admin generates invite token via /admin/invite
  2. Server creates invite record: (token, expires_at = now + 24 hours, used = false)
  3. Server displays QR code encoding URL: https://gallery.example.com/register?invite={token}
  4. Recipient scans QR code → visits registration page → creates account
  5. Server marks invite as used and deletes it

Why QR codes:

  • Frictionless on mobile (scan → tap → register)
  • No need to type long token strings
  • Can be displayed on screen or printed

8) Performance optimizations

Database:

  • WAL mode enables concurrent reads (multiple sessions browsing simultaneously)
  • Indexes on hot paths (collections.category_id, images.collection_id, shares.token)
  • Denormalized counts (avg_rating, view_count) to avoid expensive joins

Rendering:

  • Minimal CSS (dark theme, ~300 lines)
  • No external fonts (system font stack)
  • Lazy-load images in grid view (browser-native loading="lazy")
  • Responsive images with srcset (serve smaller thumbnails on mobile)

Caching headers:

  • Thumbnails: 1 year cache (filenames include image ID, so immutable)
  • Static assets (CSS, JS): 1 week cache
  • HTML pages: no-cache (always fresh on reload)

Result:

  • Browsing 100-image collections: <50ms server response time
  • Thumbnail generation: ~200ms per 400px cover (Sharp is fast)
  • Page loads without cache: ~300ms (1 HTML + 20 thumbnails)
  • Page loads with cache: ~20ms (just HTML)

Tradeoffs

Chose simplicity over scalability:

  • Single process works great for <10 concurrent users, not for thousands
  • In-memory session store (lost on restart, but that’s fine for small teams)
  • SQLite instead of PostgreSQL (easier deployment, fewer moving parts)

Chose server-side rendering over SPA:

  • Faster initial loads, simpler debugging, works without JS
  • Tradeoff: can’t do client-side routing or optimistic updates (but not needed for a gallery)

Chose lazy thumbnail generation over eager:

  • Faster scans, less wasted work
  • Tradeoff: first view is slower (wait for thumbnail to generate)

Chose time-limited shares over permanent links:

  • Better privacy (links expire automatically)
  • Tradeoff: need to regenerate link after 7 days if still sharing

What I learned

Embedded SQLite is underrated. For read-heavy, small-to-medium datasets (<100GB), it’s faster and simpler than PostgreSQL. No network latency, no connection pooling, no migration headaches.

node:sqlite is production-ready. Despite being experimental, it’s been stable and performant. Synchronous API is easier to reason about than async wrappers.

Server-side rendering is still great. SPAs are overkill for most CRUD apps. Rendering HTML on the server is faster, simpler, and more accessible.

Lazy is better than eager. Don’t pre-generate resources that might never be used. Generate on-demand and cache aggressively.

Zero dependencies is freeing. No npm security alerts, no version conflicts, no transitive dependency hell. Just Node.js + native modules.

Results

Current state:

  • Single Node.js process: ~150MB memory footprint with 5,000 images cached
  • Zero npm dependencies for core functionality (Hono and Sharp are the only runtime deps)
  • Sub-50ms response times for cached queries
  • Lazy thumbnail generation: first load ~200ms, subsequent loads <20ms
  • 7-day share links with automatic expiration
  • QR code invites for frictionless user onboarding

User outcomes (private testing):

  • Browsing 5,000+ images organized in 200+ collections
  • Search finds collections instantly (<10ms)
  • Lightbox keyboard shortcuts feel native (no lag)
  • Sharing collections with family takes 2 clicks

What I’d do next

  • Mobile app: PWA with offline support for favorite collections
  • Bulk editing: multi-select collections to batch-tag or rate
  • EXIF data extraction: show camera, lens, settings in image info overlay
  • Smart albums: auto-group by date, location (if GPS metadata present), or visual similarity
  • Download as ZIP: export entire collection for backup
  • Trash/archive: soft-delete collections instead of immediate deletion
  • Multi-user permissions: admin vs viewer roles (currently all users have full access)