name: rootguard services: # The one, narrow, auditable bridge letting cosign's own attestation # checks (see core's/updater's ROOTGUARD_ATTESTATION_PROXY_URL below) # reach GHCR/Sigstore from the fully internet-isolated `control` # network - see compose.release.yaml's identical service for the full # rationale. Wired in here too (not just the release compose file): # found live that a local run overriding ROOTGUARD_*_IMAGE to real, # signed ghcr.io digests (rather than ROOTGUARD_SKIP_ATTESTATION) hits # the exact same isolated-network failure. attestation-proxy: build: context: ./rootguard-attestation-proxy image: "${ROOTGUARD_ATTESTATION_PROXY_IMAGE:-rootguard-attestation-proxy:dev}" container_name: rootguard-attestation-proxy restart: unless-stopped read_only: true cap_drop: [ALL] security_opt: ["no-new-privileges:true"] networks: - control - egress healthcheck: test: ["CMD", "/rootguard-attestation-proxy", "healthcheck"] interval: 15s timeout: 3s retries: 5 core: build: context: ./rootguard-core image: "${ROOTGUARD_CORE_IMAGE:-rootguard-core:dev}" container_name: rootguard-core restart: unless-stopped depends_on: updater: condition: service_healthy attestation-proxy: condition: service_healthy environment: PORT: "8081" # See rootguard-core/internal/stack/attestation.go's # runAttestationCommand doc comment - scoped to cosign's own # subprocess only, never Core's ambient environment. Only actually # exercised in the "real signed images" local-run mode (see # ROOTGUARD_SKIP_ATTESTATION's own comment below) - harmless to # leave set otherwise, since attestation is skipped entirely then. ROOTGUARD_ATTESTATION_PROXY_URL: http://attestation-proxy:8888 ROOTGUARD_API_TOKEN: "${ROOTGUARD_API_TOKEN:?set ROOTGUARD_API_TOKEN in .env}" ROOTGUARD_CORE_CONTAINER: rootguard-core # Found live-testing this exact file (2026-08-29): a locally-built # dev image is never cosign-attested, so the first guided-setup # deploy always hit the attestation gate (installer.Manager, see # docs/security-audit-log.md) with no way to opt out - compose.release.yaml # already forwards this, compose.yaml never did. Test-only escape # hatch - see cmd/rootguard/main.go's own doc comment on # ROOTGUARD_SKIP_ATTESTATION for what it disables and why; leave # unset for a normal local run against real signed images. ROOTGUARD_SKIP_ATTESTATION: "${ROOTGUARD_SKIP_ATTESTATION:-false}" ROOTGUARD_INSTALLATION_DIR: /var/lib/rootguard/installation ROOTGUARD_UNBOUND_IMAGE: "${ROOTGUARD_UNBOUND_IMAGE:-ghcr.io/foxly-it/rootguard-unbound:latest}" ROOTGUARD_ADGUARD_IMAGE: "${ROOTGUARD_ADGUARD_IMAGE:-adguard/adguardhome:v0.107.80}" ROOTGUARD_ADGUARD_BETA_IMAGE: "${ROOTGUARD_ADGUARD_BETA_IMAGE:-adguard/adguardhome:beta}" ROOTGUARD_ADGUARD_UPDATE_IMAGE: "${ROOTGUARD_ADGUARD_UPDATE_IMAGE:-adguard/adguardhome:latest}" ROOTGUARD_UNBOUND_UPDATE_IMAGE: "${ROOTGUARD_UNBOUND_UPDATE_IMAGE:-ghcr.io/foxly-it/rootguard-unbound:latest}" ROOTGUARD_BLOCKPAGE_IMAGE: "${ROOTGUARD_BLOCKPAGE_IMAGE:-ghcr.io/foxly-it/rootguard-blockpage:latest}" ROOTGUARD_UPDATE_DIR: /var/lib/rootguard/updates ROOTGUARD_CONTROL_PLANE_UPDATER_URL: http://updater:8082 ROOTGUARD_CONTROL_PLANE_UPDATER_TOKEN: "${ROOTGUARD_API_TOKEN:?set ROOTGUARD_API_TOKEN in .env}" ROOTGUARD_UPDATER_UPDATE_IMAGE: "${ROOTGUARD_UPDATER_UPDATE_IMAGE:-ghcr.io/foxly-it/rootguard-updater:latest}" ROOTGUARD_ATTESTATION_PROXY_UPDATE_IMAGE: "${ROOTGUARD_ATTESTATION_PROXY_UPDATE_IMAGE:-rootguard-attestation-proxy:dev}" ROOTGUARD_SELF_UPDATE_DIR: /var/lib/rootguard/updater-self-update ROOTGUARD_COMPOSE_FILE: /opt/rootguard/compose.yaml ROOTGUARD_COMPOSE_PROJECT: rootguard # Core never reads these four - they exist only so `docker compose -f # compose.yaml ... up -d --no-deps updater` (used to replace the # updater's own container, see ROOTGUARD_SELF_UPDATE_DIR above), and # one hop further `... up -d --no-deps core webapp`, can interpolate # the whole compose.yaml file, which also references them for the # webapp service. Same reason the updater service carries them. # Found in review: only the two `${VAR:?...}` ones were carried here - # ROOTGUARD_ADMIN_USER/ROOTGUARD_WEB_PORT below were missed, so a # self-update-triggered `up -d --no-deps webapp` silently re-created # it with ROOTGUARD_ADMIN_USER=admin/ROOTGUARD_WEB_PORT=8080 # regardless of whatever an operator had actually set in .env. ROOTGUARD_ADMIN_PASSWORD: "${ROOTGUARD_ADMIN_PASSWORD:?set ROOTGUARD_ADMIN_PASSWORD in .env}" ROOTGUARD_RECOVERY_TOKEN: "${ROOTGUARD_RECOVERY_TOKEN:?set ROOTGUARD_RECOVERY_TOKEN in .env}" ROOTGUARD_ADMIN_USER: "${ROOTGUARD_ADMIN_USER:-admin}" ROOTGUARD_WEB_PORT: "${ROOTGUARD_WEB_PORT:-8080}" # Propagates the real host checkout directory into `docker compose -f # /opt/rootguard/compose.yaml ...` re-invocations (self-update and the # control-plane update both do this from inside a container). Without # this, `${PWD}` below re-resolves against the wrong process's cwd and # silently mounts an empty directory instead of the real file - see the # matching comment on the volumes line. PWD: "${PWD}" UNBOUND_CONFIG_DIR: /var/lib/rootguard/unbound UNBOUND_CONTAINER_CONFIG_DIR: /etc/unbound/unbound.d UNBOUND_CONTAINER_NAME: rootguard-unbound ADGUARD_INSTALLER_URL: http://rootguard-adguard:3000 ADGUARD_API_URL: http://rootguard-adguard:80 ADGUARD_DATA_DIR: /var/lib/rootguard/adguard ADGUARD_UPSTREAM: 172.29.53.2:5335 ADGUARD_DNS_ADDRESS: rootguard-adguard:53 ROOTGUARD_BLOCKPAGE_AUTH_DIR: /var/lib/rootguard/adguard-auth volumes: - /var/run/docker.sock:/var/run/docker.sock - rootguard-data:/var/lib/rootguard - unbound-config:/var/lib/rootguard/unbound - adguard-auth:/var/lib/rootguard/adguard-auth # ${PWD}, not a relative "./compose.yaml": docker compose resolves a # relative bind source against the directory of whatever -f file is # being read, not against this file's own location. That's fine for # the top-level `docker compose up` (PWD = the real checkout dir), but # breaks the moment Core or the Updater re-invoke `docker compose -f # /opt/rootguard/compose.yaml ...` from inside their own container to # recreate a sibling service: the relative path would then resolve # against /opt/rootguard - a path that only exists inside containers, # not on the real host - silently mounting an auto-vivified empty # directory instead of the real file. ${PWD} sidesteps this since it's # a literal environment-variable substitution, not filesystem-relative # resolution; the PWD env var above keeps propagating the correct # value through every such re-invocation. - ${PWD}/compose.yaml:/opt/rootguard/compose.yaml:ro networks: - control healthcheck: test: ["CMD", "wget", "-q", "-O", "-", "http://127.0.0.1:8081/api/health"] interval: 15s timeout: 5s retries: 5 webapp: build: context: ./rootguard-webapp image: "${ROOTGUARD_WEBAPP_IMAGE:-rootguard-webapp:dev}" container_name: rootguard-webapp restart: unless-stopped depends_on: core: condition: service_healthy environment: PORT: "8080" ROOTGUARD_CORE_URL: http://core:8081 ROOTGUARD_API_TOKEN: "${ROOTGUARD_API_TOKEN:?set ROOTGUARD_API_TOKEN in .env}" ROOTGUARD_ADMIN_USER: "${ROOTGUARD_ADMIN_USER:-admin}" ROOTGUARD_ADMIN_PASSWORD: "${ROOTGUARD_ADMIN_PASSWORD:?set ROOTGUARD_ADMIN_PASSWORD in .env}" ROOTGUARD_RECOVERY_TOKEN: "${ROOTGUARD_RECOVERY_TOKEN:?set ROOTGUARD_RECOVERY_TOKEN in .env}" ROOTGUARD_SESSION_FILE: /var/lib/rootguard-sessions/sessions.json volumes: - rootguard-sessions:/var/lib/rootguard-sessions ports: - "${ROOTGUARD_WEB_PORT:-8080}:8080/tcp" networks: - control - edge healthcheck: # The distroless runtime image has neither wget nor a shell - unlike # core/updater's docker:29-cli base, there's nothing here for a # "CMD wget ..." style check to exec. rootguard-webapp itself # implements -healthcheck for exactly this (see cmd/rootguard-webapp). test: ["CMD", "/app/rootguard-webapp", "-healthcheck"] interval: 15s timeout: 5s retries: 5 updater: build: context: ./rootguard-updater image: "${ROOTGUARD_UPDATER_IMAGE:-rootguard-updater:dev}" container_name: rootguard-updater restart: unless-stopped depends_on: attestation-proxy: condition: service_healthy environment: # See rootguard-updater/attestation.go's verifyAttestation doc # comment - scoped to cosign's own subprocess only. ROOTGUARD_ATTESTATION_PROXY_URL: http://attestation-proxy:8888 ROOTGUARD_UPDATER_TOKEN: "${ROOTGUARD_API_TOKEN:?set ROOTGUARD_API_TOKEN in .env}" ROOTGUARD_UPDATER_DATA_DIR: /var/lib/rootguard/control-plane-updater ROOTGUARD_COMPOSE_FILE: /opt/rootguard/compose.yaml ROOTGUARD_COMPOSE_PROJECT: rootguard ROOTGUARD_CORE_UPDATE_IMAGE: "${ROOTGUARD_CORE_UPDATE_IMAGE:-rootguard-core:dev}" ROOTGUARD_WEBAPP_UPDATE_IMAGE: "${ROOTGUARD_WEBAPP_UPDATE_IMAGE:-rootguard-webapp:dev}" ROOTGUARD_UPDATER_SKIP_PULL: "${ROOTGUARD_UPDATER_SKIP_PULL:-false}" ROOTGUARD_API_TOKEN: "${ROOTGUARD_API_TOKEN:?set ROOTGUARD_API_TOKEN in .env}" ROOTGUARD_ADMIN_USER: "${ROOTGUARD_ADMIN_USER:-admin}" ROOTGUARD_ADMIN_PASSWORD: "${ROOTGUARD_ADMIN_PASSWORD:?set ROOTGUARD_ADMIN_PASSWORD in .env}" ROOTGUARD_RECOVERY_TOKEN: "${ROOTGUARD_RECOVERY_TOKEN:?set ROOTGUARD_RECOVERY_TOKEN in .env}" ROOTGUARD_WEB_PORT: "${ROOTGUARD_WEB_PORT:-8080}" ROOTGUARD_CORE_IMAGE: "${ROOTGUARD_CORE_IMAGE:-rootguard-core:dev}" ROOTGUARD_WEBAPP_IMAGE: "${ROOTGUARD_WEBAPP_IMAGE:-rootguard-webapp:dev}" ROOTGUARD_SESSION_DIR: /var/lib/rootguard-sessions # See the matching PWD/${PWD} comments on the core service above - the # same self-referential-mount hazard applies here now that Core can # recreate this very service (self-update). PWD: "${PWD}" volumes: - /var/run/docker.sock:/var/run/docker.sock - rootguard-data:/var/lib/rootguard - rootguard-sessions:/var/lib/rootguard-sessions - ${PWD}/compose.yaml:/opt/rootguard/compose.yaml:ro networks: - control healthcheck: test: ["CMD", "wget", "-q", "-O", "-", "http://127.0.0.1:8082/health"] interval: 15s timeout: 5s retries: 5 networks: edge: control: internal: true # Not internal - real internet access, but only # rootguard-attestation-proxy sits here. Core/the Updater stay on # `control` only; they never join `egress` directly. egress: volumes: unbound-config: name: rootguard-unbound-config rootguard-data: name: rootguard-data rootguard-sessions: name: rootguard-sessions adguard-auth: name: rootguard-adguard-auth