# commons/compositions/apex/docker-compose.yml # The shared apex CORE composition. Consumed by each node via include: (SP3). # FULLY ${env}-parameterized; NO './' host paths (relative paths in an included file # resolve against THIS dir — always use absolute ${APEX_*} vars). The engine exports # APEX_TIER*/APEX_TIER*_SHARED/APEX_TIER_ROOT*/APEX_SUBNET/APEX_NODE_FQDN at invoke. x-environment: &shared-environment TZ: "${TZ:-Etc/UTC}" # override per node via TZ in the env; default UTC services: # --- optional: adguard (shares traefik netns) --- adguard: image: adguard/adguardhome:v0.107.79 container_name: apex-adguard volumes: - ${APEX_TIER1}/adguard/data:/opt/adguardhome/work - ${APEX_TIER1}/adguard/conf:/opt/adguardhome/conf environment: <<: *shared-environment network_mode: "service:traefik" restart: unless-stopped labels: # backup (resticontainer, core/all-nodes): adguard config only (work data regenerable) - "restic.enable=true" - "restic.backup.paths=/opt/adguardhome/conf" cap_add: - NET_BIND_SERVICE - SYS_NICE - CAP_CHOWN profiles: [ adguard ] security_opt: - no-new-privileges:true # --- optional: observability agent (profile-gated) --- # Grafana Alloy: one agent per node. Collects host metrics (node_exporter- # equivalent) + container metrics (cadvisor) and PUSHES them via remote_write; # collects systemd-journal + docker logs and PUSHES them to Loki. All targets # are OUTBOUND to a central Prometheus + Loki (hosted on whichever node runs them), so # Alloy needs egress (direct, not enclave). Replaces the old store-only `loki` # service (which had no feeder). Config is a committed, env-parameterized asset. alloy: image: grafana/alloy:v1.20.0 container_name: apex-alloy volumes: - ${APEX_COMMONS}/assets/alloy/config.alloy:/etc/alloy/config.alloy:ro - ${APEX_TIER2}/alloy/data:/var/lib/alloy - /var/run/docker.sock:/var/run/docker.sock:ro - /run/containerd/containerd.sock:/run/containerd/containerd.sock:ro # cadvisor per-container stats on nodes using the containerd snapshotter (overlayfs) - /proc:/host/proc:ro - /sys:/host/sys:ro - /:/rootfs:ro - /var/log/journal:/var/log/journal:ro - /run/log/journal:/run/log/journal:ro - /etc/machine-id:/etc/machine-id:ro environment: <<: *shared-environment APEX_NODE_HOST: "${APEX_NODE_HOST?error}" # node + instance metric/log labels # push targets + machine auth (per-node scalars in apex.env / secret .env) APEX_LOKI_PUSH_URL: "${APEX_LOKI_PUSH_URL?error}" APEX_PROM_PUSH_URL: "${APEX_PROM_PUSH_URL?error}" APEX_OBS_BASIC_AUTH_USER: "${APEX_OBS_BASIC_AUTH_USER?error}" APEX_OBS_BASIC_AUTH_PASS: "${APEX_OBS_BASIC_AUTH_PASS?error}" GOMEMLIMIT: "${ALLOY_GOMEMLIMIT:-160MiB}" GOGC: "${ALLOY_GOGC:-75}" networks: direct: {} extra_hosts: - "host.docker.internal:host-gateway" command: - run - --server.http.listen-addr=0.0.0.0:12345 - --storage.path=/var/lib/alloy - /etc/alloy/config.alloy restart: unless-stopped user: "0:0" # root: required for host metrics, journal, and docker.sock cgroup: host # host cgroup namespace: the cadvisor exporter needs it to read per-container cgroups (else machine-root only) hostname: "${APEX_NODE_HOST}" # UTS hostname => node_exporter's node_uname_info `nodename` (else the container id) profiles: [ alloy ] security_opt: - no-new-privileges:true # --- backups (host network, manual profile, one-shot) --- # resticontainer (github.com/deytenit/resticontainer): a restic wrapper that # discovers per-service backup intent from `restic.*` compose labels # (restic.enable / restic.backup.paths / restic.backup.stop / restic.hooks.*). # On `backup` it queries the docker daemon, runs each service's pre-hook (e.g. # pg_dump), stops any container flagged restic.backup.stop, resolves the labeled # container mounts to host paths (via the /srv mount), snapshots the union # in a single restic run, then restarts + runs post-hooks. Every other subcommand # (init/snapshots/forget/…) passes straight through to restic. Needs the docker # socket (list/inspect/exec/stop/start) and /srv read-only (the fleet's data root) # — a privileged manual one-shot, same trust surface as the old restic core. resticontainer: image: ghcr.io/deytenit/resticontainer:0.19.1 container_name: apex-resticontainer volumes: - ${APEX_TIER3}/restic/cache:/var/restic-cache - /var/run/docker.sock:/var/run/docker.sock # query + exec/stop/start # apex tier data + checkouts live under /srv, and the @tierN bind mounts are # ABSOLUTE symlinks into /srv (compositions/

