Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Select an option

  • Save shirayu/aa60cf546d612b71487e01ac80a18158 to your computer and use it in GitHub Desktop.

Select an option

Save shirayu/aa60cf546d612b71487e01ac80a18158 to your computer and use it in GitHub Desktop.
Play Audio & Desktop Notifications from Docker / Podman Containers

Play Audio and Desktop Notifications from Docker / Podman Containers

This guide explains how to play audio and send desktop notifications from a Debian- or Ubuntu-based container to a native Linux desktop host.

The setup shares two UNIX sockets from the host desktop session:

  • The PulseAudio socket, or PipeWire’s PulseAudio-compatible socket, for audio playback
  • The session D-Bus socket for desktop notifications

A Python verification script checks the socket mounts, sends a test notification, generates a short WAV tone, and plays it through the host audio server.

Warning

Sharing the host session D-Bus and audio sockets gives the container access to parts of your desktop session. Use this configuration only with trusted images and trusted code.

1. Docker Configuration

With Docker, the container process should run under the same UID and GID as the host desktop user who owns the runtime sockets.

Export the required values before starting the container:

export HOST_UID="$(id -u)"
export HOST_GID="$(id -g)"
export XDG_RUNTIME_DIR="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}"

You can verify them with:

printf 'UID=%s\nGID=%s\nXDG_RUNTIME_DIR=%s\n' \
    "$HOST_UID" \
    "$HOST_GID" \
    "$XDG_RUNTIME_DIR"

Create the following compose.yml:

services:
  audio-dev-container:
    build: .

    user: "${HOST_UID}:${HOST_GID}"

    environment:
      XDG_RUNTIME_DIR: /run/host-desktop
      PULSE_SERVER: unix:/run/host-desktop/pulse/native
      DBUS_SESSION_BUS_ADDRESS: unix:path=/run/host-desktop/bus
      DISPLAY: "${DISPLAY:-}"
      WAYLAND_DISPLAY: "${WAYLAND_DISPLAY:-}"

    volumes:
      - "${XDG_RUNTIME_DIR}/pulse/native:/run/host-desktop/pulse/native"
      - "${XDG_RUNTIME_DIR}/bus:/run/host-desktop/bus"

The host sockets are mounted under /run/host-desktop rather than replacing the container’s own /run/user directory.

The verification script uses DISPLAY or WAYLAND_DISPLAY as a check that it was launched from a graphical desktop session. notify-send itself communicates through the mounted D-Bus session socket, so this setup does not need to mount the X11 or Wayland display socket.

Start the service with:

docker compose up --build -d

2. Rootless Podman Configuration

Rootless Podman supports keep-id user namespaces. This maps the calling host user’s UID and GID to the same numeric UID and GID inside the container.

As a result, this setup does not need HOST_UID, HOST_GID, or an explicit user: entry:

services:
  audio-dev-container:
    build: .

    userns_mode: keep-id

    environment:
      XDG_RUNTIME_DIR: /run/host-desktop
      PULSE_SERVER: unix:/run/host-desktop/pulse/native
      DBUS_SESSION_BUS_ADDRESS: unix:path=/run/host-desktop/bus
      DISPLAY: "${DISPLAY:-}"
      WAYLAND_DISPLAY: "${WAYLAND_DISPLAY:-}"

    volumes:
      - "${XDG_RUNTIME_DIR}/pulse/native:/run/host-desktop/pulse/native"
      - "${XDG_RUNTIME_DIR}/bus:/run/host-desktop/bus"

Make sure the host runtime directory is available:

export XDG_RUNTIME_DIR="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}"

Then start the service:

podman compose up --build -d

The equivalent option when invoking Podman directly is:

podman run --userns=keep-id ...

keep-id is specific to rootless Podman. Docker supports other forms of user-namespace isolation and remapping, but its default configuration does not provide a direct equivalent that automatically preserves the calling user’s numeric UID and GID. For the Docker example in Section 1, explicitly setting user: "${HOST_UID}:${HOST_GID}" remains the simplest approach.

Note

Compose support for Podman-specific options can depend on the Compose provider and version being used. If userns_mode: keep-id is rejected, run the container directly with podman run --userns=keep-id or check the documentation for your installed Compose provider.

3. Required Container Packages

Add the following packages to a Debian- or Ubuntu-based Dockerfile:

FROM debian:bookworm-slim

