↓ Skip to main content

XRdp sound support

·1699 words·9 mins
Max Severance
Author
Max Severance
Architecting fabric convergence at hyperscale.
Table of Contents

Sound Support in xrdp Docker via PipeWire: A Comprehensive Implementation Guide
#

Introduction
#

This blog post documents a complete implementation of audio sound support in an xrdp Docker container using PipeWire. The session covers everything from initial investigation through successful implementation, including troubleshooting, multiple iterations, and final refactoring for maintainability. Whether you’re running xrdp in Docker for remote desktop access or building a headless Linux desktop environment, this guide provides detailed instructions for enabling audio redirection to your RDP client.

TLDR
#

  • Added PipeWire sound support to xrdp Docker container using pipewire-module-xrdp for audio redirection
  • Installed Chrome and volume control (pavucontrol-qt) with desktop shortcuts
  • Fixed environment variables (XDG_RUNTIME_DIR, PULSE_SERVER) to propagate to user sessions via .xsession
  • Used XDG autostart to load the xrdp audio module in user session context (not at container startup)
  • Refactored configuration from inline generation to file-based approach for maintainability

Initial Setup Investigation
#

The session began with checking the existing xrdp Docker setup to understand the current state and determine what was needed for sound support.

Checking the Dockerfile
#

The assistant examined the existing Dockerfile located at dev/xrdpdocker/Dockerfile:

FROM debian:forky

RUN apt update && apt install -y --no-install-recommends \
	xrdp \
	wireplumber \
	xorg \
	xorgxrdp \
	lxqt \
	dbus-x11 \
	xterm \
	x11-xserver-utils \
	konsole \
	sudo \
	ca-certificates \
	fish \
	tmux \
	mesa-utils \
	&& apt clean \
	&& apt remove -y lxqt-powermanagement 2>/dev/null || true

The existing setup already had wireplumber installed (a PipeWire session manager), but the core PipeWire packages and sound redirection modules were missing.

Checking the Start Script
#

The startup script at dev/xrdpdocker/start-kde.sh was also reviewed:

#!/bin/bash
export LANG=C.UTF-8
export TERM=xterm
export XDG_DATA_DIRS=/usr/share/icons/Adwaita:/usr/share/icons:/usr/share/desktop-icons:/usr/share

sed -i 's/Hidden=true/Hidden=false/' /usr/share/icons/Adwaita/index.theme

mkdir -p ~/.config/lxqt
echo -e "[General]\nicon_theme=Adwaita\nTerminalCommand=konsole" > ~/.config/lxqt/lxqt.conf

# ... additional konsole configuration ...

echo startlxqt > ~/.xsession

umask 000
sudo rm -rf /var/run/xrdp/xrdp-sesman.pid
sudo /usr/sbin/xrdp-sesman & 

/usr/sbin/xrdp --nodaemon 2>/dev/null
echo READY
wait

The start script launched xrdp but had no PipeWire or audio configuration.


First Implementation Attempt
#

Adding PipeWire Packages
#

The initial approach added several PipeWire packages to the Dockerfile:

RUN apt update && apt install -y --no-install-recommends \
	xrdp \
	wireplumber \
	pipewire \
	pipewire-pulse \
	pipewire-audio-client-libraries \
	libpulse-mainloop-glib0 \
	pulseaudio \
	# ... other packages ...

The reasoning was that xrdp’s sound module historically worked with PulseAudio, and pipewire-pulse provides PulseAudio protocol compatibility.

Adding Startup Logic
#

The start script was modified to start PipeWire services:

mkdir -p $XDG_RUNTIME_DIR

pipewire &
sleep 1
wireplumber &
sleep 1
pulseaudio --start --log-target=journal 2>/dev/null || true

This initial implementation had issues that needed correction.


User Correction: Pure PipeWire
#

The user questioned why PulseAudio was added when they specifically asked for PipeWire. This led to a necessary correction.

Removing Unnecessary PulseAudio
#

After the user’s feedback, the implementation was simplified to use pure PipeWire:

