The data directory
Configuration is read once and never written back. Everything the PBX produces
— call logs, recordings, voicemail, and the runtime state behind do-not-disturb
and forwarding — goes to a separate writable tree named by dataDir.
configVersion: lyno/v1
kind: System
spec:
dataDir: /var/lib/lyno
There is no default. Without dataDir there is no data plane at all: no
call log, no recorder, no upload queue. That is a valid way to run Lyno, and it
is what the shipped example configuration does.
When it is required
Validation refuses a configuration that has nowhere to write:
dataDir is required: call logs, recordings or voicemail are enabled and there is nowhere to write them
That fires when cdr.enabled, recording.enabled or voicemail.enabled is on
anywhere — globally or on a tenant — or when any tenant declares mailboxes.
Feature codes are the exception. dnd and forward steps work without a
dataDir; their state simply lives in memory and does not outlive the process.
The PBX says so at startup rather than refusing to run:
feature codes are configured but dataDir is not:
do-not-disturb and call forwarding will not survive a restart
Webhooks are the other exception, and a sharper
one. Their queue is durable, so without a dataDir there is nowhere to put it
— but validation does not refuse the configuration. The PBX logs at error
level and carries on serving calls:
webhooks are configured but there is no data_dir to queue them under,
they will not be sent
Nothing is delivered and nothing is kept for later, so a receiver that is merely quiet looks exactly like one that is broken. Check this line first.
The path rule does not apply here
dataDir is the one path that is written back exactly as you typed it.
Every other path in the configuration is made absolute on load and relative
again on save; doing that to /var/lib/lyno would turn it into
../../../var/lib/lyno, which breaks the moment the configuration file moves.
A relative dataDir still resolves against the directory holding lyno.yaml,
like everything else. An absolute one is the sane choice.
Layout
/var/lib/lyno/
features.json do-not-disturb and forwarding, all tenants
tmp/ scratch, confined to this tree
recordings/inflight/<id>.wav a file here is the mark of a crash
recordings/2026/07/26/<id>.wav plus <id>.json beside it
uploads/pending/<jobid>.json the object-store queue
uploads/failed/<jobid>.json jobs given up on, never discarded
webhooks/pending/<jobid>.json the webhook delivery queue
webhooks/failed/<jobid>.json deliveries given up on
acme/
cdr/2026/07/26/<id>.json one record per call
cdr/2026/07/26/index.jsonl the day's index, for listing
cdr/inflight/<id>.json a call still in progress
voicemail/100/<id>.wav plus <id>.json beside it
Two things about that tree are easy to get wrong:
- Call logs and voicemail are per tenant; recordings and the upload queue are
not.
recordings/anduploads/sit at the root and hold every tenant's files together, disambiguated by thetenantfield inside each sidecar. There is one recorder shared by the whole dialplan, and it has one root. - The dates mean different things. A recording's day directory is UTC. A call log's day directory is the tenant's local date, so asking a Dutch operator for "yesterday" does not hand them a day that splits at 02:00.
A tenant's subdirectory is named after the tenant. A tenant can take a directory of its own instead:
kind: Tenant
metadata: { name: acme }
spec:
domains: [acme.pbx.example.com]
dataDir: /srv/acme-recordings
Like the global one it is written back exactly as typed.
Permissions and durability
Directories are created 0700 and files 0600. A recording is a conversation
and a call log is a record of who called whom; neither is world-readable by
accident.
Every write is atomic: a temporary file, an fsync, a rename, an fsync of the parent directory. A crash therefore leaves either the old file or the new one, never half of either.
The tree is opened with openat and every path is resolved inside it, so
nothing written here can escape the root — including a hostile SIP Call-ID.
Nothing is ever named after something the network chose. A Call-ID is a
field inside a sidecar, never a path element.
TMPDIR is repointed at <dataDir>/tmp for the process, because the media
library otherwise scatters raw conversation audio through the system temporary
directory.
Give it its own mount
The configuration mount is read-only in the shipped container and should stay that way. Recordings are the one thing here that grows without bound, so putting them on a separate volume is a size decision as much as a privacy one.
Startup probes the directory and fails with an error naming the uid that could
not write to it. Validation deliberately does not check the filesystem:
-validate has to pass in CI and against a read-only container mount, neither
of which has the volume.
What lands where
| Feature | Where | Page |
|---|---|---|
| Call logs | <tenant>/cdr/ | Call logs |
| Recordings | recordings/ at the root | Call recording |
| Voicemail | <tenant>/voicemail/<mailbox>/ | Voicemail |
| Do-not-disturb and forwarding | features.json at the root | Feature codes |
| Pending uploads | uploads/ at the root | Call recording |
Retention
Retention is applied per artefact, not per tree:
| Setting | Applied by | Effect |
|---|---|---|
cdr.retention | a 24-hour janitor | Whole day directories older than the cutoff are removed. |
voicemail.retention | a 24-hour janitor | Messages older than the cutoff are removed. |
recording.retention | nothing | Validated and editable, but no janitor applies it. Recordings grow forever; prune them yourself. |
recording.retention does nothing todayThe key is accepted and validated, and the editor offers it, but there is no
recording janitor in the code. Treat recording growth as your problem until
that changes — a cron job over recordings/ by directory name is the same
operation the CDR janitor performs.