name: mqtt-platform # MQTT Management Platform — Release 1 # See specification.md §4, §5, §29, §60 for architecture decisions. x-backend-env: &backend-env APP_ENV: ${APP_ENV:-development} DATABASE_URL: postgresql+psycopg://${DATABASE_USER:-mqttadmin}:${DATABASE_PASSWORD:-changeme}@${DATABASE_HOST:-mqtt-db}:${DATABASE_PORT:-5432}/${DATABASE_NAME:-mqttadmin} SESSION_SECRET: ${SESSION_SECRET:?SESSION_SECRET must be set} SECRET_ENCRYPTION_KEY: ${SECRET_ENCRYPTION_KEY:?SECRET_ENCRYPTION_KEY must be set} ADMIN_INITIAL_USERNAME: ${ADMIN_INITIAL_USERNAME:-admin} ADMIN_INITIAL_PASSWORD: ${ADMIN_INITIAL_PASSWORD:?ADMIN_INITIAL_PASSWORD must be set} MOSQUITTO_HOST: ${MOSQUITTO_HOST:-mosquitto} MOSQUITTO_MGMT_PORT: ${MOSQUITTO_MGMT_PORT:-1883} MOSQUITTO_DYNSEC_USERNAME: ${MOSQUITTO_DYNSEC_USERNAME:-admin} MOSQUITTO_DYNSEC_PASSWORD: ${MOSQUITTO_DYNSEC_PASSWORD:?MOSQUITTO_DYNSEC_PASSWORD must be set} CONTROL_API_TOKEN: ${CONTROL_API_TOKEN:?CONTROL_API_TOKEN must be set} DEFAULT_TLS_MIN_VERSION: ${DEFAULT_TLS_MIN_VERSION:-tlsv1.2} DEFAULT_CERT_VALIDITY_DAYS: ${DEFAULT_CERT_VALIDITY_DAYS:-365} DEFAULT_CA_VALIDITY_DAYS: ${DEFAULT_CA_VALIDITY_DAYS:-3650} services: mqtt-db: image: postgres:17-alpine restart: unless-stopped environment: POSTGRES_USER: ${DATABASE_USER:-mqttadmin} POSTGRES_PASSWORD: ${DATABASE_PASSWORD:-changeme} POSTGRES_DB: ${DATABASE_NAME:-mqttadmin} volumes: - mqtt_db_data:/var/lib/postgresql/data # Port deliberately NOT published to the host (spec §5.2). networks: - backend-net healthcheck: test: ["CMD-SHELL", "pg_isready -U ${DATABASE_USER:-mqttadmin} -d ${DATABASE_NAME:-mqttadmin}"] interval: 5s timeout: 3s retries: 12 mosquitto: # Broker image = pinned base (eclipse-mosquitto:2.1.2-alpine, pinned # inside mosquitto/Dockerfile) with our bootstrap config BAKED IN. # Docker's copy-up then seeds a fresh mosquitto_config volume with # OUR dynsec-enabled config instead of the stock anonymous one; # existing volumes (holding deployed revisions) are never touched. build: context: ./mosquitto restart: unless-stopped # Spec §29: no docker.sock. Control sidecar shares the broker's PID namespace # and the mosquitto_config volume; it signals PID 1 (the broker) directly. environment: # Used by the dynsec plugin on first start to seed the 'admin' client # (verified: Mosquitto 2.1 dynamic security docs). MOSQUITTO_DYNSEC_PASSWORD: ${MOSQUITTO_DYNSEC_PASSWORD:?MOSQUITTO_DYNSEC_PASSWORD must be set} volumes: - mosquitto_config:/mosquitto/config - mosquitto_data:/mosquitto/data - mosquitto_log:/mosquitto/log networks: - backend-net # Release 1 ships an internal-only, plaintext listener on the backend # network for the management channel; production listeners are added # through the platform (spec §60). Nothing is published to the host. # Healthcheck: a REAL dynamic-security roundtrip via mosquitto_ctrl — # proves broker up + dynsec plugin loaded + admin auth works, exactly # what the backend depends on. NOT a $SYS probe: the 2.1 auto-generated # admin role set does not guarantee $SYS read access. # ($$ escapes compose interpolation; resolved inside the container.) healthcheck: test: ["CMD-SHELL", "mosquitto_ctrl -h 127.0.0.1 -u admin -P \"$$MOSQUITTO_DYNSEC_PASSWORD\" dynsec listClients >/dev/null 2>&1"] interval: 10s timeout: 5s retries: 12 start_period: 15s mosquitto-control: build: context: ./broker-control restart: unless-stopped depends_on: mosquitto: condition: service_started # Shares PID namespace with the broker so `mosquitto_signal -p 1 ...` # reaches the broker process without mounting docker.sock. pid: "service:mosquitto" environment: # Minimal default token for the internal control API; in production # this MUST be replaced (it only guards a backend-net-only endpoint). CONTROL_API_TOKEN: ${CONTROL_API_TOKEN:?CONTROL_API_TOKEN must be set} # Dynsec admin credentials for the post-deploy broker health probe # (mosquitto_sub against $SYS/broker/version on loopback). MOSQUITTO_DYNSEC_USERNAME: ${MOSQUITTO_DYNSEC_USERNAME:-admin} MOSQUITTO_DYNSEC_PASSWORD: ${MOSQUITTO_DYNSEC_PASSWORD:?MOSQUITTO_DYNSEC_PASSWORD must be set} MOSQUITTO_CONFIG_DIR: /mosquitto/config volumes: - mosquitto_config:/mosquitto/config networks: - backend-net healthcheck: test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1:8090/health/live"] interval: 10s timeout: 3s retries: 12 start_period: 5s mqtt-backend: build: context: ./backend restart: unless-stopped depends_on: mqtt-db: condition: service_healthy mosquitto: condition: service_healthy mosquitto-control: condition: service_healthy environment: <<: *backend-env volumes: - backend_data:/data networks: - frontend-net - backend-net healthcheck: test: ["CMD-SHELL", "python -c \"import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health/live', timeout=3)\""] interval: 10s timeout: 5s retries: 12 start_period: 15s mqtt-frontend: build: context: ./frontend args: # Only non-secret build args cross the frontend boundary (spec §7.2). VITE_API_BASE_URL: ${VITE_API_BASE_URL:-/api/v1} restart: unless-stopped depends_on: mqtt-backend: condition: service_healthy networks: - frontend-net ports: - "${FRONTEND_PORT:-8080}:80" healthcheck: test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1:80/ || exit 1"] interval: 10s timeout: 3s retries: 12 volumes: mqtt_db_data: mosquitto_config: mosquitto_data: mosquitto_log: backend_data: networks: frontend-net: backend-net: