name: maven # One image (built once), one container per daemon. Only mavend holds the key # and the db volume; the modules mount just the shared socket dir + models. # IPC stays unix-domain over the shared `sockets` volume — no code change from # the bare-metal setup, only paths move to /run/maven. x-image: &image # NOT "maven" — that's Apache Maven on Docker Hub and compose will happily # pull it, giving every container `mvn-entrypoint.sh` and exit 127. image: mavenai:latest # build on EVERY service (same image name ⇒ built once) so `docker compose # build ` actually rebuilds. With build on only one service, the # others silently no-op and you deploy a stale binary. build: context: . # the zone is declared once, here. The image points /etc/localtime at it so # a caller reading the system zone agrees with one reading TZ (V-545). args: TZ: Europe/Samara pull_policy: never # only ever the locally-built image restart: unless-stopped # local time for clock/date replies AND quiet-hours evaluation. Change to # your zone; needs tzdata in the image (installed in the Dockerfile). environment: - TZ=Europe/Samara services: mavend: <<: *image command: ["mavend", "-config", "/opt/maven/config/mavend.json"] # both nets: `default` keeps the `mavend` DNS name the other daemons reach # (mavweb -> mavend:9100); `ecosystem` reaches nexus/praxis/hexis by name. networks: [default, ecosystem] # the key lives ONLY here. deploy/db_key.env holds MAVEN_DB_KEY=. env_file: - ./deploy/db_key.env - ./deploy/telegram.env volumes: - dbdata:/var/lib/maven # encrypted db at rest - sockets:/run/maven # IPC socket dir - ./deploy/mavend.json:/opt/maven/config/mavend.json:ro - ./models:/opt/maven/models:ro - /mnt/hdd1/llms:/opt/maven/models/llm:ro # LFM gguf library # the LFM engine (llama-server) offloads onto the AMD iGPU (RADV RENOIR, # Ryzen 5 5600U) via Vulkan — same device + render gid as mavsttd, which # uses the same driver for whisper. Without these Vulkan # enumerates zero devices and llama-server silently falls back to CPU. devices: - "/dev/dri:/dev/dri" group_add: - "993" # host 'render' gid owning /dev/dri/renderD128 (getent group render) # the decrypted working copy lives in RAM (see db_tmpfs in mavend.json). tmpfs: - /dev/shm # the voice wire, for mavwaked and mavenclient on workpc (V-515). # # LOOPBACK ONLY, and that is the whole security argument. internal/voice # is plaintext with no auth: its own server doc says production binds # inside the wg tunnel, "the wg layer IS the L0 floor". workpc is not a wg # peer, it is on wlan0. So the tunnel is ssh instead, terminated on this # loopback address, and nothing new is on the LAN. Anyone who could reach # a LAN-bound port here could push audio and hear his facts read back. # SurfaceVoice caps acts at L0; it does not cap reading. # # Host 9100 is Vikunja's MCP, hence 9110. The container side stays 9100 # so mavweb keeps reaching mavend:9100 by name. ports: ["127.0.0.1:9110:9100"] mavsttd: <<: *image command: ["mavsttd", "-socket", "/run/maven/stt.sock", "-model", "/opt/maven/models/stt/ggml-small.bin"] depends_on: [mavend] # whisper uses libggml-vulkan (RADV on the host's AMD GPU) → needs the # render node AND membership in the group that owns it, or Vulkan enumerates # zero devices and falls back to CPU. devices: - "/dev/dri:/dev/dri" group_add: - "993" # host 'render' gid owning /dev/dri/renderD128 (getent group render) volumes: - sockets:/run/maven - ./models:/opt/maven/models:ro mavttsd: <<: *image command: ["mavttsd", "-socket", "/run/maven/tts.sock", "-piper", "/opt/maven/piper/piper", "-model", "/opt/maven/models/tts/ru_RU-irina-medium.onnx", "-espeak_data", "/opt/maven/piper/espeak-ng-data"] depends_on: [mavend] volumes: - sockets:/run/maven - ./models:/opt/maven/models:ro mavweb: <<: *image # voice.bind is 0.0.0.0:9100 in deploy/mavend.json so mavweb can reach it # cross-container. Verified 2026-07-06. # -ambient-enabled turns on POST /api/ambient (Vikunja #126): the phone posts # notification text, mavweb keeps only a meeting time. The explicit enabled # flag prevents an unset token from looking like an intentional dark state. # Both values come from the gitignored .env docker compose reads for # interpolation, NOT from an env_file — flags are interpolated before any # service env exists. # Weakness worth naming: mavweb takes this as a flag, so it is visible in # `ps` inside this container, unlike the zenmoney and IMAP secrets which are # read from files. command: ["mavweb", "-addr", ":9201", "-voice", "mavend:9100", "-core", "/run/maven/mavend.sock", "-ambient-enabled", "${MAVEN_AMBIENT_ENABLED:-false}", "-ambient-token", "${MAVEN_AMBIENT_TOKEN:-}", "-nexus", "http://nexus:9740", "-praxis", "http://praxis:8989", "-hexis", "http://hexis:9741"] depends_on: [mavend] # loopback-only on purpose: /tools defines+executes arbitrary argv and # mavweb has no in-process auth of its own without -webauthn-origin/-rpid. # Reaching the UI therefore requires the wg tunnel (or the local nginx) by # construction, not by convention. The other daemons talk to mavweb over # the compose network, not this published port. ports: ["127.0.0.1:9201:9201"] # ecosystem: reach the siblings by name for the read-only /ecosystem panel; # default: keep resolving mavend:9100 for voice + the IPC socket peers. networks: [default, ecosystem] volumes: - sockets:/run/maven mavpoll: <<: *image network_mode: host command: ["mavpoll", "-socket", "/run/maven/mavend.sock", "-netdata", "http://127.0.0.1:19999", "-kuma", "http://127.0.0.1:3001/metrics", "-kuma-key", "uk5_mavpoll-key"] # Money tracking (Vikunja #125) is OFF: it needs a zenmoney token, # which mavpoll reads from a FILE so it never appears in `ps`, in # this file, or in shell history. To enable, mount the token and # append: "-zenmoney-token-file", "/run/secrets/zenmoney.token" # (optionally "-zenmoney-interval", "1h"). Core never sees the # token — the poller writes facts(kind=env, source=poll:zenmoney) # and mavend only reads those back when he asks. depends_on: [mavend] volumes: - sockets:/run/maven # - ./deploy/zenmoney.token:/run/secrets/zenmoney.token:ro # The mail reader (Vikunja #246) is OFF and commented out: it needs an IMAP # account, and there is none on this box. mavmaild reads the password from a # FILE so it never appears in `ps`, in this file, or in shell history — the # same rule mavpoll follows for the zenmoney token. Core never sees the # password: the reader hands core message text on one IPC method, and core # writes what the model extracts as task CANDIDATES he reviews on /tasks. # Nothing here can create a reminder, so a misread mail cannot fire. # # To enable: write the password to deploy/imap.password (0600, gitignored), # add an "email": {} block to deploy/mavend.json, and uncomment this service. # mavmaild: # <<: *image # command: ["mavmaild", "-socket", "/run/maven/mavend.sock", # "-imap", "imap.example.org:993", # "-user", "kami@example.org", # "-password-file", "/run/secrets/imap.password", # "-mailbox", "INBOX", # "-interval", "15m", # "-state", "/var/lib/mavmaild/mail-seen.json"] # depends_on: [mavend] # volumes: # - sockets:/run/maven # # Its OWN volume, not dbdata. The whole point of a separate reader is # # that a compromise on either side does not reach the other, and dbdata # # is the encrypted database. The reader needs one JSON file of UIDs and # # gets a volume that holds nothing else, so neither can be restored from # # a backup of the other. # - maildata:/var/lib/mavmaild # - ./deploy/imap.password:/run/secrets/imap.password:ro # The calendar reader (Vikunja #644) is OFF and commented out: it needs a # CalDAV account, and there is none on this box. It was built, listed in # `make build`, and deployed nowhere, which is the worst of the three states — # this block records the decision instead. # # What its absence costs, so the cost is visible from here: # - Agenda questions route correctly and answer from nothing. Stage 0 sends # "что у меня сегодня" to IntentQuery (V-498) and the `calendar` query # source reads facts(kind=env, source=caldav:*) that nobody writes. # - The nudge gate loses a suppressor. loop.State.CalendarBusy is fed by # those same facts, so "do not nag mid-meeting" is permanently false. # # Core never sees the CalDAV password: the reader polls the collection itself # and hands core one fact per event over WriteFact. Nothing here can create a # reminder, so a misread event cannot fire. # # The password is read from a FILE, so it never appears in `ps`, in this file, # or in shell history — the same rule mavpoll and mavmaild follow. # # To enable: write the password to deploy/caldav.password (0600, gitignored), # point -url at the collection, and uncomment this service. No mavend.json # block is needed — events arrive over IPC as facts. -render-url is optional # and OFF here: it publishes Maven's own reminders back as events, and it must # not name the collection -url reads, or the poller reads its own writes back # in (checkRenderTarget refuses that). It takes -render-pass-file, and falls # back to this password when that is not given. # mavcaldav: # <<: *image # command: ["mavcaldav", "-socket", "/run/maven/mavend.sock", # "-url", "http://localhost:5232/kami/personal", # "-user", "kami", # "-pass-file", "/run/secrets/caldav.password", # "-interval", "5m"] # depends_on: [mavend] # volumes: # - sockets:/run/maven # - ./deploy/caldav.password:/run/secrets/caldav.password:ro volumes: dbdata: sockets: # maildata — the mail reader's seen-UID file, and nothing else. See mavmaild. maildata: networks: default: ecosystem: external: true # created by deploy/ecosystem/docker-compose.yml name: ecosystem