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. This page covers 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.

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