RUN apt update && apt install -y --no-install-recommends \
	xrdp \
	wireplumber \
	pipewire \
	pipewire-pulse \
	pipewire-audio-client-libraries \
	libpulse-mainloop-glib0 \
	# removed: pulseaudio \
	# ... other packages ...

The pipewire-pulse package provides PulseAudio compatibility for applications that need it, while keeping the core as PipeWire.

Updated Start Script
#

mkdir -p $XDG_RUNTIME_DIR

pipewire &
sleep 1
wireplumber &
# Removed pulseaudio startup - pipewire-pulse runs when needed

Research: pipewire-module-xrdp
#

A crucial discovery was that xrdp requires a specific module called pipewire-module-xrdp to enable sound redirection.

Searching for the Module
#

Web research revealed that:

  • The module is maintained by neutrinolabs (the xrdp developers)
  • It’s available in DebianSid/forky as a package
  • It provides scripts to automatically load PipeWire xrdp modules when an xrdp session starts

Debian Package Details
#

The package provides:

  • /usr/libexec/pipewire-module-xrdp/load_pw_modules.sh - script to load the module
  • /etc/xdg/autostart/pipewire-xrdp.desktop - XDG autostart integration
  • libpipewire-0.3-modules-xrdp - the actual module

Adding to Dockerfile
#

RUN apt update && apt install -y --no-install-recommends \
	xrdp \
	wireplumber \
	pipewire \
	pipewire-bin \
	libpipewire-0.3-modules \
	pipewire-module-xrdp \
	# ... other packages ...

Package Name Fix
#

During a sanity check, a package name error was discovered in the Dockerfile diff:

-	x11-xserver-utils \
+	x11-server-utils \

The correct package name is x11-xserver-utils, not x11-server-utils. This was corrected:

RUN apt update && apt install -y --no-install-recommends \
	# ... other packages ...
	x11-xserver-utils \
	# ...

Building the Image
#

The Docker image was built using nerdctl:

cd dev/xrdpdocker && sudo nerdctl build -t locals.tf/xrdp .

The build succeeded, pulling from the Debian forky base image and installing all required packages.

Container Startup
#

The container was started with the appropriate flags:

sudo nerdctl run -d --name xrdp --restart=always \
  -e XDG_RUNTIME_DIR=/tmp/ \
  -e XDG_DATA_DIRS=/usr/share/icons/Adwaita:/usr/share/icons:/usr/share/desktop-icons:/usr/share \
  --device /dev/dri/card0 --device /dev/dri/renderD128 \
  --network=bridge -v /data:/data \
  -p 0.0.0.0:3389:3389/tcp locals.tf/xrdp:latest

Startup Errors and Initial Issues
#

Several errors appeared in the container logs:

PipeWire Warnings at Startup
#

[E][19:05:18.684686] mod.rt       | [     module-rt.c:  294 pw_rtkit_bus_get()] Failed to connect to session bus: Unable to autolaunch a dbus-daemon without a $DISPLAY for X11
[E][19:05:18.684765] mod.rt       | [     module-rt.c:  294 pw_rtkit_bus_get()] Failed to connect to system bus: Failed to connect to socket /run/dbus/system_bus_socket: No such file or directory

These errors occur because PipeWire starts before X11/D-Bus is available. They’re expected and not fatal - PipeWire will work once the session starts.

sed Permission Error
#

sed: couldn't open temporary file /usr/share/icons/Adwaita/sedgUHuh1: Permission denied

This was from the start-kde.sh script trying to modify the Adwaita icon theme as root before the user session. Fixed by adding error suppression:

sed -i 's/Hidden=true/Hidden=false/' /usr/share/icons/Adwaita/index.theme 2>/dev/null || true

Sound Not Working: Diagnosis
#

After connecting via RDP, the user tested sound:

user@be3ec2fee8ec ~> pactl info
Connection failure: Connection refused
pa_context_connect() failed: Connection refused

user@be3ec2fee8ec ~> pw-cli list sinks
Error: "failed to connect: Host is down"

Root Cause: Environment Variables
#

