Deployment
August 14, 2026 · View on GitHub
This page is for operators running the phoonnx TTS server as a public or long-lived
service. It covers persisting the voice cache, prefetching voice weights before serving
traffic, health checks, resource sizing and memory budgeting, diagnosing OOM kills,
prosody enrichment behavior, and verifying that mycroft.conf actually applied. For
the image itself and its quick start, see docker.md.
Persist the cache volume
The container's voice index and downloaded voice weights live under
/home/ovos/.cache. Mount that path as a named volume, as the bundled
docker-compose.yml does:
volumes:
- phoonnx-cache:/home/ovos/.cache
Without a persistent volume, every container restart starts from the image's pre-filled voice index but with no voice weights downloaded, so the next request for each voice re-downloads it.
A volume is seeded from the image only when it is first created. An existing volume keeps its own copy of the voice index, so a newer image's index stays masked and prefetch resolves ids against the older catalog. Refresh it after upgrading the image:
docker compose exec phoonnx-tts phoonnx-voices update-cache
A cold cache is slow, not broken
phoonnx-voices update-cache — baked into the image at build time — only fills the
voice index: the catalog of known voice ids, languages and download URLs. It does
not fetch any ONNX weights. A voice's actual model file downloads on first use, the
first time a synth request asks for that voice.
On a public server this looks like a hang: the first caller for a given voice waits on a real network download before hearing anything back. It is not a fault — the fix is to download the voices you plan to serve before routing traffic to the instance.
Prefetch voices before serving traffic
Set PHOONNX_PREFETCH_VOICES to a comma-separated list of voice ids. The container
entrypoint downloads each one — the same weights phoonnx-voices download <voice_id> fetches — before starting the TTS server:
services:
phoonnx-tts:
environment:
PHOONNX_PREFETCH_VOICES: "OpenVoiceOS/phoonnx_pt-PT_miro_tugaphone,OpenVoiceOS/phoonnx_ca_miro_espeak"
Every listed voice must download, and startup aborts if any of them fails. A voice
id names one specific voice; a server that starts without it answers requests for
that voice with an error at synthesis time instead, which turns a missing voice into
a runtime surprise. Failing at startup makes it visible immediately. Check the
container logs for [prefetch] FAILED lines to see which id is wrong.
With a restart policy set, a failing prefetch means the container restarts and
tries again, so a wrong voice id or an unreachable model host produces a restart
loop rather than a running server. That is the intended trade for an explicit
request; remove the id from the list to start without that voice.
Only prefetch the voices mycroft.conf actually configures. Weights for a voice not
in that list still download lazily on its first request.
Health checks
/status answers without touching a voice model, so it is the right endpoint for a
container health check or a load balancer — it is what the bundled compose
healthcheck uses:
curl http://localhost:9666/status
A synthesis request is not a health check. /synthesize/... can block on a cold
voice download and will mark a healthy instance as failed while that download runs.
Resource sizing
Size the container for the voices you actually serve, not the full catalog. Each loaded voice holds an ONNX Runtime session in memory in addition to its weight file on disk; the batteries-included image also carries the full phonemizer and cloning dependency set regardless of which voices you use. Disk needs one voice's weights per prefetched voice, on the same volume as the cache so a restart does not lose them.
A catalog family sharing one underlying model — the omnivoice and qwen3tts
entries, for example — is still one voice-config entry per model artifact: each
cached voice id owns its own session, so loading several voices from the same
family loads several sessions, not one shared one. Weight size, not entry count, is
what dominates memory: a piper voice is on the order of tens of megabytes, an
omnivoice voice on the order of gigabytes.
The shipped image runs ovos-tts-server as a single ASGI app instance passed
directly to uvicorn.run(), with no --workers flag and no process manager — it is
one process, one worker, no fan-out. Memory multiplies with worker count only for
an operator who fronts the plugin's ASGI app with their own multi-worker deployment
(a gunicorn/uvicorn invocation using an import string, e.g.
uvicorn app:server --workers 4) instead of the bundled entrypoint: each worker
process there loads its own PhoonnxTTSPlugin and its own voice cache. This is
distinct from how many clients one worker can serve concurrently, which does not
multiply memory the same way.
Memory budgeting
max_loaded_bytes (see ovos_plugin.md) bounds how
much voice weight the plugin keeps resident, evicting the least-recently-used
unpinned voice to make room for a new one. It is a soft, in-process limit — it has
no visibility into the phonemizer, the ONNX Runtime arena, or per-request buffers,
none of which it counts against the budget.
The container's cgroup memory limit is the real backstop, and it is a hard kill, not
an eviction. Set max_loaded_bytes well below that limit, leaving headroom for the
runtime itself, whichever phonemizer backends are loaded, and in-flight request
buffers. A budget set close to the cgroup limit does not fail safely: instead of
evicting a voice to stay under budget, the process gets OOM-killed by the kernel
mid-request. A budget with real headroom trades occasional cache misses (a voice
reloading from disk) for a server that never gets killed outright.
Diagnosing OOM kills
In the shipped image the server execs as PID 1 — no supervisor, no worker children —
so an OOM kill terminates the container outright, and docker inspect <container> --format '{{.RestartCount}}' under a restart: unless-stopped policy does
increment and is a valid signal there. RestartCount only undercounts in a
deployment where a supervisor or a multi-process server (see the multi-worker note
above) respawns children inside the container without the container itself
restarting — a kill there never touches RestartCount.
Either way, the kernel log is what distinguishes an OOM kill from any other crash or exit code, and it works regardless of process model:
journalctl -k | grep -i "killed process"
or, without journalctl, dmesg | grep -i "killed process". The kernel logs the
killed process by its comm — for the shipped image this is python3.12 (the
interpreter behind the ovos-tts-server console-script shebang), not phoonnx or
ovos-tts-server, so name matching will not find it. Identify the victim by the
memcg/cgroup path in the same OOM report instead — it contains the container id.
Once confirmed, the fix is to lower max_loaded_bytes, raise the container's memory
limit, or both.
Prosody enrichment degrades instead of failing
Voices that enrich text before synthesis — Arabic/Hebrew diacritization
(add_diacritics, diacritizer_model) and per-language script transforms such as
Russian stress marking (stressonnx) — never abort synthesis when that enrichment
fails or its backend is missing. Each falls back to the plain, unenriched text and
logs a warning:
diacritization failed for lang=ar: <error> — synthesizing unstressed text
stressonnx not installed — Russian stress skipped
The voice still speaks; it loses the stress or vocalization cues that make the target script unambiguous. Grep the server logs for these warnings to see whether an optional enrichment dependency is missing rather than assuming a silent voice quality regression is a synthesis failure.
Language codes and enrichment backends
A voice id carries a full regional code (ru_RU, pt-PT, ...), but the enrichment
backends above key off the base language only — ru, not ru_RU or ru-RU. What
determines whether a voice gets stress marking, script transforms, or diacritization
is whether its base language has an entry, not the region. When configuring or
auditing enrichment for a voice, check the base language rather than the full
regional code.
Verify the configuration actually applied
The plugin reads tts.ovos-tts-plugin-phoonnx from mycroft.conf. Configuration
placed under any other key, or under the wrong module name, is silently ignored — the
server runs on the default voice and appears configured:
{
"tts": {
"module": "ovos-tts-plugin-phoonnx",
"ovos-tts-plugin-phoonnx": {
"voice": "OpenVoiceOS/phoonnx_pt-PT_miro_tugaphone"
}
}
}
Prove it applied by synthesizing and listening to (or inspecting) the result, rather than trusting the mounted file:
curl "http://localhost:9666/synthesize/hello%20world" --output hello.wav
If the configured voice was never prefetched, this first call also pays the cold download cost described above — expected, and separate from whether the config applied.