Part 14 — the last — of The Operator’s Cookbook, a 14-part series of
complete, copy-paste GopherTrunk builds — one working rig per part, antenna to
browser. Thirteen recipes ago the rig was one $40 dongle and four config
blocks; along the way it learned trunked DMR and TETRA, analog and tone-out,
grew remote radios and a second antenna, started streaming, archiving,
running headless, and calling everything by name. This closing part is the
map: one kitchen-sink config.yaml walked top to bottom, every block stamped
with the part that owns it, plus the decision table that turns “what do I
want?” into “which part do I read?”.
TL;DR: A full GopherTrunk config is about ten top-level sections, and the cookbook covered each where it mattered:
sdr(Parts 1–2, 7–8, 12),trunking.systems(Parts 1–5),scanner/tone_out(Part 6),recordings/retention/baseband(Part 10),broadcast(Part 9),api/web(Parts 1, 11),storage+ alias files (Part 13). Most keys should stay at their defaults — the authoritative, commented reference isconfig.example.yaml, withinternal/config/config.gobehind it. The golden rules: paths resolve relative to the folder containing config.yaml,gainis in tenths of a dB, secrets go intoken_fileand environment variables, and every change is one knob at a time with the log watched.
Key takeaways
- The config is organized by subsystem; your goals aren’t. That’s what this series was for — the decision table below maps intent to recipe, and the annotated config maps each block back to its part.
- Defaults are load-bearing. Per-protocol FEC is on without any YAML;
timeouts, hangtime and the DSP knobs ship at values earned on real
captures. The
*_mode: "off"opt-outs exist for pre-stripped test fixtures, not for tuning. config.example.yamlis the source of truth. Every key in this series exists there with a comment; if a blog post, forum tip or old gist disagrees with it, the example file wins.- A config is maintainable when every line is explainable. The starter rig had three numbers you chose. Keep that property as it grows: know which part of this series justifies each block you’ve added.
Cheat sheet
| Config section | What it owns | Cookbook part |
|---|---|---|
sdr |
dongles, roles, wideband channels, remote radios, diversity | 1, 7, 8, 12 |
trunking.systems |
protocols, control channels, band plans, per-system policy | 1–5 |
scanner, tone_out |
conventional channels, scan modes, paging tones | 6, 7 |
recordings, retention, baseband |
formats, FLAC, sweeper, IQ captures | 10 |
broadcast |
Broadcastify, Rdio Scanner, OpenMHz, Icecast, webhooks | 9 |
api, web, audio |
HTTP/auth posture, tabs, live audio | 1, 11 |
storage + alias files |
call log, labels, talkgroup/RID names | 10, 13 |
In this post
- The map — the kitchen-sink config, block by block, part by part.
- The decision table — what do you want? → which part.
- Knobs that matter, defaults to leave alone — where tuning helps and where it hurts.
- Config hygiene — paths, units, secrets, and the web’s write mode.
- Where to go from here — the series wrap, and what to read next.
The map
Here is a rig that has absorbed all thirteen recipes, abridged to the keys
that carry weight. Every key exists in config.example.yaml; every ← Part
note is a link target in this series:
log:
level: info # debug adds per-call audio-quality lines
storage:
path: "../data/calls.db" # call log + labels ← Parts 10, 13
recordings:
dir: "../recordings"
format: flac # lossless, ~half of WAV ← Part 10
mbe_files: false # DSD-FME sidecars ← Part 10
enhance:
enabled: false # faithful by default; OP25-ish when on
retention:
call_log_days: 30 # the sweeper ← Part 10
files_days: 14
interval: "1h"
sdr:
sample_rate: 2_400_000
autotune: false # ppm suggester ← Part 1
devices:
- serial: "00000001" # the original $40 stick ← Part 1
role: wideband
gain: "auto" # TENTHS of a dB if fixed
center_freq_hz: 858_000_000
voice_taps: 2
channels:
- frequency_hz: 857_262_500
system: "Metro-P25"
- serial: "00000002" # spill-over voice ← Part 7
role: voice
gain: "auto"
rtl_tcp: [] # radios far away ← Part 8
soapy_remote: [] # USRP-class + diversity ← Parts 8, 12
trunking:
voice_hangtime_ms: 3500 # leave alone (see below)
systems:
- name: "Metro-P25"
protocol: p25 # ← Part 1
control_channels: [857_262_500, 858_487_500]
talkgroup_file: "../config/talkgroups-p25.csv" # ← Part 13
rid_alias_file: "../config/rids-p25.csv" # ← Part 13
- name: "Regional-DMR"
protocol: dmr # Tier III + band plan ← Part 2
control_channels: [851_037_500]
# dmr_band_plan omitted → learned off the air
scanner:
scan_mode: all # all | list ← Part 7
conventional: [] # analog FM channels ← Part 6
tone_out:
profiles: [] # two-tone fire paging ← Part 6
broadcast: # outbound feeds ← Part 9
min_duration_ms: 0
# broadcastify: / rdioscanner: / openmhz: / icecast: / webhook:
baseband: # IQ capture & replay ← Part 10
# record: / replay: / auto_record:
audio:
enabled: false # live speakers, off headless
api:
http_addr: "127.0.0.1:8080" # ← Part 1
auth:
mode: "auto" # LAN posture ← Part 11
# token_file: "../config/api-token"
metrics:
enabled: true # /metrics for the watchdogs ← Part 11
Two structural observations that only show up at this altitude. First, the
config has a radio half and an output half — sdr + trunking decide
what gets decoded; recordings/broadcast/api decide where it goes — and
they scale independently, which is why Part 7’s many-systems build and Part
9’s streaming build never stepped on each other. Second, systems are the
join point: a channels: entry on a dongle, a system: filter on a feed,
and an alias file all reference trunking.systems[].name — keep those names
stable, because half the config points at them.
The decision table
The series was organized by goal; here is the whole thing as a lookup:
| What do you want? | Read | The load-bearing keys |
|---|---|---|
| Hear a local P25 system for ~$40 | Part 1 | role: wideband, voice_taps, control_channels |
| Trunked DMR (Tier III) | Part 2 | protocol: dmr, dmr_band_plan (or let it learn) |
| Two conversations off one DMR repeater | Part 3 | protocol: dmr-tier2, dmr_interleaved_voice |
| TETRA, infrastructure or direct mode | Part 4 / Part 5 | protocol: tetra / tetra-dmo, tetra_mcc/tetra_mnc |
| Analog FM, marine, fire tone-out | Part 6 | scanner.conventional, tone_out.profiles |
| Several systems on one box | Part 7 | device roles, scan_mode, priorities |
| Antenna far from the decoder | Part 8 | rtl_tcp, soapy_remote, ka9q_radio |
| Feed Broadcastify / Rdio / OpenMHz | Part 9 | broadcast.* backends |
| Keep everything, forever, small | Part 10 | recordings.format: flac, retention |
| Run 24/7 in a closet | Part 11 | api.auth, metrics, systemd/Docker |
| A second antenna against fading | Part 12 | diversity: mrc, antenna:, diversity_capture |
| Names instead of numbers | Part 13 | talkgroup_file, rid_alias_file, labels |
Knobs that matter, defaults to leave alone
Worth setting deliberately: the handful of numbers each recipe made you
choose. Device serials, center_freq_hz and sample_rate (your spectrum),
gain (staged, in tenths, per
The Analog Edge),
control_channels (all the alternates), voice_taps (your concurrency),
the storage/recordings paths and retention windows (your disk), and
api.auth.mode the moment the daemon binds off loopback.
Leave alone until a symptom names them:
- The FEC opt-outs (
motorola_bch_mode,tetra_channel_coding,p25_phase2_trellis_mode, …). Per-protocol FEC is on by default with no YAML at all; these keys exist for pre-stripped capture fixtures. Turning one “off” on a live system just breaks decode. call_timeout_ms/voice_hangtime_ms— 30 s and 3.5 s were chosen against real systems; shorten them only for a documented reason (call assembly).recordings.equalizer/enhanceinternals — the corners and targets mirror OP25-class defaults; toggleenhance.enabled, don’t sculpt filters before listening.watchdog_interval_ms,tuner_strategy: auto,cc_huntbackoffs — operational machinery with field-tested defaults.- Demod mode overrides (
p25_phase1_demod_mode: cqpskand friends) — empirical switches, not preferences. The config file itself warns at length: don’t set CQPSK because a site is “simulcast”; set it only when a strong, clean signal won’t lock in C4FM.
Config hygiene
Habits that kept every build in this series debuggable:
config.example.yamlis the reference, not the internet. Every accepted key lives there with a comment, andinternal/config/config.gois the authoritative list behind it. When in doubt, open the example file — it ships beside the binary.- Paths are relative to the config file’s folder.
../recordingslands beside the config directory, not your shell’s cwd — the number-one moved-rig surprise. Absolute paths and environment variables are taken as-is. - Units bite twice.
gainis tenths of a dB ("496"= 49.6 dB); frequencies are Hz with_separators encouraged. When translating from SDRTrunk/OP25 numbers, convert deliberately. - Secrets stay out of the file.
api.auth.token_filebeats an inline token (and re-reads on every request, so you can rotate without a restart); broadcast API keys make a config unshareable, so strip them before pasting a config into an issue. - The web can edit the file for you. Settings changes go through
PATCH /api/v1/settings, which writes back toconfig.yamlpreserving comments and formatting and hot-applies what it can — the write-mode and reflect-driven form deep dives show how. For building a config from scratch,gophertrunk configlaunches the Config Builder in the terminal (config servefor the web version). - One knob at a time, log watched. The Part 1 rule scales all the way up: when a change misbehaves, the diff is the suspect list.
Where to go from here
The cookbook told you what to do; the rest of this blog is why it works. Three natural next reads:
- Operations depth — hardening, TLS, metrics, watchdogs, staying up: the Running It For Real series is the production companion to Parts 9–11.
- One protocol, all the way down — the new P25 End to End series follows the Part 1 rig’s protocol from C4FM symbols to recorded IMBE, the deep-dive twin of this series’ starter recipe.
- How the sausage is made — From Spec to Shipping is the methodology series: how a protocol document becomes tested decode code, including the verification discipline this cookbook kept citing.
And when a rig misbehaves in ways no troubleshooting table here covered: The Hunt for discovery, The Analog Edge for everything before the ADC. The decoder can only be as good as the samples — that rule opened this series, and it’s still the best first question.
FAQ
Is there one config file I can copy for everything? Deliberately not — a kitchen-sink config running paging, ADS-B, six systems and four feeds on hardware that doesn’t exist would just fail validation. Start from Part 1’s four blocks, then add sections from the part that matches your next goal; the map above is the merge guide.
How do I know a key I found online is still valid?
Check config.example.yaml in your installed version — it is the maintained,
commented reference, and internal/config/config.go behind it is
authoritative. This series verified every key it printed against that file,
but configs age; the example file ships with the release you actually run.
What’s the minimal working config?
Part 1’s: storage.path, recordings.dir, one sdr.devices entry, and one
trunking.systems entry with protocol and control_channels. Everything
else in this finale is optional and defaulted — which is the real headline of
the config design.
Should I edit config.yaml by hand or through the web UI? Both are first-class. The web Settings form is generated from the config schema itself and preserves your file’s comments when it writes; hand editing is faster for bulk changes and works headless over SSH. Whichever you use, keep the file backed up — it is the one artifact that reproduces your entire rig.
Do I need to restart after every config change? No — settings changed through the API hot-apply where the daemon knows how to reload them in-process, and the response tells you per-field what applied and what needs a restart. Structural changes (new devices, new systems) want a clean restart; watch the startup lines each part taught you.
Series navigation
Part 14 of 14 · ← Part 13: Naming Everything — Aliases, Labels & Exports · Back to the series index