Skip to content
by Adithya Rajendran

Running GUI Apps in LXD Containers

Run GUI tools such as Antigravity in an isolated LXD container while rendering their windows on the host desktop.

Containers are great for isolating workloads—until you need to run a GUI application. At that point, the usual advice is to use a VM and move on. There is a lighter option: relay the host’s display sockets into an LXD container, grant the container GPU access, and let the application render like any other desktop app.

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 mapped to GID 44. Verify each value on the host before importing the profile.

yaml
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}"
          [ -S /mnt/wayland-0 ] && [ ! -e "${run_dir}/wayland-0" ] && ln -s /mnt/wayland-0 "${run_dir}/wayland-0"
          [ -S /mnt/X1 ] && [ ! -e "${tmp_dir}/X1" ] && ln -s /mnt/X1 "${tmp_dir}/X1"
      - 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: Sandboxed container for Graphical Applications
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:

  1. Graphics stack: libgl1, libgl1-mesa-dri, libegl1, mesa-vulkan-drivers, and libwayland-egl1 provide 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 pass-through 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.runtime to pass the host runtime libraries into the container.
  2. Desktop plumbing: dbus-user-session and xdg-utils support interprocess messaging and MIME/URL handling. Many desktop applications expect a D-Bus user session; without one, some integrations fail silently.
  3. Fonts: Fonts are easy to overlook. Without them, applications may show missing glyphs or placeholder text. fonts-liberation and fonts-ubuntu cover 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:

bash
[ -S /mnt/wayland-0 ] && [ ! -e "${run_dir}/wayland-0" ] && ln -s /mnt/wayland-0 "${run_dir}/wayland-0"
[ -S /mnt/X1 ] && [ ! -e "${tmp_dir}/X1" ] && ln -s /mnt/X1 "${tmp_dir}/X1"

The 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:

bash
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 user

DISPLAY=: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:

bash
cat gui.yaml | lxc profile create gui -

Launch a container with it:

bash
lxc launch ubuntu:24.04 ai-dev --profile default --profile gui

After cloud-init finishes—monitor it with lxc exec ai-dev -- cloud-init status --wait—open a shell and install your application:

bash
lxc shell ai-dev
# install Antigravity, run it — it renders on your host desktop

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-paste bridge 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 pass-through configuration rather than assuming this generic profile is sufficient.
  • UID mapping: This profile assumes the ubuntu user has UID 1000 inside an unprivileged container. If the container uses a different UID, update the socket ownership and proxy settings to match.

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.