Wolf on Proxmox (LXC2Docker)

Run Games-on-Whales Wolf — stream virtual desktops and applications to Moonlight clients — on a Proxmox VE host, backed by docker-lxc-daemon. This is the Proxmox counterpart of the SmoothNAS Wolf plugin: the same container shape, expressed as a Compose file against the host daemon, with the Wolf Den management UI on :8080. Wolf and Wolf Den run the upstream Games-on-Whales images unmodified, from the stable or beta channel.

Quick install (Proxmox host)

Run on your Proxmox VE node:

bash -c "$(curl -fsSL https://raw.githubusercontent.com/games-on-whales/proxmox-community-script/main/install.sh)"

It installs docker-lxc-daemon, the virtual-input/runtime prerequisites, and Wolf. Everything is asked up front — the same selection model as the Wolf quickstart scripts this installer descends from: a numbered list of what the host actually has, defaulting to the first entry, auto-selected when there is only one candidate.

PromptCandidates / boundDefault
CT id for the Wolf containernot owned by any node in the cluster (/etc/pve/nodes/*/)lowest free id
Storage for the Wolf CTactive rootdir storages, with free space (pvesm status)first listed
Disk size, GB (CT rootfs)free space on the chosen storage100 GB
State volume size, GB (config, pairings, game data)what is left after the rootfs100 GB
CPU coresnproc4 cores
RAM, MBMemTotal4096 MB
GPUrender nodes under /sys/class/drm/, as vendor name (driver, node)first listed
App image buildedge or fedora, checked against ghcredge
Wolf release channelstable or beta (Wolf and Wolf Den)stable
Static LAN IP (CIDR)must be CIDR—
Keyboard layout for the appsXKB layout (de, fr, us,de, …); - for nonethe host's (/etc/default/keyboard)
Locale for the appslocale name (de_DE.UTF-8, …); - for nonethe host's (/etc/default/locale)
Timezone for the appszone name (Europe/Berlin, …); - for nonethe host's (timedatectl)

nvidia_drm modeset=1 is probed before the GPU scan, since NVIDIA cards expose no render node without it. The bridge spec is derived from the LAN IP and the host's default route. To run unattended, preset WOLF_CTID, DLD_STORAGE, WOLF_LAN_IP, WOLF_DISK_SIZE, WOLF_STATE_SIZE, WOLF_CPUS, WOLF_MEMORY, WOLF_RENDER_NODE, WOLF_IMAGE_TAG, WOLF_CHANNEL, WOLF_KEYBOARD (and optionally WOLF_KEYBOARD_VARIANT), WOLF_LOCALE, WOLF_TZ (and optionally WOLF_BRIDGE) — anything left unset takes its default, and a preset that isn't a real candidate, or doesn't fit the host, aborts the install. Every size question states its unit and accepts the unit back (100, 100G and 100 GB all mean the same); an answer that isn't a size re-asks rather than falling through to the default. The manual steps below are the equivalent, broken out.

bash tests/gather-dryrun.sh exercises this section against a faked host.

Cluster CT ids and daemon upgrades

The installer requires docker-lxc-daemon >= 0.1.3, installs or upgrades the package when needed, and restarts the service to load the new binary and settings. Release 0.1.2 ignores dld.vmid and checks only the local node's guest ids: choosing WOLF_CTID=200 cannot prevent it from trying a peer's CT id instead. The fixed daemon checks CT and VM ids across every node, honours Wolf's pinned id, and keeps image-template allocation from taking that id during a cold start.

WOLF_CTID controls Wolf itself. Session and image-template CTs take other free cluster-wide ids; it does not set an allocation floor or reserve a range. Proxmox still arbitrates simultaneous creates from other nodes, so a selected id can become occupied between the check and creation. No CT-id range is reserved by this installer.

bash tests/daemon-dryrun.sh checks fresh installs, upgrades, version rejection, and daemon restarts against a faked host.

Uninstall

bash -c "$(curl -fsSL https://raw.githubusercontent.com/games-on-whales/proxmox-community-script/main/uninstall.sh)"

Removes the Wolf CT and any session CTs, docker-lxc-daemon, the host prerequisites and /opt/wolf-proxmox, and unmounts /etc/wolf. The state volume — config, client pairings and the game library — is kept; pass --purge-state to free it, --keep-daemon to leave the daemon package installed, --yes to skip the confirmation.

Why stopping it by hand doesn't work. The Wolf CT is declared restart: unless-stopped, and the daemon's restart watcher brings back anything it finds exited without a recorded stop. pct stop looks exactly like a crash to it and is undone within five seconds, which is why the container appears to come straight back. Removal has to go through Docker, which deletes the record the watcher reads — docker rm -f, what the uninstaller uses.

That is not sufficient on every daemon build, either: the watcher can restart a container in the window between the stop landing and the record being deleted, which leaves a CT running that nothing tracks any more. The uninstaller verifies each removal and sweeps up to three times, then — once the daemon is stopped and a CT count finally means something — names any Proxmox CT left standing with no Docker record, along with the pct commands to remove it. It does not destroy guests on its own.

bash tests/uninstall-dryrun.sh exercises it against a faked host, including a container that comes back and one that outlives its record.

How it works

docker-lxc-daemon on the PVE host serves the Docker Engine API but backs every container with an LXC / Proxmox CT. Wolf runs as one such CT with the host socket bind-mounted in, so each session it starts (a desktop, an app, or a game) is launched as a sibling Proxmox CT through the same daemon — not a nested container.

The Compose file reproduces the SmoothNAS wolf-runtime + gpu-* profiles:

SmoothNAS pluginHere (Compose → LXC2Docker)
wolf-runtime socket mount-v /var/run/docker.sock:/var/run/docker.sock
wolf-runtime devices/capsdevices: + cap_add: + device_cgroup_rules:
gpu-nvidia profiledocker-compose.nvidia.yml (NVIDIA_VISIBLE_DEVICES → CDI)
gpu-intel / gpu-amdbase file (/dev/dri)
tier-bound state volume-v /etc/wolf:/etc/wolf
identity-mapped runtime vol-v ${WOLF_RUNTIME_DIR}:${WOLF_RUNTIME_DIR}
bundled Wolf Den (:8080)upstream wolf-den as a second service, sharing Wolf's API socket
hostExpose Moonlight portsgow.lan static IP on the CT
SmoothNAS LXC runtimedocker-lxc-daemon on the PVE host
(new for Proxmox)gow.pve=true → sessions are Proxmox CTs

Prerequisites

  1. docker-lxc-daemon >= 0.1.3 on the PVE host:

    bash -c "$(curl -fsSL https://raw.githubusercontent.com/games-on-whales/LXC2Docker/main/scripts/install-on-pve.sh)"
    systemctl enable --now docker-lxc-daemon

    Configure its --lan-* bridge so CTs can take a static LAN IP.

  2. GPU drivers loaded on the host (Intel/AMD /dev/dri, or NVIDIA with nvidia-drm modeset=1). Confirm with ls /dev/dri / nvidia-smi.

  3. NVIDIA only — nvidia-container-toolkit on the host, for nvidia-ctk:

    curl -fsSL https://nvidia.github.io/libnvidia-container/gpgkey \
      | gpg --dearmor -o /usr/share/keyrings/nvidia-container-toolkit-keyring.gpg
    curl -fsSL https://nvidia.github.io/libnvidia-container/stable/deb/nvidia-container-toolkit.list \
      | sed 's#deb https://#deb [signed-by=/usr/share/keyrings/nvidia-container-toolkit-keyring.gpg] https://#g' \
      > /etc/apt/sources.list.d/nvidia-container-toolkit.list
    apt-get update && apt-get install -y nvidia-container-toolkit-base
    nvidia-ctk cdi generate --format=json --output=/etc/cdi/nvidia.json

    The daemon injects the host driver into the CT by translating this CDI spec into LXC mounts and device nodes — it does not pass the GPU through directly. Without nvidia-ctk it logs NVIDIA GPU setup failed ... (container will start without GPU) to its journal and builds the CT with no driver libraries and no /dev/nvidia*, while /dev/dri still gets passed through. Wolf then finds the NVIDIA render node, commits to the NVENC pipeline, and panics in eglInitialize on the first stream. Moonlight reports that as "No video received from host" naming UDP 47998/48000 — ports Wolf never uses — so nothing on screen points at the GPU. The installer does all of this for you and refuses to continue if it can't.

    32-bit driver libraries. nvidia-ctk emits only the 64-bit driver, and a Proxmox host has no i386 multiarch, so the .run installer skips the 32-bit libraries. Steam's GPU probe and every 32-bit GL/Vulkan program in a session then fall back to llvmpipe (Steam reports llvmpipe and builds its shader pre-cache for it). The installer therefore installs /usr/local/sbin/wolf-nvidia-cdi (nvidia-cdi.sh), which regenerates the spec and adds the NVIDIA 32-bit libraries extracted (never installed) from the .run of exactly the running driver version — downloaded from download.nvidia.com once per driver version, or taken from WOLF_NVIDIA_RUN=/path/to/NVIDIA-Linux-x86_64-<version>.run for that run (an offline host). They are kept in /var/lib/wolf-proxmox/nvidia-lib32/<version>/; other versions are removed. wolf-nvidia-cdi.service re-runs it at every boot, so a driver upgrade is picked up after the reboot; run wolf-nvidia-cdi by hand to apply one without rebooting. Set WOLF_NVIDIA_LIB32=0 (installer environment, or /etc/default/wolf-nvidia-cdi) to skip the 32-bit libraries. If the download fails, the spec is still written with the 64-bit driver and a warning.

  4. Virtual-input udev rules on the host:

    install -m 0644 85-wolf-virtual-inputs.rules /etc/udev/rules.d/
    modprobe uinput
    udevadm control --reload-rules && udevadm trigger
  5. A free static LAN IP for the Wolf CT (Moonlight discovery relies on mDNS/multicast reaching the LAN).

  6. Bind-mount source dirs on the host — Wolf's /etc/wolf (state) and /run/wolf (runtime) are bind-mounted from the host, so they must exist before it starts (/run is tmpfs, so recreate /run/wolf on boot):

    install -m 0644 tmpfiles.d/wolf-proxmox.conf /etc/tmpfiles.d/
    systemd-tmpfiles --create /etc/tmpfiles.d/wolf-proxmox.conf

Bring up

cp .env.example .env      # set WOLF_LAN_IP, DLD_STORAGE, WOLF_RENDER_NODE, WOLF_CHANNEL, ...
docker compose up -d                                             # Intel / AMD
docker compose -f docker-compose.yml -f docker-compose.nvidia.yml up -d   # NVIDIA
  • Wolf Den UI: http://<proxmox-host>:8080 (WOLF_DEN_PORT). The daemon publishes it on the Proxmox host, not on the Wolf CT's LAN IP.
  • Pairing: point Moonlight at <WOLF_LAN_IP>, then open http://<WOLF_LAN_IP>:47989 to enter the PIN and pick a desktop/app.

Ports (Moonlight)

47984/tcp 47989/tcp 47999/udp 48010/tcp 48100/udp 48200/udp. With the gow.lan static IP these are reachable directly on the CT. Wolf Den's 8080/tcp is on the Proxmox host instead. On segmented LANs you may need a multicast querier on the bridge for discovery.

Images

There is no image of this recipe's own. The two services run the upstream images as published:

ServiceImage
wolfghcr.io/games-on-whales/wolf:${WOLF_CHANNEL}
wolf-denghcr.io/games-on-whales/wolf-den:${WOLF_CHANNEL}

WOLF_CHANNEL is stable (the default) or beta. Wolf and Wolf Den updates arrive with docker compose pull && docker compose up -d. Set WOLF_IMAGE / WOLF_DEN_IMAGE to a tag or digest to pin either one instead. What this recipe adds is mounted into the Wolf container, not built into an image: wolf-config-init.sh (as /etc/cont-init.d/40-wolf-proxmox.sh) and config/default-config.toml.

Wolf Den runs in its own small CT (WOLF_DEN_DISK_SIZE, default 8G). It reaches Wolf through the API socket at ${WOLF_RUNTIME_DIR}/wolf.sock, which both CTs bind from the same host directory.

Configuration

On first start wolf-config-init.sh seeds /etc/wolf/cfg/config.toml with a fresh host uuid and the default app profile set (config/default-config.toml — Wolf UI, desktops, Steam, RetroArch, …), and on every start it pins the GOW app images to WOLF_IMAGE_TAG and applies the keyboard layout, locale and timezone (see below). The upstream image's own startup does the rest (fake-udev, PulseAudio, Wolf). Edit /etc/wolf/cfg/config.toml afterwards (or use Wolf Den) to customise apps; it is preserved across restarts.

App image build (edge or fedora): the installer asks which build of the app images to run — edge (Ubuntu-based, the tested default) or fedora (Fedora-based, newer Mesa). It applies to the apps Wolf launches (Firefox, Steam, RetroArch, XFCE, …) and the pulseaudio sidecar; Wolf and Wolf Den themselves come from WOLF_CHANNEL and are unaffected. The installer checks the tag really is published on ghcr before writing it, so a typo fails at install rather than as an app that silently won't launch. On NVIDIA, non-edge builds have been seen to fall back to llvmpipe software rendering, which Wolf's capture/encode pipeline cannot negotiate (black screen) — set WOLF_IMAGE_TAG=edge in .env and apply it if you hit that. The tags are rewritten in config.toml on every start, so switching is just an edit plus docker compose up -d.

Applying .env changes: run docker compose up -d in the recipe directory. Compose recreates the containers whose settings changed; docker restart wolf does not read .env and keeps the old values. With WOLF_CTID pinned, docker-lxc-daemon 0.1.3 fails that recreate with allocate VMID: <id> is already in use by a guest in this cluster, because Compose creates the replacement before it removes the old container. On those releases use docker compose rm -sf wolf && docker compose up -d instead.

Keyboard layout, locale and timezone: without them every session comes up with a US keyboard, English UI and UTC. The installer asks for all three, defaulting to the host's own, and writes them to .env as WOLF_KEYBOARD (plus WOLF_KEYBOARD_VARIANT, e.g. nodeadkeys), WOLF_LOCALE and WOLF_TZ. On every start wolf-config-init.sh adds them to the env of every docker app in config.toml — including apps added later through Wolf Den — as XKB_DEFAULT_LAYOUT, XKB_DEFAULT_VARIANT, LANG, LANGUAGE (e.g. de_DE:de) and TZ. An app that sets one of these itself keeps its own value. Changing or clearing a setting in .env updates the entries the recipe wrote once applied (docker compose up -d); it remembers which those were in /etc/wolf/cfg/.wolf-proxmox-env.

Proton logs: off by default. Set WOLF_PROTON_LOG=1 in .env (or a Wine debug channel list such as +seh,+timestamp) and apply it with docker compose up -d to have Proton write steam-<appid>.log into the app's home under /etc/wolf while you debug a game, then clear it again: a game that throws in a loop can log tens of MB a second until /etc/wolf is full. It is applied as PROTON_LOG the same way as the settings above, so an app that sets its own keeps it. The PROTON_LOG=1 that earlier versions seeded into the Steam app is removed on the next start unless WOLF_PROTON_LOG asks for it.

Game data / disk space: Wolf stores its state, its client pairings and per-app game data (Steam libraries) under /etc/wolf. The installer backs that with a volume allocated from DLD_STORAGE by pvesm, so it behaves the same on lvmthin, LVM, ZFS, dir or NFS — no host filesystem layout to arrange, and nothing on the OS root. Size it with WOLF_STATE_SIZE (GB, prompted at install time, default 100); set WOLF_STATE_VOLUME to adopt an existing volume id.

The volume takes the first free VM ID from 9000 up — well clear of the ids the daemon hands to session CTs, so destroying a guest can never take the game library with it — and lives outside the Wolf container on purpose: the daemon discards a warm CT rootfs whenever the image ref or digest changes, so state kept on the rootfs would be destroyed by a routine image update. Re-running the installer reuses the volume and never reformats it.

Because the volume belongs to no guest, nothing else ever activates it: PVE leaves guest volumes deactivated (PVE 9 creates LVM ones with autoactivation off) and no pct start covers this one. The installer activates it through PVE::Storage before formatting — without that, mkfs runs against a path with no device behind it and reports "the file /dev/pve/vm-9000-wolf-state does not exist and no size was specified", which reads like the size question was ignored but is not related to it — and opts LVM volumes back into boot-time autoactivation so the /etc/wolf fstab entry has something to mount.

bash tests/state-volume-dryrun.sh exercises this section against a faked storage layer.

CT id (WOLF_CTID): the installer asks which Proxmox CT id the Wolf container should take, defaulting to the lowest id no guest owns and refusing one that is already in use. It checks every node — /etc/pve/nodes/*/lxc and /etc/pve/nodes/*/qemu-server, not just this node's config dir — because pct create rejects an id owned anywhere in the cluster. The daemon's own allocator looks only at the local node, so on a cluster it eventually picks an id a peer holds and the create fails with

manager: pct create 103 from tarball: CT 103 already exists on node 'pve1'

which surfaces as Wolf restarting forever, one id higher each time. Pinning the id via WOLF_CTID (passed to the daemon as the dld.vmid label) avoids that. The Wolf CT is always created on the node you run the installer on — the daemon drives pct locally and has no way to target another node — so run it on the node whose GPU you want Wolf to use.

CT sizing: WOLF_DISK_SIZE sizes the Wolf CT's rootfs on DLD_STORAGE, and WOLF_CPUS / WOLF_MEMORY cap its cores and RAM. Leave the latter two unset and the CT is uncapped — all host cores and RAM, the daemon's default. Session CTs that Wolf launches are sized separately, by Wolf.

Controllers

Wolf builds virtual gamepads on the host via uinput and injects them into each session, so controllers work in the Wolf UI, Steam Big Picture, and games.

Known caveat — Steam Input: for games launched through Steam with Steam Input enabled, the controller may not register in-game. Steam Input grabs the physical pad and builds its own virtual gamepad via /dev/uinput inside the session container, which isn't passed through to sessions yet (only Wolf itself gets uinput). Until that lands, disable Steam Input so the game reads Wolf's pad directly:

  • Per game: game → ⚙ → Controller → Override for … → Disable Steam Input, or
  • Globally: Steam → Settings → Controller → turn off the desktop/BPM toggles.

Menus (Wolf UI, Big Picture) are unaffected — they read the pad over evdev.

Status

Verified on a live Proxmox VE host (RTX 5080): Wolf + Wolf Den, sibling session CTs, controllers, and hardware NVENC all working. Mirrors the production SmoothNAS Wolf plugin.