Skip to content

Installation — Docker Compose

  • Docker Engine and Docker Compose
  • Bluetooth adapter on the host
  • PulseAudio or PipeWire on the host
  • Music Assistant on your network

The published image supports linux/amd64, linux/arm64, and linux/arm/v7.

  1. Pair the speaker on the host first

    Terminal window
    bluetoothctl
    scan on
    pair AA:BB:CC:DD:EE:FF
    trust AA:BB:CC:DD:EE:FF
    connect AA:BB:CC:DD:EE:FF
    exit
  2. Create .env

    AUDIO_UID=1000
    AUDIO_GID=1000
    TZ=Europe/London
    WEB_PORT=8080
    BASE_LISTEN_PORT=8928
  3. Create docker-compose.yml

    services:
    sendspin-client:
    image: ghcr.io/trudenboy/sendspin-bt-bridge:latest
    container_name: sendspin-client
    restart: unless-stopped
    network_mode: host
    volumes:
    - /var/run/dbus:/var/run/dbus
    - /run/user/${AUDIO_UID:-1000}/pulse:/run/user/${AUDIO_UID:-1000}/pulse
    - /run/user/${AUDIO_UID:-1000}/pipewire-0:/run/user/${AUDIO_UID:-1000}/pipewire-0
    - /etc/docker/Sendspin:/config
    environment:
    - SENDSPIN_SERVER=auto
    - TZ=${TZ:-UTC}
    - WEB_PORT=${WEB_PORT:-8080}
    - BASE_LISTEN_PORT=${BASE_LISTEN_PORT:-8928}
    - CONFIG_DIR=/config
    - AUDIO_UID=${AUDIO_UID:-1000}
    - AUDIO_GID=${AUDIO_GID:-1000}
    - PULSE_SERVER=unix:/run/user/${AUDIO_UID:-1000}/pulse/native
    - XDG_RUNTIME_DIR=/run/user/${AUDIO_UID:-1000}
    devices:
    - /dev/bus/usb:/dev/bus/usb
    cap_add:
    - NET_ADMIN
    - NET_RAW
  4. Start the container

    Terminal window
    mkdir -p /etc/docker/Sendspin
    docker compose up -d
  5. Open the web UI

    http://<host-ip>:<WEB_PORT>
  • WEB_PORT controls the direct web UI/API listener in Docker mode.
  • BASE_LISTEN_PORT is the default Sendspin listener base for devices that do not define listen_port explicitly.
  • Each device without a manual port uses BASE_LISTEN_PORT + device_index.
  • Advanced setups can assign a per-device listen_port and listen_host in the web UI or /config/config.json after the first start.

Example device block in /config/config.json:

{
"mac": "11:22:33:44:55:66",
"player_name": "Kitchen Speaker",
"listen_port": 8935,
"listen_host": "192.168.1.50"
}

listen_host only changes the advertised host/IP shown for the player; it does not change the bind address inside the container.

If you run more than one bridge container on the same machine:

  • give each container a unique WEB_PORT
  • give each container a unique BASE_LISTEN_PORT
  • do not configure the same Bluetooth speaker in two running containers

network_mode: host is required for:

  • mDNS discovery when SENDSPIN_SERVER=auto
  • access to the host Bluetooth stack through D-Bus

Required capabilities:

CapabilityPurpose
NET_ADMINBluetooth adapter control
NET_RAWRaw Bluetooth/HCI socket access

On Ubuntu 24.04+ and other hosts with strict AppArmor/seccomp defaults, add security_opt to avoid Bluetooth permission errors:

security_opt:
- apparmor:unconfined
- seccomp:unconfined

This is already included in the project’s docker-compose.yml. HAOS and other minimal OSes ignore these options safely.

Terminal window
docker logs -f sendspin-client
curl -s http://localhost:${WEB_PORT:-8080}/api/preflight | python3 -m json.tool

Recent images also print startup diagnostics for:

  • the init UID/GID inside the container
  • the app UID/GID used for the running bridge process
  • the selected audio socket path
  • the socket owner/mode
  • a live pactl info probe result
  • whether the container had to wait for late D-Bus / Bluetooth / audio readiness on a cold host boot
  • a warning when the running bridge process does not match the user-scoped host audio socket

If you have configured Bluetooth devices, recent images also wait briefly for late host startup dependencies before launching the bridge process. This reduces the common “first boot after host restart needs one extra container restart” race.

If your host comes up especially slowly, you can tune the wait with:

