Backend: GET /api/random/ (aliases /random and /random/) returns a random library image with: - rating=s,q,e filtering (comma separated, default any); - fastfetch mode (?fastfetch=1 or any User-Agent containing "fastfetch") that only considers png/jpg/gif - what terminal viewers can show; - JSON with j_id, filename, extension, rating, size, e621 id plus absolute url/download_url/thumbnail_url. Authenticated callers get signed URLs so fastfetch and image viewers can load them without headers; guests get unsigned URLs and never receive hidden_from_guests items. Tests: apps/library/tests/test_random.py (8 tests) covering the response contract, guest signatures, image-only default, the fastfetch format restriction (flag and User-Agent), rating filters, guest visibility and the short alias. Frontend: /random page with rating pills, R to roll, Open/Download and a library link, plus navigation and command palette entries; needs a backend, hidden in local mode. nginx: /random negotiates on Accept so browsers keep getting the SPA while scripts get the JSON (verified with the proxy and frontend containers). Also fixes a regression from the SSRF change: the guest download proxy still referenced the removed 'parsed' variable on its success path, so every proxied download would have 500'd. Redirect hops are now covered by tests with a mocked requests.get.
J621 deployment (Docker + Tailscale)
Three compose variants, all behind an nginx service and a Tailscale sidecar. Nothing is published on the host's ports and no system nginx or reverse proxy is involved: the sidecar shares the nginx service's network namespace and Tailscale Serve/Funnel exposes it.
Funnel set (public HTTPS)
| Compose | Services | Entry point | Serve config |
|---|---|---|---|
compose.yml (default) |
mariadb, redis, backend, frontend, nginx, tailscale | https://<host>.<tailnet>.ts.net → nginx → backend + SPA |
serve.default.json |
compose.frontend.yml |
frontend, nginx, tailscale | https://<host>.<tailnet>.ts.net → nginx → SPA only |
serve.frontend.json |
compose.backend.yml |
mariadb, redis, backend, nginx, tailscale | https://<host>.<tailnet>.ts.net:8443 → nginx → API |
serve.backend.json |
Funnel can only expose ports 443, 8443 and 10000, so the separate
backend uses 8443. Change it in serve.backend.json (and .env) if you
prefer 10000.
Tailnet-only set (no Funnel)
The same three stacks without AllowFunnel in the serve config: the sidecar
still registers the node and serves over HTTPS with a tailnet certificate,
but only devices on your tailnet can reach it — nothing is exposed to the
public internet.
| Compose | Serve config | Reachable at |
|---|---|---|
compose.tailnet.yml (both) |
serve.default.tailnet.json |
https://<host>.<tailnet>.ts.net |
compose.tailnet.frontend.yml |
serve.frontend.tailnet.json |
https://<host>.<tailnet>.ts.net |
compose.tailnet.backend.yml |
serve.backend.tailnet.json |
https://<host>.<tailnet>.ts.net:8443 |
docker compose -f compose.tailnet.yml up -d
# or compose.tailnet.frontend.yml / compose.tailnet.backend.yml
Notes:
- Project names are
…-tailnetso both sets can be installed side by side. - Serve needs no tailnet policy change (Funnel requires the
funnelnode attribute in your ACLs), so this set works as soon as the sidecar joins. - Both sets use the same
deploy/datadirectory (library, database, logs). Run one set per data directory — two MariaDB instances on one data dir would corrupt it. Giving the tailnet set its ownTS_HOSTNAME(or running it on a second host) is the way to run both. - Switching a host from funnel to tailnet (or back) is just starting the other
compose file with the same
.env.
Environment file
gen_env.sh builds deploy/.env from .env.example, generating SECRET_KEY
and both database passwords with openssl:
./gen_env.sh # asks for the Tailscale auth key + hostname(s)
./gen_env.sh --no-prompt # secrets and defaults only; fill TS_AUTHKEY later
./gen_env.sh --update # refresh hostnames/authkey, keep the existing secrets
./gen_env.sh --force # regenerate everything, including SECRET_KEY
It derives TS_HOSTNAME and ALLOWED_HOSTS from the tailnet hostname you
give it, and can set CORS_ALLOWED_ORIGINS/CSRF_TRUSTED_ORIGINS when you
provide the frontend's hostname for a split deployment. --update is the safe
way to add hostnames later; --force rotates SECRET_KEY, which invalidates
signed media URLs and stored e621 API keys. The file is written with mode 600
and is git-ignored.
Usage
cd deploy
./gen_env.sh # or: cp .env.example .env and edit it yourself
docker compose up -d # default: everything on one host, public funnel
# or
docker compose -f compose.frontend.yml up -d
docker compose -f compose.backend.yml up -d
# tailnet-only (no public funnel): compose.tailnet*.yml, see below
The images are pulled from the Gitea registry; append --build (or run the
push scripts) if you build locally. Data lives in deploy/data/
(media/, logs/, mariadb/, redis/) and the Tailscale node state in
deploy/tailscale-state/; both are git-ignored.
First start
The SPA asks where the backend is (/setup) in production builds:
- Default compose — leave the field blank (same origin) and everything works through nginx.
- Frontend-only + backend-only — point the (cross-origin) SPA at the
backend's funnel URL, e.g.
https://j621-backend.<tailnet>.ts.net:8443, and give the backendCORS_ALLOWED_ORIGINS=https://<frontend-host>.<tailnet>.ts.net(plusCSRF_TRUSTED_ORIGINSfor the admin). - Without a backend at all, choose "Continue without a backend" to run in local mode (e621 browsing only).
Any origin and port is fine — the backend builds its media URLs from the
forwarded host/proto (TRUST_PROXY_HEADERS=true is set in all composes).
Images
Two Dockerfiles, both built from the repository root:
docker build -f deploy/J621-Frontend -t j621-frontend .
docker build --build-arg "GIT_HASH=$(git rev-parse --short HEAD)" \
-f deploy/J621-Backend -t j621-backend .
J621-Frontend builds the SPA and serves it as static files.
J621-Backend is Django + gunicorn (migrations run on start) and includes
ffmpeg for video thumbnails. The GIT_HASH build arg is baked into the
backend image so the shell's version pill shows the commit (images have no
.git directory); the push scripts pass it automatically.
Push multi-arch images to the Gitea registry:
./push_all.sh # or push_frontend.sh / push_backend.sh [sha]
They tag :latest and :<commit-sha> and expect docker login gitea.rainbow-herring.ts.net to succeed.
Scheduled jobs
Compose files with a backend also run a scheduler service — the same
backend image with a different entrypoint, so no host cron is involved:
| Job | Default interval | Env override |
|---|---|---|
sync_followed_tags + sync_followed_pools |
every 30 minutes | J621_SYNC_EVERY |
cleanup_similarity |
hourly | J621_CLEAN_EVERY |
refresh_guest_blacklist |
daily | J621_BLACKLIST_EVERY |
It waits for the database and migrations before its first run, runs every job
once on start, then keeps to the intervals (failures are logged and retried
next round). Output goes to docker compose logs scheduler. Intervals are
seconds, set in deploy/.env. The frontend-only composes have no backend, so
no scheduler.
Tests
The security/permission suite lives in backend/apps/core/tests/:
cd ../backend
./venv/bin/python manage.py test apps.core.tests
It needs a one-time grant on the database user (test databases are created from scratch):
GRANT ALL ON `test_j621`.* TO 'j621'@'%';
Notes
- The nginx service is the only entry point:
/api,/admin,/staticand/healthgo to the backend, everything else to the SPA. Both upstreams are resolved at request time, so the same config works in all three variants. - The compose healthchecks use
/nginx-health(nginx itself),/health(Django + database) and the frontend's static server, sodepends_on: condition: service_healthygates the sidecar on a working stack. deploy/data/media/libraryis the watched folder; drop files there (or use uploads) and runmanage.py scan_filesinside the backend container.