Builds deploy/.env from .env.example, generating SECRET_KEY and both database passwords with openssl (base64/hex only, so nothing needs quoting in the env file or the compose parser). Derives TS_HOSTNAME and ALLOWED_HOSTS from the tailnet hostname, optionally sets CORS_ALLOWED_ORIGINS/CSRF_TRUSTED_ORIGINS for split deployments, forces DEBUG=False and writes the file with mode 600. Modes: --no-prompt (defaults only), --update (refresh hostnames/auth key while keeping the existing secrets, reading the stored FQDN from ALLOWED_HOSTS), --force (rotate everything, with the SECRET_KEY warning in the docs). Refuses to overwrite an existing file otherwise. Verified: all modes, updated FQDN preservation, mode 600, and docker compose config accepting the generated file.
6.3 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
…-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.
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.