ok... ok... I'll add it to git...

This commit is contained in:
2025-11-14 15:31:25 +01:00
commit 664fe55ff4
106 changed files with 24602 additions and 0 deletions
+190
View File
@@ -0,0 +1,190 @@
# Docker Compose Stacks
This directory contains version-controlled Docker Compose files for all services in the tower-of-joy infrastructure.
## Deployment
### Via Portainer API (Recommended for Automation)
Use the `update-stack.sh` script to programmatically update stacks via Portainer's REST API:
```bash
# Update a stack from YAML file
cd stacks/
./update-stack.sh open-webui.yml
```
**First-time setup:**
- Script will prompt for Portainer admin credentials
- Generates and stores API token in `.portainer-token` (gitignored)
- Subsequent runs use stored token automatically
**Non-interactive mode (for scripts/automation):**
```bash
export PORTAINER_USERNAME="admin"
export PORTAINER_PASSWORD="your-password"
./update-stack.sh open-webui.yml
```
**Benefits:**
- Safe for LLM agents and automation
- No need for remote Portainer UI access
- Version-controlled YAML files as source of truth
- Automatic authentication and token management
### Via Portainer UI
1. Navigate to **Stacks****Add Stack**
2. Choose **Upload** and select the `.yml` file
3. Review configuration and adjust environment variables
4. Click **Deploy the stack**
### Via Docker CLI
```bash
# Deploy a stack
docker compose -f stacks/<stack-name>.yml up -d
# Stop a stack
docker compose -f stacks/<stack-name>.yml down
# Update a stack
docker compose -f stacks/<stack-name>.yml pull
docker compose -f stacks/<stack-name>.yml up -d
```
## Stack Inventory
### Phase 1: Foundation
| Stack | File | Ports | GPU | Description |
|-------|------|-------|-----|-------------|
| **Portainer** | `portainer.yml` | 8080, 8443 | No | Container management UI |
| **Nginx Proxy Manager** | `nginx-proxy-manager.yml` | 8000, 80, 443 | No | Reverse proxy and unified web interface |
| **Ollama** | `ollama.yml` | 11434 | **Yes** | ML model serving with GPU acceleration |
### Phase 2: Networking
| Stack | File | Ports | GPU | Description |
|-------|------|-------|-----|-------------|
| **Headscale** | `headscale.yml` | 8085, 9090 | No | Self-hosted Tailscale control server |
### Phase 3: Monitoring
| Stack | File | Ports | GPU | Description |
|-------|------|-------|-----|-------------|
| **Uptime Kuma** | `uptime-kuma.yml` | 3001 | No | Service availability monitoring |
| **Netdata** | `netdata.yml` | 19999 | No | Real-time system performance monitoring |
| **Heimdall** | `heimdall.yml` | 8888, 8889 | No | Application dashboard |
### Phase 4: Optimization
| Stack | File | Ports | GPU | Description |
|-------|------|-------|-----|-------------|
| **Watchtower** | `watchtower.yml` | - | No | Automatic container updates |
| **Duplicati** | `duplicati.yml` | 8200 | No | Backup solution |
### Backlog: Applications
| Stack | File | Ports | GPU | Description |
|-------|------|-------|-----|-------------|
| **Jellyfin** | `jellyfin.yml` | 8096, 8920, 7359, 1900 | **Yes** | Media server with GPU transcoding |
| **Nextcloud** | `nextcloud.yml` | 8082 | No | Cloud storage (includes DB and Redis) |
| **Gitea** | `gitea.yml` | 3002, 2222 | No | Git repository hosting (includes PostgreSQL) |
| **Samba** | `samba.yml` | 139, 445 | No | Network file sharing |
## Port Allocation
### Infrastructure Services (8000-8099)
- 8000: Nginx Proxy Manager (unified web interface)
- 8080: Portainer
- 8081: AMP (game servers - existing)
- 8082: Nextcloud
- 8085: Headscale
- 8096: Jellyfin
### Git & Development Services
- 2222: Gitea SSH
- 3002: Gitea HTTP
### Monitoring Services (3000-3999, 19000-19999)
- 3001: Uptime Kuma
- 8200: Duplicati
- 8888: Heimdall
- 19999: Netdata
### ML/API Services (11000+)
- 11434: Ollama
### Network Services
- 80: HTTP (NPM reverse proxy)
- 443: HTTPS (NPM reverse proxy)
- 139, 445: Samba/SMB
- 9090: Headscale metrics
## Storage Convention
All stacks follow the dual-disk strategy:
**SSD (Performance):**
- Configs: `/home/jpmschweitzer/docker-data/<service>/config`
- Cache: `/home/jpmschweitzer/docker-data/<service>/cache`
- Databases: `/home/jpmschweitzer/docker-data/<service>/db`
**HDD (Capacity):**
- User content: `/mnt/media/<service>/data`
- Media files: `/mnt/media/<service>/media`
- Backups: `/mnt/media/backups/<service>`
## GPU Services
Stacks requiring GPU access (marked with **Yes** above):
- `ollama.yml` - ML model inference
- `jellyfin.yml` - Hardware transcoding
**Prerequisites:**
- NVIDIA Container Toolkit installed
- GPU verified: `docker run --rm --gpus all nvidia/cuda:11.4.0-base-ubuntu20.04 nvidia-smi`
## Before Deploying
1. **Review environment variables** - Change default passwords!
2. **Create directories** - Ensure volume paths exist
3. **Check ports** - Verify no conflicts with existing services
4. **GPU services** - Confirm NVIDIA toolkit installed
5. **Update STATUS.md** - Mark stack as deployed when complete
## After Deploying
1. **Test service** - Access web UI or API endpoint
2. **Check logs** - `docker logs <container-name>`
3. **Verify GPU** - `docker exec <container> nvidia-smi` (if applicable)
4. **Update documentation** - Add to STATUS.md and CHANGELOG.md
5. **Configure backup** - Add to Duplicati backup job
## Maintenance
### Update a Stack
```bash
# Pull latest images
docker compose -f stacks/<stack-name>.yml pull
# Recreate containers with new images
docker compose -f stacks/<stack-name>.yml up -d
# Or let Watchtower handle it automatically
```
### Backup Stack Configuration
```bash
# Stacks are version-controlled in this directory
# Backup container data separately (see scripts/backup.sh)
```
### Troubleshooting
- Container won't start: `docker logs <container-name>`
- Port conflicts: `sudo netstat -tulpn | grep <port>`
- Permission issues: Check volume path ownership
- GPU not detected: Verify NVIDIA toolkit and restart Docker
---
*For detailed implementation instructions, see containers/implementation-plan.md*
+103
View File
@@ -0,0 +1,103 @@
version: '3.8'
# Core API - OpenAPI-compatible functions and AI orchestration for Open WebUI
# Purpose: Provides OpenAI-compatible API (/v1/chat/completions) and tool functions (web scraping)
# Port: 8083 (HTTP API)
# Network: ai-dataplane (shared with Open WebUI, Ollama, Qdrant)
#
# Setup: Create venv before first deployment:
# cd /home/jpmschweitzer/Projects/portainer-core/services/core-api
# python3 -m venv .venv
# source .venv/bin/activate
# pip install -r requirements.txt
services:
core-api:
image: python:3.12
container_name: core-api
restart: unless-stopped
# Hot-reload development mode
command: >
sh -c "
if [ ! -f /venv/bin/activate ]; then
echo 'Creating venv and installing dependencies...' &&
python3 -m venv /venv &&
/venv/bin/pip install --upgrade pip &&
/venv/bin/pip install -r /app/requirements.txt;
fi &&
/venv/bin/uvicorn src.main:app
--host 0.0.0.0
--port 8083
--reload
--reload-dir /app/src
"
ports:
- "8083:8083"
environment:
# Application
- APP_NAME=Core API
- APP_VERSION=1.0.0-phase1
- DEBUG=true
# Server
- HOST=0.0.0.0
- PORT=8083
# Logging
- LOG_LEVEL=INFO
# Ollama Configuration (AI Orchestration)
- OLLAMA_BASE_URL=http://ollama:11434
- OLLAMA_TIMEOUT=300
# Model Configuration
- DEFAULT_MODEL=gemma:7b
- LIGHTWEIGHT_MODELS=gemma:2b,gemma:7b
- HEAVY_MODELS=mistral:7b,gemma2:9b,mixtral:8x7b
- CODE_MODELS=codestral:latest,codegemma:latest
# Model Aliases (OpenAI → Local)
- ALIAS_GPT35=gemma:7b
- ALIAS_GPT4=mistral:7b
- ALIAS_GPT4_TURBO=mixtral:8x7b
- ALIAS_GPT4_CODE=codestral:latest
# Web Scraper settings
- WEB_SCRAPER_REQUEST_TIMEOUT=30
- WEB_SCRAPER_MAX_REDIRECTS=5
- WEB_SCRAPER_USER_AGENT=Mozilla/5.0 (compatible; CoreAPI/1.0)
- WEB_SCRAPER_DEFAULT_MAX_LENGTH=10000
- WEB_SCRAPER_MAX_LINKS_TO_EXTRACT=50
# Python path
- PYTHONPATH=/app
volumes:
# Mount source code for live editing (not .venv - that's container-specific)
- /home/jpmschweitzer/Projects/portainer-core/services/core-api:/app
# Persist container's venv for fast restarts
- /home/jpmschweitzer/docker-data/core-api/venv:/venv
# Persist logs
- /home/jpmschweitzer/docker-data/core-api/logs:/app/logs
networks:
- ai-dataplane
labels:
- "com.centurylinklabs.watchtower.enable=true"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8083/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 30s
networks:
ai-dataplane:
external: true
+105
View File
@@ -0,0 +1,105 @@
#!/bin/bash
set -euo pipefail
#############################################################################
# Portainer Stack Creator
#
# Creates new Portainer stacks via REST API using local YAML files.
#
# Usage:
# ./create-stack.sh <stack-name.yml>
#
# Example:
# ./create-stack.sh gitea.yml
#############################################################################
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ENV_FILE="$SCRIPT_DIR/.env"
# Colors
RED='\033[0;31m'
GREEN='\033[0;32m'
BLUE='\033[0;34m'
NC='\033[0m'
log_info() { echo -e "${BLUE}[INFO]${NC} $1"; }
log_success() { echo -e "${GREEN}[SUCCESS]${NC} $1"; }
log_error() { echo -e "${RED}[ERROR]${NC} $1"; }
# Load credentials
if [ ! -f "$ENV_FILE" ]; then
log_error ".env file not found"
exit 1
fi
source "$ENV_FILE"
PORTAINER_URL="${PORTAINER_URL:-http://localhost:8001}"
# Validate arguments
if [ $# -ne 1 ]; then
log_error "Usage: $0 <stack-file.yml>"
exit 1
fi
YAML_FILE="$1"
STACK_NAME=$(basename "$YAML_FILE" .yml)
if [ ! -f "$YAML_FILE" ]; then
log_error "File not found: $YAML_FILE"
exit 1
fi
log_info "Creating stack: $STACK_NAME"
# Authenticate
log_info "Authenticating with Portainer..."
TOKEN=$(curl -s -X POST "$PORTAINER_URL/api/auth" \
-H "Content-Type: application/json" \
-d "{\"username\":\"$PORTAINER_USERNAME\",\"password\":\"$PORTAINER_PASSWORD\"}" \
| python3 -c "import sys, json; print(json.load(sys.stdin)['jwt'])" 2>/dev/null)
if [ -z "$TOKEN" ]; then
log_error "Authentication failed"
exit 1
fi
log_success "Authenticated"
# Get endpoint ID
ENDPOINT_ID=$(curl -s -X GET "$PORTAINER_URL/api/endpoints" \
-H "Authorization: Bearer $TOKEN" \
| python3 -c "import sys, json; print(json.load(sys.stdin)[0]['Id'])" 2>/dev/null)
if [ -z "$ENDPOINT_ID" ]; then
log_error "Failed to get endpoint ID"
exit 1
fi
log_info "Endpoint ID: $ENDPOINT_ID"
# Read YAML content
YAML_CONTENT=$(cat "$YAML_FILE")
# Create stack
log_info "Creating stack in Portainer..."
RESPONSE=$(curl -s -X POST "$PORTAINER_URL/api/stacks/create/standalone/string?endpointId=$ENDPOINT_ID" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d @- <<EOF
{
"name": "$STACK_NAME",
"stackFileContent": $(echo "$YAML_CONTENT" | python3 -c "import sys, json; print(json.dumps(sys.stdin.read()))")
}
EOF
)
# Check for errors
if echo "$RESPONSE" | grep -q '"message"' && echo "$RESPONSE" | grep -q '"err"'; then
log_error "Failed to create stack: $RESPONSE"
exit 1
fi
log_success "✓ Stack '$STACK_NAME' created successfully!"
echo "$RESPONSE" | python3 -c "import sys, json; data=json.load(sys.stdin); print(f\"Stack ID: {data.get('Id', 'N/A')}\")" 2>/dev/null || true
+91
View File
@@ -0,0 +1,91 @@
version: '3.8'
# Gitea - Self-Hosted Git Service with Database
# Application Layer
# Ports: 3002 (HTTP), 2222 (SSH)
# GPU: No
# Storage: SSD (repositories, database)
services:
gitea-db:
image: postgres:14-alpine
container_name: gitea-db
restart: unless-stopped
volumes:
# Database on SSD for performance
- /home/jpmschweitzer/docker-data/gitea/db:/var/lib/postgresql/data
environment:
- POSTGRES_USER=gitea
- POSTGRES_PASSWORD=gPdM7QV4gvotE9f9lGS4yj
- POSTGRES_DB=gitea
- TZ=Europe/Amsterdam
networks:
- gitea-network
gitea:
image: gitea/gitea:latest
container_name: gitea
restart: unless-stopped
ports:
- "3002:3000" # HTTP web interface
- "2222:22" # SSH git access (mapped to avoid host SSH conflict)
volumes:
# Git repositories and app config on SSD
- /home/jpmschweitzer/docker-data/gitea/data:/data
- /etc/timezone:/etc/timezone:ro
- /etc/localtime:/etc/localtime:ro
environment:
- USER_UID=1000
- USER_GID=1000
- GITEA__database__DB_TYPE=postgres
- GITEA__database__HOST=gitea-db:5432
- GITEA__database__NAME=gitea
- GITEA__database__USER=gitea
- GITEA__database__PASSWD=gPdM7QV4gvotE9f9lGS4yj
- TZ=Europe/Amsterdam
depends_on:
- gitea-db
networks:
- gitea-network
networks:
gitea-network:
driver: bridge
# ⚠️ SECURITY WARNING:
# Change POSTGRES_PASSWORD and GITEA__database__PASSWD before deploying!
# Use a strong, unique password.
#
# After Deployment:
# 1. Access http://localhost:3002
# 2. First-time setup:
# - Server domain: localhost:3002 or your domain
# - SSH server domain: localhost
# - SSH server port: 2222 (external)
# - HTTP listen port: 3000 (internal)
# - Application URL: http://localhost:3002 or https://git.schweitz.net (if using NPM)
# - Database: PostgreSQL (pre-configured via environment variables)
# - Create admin account (strong password!)
# 3. Configure additional settings:
# - Email settings (optional)
# - Enable/disable user registration
# - Configure webhooks and integrations
#
# SSH Git Clone Usage:
# git clone ssh://git@localhost:2222/username/repo.git
#
# Nginx Proxy Manager Setup (for external access):
# 1. Add proxy host: git.schweitz.net → http://gitea:3000
# 2. Enable SSL with Let's Encrypt
# 3. Update GITEA__server__ROOT_URL in environment to https://git.schweitz.net
#
# Features:
# - Git repository hosting
# - Organizations and teams
# - Issue tracking
# - Pull requests and code review
# - Wiki and project documentation
# - CI/CD integration (Gitea Actions)
# - Webhooks for automation
# - Migration from GitHub/GitLab
# - Lightweight and fast
+56
View File
@@ -0,0 +1,56 @@
version: '3.8'
# Headscale - Self-Hosted Tailscale Control Server
# Phase 2: Networking & External Access
# Ports: 8085 (Web/API), 9090 (Metrics)
# GPU: No
# Storage: SSD (config and database)
services:
headscale:
image: headscale/headscale:latest
container_name: headscale
restart: unless-stopped
ports:
- "8085:8080" # Web/API port
- "9090:9090" # Metrics port (optional)
volumes:
- /home/jpmschweitzer/docker-data/headscale/config:/etc/headscale
- /home/jpmschweitzer/docker-data/headscale/data:/var/lib/headscale
command: serve
environment:
- TZ=Europe/Amsterdam
networks:
- headscale-network
networks:
headscale-network:
driver: bridge
# Setup Instructions:
# 1. Create directories:
# mkdir -p ~/docker-data/headscale/{config,data}
#
# 2. Generate config:
# docker exec headscale headscale config generate > ~/docker-data/headscale/config/config.yaml
#
# 3. Edit config (important settings):
# - server_url: http://tower-of-joy:8085 (or your IP)
# - db_type: sqlite3
# - db_path: /var/lib/headscale/db.sqlite
#
# 4. Restart container: docker restart headscale
#
# 5. Create user: docker exec headscale headscale users create homelab
#
# 6. Generate pre-auth key:
# docker exec headscale headscale preauthkeys create --user homelab --expiration 24h
#
# 7. Connect devices:
# - Install Tailscale client on devices
# - Run: tailscale up --login-server=http://tower-of-joy:8085 --authkey=<key>
#
# Verify:
# - Health check: curl http://localhost:8085/health
# - List users: docker exec headscale headscale users list
# - List nodes: docker exec headscale headscale nodes list
+43
View File
@@ -0,0 +1,43 @@
version: '3.8'
# Heimdall - Application Dashboard
# Phase 3: Monitoring & Management
# Ports: 8888 (HTTP), 8889 (HTTPS)
# GPU: No
# Storage: SSD (configuration)
services:
heimdall:
image: linuxserver/heimdall:latest
container_name: heimdall
restart: unless-stopped
ports:
- "8888:80"
- "8889:443"
volumes:
- /home/jpmschweitzer/docker-data/heimdall:/config
environment:
- PUID=1000 # Your user ID (run: id -u)
- PGID=1000 # Your group ID (run: id -g)
- TZ=Europe/Amsterdam
# After Deployment:
# 1. Access http://localhost:8888
# 2. Add application tiles for quick access:
# - Portainer: http://tower-of-joy:8080
# - NPM: http://tower-of-joy:8000
# - Jellyfin: http://tower-of-joy:8096
# - Nextcloud: http://tower-of-joy:8082
# - AMP: http://tower-of-joy:8081
# - Uptime Kuma: http://tower-of-joy:3001
# - Netdata: http://tower-of-joy:19999
# - Ollama: http://tower-of-joy:11434
# 3. Customize colors and icons for each service
# 4. Set as browser homepage for easy access
#
# Features:
# - Unified dashboard for all services
# - One-click access to any service
# - Custom backgrounds and themes
# - Search functionality
# - Mobile-friendly
+69
View File
@@ -0,0 +1,69 @@
version: '3.8'
# Jellyfin - Media Server with GPU Transcoding
# Backlog: Application Deployment
# Ports: 8096 (HTTP), 8920 (HTTPS), 7359 (Auto-discovery), 1900 (DLNA)
# GPU: YES - Requires NVIDIA Container Toolkit
# Storage: SSD (config/cache), HDD (media files)
services:
jellyfin:
image: jellyfin/jellyfin:latest
container_name: jellyfin
user: 1000:1000 # Replace with your UID:GID (run: id)
network_mode: host
restart: unless-stopped
volumes:
# Config and cache on SSD (performance-critical)
- /home/jpmschweitzer/docker-data/jellyfin/config:/config
- /home/jpmschweitzer/docker-data/jellyfin/cache:/cache
# Media files on HDD (read-only for safety)
- /mnt/media/jellyfin/movies:/media/movies:ro
- /mnt/media/jellyfin/series:/media/series:ro
environment:
- NVIDIA_VISIBLE_DEVICES=all
- NVIDIA_DRIVER_CAPABILITIES=all
- TZ=Europe/Amsterdam
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: 1
capabilities: [gpu, video, compute, utility]
# GPU Transcoding Setup:
# 1. Deploy stack
# 2. Verify GPU access: docker exec jellyfin nvidia-smi
# 3. Access http://localhost:8096
# 4. Complete initial setup wizard
# 5. Navigate to: Dashboard → Playback → Transcoding
# 6. Configure hardware acceleration:
# - Hardware acceleration: NVIDIA NVENC
# - Enable hardware decoding: Check all applicable formats
# - Enable hardware encoding: Enabled
# - Encoding preset: Auto or High Quality
# 7. Test with video playback
# 8. Monitor GPU: watch -n 1 nvidia-smi
#
# Expected Results:
# - Dashboard shows "(hw)" during transcoding
# - nvidia-smi shows Video Engine usage
# - CPU usage remains low during transcoding
# - RTX 2080 Ti can handle multiple 4K transcodes simultaneously
#
# Media Organization:
# /mnt/media/jellyfin/
# ├── movies/
# │ ├── Movie Title (Year)/
# │ │ └── Movie Title (Year).mkv
# ├── tv/
# │ ├── TV Show Name/
# │ │ ├── Season 01/
# │ │ │ ├── S01E01.mkv
# │ │ │ └── S01E02.mkv
# └── music/
# ├── Artist/
# │ ├── Album/
# │ │ └── Track.mp3
+62
View File
@@ -0,0 +1,62 @@
version: '3.8'
# Maintenance Runner - Scheduled Tasks & Scripts
# Purpose: Centralized container for running maintenance tasks (backups, cleanup, health checks, etc.)
# Ports: None (background service)
# GPU: No
# Storage: Read access to Docker data, write to backup location
services:
maintenance:
image: alpine:latest
container_name: maintenance
restart: unless-stopped
volumes:
# Docker configs to backup (read-only)
- /home/jpmschweitzer/docker-data:/data/docker-data:ro
# Host-based service configs to backup (read-only)
- /home/jpmschweitzer/.config/code-server:/data/code-server-config:ro
# Backup destination (read-write)
- /mnt/media/backups:/backups
# Maintenance scripts
- /home/jpmschweitzer/docker-data/maintenance/scripts:/scripts:ro
# Crontab configuration
- /home/jpmschweitzer/docker-data/maintenance/crontab:/etc/crontabs/root:ro
# Logs
- /home/jpmschweitzer/docker-data/maintenance/logs:/var/log/maintenance
# Docker socket for container management (read-only)
- /var/run/docker.sock:/var/run/docker.sock:ro
environment:
- TZ=Europe/Amsterdam
command: >
sh -c "
apk add --no-cache docker-cli &&
crond -f -l 2
"
# crond flags:
# -f: foreground (don't daemonize)
# -l 2: log level 2 (errors and info)
# Usage:
# 1. Create maintenance scripts in ~/docker-data/maintenance/scripts/
# 2. Define schedule in ~/docker-data/maintenance/crontab
# 3. Container will execute scripts per schedule
#
# Adding new maintenance tasks:
# 1. Create new script in scripts/ directory
# 2. Make it executable: chmod +x script-name.sh
# 3. Add to crontab with desired schedule
# 4. Restart container: docker restart maintenance
#
# View logs:
# - Cron logs: docker logs maintenance
# - Task logs: ~/docker-data/maintenance/logs/
#
# Manual task execution:
# docker exec maintenance /scripts/backup-configs.sh
+48
View File
@@ -0,0 +1,48 @@
version: '3.8'
# Netdata - Real-Time System Performance Monitoring
# Phase 3: Monitoring & Management
# Ports: 19999
# GPU: No
# Storage: Minimal (reads from host /proc and /sys)
services:
netdata:
image: netdata/netdata:latest
container_name: netdata
restart: unless-stopped
hostname: tower-of-joy
ports:
- "19999:19999"
cap_add:
- SYS_PTRACE # Required for process monitoring
security_opt:
- apparmor:unconfined
volumes:
- /proc:/host/proc:ro
- /sys:/host/sys:ro
- /var/run/docker.sock:/var/run/docker.sock:ro
environment:
- TZ=Europe/Amsterdam
# Optional: Claim to Netdata Cloud for remote access
# - NETDATA_CLAIM_TOKEN=your-claim-token
# - NETDATA_CLAIM_URL=https://app.netdata.cloud
# After Deployment:
# 1. Access http://localhost:19999
# 2. Explore dashboard sections:
# - CPU usage
# - RAM usage
# - Disk I/O
# - Network traffic
# - Docker containers
# - GPU monitoring (if configured)
# 3. Set up alerts (optional)
# 4. Configure alarm notifications
#
# Features:
# - Real-time graphs (1s granularity)
# - Per-container metrics
# - System health monitoring
# - Historical data
# - Low resource overhead
+99
View File
@@ -0,0 +1,99 @@
version: '3.8'
# Nextcloud - Cloud Storage with Database and Redis
# Backlog: Application Deployment
# Ports: 8082
# GPU: No
# Storage: SSD (config/database), HDD (user data)
services:
nextcloud-db:
image: mariadb:10.11
container_name: nextcloud-db
command: --transaction-isolation=READ-COMMITTED --log-bin=binlog --binlog-format=ROW
restart: unless-stopped
volumes:
# Database on SSD for performance
- /home/jpmschweitzer/docker-data/nextcloud/db:/var/lib/mysql
environment:
- MYSQL_ROOT_PASSWORD=xDgobrmzXOl+GgvBdXC9+z5v0OrWb29t
- MYSQL_PASSWORD=maF91Sw9is6Zb57JVxU/gPGP8O/DsxFq
- MYSQL_DATABASE=nextcloud
- MYSQL_USER=nextcloud
- TZ=Europe/Amsterdam
networks:
- nextcloud-network
nextcloud-redis:
image: redis:alpine
container_name: nextcloud-redis
restart: unless-stopped
environment:
- TZ=Europe/Amsterdam
networks:
- nextcloud-network
nextcloud:
image: nextcloud:stable
container_name: nextcloud
restart: unless-stopped
ports:
- "8082:80"
volumes:
# App config on SSD
- /home/jpmschweitzer/docker-data/nextcloud/config:/var/www/html
# User data on HDD (large files)
- /mnt/media/nextcloud/data:/var/www/html/data
environment:
- MYSQL_HOST=nextcloud-db
- MYSQL_PASSWORD=maF91Sw9is6Zb57JVxU/gPGP8O/DsxFq
- MYSQL_DATABASE=nextcloud
- MYSQL_USER=nextcloud
- REDIS_HOST=nextcloud-redis
- TZ=Europe/Amsterdam
depends_on:
- nextcloud-db
- nextcloud-redis
networks:
- nextcloud-network
networks:
nextcloud-network:
driver: bridge
# ⚠️ SECURITY WARNING:
# Change MYSQL_ROOT_PASSWORD and MYSQL_PASSWORD before deploying!
# Use strong, unique passwords.
#
# After Deployment:
# 1. Access http://localhost:8082
# 2. First-time setup:
# - Create admin account (strong password!)
# - Data folder: /var/www/html/data (default)
# - Database: MySQL/MariaDB
# - Database user: nextcloud
# - Database password: (the one you set above)
# - Database name: nextcloud
# - Database host: nextcloud-db
# 3. Wait for installation (may take a few minutes)
# 4. Configure trusted domains:
# docker exec -u www-data nextcloud php occ config:system:set trusted_domains 1 --value=tower-of-joy
# docker exec -u www-data nextcloud php occ config:system:set trusted_domains 2 --value=192.168.x.x
#
# Optimization (recommended):
# docker exec -u www-data nextcloud php occ db:add-missing-indices
# docker exec -u www-data nextcloud php occ db:convert-filecache-bigint
# docker exec -u www-data nextcloud php occ background:cron
#
# Add cron job for background tasks:
# echo "*/5 * * * * docker exec -u www-data nextcloud php cron.php" | sudo tee -a /etc/crontab
#
# Features:
# - File sync and share
# - Calendar and contacts
# - Collaborative editing
# - Photo gallery
# - Mobile apps (iOS/Android)
# - Desktop sync client
# - External storage support
+36
View File
@@ -0,0 +1,36 @@
version: '3.8'
# Nginx Proxy Manager - Reverse Proxy & Unified Web Interface
# Phase 1: Foundation Setup
# Ports: 8000 (Admin UI), 80 (HTTP), 443 (HTTPS)
# GPU: No
# Storage: SSD (configs and SSL certificates)
services:
nginx-proxy-manager:
image: jc21/nginx-proxy-manager:latest
container_name: nginx-proxy-manager
restart: unless-stopped
ports:
- "8000:81" # Admin web interface (unified entry point)
- "80:80" # HTTP reverse proxy traffic
- "443:443" # HTTPS reverse proxy traffic
volumes:
# SSD storage for configs and certificates (performance-critical)
- /home/jpmschweitzer/docker-data/nginx-proxy-manager/data:/data
- /home/jpmschweitzer/docker-data/nginx-proxy-manager/letsencrypt:/etc/letsencrypt
environment:
- DB_SQLITE_FILE=/data/database.sqlite
- TZ=Europe/Amsterdam
# Setup Instructions:
# 1. Deploy this stack
# 2. Access http://localhost:8000
# 3. Default login: admin@example.com / changeme
# 4. IMPORTANT: Change admin credentials immediately!
# 5. Add proxy hosts for your services (Portainer, Jellyfin, etc.)
#
# Example Proxy Host Configuration:
# - Domain: portainer.tower-of-joy.local
# - Forward to: portainer:9000
# - Enable SSL with Let's Encrypt (optional)
+56
View File
@@ -0,0 +1,56 @@
version: '3.8'
# Ollama - GPU-Accelerated ML Model Serving
# Phase 1: Foundation Setup
# Ports: 11434 (API)
# GPU: YES - Requires NVIDIA Container Toolkit
# Storage: SSD or HDD for models (models are 2-15GB each)
services:
ollama:
image: ollama/ollama:latest
container_name: ollama
restart: unless-stopped
ports:
- "11434:11434" # Ollama API endpoint
volumes:
# Model storage - choose based on available space:
# SSD (faster load times): /home/jpmschweitzer/docker-data/ollama/models
# HDD (more space): /mnt/media/ollama/models
- /home/jpmschweitzer/docker-data/ollama/models:/root/.ollama
environment:
- TZ=Europe/Amsterdam
- NVIDIA_VISIBLE_DEVICES=all
- NVIDIA_DRIVER_CAPABILITIES=all
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: 1
capabilities: [gpu]
# GPU Requirements:
# - RTX 2080 Ti (11GB VRAM)
# - Suitable for 3B-13B parameter models
# - NVIDIA Container Toolkit must be installed
#
# After Deployment:
# 1. Verify GPU access: docker exec ollama nvidia-smi
# 2. Pull a model: docker exec ollama ollama pull llama3.2:3b
# 3. List models: docker exec ollama ollama list
# 4. Test inference: docker exec ollama ollama run llama3.2:3b "Hello"
# 5. Monitor GPU during inference: watch -n 1 nvidia-smi
#
# Recommended Models for RTX 2080 Ti (11GB VRAM):
# - llama3.2:3b (2GB) - Fast, general purpose
# - mistral:7b (4GB) - High quality, coding
# - codellama:7b (4GB) - Code-specialized
# - phi3:mini (2GB) - Fast reasoning
#
# API Usage:
# curl http://localhost:11434/api/generate -d '{
# "model": "llama3.2:3b",
# "prompt": "Why is the sky blue?",
# "stream": false
# }'
+58
View File
@@ -0,0 +1,58 @@
version: '3.8'
services:
open-webui:
image: ghcr.io/open-webui/open-webui:main
container_name: open-webui
restart: unless-stopped
ports:
- "82:8080"
environment:
# Ollama connection (direct - fallback)
- OLLAMA_BASE_URL=http://192.168.86.149:11434
# Tatlock AI Orchestrator (OpenAI-compatible endpoint)
- OPENAI_API_BASE_URLS=http://core-api:8083/v1
- OPENAI_API_KEYS=dummy
# Default model
- DEFAULT_MODELS=gemma3:12b
# Enable features
- ENABLE_RAG_WEB_SEARCH=true
- ENABLE_OLLAMA_API=true
- WEBUI_AUTH=true
# Web search configuration
- RAG_WEB_SEARCH_ENGINE=duckduckgo
# RAG & Vector Database - Qdrant for conversation memory
- VECTOR_DB=qdrant
- QDRANT_URI=http://qdrant:6333
- RAG_EMBEDDING_ENGINE=ollama
- RAG_EMBEDDING_MODEL=nomic-embed-text
- RAG_EMBEDDING_MODEL_AUTO_UPDATE=true
# Enable native memory feature
- ENABLE_MEMORY=true
- MEMORY_COLLECTION_NAME=open-webui_memories
# Session settings
- WEBUI_SESSION_COOKIE_SAME_SITE=lax
- WEBUI_SESSION_COOKIE_SECURE=false
volumes:
- /home/jpmschweitzer/docker-data/open-webui:/app/backend/data
networks:
- ai-dataplane
labels:
- "com.centurylinklabs.watchtower.enable=true"
networks:
ai-dataplane:
driver: bridge
name: ai-dataplane
+62
View File
@@ -0,0 +1,62 @@
version: '3.8'
# Organizr - Unified Dashboard with Tabbed Interface
# "One page to rule them all" - Tabs for all services in single interface
# Phase 3.5: Unified Access Layer
# Ports: 9999 (Web UI)
# GPU: No
# Storage: SSD (configs and user data)
services:
organizr:
image: organizr/organizr:latest
container_name: organizr
restart: unless-stopped
ports:
- "9999:80"
volumes:
# SSD storage for configs and user data
- /home/jpmschweitzer/docker-data/organizr:/config
environment:
- PUID=1000
- PGID=1000
- TZ=Europe/Amsterdam
- fpm=true # Enable PHP-FPM for better performance
# Setup Instructions:
# 1. Ensure tower-of-joy is connected to Headscale mesh (get mesh IP)
# 2. Deploy this stack: make deploy-organizr
# 3. Access via mesh IP: http://10.99.0.1:9999 (or local: http://192.168.86.149:9999)
# 4. Complete setup wizard
# 5. Add tabs using MESH IPs for VPN access:
# - Portainer: http://10.99.0.1:8001
# - Uptime Kuma: http://10.99.0.1:3001
# - Netdata: http://10.99.0.1:19999
# - Heimdall: http://10.99.0.1:8888
# - Jellyfin: http://10.99.0.1:8096 (when deployed)
# - Nextcloud: http://10.99.0.1:8082 (when deployed)
#
# Access Pattern (Option B - Hybrid):
# VPN Access (Primary - Admin Tools):
# - Connect to Headscale VPN
# - Access Organizr: http://10.99.0.1:9999
# - All tabs use mesh IPs (10.99.0.x)
# - Secure, no public exposure
#
# Public Access (Media/Files Only):
# - https://home.schweitz.net → Organizr (optional public)
# - https://media.schweitz.net → Jellyfin
# - https://cloud.schweitz.net → Nextcloud
# - Admin tools NOT accessible without VPN
#
# Mesh IP Benefits:
# - Works from anywhere (VPN connected)
# - No complex proxy rewrites needed
# - Secure by default
# - Easy device addition
#
# Important Notes:
# - Use mesh IPs (10.99.0.x) instead of local IPs (192.168.86.x)
# - Some services may block iframe embedding (X-Frame-Options header)
# - Organizr authentication secures public access
# - See docs/mesh-access-strategy.md for complete guide
+25
View File
@@ -0,0 +1,25 @@
version: '3.8'
# Portainer - Container Management UI
# Phase 1: Foundation Setup
# Ports: 8080 (HTTP), 8443 (HTTPS)
# GPU: No
# Storage: Docker volume (portainer_data)
services:
portainer:
image: portainer/portainer-ce:latest
container_name: portainer
restart: always
ports:
- "8080:9000" # Main Portainer web UI
- "8443:9443" # Portainer HTTPS access
volumes:
- /var/run/docker.sock:/var/run/docker.sock # Docker socket for container management
- portainer_data:/data # Persistent data storage
environment:
- TZ=Europe/Amsterdam
volumes:
portainer_data:
name: portainer_data
+55
View File
@@ -0,0 +1,55 @@
version: '3.8'
# Qdrant Vector Database
# Purpose: Efficient vector storage for Open WebUI RAG (conversation memory & documents)
# Ports: 6333 (HTTP API), 6334 (gRPC)
# GPU: NO - CPU-based vector operations are efficient
# Storage: SSD for vector data (performance-critical)
services:
qdrant:
image: qdrant/qdrant:latest
container_name: qdrant
restart: unless-stopped
ports:
- "6333:6333" # HTTP API
- "6334:6334" # gRPC API
volumes:
# Vector storage on SSD for performance
- /home/jpmschweitzer/docker-data/qdrant/storage:/qdrant/storage
# Snapshots for backups
- /home/jpmschweitzer/docker-data/qdrant/snapshots:/qdrant/snapshots
environment:
- TZ=Europe/Amsterdam
networks:
- ai-dataplane
networks:
ai-dataplane:
external: true
# Qdrant Performance Notes:
# - Optimized for high-dimensional vectors (embeddings)
# - Supports HNSW indexing for fast similarity search
# - Efficient memory usage (~1-2GB for thousands of documents)
# - No GPU required (CPU operations are fast enough)
#
# Storage Estimates:
# - ~1KB per conversation turn (with embedding)
# - 10,000 turns = ~10MB
# - Very efficient for conversation memory
#
# After Deployment:
# 1. Check logs: docker logs qdrant
# 2. Access UI: http://localhost:6333/dashboard
# 3. Verify API: curl http://localhost:6333/collections
#
# Integration with Open WebUI:
# - Set VECTOR_DB=qdrant in Open WebUI
# - Set QDRANT_URL=http://qdrant:6333
# - Open WebUI will automatically create collections
#
# Collections Created:
# - Documents: User-uploaded files for RAG
# - Conversations: Chat history for memory
# - Web search results: Cached search results
+73
View File
@@ -0,0 +1,73 @@
version: '3.8'
# Samba - Network File Sharing (SMB/CIFS)
# Backlog: Application Deployment
# Ports: 139, 445
# GPU: No
# Storage: HDD (shares from media drive)
services:
samba:
image: dperson/samba
container_name: samba
restart: unless-stopped
ports:
- "139:139"
- "445:445"
environment:
- TZ=Europe/Amsterdam
- USERID=1000 # Your user ID (run: id -u)
- GROUPID=1000 # Your group ID (run: id -g)
volumes:
# Config on SSD
- /home/jpmschweitzer/docker-data/samba:/share/config
# Shares from HDD
- /mnt/media/jellyfin:/share/media # Media files (read/write)
- /mnt/media/downloads:/share/downloads # Downloads folder
- /mnt/media/backups:/share/backups:ro # Backups (read-only)
command: >
-s "Media;/share/media;yes;no;yes;all"
-s "Downloads;/share/downloads;yes;no;no;all"
-s "Backups;/share/backups;yes;no;yes;all"
-u "jpmschweitzer;IG3omTybtVW3pVmmBi1D5FjnQ0MnZLUG"
-p
# ⚠️ SECURITY WARNING:
# Change CHANGEME_SAMBA_PASSWORD before deploying!
#
# Share Configuration Format:
# -s "ShareName;/path;browseable;readonly;guest;users"
#
# Current Shares:
# 1. Media - Read/write access to Jellyfin media
# 2. Downloads - Read/write downloads folder (guest: no)
# 3. Backups - Read-only access to backups
#
# Note: Nextcloud files accessible via web interface at https://cloud.schweitz.net
#
# Access from Clients:
#
# Windows:
# 1. Open File Explorer
# 2. Address bar: \\tower-of-joy\Media
# 3. Enter credentials: jpmschweitzer / (your password)
#
# Mac:
# 1. Finder → Go → Connect to Server
# 2. Enter: smb://tower-of-joy/Media
# 3. Enter credentials
#
# Linux:
# 1. Install smbclient: sudo apt install smbclient
# 2. List shares: smbclient -L tower-of-joy -U jpmschweitzer
# 3. Connect: smbclient //tower-of-joy/Media -U jpmschweitzer
# Or mount: sudo mount -t cifs //tower-of-joy/Media /mnt/media -o username=jpmschweitzer
#
# Mobile (iOS/Android):
# Use file manager apps that support SMB (e.g., FE File Explorer, Solid Explorer)
#
# Firewall Configuration:
# If using UFW, allow Samba:
# sudo ufw allow 139/tcp
# sudo ufw allow 445/tcp
+252
View File
@@ -0,0 +1,252 @@
#!/bin/bash
set -euo pipefail
#############################################################################
# Portainer Stack Updater (Automation-Only)
#
# Updates Portainer stacks via REST API using local YAML files.
# Credentials stored in .env file (gitignored).
#
# Usage:
# ./update-stack.sh <stack-name.yml>
#
# Example:
# ./update-stack.sh open-webui.yml
#############################################################################
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
TOKEN_FILE="$SCRIPT_DIR/.portainer-token"
ENV_FILE="$SCRIPT_DIR/.env"
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
log_info() { echo -e "${BLUE}[INFO]${NC} $1"; }
log_success() { echo -e "${GREEN}[SUCCESS]${NC} $1"; }
log_error() { echo -e "${RED}[ERROR]${NC} $1"; }
# Load credentials from .env
if [ ! -f "$ENV_FILE" ]; then
log_error ".env file not found at $ENV_FILE"
exit 1
fi
source "$ENV_FILE"
if [ -z "${PORTAINER_USERNAME:-}" ] || [ -z "${PORTAINER_PASSWORD:-}" ]; then
log_error "PORTAINER_USERNAME and PORTAINER_PASSWORD must be set in .env file"
exit 1
fi
PORTAINER_URL="${PORTAINER_URL:-http://localhost:8080}"
#############################################################################
# Authentication
#############################################################################
authenticate() {
log_info "Authenticating with Portainer..."
local response
response=$(curl -s -X POST "$PORTAINER_URL/api/auth" \
-H "Content-Type: application/json" \
-d "{\"username\":\"$PORTAINER_USERNAME\",\"password\":\"$PORTAINER_PASSWORD\"}" \
2>&1)
if [ $? -ne 0 ]; then
log_error "Failed to connect to Portainer at $PORTAINER_URL"
exit 1
fi
# Extract token
local token
token=$(echo "$response" | python3 -c "import sys, json; print(json.load(sys.stdin)['jwt'])" 2>/dev/null)
if [ -z "$token" ]; then
log_error "Failed to authenticate. Check credentials in .env file."
exit 1
fi
# Save token
echo "$token" > "$TOKEN_FILE"
chmod 600 "$TOKEN_FILE"
log_success "Authentication successful"
echo "$token"
}
get_token() {
if [ -f "$TOKEN_FILE" ]; then
cat "$TOKEN_FILE"
else
authenticate
fi
}
#############################################################################
# Stack Operations
#############################################################################
get_endpoint_id() {
local token="$1"
local response
response=$(curl -s -X GET "$PORTAINER_URL/api/endpoints" \
-H "Authorization: Bearer $token" 2>&1)
if [ $? -ne 0 ]; then
log_error "Failed to get endpoints"
return 1
fi
# Get first endpoint (local docker)
local endpoint_id
endpoint_id=$(echo "$response" | python3 -c "import sys, json; print(json.load(sys.stdin)[0]['Id'])" 2>/dev/null)
if [ -z "$endpoint_id" ]; then
log_error "No endpoints found"
return 1
fi
echo "$endpoint_id"
}
get_stack_id() {
local token="$1"
local stack_name="$2"
local response
response=$(curl -s -X GET "$PORTAINER_URL/api/stacks" \
-H "Authorization: Bearer $token" 2>&1)
if [ $? -ne 0 ]; then
log_error "Failed to get stacks"
return 1
fi
# Find stack by name
local stack_id
stack_id=$(echo "$response" | python3 -c "
import sys, json
stacks = json.load(sys.stdin)
for stack in stacks:
if stack['Name'] == '$stack_name':
print(stack['Id'])
break
" 2>/dev/null)
echo "$stack_id"
}
update_stack() {
local token="$1"
local stack_id="$2"
local endpoint_id="$3"
local yaml_content="$4"
log_info "Updating stack ID $stack_id..."
# Create JSON payload
local payload
payload=$(python3 -c "
import json, sys
data = {
'stackFileContent': '''$yaml_content''',
'prune': False,
'pullImage': False
}
print(json.dumps(data))
")
local response
response=$(curl -s -X PUT "$PORTAINER_URL/api/stacks/$stack_id?endpointId=$endpoint_id" \
-H "Authorization: Bearer $token" \
-H "Content-Type: application/json" \
-d "$payload" 2>&1)
if [ $? -ne 0 ]; then
log_error "Failed to update stack"
return 1
fi
# Check if response contains error
if echo "$response" | grep -q '"message"'; then
log_error "Stack update failed: $response"
return 1
fi
log_success "Stack updated successfully"
return 0
}
#############################################################################
# Main
#############################################################################
main() {
# Validate arguments
if [ $# -ne 1 ]; then
log_error "Usage: $0 <stack-file.yml>"
log_error "Example: $0 open-webui.yml"
exit 1
fi
local yaml_file="$1"
local yaml_path
# Support both relative and absolute paths
if [ -f "$yaml_file" ]; then
yaml_path="$yaml_file"
elif [ -f "$SCRIPT_DIR/$yaml_file" ]; then
yaml_path="$SCRIPT_DIR/$yaml_file"
else
log_error "Stack file not found: $yaml_file"
exit 1
fi
# Extract stack name from filename (remove .yml extension)
local stack_name
stack_name=$(basename "$yaml_file" .yml)
log_info "Updating stack: $stack_name"
# Read YAML content
local yaml_content
yaml_content=$(cat "$yaml_path")
# Get or create API token
local token
token=$(get_token)
# Get endpoint ID (local Docker)
local endpoint_id
endpoint_id=$(get_endpoint_id "$token")
if [ -z "$endpoint_id" ]; then
log_error "Failed to get endpoint ID"
exit 1
fi
# Get stack ID
local stack_id
stack_id=$(get_stack_id "$token" "$stack_name")
if [ -z "$stack_id" ]; then
log_error "Stack '$stack_name' not found in Portainer"
exit 1
fi
# Update stack
if update_stack "$token" "$stack_id" "$endpoint_id" "$yaml_content"; then
log_success "✓ Stack '$stack_name' updated successfully"
exit 0
else
log_error "Failed to update stack"
exit 1
fi
}
main "$@"
+52
View File
@@ -0,0 +1,52 @@
version: '3.8'
# Uptime Kuma - Service Availability Monitoring
# Phase 3: Monitoring & Management
# Ports: 3001
# GPU: No
# Storage: SSD (monitoring data)
services:
uptime-kuma:
image: louislam/uptime-kuma:latest
container_name: uptime-kuma
restart: unless-stopped
ports:
- "3001:3001"
volumes:
- /home/jpmschweitzer/docker-data/uptime-kuma:/app/data
environment:
- TZ=Europe/Amsterdam
networks:
- default
- ai-dataplane
- nextcloud-network
- headscale-network
- samba-network
networks:
ai-dataplane:
external: true
name: ai-dataplane
nextcloud-network:
external: true
name: nextcloud_nextcloud-network
headscale-network:
external: true
name: stacks_headscale-network
samba-network:
external: true
name: samba_default
# After Deployment:
# 1. Access http://localhost:3001
# 2. Create admin account on first visit
# 3. Add monitors for services:
# - Jellyfin: http://localhost:8096
# - Nextcloud: http://localhost:8082
# - Portainer: http://localhost:8080
# - NPM: http://localhost:8000
# - Headscale: http://localhost:8085/health
# - Ollama: http://localhost:11434/api/tags
# 4. Set check intervals (60 seconds recommended)
# 5. Configure notifications (optional: email, Discord, Slack)
+43
View File
@@ -0,0 +1,43 @@
version: '3.8'
# Watchtower - Automatic Container Updates
# Phase 4: Optimization & Security
# Ports: None (runs as background service)
# GPU: No
# Storage: None (reads Docker socket)
services:
watchtower:
image: containrrr/watchtower:latest
container_name: watchtower
restart: unless-stopped
volumes:
- /var/run/docker.sock:/var/run/docker.sock
environment:
- WATCHTOWER_CLEANUP=true # Remove old images after update
- WATCHTOWER_SCHEDULE=0 0 4 * * * # Run at 4 AM daily (cron format)
- TZ=Europe/Amsterdam
# Optional: Enable notifications
# - WATCHTOWER_NOTIFICATIONS=shoutrrr
# - WATCHTOWER_NOTIFICATION_URL= # Add notification URL (Discord, Slack, etc.)
# Optional: Monitor only specific containers
# - WATCHTOWER_LABEL_ENABLE=true # Only update containers with label com.centurylinklabs.watchtower.enable=true
# Schedule Format (cron):
# - 0 0 4 * * * = Daily at 4 AM
# - 0 0 4 * * SUN = Weekly on Sunday at 4 AM
# - 0 0 */6 * * * = Every 6 hours
#
# Manual Trigger:
# docker exec watchtower watchtower --run-once
#
# Exclude Specific Containers:
# Add label to container: com.centurylinklabs.watchtower.enable=false
#
# Monitor Watchtower Activity:
# docker logs watchtower
#
# Security Note:
# Watchtower has full Docker socket access. Review updates in logs.
# Consider excluding critical services and updating them manually.