Skip to main content

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/ and uploads/ sit at the root and hold every tenant's files together, disambiguated by the tenant field 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

FeatureWherePage
Call logs<tenant>/cdr/Call logs
Recordingsrecordings/ at the rootCall recording
Voicemail<tenant>/voicemail/<mailbox>/Voicemail
Do-not-disturb and forwardingfeatures.json at the rootFeature codes
Pending uploadsuploads/ at the rootCall recording

Retention

Retention is applied per artefact, not per tree:

SettingApplied byEffect
cdr.retentiona 24-hour janitorWhole day directories older than the cutoff are removed.
voicemail.retentiona 24-hour janitorMessages older than the cutoff are removed.
recording.retentionnothingValidated and editable, but no janitor applies it. Recordings grow forever; prune them yourself.
recording.retention does nothing today

The 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.