# Log framework (LOGGING.md): every container keeps rotated json-file logs and carries labels a collector can use as # low-cardinality stream labels without parsing: vanguard.service / vanguard.category. x-logging: &vglog driver: json-file options: { max-size: "50m", max-file: "5" } services: mediamtx: logging: *vglog labels: { vanguard.service: mediamtx, vanguard.category: stream } image: bluenviron/mediamtx:latest-ffmpeg # alpine variant: has sh + wget for the is-live Discord hook container_name: mediamtx user: "1000:1000" # recordings owned by the debian user, so the archive pull can delete them environment: TZ: America/New_York # recording filenames are stamped in the container's clock; must match the host LIVE_WEBHOOK_URL: ${LIVE_WEBHOOK_URL:-} LIVE_HOST: ${LIVE_HOST:-live.example.com} ports: - "1935:1935" # RTMP ingest - "8890:8890/udp" # SRT ingest - "8189:8189/udp" # WebRTC media (viewers + Go Live) - "8189:8189/tcp" # WebRTC ICE-TCP fallback - "127.0.0.1:8888:8888" # HLS -> Caddy live.HOST/hls/* - "127.0.0.1:8889:8889" # WebRTC -> Caddy live.HOST/webrtc/* - "127.0.0.1:9997:9997" # API -> Caddy live.HOST/api/* - "127.0.0.1:9998:9998" # Prometheus metrics (per-stream bitrate, readers, drops) -> scraper on the host volumes: - ./mediamtx.yml:/mediamtx.yml:ro - ./bin/mtx-on-ready.sh:/vanguard/on-ready.sh:ro # is-live notice + WebRTC audio mirror (runOnReady) - /srv/media/streams:/recordings restart: unless-stopped copyparty: logging: *vglog labels: { vanguard.service: copyparty, vanguard.category: media } image: copyparty/ac:latest container_name: copyparty ports: - "127.0.0.1:3923:3923" volumes: - /srv/media:/w - ./copyparty.conf:/cfg/copyparty.conf:ro - copyparty_hist:/cfg/hist restart: unless-stopped syncthing: logging: *vglog labels: { vanguard.service: syncthing, vanguard.category: intake } image: syncthing/syncthing:latest container_name: syncthing environment: - PUID=1000 - PGID=1000 ports: - "22000:22000/tcp" - "22000:22000/udp" - "127.0.0.1:8384:8384" # admin UI, reach via ssh tunnel volumes: - /srv/media/.syncthing:/var/syncthing/config # created by install.sh, owned by uid 1000 - /srv/media/clips:/var/syncthing/clips restart: unless-stopped caddy: logging: *vglog labels: { vanguard.service: caddy, vanguard.category: auth } image: caddy:latest container_name: caddy depends_on: [copyparty] network_mode: host # talks to copyparty (127.0.0.1:3923) and dumpd (127.0.0.1:8080) on loopback volumes: - ./Caddyfile:/etc/caddy/Caddyfile:ro - ./dock:/srv/dock:ro - ./clips:/srv/clips:ro # clip-intake self-service page - ./about:/srv/about:ro # gateway page (/about) - /srv/live:/srv/live:ro # live portal static build (deployLive.sh) - caddy_data:/data - caddy_config:/config restart: unless-stopped # ---------------------------------------------------------------- identity (Authentik) authentik-db: logging: *vglog labels: { vanguard.service: authentik-db, vanguard.category: auth } image: docker.io/library/postgres:16-alpine container_name: authentik-db environment: POSTGRES_DB: authentik POSTGRES_USER: authentik POSTGRES_PASSWORD: ${AUTHENTIK_PG_PASS} volumes: - /srv/appdata/authentik/db:/var/lib/postgresql/data healthcheck: test: ["CMD-SHELL", "pg_isready -d authentik -U authentik"] interval: 30s timeout: 5s retries: 5 start_period: 20s restart: unless-stopped authentik-redis: logging: *vglog labels: { vanguard.service: authentik-redis, vanguard.category: auth } image: docker.io/library/redis:alpine container_name: authentik-redis command: --save 60 1 --loglevel warning volumes: - /srv/appdata/authentik/redis:/data healthcheck: test: ["CMD-SHELL", "redis-cli ping | grep PONG"] interval: 30s timeout: 3s retries: 5 restart: unless-stopped authentik-server: logging: *vglog labels: { vanguard.service: authentik-server, vanguard.category: auth } image: ghcr.io/goauthentik/server:${AUTHENTIK_TAG:-2025.8} container_name: authentik-server command: server environment: &authentik_env AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY} AUTHENTIK_BOOTSTRAP_PASSWORD: ${AUTHENTIK_BOOTSTRAP_PASSWORD:-} # akadmin password on FIRST start (no initial-setup page) AUTHENTIK_BOOTSTRAP_TOKEN: ${AUTHENTIK_BOOTSTRAP_TOKEN:-} # API token for akadmin, created on first start (blueprints/automation) AUTHENTIK_REDIS__HOST: authentik-redis AUTHENTIK_POSTGRESQL__HOST: authentik-db AUTHENTIK_POSTGRESQL__USER: authentik AUTHENTIK_POSTGRESQL__NAME: authentik AUTHENTIK_POSTGRESQL__PASSWORD: ${AUTHENTIK_PG_PASS} AUTHENTIK_ERROR_REPORTING__ENABLED: "false" ports: - "127.0.0.1:9000:9000" volumes: - /srv/appdata/authentik/media:/media - /srv/appdata/authentik/templates:/templates depends_on: authentik-db: { condition: service_healthy } authentik-redis: { condition: service_healthy } restart: unless-stopped authentik-worker: logging: *vglog labels: { vanguard.service: authentik-worker, vanguard.category: auth } image: ghcr.io/goauthentik/server:${AUTHENTIK_TAG:-2025.8} container_name: authentik-worker command: worker environment: *authentik_env volumes: - /srv/appdata/authentik/media:/media - /srv/appdata/authentik/templates:/templates - /srv/appdata/authentik/certs:/certs depends_on: authentik-db: { condition: service_healthy } authentik-redis: { condition: service_healthy } restart: unless-stopped # ---------------------------------------------------------------- watching (Jellyfin, direct play only) jellyfin: logging: *vglog labels: { vanguard.service: jellyfin, vanguard.category: media } image: jellyfin/jellyfin:latest container_name: jellyfin user: "1000:1000" environment: JELLYFIN_PublishedServerUrl: https://${WATCH_HOST:-watch.example.com} ports: - "127.0.0.1:8096:8096" volumes: - /srv/appdata/jellyfin/config:/config - /srv/appdata/jellyfin/cache:/cache - /srv/media/streams:/media/streams:ro - /srv/media/clips:/media/clips:ro - /srv/media/finals:/media/finals:ro restart: unless-stopped # ---------------------------------------------------------------- database (for the Discord bot and friends) mariadb: logging: *vglog labels: { vanguard.service: mariadb, vanguard.category: infra } image: mariadb:11 container_name: mariadb network_mode: host # PUBLIC on 3306: host networking so the nftables input rules (db_allow / rate # limit) actually apply — Docker-published ports bypass the host firewall. environment: MARIADB_ROOT_PASSWORD: ${MARIADB_ROOT_PASSWORD} MARIADB_ROOT_HOST: localhost # root only from the box itself; the bot user may connect from anywhere MARIADB_DATABASE: ${BOT_DB_NAME:-vanguardbot} MARIADB_USER: ${BOT_DB_USER:-bot} MARIADB_PASSWORD: ${BOT_DB_PASSWORD} GITEA_DB_PASSWORD: ${GITEA_DB_PASSWORD} # used by mariadb/init/10-gitea.sh on first init volumes: - /srv/appdata/mariadb:/var/lib/mysql - ./mariadb/init:/docker-entrypoint-initdb.d:ro healthcheck: test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"] interval: 20s timeout: 5s retries: 6 start_period: 30s restart: unless-stopped # ------------------------------------------- Discord bot (python; its release is installed in /srv/bot/app) discord-bot: logging: *vglog labels: { vanguard.service: discord-bot, vanguard.category: bot } build: ./bot container_name: discord-bot user: "1000:1000" environment: DISCORD_BOT_TOKEN: ${DISCORD_BOT_TOKEN:-} DISCORD_GUILD_ID: ${DISCORD_GUILD_ID:-} DB_HOST: host.docker.internal # mariadb runs on the host network DB_PORT: "3306" DB_NAME: ${BOT_DB_NAME:-vanguardbot} DB_USER: ${BOT_DB_USER:-bot} DB_PASSWORD: ${BOT_DB_PASSWORD} DB_BACKEND: mariadb DATA_DIR: /data WEB_ALLOWED_GROUPS: ${BOT_WEB_ALLOWED_GROUPS:-bot-users,admins} # the bot's own gate; Caddy already requires BOT_REQUIRE_GROUP WEB_SERVE_UI: "true" # serve the bot's own pages at / on this port (same origin as /api) PYTHONPATH: /app/vendor # the bundle's dependencies; the image installs none MEDIA_CLIPS: /media/clips WEB_HOST: 0.0.0.0 WEB_PORT: "8000" WEB_PUBLIC_URL: https://${BOT_HOST:-bot.example.com} AUTH_HEADER_USER: X-Authentik-Username AUTH_HEADER_GROUPS: X-Authentik-Groups AUTH_HEADER_EMAIL: X-Authentik-Email TZ: America/New_York PYTHONUNBUFFERED: "1" ports: - "127.0.0.1:8000:8000" # web UI; public only via Caddy + Authentik forward-auth at https://BOT_HOST/ volumes: - ${BOT_APP_DIR:-/srv/bot/app}:/app # CI bundle installed by ./deployBotBundle.sh (code + vendor/) - /srv/media/clips:/media/clips # rw: for a clip-submissions feature (writes into clips//) - /srv/appdata/discord-bot:/data # persistent scratch (sqlite, caches) if the bot wants files extra_hosts: - "host.docker.internal:host-gateway" networks: - botnet # its OWN network: the bot trusts the identity headers Caddy injects, so # nothing else must be able to reach :8000 and forge them. Caddy is on the # host network and uses the published 127.0.0.1:8000; MariaDB is reached # through host.docker.internal. The bot needs no sibling container. depends_on: mariadb: { condition: service_healthy } restart: unless-stopped # ---------------------------------------------------------------- Gitea + Actions runner gitea: logging: *vglog labels: { vanguard.service: gitea, vanguard.category: code } image: docker.gitea.com/gitea:1.24 container_name: gitea environment: USER_UID: "1000" USER_GID: "1000" GITEA__database__DB_TYPE: mysql GITEA__database__HOST: host.docker.internal:3306 # mariadb runs on the host network GITEA__database__NAME: gitea GITEA__database__USER: gitea GITEA__database__PASSWD: ${GITEA_DB_PASSWORD} GITEA__server__DOMAIN: ${GIT_HOST:-git.example.com} GITEA__server__ROOT_URL: https://${GIT_HOST:-git.example.com}/ GITEA__server__HTTP_PORT: "3000" GITEA__server__SSH_DOMAIN: ${GIT_HOST:-git.example.com} GITEA__server__SSH_PORT: "2222" # what users put in their remotes GITEA__server__SSH_LISTEN_PORT: "22" # inside the container # accounts come from Authentik (Discord) via OIDC: external auto-registration on, local self-registration off GITEA__service__DISABLE_REGISTRATION: "false" GITEA__service__ALLOW_ONLY_EXTERNAL_REGISTRATION: "true" GITEA__service__SHOW_REGISTRATION_BUTTON: "false" GITEA__service__REQUIRE_SIGNIN_VIEW: "false" GITEA__oauth2_client__ENABLE_AUTO_REGISTRATION: "true" GITEA__oauth2_client__ACCOUNT_LINKING: auto GITEA__oauth2_client__USERNAME: nickname # Authentik preferred_username GITEA__oauth2_client__UPDATE_AVATAR: "true" GITEA__oauth2_client__OPENID_CONNECT_SCOPES: openid email profile GITEA__actions__ENABLED: "true" GITEA__actions__DEFAULT_ACTIONS_URL: github GITEA__webhook__ALLOWED_HOST_LIST: private,loopback,external GITEA__security__INSTALL_LOCK: "true" # no install page: db/settings come from env; admin user via configure.py GITEA__time__DEFAULT_UI_LOCATION: America/New_York ports: - "127.0.0.1:3000:3000" - "2222:22" volumes: - /srv/appdata/gitea:/data - /etc/timezone:/etc/timezone:ro - /etc/localtime:/etc/localtime:ro extra_hosts: - "host.docker.internal:host-gateway" depends_on: mariadb: { condition: service_healthy } restart: unless-stopped act_runner: logging: *vglog labels: { vanguard.service: act_runner, vanguard.category: deploy } image: docker.gitea.com/act_runner:latest container_name: act_runner environment: GITEA_INSTANCE_URL: https://${GIT_HOST:-git.example.com}/ GITEA_RUNNER_REGISTRATION_TOKEN: ${GITEA_RUNNER_TOKEN:-} # set by ./registerGiteaRunner.sh GITEA_RUNNER_NAME: vanguard-runner # JavaScript actions (checkout, upload-artifact) need node INSIDE the job image, so a label used with # them must point at an image that has it. "python" is bare CPython for plain run: steps; "python312" # adds node and is what the Discord bot's CI uses, so its wheels match the python:3.12-slim runtime. GITEA_RUNNER_LABELS: "ubuntu-latest:docker://docker.gitea.com/runner-images:ubuntu-latest,ubuntu-22.04:docker://docker.gitea.com/runner-images:ubuntu-22.04,dotnet:docker://mcr.microsoft.com/dotnet/sdk:10.0,python:docker://python:3.12,python312:docker://nikolaik/python-nodejs:python3.12-nodejs22" volumes: - /srv/appdata/act_runner:/data - /var/run/docker.sock:/var/run/docker.sock # jobs run as sibling containers; root-equivalent on the host depends_on: - gitea restart: unless-stopped # ---- metrics taps (LOGGING.md): Prometheus text format on loopback, scraped by whichever store is chosen later node_exporter: logging: *vglog labels: { vanguard.service: node_exporter, vanguard.category: infra } image: quay.io/prometheus/node-exporter:latest container_name: node_exporter network_mode: host # listens on 127.0.0.1:9100 only pid: host command: ["--path.rootfs=/host", "--web.listen-address=127.0.0.1:9100", "--collector.filesystem.mount-points-exclude=^/(dev|proc|sys|run|var/lib/docker/.+)($$|/)"] volumes: - /:/host:ro,rslave restart: unless-stopped cadvisor: logging: *vglog labels: { vanguard.service: cadvisor, vanguard.category: infra } image: gcr.io/cadvisor/cadvisor:latest container_name: cadvisor ports: - "127.0.0.1:9101:8080" # per-container CPU / memory / network / disk command: ["--housekeeping_interval=10s", "--docker_only=true", "--store_container_labels=false", "--whitelisted_container_labels=vanguard.service,vanguard.category"] volumes: - /:/rootfs:ro - /var/run:/var/run:ro - /sys:/sys:ro - /var/lib/docker/:/var/lib/docker:ro - /dev/disk/:/dev/disk:ro devices: - /dev/kmsg privileged: true restart: unless-stopped # ================================================================ observability (Phase 13) # Logs: Vector -> VictoriaLogs. Metrics: VictoriaMetrics scrapes the four taps. # Alerting: a vmalert per datasource -> Alertmanager -> Discord. All loopback; published via diag.HOST. # The deadman (bin/vanguard-deadman.sh, a host timer) watches THIS stack, because nothing in it can. victorialogs: logging: *vglog labels: { vanguard.service: victorialogs, vanguard.category: infra } image: docker.io/victoriametrics/victoria-logs:latest container_name: victorialogs user: "1000:1000" command: - -storageDataPath=/data - -retentionPeriod=30d - -httpListenAddr=:9428 - -http.pathPrefix=/logs # its own UI then emits /logs/... so Caddy must NOT strip the prefix ports: - "127.0.0.1:9428:9428" volumes: - /srv/appdata/victorialogs:/data restart: unless-stopped vector: logging: *vglog labels: { vanguard.service: vector, vanguard.category: infra } image: docker.io/timberio/vector:latest-debian container_name: vector environment: VECTOR_CONFIG: /etc/vector/vector.yaml volumes: - ./observability/vector.yaml:/etc/vector/vector.yaml:ro - /var/run/docker.sock:/var/run/docker.sock # Phase 13 decision "A": container logs need the Docker # API, so this is root-equivalent on the host. Upgrade # paths: journald log driver, or a read-only socket proxy. - /var/log/journal:/var/log/journal:ro # our own systemd units - /etc/machine-id:/etc/machine-id:ro # journald needs it to find the right journal - vector_data:/var/lib/vector depends_on: - victorialogs restart: unless-stopped victoriametrics: logging: *vglog labels: { vanguard.service: victoriametrics, vanguard.category: infra } image: docker.io/victoriametrics/victoria-metrics:latest container_name: victoriametrics user: "1000:1000" command: - -storageDataPath=/data - -retentionPeriod=90d - -httpListenAddr=:8428 - -promscrape.config=/etc/vm/scrape.yml - -promscrape.config.strictParse=false ports: - "127.0.0.1:8428:8428" volumes: - /srv/appdata/victoriametrics:/data - ./observability/scrape.yml:/etc/vm/scrape.yml:ro extra_hosts: - "host.docker.internal:host-gateway" # the four taps bind the host's loopback restart: unless-stopped vmalert-metrics: logging: *vglog labels: { vanguard.service: vmalert-metrics, vanguard.category: infra } image: docker.io/victoriametrics/vmalert:latest container_name: vmalert-metrics command: - -datasource.url=http://victoriametrics:8428 - -notifier.url=http://alertmanager:9093 - -remoteWrite.url=http://victoriametrics:8428 - -rule=/etc/alerts/alerts-metrics.yml - -httpListenAddr=:8880 - -evaluationInterval=1m ports: - "127.0.0.1:8880:8880" volumes: - ./observability/alerts-metrics.yml:/etc/alerts/alerts-metrics.yml:ro depends_on: - victoriametrics - alertmanager restart: unless-stopped vmalert-logs: logging: *vglog labels: { vanguard.service: vmalert-logs, vanguard.category: infra } image: docker.io/victoriametrics/vmalert:latest container_name: vmalert-logs # A vmalert instance has one datasource, so log rules need their own: this one queries VictoriaLogs' # LogsQL stats endpoints, which answer in the Prometheus format vmalert expects. command: - -datasource.url=http://victorialogs:9428/logs - -notifier.url=http://alertmanager:9093 - -remoteWrite.url=http://victoriametrics:8428 - -rule=/etc/alerts/alerts-logs.yml - -httpListenAddr=:8881 - -evaluationInterval=2m ports: - "127.0.0.1:8881:8881" volumes: - ./observability/alerts-logs.yml:/etc/alerts/alerts-logs.yml:ro depends_on: - victorialogs - alertmanager restart: unless-stopped alertmanager: logging: *vglog labels: { vanguard.service: alertmanager, vanguard.category: infra } image: docker.io/prom/alertmanager:latest container_name: alertmanager command: - --config.file=/etc/alertmanager/alertmanager.yml - --storage.path=/data - --web.listen-address=:9093 ports: - "127.0.0.1:9093:9093" volumes: - ./observability/alertmanager.yml:/etc/alertmanager/alertmanager.yml:ro - /etc/vanguard/alert-webhook:/etc/vanguard/alert-webhook:ro # written by configure.py, mode 0600 - /srv/appdata/alertmanager:/data extra_hosts: - "host.docker.internal:host-gateway" # the Watchdog heartbeat goes to dumpd on the bridge port restart: unless-stopped networks: botnet: # isolates discord-bot from the shared default network (see above) volumes: copyparty_hist: vector_data: caddy_data: caddy_config: