From 933b56f68f2376c02f2a8afc03c158aaeeb929a8 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Wed, 15 Apr 2026 20:54:31 +0200 Subject: [PATCH] docs: add implementation plan for externalizing compose templates Co-Authored-By: Claude Opus 4.6 (1M context) --- ...4-15-externalize-compose-templates-plan.md | 961 ++++++++++++++++++ 1 file changed, 961 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-15-externalize-compose-templates-plan.md diff --git a/docs/superpowers/plans/2026-04-15-externalize-compose-templates-plan.md b/docs/superpowers/plans/2026-04-15-externalize-compose-templates-plan.md new file mode 100644 index 0000000..b980d59 --- /dev/null +++ b/docs/superpowers/plans/2026-04-15-externalize-compose-templates-plan.md @@ -0,0 +1,961 @@ +# Externalize Docker Compose Templates — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace inline docker-compose generation in installer scripts with static template files, reducing duplication and enabling user customization. + +**Architecture:** Static YAML templates in `installer/templates/` are copied to the install directory. The installer writes `.env` (including `COMPOSE_FILE` to select which templates are active) and runs `docker compose up -d`. Conditional features (TLS, monitoring) are handled via compose file layering and `.env` variables instead of heredoc injection. + +**Tech Stack:** Docker Compose v2, YAML, Bash, PowerShell + +**Spec:** `docs/superpowers/specs/2026-04-15-externalize-compose-templates-design.md` + +--- + +### Task 1: Create `docker-compose.yml` (infra base template) + +**Files:** +- Create: `installer/templates/docker-compose.yml` + +This is the shared infrastructure base — always loaded regardless of deployment mode. + +- [ ] **Step 1: Create the infra base template** + +```yaml +# Cameleer Infrastructure +# Shared base — always loaded. Mode-specific services in separate compose files. + +services: + cameleer-traefik: + image: ${TRAEFIK_IMAGE:-gitea.siegeln.net/cameleer/cameleer-traefik}:${VERSION:-latest} + restart: unless-stopped + ports: + - "${HTTP_PORT:-80}:80" + - "${HTTPS_PORT:-443}:443" + - "${LOGTO_CONSOLE_BIND:-127.0.0.1}:${LOGTO_CONSOLE_PORT:-3002}:3002" + environment: + PUBLIC_HOST: ${PUBLIC_HOST:-localhost} + CERT_FILE: ${CERT_FILE:-} + KEY_FILE: ${KEY_FILE:-} + CA_FILE: ${CA_FILE:-} + volumes: + - cameleer-certs:/certs + - ${DOCKER_SOCKET:-/var/run/docker.sock}:/var/run/docker.sock:ro + labels: + - "prometheus.io/scrape=true" + - "prometheus.io/port=8082" + - "prometheus.io/path=/metrics" + networks: + - cameleer + - cameleer-traefik + - monitoring + + cameleer-postgres: + image: ${POSTGRES_IMAGE:-gitea.siegeln.net/cameleer/cameleer-postgres}:${VERSION:-latest} + restart: unless-stopped + environment: + POSTGRES_DB: ${POSTGRES_DB:-cameleer_saas} + POSTGRES_USER: ${POSTGRES_USER:-cameleer} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD must be set in .env} + volumes: + - cameleer-pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER:-cameleer} -d $${POSTGRES_DB:-cameleer_saas}"] + interval: 5s + timeout: 5s + retries: 5 + networks: + - cameleer + - monitoring + + cameleer-clickhouse: + image: ${CLICKHOUSE_IMAGE:-gitea.siegeln.net/cameleer/cameleer-clickhouse}:${VERSION:-latest} + restart: unless-stopped + environment: + CLICKHOUSE_PASSWORD: ${CLICKHOUSE_PASSWORD:?CLICKHOUSE_PASSWORD must be set in .env} + volumes: + - cameleer-chdata:/var/lib/clickhouse + healthcheck: + test: ["CMD-SHELL", "clickhouse-client --password $${CLICKHOUSE_PASSWORD} --query 'SELECT 1'"] + interval: 10s + timeout: 5s + retries: 3 + labels: + - "prometheus.io/scrape=true" + - "prometheus.io/port=9363" + - "prometheus.io/path=/metrics" + networks: + - cameleer + - monitoring + +volumes: + cameleer-pgdata: + cameleer-chdata: + cameleer-certs: + +networks: + cameleer: + driver: bridge + cameleer-traefik: + name: cameleer-traefik + driver: bridge + monitoring: + name: cameleer-monitoring-noop +``` + +Key changes from the generated version: +- Logto console port always present with `LOGTO_CONSOLE_BIND` controlling exposure +- Prometheus labels unconditional on traefik and clickhouse +- `monitoring` network defined as local noop bridge +- All services join `monitoring` network +- `POSTGRES_DB` uses `${POSTGRES_DB:-cameleer_saas}` (parameterized — standalone overrides via `.env`) +- Password variables use `:?` fail-if-unset + +Note: The SaaS mode uses `cameleer-postgres` (custom multi-DB image) while standalone uses `postgres:16-alpine`. The `POSTGRES_IMAGE` variable already handles this — the infra base uses `${POSTGRES_IMAGE:-...}` and standalone `.env` sets `POSTGRES_IMAGE=postgres:16-alpine`. + +- [ ] **Step 2: Verify YAML is valid** + +Run: `python -c "import yaml; yaml.safe_load(open('installer/templates/docker-compose.yml'))"` +Expected: No output (valid YAML). If python/yaml not available, use `docker compose -f installer/templates/docker-compose.yml config --quiet` (will fail on unset vars, but validates structure). + +- [ ] **Step 3: Commit** + +```bash +git add installer/templates/docker-compose.yml +git commit -m "feat(installer): add infra base docker-compose template" +``` + +--- + +### Task 2: Create `docker-compose.saas.yml` (SaaS mode template) + +**Files:** +- Create: `installer/templates/docker-compose.saas.yml` + +SaaS-specific services: Logto identity provider and cameleer-saas management plane. + +- [ ] **Step 1: Create the SaaS template** + +```yaml +# Cameleer SaaS — Logto + management plane +# Loaded in SaaS deployment mode + +services: + cameleer-logto: + image: ${LOGTO_IMAGE:-gitea.siegeln.net/cameleer/cameleer-logto}:${VERSION:-latest} + restart: unless-stopped + depends_on: + cameleer-postgres: + condition: service_healthy + environment: + DB_URL: postgres://${POSTGRES_USER:-cameleer}:${POSTGRES_PASSWORD}@cameleer-postgres:5432/logto + ENDPOINT: ${PUBLIC_PROTOCOL:-https}://${PUBLIC_HOST:-localhost} + ADMIN_ENDPOINT: ${PUBLIC_PROTOCOL:-https}://${PUBLIC_HOST:-localhost}:${LOGTO_CONSOLE_PORT:-3002} + TRUST_PROXY_HEADER: 1 + NODE_TLS_REJECT_UNAUTHORIZED: "${NODE_TLS_REJECT:-0}" + LOGTO_ENDPOINT: http://cameleer-logto:3001 + LOGTO_ADMIN_ENDPOINT: http://cameleer-logto:3002 + LOGTO_PUBLIC_ENDPOINT: ${PUBLIC_PROTOCOL:-https}://${PUBLIC_HOST:-localhost} + PUBLIC_HOST: ${PUBLIC_HOST:-localhost} + PUBLIC_PROTOCOL: ${PUBLIC_PROTOCOL:-https} + PG_HOST: cameleer-postgres + PG_USER: ${POSTGRES_USER:-cameleer} + PG_PASSWORD: ${POSTGRES_PASSWORD} + PG_DB_SAAS: cameleer_saas + SAAS_ADMIN_USER: ${SAAS_ADMIN_USER:-admin} + SAAS_ADMIN_PASS: ${SAAS_ADMIN_PASS:?SAAS_ADMIN_PASS must be set in .env} + healthcheck: + test: ["CMD-SHELL", "node -e \"require('http').get('http://localhost:3001/oidc/.well-known/openid-configuration', r => process.exit(r.statusCode === 200 ? 0 : 1)).on('error', () => process.exit(1))\" && test -f /data/logto-bootstrap.json"] + interval: 10s + timeout: 5s + retries: 60 + start_period: 30s + labels: + - traefik.enable=true + - traefik.http.routers.cameleer-logto.rule=PathPrefix(`/`) + - traefik.http.routers.cameleer-logto.priority=1 + - traefik.http.routers.cameleer-logto.entrypoints=websecure + - traefik.http.routers.cameleer-logto.tls=true + - traefik.http.routers.cameleer-logto.service=cameleer-logto + - traefik.http.routers.cameleer-logto.middlewares=cameleer-logto-cors + - "traefik.http.middlewares.cameleer-logto-cors.headers.accessControlAllowOriginList=${PUBLIC_PROTOCOL:-https}://${PUBLIC_HOST:-localhost}:${LOGTO_CONSOLE_PORT:-3002}" + - traefik.http.middlewares.cameleer-logto-cors.headers.accessControlAllowMethods=GET,POST,PUT,PATCH,DELETE,OPTIONS + - traefik.http.middlewares.cameleer-logto-cors.headers.accessControlAllowHeaders=Authorization,Content-Type + - traefik.http.middlewares.cameleer-logto-cors.headers.accessControlAllowCredentials=true + - traefik.http.services.cameleer-logto.loadbalancer.server.port=3001 + - traefik.http.routers.cameleer-logto-console.rule=PathPrefix(`/`) + - traefik.http.routers.cameleer-logto-console.entrypoints=admin-console + - traefik.http.routers.cameleer-logto-console.tls=true + - traefik.http.routers.cameleer-logto-console.service=cameleer-logto-console + - traefik.http.services.cameleer-logto-console.loadbalancer.server.port=3002 + volumes: + - cameleer-bootstrapdata:/data + networks: + - cameleer + - monitoring + + cameleer-saas: + image: ${CAMELEER_IMAGE:-gitea.siegeln.net/cameleer/cameleer-saas}:${VERSION:-latest} + restart: unless-stopped + depends_on: + cameleer-logto: + condition: service_healthy + environment: + # SaaS database + SPRING_DATASOURCE_URL: jdbc:postgresql://cameleer-postgres:5432/cameleer_saas + SPRING_DATASOURCE_USERNAME: ${POSTGRES_USER:-cameleer} + SPRING_DATASOURCE_PASSWORD: ${POSTGRES_PASSWORD} + # Identity (Logto) + CAMELEER_SAAS_IDENTITY_LOGTOENDPOINT: http://cameleer-logto:3001 + CAMELEER_SAAS_IDENTITY_LOGTOPUBLICENDPOINT: ${PUBLIC_PROTOCOL:-https}://${PUBLIC_HOST:-localhost} + # Provisioning — passed to per-tenant server containers + CAMELEER_SAAS_PROVISIONING_PUBLICHOST: ${PUBLIC_HOST:-localhost} + CAMELEER_SAAS_PROVISIONING_PUBLICPROTOCOL: ${PUBLIC_PROTOCOL:-https} + CAMELEER_SAAS_PROVISIONING_NETWORKNAME: ${COMPOSE_PROJECT_NAME:-cameleer-saas}_cameleer + CAMELEER_SAAS_PROVISIONING_TRAEFIKNETWORK: cameleer-traefik + CAMELEER_SAAS_PROVISIONING_DATASOURCEUSERNAME: ${POSTGRES_USER:-cameleer} + CAMELEER_SAAS_PROVISIONING_DATASOURCEPASSWORD: ${POSTGRES_PASSWORD} + CAMELEER_SAAS_PROVISIONING_CLICKHOUSEPASSWORD: ${CLICKHOUSE_PASSWORD} + CAMELEER_SAAS_PROVISIONING_SERVERIMAGE: ${CAMELEER_SAAS_PROVISIONING_SERVERIMAGE:-gitea.siegeln.net/cameleer/cameleer-server:latest} + CAMELEER_SAAS_PROVISIONING_SERVERUIIMAGE: ${CAMELEER_SAAS_PROVISIONING_SERVERUIIMAGE:-gitea.siegeln.net/cameleer/cameleer-server-ui:latest} + labels: + - traefik.enable=true + - traefik.http.routers.saas.rule=PathPrefix(`/platform`) + - traefik.http.routers.saas.entrypoints=websecure + - traefik.http.routers.saas.tls=true + - traefik.http.services.saas.loadbalancer.server.port=8080 + - "prometheus.io/scrape=true" + - "prometheus.io/port=8080" + - "prometheus.io/path=/platform/actuator/prometheus" + volumes: + - cameleer-bootstrapdata:/data/bootstrap:ro + - cameleer-certs:/certs + - ${DOCKER_SOCKET:-/var/run/docker.sock}:/var/run/docker.sock + group_add: + - "${DOCKER_GID:-0}" + networks: + - cameleer + - monitoring + +volumes: + cameleer-bootstrapdata: + +networks: + monitoring: + name: cameleer-monitoring-noop +``` + +Key changes: +- Logto console traefik labels always included (harmless when port is localhost-only) +- Prometheus labels on cameleer-saas always included +- `DOCKER_GID` read from `.env` via `${DOCKER_GID:-0}` instead of inline `stat` +- Both services join `monitoring` network +- `monitoring` network redefined as noop bridge (compose merges with base definition) + +- [ ] **Step 2: Commit** + +```bash +git add installer/templates/docker-compose.saas.yml +git commit -m "feat(installer): add SaaS docker-compose template" +``` + +--- + +### Task 3: Create `docker-compose.server.yml` (standalone mode template) + +**Files:** +- Create: `installer/templates/docker-compose.server.yml` +- Create: `installer/templates/traefik-dynamic.yml` + +Standalone-specific services: cameleer-server + server-ui. Also includes the traefik dynamic config that standalone mode needs (overrides the baked-in SaaS redirect). + +- [ ] **Step 1: Create the standalone template** + +```yaml +# Cameleer Server (standalone) +# Loaded in standalone deployment mode + +services: + cameleer-traefik: + volumes: + - ./traefik-dynamic.yml:/etc/traefik/dynamic.yml:ro + + cameleer-postgres: + image: postgres:16-alpine + environment: + POSTGRES_DB: ${POSTGRES_DB:-cameleer} + healthcheck: + test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER:-cameleer} -d $${POSTGRES_DB:-cameleer}"] + + cameleer-server: + image: ${SERVER_IMAGE:-gitea.siegeln.net/cameleer/cameleer-server}:${VERSION:-latest} + container_name: cameleer-server + restart: unless-stopped + depends_on: + cameleer-postgres: + condition: service_healthy + environment: + CAMELEER_SERVER_TENANT_ID: default + SPRING_DATASOURCE_URL: jdbc:postgresql://cameleer-postgres:5432/${POSTGRES_DB:-cameleer}?currentSchema=tenant_default + SPRING_DATASOURCE_USERNAME: ${POSTGRES_USER:-cameleer} + SPRING_DATASOURCE_PASSWORD: ${POSTGRES_PASSWORD} + CAMELEER_SERVER_CLICKHOUSE_URL: jdbc:clickhouse://cameleer-clickhouse:8123/cameleer + CAMELEER_SERVER_CLICKHOUSE_USERNAME: default + CAMELEER_SERVER_CLICKHOUSE_PASSWORD: ${CLICKHOUSE_PASSWORD} + CAMELEER_SERVER_SECURITY_BOOTSTRAPTOKEN: ${BOOTSTRAP_TOKEN:?BOOTSTRAP_TOKEN must be set in .env} + CAMELEER_SERVER_SECURITY_UIUSER: ${SERVER_ADMIN_USER:-admin} + CAMELEER_SERVER_SECURITY_UIPASSWORD: ${SERVER_ADMIN_PASS:?SERVER_ADMIN_PASS must be set in .env} + CAMELEER_SERVER_SECURITY_CORSALLOWEDORIGINS: ${PUBLIC_PROTOCOL:-https}://${PUBLIC_HOST:-localhost} + CAMELEER_SERVER_RUNTIME_ENABLED: "true" + CAMELEER_SERVER_RUNTIME_SERVERURL: http://cameleer-server:8081 + CAMELEER_SERVER_RUNTIME_ROUTINGDOMAIN: ${PUBLIC_HOST:-localhost} + CAMELEER_SERVER_RUNTIME_ROUTINGMODE: path + CAMELEER_SERVER_RUNTIME_JARSTORAGEPATH: /data/jars + CAMELEER_SERVER_RUNTIME_DOCKERNETWORK: cameleer-apps + CAMELEER_SERVER_RUNTIME_JARDOCKERVOLUME: cameleer-jars + CAMELEER_SERVER_RUNTIME_BASEIMAGE: gitea.siegeln.net/cameleer/cameleer-runtime-base:${VERSION:-latest} + labels: + - traefik.enable=true + - traefik.http.routers.server-api.rule=PathPrefix(`/api`) + - traefik.http.routers.server-api.entrypoints=websecure + - traefik.http.routers.server-api.tls=true + - traefik.http.services.server-api.loadbalancer.server.port=8081 + - traefik.docker.network=cameleer-traefik + healthcheck: + test: ["CMD-SHELL", "curl -sf http://localhost:8081/api/v1/health || exit 1"] + interval: 10s + timeout: 5s + retries: 30 + start_period: 30s + volumes: + - jars:/data/jars + - cameleer-certs:/certs:ro + - ${DOCKER_SOCKET:-/var/run/docker.sock}:/var/run/docker.sock + group_add: + - "${DOCKER_GID:-0}" + networks: + - cameleer + - cameleer-traefik + - cameleer-apps + - monitoring + + cameleer-server-ui: + image: ${SERVER_UI_IMAGE:-gitea.siegeln.net/cameleer/cameleer-server-ui}:${VERSION:-latest} + restart: unless-stopped + depends_on: + cameleer-server: + condition: service_healthy + environment: + CAMELEER_API_URL: http://cameleer-server:8081 + BASE_PATH: "" + labels: + - traefik.enable=true + - traefik.http.routers.ui.rule=PathPrefix(`/`) + - traefik.http.routers.ui.priority=1 + - traefik.http.routers.ui.entrypoints=websecure + - traefik.http.routers.ui.tls=true + - traefik.http.services.ui.loadbalancer.server.port=80 + - traefik.docker.network=cameleer-traefik + networks: + - cameleer-traefik + - monitoring + +volumes: + jars: + +networks: + cameleer-apps: + name: cameleer-apps + driver: bridge + monitoring: + name: cameleer-monitoring-noop +``` + +Key design decisions: +- `cameleer-traefik` and `cameleer-postgres` entries are **overrides** — compose merges them with the base. The postgres image switches to `postgres:16-alpine` and the healthcheck uses `${POSTGRES_DB:-cameleer}` instead of hardcoded `cameleer_saas`. Traefik gets the `traefik-dynamic.yml` volume mount. +- `DOCKER_GID` from `.env` via `${DOCKER_GID:-0}` +- `BOOTSTRAP_TOKEN` uses `:?` fail-if-unset +- Both server and server-ui join `monitoring` network + +- [ ] **Step 2: Create the traefik dynamic config template** + +```yaml +tls: + stores: + default: + defaultCertificate: + certFile: /certs/cert.pem + keyFile: /certs/key.pem +``` + +This file is only relevant in standalone mode (overrides the baked-in SaaS `/` -> `/platform/` redirect in the traefik image). + +- [ ] **Step 3: Commit** + +```bash +git add installer/templates/docker-compose.server.yml installer/templates/traefik-dynamic.yml +git commit -m "feat(installer): add standalone docker-compose and traefik templates" +``` + +--- + +### Task 4: Create overlay templates (TLS + monitoring) + +**Files:** +- Create: `installer/templates/docker-compose.tls.yml` +- Create: `installer/templates/docker-compose.monitoring.yml` + +- [ ] **Step 1: Create the TLS overlay** + +```yaml +# Custom TLS certificates overlay +# Adds user-supplied certificate volume to traefik + +services: + cameleer-traefik: + volumes: + - ./certs:/user-certs:ro +``` + +- [ ] **Step 2: Create the monitoring overlay** + +```yaml +# External monitoring network overlay +# Overrides the noop monitoring bridge with a real external network + +networks: + monitoring: + external: true + name: ${MONITORING_NETWORK:?MONITORING_NETWORK must be set in .env} +``` + +This is the key to the monitoring pattern: the base compose files define `monitoring` as a local noop bridge and all services join it. When this overlay is included in `COMPOSE_FILE`, compose merges the network definition — overriding it to point at the real external monitoring network. No per-service entries needed. + +- [ ] **Step 3: Commit** + +```bash +git add installer/templates/docker-compose.tls.yml installer/templates/docker-compose.monitoring.yml +git commit -m "feat(installer): add TLS and monitoring overlay templates" +``` + +--- + +### Task 5: Create `.env.example` + +**Files:** +- Create: `installer/templates/.env.example` + +- [ ] **Step 1: Create the documented variable reference** + +```bash +# Cameleer Configuration +# Copy this file to .env and fill in the values. +# The installer generates .env automatically — this file is for reference. + +# ============================================================ +# Compose file assembly (set by installer) +# ============================================================ +# SaaS: docker-compose.yml:docker-compose.saas.yml +# Standalone: docker-compose.yml:docker-compose.server.yml +# Add :docker-compose.tls.yml for custom TLS certificates +# Add :docker-compose.monitoring.yml for external monitoring network +COMPOSE_FILE=docker-compose.yml:docker-compose.saas.yml + +# ============================================================ +# Image version +# ============================================================ +VERSION=latest + +# ============================================================ +# Public access +# ============================================================ +PUBLIC_HOST=localhost +PUBLIC_PROTOCOL=https + +# ============================================================ +# Ports +# ============================================================ +HTTP_PORT=80 +HTTPS_PORT=443 +# Set to 0.0.0.0 to expose Logto admin console externally (default: localhost only) +# LOGTO_CONSOLE_BIND=0.0.0.0 +LOGTO_CONSOLE_PORT=3002 + +# ============================================================ +# PostgreSQL +# ============================================================ +POSTGRES_USER=cameleer +POSTGRES_PASSWORD=CHANGE_ME +# SaaS: cameleer_saas, Standalone: cameleer +POSTGRES_DB=cameleer_saas + +# ============================================================ +# ClickHouse +# ============================================================ +CLICKHOUSE_PASSWORD=CHANGE_ME + +# ============================================================ +# Admin credentials (SaaS mode) +# ============================================================ +SAAS_ADMIN_USER=admin +SAAS_ADMIN_PASS=CHANGE_ME + +# ============================================================ +# Admin credentials (standalone mode) +# ============================================================ +# SERVER_ADMIN_USER=admin +# SERVER_ADMIN_PASS=CHANGE_ME +# BOOTSTRAP_TOKEN=CHANGE_ME + +# ============================================================ +# TLS +# ============================================================ +# Set to 1 to reject unauthorized TLS certificates (production) +NODE_TLS_REJECT=0 +# Custom TLS certificate paths (inside container, set by installer) +# CERT_FILE=/user-certs/cert.pem +# KEY_FILE=/user-certs/key.pem +# CA_FILE=/user-certs/ca.pem + +# ============================================================ +# Docker +# ============================================================ +DOCKER_SOCKET=/var/run/docker.sock +# GID of the docker socket — detected by installer, used for container group_add +DOCKER_GID=0 + +# ============================================================ +# Provisioning images (SaaS mode only) +# ============================================================ +# CAMELEER_SAAS_PROVISIONING_SERVERIMAGE=gitea.siegeln.net/cameleer/cameleer-server:latest +# CAMELEER_SAAS_PROVISIONING_SERVERUIIMAGE=gitea.siegeln.net/cameleer/cameleer-server-ui:latest + +# ============================================================ +# Monitoring (optional) +# ============================================================ +# External Docker network name for Prometheus scraping. +# Only needed when docker-compose.monitoring.yml is in COMPOSE_FILE. +# MONITORING_NETWORK=prometheus +``` + +- [ ] **Step 2: Commit** + +```bash +git add installer/templates/.env.example +git commit -m "feat(installer): add .env.example with documented variables" +``` + +--- + +### Task 6: Update `install.sh` — replace compose generation with template copying + +**Files:** +- Modify: `installer/install.sh:574-672` (generate_env_file — add COMPOSE_FILE and LOGTO_CONSOLE_BIND) +- Modify: `installer/install.sh:674-1135` (replace generate_compose_file + generate_compose_file_standalone with copy_templates) +- Modify: `installer/install.sh:1728-1731` (reinstall cleanup — delete template files) +- Modify: `installer/install.sh:1696-1710` (upgrade path — copy templates instead of generate) +- Modify: `installer/install.sh:1790-1791` (main — call copy_templates instead of generate_compose_file) + +- [ ] **Step 1: Replace `generate_compose_file` and `generate_compose_file_standalone` with `copy_templates`** + +Delete both functions (`generate_compose_file` at line 674 and `generate_compose_file_standalone` at line 934) and replace with: + +```bash +copy_templates() { + local src + src="$(cd "$(dirname "$0")" && pwd)/templates" + + # Base infra — always copied + cp "$src/docker-compose.yml" "$INSTALL_DIR/docker-compose.yml" + cp "$src/.env.example" "$INSTALL_DIR/.env.example" + + # Mode-specific + if [ "$DEPLOYMENT_MODE" = "standalone" ]; then + cp "$src/docker-compose.server.yml" "$INSTALL_DIR/docker-compose.server.yml" + cp "$src/traefik-dynamic.yml" "$INSTALL_DIR/traefik-dynamic.yml" + else + cp "$src/docker-compose.saas.yml" "$INSTALL_DIR/docker-compose.saas.yml" + fi + + # Optional overlays + if [ "$TLS_MODE" = "custom" ]; then + cp "$src/docker-compose.tls.yml" "$INSTALL_DIR/docker-compose.tls.yml" + fi + if [ -n "$MONITORING_NETWORK" ]; then + cp "$src/docker-compose.monitoring.yml" "$INSTALL_DIR/docker-compose.monitoring.yml" + fi + + log_info "Copied docker-compose templates to $INSTALL_DIR" +} +``` + +- [ ] **Step 2: Update `generate_env_file` to include `COMPOSE_FILE`, `LOGTO_CONSOLE_BIND`, and `DOCKER_GID`** + +In the standalone `.env` block (line 577-614), add after the `DOCKER_GID` line: + +```bash +# Compose file assembly +COMPOSE_FILE=docker-compose.yml:docker-compose.server.yml$([ "$TLS_MODE" = "custom" ] && echo ":docker-compose.tls.yml")$([ -n "$MONITORING_NETWORK" ] && echo ":docker-compose.monitoring.yml") +EOF +``` + +In the SaaS `.env` block (line 617-668), add `LOGTO_CONSOLE_BIND` and `COMPOSE_FILE`. After the `LOGTO_CONSOLE_PORT` line: + +```bash +LOGTO_CONSOLE_BIND=$([ "$LOGTO_CONSOLE_EXPOSED" = "true" ] && echo "0.0.0.0" || echo "127.0.0.1") +``` + +And at the end of the SaaS block, add the `COMPOSE_FILE` line: + +```bash +# Compose file assembly +COMPOSE_FILE=docker-compose.yml:docker-compose.saas.yml$([ "$TLS_MODE" = "custom" ] && echo ":docker-compose.tls.yml")$([ -n "$MONITORING_NETWORK" ] && echo ":docker-compose.monitoring.yml") +``` + +Also add the `MONITORING_NETWORK` variable to `.env` when set: + +```bash +if [ -n "$MONITORING_NETWORK" ]; then + echo "" >> "$f" + echo "# Monitoring" >> "$f" + echo "MONITORING_NETWORK=${MONITORING_NETWORK}" >> "$f" +fi +``` + +- [ ] **Step 3: Update `main()` — replace `generate_compose_file` call with `copy_templates`** + +At line 1791, change: +```bash + generate_compose_file +``` +to: +```bash + copy_templates +``` + +- [ ] **Step 4: Update `handle_rerun` upgrade path** + +At line 1703, change: +```bash + generate_compose_file +``` +to: +```bash + copy_templates +``` + +- [ ] **Step 5: Update reinstall cleanup to remove template files** + +At lines 1728-1731, update the `rm -f` list to include all possible template files: +```bash + rm -f "$INSTALL_DIR/.env" "$INSTALL_DIR/.env.bak" "$INSTALL_DIR/.env.example" \ + "$INSTALL_DIR/docker-compose.yml" "$INSTALL_DIR/docker-compose.saas.yml" \ + "$INSTALL_DIR/docker-compose.server.yml" "$INSTALL_DIR/docker-compose.tls.yml" \ + "$INSTALL_DIR/docker-compose.monitoring.yml" "$INSTALL_DIR/traefik-dynamic.yml" \ + "$INSTALL_DIR/cameleer.conf" "$INSTALL_DIR/credentials.txt" \ + "$INSTALL_DIR/INSTALL.md" +``` + +- [ ] **Step 6: Commit** + +```bash +git add installer/install.sh +git commit -m "refactor(installer): replace sh compose generation with template copying" +``` + +--- + +### Task 7: Update `install.ps1` — replace compose generation with template copying + +**Files:** +- Modify: `installer/install.ps1:574-666` (Generate-EnvFile — add COMPOSE_FILE and LOGTO_CONSOLE_BIND) +- Modify: `installer/install.ps1:671-1105` (replace Generate-ComposeFile + Generate-ComposeFileStandalone with Copy-Templates) +- Modify: `installer/install.ps1:1706-1723` (upgrade path) +- Modify: `installer/install.ps1:1746` (reinstall cleanup) +- Modify: `installer/install.ps1:1797-1798` (Main — call Copy-Templates) + +- [ ] **Step 1: Replace `Generate-ComposeFile` and `Generate-ComposeFileStandalone` with `Copy-Templates`** + +Delete both functions and replace with: + +```powershell +function Copy-Templates { + $c = $script:cfg + $src = Join-Path $PSScriptRoot 'templates' + + # Base infra — always copied + Copy-Item (Join-Path $src 'docker-compose.yml') (Join-Path $c.InstallDir 'docker-compose.yml') -Force + Copy-Item (Join-Path $src '.env.example') (Join-Path $c.InstallDir '.env.example') -Force + + # Mode-specific + if ($c.DeploymentMode -eq 'standalone') { + Copy-Item (Join-Path $src 'docker-compose.server.yml') (Join-Path $c.InstallDir 'docker-compose.server.yml') -Force + Copy-Item (Join-Path $src 'traefik-dynamic.yml') (Join-Path $c.InstallDir 'traefik-dynamic.yml') -Force + } else { + Copy-Item (Join-Path $src 'docker-compose.saas.yml') (Join-Path $c.InstallDir 'docker-compose.saas.yml') -Force + } + + # Optional overlays + if ($c.TlsMode -eq 'custom') { + Copy-Item (Join-Path $src 'docker-compose.tls.yml') (Join-Path $c.InstallDir 'docker-compose.tls.yml') -Force + } + if ($c.MonitoringNetwork) { + Copy-Item (Join-Path $src 'docker-compose.monitoring.yml') (Join-Path $c.InstallDir 'docker-compose.monitoring.yml') -Force + } + + Log-Info "Copied docker-compose templates to $($c.InstallDir)" +} +``` + +- [ ] **Step 2: Update `Generate-EnvFile` to include `COMPOSE_FILE`, `LOGTO_CONSOLE_BIND`, and `MONITORING_NETWORK`** + +In the standalone `.env` content block, add after `DOCKER_GID`: + +```powershell +$composeFile = 'docker-compose.yml:docker-compose.server.yml' +if ($c.TlsMode -eq 'custom') { $composeFile += ':docker-compose.tls.yml' } +if ($c.MonitoringNetwork) { $composeFile += ':docker-compose.monitoring.yml' } +``` + +Then append to `$content`: +```powershell +$content += "`n`n# Compose file assembly`nCOMPOSE_FILE=$composeFile" +if ($c.MonitoringNetwork) { + $content += "`n`n# Monitoring`nMONITORING_NETWORK=$($c.MonitoringNetwork)" +} +``` + +In the SaaS `.env` content block, add `LOGTO_CONSOLE_BIND` after `LOGTO_CONSOLE_PORT`: + +```powershell +$consoleBind = if ($c.LogtoConsoleExposed -eq 'true') { '0.0.0.0' } else { '127.0.0.1' } +``` + +Add to the content string: `LOGTO_CONSOLE_BIND=$consoleBind` + +Build `COMPOSE_FILE`: +```powershell +$composeFile = 'docker-compose.yml:docker-compose.saas.yml' +if ($c.TlsMode -eq 'custom') { $composeFile += ':docker-compose.tls.yml' } +if ($c.MonitoringNetwork) { $composeFile += ':docker-compose.monitoring.yml' } +``` + +And append to `$content`: +```powershell +$content += "`n`n# Compose file assembly`nCOMPOSE_FILE=$composeFile" +if ($c.MonitoringNetwork) { + $content += "`n`n# Monitoring`nMONITORING_NETWORK=$($c.MonitoringNetwork)" +} +``` + +- [ ] **Step 3: Update `Main` — replace `Generate-ComposeFile` call with `Copy-Templates`** + +At line 1798, change: +```powershell + Generate-ComposeFile +``` +to: +```powershell + Copy-Templates +``` + +- [ ] **Step 4: Update `Handle-Rerun` upgrade path** + +At line 1716, change: +```powershell + Generate-ComposeFile +``` +to: +```powershell + Copy-Templates +``` + +- [ ] **Step 5: Update reinstall cleanup to remove template files** + +At line 1746, update the filename list: +```powershell +foreach ($fname in @('.env','.env.bak','.env.example','docker-compose.yml','docker-compose.saas.yml','docker-compose.server.yml','docker-compose.tls.yml','docker-compose.monitoring.yml','traefik-dynamic.yml','cameleer.conf','credentials.txt','INSTALL.md')) { +``` + +- [ ] **Step 6: Commit** + +```bash +git add installer/install.ps1 +git commit -m "refactor(installer): replace ps1 compose generation with template copying" +``` + +--- + +### Task 8: Update existing generated install and clean up + +**Files:** +- Modify: `installer/cameleer/docker-compose.yml` (replace with template copy for dev environment) + +- [ ] **Step 1: Remove the old generated docker-compose.yml from the cameleer/ directory** + +The `installer/cameleer/` directory contains a previously generated install. The `docker-compose.yml` there is now stale — it was generated by the old inline method. Since this is a dev environment output, remove it (it will be recreated by running the installer with the new template approach). + +```bash +git rm installer/cameleer/docker-compose.yml +``` + +- [ ] **Step 2: Add `installer/cameleer/` to `.gitignore` if not already there** + +The install output directory should not be tracked. Check if `.gitignore` already covers it. If not, add: + +``` +installer/cameleer/ +``` + +This prevents generated `.env`, `credentials.txt`, and compose files from being committed. + +- [ ] **Step 3: Commit** + +```bash +git add -A installer/cameleer/ .gitignore +git commit -m "chore(installer): remove generated install output, add to gitignore" +``` + +--- + +### Task 9: Verify the templates produce equivalent output + +**Files:** (no changes — verification only) + +- [ ] **Step 1: Compare template output against the old generated compose** + +Create a temporary `.env` file and run `docker compose config` to render the resolved compose. Compare against the old generated output: + +```bash +cd installer/cameleer +# Back up old generated file for comparison +cp docker-compose.yml docker-compose.old.yml 2>/dev/null || true + +# Create a test .env that exercises the SaaS path +cat > /tmp/test-saas.env << 'EOF' +COMPOSE_FILE=docker-compose.yml:docker-compose.saas.yml +VERSION=latest +PUBLIC_HOST=test.example.com +PUBLIC_PROTOCOL=https +HTTP_PORT=80 +HTTPS_PORT=443 +LOGTO_CONSOLE_PORT=3002 +LOGTO_CONSOLE_BIND=0.0.0.0 +POSTGRES_USER=cameleer +POSTGRES_PASSWORD=testpass +POSTGRES_DB=cameleer_saas +CLICKHOUSE_PASSWORD=testpass +SAAS_ADMIN_USER=admin +SAAS_ADMIN_PASS=testpass +NODE_TLS_REJECT=0 +DOCKER_SOCKET=/var/run/docker.sock +DOCKER_GID=0 +CAMELEER_SAAS_PROVISIONING_SERVERIMAGE=gitea.siegeln.net/cameleer/cameleer-server:latest +CAMELEER_SAAS_PROVISIONING_SERVERUIIMAGE=gitea.siegeln.net/cameleer/cameleer-server-ui:latest +EOF + +# Render the new templates +cd ../templates +docker compose --env-file /tmp/test-saas.env config +``` + +Expected: A fully resolved compose with all 5 services (traefik, postgres, clickhouse, logto, saas), correct environment variables, and the monitoring noop network. + +- [ ] **Step 2: Test standalone mode rendering** + +```bash +cat > /tmp/test-standalone.env << 'EOF' +COMPOSE_FILE=docker-compose.yml:docker-compose.server.yml +VERSION=latest +PUBLIC_HOST=test.example.com +PUBLIC_PROTOCOL=https +HTTP_PORT=80 +HTTPS_PORT=443 +POSTGRES_IMAGE=postgres:16-alpine +POSTGRES_USER=cameleer +POSTGRES_PASSWORD=testpass +POSTGRES_DB=cameleer +CLICKHOUSE_PASSWORD=testpass +SERVER_ADMIN_USER=admin +SERVER_ADMIN_PASS=testpass +BOOTSTRAP_TOKEN=testtoken +DOCKER_SOCKET=/var/run/docker.sock +DOCKER_GID=0 +EOF + +cd ../templates +docker compose --env-file /tmp/test-standalone.env config +``` + +Expected: 5 services (traefik, postgres with `postgres:16-alpine` image, clickhouse, server, server-ui). Postgres `POSTGRES_DB` should be `cameleer`. Server should have all env vars resolved. + +- [ ] **Step 3: Test with TLS + monitoring overlays** + +```bash +cat > /tmp/test-full.env << 'EOF' +COMPOSE_FILE=docker-compose.yml:docker-compose.saas.yml:docker-compose.tls.yml:docker-compose.monitoring.yml +VERSION=latest +PUBLIC_HOST=test.example.com +PUBLIC_PROTOCOL=https +HTTP_PORT=80 +HTTPS_PORT=443 +LOGTO_CONSOLE_PORT=3002 +LOGTO_CONSOLE_BIND=0.0.0.0 +POSTGRES_USER=cameleer +POSTGRES_PASSWORD=testpass +POSTGRES_DB=cameleer_saas +CLICKHOUSE_PASSWORD=testpass +SAAS_ADMIN_USER=admin +SAAS_ADMIN_PASS=testpass +NODE_TLS_REJECT=0 +DOCKER_SOCKET=/var/run/docker.sock +DOCKER_GID=0 +MONITORING_NETWORK=prometheus +CAMELEER_SAAS_PROVISIONING_SERVERIMAGE=gitea.siegeln.net/cameleer/cameleer-server:latest +CAMELEER_SAAS_PROVISIONING_SERVERUIIMAGE=gitea.siegeln.net/cameleer/cameleer-server-ui:latest +EOF + +cd ../templates +docker compose --env-file /tmp/test-full.env config +``` + +Expected: Same as SaaS mode but with `./certs:/user-certs:ro` volume on traefik and the `monitoring` network declared as `external: true` with name `prometheus`. + +- [ ] **Step 4: Clean up temp files** + +```bash +rm -f /tmp/test-saas.env /tmp/test-standalone.env /tmp/test-full.env +``` + +- [ ] **Step 5: Commit verification results as a note (optional)** + +No code changes — this task is verification only. If all checks pass, proceed to the final commit. + +--- + +### Task 10: Final commit — update CLAUDE.md deployment modes table + +**Files:** +- Modify: `CLAUDE.md` (update Deployment Modes section to reference template files) + +- [ ] **Step 1: Update the deployment modes documentation** + +In the "Deployment Modes (installer)" section of CLAUDE.md, add a note about the template-based approach: + +After the deployment modes table, add: + +```markdown +The installer uses static docker-compose templates in `installer/templates/`. Templates are copied to the install directory and composed via `COMPOSE_FILE` in `.env`: +- `docker-compose.yml` — shared infrastructure (traefik, postgres, clickhouse) +- `docker-compose.saas.yml` — SaaS mode (logto, cameleer-saas) +- `docker-compose.server.yml` — standalone mode (server, server-ui) +- `docker-compose.tls.yml` — overlay: custom TLS cert volume +- `docker-compose.monitoring.yml` — overlay: external monitoring network +``` + +- [ ] **Step 2: Commit** + +```bash +git add CLAUDE.md +git commit -m "docs: update CLAUDE.md with template-based installer architecture" +```