commit 462edb32b168ea1fbc9c3af578aeada6d951bbf4 Author: Paccoco <99041736+Paccoco@users.noreply.github.com> Date: Wed Apr 15 18:19:24 2026 -0500 Initial commit: all TrueNAS docker compose stacks diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..94fa287 --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +# Never commit real secrets +.env + +# Postgres init SQL contains hardcoded passwords (Option A decision) +# Copy from databases/initdb/01-create-databases.sql.example and fill in real values +databases/initdb/01-create-databases.sql + +# OS noise +.DS_Store +Thumbs.db + +# Editor noise +.vscode/ +.idea/ +*.swp +*.swo diff --git a/README.md b/README.md new file mode 100644 index 0000000..ec2612a --- /dev/null +++ b/README.md @@ -0,0 +1,35 @@ +# truenas-stacks + +Docker Compose stacks for PlausibleDeniability (TrueNAS Scale). + +## Stacks + +- databases/ - shared-postgres, shared-mariadb, shared-redis - deploy FIRST +- infrastructure/ - newt, homepage, uptime-kuma, dockhand, netdata, tailscale - deploy SECOND +- media/ - plex, tautulli, audiobookshelf, seerr, calibre-web +- photos/ - immich server + machine learning +- home/ - kitchenowl, donetick, dakboard bridge, shlink, qui +- dev/ - gitea, rackpeek +- ai/ - ollama, openwebui, scriberr + +## Deployment order + +1. databases +2. infrastructure +3. All other stacks in any order + +## Setup on a new machine + + git clone git@github.com:Paccoco/truenas-stacks.git /mnt/docker-ssd/docker/compose + cp databases/.env.example databases/.env && nano databases/.env + # repeat for each stack, then import each truenas-app.yaml via TrueNAS Apps UI + +## Networks + +- ix-databases_shared-databases: created by databases stack, used by media/photos/home/dev/ai +- pangolin: created by newt at runtime, used by any service exposed via reverse proxy + +## Secrets + +.env files are gitignored. .env.example files are committed with placeholder values. +Real .env files live only on TrueNAS at /mnt/docker-ssd/docker/compose//.env diff --git a/ai/.env.example b/ai/.env.example new file mode 100644 index 0000000..16a7dd2 --- /dev/null +++ b/ai/.env.example @@ -0,0 +1,10 @@ +TZ=America/Chicago + +# OpenWebUI +OPENWEBUI_SECRET_KEY=changeme + +# OpenWebUI postgres — must match databases initdb/01-create-databases.sql +OPENWEBUI_DB_PASS=changeme + +# Scriberr — Hugging Face token for model downloads +HF_TOKEN=changeme diff --git a/ai/docker-compose.yaml b/ai/docker-compose.yaml new file mode 100644 index 0000000..1b2b376 --- /dev/null +++ b/ai/docker-compose.yaml @@ -0,0 +1,96 @@ +networks: + ai-net: + driver: bridge + ix-databases_shared-databases: + external: true + pangolin: + external: true + +services: + + ollama: + container_name: ollama + image: ollama/ollama:latest + restart: unless-stopped + networks: + - ai-net + ports: + - "11434:11434" + environment: + TZ: ${TZ} + NVIDIA_VISIBLE_DEVICES: all + NVIDIA_DRIVER_CAPABILITIES: compute,utility + volumes: + - /mnt/docker-ssd/docker/appdata/ollama:/root/.ollama + deploy: + resources: + reservations: + devices: + - driver: nvidia + count: all + capabilities: [gpu] + healthcheck: + test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:11434/ >/dev/null 2>&1 || exit 1"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 30s + + openwebui: + container_name: openwebui + image: ghcr.io/open-webui/open-webui:main + restart: unless-stopped + networks: + - ai-net + - ix-databases_shared-databases + - pangolin + ports: + - "8282:8080" + environment: + TZ: ${TZ} + OLLAMA_BASE_URL: http://ollama:11434 + WEBUI_SECRET_KEY: ${OPENWEBUI_SECRET_KEY} + DATABASE_URL: postgresql://openwebui:${OPENWEBUI_DB_PASS}@shared-postgres:5432/openwebui + ENABLE_SIGNUP: "true" + ENABLE_LOGIN_FORM: "true" + volumes: + - /mnt/docker-ssd/docker/appdata/openwebui:/app/backend/data + depends_on: + ollama: + condition: service_healthy + healthcheck: + test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8080/ >/dev/null 2>&1 || exit 1"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 60s + + scriberr: + container_name: scriberr + image: ghcr.io/rishikanthc/scriberr-cuda:v1.2.0 + restart: unless-stopped + networks: + - ai-net + - pangolin + ports: + - "8032:8080" + environment: + TZ: ${TZ} + HF_TOKEN: ${HF_TOKEN} + NVIDIA_VISIBLE_DEVICES: all + NVIDIA_DRIVER_CAPABILITIES: compute,utility + volumes: + - /mnt/docker-ssd/docker/appdata/scriberr:/app/data + deploy: + resources: + reservations: + devices: + - driver: nvidia + count: all + capabilities: [gpu] + healthcheck: + test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8080/ >/dev/null 2>&1 || exit 1"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 60s diff --git a/ai/truenas-app.yaml b/ai/truenas-app.yaml new file mode 100644 index 0000000..a59e951 --- /dev/null +++ b/ai/truenas-app.yaml @@ -0,0 +1,2 @@ +include: + - /mnt/docker-ssd/docker/compose/ai/docker-compose.yaml diff --git a/databases/.env.example b/databases/.env.example new file mode 100644 index 0000000..cecca4b --- /dev/null +++ b/databases/.env.example @@ -0,0 +1,15 @@ +TZ=America/Chicago + +# PostgreSQL +POSTGRES_USER=postgres +POSTGRES_PASSWORD=changeme +POSTGRES_DB=postgres + +# MariaDB +MARIADB_ROOT_PASSWORD=changeme +UPTIMEKUMA_DB_NAME=uptime_kuma +UPTIMEKUMA_DB_USER=uptime_kuma +UPTIMEKUMA_DB_PASS=changeme + +# Redis +REDIS_PASSWORD=changeme diff --git a/databases/docker-compose.yaml b/databases/docker-compose.yaml new file mode 100644 index 0000000..a706213 --- /dev/null +++ b/databases/docker-compose.yaml @@ -0,0 +1,70 @@ +networks: + shared-databases: + driver: bridge + +services: + + postgres: + container_name: shared-postgres + image: ghcr.io/immich-app/postgres:17-vectorchord0.5.3-pgvector0.8.1 + restart: unless-stopped + networks: + - shared-databases + ports: + - "5432:5432" + environment: + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + POSTGRES_DB: ${POSTGRES_DB} + PGDATA: /var/lib/postgresql/data/pgdata + volumes: + - /mnt/docker-ssd/docker/databases/postgres:/var/lib/postgresql/data + - /mnt/docker-ssd/docker/compose/databases/initdb:/docker-entrypoint-initdb.d:ro + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"] + interval: 30s + timeout: 10s + retries: 5 + start_period: 30s + + mariadb: + container_name: shared-mariadb + image: mariadb:11.4 + restart: unless-stopped + networks: + - shared-databases + ports: + - "3306:3306" + environment: + MARIADB_ROOT_PASSWORD: ${MARIADB_ROOT_PASSWORD} + UPTIMEKUMA_DB_NAME: ${UPTIMEKUMA_DB_NAME} + UPTIMEKUMA_DB_USER: ${UPTIMEKUMA_DB_USER} + UPTIMEKUMA_DB_PASS: ${UPTIMEKUMA_DB_PASS} + volumes: + - /mnt/docker-ssd/docker/databases/mariadb:/var/lib/mysql + - /mnt/docker-ssd/docker/compose/databases/initdb-mariadb:/docker-entrypoint-initdb.d:ro + healthcheck: + test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"] + interval: 30s + timeout: 10s + retries: 5 + start_period: 30s + + redis: + container_name: shared-redis + image: redis:7-alpine + restart: unless-stopped + networks: + - shared-databases + ports: + - "6379:6379" + command: > + sh -c "redis-server --appendonly yes --requirepass '${REDIS_PASSWORD}'" + volumes: + - /mnt/docker-ssd/docker/databases/redis:/data + healthcheck: + test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"] + interval: 30s + timeout: 10s + retries: 5 + start_period: 10s diff --git a/databases/initdb-mariadb/01-create-uptime-kuma-db.sh b/databases/initdb-mariadb/01-create-uptime-kuma-db.sh new file mode 100755 index 0000000..c06b081 --- /dev/null +++ b/databases/initdb-mariadb/01-create-uptime-kuma-db.sh @@ -0,0 +1,9 @@ +#!/bin/sh +set -eu + +mariadb -uroot -p"${MARIADB_ROOT_PASSWORD}" </dev/null 2>&1 || exit 1"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 30s + + rackpeek: + container_name: ix-rackpeek-rackpeek-1 + image: aptacode/rackpeek:latest + restart: unless-stopped + networks: + - dev-net + - pangolin + ports: + - "8283:8080" + environment: + TZ: ${TZ} + RPK_YAML_DIR: /app/config + volumes: + - /mnt/tank/docker/appdata/app/rackpeek/config:/app/config + - /mnt/tank/docker/appdata/app/rackpeek/dpkeys:/home/app/.aspnet/DataProtection-Keys + healthcheck: + test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8080/ >/dev/null 2>&1 || exit 1"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 30s diff --git a/dev/truenas-app.yaml b/dev/truenas-app.yaml new file mode 100644 index 0000000..43360ee --- /dev/null +++ b/dev/truenas-app.yaml @@ -0,0 +1,2 @@ +include: + - /mnt/docker-ssd/docker/compose/dev/docker-compose.yaml diff --git a/home/.env.example b/home/.env.example new file mode 100644 index 0000000..aaab01b --- /dev/null +++ b/home/.env.example @@ -0,0 +1,16 @@ +TZ=America/Chicago + +# KitchenOwl +KITCHENOWL_URL=https://owl.yourdomain.com +KITCHENOWL_JWT_SECRET=changeme + +# DoneTick DakBoard bridge +DONETICK_BRIDGE_TOKEN=changeme +DONETICK_API_KEY=changeme +DONETICK_BASE_URL=http://10.5.1.x:2021 + +# Shlink — must match databases initdb/01-create-databases.sql +SHLINK_DOMAIN=shlink.yourdomain.com +SHLINK_DB_NAME=shlink +SHLINK_DB_USER=shlink +SHLINK_DB_PASS=changeme diff --git a/home/docker-compose.yaml b/home/docker-compose.yaml new file mode 100644 index 0000000..2b00ad1 --- /dev/null +++ b/home/docker-compose.yaml @@ -0,0 +1,430 @@ +networks: + home-net: + driver: bridge + ix-databases_shared-databases: + external: true + pangolin: + external: true + +services: + + kitchenowl: + container_name: kitchenowl + image: tombursch/kitchenowl:latest + restart: unless-stopped + networks: + - home-net + - pangolin + ports: + - "8086:8080" + environment: + TZ: ${TZ} + FRONT_URL: ${KITCHENOWL_URL} + JWT_SECRET_KEY: ${KITCHENOWL_JWT_SECRET} + STORAGE_PATH: /data + DEBUG: "False" + volumes: + - /mnt/tank/docker/appdata/kitchenowl:/data + healthcheck: + test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8080/ >/dev/null 2>&1 || exit 1"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 30s + + donetick: + container_name: donetick + image: donetick/donetick:latest + restart: unless-stopped + networks: + - home-net + - pangolin + ports: + - "2021:2021" + environment: + TZ: ${TZ} + DT_ENV: selfhosted + volumes: + - /mnt/tank/docker/appdata/donetick/data:/donetick-data + - /mnt/tank/docker/appdata/donetick/config:/config + - /mnt/tank/docker/appdata/donetick/assets:/app/assets + healthcheck: + test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://127.0.0.1:2021/ || exit 1"] + interval: 1m + timeout: 5s + retries: 3 + start_period: 1m + + donetick-dakboard-bridge: + container_name: donetick-dakboard-bridge + image: python:3.12-alpine + restart: unless-stopped + networks: + - home-net + - pangolin + ports: + - "5087:8080" + environment: + TZ: ${TZ} + BRIDGE_TOKEN: ${DONETICK_BRIDGE_TOKEN} + DONETICK_API_KEY: ${DONETICK_API_KEY} + DONETICK_BASE_URL: ${DONETICK_BASE_URL} + TASKS_PATH: /eapi/v1/chore + MAX_ITEMS: "20" + SHOW_COMPLETED: "false" + HIDE_INACTIVE: "true" + COMPLETED_STATUS_VALUE: "10" + REFRESH_SECONDS: "300" + healthcheck: + test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8080/healthz >/dev/null 2>&1 || exit 1"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 15s + command: + - python + - "-c" + - | + import json, os, html, urllib.request, urllib.error + from http.server import BaseHTTPRequestHandler, HTTPServer + from urllib.parse import urlparse, parse_qs + from datetime import datetime + + HOST = "0.0.0.0" + PORT = 8080 + + DONETICK_BASE_URL = os.environ["DONETICK_BASE_URL"].rstrip("/") + DONETICK_API_KEY = os.environ["DONETICK_API_KEY"] + BRIDGE_TOKEN = os.environ["BRIDGE_TOKEN"].strip() + TASKS_PATH = os.environ.get("TASKS_PATH", "/eapi/v1/chore") + + MAX_ITEMS = int(os.environ.get("MAX_ITEMS", "20")) + SHOW_COMPLETED = os.environ.get("SHOW_COMPLETED", "false").lower() in ("1", "true", "yes", "on") + HIDE_INACTIVE = os.environ.get("HIDE_INACTIVE", "true").lower() in ("1", "true", "yes", "on") + COMPLETED_STATUS_VALUE = os.environ.get("COMPLETED_STATUS_VALUE", "10").strip() + REFRESH_SECONDS = int(os.environ.get("REFRESH_SECONDS", "300")) + + TASKS_URL = f"{DONETICK_BASE_URL}{TASKS_PATH}" + + LOW_VALUE_LABELS = {"shared", "duesoon", "submitted", "waitingsubmission", "notes"} + + PRIORITY_COLORS = {1: "#ff6b6b", 2: "#ff9f1c", 3: "#ffd166", 4: "#8ecae6"} + + def parse_dt(value): + if not value: + return None + try: + return datetime.fromisoformat(value.replace("Z", "+00:00")) + except Exception: + return None + + def has_label(task, label_name): + if not label_name: + return False + wanted = label_name.strip().lower() + labels = task.get("labelsV2") or [] + return any(str(lbl.get("name", "")).strip().lower() == wanted for lbl in labels) + + def priority_text(priority): + if priority is None: + return None + try: + p = int(priority) + except Exception: + return None + return f"P{p}" if p in (1, 2, 3, 4) else None + + def priority_num(priority): + try: + p = int(priority) + if p in (1, 2, 3, 4): + return p + except Exception: + pass + return None + + def recurrence_text(task): + ftype = str(task.get("frequencyType", "") or "").strip().lower() + freq = task.get("frequency") + try: + freq_num = int(freq) if freq is not None else None + except Exception: + freq_num = None + if not ftype: + return None + if ftype == "daily": + return "Daily" if freq_num in (None, 1) else f"Every {freq_num} days" + if ftype == "weekly": + return "Weekly" if freq_num in (None, 1) else f"Every {freq_num} weeks" + if ftype == "monthly": + return "Monthly" if freq_num in (None, 1) else f"Every {freq_num} months" + if ftype == "yearly": + return "Yearly" if freq_num in (None, 1) else f"Every {freq_num} years" + if ftype == "once": + return "One-time" + return ftype.capitalize() + + def due_title_and_state(due_raw): + dt = parse_dt(due_raw) + if not dt: + return ("No due date", "nodue", None) + local = dt.astimezone() + now = datetime.now().astimezone() + days = (local.date() - now.date()).days + time_text = local.strftime("%-I:%M %p") + if days < 0: + overdue_days = abs(days) + if overdue_days == 1: + return (f"Overdue \u2022 Yesterday {time_text}", "overdue", local) + return (f"Overdue \u2022 {local.strftime('%a, %b %-d')} {time_text}", "overdue", local) + if days == 0: + return (f"Due today \u2022 {time_text}", "today", local) + if days == 1: + return (f"Due tomorrow \u2022 {time_text}", "tomorrow", local) + if days <= 6: + return (f"Due {local.strftime('%a')} \u2022 {time_text}", "upcoming", local) + return (f"Due {local.strftime('%b %-d')} \u2022 {time_text}", "upcoming", local) + + def dedupe_parts(parts): + seen = set() + out = [] + for p in parts: + if not p: + continue + key = p.strip().lower() + if key in seen: + continue + seen.add(key) + out.append(p) + return out + + def subtitle_text(task): + parts = [] + labels = task.get("labelsV2") or [] + label_names = [str(lbl.get("name", "")).strip() for lbl in labels if lbl.get("name")] + prominent = [x for x in label_names if x.strip().lower() not in LOW_VALUE_LABELS] + low = [x for x in label_names if x.strip().lower() in LOW_VALUE_LABELS] + parts.extend(prominent[:3]) + ptxt = priority_text(task.get("priority")) + if ptxt: + parts.append(ptxt) + rtxt = recurrence_text(task) + if rtxt: + label_keys = {x.strip().lower() for x in label_names} + if rtxt.strip().lower() not in label_keys: + parts.append(rtxt) + if not prominent and low: + parts.extend(low[:1]) + parts = dedupe_parts(parts) + return " \u2022 ".join(parts) + + def visible_label_chips(task): + chips = [] + labels = task.get("labelsV2") or [] + for lbl in labels: + name = str(lbl.get("name", "")).strip() + if not name or name.lower() in LOW_VALUE_LABELS: + continue + color = str(lbl.get("color", "")).strip() or "#607d8b" + chips.append({"name": name, "color": color}) + return chips[:4] + + def fetch_tasks(include_label=None, exclude_label=None): + req = urllib.request.Request(TASKS_URL, headers={"secretkey": DONETICK_API_KEY, "Accept": "application/json", "User-Agent": "donetick-dakboard-bridge/embed-v5"}, method="GET") + with urllib.request.urlopen(req, timeout=20) as resp: + data = json.loads(resp.read().decode("utf-8")) + if not isinstance(data, list): + raise ValueError("DoneTick API did not return a JSON array") + items = [] + now = datetime.now().astimezone() + for task in data: + if HIDE_INACTIVE and not task.get("isActive", True): + continue + status = str(task.get("status", "")) + if not SHOW_COMPLETED and status == COMPLETED_STATUS_VALUE: + continue + if include_label and not has_label(task, include_label): + continue + if exclude_label and has_label(task, exclude_label): + continue + name = str(task.get("name", "")).strip() or "Untitled task" + due_raw = task.get("nextDueDate") + due_title, state, due_local = due_title_and_state(due_raw) + subtitle = subtitle_text(task) + priority = priority_num(task.get("priority")) + label_chips = visible_label_chips(task) + if due_local: + overdue_rank = 0 if due_local < now else 1 + due_rank = due_local.timestamp() + missing_rank = 0 + else: + overdue_rank = 2 + due_rank = 99999999999 + missing_rank = 1 + items.append({"value": due_title, "title": name, "subtitle": subtitle, "state": state, "priority": priority, "priority_color": PRIORITY_COLORS.get(priority), "chips": label_chips, "_sort_overdue": overdue_rank, "_sort_due": due_rank, "_sort_missing": missing_rank, "_sort_name": name.lower()}) + items.sort(key=lambda x: (x["_sort_overdue"], x["_sort_missing"], x["_sort_due"], x["_sort_name"])) + items = items[:MAX_ITEMS] + for item in items: + for k in ("_sort_overdue", "_sort_due", "_sort_missing", "_sort_name"): + item.pop(k, None) + return items + + def render_embed(items, header_title): + cards = [] + for item in items: + state = html.escape(item.get("state", "upcoming")) + due = html.escape(item.get("value", "")) + title = html.escape(item.get("title", "")) + subtitle = html.escape(item.get("subtitle", "")) + priority_color = item.get("priority_color") + due_style = "" + if state == "overdue": + due_style = "color:#ff6b6b;" + elif priority_color: + due_style = f"color:{html.escape(priority_color)};" + chips_html = "" + chips = item.get("chips") or [] + if chips: + chip_bits = [f'{html.escape(c.get("name",""))}' for c in chips] + chips_html = f'
{"".join(chip_bits)}
' + subtitle_html = f'
{subtitle}
' if subtitle else "" + cards.append(f'
{due}
{title}
{chips_html}{subtitle_html}
') + tasks_html = "\n".join(cards) if cards else '
No tasks right now
' + return f"""DoneTick Tasks
{html.escape(header_title)}
{tasks_html}
""" + + class Handler(BaseHTTPRequestHandler): + def _json(self, status, payload): + body = json.dumps(payload, ensure_ascii=False).encode("utf-8") + self.send_response(status) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def _html(self, status, payload): + body = payload.encode("utf-8") + self.send_response(status) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def _authorized(self): + if not BRIDGE_TOKEN: + return True + auth = self.headers.get("Authorization", "") + if auth == f"Bearer {BRIDGE_TOKEN}": + return True + if self.headers.get("X-Bridge-Token", "") == BRIDGE_TOKEN: + return True + qs = parse_qs(urlparse(self.path).query) + if qs.get("token", [None])[0] == BRIDGE_TOKEN: + return True + return False + + def do_GET(self): + parsed = urlparse(self.path) + path = parsed.path + qs = parse_qs(parsed.query) + if path == "/healthz": + return self._json(200, {"ok": True}) + if path not in ("/", "/dakboard/tasks.json", "/embed"): + return self._json(404, {"error": "not_found"}) + if not self._authorized(): + return self._json(401, {"error": "unauthorized"}) + include_label = (qs.get("label", [""])[0] or "").strip() + exclude_label = (qs.get("exclude_label", [""])[0] or "").strip() + if include_label: + header = include_label + elif exclude_label: + header = f"Not {exclude_label}" + else: + header = "Tasks" + try: + items = fetch_tasks(include_label=include_label, exclude_label=exclude_label) + if path == "/embed": + return self._html(200, render_embed(items, header)) + return self._json(200, items) + except urllib.error.HTTPError as e: + return self._json(502, {"error": "upstream_http_error", "status": e.code}) + except Exception as e: + return self._json(502, {"error": "bridge_error", "detail": str(e)}) + + def log_message(self, format, *args): + print("%s - - [%s] %s" % (self.address_string(), self.log_date_time_string(), format % args)) + + HTTPServer((HOST, PORT), Handler).serve_forever() + + qui: + container_name: qui + image: ghcr.io/autobrr/qui:latest + restart: unless-stopped + networks: + - home-net + - pangolin + ports: + - "7476:7476" + environment: + TZ: ${TZ} + HOME: /config + XDG_CONFIG_HOME: /config + XDG_DATA_HOME: /config + volumes: + - /mnt/tank/docker/appdata/qui/qui/config:/config + healthcheck: + test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:7476/ >/dev/null 2>&1 || exit 1"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 30s + + shlink: + container_name: shlink + image: shlinkio/shlink:stable + restart: unless-stopped + networks: + - home-net + - ix-databases_shared-databases + - pangolin + ports: + - "8087:8080" + environment: + TZ: ${TZ} + DEFAULT_DOMAIN: ${SHLINK_DOMAIN} + IS_HTTPS_ENABLED: "true" + DB_DRIVER: postgres + DB_HOST: shared-postgres + DB_PORT: 5432 + DB_NAME: ${SHLINK_DB_NAME} + DB_USER: ${SHLINK_DB_USER} + DB_PASSWORD: ${SHLINK_DB_PASS} + DB_USE_ENCRYPTION: "false" + REDIRECT_STATUS_CODE: "302" + SHELL_VERBOSITY: "3" + GEOLITE_LICENSE_KEY: "" + volumes: + - /mnt/docker-ssd/docker/appdata/shlink:/etc/shlink/data + healthcheck: + test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8080/rest/health >/dev/null 2>&1 || exit 1"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 30s + + shlink-web-client: + container_name: shlink-web-client + image: ghcr.io/shlinkio/shlink-web-client:stable + restart: unless-stopped + networks: + - home-net + - pangolin + ports: + - "8088:8080" + environment: + TZ: ${TZ} + healthcheck: + test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8080/ >/dev/null 2>&1 || exit 1"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 15s diff --git a/home/truenas-app.yaml b/home/truenas-app.yaml new file mode 100644 index 0000000..ba6d7d7 --- /dev/null +++ b/home/truenas-app.yaml @@ -0,0 +1,2 @@ +include: + - /mnt/docker-ssd/docker/compose/home/docker-compose.yaml diff --git a/infrastructure/.env.example b/infrastructure/.env.example new file mode 100644 index 0000000..dc26530 --- /dev/null +++ b/infrastructure/.env.example @@ -0,0 +1,23 @@ +TZ=America/Chicago + +# User/group for dockhand +PUID=1001 +PGID=1001 + +# Newt / Pangolin tunnel +NEWT_ID=changeme +NEWT_SECRET=changeme +PANGOLIN_ENDPOINT=https://yourdomain.com + +# Tailscale +TS_HOSTNAME=plausibledeniability +TS_AUTHKEY=changeme + +# Netdata cloud +NETDATA_CLAIM_TOKEN=changeme +NETDATA_CLAIM_ROOMS=changeme + +# Uptime Kuma — must match databases .env +UPTIMEKUMA_DB_NAME=uptime_kuma +UPTIMEKUMA_DB_USER=uptime_kuma +UPTIMEKUMA_DB_PASS=changeme diff --git a/infrastructure/docker-compose.yaml b/infrastructure/docker-compose.yaml new file mode 100644 index 0000000..0a013d3 --- /dev/null +++ b/infrastructure/docker-compose.yaml @@ -0,0 +1,152 @@ +networks: + infra-net: + driver: bridge + ix-databases_shared-databases: + external: true + pangolin: + external: true + +services: + + newt: + container_name: ix-newt-newt-1 + image: fosrl/newt:latest + restart: unless-stopped + networks: + - pangolin + environment: + NEWT_ID: ${NEWT_ID} + NEWT_SECRET: ${NEWT_SECRET} + PANGOLIN_ENDPOINT: ${PANGOLIN_ENDPOINT} + TZ: ${TZ} + security_opt: + - no-new-privileges:true + volumes: + - /var/run/docker.sock:/var/run/docker.sock:ro + - /mnt/tank/docker/appdata/newt:/app/data + + homepage: + container_name: ix-homepage-homepage-1 + image: ghcr.io/gethomepage/homepage:latest + restart: unless-stopped + networks: + - infra-net + - pangolin + ports: + - "3300:3000" + environment: + TZ: ${TZ} + volumes: + - /mnt/tank/docker/appdata/homepage:/app/config + - /var/run/docker.sock:/var/run/docker.sock:ro + healthcheck: + test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:3000/ >/dev/null 2>&1 || exit 1"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 30s + + dockhand: + container_name: ix-dockhand-dockhand-1 + image: fnsys/dockhand:latest + restart: unless-stopped + networks: + - infra-net + - pangolin + ports: + - "3230:3000" + environment: + TZ: ${TZ} + PUID: ${PUID} + PGID: ${PGID} + volumes: + - /mnt/tank/docker/appdata/dockhand:/app/data + - /var/run/docker.sock:/var/run/docker.sock:ro + healthcheck: + test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:3000/ >/dev/null 2>&1 || exit 1"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 30s + + uptime-kuma: + container_name: uptime-kuma + image: louislam/uptime-kuma:2 + restart: unless-stopped + networks: + - infra-net + - ix-databases_shared-databases + - pangolin + ports: + - "3001:3001" + environment: + TZ: ${TZ} + DB_TYPE: mariadb + DB_HOST: shared-mariadb + DB_PORT: 3306 + DB_NAME: ${UPTIMEKUMA_DB_NAME} + DB_USERNAME: ${UPTIMEKUMA_DB_USER} + DB_PASSWORD: ${UPTIMEKUMA_DB_PASS} + volumes: + - /mnt/tank/docker/appdata/uptime-kuma:/app/data + healthcheck: + test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:3001/ >/dev/null 2>&1 || exit 1"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 30s + + tailscale: + container_name: ix-tailscale-tailscale-1 + image: ghcr.io/tailscale/tailscale:v1.94.2 + restart: unless-stopped + networks: + - infra-net + cap_add: + - NET_ADMIN + - SYS_MODULE + devices: + - /dev/net/tun:/dev/net/tun + environment: + TZ: ${TZ} + TS_HOSTNAME: ${TS_HOSTNAME} + TS_AUTHKEY: ${TS_AUTHKEY} + TS_STATE_DIR: /var/lib/tailscale + TS_USERSPACE: "false" + TS_ACCEPT_DNS: "false" + TS_AUTH_ONCE: "true" + TS_EXTRA_ARGS: "--advertise-exit-node=false --reset=false --accept-routes=true" + TS_SOCKET: /var/run/tailscale/tailscaled.sock + NVIDIA_VISIBLE_DEVICES: void + volumes: + - /mnt/docker-ssd/docker/appdata/tailscale:/var/lib/tailscale + + netdata: + container_name: ix-netdata-netdata-1 + image: netdata/netdata:v2.9.0 + restart: unless-stopped + networks: + - infra-net + pid: host + cap_add: + - SYS_PTRACE + - SYS_ADMIN + security_opt: + - apparmor:unconfined + environment: + TZ: Etc/UTC + NETDATA_CLAIM_TOKEN: ${NETDATA_CLAIM_TOKEN} + NETDATA_CLAIM_URL: https://app.netdata.cloud + NETDATA_CLAIM_ROOMS: ${NETDATA_CLAIM_ROOMS} + NETDATA_LISTENER_PORT: 19999 + volumes: + - /mnt/docker-ssd/docker/appdata/netdata/config:/etc/netdata + - /mnt/docker-ssd/docker/appdata/netdata/lib:/var/lib/netdata + - /mnt/docker-ssd/docker/appdata/netdata/cache:/var/cache/netdata + - /etc/passwd:/host/etc/passwd:ro + - /etc/group:/host/etc/group:ro + - /etc/localtime:/etc/localtime:ro + - /proc:/host/proc:ro + - /sys:/host/sys:ro + - /etc/os-release:/host/etc/os-release:ro + - /var/run/docker.sock:/var/run/docker.sock:ro diff --git a/infrastructure/truenas-app.yaml b/infrastructure/truenas-app.yaml new file mode 100644 index 0000000..fd38d42 --- /dev/null +++ b/infrastructure/truenas-app.yaml @@ -0,0 +1,2 @@ +include: + - /mnt/docker-ssd/docker/compose/infrastructure/docker-compose.yaml diff --git a/media/.env.example b/media/.env.example new file mode 100644 index 0000000..eeb34fb --- /dev/null +++ b/media/.env.example @@ -0,0 +1,14 @@ +TZ=America/Chicago + +# User/group for plex and tautulli +PUID=1000 +PGID=1000 + +# User/group for calibre-web-automated (different from plex) +CWA_PUID=1001 +CWA_PGID=1001 + +# Seerr postgres — must match databases initdb/01-create-databases.sql +SEERR_DB_NAME=seerr +SEERR_DB_USER=seerr +SEERR_DB_PASS=changeme diff --git a/media/docker-compose.yaml b/media/docker-compose.yaml new file mode 100644 index 0000000..0354524 --- /dev/null +++ b/media/docker-compose.yaml @@ -0,0 +1,142 @@ +networks: + media-net: + driver: bridge + ix-databases_shared-databases: + external: true + pangolin: + external: true + +services: + + plex: + container_name: ix-plex-plex-1 + image: lscr.io/linuxserver/plex:latest + restart: unless-stopped + networks: + - media-net + - pangolin + ports: + - "32400:32400/tcp" + - "1900:1900/udp" + - "5353:5353/udp" + - "8324:8324/tcp" + - "32410:32410/udp" + - "32412:32412/udp" + - "32413:32413/udp" + - "32414:32414/udp" + - "32469:32469/tcp" + environment: + TZ: ${TZ} + PUID: ${PUID} + PGID: ${PGID} + VERSION: docker + NVIDIA_VISIBLE_DEVICES: all + NVIDIA_DRIVER_CAPABILITIES: compute,video,utility + volumes: + - /mnt/docker-ssd/docker/appdata/plex:/config + - /mnt/unraid/data/media/anime:/anime + - /mnt/unraid/data/media/movies:/movies + - /mnt/unraid/data/media/tv:/tv + deploy: + resources: + reservations: + devices: + - driver: nvidia + count: all + capabilities: [gpu, video, compute] + + tautulli: + container_name: ix-tautulli-tautulli-1 + image: ghcr.io/tautulli/tautulli:latest + restart: unless-stopped + networks: + - media-net + - pangolin + ports: + - "8181:8181" + environment: + TZ: ${TZ} + PUID: ${PUID} + PGID: ${PGID} + volumes: + - /mnt/docker-ssd/docker/appdata/tautulli:/config + healthcheck: + test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8181/ >/dev/null 2>&1 || exit 1"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 30s + + audiobookshelf: + container_name: ix-audiobookshelf-audiobookshelf-1 + image: ghcr.io/advplyr/audiobookshelf:latest + restart: unless-stopped + networks: + - media-net + - pangolin + ports: + - "13358:80" + environment: + TZ: ${TZ} + volumes: + - /mnt/unraid/data/media/books/Audiobooks:/audiobooks + - /mnt/docker-ssd/docker/appdata/audiobookshelf/config:/config + - /mnt/docker-ssd/docker/appdata/audiobookshelf/metadata:/metadata + healthcheck: + test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:80/ >/dev/null 2>&1 || exit 1"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 30s + + seerr: + container_name: seerr + image: ghcr.io/seerr-team/seerr:latest + restart: unless-stopped + networks: + - media-net + - ix-databases_shared-databases + - pangolin + ports: + - "5055:5055" + environment: + TZ: ${TZ} + DB_TYPE: postgres + DB_HOST: shared-postgres + DB_PORT: 5432 + DB_NAME: ${SEERR_DB_NAME} + DB_USER: ${SEERR_DB_USER} + DB_PASS: ${SEERR_DB_PASS} + LOG_LEVEL: info + volumes: + - /mnt/docker-ssd/docker/appdata/seerr:/app/config + healthcheck: + test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:5055/ >/dev/null 2>&1 || exit 1"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 30s + + calibre-web-automated: + container_name: calibre-web-automated + image: crocodilestick/calibre-web-automated:latest + restart: unless-stopped + networks: + - media-net + - pangolin + ports: + - "8183:8083" + environment: + TZ: ${TZ} + PUID: ${CWA_PUID} + PGID: ${CWA_PGID} + volumes: + - /mnt/tank/docker/appdata/autocalibreweb-clean/config:/config + - /mnt/unraid/data/media/books/ingest:/cwa-book-ingest + - /mnt/unraid/data/media/books/Calibre:/calibre-library + healthcheck: + test: ["CMD-SHELL", "curl -sf http://127.0.0.1:8083/ >/dev/null 2>&1 || exit 1"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 60s diff --git a/media/truenas-app.yaml b/media/truenas-app.yaml new file mode 100644 index 0000000..70fafeb --- /dev/null +++ b/media/truenas-app.yaml @@ -0,0 +1,2 @@ +include: + - /mnt/docker-ssd/docker/compose/media/docker-compose.yaml diff --git a/photos/.env.example b/photos/.env.example new file mode 100644 index 0000000..67303c3 --- /dev/null +++ b/photos/.env.example @@ -0,0 +1,9 @@ +TZ=America/Chicago + +# Immich postgres — must match databases initdb/01-create-databases.sql +IMMICH_DB_NAME=immich +IMMICH_DB_USER=immich +IMMICH_DB_PASS=changeme + +# Redis — must match databases .env +REDIS_PASSWORD=changeme diff --git a/photos/docker-compose.yaml b/photos/docker-compose.yaml new file mode 100644 index 0000000..b302dbd --- /dev/null +++ b/photos/docker-compose.yaml @@ -0,0 +1,90 @@ +networks: + photos-net: + driver: bridge + ix-databases_shared-databases: + external: true + pangolin: + external: true + +services: + + immich-server: + container_name: ix-immich-immich-server-1 + image: ghcr.io/immich-app/immich-server:release + restart: unless-stopped + networks: + - photos-net + - ix-databases_shared-databases + - pangolin + ports: + - "2283:2283" + environment: + TZ: ${TZ} + DB_HOSTNAME: shared-postgres + DB_PORT: 5432 + DB_DATABASE_NAME: ${IMMICH_DB_NAME} + DB_USERNAME: ${IMMICH_DB_USER} + DB_PASSWORD: ${IMMICH_DB_PASS} + REDIS_HOSTNAME: shared-redis + REDIS_PORT: 6379 + REDIS_PASSWORD: ${REDIS_PASSWORD} + IMMICH_LOG_LEVEL: log + NVIDIA_VISIBLE_DEVICES: all + NVIDIA_DRIVER_CAPABILITIES: all + volumes: + - /mnt/unraid/immich:/data + - /mnt/docker-ssd/docker/appdata/immich:/config + deploy: + resources: + reservations: + devices: + - driver: nvidia + count: all + capabilities: [gpu, video, compute] + healthcheck: + test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:2283/api/server/ping >/dev/null 2>&1 || exit 1"] + interval: 30s + timeout: 10s + retries: 5 + start_period: 60s + depends_on: + immich-ml: + condition: service_started + + immich-ml: + container_name: ix-immich-immich-machine-learning-1 + image: ghcr.io/immich-app/immich-machine-learning:release + restart: unless-stopped + networks: + - photos-net + - ix-databases_shared-databases + environment: + TZ: ${TZ} + DB_HOSTNAME: shared-postgres + DB_PORT: 5432 + DB_DATABASE_NAME: ${IMMICH_DB_NAME} + DB_USERNAME: ${IMMICH_DB_USER} + DB_PASSWORD: ${IMMICH_DB_PASS} + REDIS_HOSTNAME: shared-redis + REDIS_PORT: 6379 + REDIS_PASSWORD: ${REDIS_PASSWORD} + DEVICE: cuda + NVIDIA_VISIBLE_DEVICES: all + NVIDIA_DRIVER_CAPABILITIES: all + TRANSFORMERS_CACHE: /cache + MACHINE_LEARNING_CACHE_FOLDER: /cache + volumes: + - /mnt/docker-ssd/docker/appdata/immich:/cache + deploy: + resources: + reservations: + devices: + - driver: nvidia + count: all + capabilities: [gpu] + healthcheck: + test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:3003/health >/dev/null 2>&1 || exit 1"] + interval: 30s + timeout: 10s + retries: 5 + start_period: 120s diff --git a/photos/truenas-app.yaml b/photos/truenas-app.yaml new file mode 100644 index 0000000..ca39004 --- /dev/null +++ b/photos/truenas-app.yaml @@ -0,0 +1,2 @@ +include: + - /mnt/docker-ssd/docker/compose/photos/docker-compose.yaml