RUN apt-get update && apt-get install -y --no-install-recommends \
    libnotify-bin \
    pulseaudio-utils \
    python3-minimal \
    && rm -rf /var/lib/apt/lists/*

WORKDIR /app

COPY check_integration.py /app/check_integration.py

RUN chmod +x /app/check_integration.py

CMD ["sleep", "infinity"]

The installed packages provide:

  • libnotify-bin: the notify-send command
  • pulseaudio-utils: the paplay command
  • python3-minimal: the Python interpreter used by the verification script

On a PipeWire-based desktop, paplay normally connects through PipeWire’s PulseAudio-compatible socket.

4. Verification Script

Save the following as check_integration.py.

5. Running the Verification

Docker

Run the script inside the active service:

docker compose exec audio-dev-container \
    python3 /app/check_integration.py

Podman

Run:

podman compose exec audio-dev-container \
    python3 /app/check_integration.py

A successful run should produce output similar to:

Sent desktop notification.
Played test sound through PulseAudio/PipeWire.
🎉 Container notification and sound checks passed successfully!

You should also see a desktop notification and hear a short 880 Hz tone.

The script confirms that the socket paths exist and that notify-send and paplay exit successfully. It cannot automatically confirm that the notification was visible to the user or that the sound was audible, so verify both manually.

6. How the Socket Mapping Works

On the host, the relevant paths normally look like this:

${XDG_RUNTIME_DIR}/bus
${XDG_RUNTIME_DIR}/pulse/native

For a host user with UID 1000, these are commonly:

/run/user/1000/bus
/run/user/1000/pulse/native

Inside the container, they are mounted as:

/run/host-desktop/bus
/run/host-desktop/pulse/native

The container environment is then configured as follows:

XDG_RUNTIME_DIR=/run/host-desktop
DBUS_SESSION_BUS_ADDRESS=unix:path=/run/host-desktop/bus
PULSE_SERVER=unix:/run/host-desktop/pulse/native

The Python script derives both socket paths from XDG_RUNTIME_DIR, so these values must remain consistent.

7. Troubleshooting

XDG_RUNTIME_DIR is not set

Check the environment inside the container:

docker compose exec audio-dev-container \
    printenv XDG_RUNTIME_DIR

The expected value is:

/run/host-desktop

For Podman, replace docker compose with podman compose.

The D-Bus socket is missing

Check the host socket:

ls -l "${XDG_RUNTIME_DIR}/bus"

Check the mounted socket inside the container:

docker compose exec audio-dev-container \
    ls -l /run/host-desktop/bus

The D-Bus session socket normally exists only for an active user session. A container started by a system service, cron job, SSH-only session, or another user may not have access to the graphical desktop session.

The audio socket is missing

Check the host path:

ls -l "${XDG_RUNTIME_DIR}/pulse/native"

On a PipeWire system, inspect the user services:

systemctl --user status pipewire pipewire-pulse

On a PulseAudio system, run:

pactl info

Check the mounted socket inside the container:

docker compose exec audio-dev-container \
    ls -l /run/host-desktop/pulse/native

DISPLAY or WAYLAND_DISPLAY is required

Check the values on the host:

printf 'DISPLAY=%s\nWAYLAND_DISPLAY=%s\n' \
    "${DISPLAY:-}" \
    "${WAYLAND_DISPLAY:-}"

At least one must be non-empty because the verification script uses these variables to confirm that it was launched from a graphical session.

Check the values inside the container:

docker compose exec audio-dev-container \
    env | grep -E '^(DISPLAY|WAYLAND_DISPLAY)='

These variables are used only as a session check by the script. This guide does not mount the X11 or Wayland display socket because the container is not running a graphical application.

notify-send fails

Verify the D-Bus address:

docker compose exec audio-dev-container \
    printenv DBUS_SESSION_BUS_ADDRESS

The expected value is:

unix:path=/run/host-desktop/bus

Try the command manually:

docker compose exec audio-dev-container \
    notify-send \
    "Container Test" \
    "Manual notification test"

A notification may still be hidden when:

  • Do Not Disturb mode is enabled
  • Notifications are disabled by the desktop environment
  • The desktop session is locked
  • No notification daemon is running
  • The notification daemon rejects the request

paplay fails

Verify the configured server:

docker compose exec audio-dev-container \
    printenv PULSE_SERVER

The expected value is:

unix:/run/host-desktop/pulse/native

Inspect the connection:

docker compose exec audio-dev-container \
    pactl info

Permission denied

Confirm that the socket files belong to the same user used by the container:

ls -ln \
    "${XDG_RUNTIME_DIR}/bus" \
    "${XDG_RUNTIME_DIR}/pulse/native"

For Docker, inspect the effective container user:

docker compose exec audio-dev-container id

Its UID and GID should match HOST_UID and HOST_GID.

For rootless Podman with keep-id, inspect the mapping:

podman compose exec audio-dev-container id

The UID and GID should match those of the user who started Podman.

8. Podman and SELinux

On SELinux-enabled systems, the container may be prevented from accessing the mounted UNIX sockets even when the UID and GID are correct.

As a troubleshooting measure, disable SELinux labeling for this container:

services:
  audio-dev-container:
    security_opt:
      - label=disable

A complete rootless Podman example is:

services:
  audio-dev-container:
    build: .

    userns_mode: keep-id

    security_opt:
      - label=disable

    environment:
      XDG_RUNTIME_DIR: /run/host-desktop
      PULSE_SERVER: unix:/run/host-desktop/pulse/native
      DBUS_SESSION_BUS_ADDRESS: unix:path=/run/host-desktop/bus
      DISPLAY: "${DISPLAY:-}"
      WAYLAND_DISPLAY: "${WAYLAND_DISPLAY:-}"

    volumes:
      - "${XDG_RUNTIME_DIR}/pulse/native:/run/host-desktop/pulse/native"
      - "${XDG_RUNTIME_DIR}/bus:/run/host-desktop/bus"

Caution

label=disable weakens SELinux isolation for the container. Use it only with trusted workloads and only when the default configuration is blocked by SELinux.

Avoid applying :z or :Z labels to desktop session sockets without understanding the consequences. Relabeling files under the host runtime directory may interfere with the desktop session or other applications.

9. Security Considerations

The session D-Bus socket is not limited to notifications. Depending on the desktop environment and available services, a process with access to the bus may be able to interact with other applications and desktop components.

The audio socket may also expose more than playback. Depending on the server configuration and permissions, clients may be able to enumerate audio devices, inspect streams, or access recording sources.

Recommended precautions include:

  • Use only trusted container images and applications.
  • Do not expose these sockets to containers that process untrusted code.
  • Remove the socket mounts when audio and notifications are not needed.
  • Avoid running the container with unnecessary Linux capabilities.
  • Keep the rest of the container filesystem and host mounts restricted.
  • Consider a filtered D-Bus proxy when only notification access is required.
  • Consider a dedicated or restricted audio endpoint for stronger isolation.

10. WSL2 and macOS

This UNIX-socket configuration is intended for native Linux desktop sessions.

WSL2

WSL2 with WSLg may provide a PulseAudio-compatible endpoint, but its paths and environment variables differ from those of a native Linux desktop.

Inspect the existing value inside WSL:

printf '%s\n' "$PULSE_SERVER"

Do not assume that /run/user/<UID>/pulse/native exists or that the Linux Compose example works unchanged.

Desktop notification forwarding may also require a WSL-specific integration mechanism.

macOS

Docker Desktop and Podman on macOS run Linux containers inside a virtual machine. Podman’s documentation likewise describes its macOS environment as using a Podman-managed VM.

macOS does not provide a native Linux PulseAudio socket or Linux session D-Bus socket to those containers.

A setting such as:

PULSE_SERVER=tcp:host.docker.internal:4713

works only when a compatible host-side audio server has been installed and explicitly configured to accept TCP connections.

Desktop notifications require a separate host-side bridge or integration service.

License

AGPL v3, shirayu
#!/usr/bin/env python3
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright (c) shirayu
import math
import os
import shutil
import subprocess
import sys
import tempfile
import wave
from pathlib import Path
from typing import NoReturn
HOST_RUNTIME_DIR = Path("/run/host")
def fail(message: str) -> NoReturn:
print(f"ERROR: {message}", file=sys.stderr)
raise SystemExit(1)
def require_socket(path: Path) -> None:
if not path.exists():
fail(f"Socket does not exist: {path}")
if not path.is_socket():
fail(f"Path is not a UNIX socket: {path}")
def require_command(name: str) -> None:
if shutil.which(name) is None:
fail(f"Required command is not installed: {name}")
def require_environment(name: str, expected: str) -> None:
actual = os.environ.get(name)
if not actual:
fail(f"{name} is not set")
if actual != expected:
fail(f"{name} must be {expected!r}, but is {actual!r}")
def check_integration_configuration() -> None:
pulse_socket = HOST_RUNTIME_DIR / "pulse" / "native"
dbus_socket = HOST_RUNTIME_DIR / "bus"
require_socket(pulse_socket)
require_socket(dbus_socket)
require_environment(
"PULSE_SERVER",
f"unix:{pulse_socket}",
)
require_environment(
"DBUS_SESSION_BUS_ADDRESS",
f"unix:path={dbus_socket}",
)
def run_command(command: list[str], description: str) -> None:
try:
subprocess.run(
command,
check=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE,
text=True,
)
except subprocess.CalledProcessError as error:
details = error.stderr.strip() or f"exit status {error.returncode}"
fail(f"{description} failed: {details}")
def write_test_tone(path: Path) -> None:
sample_rate = 44_100
duration_seconds = 0.25
frequency_hz = 880
amplitude = 0.25
frame_count = int(sample_rate * duration_seconds)
frames = bytearray()
for index in range(frame_count):
sample = int(32_767 * amplitude * math.sin(2 * math.pi * frequency_hz * index / sample_rate))
frames.extend(sample.to_bytes(2, "little", signed=True))
with wave.open(str(path), "wb") as sound:
sound.setnchannels(1)
sound.setsampwidth(2)
sound.setframerate(sample_rate)
sound.writeframes(frames)
def check_notification() -> None:
require_command("notify-send")
run_command(
[
"notify-send",
"Container Test",
"Notification integration works!",
],
"Desktop notification test",
)
print("Sent a desktop notification.")
def check_audio() -> None:
require_command("paplay")
with tempfile.TemporaryDirectory() as temp_dir:
sound_path = Path(temp_dir) / "test.wav"
write_test_tone(sound_path)
run_command(
["paplay", str(sound_path)],
"Audio playback test",
)
print("Played a test sound through PulseAudio/PipeWire.")
def main() -> None:
check_integration_configuration()
check_notification()
check_audio()
print("Container integration commands completed successfully.")
print("Please confirm that the notification appeared and the sound was audible.")
if __name__ == "__main__":
main()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment