# 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 # Defense-in-depth (applies to app/reverb/queue/scheduler via <<: *image). no-new-privileges # blocks privilege escalation through setuid binaries — the image's drop-privileges startup only # LOWERS privileges, so it stays compatible. pids_limit is a fork-bomb backstop, set generously so # php-fpm/queue workers never hit it under normal load. cap_drop:[ALL] and read_only are # deliberately NOT set here: they need a per-service prod smoke test first (nginx :80 bind needs # NET_BIND_SERVICE, php-fpm/nginx/supervisor write pid/sockets/logs), so enable them after testing. security_opt: - "no-new-privileges:true" pids_limit: 1024 # Reach the Docker host's sshd from the PHP layer so the "Clusev host" Docker view (and any other # host-targeted SSH) can talk to the real machine — same mapping the terminal sidecar uses. extra_hosts: - "host.docker.internal:host-gateway" 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" expose: - "3000" security_opt: - "no-new-privileges:true" pids_limit: 512 networks: - clusev caddy: # Pinned by digest (supply-chain integrity): a mutable tag could silently swap the image under us. # Bump: docker buildx imagetools inspect caddy:2-alpine --format '{{.Manifest.Digest}}' image: caddy:2-alpine@sha256:5f5c8640aae01df9654968d946d8f1a56c497f1dd5c5cda4cf95ab7c14d58648 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 EXACT address (a /32, or its # tightest CIDR) so Caddy trusts its X-Forwarded-Proto/-For. Leave as 127.0.0.1/32 when # Caddy terminates TLS itself. SECURITY: never a broad range / 0.0.0.0/0 — a too-wide value # lets any client forge X-Forwarded-For and defeat the IP-keyed throttles + brute-force ban. 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 security_opt: - "no-new-privileges:true" pids_limit: 512 depends_on: - app - reverb - terminal networks: - clusev mariadb: # Pinned by digest (see caddy). Bump: docker buildx imagetools inspect mariadb:11 --format '{{.Manifest.Digest}}' image: mariadb:11@sha256:efb4959ef2c835cd735dbc388eb9ad6aab0c78dd64febcd51bc17481111890c4 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 # Quiet the protocol-normal "Aborted connection" WARNINGs (log_warnings=1) + worker timeouts. - ./docker/mariadb/tuning.cnf:/etc/mysql/conf.d/tuning.cnf:ro healthcheck: test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"] interval: 10s timeout: 5s retries: 30 security_opt: - "no-new-privileges:true" pids_limit: 1024 networks: - clusev # no host ports in prod — DB is reachable only on the internal clusev network redis: # Pinned by digest (see caddy). Bump: docker buildx imagetools inspect redis:7-alpine --format '{{.Manifest.Digest}}' image: redis:7-alpine@sha256:6ab0b6e7381779332f97b8ca76193e45b0756f38d4c0dcda72dbb3c32061ab99 restart: unless-stopped command: redis-server --appendonly yes --requirepass "${REDIS_PASSWORD:?set REDIS_PASSWORD in .env}" volumes: - redis-data:/data security_opt: - "no-new-privileges:true" pids_limit: 512 networks: - clusev volumes: mariadb-data: redis-data: caddy-data: caddy-config: networks: clusev: driver: bridge