Multi-room audio into real DACs, managed from a browser. One Docker image, a web panel that creates and supervises as many Snapcast players as you have outputs, and audio that reaches the hardware at the source sample rate.
Part of the Home Audio Stack β Music Assistant β Snapcast β PipeWire, into USB DACs, Bluetooth speakers and LED strips. That page maps how these projects fit together.
| π Every room in sync | Press play once; the kitchen and the living room stay together. Walk between them and the song follows without an echo. |
| π― Nothing is resampled | A 44.1 kHz track reaches the DAC as 44.1 kHz. The volume lands on the real hardware, not a software fader. |
| ποΈ No compose edit to add a room | Create, start, stop and re-point players from the panel. Several DACs from one container. |
| π It reconnects itself | 5s β 60s backoff that resets after a healthy session, and a watchdog for the case snapclient survives its own output disappearing. |
| π PipeWire or ALSA | Bind to a PipeWire sink, or address hw:CARD=DX5,DEV=0 directly when PipeWire is not an option. |
| π Runs unprivileged | uid 1000, no root, no privileged mode. |
The host needs a working PipeWire session first β see Host setup if you have not done that. Then:
mkdir -p panel_config && sudo chown -R 1000:1000 panel_config
curl -O https://raw.githubusercontent.com/shuricksumy/pipewire-snapclient/main/docker-compose-panel-example.yaml
docker compose -f docker-compose-panel-example.yaml up -dBrowse http://<host>:8080/, press Add player, pick an output, point it at your Snapserver (or Music Assistant) and press Save.
With no
ROLEset the image runs the panel. For a single-purpose container useROLE=snapclientorROLE=snapserverβ see Other roles.
Each player is a supervised snapclient child process with its own output, so several
DACs run side by side from one container. Every field is what ROLE=snapclient would
take from the environment β SERVER_IP, SNAP_PORT, CLIENT_ID, PIPEWIRE_NODE,
PIPEWIRE_LATENCY, SNAP_EXTRA β editable per player at runtime and stored in
/config/players.json, so they come back on restart.
Each row reads the snapserver's control port, so it shows what is playing and offers transport and volume β the same controls Snapweb and Music Assistant drive.
- A paused player keeps its controls. Music Assistant parks a paused group on a
stream reporting
canControl=false; the panel drives the last controllable one instead, so you can still press play. - A stopped player resets its row: no leftover track, no transport.
- The π button cycles system / light / dark and remembers the choice.
Every player picks one output, and the dialog lists both kinds together:
| Output | What it does | When you want it |
|---|---|---|
| PipeWire | Binds the player to one sink via PIPEWIRE_NODE. Sample rate follows the source, volume lands on the hardware sink, and the panel watches the sink so an unplugged DAC is noticed. |
The normal case, on a host with a working PipeWire session. |
| ALSA | Hands the device straight to snapclient -s, e.g. hw:CARD=DX5,DEV=0. Skips PIPEWIRE_NODE and the sink watchdog, which have no meaning off the graph. |
PipeWire is broken, absent, or you want the card to yourself. |
ALSA devices are enumerated with snapclient -l, which needs the sound hardware passed
through as devices, not as a volume:
devices:
- /dev/snd:/dev/snd # NOT "-v /dev/snd:/dev/snd"-v maps the device nodes but the container's device cgroup still refuses to open them,
so snapclient reports "No such device" and enumeration comes back with conversion
plugins only. The panel detects exactly that and says so in a banner. If your device is
not listed, Custom ALSA device⦠lets you type it.
β‘ Real-time scheduling is container-level. Players are children of the panel process and inherit its limits, so the panel needs the same
cap_add: SYS_NICEandrtprio/memlockulimits a standalone client would β otherwise every player runs at normal priority and audio glitches under load. The example compose file sets them.
π Security. With
ADMIN_PASSWORDunset there is no authentication at all β intended for a trusted LAN. Set it (and optionallyADMIN_USER) to put HTTP Basic auth in front of every route, the page included. Do not port-forward this.
Two things the panel does not do. Restarting the container stops every player β one container per player survives a panel restart, this does not. And it manages clients, not the server: groups, streams and server-wide settings still belong to Snapweb or Music Assistant, which the header links to.
Music Assistant is the library and streaming brain β Spotify, Plex, local files, radio β and Home Assistant drives it. What it cannot do on its own is put audio into a USB DAC plugged into some other Linux box, at the original sample rate, in sync with the rest of the house.
MA solves the "in sync" half with its Snapcast provider: it ships a built-in Snapserver and streams synchronised audio to any Snapcast client on the network. This image is the client for the hi-fi end of that chain.
flowchart LR
subgraph MA["π΅ Music Assistant"]
LIB["Spotify Β· Plex<br/>local library Β· radio"] --> SS["built-in<br/>Snapserver"]
end
SS -- "TCP 1704<br/>synced audio" --> SC["<b>this image</b><br/>panel + players"]
SC -- "PipeWire socket" --> PW["host PipeWire"]
PW --> DAC["π USB DAC<br/>Topping DX5"]
SS -. "other rooms" .-> OTHER["Snapdroid Β· snapweb<br/>ESP32 Β· Raspberry Pi"]
style SC stroke-width:3px
The normal way β let Music Assistant be the server. Add the Snapcast provider in MA
(Settings β Player Providers β Add β Snapcast), leave the built-in server on, and point
your players at the MA host. They appear under the Snapcast provider within seconds and
can be grouped with your other rooms.
The advanced way β run the Snapserver here too
MA can use an external Snapserver instead (ROLE=snapserver), which is what you want when
Snapcast, not MA, is the centre of your audio setup β e.g. LedFx or another producer also
writes into the same FIFOs. Three things to know:
| Version | MA needs snapserver β₯ 0.27.0 and specifically cannot use 0.30.0. This image tracks the latest upstream release (0.35.0 today). |
| Ports | 1704, 1705 and the 4953β5153 range must be reachable β MA creates a stream per player in that range. network_mode: host is the simple answer. |
| Stream name | MA requires a stream named default. snapserver.conf here ships Default β rename it if you go this route. |
The container does not run its own PipeWire daemon β it connects to the host's through a bind-mounted socket. So the host has to be a working PipeWire machine first.
Shortcut:
ubuntu-pipewire-install-on-host.shdoes all of it, verifies the socket, and prints the compose settings for your host:./ubuntu-pipewire-install-on-host.sh # the uid-1000 user, whatever it is called ./ubuntu-pipewire-install-on-host.sh <username> # or a specific accountWith no argument it targets uid 1000 β the uid the container runs as, and the one in the
/run/user/1000/pipewire-0path the compose files mount, so nouser:line is needed. A username that does not exist yet is created, taking uid 1000 if that is still free. It also installs Docker CE whendockeris missing (SKIP_DOCKER=1to skip that).
Step by step
Docker Engine plus the Compose plugin. The script above installs both from Docker's own
repository when docker is not already on the host (distro packages lag and often omit the
compose plugin); set SKIP_DOCKER=1 to leave an existing setup alone, or install it yourself
from the official guide.
You also want the uid of the user whose PipeWire session the container will attach to. Note it
now β the same number appears in the socket path and in user: in your compose file:
id -u # usually 1000sudo apt update && sudo apt install -y \
pipewire pipewire-audio pipewire-pulse pipewire-alsa \
wireplumber alsa-utils rtkitNote: the real-time helper package is
rtkit, notrtkit-daemonβ no such package exists on Debian or Ubuntu, and apt aborts the entire command on one unknown name, so a single typo leaves nothing installed. Likewisepipewire-audiois the current name of what used to bepipewire-audio-client-libraries.
usermod -aG is all-or-nothing: if any listed group does not exist it exits with an error and
adds none of them. bluetooth, render, pulse-access and docker only exist once their
package is installed, so add whichever are actually present:
for g in audio video render bluetooth lp docker; do
getent group "$g" >/dev/null && sudo usermod -aG "$g" "$USER"
done
# Group membership only applies to new sessions -- log out and back in, then verify:
id -nGTo allow your DAC to switch sample rates without resampling:
mkdir -p ~/.config/pipewire/pipewire.conf.d/
cat <<EOF > ~/.config/pipewire/pipewire.conf.d/bitperfect.conf
context.properties = {
# Rate used while nothing is playing; PipeWire switches to the source rate on demand
default.clock.rate = 48000
# Trim this list to the rates your DAC actually supports
default.clock.allowed-rates = [ 44100 48000 88200 96000 176400 192000 352800 384000 ]
default.clock.min-quantum = 32
default.clock.max-quantum = 8192
}
EOF
systemctl --user restart pipewire pipewire-pulse wireplumberOn a server, the user's PipeWire services only start when that user logs in. Lingering keeps them up so the DAC is available to the container across reboots and logouts:
# 1. Keep this user's services running when nobody is logged in.
# This is also what makes systemd create and keep /run/user/<uid>/, which is
# where the socket the container mounts lives.
sudo loginctl enable-linger "$USER"
# 2. Enable and start the audio services for the user session.
# The '--user' flag is mandatory here.
systemctl --user enable --now pipewire.socket pipewire.service \
pipewire-pulse.service wireplumber.service
# 3. Verify the services are running
systemctl --user status pipewire wireplumber --no-pagerWhen driving these from a root shell or a cron job rather than your own login session, point the tools at the right bus first:
export XDG_RUNTIME_DIR="/run/user/$(id -u)"
export DBUS_SESSION_BUS_ADDRESS="unix:path=${XDG_RUNTIME_DIR}/bus"All four must pass before you start a container β every one is something the container itself cannot fix:
# a) The socket the client bind-mounts exists and belongs to you.
# This exact path goes in the compose 'volumes:' entry.
ls -l /run/user/$(id -u)/pipewire-0
# b) WirePlumber sees your DAC. Note the sink name -- a substring of it is PLAYER_NAME.
wpctl status
# c) The exact node.name for PIPEWIRE_NODE
pw-cli ls Node | grep -E 'node.name|node.description'
# d) Audio actually reaches the DAC (you should hear it)
speaker-test -c 2 -t sine -l 1Then wire the results into your compose file:
| Check | Goes into |
|---|---|
id -u (e.g. 1000) |
the socket path, plus user: "<uid>:<gid>" if it is not 1000 |
| socket path from (a) | volumes: - /run/user/1000/pipewire-0:/tmp/pipewire-0 |
| sink name from (b) | PLAYER_NAME, or the panel's output picker |
node.name from (c) |
PIPEWIRE_NODE, or the panel's output picker |
Running the server role as well? Its bind mounts must be writable by the same uid, otherwise snapserver cannot create its FIFOs or write its config β Docker creates a missing bind-mount source as a root-owned directory:
mkdir -p ./snapserver_config /tmp/snapfifo
sudo chown -R "$(id -u):$(id -g)" ./snapserver_config /tmp/snapfifoHeads-up: the image defaults to
ROLE=panel. A container that used to run a bare snapclient with noROLEset will come up as the web panel instead β addROLE=snapclientto keep the old behaviour.
Ready-to-edit compose files live in the repo: panel Β· client Β· server Β· local build.
Client role β one container, one DAC
services:
snapclient-dx5:
image: ghcr.io/shuricksumy/snapcast-pipewire:latest
container_name: snapclient-dx5
network_mode: host
cap_add:
- SYS_NICE
ulimits:
rtprio: 95
memlock: -1
group_add:
- audio
environment:
- ROLE=snapclient # required: the image defaults to the web panel
- SERVER_IP=127.0.0.1
- SNAP_PORT=1704
- CLIENT_ID=Lounge-DX5
- PLAYER_NAME=DX5 # substring of the sink name in wpctl status, to set volume
- INIT_VOL=0.5
- PIPEWIRE_NODE=alsa_output.usb-Topping_DX5-00.analog-stereo
- PIPEWIRE_LATENCY=2048/192000
volumes:
- /run/user/1000/pipewire-0:/tmp/pipewire-0
- /dev/shm:/dev/shm
restart: unless-stoppedServer role β the Snapserver itself
services:
snapserver:
image: ghcr.io/shuricksumy/snapcast-pipewire:latest
container_name: snapserver
network_mode: host
environment:
- ROLE=snapserver # required: the image defaults to the web panel
- SNAP_PORT=1704
volumes:
# Must be writable by uid 1000: sudo chown -R 1000:1000 snapserver_config
- ./snapserver_config:/config
# Host /tmp/snapfifo becomes the container's /tmp, so the pipe:///tmp/snapfifo
# sources in snapserver.conf are /tmp/snapfifo/snapfifo* on the host
- /tmp/snapfifo:/tmp
restart: unless-stoppedThe tuned snapserver.conf in this repo is seeded into /config on
first run; your edits win from then on.
The image runs as uid/gid 1000 (group audio), not root. 1000 is the uid that normally owns
/run/user/1000/pipewire-0 on a desktop host, which is exactly the socket a player bind-mounts.
- Host user is not 1000? Check with
id -uand pin the container to it:user: "<uid>:<gid>". - Bind mounts (
/config, the FIFO directory) keep their host ownership, sosudo chown -R 1000:1000 <dir>once. The entrypoint stops with that exact instruction rather than failing halfway. - Real-time scheduling still works:
cap_add: SYS_NICEplus thertprio/memlockulimits are granted to the process regardless of the uid.
Environment variables
| Variable | Default | Description |
|---|---|---|
| ROLE | panel | panel (the web UI, the default), snapclient or snapserver. |
| SNAP_PORT | 1704 | The TCP streaming port. Ignored if SERVER_IP already carries a port. |
| SERVER_IP | 127.0.0.1 | Snapserver address. Accepts host, host:port or tcp://host:port. In the panel role it seeds the Add-player form. |
| CLIENT_ID | Snap-Node | (Client only) Name appearing in the Web UI (--hostID). |
| PLAYER_NAME | (empty) | (Client only) Substring of the sink name in wpctl status; picks which sink gets INIT_VOL. Empty = default sink. |
| INIT_VOL | 1.0 | (Client only) Volume set once at startup, 0.0β1.0. The sink is also unmuted. |
| PIPEWIRE_NODE | (empty) | (Client only) Target node.name from pw-cli ls Node. Empty = default sink. |
| PIPEWIRE_LATENCY | 2048/192000 | Buffer size / sample rate hint passed to PipeWire. |
| USE_ALSA | false | (Client only) true routes through the ALSAβPipeWire bridge. The panel has a per-player output picker instead. |
| SNAP_EXTRA | (empty) | (Client only) Extra arguments appended to the snapclient command line. |
| EXTRA_ARGS | (empty) | (Server only) Extra arguments appended to the snapserver command line. |
| DEBUG | false | true enables set -x tracing in the entrypoint. |
Panel role only
| Variable | Default | Description |
|---|---|---|
| PORT | 8080 | Port the panel listens on. |
| ADMIN_PASSWORD | (empty) | Set it to require HTTP Basic auth on every route. Empty = no authentication. |
| ADMIN_USER | admin | Username for the above. |
| SNAP_CONTROL_PORT | 1705 | Snapserver's JSON-RPC port β now playing, transport, volume. A separate listener from the stream port, so it is not derived from SNAP_PORT. |
| SNAP_WEB_PORT | 1780 | Snapweb's port, used for the header link. |
| CONFIG_DIR | /config | Where players.json is written. Must be writable by uid 1000. |
| POLL_SECONDS | 5 | How often the browser re-reads the player table. |
| BIND_HOST | 0.0.0.0 | Address the panel binds to. |
SERVER_IP, SNAP_PORT, PIPEWIRE_LATENCY and the ports above only seed the Add-player
form: each player stores its own copy, and the panel's Settings dialog can change the
defaults without touching compose.
Building it yourself
The Snapcast packages are downloaded from the upstream GitHub release during the build β
nothing is committed to this repo. Stage 0 of the Dockerfile resolves the release, picks the
_<arch>_trixie_with-pipewire.deb asset for the target architecture, and verifies each download
against the sha256 digest the GitHub API publishes for it. A missing asset, a missing digest or a
checksum mismatch fails the build.
The runtime stage then asserts that the installed binaries resolve all their shared libraries and
that snapclient is really linked against libpipewire β a stock distro package would install
fine and then reject --player pipewire only at runtime.
| Build arg | Default | Purpose |
|---|---|---|
| SNAPCAST_VERSION | latest | Release tag to install, e.g. v0.35.0. latest resolves the newest published release at build time; CI pins the tag it resolved so the layer caches. |
| SNAPCAST_SUITE | trixie | Debian suite variant of the release asset (trixie, bookworm, bullseye). |
| REFRESH_WEEK | 0 | Cache epoch. CI sets it to the ISO week so the weekly scheduled rebuild really re-runs apt-get upgrade instead of restoring a stale layer. |
docker buildx build --platform linux/amd64,linux/arm64 -t snapcast-pipewire .
# Reproducible build against a specific release:
docker buildx build --build-arg SNAPCAST_VERSION=v0.35.0 -t snapcast-pipewire .Tests. The panel's supervisor and API are covered by a pytest suite that needs no
PipeWire, no DAC, no snapserver and no root β snapclient is replaced by
tests/fake_snapclient.py and the control port by
tests/fake_snapserver.py, so it runs anywhere and gates every
build in CI:
pip install flask pytest
python -m pytest tests/ -qBluetooth speakers
A Bluetooth sink is just another PipeWire node, so the panel lists it like any other output β it only exists while the speaker is connected.
sudo apt-get update
sudo apt-get install bluetooth bluez bluez-tools alsa-utilsPair with the Go TUI rather than raw bluetoothctl β install from
bluetuith-org/bluetuith, or unpack the prebuilt
binary from utils:
# uname -m reports aarch64, but the tarball is named arm64
case "$(uname -m)" in aarch64) ARCH=arm64 ;; *) ARCH=x86_64 ;; esac
tar -xzf "utils/bluetuith_0.2.6_Linux_${ARCH}.tar.gz" -C /tmp bluetuith
sudo install -m 755 /tmp/bluetuith /usr/local/bin/bluetuith
bluetuith # scan, pair, trust, connectThen find the node name for the player:
pw-cli ls Node | grep -E 'node.name|node.description'
# e.g. bluez_output.20_18_12_00_07_C4.1A JBL Charge 5 that refuses to behave is covered by JBL-fix.sh.
- No mDNS. This image does not run
avahi-daemon, so Zeroconf announcement is not available β point clients atSERVER_IPexplicitly. (snapserverlogs a harmlessAvahi: Failed to create clientline at startup.) - Healthcheck tracks the process, not the connection: a client retrying against an unreachable server still reports healthy, because it is alive and doing what it should.
- Maintained image. Rebuilt weekly so Debian security updates and new Snapcast releases land without a commit, and scanned with Trivy on every push.
MIT β see LICENSE. Snapcast itself is GPL-3.0, and its packages are downloaded from the upstream release at build time.

