Initial commit: all TrueNAS docker compose stacks

This commit is contained in:
Paccoco
2026-04-15 18:19:24 -05:00
commit 462edb32b1
25 changed files with 1242 additions and 0 deletions

16
.gitignore vendored Normal file
View File

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

35
README.md Normal file
View File

@@ -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/<stack>/.env

10
ai/.env.example Normal file
View File

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

96
ai/docker-compose.yaml Normal file
View File

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

2
ai/truenas-app.yaml Normal file
View File

@@ -0,0 +1,2 @@
include:
- /mnt/docker-ssd/docker/compose/ai/docker-compose.yaml

15
databases/.env.example Normal file
View File

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

View File

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

View File

@@ -0,0 +1,9 @@
#!/bin/sh
set -eu
mariadb -uroot -p"${MARIADB_ROOT_PASSWORD}" <<EOSQL
CREATE DATABASE IF NOT EXISTS \`${UPTIMEKUMA_DB_NAME}\` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER IF NOT EXISTS '${UPTIMEKUMA_DB_USER}'@'%' IDENTIFIED BY '${UPTIMEKUMA_DB_PASS}';
GRANT ALL PRIVILEGES ON \`${UPTIMEKUMA_DB_NAME}\`.* TO '${UPTIMEKUMA_DB_USER}'@'%';
FLUSH PRIVILEGES;
EOSQL

View File

@@ -0,0 +1,26 @@
-- Copy to 01-create-databases.sql and fill in real passwords.
-- 01-create-databases.sql is gitignored — this example is safe to commit.
CREATE ROLE gitea LOGIN PASSWORD 'changeme';
CREATE DATABASE gitea OWNER gitea;
CREATE ROLE immich LOGIN PASSWORD 'changeme';
CREATE DATABASE immich OWNER immich;
CREATE ROLE flare LOGIN PASSWORD 'changeme';
CREATE DATABASE flare OWNER flare;
CREATE ROLE seerr LOGIN PASSWORD 'changeme';
CREATE DATABASE seerr OWNER seerr;
CREATE ROLE openwebui LOGIN PASSWORD 'changeme';
CREATE DATABASE openwebui OWNER openwebui;
CREATE ROLE uptime_kuma LOGIN PASSWORD 'changeme';
CREATE DATABASE uptime_kuma OWNER uptime_kuma;
CREATE ROLE kima_hub LOGIN PASSWORD 'changeme';
CREATE DATABASE kima_hub OWNER kima_hub;
CREATE ROLE shlink LOGIN PASSWORD 'changeme';
CREATE DATABASE shlink OWNER shlink;

View File

@@ -0,0 +1,2 @@
include:
- /mnt/docker-ssd/docker/compose/databases/docker-compose.yaml

13
dev/.env.example Normal file
View File

@@ -0,0 +1,13 @@
TZ=America/Chicago
# User/group for gitea
PUID=1000
PGID=1000
# Gitea
GITEA_ROOT_URL=https://gitea.yourdomain.com
# Gitea postgres — must match databases initdb/01-create-databases.sql
GITEA_DB_NAME=gitea
GITEA_DB_USER=gitea
GITEA_DB_PASS=changeme

62
dev/docker-compose.yaml Normal file
View File

@@ -0,0 +1,62 @@
networks:
dev-net:
driver: bridge
ix-databases_shared-databases:
external: true
pangolin:
external: true
services:
gitea:
container_name: gitea
image: gitea/gitea:1.25.3
restart: unless-stopped
networks:
- dev-net
- ix-databases_shared-databases
- pangolin
ports:
- "3002:3000"
- "2222:22"
environment:
TZ: ${TZ}
USER_UID: ${PUID}
USER_GID: ${PGID}
GITEA__server__ROOT_URL: ${GITEA_ROOT_URL}
GITEA__server__HTTP_PORT: 3000
GITEA__database__DB_TYPE: postgres
GITEA__database__HOST: shared-postgres:5432
GITEA__database__NAME: ${GITEA_DB_NAME}
GITEA__database__USER: ${GITEA_DB_USER}
GITEA__database__PASSWD: ${GITEA_DB_PASS}
volumes:
- /mnt/docker-ssd/docker/appdata/gitea:/data
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
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

2
dev/truenas-app.yaml Normal file
View File

@@ -0,0 +1,2 @@
include:
- /mnt/docker-ssd/docker/compose/dev/docker-compose.yaml

16
home/.env.example Normal file
View File

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

430
home/docker-compose.yaml Normal file
View File

@@ -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'<span class="chip" style="background:{html.escape(c.get("color","#607d8b"))};">{html.escape(c.get("name",""))}</span>' for c in chips]
chips_html = f'<div class="chips">{"".join(chip_bits)}</div>'
subtitle_html = f'<div class="subtitle">{subtitle}</div>' if subtitle else ""
cards.append(f'<div class="task {state}"><div class="due" style="{due_style}">{due}</div><div class="name">{title}</div>{chips_html}{subtitle_html}</div>')
tasks_html = "\n".join(cards) if cards else '<div class="empty">No tasks right now</div>'
return f"""<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta http-equiv="refresh" content="{REFRESH_SECONDS}"><title>DoneTick Tasks</title><link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin><link href="https://fonts.googleapis.com/css2?family=Shadows+Into+Light&display=swap" rel="stylesheet"><style>:root{{--card:rgba(18,22,30,0.48);--line:rgba(255,255,255,0.10);--text:#f4f7fb;--muted:rgba(244,247,251,0.72);--overdue:#ff6b6b;--today:#ffd166;--tomorrow:#8ecae6;--upcoming:#f4f7fb;--nodue:#adb5bd}}*{{box-sizing:border-box}}html,body{{margin:0;padding:0;width:100%;height:100%;background:transparent;color:var(--text);font-family:Inter,Segoe UI,Roboto,Arial,sans-serif}}body{{padding:10px 12px}}.wrap{{width:100%;height:100%;display:grid;grid-template-columns:repeat(auto-fit,minmax(230px,1fr));gap:12px;align-content:start}}.header{{grid-column:1/-1;font-family:"Shadows Into Light",cursive;font-size:2.35rem;line-height:1;margin-bottom:2px;color:var(--text);text-shadow:0 1px 10px rgba(0,0,0,0.35)}}.task{{background:var(--card);border:1px solid var(--line);border-radius:18px;padding:12px 14px 10px;box-shadow:0 4px 18px rgba(0,0,0,0.14);backdrop-filter:blur(4px);min-width:0}}.due{{font-family:"Shadows Into Light",cursive;font-size:1.45rem;font-weight:700;line-height:1.02;margin-bottom:2px;letter-spacing:0.01em}}.name{{font-family:"Shadows Into Light",cursive;font-size:2rem;font-weight:600;line-height:1.0;margin-bottom:8px;color:var(--text);word-break:break-word}}.chips{{display:flex;flex-wrap:wrap;gap:6px;margin-bottom:5px}}.chip{{display:inline-block;padding:4px 10px;border-radius:999px;color:#ffffff;font-size:0.80rem;font-weight:700;letter-spacing:0.02em;line-height:1.05;box-shadow:inset 0 0 0 1px rgba(255,255,255,0.08)}}.subtitle{{font-size:0.78rem;line-height:1.15;color:var(--muted);letter-spacing:0.02em;word-break:break-word}}.task.overdue .due{{color:var(--overdue)}}.task.today .due{{color:var(--today)}}.task.tomorrow .due{{color:var(--tomorrow)}}.task.upcoming .due{{color:var(--upcoming)}}.task.nodue .due{{color:var(--nodue)}}.empty{{grid-column:1/-1;font-size:1.2rem;color:var(--muted);padding:20px 4px}}</style></head><body><div class="wrap"><div class="header">{html.escape(header_title)}</div>{tasks_html}</div></body></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

2
home/truenas-app.yaml Normal file
View File

@@ -0,0 +1,2 @@
include:
- /mnt/docker-ssd/docker/compose/home/docker-compose.yaml

View File

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

View File

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

View File

@@ -0,0 +1,2 @@
include:
- /mnt/docker-ssd/docker/compose/infrastructure/docker-compose.yaml

14
media/.env.example Normal file
View File

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

142
media/docker-compose.yaml Normal file
View File

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

2
media/truenas-app.yaml Normal file
View File

@@ -0,0 +1,2 @@
include:
- /mnt/docker-ssd/docker/compose/media/docker-compose.yaml

9
photos/.env.example Normal file
View File

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

View File

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

2
photos/truenas-app.yaml Normal file
View File

@@ -0,0 +1,2 @@
include:
- /mnt/docker-ssd/docker/compose/photos/docker-compose.yaml