Test suite (the manual audit harness, now a real test):
- backend/apps/core/tests/test_security.py: 20 transactional tests across
guest visibility, object ownership, staged-upload/similarity privacy,
staff role boundaries, deletion rules, encrypted credentials, throttling
and the download allowlist. Uses temp media folders and clears cache.
Needs a one-time GRANT on test_j621 (documented in the module + README).
Images (deploy/J621-Frontend, deploy/J621-Backend, repo root as context):
- Frontend: node build -> static nginx with SPA fallback, asset caching and
an internal health endpoint.
- Backend: gunicorn + whitenoise (admin static collected at build), ffmpeg
for video thumbnails, migrations applied on start, GIT_HASH build arg so
the shell's version pill shows the commit.
Composes (distinct project names so they coexist with the dev stack):
- compose.yml (both), compose.frontend.yml, compose.backend.yml.
- A shared nginx proxy service is the only entry point (no host nginx, no
published host ports): /api,/admin,/static,/health -> backend, everything
else -> SPA; both upstreams resolve at request time so one config serves
all variants.
- A Tailscale sidecar per compose shares the nginx network namespace;
serve.default/frontend/backend.json use funnel ports 443 and 8443 only
(10000 is the remaining allowance) with ${TS_CERT_DOMAIN} substitution.
Registry: push_frontend.sh / push_backend.sh / push_all.sh build multi-arch
images and push :latest + :<sha> to the Gitea registry, following the
existing Packs-site pattern.
Docs: deploy/README.md + .env.example, ROADMAP section 6 updated,
AGENTS.md deployment and test notes.
Verified: all three composes validate, both nginx configs pass nginx -t,
both images build, the combined stack boots against real MariaDB/Redis
(migrations applied, /health ok, whitenoise serving admin static, env=prod,
git hash baked in), the proxy serves the SPA and routes /api, and
manage.py test apps.core.tests passes 20/20.
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.
| Compose | Services | Funnel | 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.
Usage
cd deploy
cp .env.example .env # fill in TS_AUTHKEY, SECRET_KEY, ALLOWED_HOSTS, ...
docker compose up -d # default: everything on one host
# or
docker compose -f compose.frontend.yml up -d
docker compose -f compose.backend.yml up -d
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.
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.