# Clusev — PROD stack. Plain Docker Compose, no Portainer. # # Caddy is the ONLY host-exposed service (auto-TLS when APP_DOMAIN is set; plain # HTTP on the bare IP otherwise). app/reverb are internal-only on the clusev net. # # Bootstrap + operate with ./install.sh. Manual deploy: # docker compose -f docker-compose.prod.yml build # docker compose -f docker-compose.prod.yml up -d # docker compose -f docker-compose.prod.yml exec -u app app php artisan migrate --force name: clusev # Locally-built tag by default; override CLUSEV_IMAGE with a GHCR digest # (ghcr.io//clusev@sha256:...) once CI signs + publishes images. x-image: &image image: "${CLUSEV_IMAGE:-clusev-app:prod}" restart: unless-stopped env_file: - .env # Safety net: force debug OFF in prod so an uncaught error can never render a # stack trace to the user (the custom resources/views/errors/* pages show # instead). `environment` overrides any APP_DEBUG left in the prod .env. environment: APP_DEBUG: "false" networks: - clusev services: app: <<: *image build: context: . dockerfile: Dockerfile target: prod args: # Build the in-image `app` user with the HOST's clusev uid/gid so php-fpm can write the # bind-mounted ./run sentinel (install.sh chowns ./run to clusev). Without this the image # defaults to 1002 and on a host where clusev != 1002 the dashboard restart/update buttons # silently fail to write their sentinel. install.sh sets HOST_UID/HOST_GID from clusev. APP_UID: "${HOST_UID:-1002}" APP_GID: "${HOST_GID:-1002}" expose: - "80" volumes: # Restart sentinel — the dashboard writes ./run/restart.request here (inside the # container: storage/app/restart-signal/) to REQUEST a stack restart. A host-side # watcher (a scoped systemd .path unit — see docker/restart-sentinel/) sees the file # appear, runs `docker compose restart`, then deletes it. The container gets NO Docker # socket; it only writes a non-sensitive marker file on this shared bind mount. - ./run:/var/www/html/storage/app/restart-signal depends_on: mariadb: condition: service_healthy redis: condition: service_started reverb: <<: *image command: php artisan reverb:start --host=0.0.0.0 --port=8080 expose: - "8080" depends_on: redis: condition: service_started queue: <<: *image command: php artisan queue:work --tries=3 --timeout=90 --sleep=1 depends_on: mariadb: condition: service_healthy redis: condition: service_started # Runs the Laravel scheduler loop (every-minute clusev:wg-sample, daily clusev:prune-audit). scheduler: <<: *image command: php artisan schedule:work depends_on: mariadb: condition: service_healthy redis: condition: service_started # Terminal sidecar: WebSocket (/terminal/ws via Caddy) ↔ SSH PTY (per server) or local shell PTY # (the Clusev "host" terminal). Internal only; authorizes the single-use token before opening a PTY. terminal: build: context: ./docker/terminal image: "${CLUSEV_TERMINAL_IMAGE:-clusev-terminal:prod}" restart: unless-stopped # Reach the Docker host's sshd from the sidecar so the "Clusev host" terminal can SSH into the # real machine (host.docker.internal → the host gateway). extra_hosts: - "host.docker.internal:host-gateway" environment: TERMINAL_SIDECAR_SECRET: "${TERMINAL_SIDECAR_SECRET:-}" APP_INTERNAL_URL: "http://app:80" # Land the host shell in a real, writable home (~) — not the read-only repo mount. TERMINAL_HOST_CWD: "/home/node" volumes: - .:/workspace:ro expose: - "3000" networks: - clusev caddy: image: caddy:2-alpine # pin by @sha256 digest before the first prod push restart: unless-stopped ports: - "${APP_PORT:-80}:80" - "443:443" - "443:443/udp" # HTTP/3 environment: # Domain is dashboard-driven via on-demand TLS (Caddy asks app:/_caddy/ask), so # no SITE_ADDRESS is needed. Only the ACME contact comes from .env. ACME_EMAIL: "${ACME_EMAIL:-admin@localhost}" # External reverse-proxy TLS: set to the upstream proxy's CIDR so Caddy trusts # its X-Forwarded-Proto. Leave as 127.0.0.1/32 when Caddy terminates TLS itself. TRUSTED_PROXY_CIDR: "${TRUSTED_PROXY_CIDR:-127.0.0.1/32}" volumes: # Mount the DIRECTORY (not the single Caddyfile): a single-file bind mount keeps the # original inode, so a `git pull` that REPLACES the file would not reach a running # container. A directory mount always reflects the current file, so `caddy reload` / # a recreate picks up Caddyfile changes on update. - ./docker/caddy:/etc/caddy:ro - caddy-data:/data # ACME account + certs — MUST persist across recreate - caddy-config:/config depends_on: - app - reverb - terminal networks: - clusev mariadb: image: mariadb:11 restart: unless-stopped environment: MARIADB_DATABASE: "${DB_DATABASE:-clusev}" MARIADB_USER: "${DB_USERNAME:-clusev}" MARIADB_PASSWORD: "${DB_PASSWORD:?set DB_PASSWORD in .env}" MARIADB_ROOT_PASSWORD: "${DB_ROOT_PASSWORD:?set DB_ROOT_PASSWORD in .env}" volumes: - mariadb-data:/var/lib/mysql healthcheck: test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"] interval: 10s timeout: 5s retries: 30 networks: - clusev # no host ports in prod — DB is reachable only on the internal clusev network redis: image: redis:7-alpine restart: unless-stopped command: redis-server --appendonly yes --requirepass "${REDIS_PASSWORD:?set REDIS_PASSWORD in .env}" volumes: - redis-data:/data networks: - clusev volumes: mariadb-data: redis-data: caddy-data: caddy-config: networks: clusev: driver: bridge