Self-hosted client photo gallery for divass. Public at gallery.divass.space, admin at /admin.
- Next.js 15 (App Router, TypeScript strict)
- Tailwind 4
- Postgres 16 via
postgres.js - MinIO via
@aws-sdk/client-s3 iron-sessionadmin auth,@node-rs/argon2password hashing- Vitest + testcontainers for tests
Double-click dev.bat. It:
- Boots Postgres + MinIO containers (with CORS + checksum-free presigning)
- Applies migrations, seeds the admin (
admin@divass.space/demo1234) - Starts
next devon http://localhost:3000
Open http://localhost:3000 — you'll be redirected to /admin/login.
dev-stop.bat— stop containers (data preserved)dev-reset.bat— wipe containers AND data
cp .env.example .env # adjust values
docker compose up -d gallery-postgres gallery-minio
npm install
npm run migrate
ADMIN_EMAIL=admin@divass.space ADMIN_PASSWORD=local-dev npm run seed:admin
npm run devOpen http://localhost:3000/admin/login.
npm testThe vitest setup boots a throwaway Postgres container per test run using testcontainers. Docker must be running.
See docs/deploy.md for the step-by-step runbook covering
Cloudflare Tunnel setup, secret generation, healthcheck verification,
PostHog first-boot, WAF toggles, and Portainer stack workflow.
TL;DR for an experienced operator:
git clone https://github.com/divass/gallery-hub.git /opt/gallery-hub
cd /opt/gallery-hub
cp .env.prod.example .env.prod # fill in secrets — see docs/deploy.md §3
docker compose -f docker-compose.prod.yml --env-file .env.prod up -d
docker compose -f docker-compose.prod.yml exec gallery-app \
npx tsx scripts/seed-admin.tsThe production compose file is docker-compose.prod.yml (pinned image
tags, healthchecks, read-only root FS, resource limits, no host port
bindings, Cloudflare Tunnel as the only ingress). docker-compose.yml
stays as the dev-only stack with exposed ports and :latest tags.
The browser does direct PUT uploads to MinIO using presigned URLs to avoid streaming through Next.js (which would double bandwidth and bottleneck on 50 MB photos). Image rendering is similarly direct — imgproxy resizes on demand from the MinIO original and serves the result back to the browser. Both need their own public hostnames:
gallery.divass.space→gallery-app:3000minio.gallery.divass.space→gallery-minio:9000(presigned PUT/GET)img.gallery.divass.space→gallery-imgproxy:8080(on-demand resize)posthog.gallery.divass.space→posthog:8000(analytics)
Inside Docker, the app uses gallery-minio:9000 for server-side
reads/writes; the browser uses the public hostnames for presigned
PUT/GET (uploads, exports) and signed imgproxy URLs (gallery rendering).
Replaced the previous 5-variant pre-encode worker. Every gallery
image, admin thumbnail, and cover hero is a signed imgproxy URL
fronting the same albums/{albumId}/{photoId}/original.{ext} key
in MinIO. Imgproxy negotiates output format from the browser Accept
header (AVIF → WEBP → JPEG fallback) and caches the resized output
for one year.
- URL builder:
src/lib/imgproxy.tswith HMAC-SHA256 signing, base64url encoding,versioncache-bust driven offphotos.updated_at. - Cheatsheet:
docs/imgproxy-cheatsheet.md - Perf + rollback:
docs/perf/2026-05-16-imgproxy-migration.md
The worker is now metadata-only (dimensions, EXIF taken_at,
thumbhash, status flip). Photo status='ready' lands ~100 ms after
finalize instead of ~5–15 s.
GET /api/health returns
{ db: 'ok'|'fail', minio: 'ok'|'fail', uptime_s: N, version: '...' }
with status 200/503. Wire it into Cloudflare uptime monitors or Portainer.
Daily encrypted Postgres dumps (pg_dump | gzip | gpg --symmetric AES256)
plus an append-only MinIO mirror to Backblaze B2 / Cloudflare R2 / a local
volume. /chikaq surfaces live usage and last-backup timestamps; a
pg-boss worker emits a PostHog storage_critical event at 85% of
STORAGE_QUOTA_BYTES.
Full architecture, schedules, restore drill, and partial-restore recipe
live in docs/backup.md.
Two-layer analytics on top of the gallery:
/chikaq(admin-gated) — first-party insights drawn straight from Postgres (views trend, top albums, recent activity per viewer). Lives in the same process; survives even if PostHog is down.- PostHog self-hosted — funnels, retention, session replay, deep cohorting.
Embedded inside
/chikaqvia a shared dashboard URL.
The compose file ships PostHog + dedicated Postgres + Redis + ClickHouse on the
same internal network. They are independent services — start them on demand:
docker compose up -d posthog-pg posthog-redis posthog-clickhouse posthogFirst boot:
-
Wait ~60 s for ClickHouse and PostHog to settle.
-
Visit
http://localhost:8000→ create the admin account (this is the PostHog project owner, not the gallery admin). -
PostHog creates a default project; copy the Project API key from Project Settings → Project API Key.
-
Paste it into
.env:POSTHOG_KEY=phc_xxx... POSTHOG_HOST=http://localhost:8000 NEXT_PUBLIC_POSTHOG_KEY=phc_xxx... NEXT_PUBLIC_POSTHOG_HOST=http://localhost:8000
-
Build a dashboard inside PostHog (funnels, retention, top events). Dashboard → Share → toggle "Share dashboard" → copy URL.
-
Paste it into
.envasPOSTHOG_DASHBOARD_URL=...and reload/chikaq— the iframe panel will render the live dashboard.
Server-side (always via safeCapture — never throws into a user flow):
gallery_view— public album page renderfavorites_view—/a/<token>/favoritesrenderfavorite_added/favorite_removed— heart toggle on a photoshare_unlocked— password-gated link unlockedexport_started/export_completed— ZIP export of an album or favorites
Client-side (PostHogProvider mounted only inside /a/<token>/*):
- Automatic
$pageview+$pageleavefor SPA navigations - Session replay (configured per project inside PostHog UI)
Admin sessions are explicitly opted out — no captures fire while previewing
albums from /admin.
PostHog + ClickHouse together idle at ~1.5 GB RAM. On a small VPS, either
schedule it (start before reviewing analytics, stop after), or fall back to
PostHog Cloud's free tier by pointing POSTHOG_HOST at
https://us.posthog.com (or eu.posthog.com) and keeping the rest of the
gallery stack local.
The admin-gated insights page is at /chikaq (admin login required). It shows
storage tiles, a 30-day views sparkline, top albums by view count, recent
activity grouped per viewer, the embedded PostHog dashboard, and a one-click
link to the Cloudflare dashboard for DDoS / IP / geo views.