The issue was that the XDG_RUNTIME_DIR wasn’t set in the user’s session environment. This variable tells applications where to find the PipeWire socket.


Environment Variable Propagation Issues
#

Multiple approaches were tried to set environment variables in the user’s desktop session.

Attempt 1: lxqt.conf [Environment] Section
#

The first attempt added environment variables to the LXQt configuration:

# ~/.config/lxqt/lxqt.conf
[General]
icon_theme=Adwaita
TerminalCommand=konsole

[Environment]
XDG_RUNTIME_DIR=/tmp/
PULSE_SERVER=unix:/tmp/pulse-native

LXQt doesn’t read the [Environment] section from lxqt.conf - this approach didn’t work.

Attempt 2: ~/.profile
#

Environment variables were added to ~/.profile:

export XDG_RUNTIME_DIR=/tmp/
export PULSE_SERVER=unix:/tmp/pulse-native

Fish (the default shell) doesn’t source ~/.profile by default - this also didn’t work.

Attempt 3: ~/.config/fish/config.fish
#

set -gx XDG_RUNTIME_DIR /tmp/
set -gx PULSE_SERVER unix:/tmp/pulse-native

Desktop applications don’t go through the shell - this didn’t work either.

Solution: ~/.xsession
#

The working solution was to create a proper .xsession file:

#!/bin/bash
export XDG_RUNTIME_DIR=/tmp/
export PULSE_SERVER=unix:/tmp/pulse/native
exec startlxqt

This file is sourced when the X session starts, making the variables available to all desktop applications.


Loading Module in User Session Context
#

A key discovery was that the PipeWire xrdp module needs to load in the user’s session context, not at container startup.

The Problem
#

The load-pipewire-module-xrdp script checks for XRDP_SESSION and XRDP_SOCKET_PATH environment variables:

if [ -n "$XRDP_SESSION" -a -n "$XRDP_SOCKET_PATH" ]; then
    # Load the xrdp module
    $PWCLI -m -d load-module libpipewire-module-xrdp sink.node.latency=4096 sink.stream.props={node.name=xrdp-sink} source.stream.props={node.name=xrdp-source}
fi

These variables are set by xrdp-sesman when a user connects, not when the container starts.

Solution: XDG Autostart
#

Created an autostart desktop file at ~/.config/autostart/pipewire-xrdp.desktop:

[Desktop Entry]
Type=Application
Name=XRDP Audio
Exec=/bin/bash -c "export XDG_RUNTIME_DIR=/tmp/ && export PULSE_SERVER=unix:/tmp/pulse/native && (pgrep -x pipewire >/dev/null || pipewire &) && sleep 1 && (pgrep -x wireplumber >/dev/null || wireplumber &) && sleep 1 && (pgrep -x pipewire-pulse >/dev/null || pipewire-pulse &) && sleep 2 && pw-cli -m -d load-module libpipewire-module-xrdp sink.node.latency=4096 sink.stream.props={node.name=xrdp-sink} source.stream.props={node.name=xrdp-source} 2>/dev/null && sleep 1 && SINK_ID=$(pw-cli ls Node | grep 'node.name = \"xrdp-sink\"' | head -1 | sed 's/.*id \\([0-9]*\\).*/\\1/') && SOURCE_ID=$(pw-cli ls Node | grep 'node.name = \"xrdp-source\"' | head -1 | sed 's/.*id \\([0-9]*\\).*/\\1/') && [ -n \"$SINK_ID\" ] && pw-metadata -n settings 0 default.audio.sink $SINK_ID && [ -n \"$SOURCE_ID\" ] && pw-metadata -n settings 0 default.audio.source $SOURCE_ID"

This autostart runs when the LXQt desktop starts, loading:

  1. pipewire (if not running)
  2. wireplumber (session manager)
  3. pipewire-pulse (PulseAudio compatibility)
  4. The xrdp audio module (creates xrdp-sink and xrdp-source nodes)
  5. Sets default audio sink/source

Adding Google Chrome
#

The user requested adding Google Chrome to the container.

Dockerfile Changes
#

