-
Notifications
You must be signed in to change notification settings - Fork 1
Headless Deployment
RigControl Web's backend runs standalone — no Electron, no GUI, no display server required. This makes it a good fit for a dedicated radio-controller box (Raspberry Pi, N100/N150 mini PC, an old laptop, a NAS) running headless in the shack, controlled remotely from a browser on another machine.
This guide covers three ways to run it: Docker Compose (recommended),
plain docker run, and systemd (no container runtime). Commands below
use docker; podman is a drop-in substitute for docker build/docker run/docker exec (same flags, verified working end to end including real
device passthrough, on a real Fedora host) — see the rootless-podman caveat
under Troubleshooting near the end of this page if you're not running a
root-daemon setup. podman compose additionally requires installing
podman-compose separately (unlike Docker, where docker compose ships
built in) — if you don't have it, use Option 2 (plain run) with podman
instead of Option 1.
Feature gap: the video feed requires the Electron desktop app as the camera capture origin — a headless deployment has no video source. Everything else (rig control, audio, CW keyer, CW decode, spectrum, spots, solar data, admin panel) works exactly as in the desktop app, viewed from any browser pointed at the controller's IP.
Platform support: x64 (N100/N150 mini PCs and any other x86_64 Linux box) and arm64 (Raspberry Pi 3/4/5 running a 64-bit OS) are both supported for the bare-metal tarball and systemd install. The Docker image is currently linux/amd64 only — on a Raspberry Pi, use the arm64 tarball (see the systemd section below).
Both build stages pin ubuntu:24.04 rather than a Debian-based Node
image, for the same glibc-2.39 floor rationale as the DEB/RPM packages
(see Linux DEB & RPM Packages). The runtime stage's
apt-get install list is a trimmed subset of the DEB/RPM dependency lists — it
drops the Electron/Chromium-only GUI libs (GTK3, NSS, X11 libs, etc.) a
headless deployment never loads, but otherwise needs the same libraries
for the same reasons (PortAudio/naudiodon, rigctld, the FT4222 reader).
Two things aren't obvious from the package names alone:
-
libpulse0is required even though naudiodon's actual I/O path is ALSA/PipeWire — its prebuiltlibportaudio.so.2dynamically linkslibpulse.so.0and fails to import without it. -
libasound2t64/libreadline8t64, notlibasound2/libreadline8: Ubuntu 24.04's 64-bittime_ttransition left the old names as ambiguous virtual packages with no installable candidate —apt-get installneeds the realt64-suffixed names directly. This only affects a directapt-get installlike the Dockerfile's; electron-builder's.debDepends: libasound2field resolves fine via apt'sProvidesmechanism, sotest-linux-packages.shnever catches this class of naming mismatch.
The Hamlib UDP Spectrum Scope receives a
multicast UDP stream from rigctld. Docker's default bridge network
mode puts the container behind a NAT'd virtual interface that LAN
multicast traffic generally can't reach — so in bridge mode, the Hamlib
UDP spectrum panel would just go dead with no obvious error, while
everything else kept working fine. Host networking avoids this by giving
the container the host's real network stack. The cost — losing Docker's
port isolation — doesn't matter on a box dedicated to this one job. If
you'll only ever use the FT-710 or
Audio I/Q spectrum sources (neither uses
multicast), bridge mode with explicit port mapping works fine too.
Group GIDs matter — don't use group names, use numbers. The non-root
container user reaches /dev/ttyUSB0//dev/snd via --group-add dialout --group-add audio, but Docker/Podman resolve those names
against /etc/group inside the image, not the host. The image bakes
in dialout=20, audio=29 — real hosts commonly use different values
(confirmed on a real Ubuntu 24.04 test host: dialout=18, audio=63).
Using the names silently grants the wrong GIDs and device access fails
with no obvious error. Always look up your actual host GIDs and pass them
as numbers:
getent group dialout audio | awk -F: '{print $1"="$3}'Find your radio's stable device path (survives reboots/replugging, unlike
/dev/ttyUSB0, which can shift):
ls -l /dev/serial/by-id/CW keying on a second serial port: some setups need DTR/RTS keying on
a serial port distinct from CAT control — e.g. the IC-7300's single-port
conflict, or any radio wired to a second USB-serial adapter for its key
jack (see CW Keying Setup). This is a separate
docker-compose.yml line (commented out by default, tagged "CW keying"),
governed by the same dialout group already granted above — no extra
config beyond adding the line and pointing the app's
Settings → KEYER → Keyer Port at the matching in-container path. Verified
against a real dual-port CP2105 adapter during development.
Pass through the whole ALSA subsystem (/dev/snd) rather than a specific
card index, which isn't stable either. This is actually more reliable than
a typical desktop install — the "PipeWire doesn't reconnect after a radio
power cycle" issue described in Audio and Video only
happens because a desktop-session PipeWire daemon is running. A headless
controller has no desktop session and no PipeWire at all, so raw ALSA
passthrough sidesteps that entire problem.
/dev/snd alone is not enough — /proc/asound also needs unmasking.
Docker and Podman mask /proc/asound inside every container by default,
regardless of what device nodes are passed through — deliberate security
hardening, since an unmasked /proc/asound can leak the host's audio
playback/recording activity to the container (see
containerd's fix).
naudiodon's PortAudio ALSA backend enumerates sound cards by reading
/proc/asound, not just by opening /dev/snd/controlC* directly, so with
the mask in place the Audio Backend's Input/Output dropdowns stay empty
even though /dev/snd's device nodes are present and correctly
permissioned (issue #55 —
confirmed by reproducing it against real ALSA hardware, both containerized
and bare-metal).
The fix — already included by default in docker-compose.yml — is
security_opt: systempaths=unconfined:
security_opt:
- systempaths=unconfinedA plain bind mount of /proc/asound (-v /proc/asound:/proc/asound:ro)
looks like the more surgical fix, but does not actually work on current
Docker: runc's "proc-safety" hardening outright rejects any bind mount
whose target is a path inside /proc, regardless of source or intent —
confirmed against runc 1.4.3 / Docker 29.7.2, which fails container
startup with ... cannot be mounted because it is inside /proc. This is a
newer, stricter check than the masking itself and applies even to a
read-only mount of a real host path. systempaths=unconfined un-masks
every default-masked path (not just /proc/asound), which is blunter
than ideal, but it's the only approach confirmed working on both Docker
and Podman.
Verified working end to end against a real FT-710 during development —
harder to set up than serial or audio, but reliable once configured. Three
things beyond serial/audio, all pre-written (commented out) in
docker-compose.yml — uncomment every line tagged "FT4222" there:
-
Install
libft4222on the host first, following FT-710 Spectrum Scope Setup — FTDI doesn't distribute it through any Linux package manager, only a direct download from ftdi.com. It's deliberately not baked into the published image: doing so would mean redistributing FTDI's proprietary SDK binary through a public Docker Hub image, which its license doesn't clearly permit.docker-compose.ymlinstead bind-mounts your own, already-licensed host installation (the whole/usr/local/libdirectory, not a specific filename, since the exact patch version varies by what you downloaded). - The whole USB bus, not a specific device path — the FT4222 library enumerates devices itself by VID:PID rather than opening a fixed path, and bus/device numbers renumber on replug/reboot anyway. Needs both the bind mount and a cgroup rule granting the USB device class (a separate permission layer from file mode bits). The host's udev rules still govern access even through a bind mount — same as running on bare metal.
-
LD_LIBRARY_PATH=/usr/local/lib— required becausedlopen()needs it: the image'sld.so.cachewas built withoutlibft4222(it only exists via the runtime bind mount), so the baredlopen("libft4222.so")call inft4222-scope-reader.cwouldn't otherwise find it.
The systemd deployment remains slightly simpler for FT4222 specifically (no bus-renumbering or library-mounting considerations) — but Docker is a fully supported, verified option too, not a fallback.
- Install Docker (
docker+ thedocker composeplugin). - Download
docker-compose.ymlfrom the repo. - Edit the serial device line to match your radio.
- Export this host's real GIDs (don't skip — see "Device access" above):
export DIALOUT_GID=$(getent group dialout | cut -d: -f3) export AUDIO_GID=$(getent group audio | cut -d: -f3)
docker compose up -d- Browse to
https://<controller-ip>:3000, accept the self-signed certificate warning, and log in asADMIN/admin(forced password change on first login — see Authentication). - To update:
docker compose pull && docker compose up -d.
docker run -d \
--name rigcontrol-web \
--restart unless-stopped \
--network host \
--group-add "$(getent group dialout | cut -d: -f3)" \
--group-add "$(getent group audio | cut -d: -f3)" \
--device /dev/serial/by-id/usb-REPLACE_ME:/dev/ttyUSB0 \
--device /dev/snd:/dev/snd \
--security-opt systempaths=unconfined \
-e RCW_DATA_DIR=/data \
-v rcw-data:/data \
jbdubbs/rigcontrol-web:latest--security-opt systempaths=unconfined is required alongside --device /dev/snd — see the "Audio" note under "Device access" above for why (a
-v /proc/asound:/proc/asound:ro bind mount looks more targeted but is
rejected outright by current Docker).
Download the prebuilt x86-64 tarball from the
latest release
(rigcontrol-web-<version>-linux-x64.tar.gz) — it ships node_modules
already compiled against this project's Ubuntu 24.04 / glibc 2.39 floor
(see scripts/build-headless-x64.sh), so no compiler or build tools are
needed on the target machine (this replaces the old git clone && npm ci flow — see issue #57):
# Node.js 24 runtime (Debian/Ubuntu)
curl -fsSL https://deb.nodesource.com/setup_24.x | sudo bash - && sudo apt-get install -y nodejs
# Node.js 24 runtime (Fedora/RHEL-family) — instead of the line above:
# sudo dnf module install -y nodejs:24
# Runtime shared libraries naudiodon/rigctld/the FT4222 reader need
# (Debian/Ubuntu; see "Image runtime dependencies" above for why each one
# is needed — this is the same list, minus the GUI-only packages):
sudo apt-get install -y --no-install-recommends \
ca-certificates libasound2t64 libpulse0 libusb-1.0-0 libreadline8t64 libportaudio2 libuuid1
# Fedora/RHEL-family equivalent instead of the line above:
# sudo dnf install -y ca-certificates alsa-lib pulseaudio-libs libusb1 readline libuuid
sudo useradd --system --home /opt/rigcontrol-web --shell /usr/sbin/nologin \
--groups dialout,audio rigcontrol-web
sudo tar xzf rigcontrol-web-<version>-linux-x64.tar.gz -C /opt --strip-components=1 --one-top-level=rigcontrol-web
sudo chown -R rigcontrol-web:rigcontrol-web /opt/rigcontrol-web
sudo mkdir -p /var/lib/rigcontrol-web
sudo chown rigcontrol-web:rigcontrol-web /var/lib/rigcontrol-web
sudo cp /opt/rigcontrol-web/rigcontrol-web.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now rigcontrol-webCheck status/logs with systemctl status rigcontrol-web /
journalctl -u rigcontrol-web -f.
The Releases page also carries an arm64 tarball
(rigcontrol-web-<version>-linux-arm64.tar.gz) for Raspberry Pi 3/4/5
running 64-bit Raspberry Pi OS (Bookworm or later) or Debian 13. It is built
against a Debian 12 glibc floor (2.36), and like the x64 tarball ships
node_modules and all helper binaries prebuilt, so no compiler is needed on
the Pi. The install steps are the same as above with the linux-arm64
filename, except the runtime library package names are the plain (non-t64)
Debian 12 ones (libasound2, libreadline8); on Debian 13 keep the t64 names shown above.
If you'd rather build it yourself (e.g. to run off a modified checkout):
sudo useradd --system --home /opt/rigcontrol-web --shell /usr/sbin/nologin \
--groups dialout,audio rigcontrol-web
sudo git clone https://github.com/jbdubbs/Rig-Control-Web.git /opt/rigcontrol-web
sudo chown -R rigcontrol-web:rigcontrol-web /opt/rigcontrol-web
# Build as the unprivileged service user, not root — install/build
# scripts (node-gyp rebuilds, etc.) run arbitrary code.
sudo -u rigcontrol-web bash -c 'cd /opt/rigcontrol-web && npm ci && npm run build'
sudo mkdir -p /var/lib/rigcontrol-web
sudo chown rigcontrol-web:rigcontrol-web /var/lib/rigcontrol-web
sudo cp docs/rigcontrol-web.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now rigcontrol-webRequires Node 24+ (same NodeSource/dnf module install commands as
above) and the same system packages as building from source on Linux
(libasound2-dev libopus-dev build-essential, plus gcc to compile
cw-key-helper/ft4222-scope-reader if you need them); bin/linux/rigctld
and the other helper binaries are already committed in the repo for x64,
so this is usually just npm ci && npm run build.
If you're running rootless Docker or Podman, device passthrough for
serial/audio can fail even with the correct numeric GIDs above — a Linux
user-namespace limitation, not specific to this image: rootless containers
can only represent host GIDs inside the engine's delegated /etc/subgid
range, and low system GIDs like dialout/audio normally aren't in it.
Symptoms: Permission denied on the device even though id inside the
container shows the right group numbers.
- Try
--user root(still confined to your own host user's privileges under rootless — not the same risk as root under a root-daemon install). - Podman's rootless escape hatch:
--userns=keep-id --user "$(id -u):$(id -g)" --group-add keep-groups. Not guaranteed — results vary by kernel/crun/podman version. -
Most reliable, and confirmed working: a standard root-daemon Docker
install (
docker.io/docker-ce, the default on most distros) rather than rootless mode — numeric--group-addworks exactly as documented with no further workarounds, verified end to end (real serial + audio device read/write access) on a real Fedora host. This is what a dedicated Pi/mini-PC controller would typically run.
Open TCP 3000 (HTTPS) inbound from your LAN. If you use the Hamlib UDP
spectrum source, also confirm UDP traffic on your multicast port (default
4531) isn't blocked between rigctld's host and this box, if they're
different machines.