environment:
- STARTUP_DEPENDENCY_WAIT_ATTEMPTS=60
- STARTUP_DEPENDENCY_WAIT_DELAY_SECONDS=1

Troubleshooting user-scoped PipeWire / PulseAudio

Section titled “Troubleshooting user-scoped PipeWire / PulseAudio”

A UID / session-owner mismatch for the user-scoped audio socket typically surfaces as “Connection refused” from pactl inside the container, but on some hosts it also shows up as the onboarding stalling on step 2 — “No Bluetooth controller detected” — because the same misalignment can sever host D-Bus access for Bluetooth. If both symptoms appear on a Raspberry Pi, see Preflight blocks on step 2 in the Raspberry Pi guide for the concrete fix sequence.

If the host audio stack is healthy but the container still shows Connection refused or cannot reach pactl, check:

Terminal window
docker exec sendspin-client ls -la /run/user/${AUDIO_UID:-1000}/pulse/
docker exec sendspin-client env | grep -E 'PULSE|XDG'
docker exec sendspin-client ps -o user:20,pid,command -C python3
docker inspect sendspin-client --format '{{json .Mounts}}'

And on the host:

Terminal window
id
pactl info
ls -la /run/user/${AUDIO_UID:-1000}/pulse/

If audio still fails, first confirm that the startup diagnostics show an App UID matching your host audio user. The old global Compose user: override is now only a temporary diagnostic test for older images:

services:
sendspin-client:
user: "${AUDIO_UID:-1000}:${AUDIO_UID:-1000}"

Then restart the container and see whether pactl starts working. Treat that as a troubleshooting step first: it helps confirm a UID/session mismatch with the host audio socket.

Headless PipeWire: Bluetooth sinks not appearing after reboot

Section titled “Headless PipeWire: Bluetooth sinks not appearing after reboot”

On PipeWire systems (Ubuntu 22.04+, Fedora, Raspberry Pi OS Bookworm), Bluetooth A2DP audio sinks are created by WirePlumber — a session manager that runs as a systemd --user service. By default, user services only start when the user logs in (GUI or SSH), and they may only be active when there’s an active interactive session.

Symptom: After a host reboot the bridge connects to the Bluetooth speaker (bluetoothctl shows Connected: yes), but pactl list sinks short shows no bluez_output.* or bluez_sink.* sink — only sendspin_fallback. Audio works again once you log in interactively.

There are two independent fixes — pick whichever fits your distro.

Option A — enable linger for the audio user so PipeWire + WirePlumber start at boot:

Terminal window
# Replace 1000 with your AUDIO_UID if different
sudo loginctl enable-linger $(id -un 1000)

Verify it took effect:

Terminal window
loginctl show-user $(id -un 1000) -p Linger
# Expected: Linger=yes

After enabling linger, reboot and confirm that Bluetooth sinks appear without logging in:

Terminal window
# Should list bluez_output.* or bluez_sink.* entries
pactl list sinks short | grep -i bluez

Option B — disable WirePlumber’s with-logind if connections also drop on logout, or churn every ~10 s even with linger enabled. See WirePlumber with-logind endpoint churn below for the per-user override.

Even after enabling linger, WirePlumber’s with-logind integration can cause two related Bluetooth symptoms on headless installations:

  • Endpoint churn: sudo journalctl -u bluetooth shows continuous endpoint registered / endpoint unregistered cycles every ~10 seconds, and BT connections succeed briefly then drop.
  • Logout-bound disconnects: observed on Ubuntu Server 24.04 — BT stays connected while an interactive user is logged in (e.g. via SSH), and drops the moment that session ends.

Both share the same root cause: on hosts without a graphical seat, WirePlumber’s logind integration continuously re-registers and unregisters A2DP media endpoints with BlueZ as sessions come and go.

Fix: Disable with-logind in WirePlumber:

Terminal window
mkdir -p ~/.config/wireplumber/bluetooth.lua.d
cat > ~/.config/wireplumber/bluetooth.lua.d/51-disable-logind.lua << 'EOF'
bluez_monitor.properties["with-logind"] = false
EOF
systemctl --user restart wireplumber

Verify the churn stopped:

Terminal window
# Should show NO 'Endpoint unregistered' entries
sudo journalctl -u bluetooth --since '30 sec ago' | grep -i unregistered

Changes to devices, adapters, WEB_PORT, BASE_LISTEN_PORT, and Music Assistant connection settings require a container restart.