Case study: galr (self-hosted gallery)
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-sqlite3ornode-sqlite3dependencies (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:
- Admin clicks “Share” on a collection
- Server generates random token (crypto.randomBytes(16).toString(‘hex’))
- Server creates share record:
(token, collection_id, expires_at = now + 7 days) - Server returns URL:
https://gallery.example.com/share/{token} - Recipient visits link → server sets
galr_sharecookie with token → serves collection - Images served via
/image/{id}endpoint (checks cookie for valid share token) - 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:
- Admin generates invite token via
/admin/invite - Server creates invite record:
(token, expires_at = now + 24 hours, used = false) - Server displays QR code encoding URL:
https://gallery.example.com/register?invite={token} - Recipient scans QR code → visits registration page → creates account
- 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)