Added the Chrome repository and installation:

RUN curl -fsSL https://dl.google.com/linux/linux_signing_key.pub | gpg --dearmor -o /usr/share/keyrings/google-chrome.gpg \
	&& echo "deb [arch=amd64 signed-by=/usr/share/keyrings/google-chrome.gpg] http://dl.google.com/linux/chrome/deb/ stable main" > /etc/apt/sources.list.d/google-chrome.list \
	&& apt update \
	&& apt install -y google-chrome-stable \
	&& apt clean

Desktop Shortcut
#

Added a desktop shortcut with --no-sandbox flag (required for running in containers):

[Desktop Entry]
Name=Chrome
Exec=/usr/bin/google-chrome --no-sandbox
Type=Application
Icon=google-chrome
Terminal=false

Volume Control Application
#

Added pavucontrol-qt (Qt-based PulseAudio/PipeWire volume control):

RUN apt update && apt install -y --no-install-recommends \
	# ... other packages ...
	pipewire-pulse \
	pulseaudio-utils \
	pavucontrol-qt \
	# ...

Desktop Shortcut
#

[Desktop Entry]
Name=Volume Control
Exec=/usr/bin/pavucontrol-qt
Type=Application
Icon=audio-volume-high
Terminal=false

Fixing Desktop Entry Execution Issues
#

When clicking desktop shortcuts, LXQt asked “open or execute”. This was fixed by marking desktop files as trusted:

for f in ~/Desktop/*.desktop; do
    [ -f "$f" ] && gio set "$f" metadata::trusted true 2>/dev/null || true
done

Pulse Server Path Correction
#

The PulseAudio socket path was initially wrong. The correct path is:

PULSE_SERVER=unix:/tmp/pulse/native

Not /tmp/pulse-native as initially configured.


Persistent Configuration Files
#

The final iteration refactored the setup to use config files instead of inline generation for better maintainability.

Directory Structure
#

xrdpdocker/
├── Dockerfile
├── start-kde.sh
├── xorg.conf
├── desktop/
│   ├── konsole.desktop
│   ├── chrome.desktop
│   └── volume.desktop
└── config/
    ├── lxqt/lxqt.conf
    ├── konsole/
    │   ├── Lucida.profile
    │   └── konsolerc
    ├── autostart/pipewire-xrdp.desktop
    └── xsession

Dockerfile Updates
#

# Copy desktop shortcuts
COPY desktop/*.desktop /home/user/Desktop/
RUN chmod +x /home/user/Desktop/*.desktop

# Copy skel config files
COPY config/lxqt/lxqt.conf /etc/skel/.config/lxqt/lxqt.conf
COPY config/konsole/Lucida.profile /etc/skel/.local/share/konsole/Lucida.profile
COPY config/konsole/konsolerc /etc/skel/.config/konsolerc
COPY config/autostart/pipewire-xrdp.desktop /etc/skel/.config/autostart/pipewire-xrdp.desktop
COPY config/xsession /etc/skel/.xsession
RUN chmod +x /etc/skel/.xsession

Start Script Simplification
#

The start script now just copies files from skel:

#!/bin/bash
export LANG=C.UTF-8
export TERM=xterm
export XDG_DATA_DIRS=/usr/share/icons/Adwaita:/usr/share/icons:/usr/share/desktop-icons:/usr/share
export XDG_RUNTIME_DIR=/tmp/

mkdir -p $XDG_RUNTIME_DIR

sed -i 's/Hidden=true/Hidden=false/' /usr/share/icons/Adwaita/index.theme 2>/dev/null || true

mkdir -p ~/.config/lxqt ~/.local/share/konsole ~/.config ~/.config/autostart

# Copy config files only if they don't exist (preserve user changes)
[ ! -f ~/.config/lxqt/lxqt.conf ] && cp -f /etc/skel/.config/lxqt/lxqt.conf ~/.config/lxqt/ 2>/dev/null || true
[ ! -f ~/.local/share/konsole/Lucida.profile

← XRDP in k8s Thin Blue Lines →