/@tierN -> /srv/com-…-tierN/

). # Mount /srv at the same path with RESTIC_HOSTFS=/ so restic follows those # symlinks and records the real host paths — a bare /:/hostfs cannot, because # the absolute symlink target /srv/… is dangling inside the container. - /srv:/srv:ro # read the data (resolves @tierN symlinks) environment: <<: *shared-environment RESTIC_HOSTFS: "/" # mount sources are already absolute (/srv/…) RESTIC_REPOSITORY: "${APEX_RESTIC_REPOSITORY?error}" RESTIC_PASSWORD: "${APEX_RESTIC_PASSWORD?error}" AWS_ACCESS_KEY_ID: "${APEX_RESTIC_AWS_ACCESS_KEY_ID?error}" AWS_SECRET_ACCESS_KEY: "${APEX_RESTIC_AWS_SECRET_ACCESS_KEY?error}" # S3 backend region + endpoint. Defaults suit AWS S3 (us-east-1, no custom # endpoint); set APEX_RESTIC_AWS_REGION / APEX_RESTIC_AWS_ENDPOINT (node apex.env) # for any other S3-compatible store (Yandex, Backblaze B2, MinIO, …). AWS_REGION: "${APEX_RESTIC_AWS_REGION:-us-east-1}" AWS_ENDPOINT_URL: "${APEX_RESTIC_AWS_ENDPOINT:-}" # max compression is fleet policy (backup/run also forces --compression max on # the CLI; needs repo format v2, which a fresh restic init creates). Per-node # override via APEX_RESTIC_COMPRESSION only if ever needed. RESTIC_COMPRESSION: "${APEX_RESTIC_COMPRESSION:-max}" RESTIC_CACHE_DIR: /var/restic-cache network_mode: "host" profiles: [ manual ] # --- docker socket proxy --- socket-proxy: image: tecnativa/docker-socket-proxy:0 container_name: apex-socket-proxy volumes: - /var/run/docker.sock:/var/run/docker.sock:ro environment: CONTAINERS: 1 SERVICES: 1 TASKS: 1 networks: socket: {} restart: unless-stopped user: "0:0" # --- edge reverse proxy (the core anchor on direct) --- traefik: image: traefik:3.7 container_name: apex-traefik depends_on: - socket-proxy volumes: - ${APEX_TIER1_SHARED}/apex/traefik/letsencrypt:/letsencrypt - ${APEX_TIER3}/traefik/logs:/logs - ${APEX_TIER1}/traefik/dynamic:/etc/traefik/dynamic:ro environment: <<: *shared-environment CF_DNS_API_TOKEN: "${APEX_TRAEFIK_CF_DNS_API_TOKEN?error}" UMASK: "0002" ports: - '0.0.0.0:80:80' - '0.0.0.0:443:443/tcp' - '0.0.0.0:443:443/udp' networks: direct: ipv4_address: ${APEX_TRAEFIK_DIRECT_IP?error} enclave: {} socket: {} extra_hosts: - "host.docker.internal:host-gateway" command: - "--api.dashboard=true" - "--log.level=INFO" - "--accesslog=true" - "--accesslog.filepath=/logs/access.log" - "--providers.file.directory=/etc/traefik/dynamic" - "--providers.file.watch=true" - "--providers.docker=true" - "--providers.docker.network=enclave" - "--providers.docker.exposedByDefault=false" - "--providers.docker.endpoint=tcp://apex-socket-proxy:2375" - "--entrypoints.web.address=:80" - "--entrypoints.web.http.redirections.entrypoint.to=websecure" - "--entryPoints.web.http.redirections.entrypoint.scheme=https" - "--entrypoints.websecure.address=:443" - "--entrypoints.websecure.asDefault=true" - "--entrypoints.websecure.http.tls.certresolver=cloudflare" - "--entrypoints.websecure.http3" - "--certificatesresolvers.cloudflare.acme.dnschallenge=true" - "--certificatesresolvers.cloudflare.acme.dnschallenge.provider=cloudflare" - "--certificatesresolvers.cloudflare.acme.email=${APEX_TRAEFIK_ACME_EMAIL?error}" - "--certificatesresolvers.cloudflare.acme.storage=/letsencrypt/acme.json" restart: unless-stopped labels: - "traefik.enable=true" - "traefik.http.routers.apex-traefik.rule=Host(`${APEX_NODE_FQDN}`) && (PathPrefix(`/api`) || PathPrefix(`/dashboard`))" - "traefik.http.middlewares.xray-enclave.ipallowlist.sourcerange=${APEX_ENCLAVE_SUBNET:-198.18.0.0/16}" - "traefik.http.routers.apex-traefik.middlewares=xray-enclave@docker" - "traefik.http.routers.apex-traefik.service=api@internal" # adguard route (backend only present when the adguard profile is active) - "traefik.http.routers.apex-adguard.rule=Host(`${APEX_NODE_FQDN}`) && PathPrefix(`/adguard`)" - "traefik.http.routers.apex-adguard.middlewares=apex-adguard-strip-prefix" - "traefik.http.middlewares.apex-adguard-strip-prefix.stripprefix.prefixes=/adguard" - "traefik.http.routers.apex-adguard.service=apex-adguard" - "traefik.http.services.apex-adguard.loadbalancer.server.port=3000" # backup (resticontainer, core/all-nodes): dynamic routing rules only # (letsencrypt certs are regenerable via ACME/Cloudflare DNS-01) - "restic.enable=true" - "restic.backup.paths=/etc/traefik/dynamic" user: "${APEX_UID?error}:${APEX_GID?error}" group_add: - "${APEX_SHARED_GID?error}" security_opt: - no-new-privileges:true xray: image: ghcr.io/xtls/xray-core:26.9.9 container_name: apex-xray volumes: - ${APEX_TIER1}/xray/data/config.jsonc:/etc/xray/config.jsonc:ro - ${APEX_TIER1_SHARED}/apex/letsencrypt:/etc/letsencrypt/live:ro - ${APEX_TIER3_SHARED}/apex/xray/log:/var/log/xray/ - ${APEX_TIER3_SHARED}/apex/xray/shm:/dev/shm/xray/ environment: <<: *shared-environment network_mode: "host" command: [ "-c", "/etc/xray/config.jsonc" ] restart: unless-stopped labels: # backup (resticontainer, core/all-nodes): xray config only (logs + shm are ephemeral) - "restic.enable=true" - "restic.backup.paths=/etc/xray/config.jsonc" user: "0:0" profiles: [ xray ] security_opt: - no-new-privileges:true networks: direct: name: direct driver: bridge ipam: config: - subnet: ${APEX_SUBNET?error} enclave: name: enclave driver: bridge internal: true socket: name: socket driver: bridge internal: true