Skip to content

Running OVOS in Containers

In a nutshell

ovos-docker publishes one container image per OVOS service, plus reference docker-compose.yml files that wire them together. Every compose service uses network_mode: host, so containers reach each other and the bus over the host's own loopback and network interfaces, not a private bridge network. The sections below cover the compose layout, audio device passthrough, networking, and the headless-server pattern for STT/TTS.

What ovos-docker ships

ovos-docker builds one image per service, published under docker.io/smartgic:

  • ovos-messagebus, ovos-core, ovos-audio, ovos-listener, ovos-cli
  • ovos-phal, ovos-phal-admin
  • ovos-gui-websocket
  • ovos-skill-base and default skill images, e.g. ovos-skill-ggwave

There is no single "OVOS in one container" image. Each image installs one Python package and runs one process. This mirrors the split described in Composable Deployments: scale, restart, or relocate any one service without touching the rest.

ovos-docker builds its images with Docker Buildx Bake for linux/amd64 and linux/arm64 (the hardcoded platforms list in docker-bake.hcl's common target), which covers a 64-bit Raspberry Pi OS install. Check the actual tags on Docker Hub for the image and version you plan to run before deploying to a Pi: not every image or channel is guaranteed to carry both architectures.

A working compose file

This is a trimmed version of the reference compose/docker-compose.yml. It covers the messagebus, core, listener, and audio services, with the config, tmp, and IPC volume mounts the upstream file uses.

docker-compose.yml
services:
  ovos_messagebus:
    image: docker.io/smartgic/ovos-messagebus:${VERSION}
    network_mode: host
    volumes:
      - ${OVOS_CONFIG_FOLDER}:/home/${OVOS_USER}/.config/mycroft:ro
      - ovos_local_state:/home/${OVOS_USER}/.local/state/mycroft
      - ${TMP_FOLDER}:/tmp/mycroft

  ovos_listener:
    image: docker.io/smartgic/ovos-listener:${VERSION}
    network_mode: host
    devices:
      - /dev/snd
    volumes:
      - ~/.config/pulse/cookie:/home/${OVOS_USER}/.config/pulse/cookie:ro
      - ${OVOS_CONFIG_FOLDER}:/home/${OVOS_USER}/.config/mycroft:ro
      - ovos_local_state:/home/${OVOS_USER}/.local/state/mycroft
      - ${TMP_FOLDER}:/tmp/mycroft
      - ${XDG_RUNTIME_DIR}/pulse:${XDG_RUNTIME_DIR}/pulse:ro
    depends_on:
      - ovos_messagebus

  ovos_audio:
    image: docker.io/smartgic/ovos-audio:${VERSION}
    network_mode: host
    devices:
      - /dev/snd
    volumes:
      - ~/.config/pulse/cookie:/home/${OVOS_USER}/.config/pulse/cookie:ro
      - ${OVOS_CONFIG_FOLDER}:/home/${OVOS_USER}/.config/mycroft
      - ovos_tts_cache:/home/${OVOS_USER}/.cache/mycroft
      - ${TMP_FOLDER}:/tmp/mycroft
      - ${XDG_RUNTIME_DIR}/pulse:${XDG_RUNTIME_DIR}/pulse:ro
    depends_on:
      - ovos_messagebus

  ovos_core:
    image: docker.io/smartgic/ovos-core:${VERSION}
    network_mode: host
    volumes:
      - ${OVOS_CONFIG_FOLDER}:/home/${OVOS_USER}/.config/mycroft
      - ovos_local_state:/home/${OVOS_USER}/.local/state/mycroft
      - ${TMP_FOLDER}:/tmp/mycroft
    depends_on:
      - ovos_messagebus

volumes:
  ovos_local_state:
  ovos_tts_cache:

OVOS_CONFIG_FOLDER mounts the host's mycroft.conf directory into every container's ~/.config/mycroft. Every service reads the same file this way, so a single edit on the host reaches all of them. TMP_FOLDER mounts a shared host directory to /tmp/mycroft in every container: this is how the listener hands recorded audio to the rest of the pipeline. Named volumes (ovos_local_state, ovos_tts_cache, and in the full reference file also ovos_models, ovos_vosk, ovos_listener_records, ovos_nltk) hold anything that must survive a container rebuild: downloaded models, the TTS cache, and local runtime state. Persona definitions are different: the full reference file bind-mounts a host directory (${OVOS_PERSONA_FOLDER}) rather than using a named volume, so that data persists only as the host path you manage yourself.

The full reference file also runs ovos_phal and ovos_phal_admin with privileged: true and cap_add: [SYS_ADMIN, DAC_OVERRIDE], plus /dev and /sys mounts, for hardware access (LEDs, buttons, power control). Add those only for the services that need direct hardware access.

Audio device passthrough

The listener and audio containers both need /dev/snd passed through with devices: ["/dev/snd"], plus access to the host's sound server. Upstream mounts PulseAudio's or PipeWire's runtime socket read-only:

volumes:
  - ${XDG_RUNTIME_DIR}/pulse:${XDG_RUNTIME_DIR}/pulse:ro
  - ${XDG_RUNTIME_DIR}/pipewire-0:${XDG_RUNTIME_DIR}/pipewire-0:ro
  - ~/.config/pulse/cookie:/home/${OVOS_USER}/.config/pulse/cookie:ro
environment:
  PULSE_SERVER: unix:${XDG_RUNTIME_DIR}/pulse/native
  PULSE_COOKIE: /home/${OVOS_USER}/.config/pulse/cookie

Without the PulseAudio cookie, the containers cannot authenticate against the host's sound server even with the socket mounted. ovos-docker ships separate compose overlays for macOS and Windows (docker-compose.macos.yml, docker-compose.windows.yml), because neither host exposes a native PulseAudio/PipeWire socket the same way Linux does.

Networking

Every service in the reference compose files runs with network_mode: host. There is no private bridge network and no inter-container DNS: a container reaches the bus at localhost:8181, same as a bare-metal process would.

Host networking shares the loopback across every container

With network_mode: host, 127.0.0.1 inside a container is the host's loopback, not a container-private one. Any process on that host, containerized or not, can reach a bus bound to 127.0.0.1. Treat the whole host as the trust boundary, not the individual container. See Satellites for the same warning applied to a thin-client fleet.

The bus listens on port 8181. The GUI service listens on a separate port, 18181 by default (config key gui_websocket.base_port, see Bus Service). Both the bus and the GUI socket ship bound to 127.0.0.1. Widening gui_websocket.host to 0.0.0.0 (required for remote display clients) combined with host networking puts the unauthenticated GUI socket on the whole LAN, not just the device — keep the default unless a remote display client needs it, and check upgraded configs that may carry an old 0.0.0.0 value.

If you ever run these images with a non-host network driver instead (a private bridge network), point websocket.host in each container's mycroft.conf at the messagebus container's name or hostname, the way any other split-host OVOS deployment does (see Composable Deployments). The upstream compose files do not do this: they rely on host networking and localhost throughout.

Server/satellite split

For the one-brain-many-speakers pattern described in Satellites, split the compose file above into a server stack and a satellite stack. The server runs the messagebus and ovos-core (plus skills); each satellite runs only a listener and an audio service, with PHAL added only if the device needs direct hardware access. Both stacks use the same images and volume layout as the reference compose file; only which services run where changes.

docker-compose.yml — server
services:
  ovos_messagebus:
    image: docker.io/smartgic/ovos-messagebus:${VERSION}
    network_mode: host
    volumes:
      - ${OVOS_CONFIG_FOLDER}:/home/${OVOS_USER}/.config/mycroft:ro
      - ovos_local_state:/home/${OVOS_USER}/.local/state/mycroft
      - ${TMP_FOLDER}:/tmp/mycroft

  ovos_core:
    image: docker.io/smartgic/ovos-core:${VERSION}
    network_mode: host
    volumes:
      - ${OVOS_CONFIG_FOLDER}:/home/${OVOS_USER}/.config/mycroft
      - ovos_local_state:/home/${OVOS_USER}/.local/state/mycroft
      - ${TMP_FOLDER}:/tmp/mycroft
    depends_on:
      - ovos_messagebus

volumes:
  ovos_local_state:
docker-compose.yml — satellite (per device)
services:
  ovos_listener:
    image: docker.io/smartgic/ovos-listener:${VERSION}
    network_mode: host
    devices:
      - /dev/snd
    volumes:
      - ~/.config/pulse/cookie:/home/${OVOS_USER}/.config/pulse/cookie:ro
      - ${OVOS_CONFIG_FOLDER}:/home/${OVOS_USER}/.config/mycroft:ro
      - ${TMP_FOLDER}:/tmp/mycroft
      - ${XDG_RUNTIME_DIR}/pulse:${XDG_RUNTIME_DIR}/pulse:ro

  ovos_audio:
    image: docker.io/smartgic/ovos-audio:${VERSION}
    network_mode: host
    devices:
      - /dev/snd
    volumes:
      - ~/.config/pulse/cookie:/home/${OVOS_USER}/.config/pulse/cookie:ro
      - ${OVOS_CONFIG_FOLDER}:/home/${OVOS_USER}/.config/mycroft
      - ovos_tts_cache:/home/${OVOS_USER}/.cache/mycroft
      - ${TMP_FOLDER}:/tmp/mycroft
      - ${XDG_RUNTIME_DIR}/pulse:${XDG_RUNTIME_DIR}/pulse:ro

  # ovos_phal:
  #   image: docker.io/smartgic/ovos-phal:${VERSION}
  #   network_mode: host
  #   # add only if this satellite needs direct hardware access (LEDs, buttons, power)

volumes:
  ovos_tts_cache:

Point each satellite's mounted mycroft.conf at the server, since network_mode: host puts every container on the satellite behind the satellite host's own IP, not a container name:

{
  "websocket": {
    "host": "192.168.1.10",
    "port": 8181,
    "route": "/core"
  }
}

Replace 192.168.1.10 with the server host's real LAN address. With network_mode: host this must be a real IP or hostname, never a container or service name: host networking has no inter-container DNS (see Networking above). See Satellites: Satellite config for the same setting applied to a bare-metal deployment, and the warnings there about the shared-session and localhost-default pitfalls.

Headless: STT/TTS servers as containers

Two sibling repositories, ovos-docker-stt and ovos-docker-tts, build standalone STT and TTS server images, one per baked-in engine: ovos-stt-server-onnx-asr, ovos-stt-server-onnx-asr-cuda, ovos-tts-server-piper, ovos-tts-server-kokoro, ovos-tts-server-phoonnx, and others. These serve plain HTTP and have no bus dependency, so they work as a shared backend for bare-metal or thin-client satellites that only run the bus, listener, and audio locally. See Self-hosted STT Server and Self-hosted TTS Server for the HTTP contract and plugin-side config keys, and Satellites: thin clients + a shared speech backend for a worked compose example splitting the speech backend from the thin client.

Memory limits and OOM kills

Two traps when you cap a speech container's memory (mem_limit / deploy.resources):

  • Size the limit above any application cache budget, with real headroom. A model or voice cache setting bounds what the application tries to keep, not what the process actually holds; the only hard ceiling is the cgroup limit, which the kernel enforces by killing the process. If a service is being OOM-killed, raising its cache budget keeps more resident and makes the kills more frequent, not less.
  • docker inspect cannot tell you it happened. State.ExitCode, State.OOMKilled, and docker ps all describe the currently running instance, so they read 0/false/Up no matter how many earlier instances died, and RestartCount misses worker-process kills inside the container entirely. The truthful instrument is the kernel log on the host:
journalctl -k | grep -iE "oom-kill|Killed process"

A container that "keeps getting slow" while showing Up in docker ps is the classic presentation of this failure mode.

Speech on the device

The listener and audio images can run speech recognition and the voice on the device, with no STT or TTS server.

Alpha channel only

Only the alpha listener image installs ovos-stt-plugin-onnx-asr. That plugin needs ovos-plugin-manager 2 or later, so the stable image skips it.

The compose files declare two volumes for the models, so an image update does not download them again:

Volume Service Path in the container
ovos_stt_models listener ~/.local/share/ovos_stt_plugin_onnxasr
ovos_tts_models audio ~/.cache/phoonnx

The recognition model needs more memory than the standard limit. Set the limit for each service with these variables. Each one falls back to STANDARD_MEMORY_LIMIT when you do not set it:

Variable Service
LISTENER_MEMORY_LIMIT listener
AUDIO_MEMORY_LIMIT audio

When you choose local speech with the containers method, the ovos-installer sets LISTENER_MEMORY_LIMIT for you.

Containerized HiveMind

ovos-docker does not build or push a HiveMind image itself, but its own compose/docker-compose.hivemind.yml already ships a ready-made stack: hivemind_listener and hivemind_cli services (pulling the docker.io/smartgic images) wired to the ovos_core dependency chain — start there before reaching for the separate project. The images themselves come from HiveMind's own project, HiveMind-Docker, with its own images (published under docker.io/smartgic, built with Docker Buildx Bake for linux/amd64 and linux/arm64) and compose files for a HiveMind hub and its satellite/listener/chatroom components. See Remote Agents with HiveMind for the pip-installed, bare-metal path this manual documents in detail; check the HiveMind-Docker repository directly for current image tags and compose usage.

Limitations

  • Wake word and mic latency. The listener still runs continuous audio capture and wake word detection inside the container. Passing /dev/snd and the sound-server socket through adds a layer compared to a bare-metal process; on constrained hardware, measure wake word latency before committing to a containerized listener rather than assuming it matches bare metal.
  • GPU access. ovos-stt-server-onnx-asr-cuda exists as a named image, which implies GPU passthrough for that variant, but the upstream README and compose files documented here do not spell out the docker run --gpus or NVIDIA Container Toolkit setup needed to use it. Treat GPU passthrough as unverified until you check the image's own Dockerfile or open an issue against ovos-docker for the missing steps.
  • Privileged containers. PHAL and PHAL-admin need privileged: true for hardware access (LEDs, buttons, power). That is a real, not cosmetic, trust boundary: a privileged container has the same access to the host as a root process would.

Read next: Production Operations Related: Composable Deployments · Self-hosted STT Server · Self-hosted TTS Server