Deploying SignalDB on TrueNAS SCALE¶
SignalDB runs well as a TrueNAS SCALE custom app — a Docker Compose
project managed by the TrueNAS middleware. This page describes the setup the
maintainers run on their own NAS; the exact compose file is in the repo at
deploy/truenas/signaldb-app.yaml
with secrets and hostnames replaced by placeholders.
The app is three services:
| Service | Image | Purpose |
|---|---|---|
signaldb |
ghcr.io/cedricziel/signaldb:main (or :main-glibc-profiling) |
The monolith: OTLP ingest, storage, query API, Explore UI, compactor |
mcp |
ghcr.io/cedricziel/signaldb/mcp:main |
MCP server sidecar; reaches the monolith at http://signaldb:3000 on the app network |
otelcol |
otel/opentelemetry-collector-contrib |
Receives the monolith's self-monitoring telemetry, scrapes host/process/container metrics for the SignalDB containers, and writes it all back into the _system/_monitoring tenant |
Only signaldb is required. Drop mcp if you don't use MCP, and otelcol if
you don't want self-monitoring — in that case point
SIGNALDB__SELF_MONITORING__ENDPOINT at the monolith itself or leave
self-monitoring off.
Prerequisites¶
- A dataset for the app data, e.g.
tank/apps/signaldb, owned by uid/gid 1000 (the images run as1000:1000). It ends up mounted at/dataand holdssignaldb.toml, the SQLite catalogs, the WAL and the Parquet storage. - A
signaldb.tomlin that dataset. Start fromsignaldb.dist.toml; the minimum for a homelab is[auth]with one tenant and an API key, plus[self_monitoring] enabled = trueif you want the dogfooding data. The monolith usesworking_dir: /data, so it picks up./signaldb.tomlautomatically and mergesSIGNALDB__<SECTION>__<KEY>environment variables over it — anything in the composeenvironment:block overrides the file. - Free host ports. The example uses
4317/4318(OTLP),30200(query API - UI) and
30228(MCP); TrueNAS prefers high ports for non-well-known services, and3000/3100are usually taken by other apps.
Install¶
In the TrueNAS UI: Apps → Discover Apps → ⋮ → Install via YAML, name it
signaldb, paste the compose file. Or from a shell on the NAS:
midclt call --job app.create '{
"custom_app": true,
"app_name": "signaldb",
"custom_compose_config_string": "<contents of signaldb-app.yaml as one JSON string>"
}'
Before pasting, replace:
sk-REPLACE-system-ingest-key— an ingest-only API key valid for the_systemtenant. It appears three times: the browser telemetry key served to the UI (world-readable — never use an admin key), and twice in the collector config for the scraped host/container metrics.o11y.example.com,o11y-mcp.example.com,o11y-ingest.example.com— your public hostnames for the UI, the MCP endpoint and the browser OTLP endpoint. If nothing is exposed publicly, drop theFRONTEND__*andMCP__OAUTH__*variables and setSIGNALDB__MCP__ALLOWED_HOSTSto<lan-ip>:30228only./mnt/tank/apps/signaldb— your dataset path.
Once running, the Explore UI is at http://<nas>:30200/ui/; log in with a
tenant id and API key from your signaldb.toml.
Updating¶
The compose tracks moving tags (:main, mcp:main). Two middleware
behaviours matter here:
app.redeploydoes not re-pull a moving tag — it recreates the containers from the locally cached image. Useapp.pull_imageswithredeploy: trueinstead; it pulls every image in the app and then redeploys:
midclt call --job app.pull_images signaldb '{"redeploy": true}'
app.updateexpects the full compose config, not a patch. To change one environment variable, fetch the current config withmidclt call app.config signaldb, edit it, and send the whole thing back ascustom_compose_config_string. Editing the files under/mnt/.ix-apps/app_configs/directly is not supported.
Confirm a deploy landed by digest, not by the image's created timestamp
(reproducible builds make that field misleading):
# on the NAS
midclt call app.image.query | jq -r '.[] | select(.repo_tags[]? | test("cedricziel/signaldb")) | "\(.repo_tags[0]) \(.repo_digests[0])"'
# anywhere with docker
docker buildx imagetools inspect ghcr.io/cedricziel/signaldb:main --format '{{.Manifest.Digest}}'
For a production install pin release tags (ghcr.io/cedricziel/signaldb:0.3.0)
and bump them in the compose instead of pulling :main.
Notes on the example¶
- Heap profiling.
main-glibc-profilingis the jemalloc build needed forSIGNALDB__SELF_MONITORING__HEAP_PROFILES_ENABLED; it also needsMALLOC_CONF=prof:true. With the standard:mainimage, drop both and keep CPU profiling (PROFILES_ENABLED) only. See Binaries. otelcolruns privileged-ish.pid: service:signaldb+SYS_PTRACElet itshostmetrics/processscraper see thesignaldbprocess, and the read-only Docker socket feedsdocker_stats; thefilter/docker_scopeprocessor limits that to this app's containers (TrueNAS names themix-<app>-<service>-N). Remove those three settings if you only want the OTLP pass-through pipelines.- Auth headers through the collector. The monolith's own exports carry
Authorization/X-Tenant-ID/X-Dataset-ID; theheaders_setterextension forwards them from the request context, so no tenant is hard-coded on that path. Only the scraped metrics (no caller) use the static headers. - MCP host guard. The MCP server rejects non-loopback
Hostheaders unless listed inSIGNALDB__MCP__ALLOWED_HOSTS; a reverse proxy that forwards the originalHostneeds the public hostname there, LAN clients need<ip>:<port>. - Memory.
mem_limit: 6gpairs with[querier] memory_limit_mb = 4096insignaldb.toml; scale both together.
Demo instance¶
deploy/truenas/signaldb-demo-app.yaml
is a separate, self-contained app for a public demo: a SignalDB monolith fed
by a trimmed OpenTelemetry Demo
(frontend, cart + Valkey, product catalog, currency, recommendation, ad,
checkout, payment, shipping, quote, email, flagd with every flag off, and the
Locust load generator — no Kafka or Envoy). The flag definitions and the product
list, which upstream mounts from its source tree, are inlined as compose
configs:. The collector drops the spans from the Node services' startup probes of
the AWS/GCP metadata endpoints, which always fail off-cloud. All demo telemetry lands in tenant demo, dataset
otel-demo.
signaldb.tomlis inlined through a composeconfigs:entry, so the data dataset only needs to exist and be owned by uid/gid 1000.- Only the UI/query API is published (
30210); OTLP stays on the app network. Point a reverse proxy (the maintainers use a Pangolin resource) at<nas>:30210and setSIGNALDB__PUBLIC__API_URLto its public URL. - Visitors sign in to the Explore UI as
demo@example.com/demo(see demo-mode.md) rather than using an API key: the account is provisioned as a tenant Viewer, and a middleware refuses every write it might otherwise be able to make, so it's safe to publish. Thesk-demo-*key insignaldb.tomlstill has full tenant write access for the OTel-Demo services themselves — keep that one out of the UI.