Running GUI Apps in LXD Containers
The LXD profile I use to run GUI tools on my Linux desktop, with shared display sockets, GPU access, and the limits of that isolation.
Contents
I wanted to keep GUI applications and their dependencies in a container while using them from my normal Linux desktop. This LXD profile connects the container to the host’s display sockets and GPU, so application windows appear alongside my other desktop apps.
This post walks through the LXD profile I use for that setup. It runs applications such as Antigravity in an isolated container while their windows render natively on my desktop.
The Core Problem: Display Sockets Don’t Cross Container Boundaries
On a modern Linux desktop, graphical applications connect through Unix-domain sockets. Wayland commonly uses $XDG_RUNTIME_DIR/wayland-0, while X11 or Xwayland uses a socket under /tmp/.X11-unix/. Those sockets live in host-managed runtime directories, which a container cannot access by default.
There are a few common workarounds:
- Bind-mounting the socket directory works, but it exposes the host’s runtime directory directly to the container and broadens the access surface.
- SSH X forwarding supports X11, but it adds latency and does not provide a native Wayland path.
- Running VNC or RDP inside the container adds a second desktop session and more moving parts.
I prefer LXD’s proxy device. It relays connections between a socket path on the host and a separate endpoint inside the container. The container does not receive a direct bind mount of the host socket, although it still gains access to the host display service.
The Profile
This example assumes a desktop user with UID/GID 1000, a Wayland socket at /run/user/1000/wayland-0, Xwayland on display :1, and a GPU-access group with GID 44 inside the container. Check the desktop IDs and display sockets on the host, and the GPU group ID in the container image.
config:
security.nesting: "true"
cloud-init.user-data: |
#cloud-config
package_update: true
packages:
# Desktop integration and session management
- dbus-user-session
- xdg-utils
- x11-utils
# Core graphics and hardware acceleration drivers
- libgl1
- libgl1-mesa-dri
- libegl1
- mesa-vulkan-drivers
# Wayland rendering base
- libwayland-egl1
# Base typography (prevents invisible text in headless containers)
- fontconfig
- fonts-liberation
- fonts-ubuntu
write_files:
- path: /usr/local/bin/setup-gui-sockets.sh
permissions: "0755"
content: |
#!/bin/bash
uid=1000
gid="$(getent passwd ${uid} | cut -d: -f4)"
run_dir="/run/user/${uid}"
tmp_dir="/tmp/.X11-unix"
mkdir -p "${run_dir}" && chmod 700 "${run_dir}" && chown ${uid}:${gid} "${run_dir}"
mkdir -p "${tmp_dir}" && chmod 1777 "${tmp_dir}"
if [ -S /mnt/wayland-0 ] && [ ! -e "${run_dir}/wayland-0" ]; then
ln -s /mnt/wayland-0 "${run_dir}/wayland-0"
fi
if [ -S /mnt/X1 ] && [ ! -e "${tmp_dir}/X1" ]; then
ln -s /mnt/X1 "${tmp_dir}/X1"
fi
- path: /etc/systemd/system/setup-gui-sockets.service
content: |
[Unit]
Description=Link GUI sockets from /mnt to runtime dirs
After=local-fs.target
[Service]
Type=oneshot
ExecStart=/usr/local/bin/setup-gui-sockets.sh
[Install]
WantedBy=multi-user.target
- path: /home/ubuntu/.profile
append: true
owner: "ubuntu:ubuntu"
defer: true
content: |
export WAYLAND_DISPLAY=wayland-0
export XDG_SESSION_TYPE=wayland
export QT_QPA_PLATFORM=wayland
export DISPLAY=:1
export XDG_RUNTIME_DIR=/run/user/1000
runcmd:
- systemctl daemon-reload
- systemctl enable --now setup-gui-sockets.service
- usermod -a -G video,render ubuntu
description: GUI applications with host display integration
devices:
gpu:
type: gpu
gid: 44
wayland_socket:
type: proxy
bind: container
connect: unix:/run/user/1000/wayland-0
listen: unix:/mnt/wayland-0
security.uid: "1000"
security.gid: "1000"
mode: "0777"
xwayland_socket:
type: proxy
bind: container
connect: unix:/tmp/.X11-unix/X1
listen: unix:/mnt/X1
security.uid: "1000"
security.gid: "1000"
mode: "0777"How It Works
Devices: Proxy Sockets and GPU Passthrough
The devices section is where the real work happens.
wayland_socket and xwayland_socket are both proxy devices. For each one, LXD accepts connections at listen inside the container—/mnt/wayland-0 or /mnt/X1—and relays them to connect on the host. With the container-side bind setting, LXD creates the listening endpoint inside the container rather than on the host.
security.uid and security.gid make the proxy process drop privileges to UID/GID 1000. That matches the host desktop user in this example; it is not a portable constant. Verify the host user’s IDs before applying the profile, or the proxy may fail with EACCES.
Why both Wayland and X11? Some apps (especially Electron-based tools like Antigravity) may fall back to X11 via Xwayland even on a Wayland session. Providing both sockets means you don’t have to fight with environment variables to coerce an app into the right protocol.
The gpu device exposes the host GPU to the container. Its gid option sets the group owner of the device inside the container, so 44 is only correct when that is the intended GPU-access group in the image. Verify the video and render group IDs rather than assuming a universal value. Hardware acceleration is noticeably smoother than software rendering for GPU-composited interfaces.
cloud-init Package Installation
The packages install three things:
- Graphics stack:
libgl1,libgl1-mesa-dri,libegl1,mesa-vulkan-drivers, andlibwayland-egl1provide Mesa’s OpenGL, EGL, Vulkan, and Wayland support. Without the required libraries, applications may fail to start or fall back to CPU rendering.- NVIDIA note: Follow LXD’s NVIDIA passthrough path rather than installing a second kernel driver inside the container. Depending on the LXD version and setup, use an NVIDIA CDI device or
nvidia.runtimeto pass the host runtime libraries into the container.
- NVIDIA note: Follow LXD’s NVIDIA passthrough path rather than installing a second kernel driver inside the container. Depending on the LXD version and setup, use an NVIDIA CDI device or
- Desktop plumbing:
dbus-user-sessionandxdg-utilssupport interprocess messaging and MIME/URL handling. Many desktop applications expect a D-Bus user session; without one, some integrations fail silently. - Fonts: Fonts are easy to overlook. Without them, applications may show missing glyphs or placeholder text.
fonts-liberationandfonts-ubuntucover several common families.
The Socket Setup Script
The proxy devices create sockets at /mnt/wayland-0 and /mnt/X1 inside the container. Applications expect them at $XDG_RUNTIME_DIR/wayland-0 and the X11 socket path under /tmp/.X11-unix/, so the profile links the proxy endpoints into those locations.
The setup-gui-sockets.sh script bridges this gap by symlinking the /mnt sockets into the expected locations:
if [ -S /mnt/wayland-0 ] && [ ! -e "${run_dir}/wayland-0" ]; then
ln -s /mnt/wayland-0 "${run_dir}/wayland-0"
fi
if [ -S /mnt/X1 ] && [ ! -e "${tmp_dir}/X1" ]; then
ln -s /mnt/X1 "${tmp_dir}/X1"
fiThe script also creates /run/user/1000 with mode 0700 and ownership assigned to the ubuntu user. Many Wayland-aware applications check this runtime directory at launch and exit if it is missing or has the wrong ownership.
The script runs as a oneshot systemd service enabled for multi-user.target and ordered after local-fs.target, so it runs during boot before a typical interactive user session.
Environment Variables
The .profile additions wire up the session for both Wayland and X11:
export WAYLAND_DISPLAY=wayland-0 # Wayland socket name (relative to XDG_RUNTIME_DIR)
export XDG_SESSION_TYPE=wayland # Tells toolkits this is a Wayland session
export QT_QPA_PLATFORM=wayland # Forces Qt apps to use the Wayland backend
export DISPLAY=:1 # X display number (matches XWayland socket X1)
export XDG_RUNTIME_DIR=/run/user/1000 # Runtime dir for the ubuntu userDISPLAY=:1 matches the X1 socket used by this profile. Do not assume every host uses display :1; confirm the active X11 or Xwayland display and socket before applying the profile.
Using the Profile
Save the profile as gui.yaml, then create it:
lxc profile create gui < gui.yamlLaunch a container with it:
lxc launch ubuntu:24.04 ai-dev --profile default --profile guiAfter cloud-init finishes—monitor it with lxc exec ai-dev -- cloud-init status --wait—open a login shell as the container’s ubuntu user. This loads the desktop settings from .profile. Install your application, then launch it as ubuntu:
lxc exec ai-dev -- su --login ubuntu
# Install your application, then launch it as ubuntu.Caveats
- Audio: This profile does not configure audio. Supporting it usually requires exposing the appropriate PipeWire socket and matching its permissions and session environment.
- Clipboard: Clipboard behavior depends on the compositor and protocol in use. Test it on your host before adding a
wl-copy/wl-pastebridge or another compositor-specific workaround.- X11 clipboard sharing usually works, but X11 provides a weaker isolation boundary: a client may be able to observe or interact with other X11 clients on the host.
- NVIDIA GPUs: Device paths, runtime libraries, and group IDs may differ from Mesa-based setups. Use LXD’s documented NVIDIA passthrough configuration rather than assuming this generic profile is sufficient.
- User IDs: This profile assumes the
ubuntuuser has UID 1000 inside the container and the desktop user has UID/GID 1000 on the host. If either differs, update that side’s paths and IDs. The proxy’ssecurity.uidandsecurity.gidselect its host-side credentials; the runtime directory and GPU-access group must match the container user.
This setup is useful for isolating application files and dependencies, but display and GPU access deliberately cross the container boundary. Treat it as operational isolation for trusted tools, not as a VM-strength sandbox for hostile code.