██████╗ █████╗ ██╗ ██╗ ███████╗██████╗ ██╗ ██╗
██╔════╝ ██╔══██╗██║ ██║ ██╔════╝██╔══██╗╚██╗ ██╔╝
██║ ███╗███████║██║ ██║ █████╗ ██████╔╝ ╚████╔╝
██║ ██║██╔══██║██║ ██║ ██╔══╝ ██╔══██╗ ╚██╔╝
╚██████╔╝██║ ██║███████╗███████╗███████╗██║ ██║ ██║
╚═════╝ ╚═╝ ╚═╝╚══════╝╚══════╝╚══════╝╚═╝ ╚═╝ ╚═╝
The lab's photo wall for members: photos and short videos in Nextcloud, everything else in its own Postgres, and every action also an MCP tool.
nextjs postgres nextcloud docker mcp
WinLab members share lab photos here: upload from a phone, react, and find who is in them. Only signed-in members see anything; every file passes through the app, which checks the session first.
> "What did we post from last year's orientation?"
⚡ list_photos { "query": "迎新" }
✓ { "photos": [{ "name": "合照", "sequenceSize": 6, ... }], "nextOffset": null }
- Shows the wall: newest taken first, photos uploaded together as one card, filtered by search, uploader or person.
- Searches by meaning: "生日蛋糕" finds the cake photos even when nobody named them, through EmbeddingGemma 2 vectors of each photo (optional; off, search matches text only).
- Takes uploads: photos up to 30 MB (HEIC turns into JPEG) and videos up to 60 seconds and 100 MB (re-encoded to 720p H.264).
- Stores files in Nextcloud under one folder of one account, with a 480 px and a 2048 px WebP made at upload.
- Lets members react: 8 reactions.
- Serves MCP at
/mcp, with the gallery as the OAuth 2.1 authorization server.
With Docker Compose, on any machine with Docker:
curl -fsSLO https://raw.githubusercontent.com/NYCU-WinLab/gallery/main/compose.yaml
curl -fsSL -o .env https://raw.githubusercontent.com/NYCU-WinLab/gallery/main/.env.example
# set POSTGRES_PASSWORD, BETTER_AUTH_*, KEYCLOAK_* and NEXTCLOUD_* in .env
docker compose up -dThis starts Postgres, runs the migrations once, then serves the gallery from ghcr.io/nycu-winlab/gallery on 127.0.0.1:3000.
Important
Put a reverse proxy with TLS in front, at the host in BETTER_AUTH_URL, and let it pass request bodies of 100 MB. MCP clients only accept an HTTPS resource, and the Keycloak redirect URI must match it.
gallery.winlab.tw deploys on every push to main. The release workflow pushes the image, then sends a Carrel app release (winfra/gallery). On the machine, carrel-deployd runs deploy/carrel-start.sh: it sets GALLERY_VERSION in /opt/gallery/.env and waits until web is healthy. A failed health check rolls back to the previous release. The app unit has a drop-in with Type=notify, NotifyAccess=all and TimeoutStartSec=300, so the check waits for the new container.
-
In your Keycloak realm, create a confidential OpenID Connect client with the redirect URI
<BETTER_AUTH_URL>/api/auth/callback/keycloak, and put its ID and secret in.env. Give its service accountview-usersso/mcpcan check that a member still exists. -
In Nextcloud, create an app password for the account that holds the files, create the folder
NEXTCLOUD_ROOTin it, and put both in.env. -
Open
BETTER_AUTH_URLand sign in. -
Make the first admin, once, after that member has signed in (later admins are added on /admins):
docker compose exec postgres psql -U gallery -d gallery -c \ "insert into admins (user_id) select id from users where username = '<their account>'"
-
Add the MCP server to a client, for example
claude mcp add --transport http gallery <BETTER_AUTH_URL>/mcp, then ask it to callwhoami.
Set these in .env; .env.example documents every one.
| Key | What it sets | Default |
|---|---|---|
POSTGRES_PASSWORD |
The database password, used by every service | required |
BETTER_AUTH_URL |
The public URL; the MCP resource is <url>/mcp |
required |
BETTER_AUTH_SECRET |
Signs sessions and OAuth queries | required |
KEYCLOAK_ISSUER |
The realm URL, e.g. https://auth.example.com/realms/lab |
required |
KEYCLOAK_CLIENT_ID, KEYCLOAK_CLIENT_SECRET |
The gallery's Keycloak client | required |
NEXTCLOUD_URL, NEXTCLOUD_USER, NEXTCLOUD_APP_PASSWORD |
Where the files live | required |
NEXTCLOUD_ROOT |
The folder in that account the gallery owns | gallery |
GALLERY_VERSION |
Image tag | main |
GALLERY_BIND, GALLERY_PORT |
Where web listens on the host | 127.0.0.1, 3000 |
COMPOSE_PROFILES, EMBED_URL |
search and http://embed:8000 turn on search by meaning (about 3 GB of memory) |
empty |
OTEL_EXPORTER_OTLP_ENDPOINT |
OTLP/HTTP endpoint for traces and error logs; off when empty | empty |
flowchart LR
member[Member] -->|browser| pages[Pages]
agent[MCP client] -->|bearer token| mcp["/mcp"]
pages --> actions[Actions]
mcp --> actions
actions --> pg[(Postgres)]
member -->|"PUT /api/upload"| media[sharp and ffmpeg]
media --> nc[(Nextcloud)]
member -->|"GET /media"| cache[Disk cache]
cache --> nc
An upload streams to a temp file, becomes an original plus two WebP sizes, and goes to Nextcloud as <root>/<photo id>/; only then is the row written. /media/<id>/<thumb|large|original> checks the session, keeps thumb and large on local disk after the first read, and streams originals with Range so videos seek. Only lib/actions, lib/auth and lib/db touch the database, and CI checks that every action is in the registry the MCP tools come from.
bun install
cp .env.example .env.local # point DATABASE_URL at a local Postgres
bun run db:migrate
bun run devUploads need ffmpeg and ffprobe on the PATH. CI runs typecheck, lint, format, the design token check, the action registry check and a build, then builds the image and probes a real docker compose up. See CLAUDE.md for the conventions.
- One web container: the media cache and upload temp files are on its own disk.
- Search by meaning scores every photo per search and embeds on CPU (about 5 seconds a photo on 2 cores), which suits a lab's few thousand photos, not a large archive.
- Video re-encoding runs inside the upload request, so a 60 second clip takes a while on a small machine.
Issues and PRs welcome: start with CONTRIBUTING.md.
MIT, made in a lab that keeps every group photo.