commit 82a8c8b6cf6ae435224112fed37f8541b9f12e51 Author: kisfenyo Date: Wed Feb 11 20:27:53 2026 +0100 added app-catalog diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1220423 --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +# Rendered output (don't commit to catalog repo) +/output/ + +# Editor files +*.swp +*.swo +*~ +.vscode/ +.idea/ + +# OS files +.DS_Store +Thumbs.db diff --git a/README.md b/README.md new file mode 100644 index 0000000..1b37107 --- /dev/null +++ b/README.md @@ -0,0 +1,90 @@ +# Felhom App Catalog + +Central repository for all Felhom customer application deployments. + +## Architecture + +``` +felhom-app-catalog/ +├── templates/ # Docker Compose templates ({{DOMAIN}} placeholder) +│ ├── actualbudget/ +│ ├── docmost/ +│ ├── filebrowser/ +│ ├── homebox/ +│ ├── immich/ +│ ├── mealie/ +│ ├── romm/ +│ ├── stirling-pdf/ +│ └── vaultwarden/ +├── customers/ # Per-customer configuration +│ ├── demo-felhom.yaml +│ └── pi-customer-1.yaml +├── scripts/ +│ └── render.sh # Renders customer repos from templates +└── README.md +``` + +## How It Works + +1. **Templates** contain Docker Compose files with `{{DOMAIN}}` placeholders +2. **Customer configs** define which apps each customer gets + any version overrides +3. **render.sh** generates per-customer Gitea repos with domain-substituted compose files +4. **Portainer GitOps** on each customer node pulls from their repo and deploys + +## Workflow + +### Adding a new app to the catalog +1. Create `templates//docker-compose.yml` using `{{DOMAIN}}` placeholder +2. Add to relevant customer configs in `customers/` +3. Run `./scripts/render.sh` to regenerate customer repos + +### Updating an app version +1. Edit the image tag in `templates//docker-compose.yml` +2. Run `./scripts/render.sh` — skips customers with version overrides +3. Portainer auto-detects git changes and redeploys (if polling enabled) + +### Adding a new customer +1. Create `customers/.yaml` +2. Create the Gitea repo: `customers/-stacks` +3. Run `./scripts/render.sh --customer ` +4. Set up Portainer GitOps stacks on the customer node + +## Portainer Stack Setup (per app) + +On the customer's Portainer: +1. **Stacks → Add Stack → Repository** +2. Repository URL: `https://gitea.felhom.eu/customers/-stacks` +3. Compose path: `/docker-compose.yml` +4. Add environment variables (secrets — DB passwords, API keys, etc.) +5. Enable **GitOps auto-update** (optional, 5-minute polling) +6. Deploy + +## Environment Variables + +Secrets are **never stored in Git**. They live in Portainer's stack environment +variables on each customer node. Each template documents required env vars +in comments at the top of the compose file. + +## Version Pinning + +In a customer YAML, you can pin specific app versions: + +```yaml +overrides: + immich_version: "v2.4.1" # Don't auto-update Immich for this customer + auto_update: false # Skip ALL updates for this customer +``` + +## App Catalog + +| App | DB Type | RAM Usage | Pi-Compatible | Description | +|-----|---------|-----------|---------------|-------------| +| ActualBudget | None (file) | ~50MB | ✅ | Personal finance / budgeting | +| Docmost | PostgreSQL + Redis | ~200MB | ⚠️ (heavy) | Wiki / documentation (Notion-like) | +| FileBrowser | None (file) | ~30MB | ✅ | Web file manager | +| Homebox | None (SQLite) | ~50MB | ✅ | Home inventory management | +| Immich | PostgreSQL + Redis | ~4GB | ❌ | Photo & video management | +| Mealie | None (SQLite) | ~200MB | ✅ | Recipe manager & meal planner | +| ROMM | MariaDB + Redis | ~300MB | ⚠️ | ROM / game library manager | +| Stirling-PDF | None | ~200MB | ✅ | PDF manipulation toolkit | +| Vaultwarden | None (SQLite) | ~50MB | ✅ | Password manager (Bitwarden) | diff --git a/customers/demo-felhom.yaml b/customers/demo-felhom.yaml new file mode 100644 index 0000000..27305a1 --- /dev/null +++ b/customers/demo-felhom.yaml @@ -0,0 +1,60 @@ +# Customer: Demo / Test Server (N100 Mini PC) +# Hardware: Intel N100, 16GB RAM, 128GB NVMe + 1TB HDD +# Network: Local + Cloudflare Tunnel for demo access + +customer_id: demo-felhom +domain: demo-felhom.eu +gitea_repo: customers/demo-felhom-stacks +hardware: n100 +notes: "Internal demo/test server for validating deployments" + +# Apps to deploy on this node +apps: + - actualbudget + - docmost + - filebrowser + - homebox + - immich + - mealie + - romm + - stirling-pdf + - vaultwarden + +# Per-customer overrides (optional) +# Uncomment to pin versions or disable auto-updates +overrides: {} + # immich_version: "v2.5.5" # Pin Immich to specific version + # auto_update: false # Skip ALL version updates from catalog + +# Portainer env vars to set (reference only — actual secrets go in Portainer!) +# These are documented here so you remember what each stack needs. +env_vars_reference: + docmost: + APP_SECRET: "generate with: openssl rand -hex 32" + DB_PASSWORD: "generate secure password" + immich: + DB_PASSWORD: "generate secure password" + romm: + DB_PASSWORD: "generate secure password" + MYSQL_ROOT_PASSWORD: "generate secure password" + ROMM_AUTH_SECRET_KEY: "generate with: openssl rand -hex 32" + vaultwarden: + ADMIN_TOKEN: "generate with: openssl rand -hex 32" + SIGNUPS_ALLOWED: "true (set to false after account creation)" + +# Backup considerations +backup_notes: + databases: + - "docmost: PostgreSQL (docmost-postgres container)" + - "immich: PostgreSQL (immich-postgres container)" + - "romm: MariaDB (romm-db container)" + file_volumes: + - "actualbudget_data" + - "docmost_storage" + - "filebrowser_data" + - "homebox_data" + - "immich_upload (on HDD: /mnt/hdd_1/storage/immich)" + - "mealie_data" + - "romm_library" + - "stirling_data" + - "vaultwarden_data" diff --git a/customers/pi-customer-1.yaml b/customers/pi-customer-1.yaml new file mode 100644 index 0000000..9185bc1 --- /dev/null +++ b/customers/pi-customer-1.yaml @@ -0,0 +1,38 @@ +# Customer: Pi Test Customer #1 (Raspberry Pi) +# Hardware: Raspberry Pi 4/5, 4-8GB RAM, SD/USB + External HDD +# Network: Local only (.local domain with self-signed cert) + +customer_id: pi-customer-1 +domain: pi-customer-1.local +gitea_repo: customers/pi-customer-1-stacks +hardware: rpi +notes: "Test customer on Raspberry Pi — lightweight apps only" + +# Apps to deploy on this node (Pi-compatible only) +apps: + - actualbudget + - filebrowser + - mealie + - stirling-pdf + - vaultwarden + +# Per-customer overrides +overrides: {} + # mealie_version: "v3.10.2" # Pin if needed + # auto_update: false + +# Portainer env vars to set (reference only) +env_vars_reference: + vaultwarden: + ADMIN_TOKEN: "generate with: openssl rand -hex 32" + SIGNUPS_ALLOWED: "true (set to false after account creation)" + +# Backup considerations +backup_notes: + databases: [] # No database containers — all apps use SQLite/file storage + file_volumes: + - "actualbudget_data" + - "filebrowser_data" + - "mealie_data" + - "stirling_data" + - "vaultwarden_data" diff --git a/scripts/render.sh b/scripts/render.sh new file mode 100644 index 0000000..20789cf --- /dev/null +++ b/scripts/render.sh @@ -0,0 +1,419 @@ +#!/bin/bash +#=============================================================================== +# Felhom App Catalog - Customer Repo Renderer v1.0 +# +# Reads customer YAML configs and generates per-customer stack directories +# by substituting {{DOMAIN}} placeholders in Docker Compose templates. +# +# Usage: +# ./render.sh # Render all customers +# ./render.sh --customer demo-felhom # Render specific customer +# ./render.sh --dry-run # Show what would be done +# ./render.sh --push # Render + push to Gitea +# ./render.sh --output-dir /tmp/out # Custom output directory +# ./render.sh --debug # Verbose output +# +# Prerequisites: +# - git (for push mode) +# - Access to Gitea (for push mode) +# +#=============================================================================== + +set -euo pipefail + +#------------------------------------------------------------------------------- +# Configuration +#------------------------------------------------------------------------------- +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CATALOG_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" +TEMPLATES_DIR="${CATALOG_DIR}/templates" +CUSTOMERS_DIR="${CATALOG_DIR}/customers" +DEFAULT_OUTPUT_DIR="${CATALOG_DIR}/output" + +# Gitea settings (for --push mode) +GITEA_URL="${GITEA_URL:-https://gitea.felhom.eu}" +GITEA_ORG="${GITEA_ORG:-customers}" + +#------------------------------------------------------------------------------- +# Colors & Logging +#------------------------------------------------------------------------------- +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +CYAN='\033[0;36m' +BOLD='\033[1m' +NC='\033[0m' + +log_info() { echo -e "${CYAN}[INFO]${NC} $*"; } +log_success() { echo -e "${GREEN}[OK]${NC} $*"; } +log_warn() { echo -e "${YELLOW}[WARN]${NC} $*"; } +log_error() { echo -e "${RED}[ERROR]${NC} $*" >&2; } +log_debug() { [[ "${DEBUG:-false}" == "true" ]] && echo -e "[DEBUG] $*" || true; } +log_step() { echo -e "\n${BOLD}[STEP]${NC} $*"; } + +#------------------------------------------------------------------------------- +# Simple YAML parser (no external dependencies) +# Handles our flat customer YAML format +#------------------------------------------------------------------------------- +yaml_get_value() { + # Get a simple key: value from YAML file + local file="$1" key="$2" + grep -E "^${key}:" "$file" 2>/dev/null | sed "s/^${key}:[[:space:]]*//" | sed 's/^"//' | sed 's/"$//' | sed "s/^'//" | sed "s/'$//" || echo "" +} + +yaml_get_list() { + # Get a YAML list (items starting with " - ") + local file="$1" key="$2" + local in_section=false + while IFS= read -r line; do + if [[ "$line" =~ ^${key}: ]]; then + in_section=true + continue + fi + if $in_section; then + if [[ "$line" =~ ^[[:space:]]*-[[:space:]]+(.*) ]]; then + echo "${BASH_REMATCH[1]}" | sed 's/^"//' | sed 's/"$//' + elif [[ "$line" =~ ^[a-zA-Z_] ]] || [[ -z "$line" && "$in_section" == true ]]; then + # New top-level key or empty line after list = end of section + # But only break on a new top-level key, not empty lines within + if [[ "$line" =~ ^[a-zA-Z_] ]]; then + break + fi + fi + fi + done < "$file" +} + +yaml_get_override() { + # Get a specific override value: overrides.key + local file="$1" key="$2" + local in_overrides=false + while IFS= read -r line; do + if [[ "$line" =~ ^overrides: ]]; then + in_overrides=true + continue + fi + if $in_overrides; then + if [[ "$line" =~ ^[[:space:]]+${key}:[[:space:]]*(.*) ]]; then + local val="${BASH_REMATCH[1]}" + echo "$val" | sed 's/^"//' | sed 's/"$//' | sed "s/^'//" | sed "s/'$//" + return + fi + if [[ "$line" =~ ^[a-zA-Z_] ]]; then + break + fi + fi + done < "$file" + echo "" +} + +#------------------------------------------------------------------------------- +# Template rendering +#------------------------------------------------------------------------------- +render_customer() { + local customer_file="$1" + local output_base="$2" + + local customer_id domain + customer_id=$(yaml_get_value "$customer_file" "customer_id") + domain=$(yaml_get_value "$customer_file" "domain") + + if [[ -z "$customer_id" || -z "$domain" ]]; then + log_error "Missing customer_id or domain in: $customer_file" + return 1 + fi + + log_step "Rendering customer: ${BOLD}${customer_id}${NC} (${domain})" + + # Check auto_update override + local auto_update + auto_update=$(yaml_get_override "$customer_file" "auto_update") + if [[ "$auto_update" == "false" ]]; then + log_warn "auto_update=false — skipping this customer" + return 0 + fi + + # Get app list + local apps=() + while IFS= read -r app; do + [[ -n "$app" ]] && apps+=("$app") + done < <(yaml_get_list "$customer_file" "apps") + + if [[ ${#apps[@]} -eq 0 ]]; then + log_warn "No apps configured for $customer_id" + return 0 + fi + + log_info "Apps: ${apps[*]}" + log_info "Domain: $domain" + + # Output directory for this customer + local customer_output="${output_base}/${customer_id}-stacks" + + if [[ "$DRY_RUN" == "true" ]]; then + echo -e " ${CYAN}[DRY-RUN]${NC} Would create: ${customer_output}/" + for app in "${apps[@]}"; do + echo -e " ${CYAN}[DRY-RUN]${NC} ${app}/docker-compose.yml ({{DOMAIN}} → ${domain})" + # Show version override if any + local version_override + version_override=$(yaml_get_override "$customer_file" "${app}_version") + if [[ -n "$version_override" ]]; then + echo -e " ${CYAN}[DRY-RUN]${NC} ↳ version pinned to: ${version_override}" + fi + done + return 0 + fi + + # Create output directory + mkdir -p "$customer_output" + + # Generate a README for the customer repo + cat > "${customer_output}/README.md" << EOF +# ${customer_id} - Application Stacks + +**Domain:** \`${domain}\` +**Generated:** $(date -u '+%Y-%m-%d %H:%M:%S UTC') +**Source:** felhom-app-catalog (render.sh) + +## Deployed Apps + +| App | Compose Path | +|-----|-------------| +$(for app in "${apps[@]}"; do echo "| ${app} | \`${app}/docker-compose.yml\` |"; done) + +## Portainer Setup + +For each app, create a stack in Portainer: +1. **Stacks → Add Stack → Repository** +2. Repository URL: \`${GITEA_URL}/${GITEA_ORG}/${customer_id}-stacks\` +3. Compose path: \`/docker-compose.yml\` +4. Add required environment variables (see comments in compose files) +5. Deploy + +--- +*Auto-generated by felhom-app-catalog. Do not edit manually.* +EOF + + # Process each app + local rendered_count=0 + for app in "${apps[@]}"; do + local template="${TEMPLATES_DIR}/${app}/docker-compose.yml" + + if [[ ! -f "$template" ]]; then + log_error "Template not found: ${template}" + continue + fi + + mkdir -p "${customer_output}/${app}" + + # Substitute {{DOMAIN}} with customer domain + sed "s/{{DOMAIN}}/${domain}/g" "$template" > "${customer_output}/${app}/docker-compose.yml" + + # Apply version override if configured + local version_override + version_override=$(yaml_get_override "$customer_file" "${app}_version") + if [[ -n "$version_override" ]]; then + log_info " ${app}: applying version override → ${version_override}" + # Find the main image line and replace the tag + # This is a simple approach — works for single-image apps and + # the first image line in multi-service apps + case "$app" in + immich) + # Immich has server + ML images that should match + sed -i "s|ghcr.io/immich-app/immich-server:[^ ]*|ghcr.io/immich-app/immich-server:${version_override}|g" \ + "${customer_output}/${app}/docker-compose.yml" + sed -i "s|ghcr.io/immich-app/immich-machine-learning:[^ ]*|ghcr.io/immich-app/immich-machine-learning:${version_override}|g" \ + "${customer_output}/${app}/docker-compose.yml" + ;; + *) + # Generic: replace the first image tag (main service) + # Get the first image line and replace its tag + local first_image + first_image=$(grep -m1 "image:" "${customer_output}/${app}/docker-compose.yml" | sed 's/.*image:[[:space:]]*//') + if [[ -n "$first_image" ]]; then + local image_name + image_name=$(echo "$first_image" | cut -d: -f1) + sed -i "s|${first_image}|${image_name}:${version_override}|" \ + "${customer_output}/${app}/docker-compose.yml" + fi + ;; + esac + fi + + rendered_count=$((rendered_count + 1)) + log_debug " Rendered: ${app}/docker-compose.yml" + done + + log_success "Rendered ${rendered_count}/${#apps[@]} apps → ${customer_output}/" +} + +#------------------------------------------------------------------------------- +# Git push to Gitea +#------------------------------------------------------------------------------- +push_to_gitea() { + local customer_dir="$1" + local customer_id + customer_id=$(basename "$customer_dir" | sed 's/-stacks$//') + + local repo_url="${GITEA_URL}/${GITEA_ORG}/${customer_id}-stacks.git" + + log_step "Pushing to Gitea: ${repo_url}" + + cd "$customer_dir" + + if [[ ! -d ".git" ]]; then + git init -b main + git remote add origin "$repo_url" + fi + + git add -A + if git diff --cached --quiet; then + log_info "No changes to push for ${customer_id}" + return 0 + fi + + git commit -m "Auto-render: $(date -u '+%Y-%m-%d %H:%M:%S UTC')" + + if git push origin main 2>/dev/null; then + log_success "Pushed to ${repo_url}" + else + # Try force push if first time or diverged + log_warn "Normal push failed, trying force push..." + git push -u origin main --force + log_success "Force-pushed to ${repo_url}" + fi +} + +#------------------------------------------------------------------------------- +# Argument parsing +#------------------------------------------------------------------------------- +DRY_RUN=false +PUSH=false +DEBUG=false +TARGET_CUSTOMER="" +OUTPUT_DIR="" + +print_help() { + cat << 'EOF' +Felhom App Catalog - Customer Repo Renderer v1.0 + +Generates per-customer Docker Compose stacks from templates. + +USAGE: + ./render.sh [OPTIONS] + +OPTIONS: + --customer ID Render only this customer + --output-dir DIR Output directory (default: ./output/) + --push Push rendered repos to Gitea + --dry-run Show what would be done + --debug Verbose output + -h, --help Show this help + +ENVIRONMENT VARIABLES: + GITEA_URL Gitea base URL (default: https://gitea.felhom.eu) + GITEA_ORG Gitea organization (default: customers) + +EXAMPLES: + ./render.sh # Render all customers + ./render.sh --customer demo-felhom # Render one customer + ./render.sh --dry-run # Preview changes + ./render.sh --push # Render + push to Gitea +EOF +} + +while [[ $# -gt 0 ]]; do + case $1 in + --customer) TARGET_CUSTOMER="$2"; shift 2 ;; + --output-dir) OUTPUT_DIR="$2"; shift 2 ;; + --push) PUSH=true; shift ;; + --dry-run) DRY_RUN=true; shift ;; + --debug) DEBUG=true; shift ;; + -h|--help) print_help; exit 0 ;; + *) log_error "Unknown option: $1"; print_help; exit 1 ;; + esac +done + +OUTPUT_DIR="${OUTPUT_DIR:-${DEFAULT_OUTPUT_DIR}}" + +#------------------------------------------------------------------------------- +# Main +#------------------------------------------------------------------------------- +main() { + echo "" + echo -e "${BOLD}Felhom App Catalog — Renderer${NC}" + echo -e "Templates: ${TEMPLATES_DIR}" + echo -e "Customers: ${CUSTOMERS_DIR}" + echo -e "Output: ${OUTPUT_DIR}" + echo "" + + # Validate directories exist + if [[ ! -d "$TEMPLATES_DIR" ]]; then + log_error "Templates directory not found: $TEMPLATES_DIR" + exit 1 + fi + if [[ ! -d "$CUSTOMERS_DIR" ]]; then + log_error "Customers directory not found: $CUSTOMERS_DIR" + exit 1 + fi + + # Find customer configs to process + local customer_files=() + if [[ -n "$TARGET_CUSTOMER" ]]; then + local target_file="${CUSTOMERS_DIR}/${TARGET_CUSTOMER}.yaml" + if [[ ! -f "$target_file" ]]; then + log_error "Customer config not found: $target_file" + exit 1 + fi + customer_files=("$target_file") + else + while IFS= read -r -d '' f; do + customer_files+=("$f") + done < <(find "$CUSTOMERS_DIR" -name "*.yaml" -print0 | sort -z) + fi + + if [[ ${#customer_files[@]} -eq 0 ]]; then + log_error "No customer configs found in $CUSTOMERS_DIR" + exit 1 + fi + + log_info "Found ${#customer_files[@]} customer(s) to render" + + # Create output directory + if [[ "$DRY_RUN" != "true" ]]; then + mkdir -p "$OUTPUT_DIR" + fi + + # Render each customer + local success_count=0 + for customer_file in "${customer_files[@]}"; do + if render_customer "$customer_file" "$OUTPUT_DIR"; then + success_count=$((success_count + 1)) + + # Push to Gitea if requested + if [[ "$PUSH" == "true" && "$DRY_RUN" != "true" ]]; then + local cid + cid=$(yaml_get_value "$customer_file" "customer_id") + push_to_gitea "${OUTPUT_DIR}/${cid}-stacks" + fi + fi + done + + echo "" + echo -e "${BOLD}${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" + echo -e "${BOLD}${GREEN} Rendering complete: ${success_count}/${#customer_files[@]} customers${NC}" + echo -e "${BOLD}${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" + echo "" + + if [[ "$DRY_RUN" != "true" ]]; then + echo -e "${BOLD}Output:${NC}" + find "$OUTPUT_DIR" -name "docker-compose.yml" -printf " %P\n" | sort + echo "" + fi + + if [[ "$PUSH" != "true" && "$DRY_RUN" != "true" ]]; then + echo -e "${YELLOW}Tip:${NC} Use --push to automatically push to Gitea repos" + fi +} + +main diff --git a/templates/actualbudget/docker-compose.yml b/templates/actualbudget/docker-compose.yml new file mode 100644 index 0000000..760b7ab --- /dev/null +++ b/templates/actualbudget/docker-compose.yml @@ -0,0 +1,41 @@ +# ActualBudget - Personal Finance / Budgeting +# Domain: budget.{{DOMAIN}} +# Database: None (file-based) +# RAM: ~50MB | Pi-compatible: Yes +# +# Environment variables (set in Portainer): +# (none required — app is self-contained) +# +# First-time setup: +# Create a password on first visit, no default credentials. + +services: + actualbudget: + image: actualbudget/actual-server:26.1.0 + container_name: actualbudget + restart: unless-stopped + environment: + - TZ=Europe/Budapest + volumes: + - actualbudget_data:/data + networks: + - traefik-public + healthcheck: + test: ["CMD", "wget", "--spider", "-q", "http://localhost:5006/"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 10s + labels: + - "traefik.enable=true" + - "traefik.http.routers.actualbudget.rule=Host(`budget.{{DOMAIN}}`)" + - "traefik.http.routers.actualbudget.entrypoints=websecure" + - "traefik.http.routers.actualbudget.tls=true" + - "traefik.http.services.actualbudget.loadbalancer.server.port=5006" + +volumes: + actualbudget_data: + +networks: + traefik-public: + external: true diff --git a/templates/docmost/docker-compose.yml b/templates/docmost/docker-compose.yml new file mode 100644 index 0000000..8062498 --- /dev/null +++ b/templates/docmost/docker-compose.yml @@ -0,0 +1,90 @@ +# Docmost - Modern Wiki / Documentation (Notion-like) +# Domain: docs.{{DOMAIN}} +# Database: PostgreSQL + Redis +# RAM: ~200MB | Pi-compatible: Heavy but possible +# +# Environment variables (set in Portainer): +# APP_SECRET - Random secret for session signing (required, generate with: openssl rand -hex 32) +# DB_PASSWORD - PostgreSQL password (required) +# +# First-time setup: +# First registered user becomes admin. + +services: + docmost: + image: docmost/docmost:0.25.3 + container_name: docmost + restart: unless-stopped + depends_on: + docmost-postgres: + condition: service_healthy + docmost-redis: + condition: service_healthy + environment: + - APP_SECRET=${APP_SECRET} + - DATABASE_URL=postgresql://docmost:${DB_PASSWORD}@docmost-postgres:5432/docmost + - REDIS_URL=redis://docmost-redis:6379 + - APP_URL=https://docs.{{DOMAIN}} + - STORAGE_DRIVER=local + - FILE_UPLOAD_SIZE_LIMIT=50mb + volumes: + - docmost_storage:/app/data/storage + networks: + - traefik-public + - docmost-internal + healthcheck: + test: ["CMD", "wget", "--spider", "-q", "http://localhost:3000/"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 30s + labels: + - "traefik.enable=true" + - "traefik.http.routers.docmost.rule=Host(`docs.{{DOMAIN}}`)" + - "traefik.http.routers.docmost.entrypoints=websecure" + - "traefik.http.routers.docmost.tls=true" + - "traefik.http.services.docmost.loadbalancer.server.port=3000" + + docmost-postgres: + image: postgres:16-alpine + container_name: docmost-postgres + restart: unless-stopped + environment: + - POSTGRES_USER=docmost + - POSTGRES_PASSWORD=${DB_PASSWORD} + - POSTGRES_DB=docmost + volumes: + - docmost_postgres_data:/var/lib/postgresql/data + networks: + - docmost-internal + healthcheck: + test: ["CMD-SHELL", "pg_isready -U docmost -d docmost"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 20s + + docmost-redis: + image: redis:7-alpine + container_name: docmost-redis + restart: unless-stopped + command: redis-server --appendonly yes + volumes: + - docmost_redis_data:/data + networks: + - docmost-internal + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 3 + +volumes: + docmost_storage: + docmost_postgres_data: + docmost_redis_data: + +networks: + traefik-public: + external: true + docmost-internal: diff --git a/templates/filebrowser/docker-compose.yml b/templates/filebrowser/docker-compose.yml new file mode 100644 index 0000000..136dab6 --- /dev/null +++ b/templates/filebrowser/docker-compose.yml @@ -0,0 +1,50 @@ +# FileBrowser - Simple Web File Manager +# Domain: files.{{DOMAIN}} +# Database: None (file-based) +# RAM: ~30MB | Pi-compatible: Yes +# +# Environment variables (set in Portainer): +# (none required) +# +# First-time setup: +# Default login: admin / admin — change immediately! +# +# Volume notes: +# filebrowser_data is the browsable file area. +# For HDD access, override this volume in Portainer: +# /mnt/hdd_1:/srv (or a specific subdirectory) + +services: + filebrowser: + image: filebrowser/filebrowser:v2.32.0 + container_name: filebrowser + restart: unless-stopped + environment: + - TZ=Europe/Budapest + - PUID=1000 + - PGID=1000 + volumes: + - filebrowser_data:/srv + - filebrowser_config:/database + networks: + - traefik-public + healthcheck: + test: ["CMD", "wget", "--spider", "-q", "http://localhost:80/health"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 10s + labels: + - "traefik.enable=true" + - "traefik.http.routers.filebrowser.rule=Host(`files.{{DOMAIN}}`)" + - "traefik.http.routers.filebrowser.entrypoints=websecure" + - "traefik.http.routers.filebrowser.tls=true" + - "traefik.http.services.filebrowser.loadbalancer.server.port=80" + +volumes: + filebrowser_data: + filebrowser_config: + +networks: + traefik-public: + external: true diff --git a/templates/homebox/docker-compose.yml b/templates/homebox/docker-compose.yml new file mode 100644 index 0000000..9b56bd2 --- /dev/null +++ b/templates/homebox/docker-compose.yml @@ -0,0 +1,44 @@ +# Homebox - Home Inventory Management +# Domain: inventory.{{DOMAIN}} +# Database: None (SQLite, file-based) +# RAM: ~50MB | Pi-compatible: Yes +# +# Environment variables (set in Portainer): +# (none required) +# +# First-time setup: +# Register on first visit, first user becomes owner. + +services: + homebox: + image: ghcr.io/sysadminsmedia/homebox:v0.16.3 + container_name: homebox + restart: unless-stopped + environment: + - HBOX_LOG_LEVEL=info + - HBOX_LOG_FORMAT=text + - HBOX_WEB_MAX_UPLOAD_SIZE=50 + - TZ=Europe/Budapest + volumes: + - homebox_data:/data + networks: + - traefik-public + healthcheck: + test: ["CMD", "wget", "--spider", "-q", "http://localhost:7745/api/v1/status"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 10s + labels: + - "traefik.enable=true" + - "traefik.http.routers.homebox.rule=Host(`inventory.{{DOMAIN}}`)" + - "traefik.http.routers.homebox.entrypoints=websecure" + - "traefik.http.routers.homebox.tls=true" + - "traefik.http.services.homebox.loadbalancer.server.port=7745" + +volumes: + homebox_data: + +networks: + traefik-public: + external: true diff --git a/templates/immich/docker-compose.yml b/templates/immich/docker-compose.yml new file mode 100644 index 0000000..38cc564 --- /dev/null +++ b/templates/immich/docker-compose.yml @@ -0,0 +1,115 @@ +# Immich - Self-hosted Photo & Video Management +# Domain: photos.{{DOMAIN}} +# Database: PostgreSQL (with VectorChord) + Redis +# RAM: ~4GB minimum | Pi-compatible: No (ML too heavy) +# +# Environment variables (set in Portainer): +# DB_PASSWORD - PostgreSQL password (required) +# +# Volume notes: +# immich_upload is the photo/video storage location. +# For HDD storage, override in Portainer stack: +# /mnt/hdd_1/storage/immich:/usr/src/app/upload +# +# First-time setup: +# Create admin account on first visit. + +services: + immich-server: + image: ghcr.io/immich-app/immich-server:v2.5.5 + container_name: immich-server + restart: unless-stopped + depends_on: + immich-postgres: + condition: service_healthy + immich-redis: + condition: service_healthy + environment: + - DB_PASSWORD=${DB_PASSWORD} + - DB_HOSTNAME=immich-postgres + - DB_USERNAME=immich + - DB_DATABASE_NAME=immich + - REDIS_HOSTNAME=immich-redis + - IMMICH_MACHINE_LEARNING_URL=http://immich-machine-learning:3003 + - TZ=Europe/Budapest + volumes: + - immich_upload:/usr/src/app/upload + networks: + - traefik-public + - immich-internal + healthcheck: + test: ["CMD", "wget", "--spider", "-q", "http://localhost:2283/api/server/ping"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 60s + labels: + - "traefik.enable=true" + - "traefik.http.routers.immich.rule=Host(`photos.{{DOMAIN}}`)" + - "traefik.http.routers.immich.entrypoints=websecure" + - "traefik.http.routers.immich.tls=true" + - "traefik.http.services.immich.loadbalancer.server.port=2283" + + immich-machine-learning: + image: ghcr.io/immich-app/immich-machine-learning:v2.5.5 + container_name: immich-machine-learning + restart: unless-stopped + environment: + - TZ=Europe/Budapest + - TRANSFORMERS_CACHE=/cache + volumes: + - immich_ml_cache:/cache + networks: + - immich-internal + healthcheck: + test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:3003/ping')"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 120s + + immich-postgres: + image: ghcr.io/immich-app/postgres:16-vectorchord0.3.0 + container_name: immich-postgres + restart: unless-stopped + environment: + - POSTGRES_USER=immich + - POSTGRES_PASSWORD=${DB_PASSWORD} + - POSTGRES_DB=immich + - POSTGRES_INITDB_ARGS=--data-checksums + volumes: + - immich_postgres_data:/var/lib/postgresql/data + networks: + - immich-internal + healthcheck: + test: ["CMD-SHELL", "pg_isready -U immich -d immich"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 30s + + immich-redis: + image: redis:7-alpine + container_name: immich-redis + restart: unless-stopped + command: redis-server --appendonly yes + volumes: + - immich_redis_data:/data + networks: + - immich-internal + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 3 + +volumes: + immich_upload: + immich_ml_cache: + immich_postgres_data: + immich_redis_data: + +networks: + traefik-public: + external: true + immich-internal: diff --git a/templates/mealie/docker-compose.yml b/templates/mealie/docker-compose.yml new file mode 100644 index 0000000..a8594d4 --- /dev/null +++ b/templates/mealie/docker-compose.yml @@ -0,0 +1,53 @@ +# Mealie - Recipe Manager & Meal Planner +# Domain: recipes.{{DOMAIN}} +# Database: None (SQLite, built-in) +# RAM: ~200MB | Pi-compatible: Yes (arm64 only) +# +# Environment variables (set in Portainer): +# (none required for basic usage) +# SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASSWORD - for email features (optional) +# +# First-time setup: +# Default login: changeme@example.com / MyPassword +# Change immediately after first login! + +services: + mealie: + image: ghcr.io/mealie-recipes/mealie:v3.10.2 + container_name: mealie + restart: unless-stopped + environment: + - ALLOW_SIGNUP=false + - PUID=1000 + - PGID=1000 + - TZ=Europe/Budapest + - MAX_WORKERS=1 + - WEB_CONCURRENCY=1 + - BASE_URL=https://recipes.{{DOMAIN}} + volumes: + - mealie_data:/app/data/ + networks: + - traefik-public + deploy: + resources: + limits: + memory: 1000M + healthcheck: + test: ["CMD", "wget", "--spider", "-q", "http://localhost:9000/"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 30s + labels: + - "traefik.enable=true" + - "traefik.http.routers.mealie.rule=Host(`recipes.{{DOMAIN}}`)" + - "traefik.http.routers.mealie.entrypoints=websecure" + - "traefik.http.routers.mealie.tls=true" + - "traefik.http.services.mealie.loadbalancer.server.port=9000" + +volumes: + mealie_data: + +networks: + traefik-public: + external: true diff --git a/templates/romm/docker-compose.yml b/templates/romm/docker-compose.yml new file mode 100644 index 0000000..fdfdf44 --- /dev/null +++ b/templates/romm/docker-compose.yml @@ -0,0 +1,115 @@ +# ROMM - ROM Manager for Game Libraries +# Domain: arcade.{{DOMAIN}} +# Database: MariaDB + Redis +# RAM: ~300MB | Pi-compatible: Possible but heavy +# +# Environment variables (set in Portainer): +# DB_PASSWORD - MariaDB user password (required) +# MYSQL_ROOT_PASSWORD - MariaDB root password (required) +# ROMM_AUTH_SECRET_KEY - Auth secret (required, generate with: openssl rand -hex 32) +# IGDB_CLIENT_ID - IGDB API client ID (optional, for game metadata) +# IGDB_CLIENT_SECRET - IGDB API client secret (optional) +# STEAMGRIDDB_API_KEY - SteamGridDB API key (optional, for cover art) +# +# First-time setup: +# Default login: admin / admin — change immediately! + +services: + romm: + image: rommapp/romm:4.5.0 + container_name: romm + restart: unless-stopped + depends_on: + romm-db: + condition: service_healthy + romm-redis: + condition: service_started + entrypoint: ["/bin/sh", "-c"] + command: + - | + if [ ! -f /romm/config/config.yml ]; then + echo "Creating default config.yml..." + cat > /romm/config/config.yml << 'CONF' + exclude: + platforms: [] + roms: [] + system: + log_level: INFO + CONF + fi + exec /docker-entrypoint.sh /init + environment: + - ROMM_AUTH_SECRET_KEY=${ROMM_AUTH_SECRET_KEY} + - DB_PASSWD=${DB_PASSWORD} + - DB_HOST=romm-db + - DB_PORT=3306 + - DB_NAME=romm + - DB_USER=romm + - REDIS_HOST=romm-redis + - REDIS_PORT=6379 + - ROMM_PORT=8080 + - IGDB_CLIENT_ID=${IGDB_CLIENT_ID:-} + - IGDB_CLIENT_SECRET=${IGDB_CLIENT_SECRET:-} + - STEAMGRIDDB_API_KEY=${STEAMGRIDDB_API_KEY:-} + - TZ=Europe/Budapest + volumes: + - romm_library:/romm/library + - romm_resources:/romm/resources + - romm_config:/romm/config + networks: + - traefik-public + - romm-internal + labels: + - "traefik.enable=true" + - "traefik.http.routers.romm.rule=Host(`arcade.{{DOMAIN}}`)" + - "traefik.http.routers.romm.entrypoints=websecure" + - "traefik.http.routers.romm.tls=true" + - "traefik.http.services.romm.loadbalancer.server.port=8080" + + romm-db: + image: mariadb:11.4 + container_name: romm-db + restart: unless-stopped + environment: + - MYSQL_ROOT_PASSWORD=${MYSQL_ROOT_PASSWORD} + - MYSQL_DATABASE=romm + - MYSQL_USER=romm + - MYSQL_PASSWORD=${DB_PASSWORD} + - TZ=Europe/Budapest + volumes: + - romm_db_data:/var/lib/mysql + networks: + - romm-internal + healthcheck: + test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 30s + + romm-redis: + image: redis:7-alpine + container_name: romm-redis + restart: unless-stopped + command: redis-server --appendonly yes + volumes: + - romm_redis_data:/data + networks: + - romm-internal + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 3 + +volumes: + romm_library: + romm_resources: + romm_config: + romm_db_data: + romm_redis_data: + +networks: + traefik-public: + external: true + romm-internal: diff --git a/templates/stirling-pdf/docker-compose.yml b/templates/stirling-pdf/docker-compose.yml new file mode 100644 index 0000000..5e77907 --- /dev/null +++ b/templates/stirling-pdf/docker-compose.yml @@ -0,0 +1,49 @@ +# Stirling-PDF - PDF Manipulation Toolkit +# Domain: pdf.{{DOMAIN}} +# Database: None +# RAM: ~200MB | Pi-compatible: Yes +# +# Environment variables (set in Portainer): +# (none required for basic usage) +# SECURITY_ENABLELOGIN=true - Enable login (optional) +# SECURITY_INITIALLOGIN_USERNAME / SECURITY_INITIALLOGIN_PASSWORD - if login enabled +# +# First-time setup: +# No login by default — accessible immediately. +# Enable login via env vars if exposing externally. + +services: + stirling-pdf: + image: stirlingtools/stirling-pdf:0.45.1 + container_name: stirling-pdf + restart: unless-stopped + environment: + - TZ=Europe/Budapest + - DOCKER_ENABLE_SECURITY=false + - INSTALL_BOOK_AND_ADVANCED_HTML_OPS=false + - LANGS=en_GB + volumes: + - stirling_data:/configs + - stirling_training:/usr/share/tessdata + networks: + - traefik-public + healthcheck: + test: ["CMD", "wget", "--spider", "-q", "http://localhost:8080/api/v1/info/status"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 20s + labels: + - "traefik.enable=true" + - "traefik.http.routers.stirling-pdf.rule=Host(`pdf.{{DOMAIN}}`)" + - "traefik.http.routers.stirling-pdf.entrypoints=websecure" + - "traefik.http.routers.stirling-pdf.tls=true" + - "traefik.http.services.stirling-pdf.loadbalancer.server.port=8080" + +volumes: + stirling_data: + stirling_training: + +networks: + traefik-public: + external: true diff --git a/templates/vaultwarden/docker-compose.yml b/templates/vaultwarden/docker-compose.yml new file mode 100644 index 0000000..8a4bc1f --- /dev/null +++ b/templates/vaultwarden/docker-compose.yml @@ -0,0 +1,53 @@ +# Vaultwarden - Password Manager (Bitwarden-compatible) +# Domain: vault.{{DOMAIN}} +# Database: None (SQLite, built-in) +# RAM: ~50MB | Pi-compatible: Yes +# +# Environment variables (set in Portainer): +# ADMIN_TOKEN - Admin panel token (optional but recommended, generate with: openssl rand -hex 32) +# SIGNUPS_ALLOWED - Set to "false" after creating your account(s) +# +# First-time setup: +# 1. Visit https://vault.{{DOMAIN}} and create an account +# 2. Set SIGNUPS_ALLOWED=false in Portainer env vars +# 3. Redeploy stack +# 4. Admin panel at https://vault.{{DOMAIN}}/admin (if ADMIN_TOKEN set) +# +# Clients: +# Use any Bitwarden client (desktop, mobile, browser extension) +# Set server URL to: https://vault.{{DOMAIN}} + +services: + vaultwarden: + image: vaultwarden/server:1.33.2-alpine + container_name: vaultwarden + restart: unless-stopped + environment: + - DOMAIN=https://vault.{{DOMAIN}} + - SIGNUPS_ALLOWED=${SIGNUPS_ALLOWED:-true} + - ADMIN_TOKEN=${ADMIN_TOKEN:-} + - WEBSOCKET_ENABLED=true + - TZ=Europe/Budapest + volumes: + - vaultwarden_data:/data + networks: + - traefik-public + healthcheck: + test: ["CMD", "wget", "--spider", "-q", "http://localhost:80/alive"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 10s + labels: + - "traefik.enable=true" + - "traefik.http.routers.vaultwarden.rule=Host(`vault.{{DOMAIN}}`)" + - "traefik.http.routers.vaultwarden.entrypoints=websecure" + - "traefik.http.routers.vaultwarden.tls=true" + - "traefik.http.services.vaultwarden.loadbalancer.server.port=80" + +volumes: + vaultwarden_data: + +networks: + traefik-public: + external: true