Files
J621/deploy
JakeBreath 30b1a1a4b0 Tailnet-only (Serve, no Funnel) compose variants
Three more composes — compose.tailnet.yml, compose.tailnet.frontend.yml,
compose.tailnet.backend.yml — mirror the funnel set exactly but mount
serve.default/frontend/backend.tailnet.json, which drop AllowFunnel. The
sidecar still registers and serves HTTPS with a tailnet certificate, but
nothing is exposed publicly; Serve also needs no ACL change.

Project names carry a -tailnet suffix so both sets can coexist, and the
README explains that each set needs its own data directory (or host), plus
how to switch a host between funnel and tailnet by starting the other file
with the same .env.

Verified: all six composes validate with docker compose config, and the
six serve configs split cleanly into funnel (AllowFunnel present) and
tailnet-only (absent).
2026-09-18 11:21:48 -05:00
..

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 …-tailnet so both sets can be installed side by side.
  • Both sets use the same deploy/data directory (library, database, logs). Run one set per data directory — two MariaDB instances on one data dir would corrupt it. Giving the tailnet set its own TS_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.

Usage

cd deploy
cp .env.example .env          # fill in TS_AUTHKEY, SECRET_KEY, ALLOWED_HOSTS, ...
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 backend CORS_ALLOWED_ORIGINS=https://<frontend-host>.<tailnet>.ts.net (plus CSRF_TRUSTED_ORIGINS for 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, /static and /health go 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, so depends_on: condition: service_healthy gates the sidecar on a working stack.
  • deploy/data/media/library is the watched folder; drop files there (or use uploads) and run manage.py scan_files inside the backend container.