# MDMesh production stack. Configure via .env (generated by ./setup.sh), then: # Cloudflare Tunnel: docker compose --profile cloudflare up -d # (by hand, also put COMPOSE_PROFILES=cloudflare in .env, as setup.sh does: --profile alone doesn't reach caddy, # which then won't believe cloudflared's CF-Connecting-IP; see docker/edge-peers.sh) # Own domain (HTTPS): docker compose -f docker-compose.yml -f docker-compose.domain.yml up -d # # Security defaults: Postgres and the server publish NO host ports; the edge (Caddy) is the only # thing exposed, and only in the own-domain override. In tunnel mode nothing is published at all — # cloudflared reaches Caddy over the internal network. services: postgres: image: postgres:14 restart: unless-stopped environment: POSTGRES_DB: ${DB_NAME:-mdmesh} POSTGRES_USER: ${DB_USER:-mdmesh} POSTGRES_PASSWORD: ${DB_PASSWORD:?set DB_PASSWORD in .env} volumes: - pgdata:/var/lib/postgresql/data healthcheck: test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-mdmesh} -d ${DB_NAME:-mdmesh}"] interval: 5s timeout: 5s retries: 20 server: # Pull-based updates use the versioned GHCR image; `build:` is the source-deploy fallback (when an # image tag isn't published locally, compose builds it and tags it with this name). apply.sh bumps # SERVER_VERSION in .env then `docker compose pull server` to fetch the new signed image. image: ghcr.io/${IMAGE_OWNER:-local}/mdmesh-server:${SERVER_VERSION:-latest} build: context: . dockerfile: docker/server.Dockerfile restart: unless-stopped depends_on: postgres: condition: service_healthy environment: DB_HOST: postgres DB_PORT: "5432" DB_NAME: ${DB_NAME:-mdmesh} DB_USER: ${DB_USER:-mdmesh} DB_PASSWORD: ${DB_PASSWORD:?set DB_PASSWORD in .env} BASE_URL: ${BASE_URL:?set BASE_URL in .env} HASH_SECRET: ${HASH_SECRET:?set HASH_SECRET in .env} # Optional; unset = the key the server keeps in its volume. Prefixed .env key for the reason given at JAVA_OPTS. JWT_SECRET: ${SERVER_JWT_SECRET:-} SECURE_ENROLLMENT: ${SECURE_ENROLLMENT:-0} LEGACY_DEVICE_API: ${LEGACY_DEVICE_API:-0} SMTP_HOST: ${SMTP_HOST:-} SMTP_PORT: ${SMTP_PORT:-25} SMTP_FROM: ${SMTP_FROM:-mdm@localhost} SMTP_USERNAME: ${SMTP_USERNAME:-} SMTP_PASSWORD: ${SMTP_PASSWORD:-} SMTP_SSL: ${SMTP_SSL:-false} SMTP_STARTTLS: ${SMTP_STARTTLS:-false} # Optional JVM flags, e.g. a temporary DEBUG log config (docs/OPERATIONS.md "Server logs"). Read from the # prefixed .env key: compose lets the shell override .env, and a generic JAVA_OPTS exported on a Java host must # not reach the server. JAVA_OPTS: ${SERVER_JAVA_OPTS:-} volumes: - mdmeshdata:/opt/mdmesh caddy: # The web image (Caddy + baked SPA). Same pull/build duality as `server`; WEB_VERSION tracks the # release. apply.sh recreates `server` + `caddy` together so the SPA matches the API. image: ghcr.io/${IMAGE_OWNER:-local}/mdmesh-web:${WEB_VERSION:-latest} build: context: . dockerfile: docker/web.Dockerfile # Enrollment-QR parameters baked into the SPA (see docker/web.Dockerfile ARGs). setup.sh # verifies the latest release APK and persists these in .env; empty values fall back to the # SPA's debug defaults, matching what release CI bakes into published images. args: VITE_AGENT_PACKAGE: ${VITE_AGENT_PACKAGE:-} VITE_AGENT_CHECKSUM: ${VITE_AGENT_CHECKSUM:-} VITE_AGENT_APK_URL: ${VITE_AGENT_APK_URL:-} restart: unless-stopped depends_on: - server - supervisor environment: SITE_ADDRESS: ${SITE_ADDRESS:-:80} ACME_EMAIL: ${ACME_EMAIL:-} # The hosting mode: in tunnel mode (cloudflare) the edge believes cloudflared's CF-Connecting-IP # (docker/edge-peers.sh). COMPOSE_PROFILES: ${COMPOSE_PROFILES:-} # Proxies in front of the edge (a CDN, a load balancer) whose X-Forwarded-For it believes, as space-separated # CIDRs; set in .env. Unset or empty: none (docs/OPERATIONS.md, Sign-in limits). TRUSTED_PROXIES: ${TRUSTED_PROXIES:-} volumes: - caddy_data:/data - caddy_config:/config # Decoupled updater/recovery service. Polls GitHub for new releases, verifies the signed manifest, # exposes /update/status, serves /recovery, and (Phase 4) applies updates by driving compose against # the host daemon. The docker socket is host-root-equivalent — the supervisor is trusted and acts # only on VERIFIED manifests + AUTHORIZED callers (cookie-forward authz). It NEVER recreates itself # or postgres; apply.sh touches only `server` + `caddy`. supervisor: image: ghcr.io/${IMAGE_OWNER:-local}/mdmesh-supervisor:${SUPERVISOR_VERSION:-latest} build: context: . dockerfile: docker/supervisor.Dockerfile restart: unless-stopped environment: GITHUB_REPO: ${GITHUB_REPO:-} UPDATE_CHANNEL: ${UPDATE_CHANNEL:-stable} POLL_INTERVAL_HOURS: ${POLL_INTERVAL_HOURS:-6} GITHUB_TOKEN: ${GITHUB_TOKEN:-} CURRENT_VERSION: ${CURRENT_VERSION:-0.0.0} AUTO_UPDATE: ${AUTO_UPDATE:-0} # 0 on source installs (IMAGE_OWNER=local): there is no registry image to pull, so the console shows the manual # upgrade steps instead of a one-click apply that could only back up, fail and roll back. Native sets 0 too. # Defaults to 0 to match this file's IMAGE_OWNER default (local); setup.sh writes 1 for a registry owner. APPLY_SUPPORTED: ${APPLY_SUPPORTED:-0} # Pin project + compose-file set so the supervisor drives the SAME stack the host launched # (default project name = dir basename would fork a duplicate; COMPOSE_FILE carries the domain # overlay so recreating caddy keeps its published ports). COMPOSE_PROJECT_NAME: ${COMPOSE_PROJECT_NAME:-mdmesh} COMPOSE_FILE: ${COMPOSE_FILE:-docker-compose.yml} COMPOSE_PROFILES: ${COMPOSE_PROFILES:-} DB_USER: ${DB_USER:-mdmesh} DB_NAME: ${DB_NAME:-mdmesh} volumes: - /var/run/docker.sock:/var/run/docker.sock - .:/project - backups:/backups cloudflared: image: cloudflare/cloudflared:latest restart: unless-stopped profiles: ["cloudflare"] depends_on: - caddy command: tunnel --no-autoupdate run environment: TUNNEL_TOKEN: ${TUNNEL_TOKEN:-} volumes: pgdata: mdmeshdata: caddy_data: caddy_config: backups: # supervisor pre-update DB dumps + version snapshots + auto.json (survives recreate)