Initial commit: all TrueNAS docker compose stacks
This commit is contained in:
16
.gitignore
vendored
Normal file
16
.gitignore
vendored
Normal 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
35
README.md
Normal 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
10
ai/.env.example
Normal 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
96
ai/docker-compose.yaml
Normal 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
2
ai/truenas-app.yaml
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
include:
|
||||||
|
- /mnt/docker-ssd/docker/compose/ai/docker-compose.yaml
|
||||||
15
databases/.env.example
Normal file
15
databases/.env.example
Normal 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
|
||||||
70
databases/docker-compose.yaml
Normal file
70
databases/docker-compose.yaml
Normal 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
|
||||||
9
databases/initdb-mariadb/01-create-uptime-kuma-db.sh
Executable file
9
databases/initdb-mariadb/01-create-uptime-kuma-db.sh
Executable 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
|
||||||
26
databases/initdb/01-create-databases.sql.example
Normal file
26
databases/initdb/01-create-databases.sql.example
Normal 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;
|
||||||
2
databases/truenas-app.yaml
Normal file
2
databases/truenas-app.yaml
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
include:
|
||||||
|
- /mnt/docker-ssd/docker/compose/databases/docker-compose.yaml
|
||||||
13
dev/.env.example
Normal file
13
dev/.env.example
Normal 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
62
dev/docker-compose.yaml
Normal 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
2
dev/truenas-app.yaml
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
include:
|
||||||
|
- /mnt/docker-ssd/docker/compose/dev/docker-compose.yaml
|
||||||
16
home/.env.example
Normal file
16
home/.env.example
Normal 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
430
home/docker-compose.yaml
Normal 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
2
home/truenas-app.yaml
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
include:
|
||||||
|
- /mnt/docker-ssd/docker/compose/home/docker-compose.yaml
|
||||||
23
infrastructure/.env.example
Normal file
23
infrastructure/.env.example
Normal 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
|
||||||
152
infrastructure/docker-compose.yaml
Normal file
152
infrastructure/docker-compose.yaml
Normal 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
|
||||||
2
infrastructure/truenas-app.yaml
Normal file
2
infrastructure/truenas-app.yaml
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
include:
|
||||||
|
- /mnt/docker-ssd/docker/compose/infrastructure/docker-compose.yaml
|
||||||
14
media/.env.example
Normal file
14
media/.env.example
Normal 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
142
media/docker-compose.yaml
Normal 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
2
media/truenas-app.yaml
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
include:
|
||||||
|
- /mnt/docker-ssd/docker/compose/media/docker-compose.yaml
|
||||||
9
photos/.env.example
Normal file
9
photos/.env.example
Normal 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
|
||||||
90
photos/docker-compose.yaml
Normal file
90
photos/docker-compose.yaml
Normal 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
2
photos/truenas-app.yaml
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
include:
|
||||||
|
- /mnt/docker-ssd/docker/compose/photos/docker-compose.yaml
|
||||||
Reference in New Issue
Block a user