Files
J621/deploy/README.md
T
JakeBreath f25526782c Run the periodic commands in a scheduler service (no host cron)
Adds deploy/scheduler-entrypoint.sh to the backend image (entrypoint
j621-scheduler) and a scheduler service to the four composes that have a
backend. It waits until the database and migrations are ready, runs every
job once, then keeps to the intervals:

  sync_followed_tags + sync_followed_pools   every 30 min (J621_SYNC_EVERY)
  cleanup_similarity                         hourly      (J621_CLEAN_EVERY)
  refresh_guest_blacklist                    daily       (J621_BLACKLIST_EVERY)

It shares the backend image, media volume and env file, so commands see
the same library and database; failures are logged and retried next
interval. Output goes to docker compose logs scheduler; the frontend-only
composes have no backend and therefore no scheduler.

Verified against a real stack: the scheduler waited for migrations, ran all
four commands on start (follow syncs as anonymous, similarity cleanup, and
a guest blacklist refresh that pulled the real 14-tag list from e621), and
kept looping. All six composes still validate.
2026-09-18 12:51:17 -05:00

7.0 KiB

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.
  • Serve needs no tailnet policy change (Funnel requires the funnel node attribute in your ACLs), so this set works as soon as the sidecar joins.
  • 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.

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 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.

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, /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.