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.
| Prompt | Candidates / bound | Default |
|---|---|---|
| CT id for the Wolf container | not owned by any node in the cluster (/etc/pve/nodes/*/) | lowest free id |
| Storage for the Wolf CT | active rootdir storages, with free space (pvesm status) | first listed |
| Disk size, GB (CT rootfs) | free space on the chosen storage | 100 GB |
| State volume size, GB (config, pairings, game data) | what is left after the rootfs | 100 GB |
| CPU cores | nproc | 4 cores |
| RAM, MB | MemTotal | 4096 MB |
| GPU | render nodes under /sys/class/drm/, as vendor name (driver, node) | first listed |
| App image build | edge or fedora, checked against ghcr | edge |
| Wolf release channel | stable or beta (Wolf and Wolf Den) | stable |
| Static LAN IP (CIDR) | must be CIDR | — |
| Keyboard layout for the apps | XKB layout (de, fr, us,de, …); - for none | the host's (/etc/default/keyboard) |
| Locale for the apps | locale name (de_DE.UTF-8, …); - for none | the host's (/etc/default/locale) |
| Timezone for the apps | zone name (Europe/Berlin, …); - for none | the 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 plugin | Here (Compose → LXC2Docker) |
|---|---|
wolf-runtime socket mount | -v /var/run/docker.sock:/var/run/docker.sock |
wolf-runtime devices/caps | devices: + cap_add: + device_cgroup_rules: |
gpu-nvidia profile | docker-compose.nvidia.yml (NVIDIA_VISIBLE_DEVICES → CDI) |
gpu-intel / gpu-amd | base 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 ports | gow.lan static IP on the CT |
| SmoothNAS LXC runtime | docker-lxc-daemon on the PVE host |
| (new for Proxmox) | gow.pve=true → sessions are Proxmox CTs |
Prerequisites
-
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-daemonConfigure its
--lan-*bridge so CTs can take a static LAN IP. -
GPU drivers loaded on the host (Intel/AMD
/dev/dri, or NVIDIA withnvidia-drm modeset=1). Confirm withls /dev/dri/nvidia-smi. -
NVIDIA only —
nvidia-container-toolkiton the host, fornvidia-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.jsonThe 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-ctkit logsNVIDIA 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/dristill gets passed through. Wolf then finds the NVIDIA render node, commits to the NVENC pipeline, and panics ineglInitializeon 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-ctkemits only the 64-bit driver, and a Proxmox host has no i386 multiarch, so the.runinstaller 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 reportsllvmpipeand 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.runof exactly the running driver version — downloaded fromdownload.nvidia.comonce per driver version, or taken fromWOLF_NVIDIA_RUN=/path/to/NVIDIA-Linux-x86_64-<version>.runfor that run (an offline host). They are kept in/var/lib/wolf-proxmox/nvidia-lib32/<version>/; other versions are removed.wolf-nvidia-cdi.servicere-runs it at every boot, so a driver upgrade is picked up after the reboot; runwolf-nvidia-cdiby hand to apply one without rebooting. SetWOLF_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. -
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 -
A free static LAN IP for the Wolf CT (Moonlight discovery relies on mDNS/multicast reaching the LAN).
-
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 (/runis tmpfs, so recreate/run/wolfon 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 openhttp://<WOLF_LAN_IP>:47989to 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:
| Service | Image |
|---|---|
wolf | ghcr.io/games-on-whales/wolf:${WOLF_CHANNEL} |
wolf-den | ghcr.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.