# CluPilot — DEV stack. Everything runs in containers (host has only Docker). # `app` = php-fpm + nginx + vite (supervisor). reverb/queue reuse the image. # Ports + UID are env-driven (see .env). No secrets hardcoded here. services: app: build: context: . dockerfile: docker/php/Dockerfile args: HOST_UID: ${HOST_UID:-1000} HOST_GID: ${HOST_GID:-1000} image: clupilot-app:dev restart: unless-stopped volumes: - .:/var/www/html # RegisterHostDns/PurgeHost write here (see queue-provisioning below, # which is where those actually run); mounted here too so an ad hoc # `artisan` in this container sees and can touch the same entries. - dns-hosts:/etc/clupilot/dns-hosts ports: # Loopback by DEFAULT. Docker publishes ports ahead of UFW, so a backend # published on 0.0.0.0 is reachable from the internet even with the # firewall closed — and reaching it directly skips the reverse proxy, and # with it every hostname and address rule the console depends on. - "${APP_PORT:-127.0.0.1:8080}:80" - "${VITE_PORT:-5173}:5173" # Laravel reads .env from the bind mount. Do NOT inject it as real env vars # (env_file): that would override the test env (phpunit force) and let the # test suite run against the dev DB. Only vite needs these at process level. environment: VITE_HMR_HOST: ${VITE_HMR_HOST:-localhost} VITE_PORT: ${VITE_PORT:-5173} # Vite dev server is opt-in — see docker/supervisor/clupilot.conf. VITE_AUTOSTART: ${VITE_AUTOSTART:-false} depends_on: mariadb: condition: service_healthy redis: condition: service_started reverb: image: clupilot-app:dev restart: unless-stopped # www-data, for the same reason as the queue above: a Blade view compiled # by a root process can never be refreshed by the web process again. user: "www-data" command: php artisan reverb:start --host=0.0.0.0 --port=8080 volumes: - .:/var/www/html ports: - "${REVERB_HOST_PORT:-127.0.0.1:8081}:8080" depends_on: - app - redis queue: image: clupilot-app:dev restart: unless-stopped # As www-data, like php-fpm in the app container — NOT as root, which is # what a compose service without `user:` gets. # # Blade compiles a view the first time it is rendered and then touch()es the # compiled file to the source's mtime. touch() with an explicit time needs # ownership, so a compiled file written by a ROOT worker cannot be refreshed # afterwards by the web process: every request for that view answered 500 # with "touch(): Utime failed: Operation not permitted". Reported from live # right after a deployment, and it is the queue that renders mails. user: "www-data" command: php artisan queue:work redis --tries=3 --timeout=90 volumes: - .:/var/www/html depends_on: - app - redis - mariadb # Dedicated worker for long-running provisioning steps (own timeout, single try; # the DB state machine owns retries). Separate from the fast default queue. # It also acts as the WireGuard hub (LocalWireguardHub runs `wg set wg0` here), # so it needs NET_ADMIN, the tun device, a persistent wg config, and the WG # UDP port published on the host for peers to reach. Set CLUPILOT_WG_ENDPOINT # to :${WG_HUB_PORT} and CLUPILOT_WG_HUB_PUBKEY to wg0's key. queue-provisioning: image: clupilot-app:dev restart: unless-stopped # Bring up wg0 (from the mounted config, once the operator has installed it) # before starting the worker, so LocalWireguardHub can manage peers. # The one worker that stays root: it brings wg0 up and runs `wg set` for # every peer change, which needs NET_ADMIN on the running process. It # renders no mail, so it is not the one that was writing compiled views — # and update.sh normalises ownership at the end of every deployment, which # heals it if it ever does. command: sh -c 'wg-quick up wg0 2>/dev/null || true; exec php artisan queue:work provisioning --queue=provisioning --tries=1 --timeout=2100 --sleep=3' cap_add: - NET_ADMIN devices: - /dev/net/tun:/dev/net/tun sysctls: - net.ipv4.ip_forward=1 ports: - "${WG_HUB_PORT:-51820}:51820/udp" volumes: - .:/var/www/html - wireguard:/etc/wireguard # RegisterHostDns writes one file per host here (name -> wg_ip); # PurgeHost removes it on host deletion. vpn-dns mounts the same volume # read-only and watches it (dnsmasq --hostsdir) — this is HOW a host # gets a name now, since it can no longer go in public DNS. - dns-hosts:/etc/clupilot/dns-hosts depends_on: - app - redis - mariadb # ── The console, reachable from inside the tunnel ─────────────────────────── # # Both of these share the provisioning container's network namespace, which is # where wg0 (10.66.0.1) lives. That is the whole point: they answer ON the # tunnel address, so a VPN client needs no route beyond the management subnet # it already has, the host's docker bridge is never exposed to the tunnel, and # the console sees the client's real 10.66.0.x address rather than a proxy's. # # Nothing here is published to the host. If the tunnel is down, neither exists # as far as the outside world is concerned. # # Both are optional: an installation without VPN_INTERNAL_HOST configured # simply does not start them (profile `vpn`). vpn-dns: image: 4km3/dnsmasq:2.90-r3 restart: unless-stopped profiles: ["vpn"] network_mode: "service:queue-provisioning" cap_add: - NET_ADMIN volumes: # Host names (fsn-01.node.… -> wg_ip), written by RegisterHostDns onto # the same volume — see queue-provisioning above. Read-only here: # dnsmasq only ever reads this directory, never writes it. - dns-hosts:/etc/clupilot/dns-hosts:ro command: >- --keep-in-foreground --log-facility=- --listen-address=${CLUPILOT_WG_HUB_ADDRESS:-10.66.0.1} --bind-interfaces --no-resolv --server=1.1.1.1 --server=9.9.9.9 --address=/${VPN_INTERNAL_HOST:-admin.invalid}/${CLUPILOT_WG_HUB_ADDRESS:-10.66.0.1} --hostsdir=/etc/clupilot/dns-hosts depends_on: - queue-provisioning vpn-gateway: image: caddy:2-alpine restart: unless-stopped profiles: ["vpn"] network_mode: "service:queue-provisioning" volumes: - ./docker/caddy/vpn.Caddyfile:/etc/caddy/Caddyfile:ro # The certificate the public Caddy already obtains and renews. Read-only, # and shared rather than duplicated: a second ACME client would be a # second thing to renew, and the private address it would have to answer # on cannot satisfy an HTTP challenge anyway. - ${CADDY_DATA_DIR:-/var/lib/caddy/.local/share/caddy}:/certs:ro environment: VPN_INTERNAL_HOST: ${VPN_INTERNAL_HOST:-admin.invalid} # The same address the client is told to use as its resolver. Hard-coding # it here while the application reads a configurable one would produce a # config pointing at an address nothing listens on. VPN_HUB_ADDRESS: ${CLUPILOT_WG_HUB_ADDRESS:-10.66.0.1} # Plain-HTTP health port on the hub address, for the deployment to ask # whether this gateway is really listening in the tunnel's namespace. # Never published; 10.66.0.1 exists only inside the tunnel. VPN_HEALTH_PORT: ${VPN_HEALTH_PORT:-8081} VPN_CERT_PATH: ${VPN_CERT_PATH:-} VPN_KEY_PATH: ${VPN_KEY_PATH:-} depends_on: - queue-provisioning - app scheduler: image: clupilot-app:dev restart: unless-stopped # www-data, for the same reason as the queue above: a Blade view compiled # by a root process can never be refreshed by the web process again. user: "www-data" command: php artisan schedule:work volumes: - .:/var/www/html depends_on: - app - redis - mariadb # Gitea Actions runner. Opt-in via profile, because a development machine does # not need to burn cycles on CI: docker compose --profile ci up -d runner runner: image: gitea/act_runner:${GITEA_RUNNER_VERSION:-0.2.6} restart: unless-stopped profiles: [ci] environment: GITEA_INSTANCE_URL: ${GITEA_INSTANCE_URL:-https://git.bave.dev} GITEA_RUNNER_REGISTRATION_TOKEN: ${GITEA_RUNNER_TOKEN:-} GITEA_RUNNER_NAME: ${GITEA_RUNNER_NAME:-clupilot-local} # ubuntu-latest maps to a real image rather than the host, so a workflow # behaves the same here as anywhere else. GITEA_RUNNER_LABELS: ubuntu-latest:docker://catthehacker/ubuntu:act-22.04 volumes: - runner-data:/data # The runner starts job containers as siblings, which needs the socket. - /var/run/docker.sock:/var/run/docker.sock mariadb: image: mariadb:11.4 restart: unless-stopped environment: MARIADB_DATABASE: ${DB_DATABASE:-clupilot} MARIADB_USER: ${DB_USERNAME:-clupilot} MARIADB_PASSWORD: ${DB_PASSWORD} MARIADB_ROOT_PASSWORD: ${DB_ROOT_PASSWORD} ports: # host-side port is separate from DB_PORT (which Laravel uses to reach # mariadb:3306 inside the network) so changing it can't break the app. - "127.0.0.1:${DB_HOST_PORT:-3306}:3306" volumes: - db-data:/var/lib/mysql healthcheck: test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"] interval: 5s timeout: 5s retries: 20 redis: image: redis:7-alpine restart: unless-stopped volumes: - redis-data:/data # Uptime Kuma bridge: Kuma has no write REST API (monitor CRUD is Socket.IO), # so this translates the REST contract CluPilot speaks. Opt-in: # docker compose --profile monitoring up -d kuma-bridge # Then set MONITORING_API_URL=http://kuma-bridge:8080 in .env. kuma-bridge: build: context: ./docker/kuma-bridge image: clupilot-kuma-bridge:dev restart: unless-stopped profiles: ["monitoring"] environment: KUMA_URL: "${KUMA_URL:-}" KUMA_USERNAME: "${KUMA_USERNAME:-}" KUMA_PASSWORD: "${KUMA_PASSWORD:-}" KUMA_TOTP: "${KUMA_TOTP:-}" BRIDGE_TOKEN: "${MONITORING_API_TOKEN:-}" volumes: db-data: runner-data: redis-data: wireguard: dns-hosts: