diff --git a/AGENTS.md b/AGENTS.md index afb1f98..45b4c0e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,10 +34,12 @@ Project constraints (do not regress): - deploy/ is the deployment source of truth: J621-Frontend (SPA on static nginx) and J621-Backend (gunicorn + whitenoise, ffmpeg, migrations on start), three compose variants (both / frontend-only / backend-only) behind - a shared nginx proxy service, and a Tailscale sidecar per compose whose - serve configs only use funnel ports 443 / 8443 / 10000. Images are pushed - to the Gitea registry with deploy/push_*.sh (multi-arch, :latest + :sha, - GIT_HASH baked in for the version pill). + a shared nginx proxy service, and a Tailscale sidecar per compose. + serve.*.json are the public Funnel variants (only ports 443 / 8443 / 10000); + serve.*.tailnet.json + compose.tailnet*.yml are the same stacks without + AllowFunnel (tailnet-only, no public exposure). Images are pushed to the + Gitea registry with deploy/push_*.sh (multi-arch, :latest + :sha, GIT_HASH + baked in for the version pill). - Security/permission tests live in backend/apps/core/tests and need a one-time grant: GRANT ALL ON `test_j621`.* TO 'j621'@'%'; diff --git a/deploy/README.md b/deploy/README.md index 25194a6..1a9187f 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -5,7 +5,9 @@ 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 | +## Funnel set (public HTTPS) + +| Compose | Services | Entry point | Serve config | | --- | --- | --- | --- | | `compose.yml` (default) | mariadb, redis, backend, frontend, nginx, tailscale | `https://..ts.net` → nginx → backend + SPA | `serve.default.json` | | `compose.frontend.yml` | frontend, nginx, tailscale | `https://..ts.net` → nginx → SPA only | `serve.frontend.json` | @@ -15,15 +17,43 @@ 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://..ts.net` | +| `compose.tailnet.frontend.yml` | `serve.frontend.tailnet.json` | `https://..ts.net` | +| `compose.tailnet.backend.yml` | `serve.backend.tailnet.json` | `https://..ts.net:8443` | + +```bash +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 ```bash cd deploy cp .env.example .env # fill in TS_AUTHKEY, SECRET_KEY, ALLOWED_HOSTS, ... -docker compose up -d # default: everything on one host +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 diff --git a/deploy/compose.tailnet.backend.yml b/deploy/compose.tailnet.backend.yml new file mode 100644 index 0000000..de2dda4 --- /dev/null +++ b/deploy/compose.tailnet.backend.yml @@ -0,0 +1,109 @@ +# J621 — backend-only deployment: API + database + nginx proxy + Tailscale. +# +# cd deploy && cp .env.example .env # TS_AUTHKEY, SECRET_KEY, hosts, CORS +# docker compose -f compose.tailnet.backend.yml up -d +# +# Tailnet-only: https://..ts.net:8443 reaches the +# nginx service from your tailnet, which routes /api, /admin, /static and /health to +# Django. "/" answers a small JSON hint because no frontend is attached. +# +# Because the frontend lives elsewhere it is cross-origin — set in .env: +# CORS_ALLOWED_ORIGINS=https://..ts.net +# CSRF_TRUSTED_ORIGINS=... (same value; only needed for the admin) +# and add this host to ALLOWED_HOSTS (comma separated list). + +name: j621-backend-deploy-tailnet + +services: + mariadb: + image: mariadb:11.4 + restart: unless-stopped + environment: + MARIADB_ROOT_PASSWORD: ${DB_ROOT_PASSWORD:-j621root} + MARIADB_DATABASE: ${DB_NAME:-j621} + MARIADB_USER: ${DB_USER:-j621} + MARIADB_PASSWORD: ${DB_PASSWORD:-j621} + volumes: + - ./data/mariadb:/var/lib/mysql + healthcheck: + test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"] + interval: 5s + timeout: 5s + retries: 20 + + redis: + image: redis:7-alpine + restart: unless-stopped + command: ["redis-server", "--appendonly", "yes"] + volumes: + - ./data/redis:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 5s + retries: 20 + + backend: + image: ${J621_REGISTRY:-gitea.rainbow-herring.ts.net/jakebreath}/j621-backend:${J621_TAG:-latest} + build: + context: .. + dockerfile: deploy/J621-Backend + # Bake the commit into the image so the shell's version pill shows it; + # the push scripts pass --build-arg themselves. + args: + GIT_HASH: ${GIT_HASH:-unknown} + restart: unless-stopped + env_file: [./.env] + environment: + DB_HOST: mariadb + DB_PORT: "3306" + REDIS_URL: redis://redis:6379/1 + TRUST_PROXY_HEADERS: "true" + volumes: + - ./data/media:/app/media + - ./data/logs:/app/logs + depends_on: + mariadb: + condition: service_healthy + redis: + condition: service_healthy + + nginx: + image: nginx:1.29-alpine + restart: unless-stopped + volumes: + - ./nginx-proxy.conf:/etc/nginx/conf.d/default.conf:ro + depends_on: + backend: + condition: service_healthy + healthcheck: + test: ["CMD-SHELL", "wget -q --spider http://127.0.0.1/nginx-health || exit 1"] + interval: 30s + timeout: 3s + retries: 3 + start_period: 10s + + tailscale: + image: tailscale/tailscale:latest + restart: unless-stopped + # Shares the nginx service's network namespace: 127.0.0.1:80 is the proxy. + network_mode: "service:nginx" + environment: + TS_AUTHKEY: ${TS_AUTHKEY:?Set TS_AUTHKEY in deploy/.env} + TS_HOSTNAME: ${TS_HOSTNAME:-j621-backend} + TS_AUTH_ONCE: "true" + TS_STATE_DIR: /var/lib/tailscale + TS_SERVE_CONFIG: /config/serve.json + volumes: + - ./tailscale-state:/var/lib/tailscale + - ./serve.backend.tailnet.json:/config/serve.json:ro + - /etc/ssl/certs:/etc/ssl/certs:ro + depends_on: + nginx: + condition: service_healthy + healthcheck: + test: ["CMD", "tailscale", "status"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 30s diff --git a/deploy/compose.tailnet.frontend.yml b/deploy/compose.tailnet.frontend.yml new file mode 100644 index 0000000..b515526 --- /dev/null +++ b/deploy/compose.tailnet.frontend.yml @@ -0,0 +1,63 @@ +# J621 — frontend-only deployment: SPA + nginx proxy + Tailscale sidecar. +# +# cd deploy && cp .env.example .env # TS_AUTHKEY at minimum +# docker compose -f compose.tailnet.frontend.yml up -d +# +# Tailnet-only: https://..ts.net (443) serves the SPA +# for devices on your tailnet; nothing is exposed publicly. +# On first start the SPA asks for a backend (/setup): point it at a +# backend-only deployment (e.g. https://j621-backend..ts.net:8443), +# leave it blank to serve one yourself, or stay in local mode. +# +# There is no backend in this compose, so /api answers 502 here until the SPA +# is configured to call one elsewhere. + +name: j621-frontend-deploy-tailnet + +services: + frontend: + image: ${J621_REGISTRY:-gitea.rainbow-herring.ts.net/jakebreath}/j621-frontend:${J621_TAG:-latest} + build: + context: .. + dockerfile: deploy/J621-Frontend + restart: unless-stopped + + nginx: + image: nginx:1.29-alpine + restart: unless-stopped + volumes: + - ./nginx-proxy.conf:/etc/nginx/conf.d/default.conf:ro + depends_on: + frontend: + condition: service_healthy + healthcheck: + test: ["CMD-SHELL", "wget -q --spider http://127.0.0.1/nginx-health || exit 1"] + interval: 30s + timeout: 3s + retries: 3 + start_period: 10s + + tailscale: + image: tailscale/tailscale:latest + restart: unless-stopped + # Shares the nginx service's network namespace: 127.0.0.1:80 is the proxy. + network_mode: "service:nginx" + environment: + TS_AUTHKEY: ${TS_AUTHKEY:?Set TS_AUTHKEY in deploy/.env} + TS_HOSTNAME: ${TS_HOSTNAME:-j621-frontend} + TS_AUTH_ONCE: "true" + TS_STATE_DIR: /var/lib/tailscale + TS_SERVE_CONFIG: /config/serve.json + volumes: + - ./tailscale-state:/var/lib/tailscale + - ./serve.frontend.tailnet.json:/config/serve.json:ro + - /etc/ssl/certs:/etc/ssl/certs:ro + depends_on: + nginx: + condition: service_healthy + healthcheck: + test: ["CMD", "tailscale", "status"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 30s diff --git a/deploy/compose.tailnet.yml b/deploy/compose.tailnet.yml new file mode 100644 index 0000000..0e72f35 --- /dev/null +++ b/deploy/compose.tailnet.yml @@ -0,0 +1,117 @@ +# J621 — default deployment: frontend + backend + database on one Tailscale +# host, behind the nginx proxy service (no host nginx, nothing published on +# the host's ports). +# +# cd deploy && cp .env.example .env # fill in TS_AUTHKEY, SECRET_KEY, ... +# docker compose up -d # or: docker compose -f compose.yml up -d +# +# Tailnet-only: no Funnel, so the service is reachable from your tailnet +# (https://..ts.net) but not from the public internet. +# The nginx service routes /api, /admin, /static and /health to the +# backend and everything else to the SPA. Same origin, so no CORS needed. + +name: j621-deploy-tailnet + +services: + mariadb: + image: mariadb:11.4 + restart: unless-stopped + environment: + MARIADB_ROOT_PASSWORD: ${DB_ROOT_PASSWORD:-j621root} + MARIADB_DATABASE: ${DB_NAME:-j621} + MARIADB_USER: ${DB_USER:-j621} + MARIADB_PASSWORD: ${DB_PASSWORD:-j621} + volumes: + - ./data/mariadb:/var/lib/mysql + healthcheck: + test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"] + interval: 5s + timeout: 5s + retries: 20 + + redis: + image: redis:7-alpine + restart: unless-stopped + command: ["redis-server", "--appendonly", "yes"] + volumes: + - ./data/redis:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 5s + retries: 20 + + backend: + image: ${J621_REGISTRY:-gitea.rainbow-herring.ts.net/jakebreath}/j621-backend:${J621_TAG:-latest} + build: + context: .. + dockerfile: deploy/J621-Backend + # Bake the commit into the image so the shell's version pill shows it; + # the push scripts pass --build-arg themselves. + args: + GIT_HASH: ${GIT_HASH:-unknown} + restart: unless-stopped + env_file: [./.env] + environment: + DB_HOST: mariadb + DB_PORT: "3306" + REDIS_URL: redis://redis:6379/1 + # The tailnet funnel terminates TLS and forwards the original host/proto. + TRUST_PROXY_HEADERS: "true" + volumes: + - ./data/media:/app/media + - ./data/logs:/app/logs + depends_on: + mariadb: + condition: service_healthy + redis: + condition: service_healthy + + frontend: + image: ${J621_REGISTRY:-gitea.rainbow-herring.ts.net/jakebreath}/j621-frontend:${J621_TAG:-latest} + build: + context: .. + dockerfile: deploy/J621-Frontend + restart: unless-stopped + + nginx: + image: nginx:1.29-alpine + restart: unless-stopped + volumes: + - ./nginx-proxy.conf:/etc/nginx/conf.d/default.conf:ro + depends_on: + backend: + condition: service_healthy + frontend: + condition: service_healthy + healthcheck: + test: ["CMD-SHELL", "wget -q --spider http://127.0.0.1/nginx-health || exit 1"] + interval: 30s + timeout: 3s + retries: 3 + start_period: 10s + + tailscale: + image: tailscale/tailscale:latest + restart: unless-stopped + # Shares the nginx service's network namespace: 127.0.0.1:80 is the proxy. + network_mode: "service:nginx" + environment: + TS_AUTHKEY: ${TS_AUTHKEY:?Set TS_AUTHKEY in deploy/.env} + TS_HOSTNAME: ${TS_HOSTNAME:-j621} + TS_AUTH_ONCE: "true" + TS_STATE_DIR: /var/lib/tailscale + TS_SERVE_CONFIG: /config/serve.json + volumes: + - ./tailscale-state:/var/lib/tailscale + - ./serve.default.tailnet.json:/config/serve.json:ro + - /etc/ssl/certs:/etc/ssl/certs:ro + depends_on: + nginx: + condition: service_healthy + healthcheck: + test: ["CMD", "tailscale", "status"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 30s diff --git a/deploy/serve.backend.tailnet.json b/deploy/serve.backend.tailnet.json new file mode 100644 index 0000000..37ace65 --- /dev/null +++ b/deploy/serve.backend.tailnet.json @@ -0,0 +1,16 @@ +{ + "TCP": { + "8443": { + "HTTPS": true + } + }, + "Web": { + "${TS_CERT_DOMAIN}:8443": { + "Handlers": { + "/": { + "Proxy": "http://127.0.0.1:80" + } + } + } + } +} diff --git a/deploy/serve.default.tailnet.json b/deploy/serve.default.tailnet.json new file mode 100644 index 0000000..cc9be0a --- /dev/null +++ b/deploy/serve.default.tailnet.json @@ -0,0 +1,16 @@ +{ + "TCP": { + "443": { + "HTTPS": true + } + }, + "Web": { + "${TS_CERT_DOMAIN}:443": { + "Handlers": { + "/": { + "Proxy": "http://127.0.0.1:80" + } + } + } + } +} diff --git a/deploy/serve.frontend.tailnet.json b/deploy/serve.frontend.tailnet.json new file mode 100644 index 0000000..cc9be0a --- /dev/null +++ b/deploy/serve.frontend.tailnet.json @@ -0,0 +1,16 @@ +{ + "TCP": { + "443": { + "HTTPS": true + } + }, + "Web": { + "${TS_CERT_DOMAIN}:443": { + "Handlers": { + "/": { + "Proxy": "http://127.0.0.1:80" + } + } + } + } +}