- Shell 96.3%
- Dockerfile 3.7%
|
|
||
|---|---|---|
| .github/workflows | ||
| config | ||
| json | ||
| tests | ||
| tmpfiles.d | ||
| .env.example | ||
| .gitignore | ||
| 85-wolf-virtual-inputs.rules | ||
| docker-compose.nvidia.yml | ||
| docker-compose.yml | ||
| Dockerfile | ||
| install.sh | ||
| README.md | ||
| startup-app.sh | ||
| uninstall.sh | ||
| wolf-den-entrypoint.sh | ||
| wolf-den-launch.sh | ||
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 bundled on :8080.
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 |
| Static LAN IP (CIDR) | must be CIDR | — |
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 (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.
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) |
Dockerfile (Wolf + Wolf Den wrapper entrypoint) |
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 on the PVE host:
Configure itsbash -c "$(curl -fsSL https://raw.githubusercontent.com/games-on-whales/LXC2Docker/main/scripts/install-on-pve.sh)" systemctl enable --now docker-lxc-daemon--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:
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. Withoutcurl -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.jsonnvidia-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. - 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, ...
# The image is published by the CI workflow; pull it (or `docker compose build`).
docker compose up -d # Intel / AMD
docker compose -f docker-compose.yml -f docker-compose.nvidia.yml up -d # NVIDIA
- Wolf Den UI:
http://<WOLF_LAN_IP>:8080 - 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 (+ Wolf
Den 8080/tcp). With the gow.lan static IP these are reachable directly on the
CT. On segmented LANs you may need a multicast querier on the bridge for
discovery.
Publishing the image
docker compose build builds locally. To publish a shared
ghcr.io/games-on-whales/wolf-proxmox image, run the
.github/workflows/wolf-proxmox-image.yml workflow (manual dispatch), then set
WOLF_IMAGE to the pinned tag/digest.
Configuration
On first start the bundled startup-app.sh seeds /etc/wolf/config.toml with a
fresh host uuid and the default app profile set (config/default-config.toml —
Wolf UI, desktops, Steam, RetroArch, …), pins the GOW app images to
WOLF_IMAGE_TAG, and installs fake-udev. Edit /etc/wolf/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_IMAGE 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 restart
if you hit that. The tags are rewritten in config.toml on every start, so
switching is just an edit plus a restart.
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.