Files
cameleer-saas/docs/superpowers/plans/2026-04-15-externalize-compose-templates-plan.md
hsiegeln 933b56f68f docs: add implementation plan for externalizing compose templates
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 20:54:31 +02:00

34 KiB

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
# 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
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
# 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

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
# 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

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
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

# 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
# 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
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

# 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
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:

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:

# 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:

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:

# 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:

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:

  generate_compose_file

to:

  copy_templates
  • Step 4: Update handle_rerun upgrade path

At line 1703, change:

      generate_compose_file

to:

      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:

      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
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:

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:

$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:

$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:

$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:

$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:

$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:

    Generate-ComposeFile

to:

    Copy-Templates
  • Step 4: Update Handle-Rerun upgrade path

At line 1716, change:

            Generate-ComposeFile

to:

            Copy-Templates
  • Step 5: Update reinstall cleanup to remove template files

At line 1746, update the filename list:

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
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).

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
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:

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
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
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
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:

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
git add CLAUDE.md
git commit -m "docs: update CLAUDE.md with template-based installer architecture"