# Externalize Docker Compose Templates

**Date:** 2026-04-15
**Status:** Approved

## Problem

The installer scripts (`install.sh` and `install.ps1`) generate docker-compose YAML inline via heredocs and string interpolation. This causes three problems:

1. **Maintainability** -- ~450 lines of compose generation are duplicated across two scripts in two languages. Every compose change must be made twice.
2. **Readability** -- The compose structure is buried inside 1800-line scripts and difficult to review or edit.
3. **User customization** -- Users cannot tweak the compose after installation without re-running the installer or editing generated output that gets overwritten on next run.

## Design

Replace inline compose generation with static template files. The installer collects user input, writes `.env`, copies templates, and runs `docker compose up -d`.

### File Layout

```
installer/
  templates/
    docker-compose.yml              # Infra: traefik, postgres, clickhouse
    docker-compose.server.yml       # Standalone: server + server-ui
    docker-compose.saas.yml         # SaaS: logto + cameleer-saas
    docker-compose.tls.yml          # Overlay: custom cert volume on traefik
    docker-compose.monitoring.yml   # Overlay: override monitoring network to external
    .env.example                    # Documented variable reference
  install.sh
  install.ps1
```

### Compose File Responsibilities

#### `docker-compose.yml` (infra base, always loaded)

- **Services:** `cameleer-traefik`, `cameleer-postgres`, `cameleer-clickhouse`
- **Prometheus labels** on all services unconditionally (harmless when no scraper connects)
- **Logto console port** always mapped; bind address controlled by `${LOGTO_CONSOLE_BIND:-127.0.0.1}` (exposed = `0.0.0.0`, unexposed = localhost-only)
- **Networks:** `cameleer`, `cameleer-traefik`, `monitoring` (local noop bridge by default)
- **Volumes:** `cameleer-pgdata`, `cameleer-chdata`, `cameleer-certs`
- All services reference the `monitoring` network

#### `docker-compose.server.yml` (standalone mode)

- **Services:** `cameleer-server`, `cameleer-server-ui`
- **Additional volumes:** `jars`
- **Additional network:** `cameleer-apps`
- **Monitoring network:** defines local noop bridge, both services reference it

#### `docker-compose.saas.yml` (SaaS mode)

- **Services:** `cameleer-logto`, `cameleer-saas`
- **Additional volume:** `cameleer-bootstrapdata`
- **Monitoring network:** defines local noop bridge, both services reference it

#### `docker-compose.tls.yml` (overlay)

- Adds `./certs:/user-certs:ro` volume mount to `cameleer-traefik`

#### `docker-compose.monitoring.yml` (overlay)

- Overrides the `monitoring` network definition from local noop bridge to `external: true` with `name: ${MONITORING_NETWORK}`
- No per-service entries needed -- services already reference the `monitoring` network in their base files

### Monitoring Network Pattern

Each compose file defines the `monitoring` network as a local bridge with a throwaway name:

```yaml
# In docker-compose.yml, docker-compose.server.yml, docker-compose.saas.yml
networks:
  monitoring:
    name: cameleer-monitoring-noop
```

Services reference this network unconditionally. Without the monitoring overlay, it is an inert local bridge. When `docker-compose.monitoring.yml` is included, Docker Compose merges the network definition, overriding it to external:

```yaml
# docker-compose.monitoring.yml
networks:
  monitoring:
    external: true
    name: ${MONITORING_NETWORK}
```

This avoids conditional YAML injection and keeps a single monitoring overlay file regardless of deployment mode.

### Logto Console Port Handling

Instead of conditionally injecting the port mapping, always include it with a bind address variable:

```yaml
# In docker-compose.yml (traefik ports)
- "${LOGTO_CONSOLE_BIND:-127.0.0.1}:${LOGTO_CONSOLE_PORT:-3002}:3002"
```

The installer writes `LOGTO_CONSOLE_BIND=0.0.0.0` when the user chooses to expose the console, and omits it (defaulting to `127.0.0.1`) when not.

### `.env.example`

Documented reference of all variables, grouped by section:

- **Version/images:** `VERSION`, `CAMELEER_SAAS_PROVISIONING_SERVERIMAGE`, `CAMELEER_SAAS_PROVISIONING_SERVERUIIMAGE`
- **Public access:** `PUBLIC_HOST`, `PUBLIC_PROTOCOL`
- **Ports:** `HTTP_PORT`, `HTTPS_PORT`, `LOGTO_CONSOLE_BIND`, `LOGTO_CONSOLE_PORT`
- **PostgreSQL:** `POSTGRES_USER`, `POSTGRES_PASSWORD`, `POSTGRES_DB`
- **ClickHouse:** `CLICKHOUSE_PASSWORD`
- **Admin credentials (SaaS):** `SAAS_ADMIN_USER`, `SAAS_ADMIN_PASS`
- **Admin credentials (standalone):** `SERVER_ADMIN_USER`, `SERVER_ADMIN_PASS`, `BOOTSTRAP_TOKEN`
- **Docker:** `DOCKER_SOCKET`, `DOCKER_GID`
- **TLS (optional):** `CERT_FILE`, `KEY_FILE`, `CA_FILE`
- **Monitoring (optional):** `MONITORING_NETWORK`
- **Compose file assembly:** `COMPOSE_FILE`
- **TLS validation:** `NODE_TLS_REJECT`

### `COMPOSE_FILE` Assembly

The installer builds the `COMPOSE_FILE` value in `.env` based on user choices:

| Mode | COMPOSE_FILE |
|---|---|
| Standalone | `docker-compose.yml:docker-compose.server.yml` |
| Standalone + TLS | `docker-compose.yml:docker-compose.server.yml:docker-compose.tls.yml` |
| Standalone + monitoring | `docker-compose.yml:docker-compose.server.yml:docker-compose.monitoring.yml` |
| SaaS | `docker-compose.yml:docker-compose.saas.yml` |
| SaaS + TLS | `docker-compose.yml:docker-compose.saas.yml:docker-compose.tls.yml` |
| SaaS + TLS + monitoring | `docker-compose.yml:docker-compose.saas.yml:docker-compose.tls.yml:docker-compose.monitoring.yml` |

### Installer Script Changes

**Removed:**

- `generate_compose_file()` / `Generate-ComposeFile` (~250 lines each, SaaS mode)
- `generate_compose_file_standalone()` / `Generate-ComposeFileStandalone` (~200 lines each, standalone mode)
- All conditional YAML injection logic (logto console, TLS, monitoring, GID)

**Added:**

- Copy template files from `templates/` to install directory
- `COMPOSE_FILE` assembly logic in `.env` generation
- `LOGTO_CONSOLE_BIND` variable (replaces conditional port injection)
- `DOCKER_GID` written to `.env` (replaces inline `stat` in heredoc)

**Unchanged:**

- User prompting, argument parsing, config file reading
- Password generation, Docker GID detection
- `credentials.txt`, `INSTALL.md`, `cameleer.conf` generation
- `docker compose up -d` invocation

### Password Fallback Safety

All password variables in templates use `:?` (fail-if-unset) instead of `:-` (default) to prevent silent fallback to weak credentials:

```yaml
SAAS_ADMIN_PASS: ${SAAS_ADMIN_PASS:?SAAS_ADMIN_PASS must be set in .env}
```

Username defaults (`:-admin`) are acceptable and retained.

### Migration for Existing Installs

Existing `installer/cameleer/` has a single generated `docker-compose.yml`. On re-install, the installer copies the template files and regenerates `.env`. The old monolithic compose file is replaced by the multi-file set. `cameleer.conf` preserves user choices, so re-running the installer reproduces the same configuration with the new file structure.
