architecture changed to preconfigured customer templates

This commit is contained in:
2026-02-12 18:47:02 +01:00
parent d5624825a9
commit 5995041697
2 changed files with 824 additions and 100 deletions
+162 -96
View File
@@ -1,12 +1,13 @@
# Felhom App Catalog # Felhom App Catalog
Central repository for all Felhom customer application deployments. Central repository for Felhom customer application templates.
## Architecture ## Architecture
``` ```
felhom-app-catalog/ This repo (source of truth) app-catalog-felhom.eu/ <- This repo (source of truth)
├── templates/ # Docker Compose templates with placeholders ├── templates.json # Portainer App Templates index (generic, with env var prompts)
├── templates/ # Docker Compose templates with ${VAR} env var syntax
│ ├── actualbudget/ │ ├── actualbudget/
│ ├── docmost/ │ ├── docmost/
│ ├── filebrowser/ │ ├── filebrowser/
@@ -17,121 +18,186 @@ felhom-app-catalog/ ← This repo (source of truth)
│ ├── romm/ │ ├── romm/
│ ├── stirling-pdf/ │ ├── stirling-pdf/
│ └── vaultwarden/ │ └── vaultwarden/
── customers/ # Per-customer configuration (YAML) ── scripts/
── demo-felhom.yaml ── generate-customer.sh # Generates customer-specific templates with baked-in secrets
│ └── pi-customer-1.yaml ```
├── scripts/
│ └── render.sh # Renders output from templates + customer configs The output is pushed to:
└── output/ # Generated monorepo (pushed to Gitea) **https://gitea.dooplex.hu/admin/customers-felhom.eu**
├── README.md
```
customers-felhom.eu/ <- Generated per-customer deployments
├── demo-felhom/ ├── demo-felhom/
│ ├── templates.json # Customer Portainer templates (zero env vars, zero-touch deploy)
│ ├── secrets.env # Reference copy of all generated secrets
│ ├── actualbudget/docker-compose.yml │ ├── actualbudget/docker-compose.yml
│ ├── immich/docker-compose.yml │ ├── docmost/docker-compose.yml
│ └── ... │ └── ...
└── pi-customer-1/ └── pi-customer-1/
├── templates.json
├── secrets.env
├── actualbudget/docker-compose.yml ├── actualbudget/docker-compose.yml
└── ... └── ...
``` ```
The `output/` directory is what gets pushed to:
**https://gitea.dooplex.hu/admin/customers-felhom.eu**
## How It Works ## How It Works
1. **Templates** contain Docker Compose files with `{{DOMAIN}}` and `{{HDD_PATH}}` placeholders 1. **Templates** in this repo contain Docker Compose files with `${DOMAIN}`, `${HDD_PATH}`, and `${SECRET}` placeholders
2. **Customer configs** define which apps each customer gets, their domain, HDD path, and any version overrides 2. **`generate-customer.sh`** substitutes all values (domain, HDD path, auto-generated passwords) and produces customer-specific compose files + a Portainer `templates.json` with zero env vars
3. **render.sh** substitutes all placeholders and generates the output directory 3. **`docker-setup.sh`** on the customer node starts Portainer with `--templates` pointing at the customer's generated `templates.json`
4. **`--push`** commits and pushes the output to the Gitea monorepo 4. **Customer** opens Portainer -> Templates -> picks an app -> clicks Deploy -> **done**
5. **Portainer GitOps** on each customer node pulls from the same repo, using a different compose path per stack
### Placeholder Reference
| Placeholder | Source | Example |
|-------------|--------|---------|
| `{{DOMAIN}}` | `domain:` in customer YAML | `demo-felhom.eu` |
| `{{HDD_PATH}}` | `hdd_path:` in customer YAML | `/mnt/hdd_1` |
### Storage Strategy
- **HDD host paths** (`{{HDD_PATH}}/storage/...`): Large user data — photos, documents, ROMs
- **Named Docker volumes** (on NVMe): Databases, app config, caches — need fast I/O
- Templates that don't use `{{HDD_PATH}}` work without it (e.g. ActualBudget, Mealie)
- If a template needs `{{HDD_PATH}}` but the customer config doesn't set `hdd_path:`, the render script refuses that app and tells you what to fix
## Workflow ## Workflow
### Render & push ### 1. Generate customer templates (on your workstation)
```bash ```bash
./scripts/render.sh # Render all customers locally # Full setup: all apps, with HDD
./scripts/render.sh --push # Render + commit + push to Gitea ./scripts/generate-customer.sh --customer demo-felhom \
./scripts/render.sh --customer demo-felhom # Render one customer only --domain demo-felhom.eu --hdd-path /mnt/hdd_1 --push
./scripts/render.sh --dry-run # Preview what would happen
./scripts/render.sh --debug # Verbose output # Raspberry Pi: lightweight apps only, no HDD yet
./scripts/generate-customer.sh --customer pi-customer-1 \
--domain pi-customer-1.local \
--apps actualbudget,filebrowser,mealie,stirling-pdf,vaultwarden --push
# Preview without creating files
./scripts/generate-customer.sh --customer test --domain test.local --dry-run
``` ```
The default Gitea repo URL is `https://gitea.dooplex.hu/admin/customers-felhom.eu.git`. Options:
Override with: `GITEA_REPO_URL=https://... ./scripts/render.sh --push` - `--customer ID` -- Customer identifier (required)
- `--domain DOMAIN` -- Customer domain (required)
- `--hdd-path PATH` -- External HDD mount path (optional; apps needing it show a field in Portainer if omitted)
- `--apps LIST` -- Comma-separated app list (default: all available)
- `--push` -- Git commit and push to customers-felhom.eu repo
- `--dry-run` -- Show what would be done
- `--debug` -- Verbose output
### Adding a new app to the catalog ### 2. Set up customer server
1. Create `templates/<appname>/docker-compose.yml` using `{{DOMAIN}}` and optionally `{{HDD_PATH}}` ```bash
2. Add the app name to relevant customer configs in `customers/` sudo ./docker-setup.sh --domain demo-felhom.eu --customer demo-felhom \
3. Run `./scripts/render.sh --push` --email certs@felhom.eu --cf-token <cloudflare-api-token>
### Updating an app version
1. Edit the image tag in `templates/<appname>/docker-compose.yml`
2. Run `./scripts/render.sh --push`
3. Portainer auto-detects git changes and redeploys (if polling enabled)
Customers with version overrides keep their pinned version.
### Adding a new customer
1. Create `customers/<customer-id>.yaml` (copy an existing one as template)
2. Run `./scripts/render.sh --push`
3. Set up Portainer GitOps stacks on the customer node (see below)
## Portainer Stack Setup (per app)
On the customer's Portainer, for each app:
1. **Stacks → Add Stack → Repository**
2. Repository URL: `https://gitea.dooplex.hu/admin/customers-felhom.eu`
3. Compose path: `<customer-id>/<appname>/docker-compose.yml`
- Example: `demo-felhom/immich/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
``` ```
### 3. Deploy apps
Open `https://portainer.<domain>` -> **Templates** -> pick an app -> **Deploy the stack**.
All fields (domain, passwords, secrets) are pre-filled. If HDD_PATH wasn't set during generation, apps that need it will show one field to fill in.
### Re-generating after changes
Re-running `generate-customer.sh` for an existing customer preserves all previously generated secrets (idempotent). Only new apps or new secrets are generated.
```bash
# Add an app to an existing customer
./scripts/generate-customer.sh --customer demo-felhom \
--domain demo-felhom.eu --hdd-path /mnt/hdd_1 --push
```
## Environment Variables & Secrets
Secrets are auto-generated by `generate-customer.sh` and baked directly into the compose files and Portainer templates. They are **not** stored separately on the customer node -- they live in the customers-felhom.eu Git repo (which is private on your Gitea).
A `secrets.env` reference file is generated alongside the compose files for easy lookup.
### Secret definitions
Defined in `generate-customer.sh` via the `APP_SECRET_DEFS` array:
```bash
APP_SECRET_DEFS=(
"docmost:APP_SECRET:hex:32"
"docmost:DB_PASSWORD:password:24"
"immich:DB_PASSWORD:password:24"
"paperless-ngx:PAPERLESS_SECRET_KEY:hex:32"
"paperless-ngx:DB_PASSWORD:password:24"
"paperless-ngx:PAPERLESS_ADMIN_USER:static:admin"
"paperless-ngx:PAPERLESS_ADMIN_PASSWORD:password:16"
"romm:DB_PASSWORD:password:24"
"romm:MYSQL_ROOT_PASSWORD:password:24"
"romm:ROMM_AUTH_SECRET_KEY:hex:32"
"vaultwarden:ADMIN_TOKEN:hex:32"
"vaultwarden:SIGNUPS_ALLOWED:static:true"
)
```
Types: `password:LENGTH` (alphanumeric), `hex:LENGTH` (crypto), `static:VALUE` (fixed).
### Variable types per app
| App | DOMAIN | HDD_PATH | Secrets |
|-----|:------:|:--------:|---------|
| ActualBudget | yes | -- | -- |
| Docmost | yes | -- | APP_SECRET, DB_PASSWORD |
| FileBrowser | yes | yes | -- |
| Homebox | yes | -- | -- |
| Immich | yes | yes | DB_PASSWORD |
| Mealie | yes | -- | -- |
| Paperless-ngx | yes | yes | PAPERLESS_SECRET_KEY, DB_PASSWORD, PAPERLESS_ADMIN_USER, PAPERLESS_ADMIN_PASSWORD |
| ROMM | yes | yes | DB_PASSWORD, MYSQL_ROOT_PASSWORD, ROMM_AUTH_SECRET_KEY |
| Stirling-PDF | yes | -- | -- |
| Vaultwarden | yes | -- | ADMIN_TOKEN |
## App Catalog ## App Catalog
| App | DB Type | RAM | Pi | HDD Data | Subdomain | | App | DB Type | RAM | Pi | HDD Data | Subdomain |
|-----|---------|-----|-----|----------|-----------| |-----|---------|-----|-----|----------|-----------|
| ActualBudget | None (file) | ~50MB | | | budget.* | | ActualBudget | None (file) | ~50MB | yes | -- | budget.* |
| Docmost | PostgreSQL + Redis | ~200MB | ⚠️ | | docs.* | | Docmost | PostgreSQL + Redis | ~200MB | maybe | -- | docs.* |
| FileBrowser | None (file) | ~30MB | | `{{HDD_PATH}}/` | files.* | | FileBrowser | None (file) | ~30MB | yes | `${HDD_PATH}/storage/filebrowser/` | files.* |
| Homebox | None (SQLite) | ~50MB | | | inventory.* | | Homebox | None (SQLite) | ~50MB | yes | -- | inventory.* |
| Immich | PostgreSQL + Redis | ~4GB | | `{{HDD_PATH}}/storage/immich/` | photos.* | | Immich | PostgreSQL + Redis | ~4GB | no | `${HDD_PATH}/storage/immich/` | photos.* |
| Mealie | None (SQLite) | ~200MB | | | recipes.* | | Mealie | None (SQLite) | ~200MB | yes | -- | recipes.* |
| Paperless-ngx | PostgreSQL + Redis | ~500MB | | `{{HDD_PATH}}/storage/paperless/` | paperless.* | | Paperless-ngx | PostgreSQL + Redis | ~500MB | yes | `${HDD_PATH}/storage/paperless/` | paperless.* |
| ROMM | MariaDB + Redis | ~300MB | ⚠️ | `{{HDD_PATH}}/storage/romm/` | arcade.* | | ROMM | MariaDB + Redis | ~300MB | maybe | `${HDD_PATH}/storage/romm/` | arcade.* |
| Stirling-PDF | None | ~200MB | | | pdf.* | | Stirling-PDF | None | ~200MB | yes | -- | pdf.* |
| Vaultwarden | None (SQLite) | ~50MB | | | vault.* | | Vaultwarden | None (SQLite) | ~50MB | yes | -- | vault.* |
### Storage strategy
- **HDD host paths** (`${HDD_PATH}/storage/...`): Large user data -- photos, documents, ROMs
- **Named Docker volumes** (on internal SSD): Databases, app config, caches -- need fast I/O
- Templates without `${HDD_PATH}` work without an external HDD (e.g., ActualBudget, Mealie)
## Adding a New App
1. Create `templates/<appname>/docker-compose.yml` using `${DOMAIN}` and optionally `${HDD_PATH}`
2. Add a template entry in `templates.json` with env definitions, description, logo, and notes
3. If the app needs secrets, add entries to `APP_SECRET_DEFS` in `generate-customer.sh`
4. Re-run `generate-customer.sh --push` for each customer
### templates.json entry format
```json
{
"type": 3,
"title": "App Name",
"description": "Short description.",
"categories": ["category"],
"platform": "linux",
"logo": "https://example.com/logo.png",
"note": "<b>Access:</b> https://subdomain.&lt;DOMAIN&gt;",
"repository": {
"url": "https://gitea.dooplex.hu/admin/app-catalog-felhom.eu",
"stackfile": "templates/appname/docker-compose.yml"
},
"env": [
{
"name": "DOMAIN",
"label": "Domain",
"description": "Your server domain (e.g., demo-felhom.eu)"
}
]
}
```
## Related Repositories
| Repository | Purpose |
|------------|---------|
| [app-catalog-felhom.eu](https://gitea.dooplex.hu/admin/app-catalog-felhom.eu) | This repo -- templates + generation script |
| [customers-felhom.eu](https://gitea.dooplex.hu/admin/customers-felhom.eu) | Generated per-customer compose files + templates |
| [deploy-portainer](https://gitea.dooplex.hu/admin/deploy-portainer) | `docker-setup.sh` -- server provisioning |
+658
View File
@@ -0,0 +1,658 @@
#!/usr/bin/env bash
#===============================================================================
# generate-customer.sh — Generate customer-specific Portainer templates
#
# Creates a customer directory with:
# - templates.json Portainer App Templates (secrets as defaults, zero-touch deploy)
# - <app>/docker-compose.yml Compose files with domain + secrets baked in
# - secrets.env Reference file with all generated secrets
#
# Run from the app-catalog-felhom.eu repo root.
#
# Usage:
# ./scripts/generate-customer.sh --customer demo-felhom --domain demo-felhom.eu \
# --hdd-path /mnt/hdd_1 --push
#
# Version: 1.0.0
#===============================================================================
set -euo pipefail
SCRIPT_VERSION="1.0.0"
#-------------------------------------------------------------------------------
# Configuration
#-------------------------------------------------------------------------------
CUSTOMERS_REPO_GIT="https://gitea.dooplex.hu/admin/customers-felhom.eu.git"
CUSTOMERS_REPO_RAW="https://gitea.dooplex.hu/admin/customers-felhom.eu"
OUTPUT_BASE="./output"
# Defaults
CUSTOMER_ID=""
DOMAIN=""
HDD_PATH=""
APP_LIST="" # comma-separated, or empty for all
PUSH=false
DRY_RUN=false
DEBUG_MODE=false
# Apps that require HDD_PATH in compose volumes
APPS_NEEDING_HDD="filebrowser immich paperless-ngx romm"
#-------------------------------------------------------------------------------
# App secret definitions
#
# Format: "app_name:VAR_NAME:type:param"
#
# Types:
# password:LENGTH — alphanumeric random string (a-zA-Z0-9)
# hex:LENGTH — hex string (for crypto secrets)
# static:VALUE — fixed value
#
# To add secrets for a new app, just add lines here.
#-------------------------------------------------------------------------------
APP_SECRET_DEFS=(
# Docmost
"docmost:APP_SECRET:hex:32"
"docmost:DB_PASSWORD:password:24"
# Immich
"immich:DB_PASSWORD:password:24"
# Paperless-ngx
"paperless-ngx:PAPERLESS_SECRET_KEY:hex:32"
"paperless-ngx:DB_PASSWORD:password:24"
"paperless-ngx:PAPERLESS_ADMIN_USER:static:admin"
"paperless-ngx:PAPERLESS_ADMIN_PASSWORD:password:16"
# ROMM
"romm:DB_PASSWORD:password:24"
"romm:MYSQL_ROOT_PASSWORD:password:24"
"romm:ROMM_AUTH_SECRET_KEY:hex:32"
# Vaultwarden
"vaultwarden:ADMIN_TOKEN:hex:32"
"vaultwarden:SIGNUPS_ALLOWED:static:true"
)
#-------------------------------------------------------------------------------
# Colors and logging
#-------------------------------------------------------------------------------
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
CYAN='\033[0;36m'
BOLD='\033[1m'
NC='\033[0m'
log_info() { echo -e "${GREEN}[INFO]${NC} $1"; }
log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
log_error() { echo -e "${RED}[ERROR]${NC} $1"; }
log_step() { echo -e "${BLUE}[STEP]${NC} $1"; }
log_success() { echo -e "${GREEN}[OK]${NC} $1"; }
log_debug() { [[ "$DEBUG_MODE" == true ]] && echo -e "${CYAN}[DEBUG]${NC} $1" || true; }
#-------------------------------------------------------------------------------
# Help
#-------------------------------------------------------------------------------
print_help() {
cat << 'EOF'
generate-customer.sh — Generate customer-specific Portainer templates
Creates a customer directory with baked-in secrets and domain config,
ready for zero-touch deployment via Portainer App Templates.
USAGE:
./scripts/generate-customer.sh [OPTIONS]
REQUIRED:
--customer ID Customer identifier (e.g., demo-felhom)
--domain DOMAIN Customer domain (e.g., demo-felhom.eu)
OPTIONAL:
--hdd-path PATH External HDD mount path (e.g., /mnt/hdd_1)
If omitted, apps needing HDD show a field in Portainer.
--apps LIST Comma-separated app list (default: all available)
Available: actualbudget,docmost,filebrowser,homebox,
immich,mealie,paperless-ngx,romm,
stirling-pdf,vaultwarden
--push Git commit and push to customers-felhom.eu repo
--dry-run Show what would be done, create nothing
--debug Verbose output
-h, --help Show this help
OUTPUT:
./output/<customer>/
├── templates.json Portainer App Templates (point --templates here)
├── secrets.env All generated secrets (reference)
├── actualbudget/docker-compose.yml
├── docmost/docker-compose.yml
└── ...
Portainer templates URL:
https://gitea.dooplex.hu/admin/customers-felhom.eu/raw/branch/main/<customer>/templates.json
EXAMPLES:
# All apps, with HDD
./scripts/generate-customer.sh --customer demo-felhom \
--domain demo-felhom.eu --hdd-path /mnt/hdd_1 --push
# Raspberry Pi — lightweight apps only
./scripts/generate-customer.sh --customer pi-customer-1 \
--domain pi-customer-1.local \
--apps actualbudget,filebrowser,mealie,stirling-pdf,vaultwarden
# Preview without creating files
./scripts/generate-customer.sh --customer test --domain test.local --dry-run
EOF
}
#-------------------------------------------------------------------------------
# Argument parsing
#-------------------------------------------------------------------------------
parse_args() {
while [[ $# -gt 0 ]]; do
case $1 in
--customer)
[[ $# -lt 2 || "$2" == --* ]] && { log_error "--customer requires a value"; exit 1; }
CUSTOMER_ID="$2"; shift 2 ;;
--domain)
[[ $# -lt 2 || "$2" == --* ]] && { log_error "--domain requires a value"; exit 1; }
DOMAIN="$2"; shift 2 ;;
--hdd-path)
[[ $# -lt 2 || "$2" == --* ]] && { log_error "--hdd-path requires a value"; exit 1; }
HDD_PATH="${2%/}"; shift 2 ;; # strip trailing slash
--apps)
[[ $# -lt 2 || "$2" == --* ]] && { log_error "--apps requires a value"; exit 1; }
APP_LIST="$2"; shift 2 ;;
--push) PUSH=true; shift ;;
--dry-run) DRY_RUN=true; shift ;;
--debug) DEBUG_MODE=true; shift ;;
-h|--help) print_help; exit 0 ;;
*) log_error "Unknown option: $1"; print_help; exit 1 ;;
esac
done
# Validate required args
if [[ -z "$CUSTOMER_ID" ]]; then
log_error "Missing required: --customer"
exit 1
fi
if [[ -z "$DOMAIN" ]]; then
log_error "Missing required: --domain"
exit 1
fi
# Sanitize customer ID (alphanumeric, hyphens, underscores only)
if [[ ! "$CUSTOMER_ID" =~ ^[a-zA-Z0-9_-]+$ ]]; then
log_error "Customer ID must be alphanumeric (hyphens/underscores allowed): $CUSTOMER_ID"
exit 1
fi
}
#-------------------------------------------------------------------------------
# Pre-flight checks
#-------------------------------------------------------------------------------
check_prerequisites() {
local missing=()
# Must be run from repo root (templates/ and templates.json must exist)
if [[ ! -f "templates.json" ]]; then
log_error "templates.json not found. Run this script from the app-catalog-felhom.eu repo root."
exit 1
fi
if [[ ! -d "templates" ]]; then
log_error "templates/ directory not found. Run this script from the app-catalog-felhom.eu repo root."
exit 1
fi
command -v jq >/dev/null 2>&1 || missing+=("jq")
command -v envsubst >/dev/null 2>&1 || missing+=("gettext-base (envsubst)")
command -v openssl >/dev/null 2>&1 || missing+=("openssl")
if [[ "$PUSH" == true ]]; then
command -v git >/dev/null 2>&1 || missing+=("git")
fi
if [[ ${#missing[@]} -gt 0 ]]; then
log_error "Missing required tools: ${missing[*]}"
log_error "Install with: sudo apt install ${missing[*]}"
exit 1
fi
}
#-------------------------------------------------------------------------------
# Determine app list
#-------------------------------------------------------------------------------
resolve_apps() {
local available_apps=()
# Discover available apps from templates/ directory
for dir in templates/*/; do
[[ -f "${dir}docker-compose.yml" ]] && available_apps+=("$(basename "$dir")")
done
if [[ -z "$APP_LIST" ]]; then
# Default: all available apps
APPS=("${available_apps[@]}")
else
# Parse comma-separated list
IFS=',' read -ra APPS <<< "$APP_LIST"
# Validate each app exists
for app in "${APPS[@]}"; do
local found=false
for avail in "${available_apps[@]}"; do
[[ "$app" == "$avail" ]] && { found=true; break; }
done
if [[ "$found" == false ]]; then
log_error "Unknown app: $app"
log_error "Available: ${available_apps[*]}"
exit 1
fi
done
fi
# Warn about HDD_PATH requirements
if [[ -z "$HDD_PATH" ]]; then
local hdd_apps=()
for app in "${APPS[@]}"; do
if [[ " $APPS_NEEDING_HDD " == *" $app "* ]]; then
hdd_apps+=("$app")
fi
done
if [[ ${#hdd_apps[@]} -gt 0 ]]; then
log_warn "No --hdd-path provided. These apps will require manual HDD_PATH input in Portainer:"
log_warn " ${hdd_apps[*]}"
fi
fi
}
#-------------------------------------------------------------------------------
# Secret generation
#-------------------------------------------------------------------------------
generate_secret() {
local type="$1"
local param="$2"
case "$type" in
password)
openssl rand -base64 $(( param * 2 )) 2>/dev/null \
| tr -dc 'a-zA-Z0-9' \
| head -c "$param"
;;
hex)
openssl rand -hex "$param" 2>/dev/null
;;
static)
echo "$param"
;;
*)
log_error "Unknown secret type: $type"
return 1
;;
esac
}
#-------------------------------------------------------------------------------
# Load or generate secrets for a customer
#
# Reads existing secrets.env to preserve previously generated values.
# Only generates new secrets for variables not already present.
#-------------------------------------------------------------------------------
load_or_generate_secrets() {
local customer_dir="$1"
local secrets_file="${customer_dir}/secrets.env"
# Load existing secrets (if re-running)
declare -gA SECRETS=()
if [[ -f "$secrets_file" ]]; then
log_info "Loading existing secrets from ${secrets_file}"
while IFS='=' read -r key value; do
[[ "$key" =~ ^[[:space:]]*# ]] && continue
[[ -z "$key" ]] && continue
key="${key#"${key%%[![:space:]]*}"}"
key="${key%"${key##*[![:space:]]}"}"
[[ -n "$key" && -n "$value" ]] && SECRETS[$key]="$value"
done < "$secrets_file"
fi
local generated=0
local preserved=0
# Generate missing secrets for requested apps
for def in "${APP_SECRET_DEFS[@]}"; do
IFS=':' read -r def_app def_var def_type def_param <<< "$def"
# Skip apps not in the customer's list
local in_list=false
for app in "${APPS[@]}"; do
[[ "$app" == "$def_app" ]] && { in_list=true; break; }
done
[[ "$in_list" == false ]] && continue
local key="${def_app}__${def_var}"
if [[ -n "${SECRETS[$key]+x}" ]]; then
log_debug " ${def_app}/${def_var}: preserved"
(( preserved++ )) || true
else
SECRETS[$key]=$(generate_secret "$def_type" "$def_param")
log_debug " ${def_app}/${def_var}: generated (${def_type}:${def_param})"
(( generated++ )) || true
fi
done
log_success "Secrets: ${generated} generated, ${preserved} preserved"
# Write secrets.env
if [[ "$DRY_RUN" == false ]]; then
cat > "$secrets_file" << EOF
# Customer: ${CUSTOMER_ID}
# Domain: ${DOMAIN}
# Generated by generate-customer.sh v${SCRIPT_VERSION}
# Generated: $(date -u '+%Y-%m-%d %H:%M:%S UTC')
#
# This file is a reference copy of all generated secrets.
# Actual values are baked into the compose files and templates.json.
# Global
DOMAIN=${DOMAIN}
$(if [[ -n "$HDD_PATH" ]]; then echo "HDD_PATH=${HDD_PATH}"; else echo "# HDD_PATH= (not set)"; fi)
EOF
# Write per-app secrets in order (namespaced to avoid collisions)
local current_app=""
for def in "${APP_SECRET_DEFS[@]}"; do
IFS=':' read -r def_app def_var _ _ <<< "$def"
local in_list=false
for app in "${APPS[@]}"; do
[[ "$app" == "$def_app" ]] && { in_list=true; break; }
done
[[ "$in_list" == false ]] && continue
if [[ "$def_app" != "$current_app" ]]; then
echo "" >> "$secrets_file"
echo "# ${def_app}" >> "$secrets_file"
current_app="$def_app"
fi
# Namespaced key: app__VAR (matches internal SECRETS[] keys)
echo "${def_app}__${def_var}=${SECRETS[${def_app}__${def_var}]}" >> "$secrets_file"
done
chmod 600 "$secrets_file"
fi
}
#-------------------------------------------------------------------------------
# Generate compose files with baked-in values
#-------------------------------------------------------------------------------
generate_compose_files() {
local customer_dir="$1"
for app in "${APPS[@]}"; do
local src="templates/${app}/docker-compose.yml"
local dst_dir="${customer_dir}/${app}"
local dst="${dst_dir}/docker-compose.yml"
if [[ ! -f "$src" ]]; then
log_error "Template not found: $src"
exit 1
fi
if [[ "$DRY_RUN" == true ]]; then
log_info "[DRY RUN] Would generate: ${dst}"
continue
fi
mkdir -p "$dst_dir"
# Step 1: Normalize {{VAR}} → ${VAR} (handle old mustache syntax)
local tmpfile
tmpfile=$(mktemp)
sed 's/{{DOMAIN}}/${DOMAIN}/g; s/{{HDD_PATH}}/${HDD_PATH}/g' "$src" > "$tmpfile"
# Step 2: Build envsubst variable list (only substitute known values)
local subst_vars='${DOMAIN}'
export DOMAIN
if [[ -n "$HDD_PATH" ]]; then
subst_vars+=' ${HDD_PATH}'
export HDD_PATH
fi
# Export app-specific secrets
for def in "${APP_SECRET_DEFS[@]}"; do
IFS=':' read -r def_app def_var _ _ <<< "$def"
[[ "$def_app" != "$app" ]] && continue
local key="${def_app}__${def_var}"
if [[ -n "${SECRETS[$key]+x}" ]]; then
export "$def_var"="${SECRETS[$key]}"
subst_vars+=" \${${def_var}}"
fi
done
log_debug " ${app}: subst_vars=${subst_vars}"
# Step 3: envsubst with explicit var list (leaves unresolved vars intact)
envsubst "$subst_vars" < "$tmpfile" > "$dst"
rm -f "$tmpfile"
# Step 4: Unset exported app-specific secrets (avoid leaking to next app)
for def in "${APP_SECRET_DEFS[@]}"; do
IFS=':' read -r def_app def_var _ _ <<< "$def"
[[ "$def_app" != "$app" ]] && continue
unset "$def_var" 2>/dev/null || true
done
log_debug " ${app}: generated"
done
}
#-------------------------------------------------------------------------------
# Generate customer-specific templates.json
#
# Takes the master templates.json, filters to requested apps, updates
# repository URLs to point at the customers repo, and removes env vars
# that have been baked into compose files.
#-------------------------------------------------------------------------------
generate_templates_json() {
local customer_dir="$1"
if [[ "$DRY_RUN" == true ]]; then
log_info "[DRY RUN] Would generate: ${customer_dir}/templates.json"
return
fi
# Build jq filter for app titles
# We need to match by stackfile path since titles might have different casing
local app_filters=()
for app in "${APPS[@]}"; do
app_filters+=("\"templates/${app}/docker-compose.yml\"")
done
local stackfile_array
stackfile_array=$(printf '%s,' "${app_filters[@]}")
stackfile_array="[${stackfile_array%,}]"
local keep_hdd_json="false"
[[ -z "$HDD_PATH" ]] && keep_hdd_json="true"
jq --arg customer "$CUSTOMER_ID" \
--arg repo "$CUSTOMERS_REPO_RAW" \
--argjson stackfiles "$stackfile_array" \
--argjson keep_hdd "$keep_hdd_json" \
'{
version: .version,
templates: [
.templates[]
| select(.repository.stackfile as $sf | $stackfiles | index($sf))
| .repository.url = $repo
| .repository.stackfile = ($customer + "/" + (.repository.stackfile | split("/") | .[1:] | join("/")))
| .env = (
if $keep_hdd then
[.env[]? | select(.name == "HDD_PATH")]
else
[]
end
)
]
}' templates.json > "${customer_dir}/templates.json"
log_debug " templates.json: $(jq '.templates | length' "${customer_dir}/templates.json") app entries"
}
#-------------------------------------------------------------------------------
# Git push to customers repo
#-------------------------------------------------------------------------------
git_push() {
local customer_dir="$1"
if [[ "$DRY_RUN" == true ]]; then
log_info "[DRY RUN] Would push to ${CUSTOMERS_REPO_GIT}"
return
fi
local repo_dir="${OUTPUT_BASE}/.git-repo"
# Clone or update the customers repo
if [[ -d "${repo_dir}/.git" ]]; then
log_info "Updating existing customers repo clone..."
cd "$repo_dir"
git pull --rebase --quiet 2>/dev/null || {
log_warn "Pull failed, continuing with local state"
}
cd - > /dev/null
else
log_info "Cloning customers repo..."
mkdir -p "$repo_dir"
git clone --quiet "$CUSTOMERS_REPO_GIT" "$repo_dir" 2>/dev/null || {
# Repo might not exist yet — init a fresh one
log_info "Repo doesn't exist yet, initializing..."
cd "$repo_dir"
git init --quiet
git remote add origin "$CUSTOMERS_REPO_GIT"
# Create initial commit so we have a branch
echo "# Felhom Customer Deployments" > README.md
echo "" >> README.md
echo "Auto-generated Portainer templates and compose files per customer." >> README.md
echo "**Do not edit manually** — regenerate with generate-customer.sh from app-catalog-felhom.eu." >> README.md
git add .
git commit --quiet -m "Initial commit"
cd - > /dev/null
}
fi
# Copy customer directory to repo
log_info "Copying ${CUSTOMER_ID}/ to repo..."
rm -rf "${repo_dir}/${CUSTOMER_ID}"
cp -r "$customer_dir" "${repo_dir}/${CUSTOMER_ID}"
# Commit and push
cd "$repo_dir"
git add "${CUSTOMER_ID}/"
if git diff --cached --quiet 2>/dev/null; then
log_info "No changes to push (already up to date)"
else
git commit --quiet -m "Update ${CUSTOMER_ID}: $(date -u '+%Y-%m-%d %H:%M UTC')"
git push --quiet origin HEAD 2>/dev/null && \
log_success "Pushed to ${CUSTOMERS_REPO_GIT}" || \
log_error "Push failed — check git credentials and repo access"
fi
cd - > /dev/null
}
#-------------------------------------------------------------------------------
# Print summary
#-------------------------------------------------------------------------------
print_summary() {
local customer_dir="$1"
local templates_url="${CUSTOMERS_REPO_RAW}/raw/branch/main/${CUSTOMER_ID}/templates.json"
echo ""
echo -e "${BOLD}${GREEN}═══════════════════════════════════════════════════════════════${NC}"
echo -e "${BOLD}${GREEN} Customer generated: ${CUSTOMER_ID}${NC}"
echo -e "${BOLD}${GREEN}═══════════════════════════════════════════════════════════════${NC}"
echo ""
echo -e "${BOLD}Customer:${NC} ${CUSTOMER_ID}"
echo -e "${BOLD}Domain:${NC} ${DOMAIN}"
if [[ -n "$HDD_PATH" ]]; then
echo -e "${BOLD}HDD Path:${NC} ${HDD_PATH}"
else
echo -e "${BOLD}HDD Path:${NC} ${YELLOW}not set (apps will prompt in Portainer)${NC}"
fi
echo -e "${BOLD}Apps:${NC} ${APPS[*]}"
echo ""
echo -e "${BOLD}Output:${NC}"
echo " ${customer_dir}/"
for app in "${APPS[@]}"; do
echo " ├── ${app}/docker-compose.yml"
done
echo " ├── templates.json"
echo " └── secrets.env"
echo ""
echo -e "${BOLD}Portainer templates URL:${NC}"
echo " ${templates_url}"
echo ""
echo -e "${BOLD}docker-setup.sh usage:${NC}"
echo " sudo ./docker-setup.sh --domain ${DOMAIN} --customer ${CUSTOMER_ID} \\"
echo " --email certs@felhom.eu --cf-token <token>"
echo ""
if [[ -z "$HDD_PATH" ]]; then
echo -e "${YELLOW}Note: HDD_PATH not set. Apps requiring it (filebrowser, immich,"
echo -e "paperless-ngx, romm) will show an HDD Path field in Portainer.${NC}"
echo ""
fi
echo -e "${BOLD}Secrets reference:${NC}"
echo " ${customer_dir}/secrets.env"
echo ""
}
#-------------------------------------------------------------------------------
# Main
#-------------------------------------------------------------------------------
main() {
parse_args "$@"
check_prerequisites
resolve_apps
local customer_dir="${OUTPUT_BASE}/${CUSTOMER_ID}"
# Print plan
echo ""
echo -e "${BOLD}Generation plan:${NC}"
echo " Customer: ${CUSTOMER_ID}"
echo " Domain: ${DOMAIN}"
echo " HDD Path: ${HDD_PATH:-<not set>}"
echo " Apps: ${APPS[*]}"
echo " Output: ${customer_dir}/"
echo " Push: ${PUSH}"
echo ""
if [[ "$DRY_RUN" == true ]]; then
echo -e "${YELLOW}DRY RUN — no files will be created${NC}"
echo ""
fi
# Create output directory
if [[ "$DRY_RUN" == false ]]; then
mkdir -p "$customer_dir"
fi
log_step "1/4 — Generating secrets..."
load_or_generate_secrets "$customer_dir"
log_step "2/4 — Generating compose files..."
generate_compose_files "$customer_dir"
log_step "3/4 — Generating templates.json..."
generate_templates_json "$customer_dir"
if [[ "$PUSH" == true ]]; then
log_step "4/4 — Pushing to customers repo..."
git_push "$customer_dir"
else
log_step "4/4 — Skipping push (use --push to upload)"
fi
print_summary "$customer_dir"
}
main "$@"