Rancher Desktop: the supported way to run Linux containers on Windows#
Rancher Desktop is the preferred Linux-container runtime on this host. It runs containers in a WSL2 distro, so the Linux CI image can be run locally and a CI failure reproduced on the dev box instead of guessed at through 25-minute pipeline round trips.
It does not replace the Windows-container lane. Windows containers still go
through Stevedore’s docker.exe — see windows-builds.md.
The two coexist: different runtimes, different images, different jobs.
Install#
winget install -e --id SUS.RancherDesktop
Verify it is up:
& "C:\Program Files\Rancher Desktop\resources\resources\win32\bin\rdctl.exe" version
rdctl client version: v1.23.1, targeting server version: v1 means the backend
is running. It provisions two WSL distros, rancher-desktop and
rancher-desktop-data, which are its own — leave them alone.
Use nerdctl, not docker#
Rancher Desktop defaults its container engine to containerd, and the CLI for
containerd is nerdctl. This is the single most common thing to get wrong here,
because docker.exe also ships in the same directory and appears to work
while talking to a completely different engine.
$nerdctl = "C:\Program Files\Rancher Desktop\resources\resources\win32\bin\nerdctl.exe"
& $nerdctl --namespace default run --rm alpine:3.20 uname -a
# Linux ... 6.18.33.2-microsoft-standard-WSL2 ... x86_64 Linux
That uname output is the check that matters: it proves a Linux kernel is
serving the container. Running docker.exe info on this host instead reports
OSType=windows, because the default docker context points at the Windows
engine used by the Stevedore lane. Both CLIs are on the box; only one of them is
talking to Linux.
--namespace default is worth passing explicitly. containerd namespaces are
real isolation, and images pulled into one namespace are invisible from another
— an image can be present and still “not found”.
If you would rather use the docker CLI, switch Rancher Desktop’s container
engine from containerd to dockerd (moby) in its settings. Then docker
targets Rancher and nerdctl stops being the entry point. Pick one and stay
with it; mixing them is how you end up debugging a missing image that is sitting
in the other engine’s store.
The image: always :latest#
Use ghcr.io/kataglyphis/kataglyphis_beschleuniger:latest for Linux
builds, in CI and locally.
:latest is the default multi-arch manifest: linux/amd64, linux/arm64
and linux/riscv64, re-shipped with fresh per-arch digests on every validating
rebuild (see the CHANGELOG entries that name them). The per-arch wrappers
behind it (:latest-amd64, :latest-arm64, :latest-riscv64) are internal
build tags; variant manifests, when published, are :latest-<variant> (e.g.
nvidia, rocm). The amd64/arm64 wrappers carry Hailo, so there is no
separate Hailo tag.
Until 2026-09-22 this manifest was called :latest-cross; that name is
retired: no release publishes it any more. The fleet resolves the ref
through @develop (since 2026-09-25), whose versions.env names :latest;
a lane left at @main still names :latest-cross, whose tags were deleted on
2026-09-29 (AGENTS.md § Image and tag naming), and fails at the pull. Move any lane still naming it to :latest. (The :latest of before 2026-08-27 was
a different, dead native-lane index — its children had 404’d for months and it
was deleted in the 2026-08-27 registry cleanup; the tag name was then reused
for the cross-lane manifest.)
.github/workflows/python-ci-linux.yml runs its containerized steps — static
analysis, tests, docs, packaging — in :latest on the x64 and arm64 runners
(CONTAINER_IMAGE in the job’s env, one image for both rows; the plan job
writes the matrix rows from the arches input, and prepare-linux-ci-host and
the run-in-linux-container steps consume the image; the upload steps run on the
host). Keep local runs on the same tag, or reproducing a CI failure
locally proves nothing.
:latest is not pinned by digest, so it still floats. Pinning would make
CI properly reproducible and is worth doing; it is not done yet.
Reproducing a CI step locally#
The Linux workflow mounts the repository at /workspace and runs the scripts in
scripts/linux/. The same shape works locally:
bash third_party/ANTfrastructure/linux/scripts/run-in-ci-image.sh . -- \
bash ./scripts/linux/cmake-configure-build.sh --preset linux-debug-clang --build-dir /tmp/build
Its options, and why this recipe is no longer four lines of nerdctl, are in
shared-script-libraries.md.
Notes that will save time:
The image is large. The first pull moves several GB; budget for it rather than assuming the command has hung.
Bind-mounting from a Dev Drive does not work for the Windows lane and the same caution applies here — see the tar-fallback note in
windows-builds.md. If a mount behaves strangely, test with a path on a normal NTFS volume before blaming the container.Build outputs land in the mounted tree, so a Linux build directory will appear next to the Windows ones. Keep them under distinct
--build-dirnames (build-linux,build-asan-clang) or the two toolchains will fight over one CMake cache.
An empty mount is not a missing drive#
Two independent things silently produce a mount that resolves fine and contains
nothing — no error, no warning, exit code 0. Both were hit on 2026-09-03 with
a repo on D: (a ReFS Dev Drive), and each one alone is enough.
1. The drive must be visible inside containerd’s own mount namespace.
Rancher’s distro automounts only the drives WSL gave it at boot — here just
C: — and containerd does not run in the distro’s init namespace:
containerd mnt:[4026532289]
init / wsl -d session mnt:[4026532204]
A mount -t drvfs D: /mnt/d from a plain wsl -d rancher-desktop -u root
session therefore lands in the wrong namespace and changes nothing that
containerd can see. Mount it where containerd actually looks:
wsl -d rancher-desktop -u root -e sh -c 'pid=$(ps -eo pid,comm | awk "\$2==\"containerd\" {print \$1; exit}"); nsenter -t "$pid" -m -- sh -c "mkdir -p /mnt/d && mount -t drvfs D: /mnt/d"'
Transient — gone when the VM restarts, and it touches a distro this page
otherwise says to leave alone. Redo it per boot, or keep the repo on C:.
2. Pass the Windows path, never the WSL path. nerdctl translates
D:\path itself and resolves the result in containerd’s namespace. Handing it
the already-translated /mnt/d/path bypasses that translation and binds an
empty directory instead — which containerd helpfully creates for you, so the
path then exists and stays empty:
-v "D:\GitHub\Repo:/workspace" # correct
-v "/mnt/d/GitHub/Repo:/workspace" # resolves, mounts nothing, no error
Do not diagnose this with -v /mnt:/hostmnt. That probe shows an almost
empty tree — no c, no real d — and looks like proof the drives are missing.
It is measuring containerd’s raw namespace, not the path Rancher actually takes,
and it will send you after the wrong cause. Probe with a Windows path against a
directory you know has contents (-v "C:\ProgramData:/probe"), then compare.
This also qualifies the Dev Drive caution above: D: here is ReFS, and once
both points are satisfied the bind mount lists the tree normally. Before blaming
ReFS for an empty mount, rule out the namespace and the path form — they present
identically and are far more likely.
The orientation AGENTS.md carried#
Moved out of AGENTS.md on 2026-09-15 (owner decision D10), unedited except for this heading and the relative links. The RULES stayed there; this is the reference behind them.
Rancher Desktop is the preferred Linux-container runtime on this host, and
the way to reproduce a Linux CI failure locally instead of guessing through
pipeline round trips. It does not replace the Windows lane — Windows containers
still go through Stevedore’s docker.exe. Full details:
docs/rancher-desktop-linux-containers.md.
$nerdctl = "C:\Program Files\Rancher Desktop\resources\resources\win32\bin\nerdctl.exe"
& $nerdctl --namespace default run --rm alpine:3.20 uname -a # expect ...WSL2... x86_64 Linux
Use
nerdctl, notdocker. Rancher defaults to the containerd engine, anddocker.exeships in the same directory while talking to a different engine entirely —docker infoon this host reportsOSType=windows, because the default context is the Windows lane. Both CLIs are present; only one is talking to Linux. (Switching Rancher’s engine todockerd (moby)flips this — pick one and stay with it.)Pass
--namespace defaultexplicitly. containerd namespaces are real isolation, so an image pulled into another namespace is genuinely “not found”.Linux builds use
ghcr.io/kataglyphis/kataglyphis_beschleuniger:latest, in CI and locally. It publishes amd64/arm64/riscv64.python-ci-linux.ymlsetsCONTAINER_IMAGEto:latest; if a local run uses a different tag, reproducing a CI failure proves nothing. The tag is not digest-pinned, so it still floats.
When to reach for this#
A CI step fails and the logs are not enough. This is the main case, and it is worth the disk: the alternative is pushing commits to watch a pipeline.
Something is suspected to be toolchain-specific — the Linux lane runs ASan/UBSan fuzzing that the Windows box does not, so a class of bug is only ever observable there.
Verifying a fix before pushing, rather than after.
WSL containers (wslc) are not a replacement yet#
Evaluated 2026-10-05 against WSL 3.0.1, whose wslc.exe went GA on 2026-09-29.
Each session is its own utility VM running dockerd 25.0.3, it needs no admin, and
it leaves Stevedore’s Windows containers alone. Keep Rancher Desktop for every
lane. wslc rejects --privileged, --platform and --device, and those are
what the lanes pass:
--privileged: the native and web lanes (microsoft/WSL#41545).--platform: both local drivers, on every run (#41123).--device: cameras and/dev/dri(#40988).
Its arm64 emulation does not survive between containers either: binfmt registered through its inner docker is gone at the next run.
Pain point here |
wslc |
|---|---|
Pulls through the Windows shim fail on |
fixed: credentials go to Windows Credential Manager |
fixed: a real named volume, still root-owned, so the chown step stays |
|
probably fixed: each bind source gets its own virtiofs share (untested, no D:) |
|
uid 1001 cannot |
the same, so the named-volume rule stays |
arm64 under QEMU |
worse: no |
Bind mounts are 2–5x faster than Rancher’s 9p: 2000 small writes take 1.7 s
instead of 4.3–4.9 s, stat is 4.8x faster, and sequential reads run at
~900 MiB/s against ~240 MiB/s. A bind mount is still 25–30x slower than
container-local disk on both engines. The one shape it can run today is the
Dart-only loop (no --privileged, and --platform can be dropped on an x64
host). OmniAccelerANT’s Invoke-DartChecks.ps1 -Engine wslc pilots exactly that
(2026-10-06). Warm, on the same checkout and image:
Dart-only step |
wslc |
Rancher nerdctl |
|---|---|---|
|
23 s |
32 s |
|
146 s |
371 s |
The first wslc run took 554 s, most of it the pull. Check again when #41545 and
#41123 land. Until then the family image is a second ~30 GB copy on C:
(session.storagePath in %LOCALAPPDATA%\wslc\settings.yaml moves it).
Persisting the cargo cache#
The :latest image runs as uid 1001. Its /usr/local/cargo belongs to that uid
now (it was root-owned until the consumer-contract fixes:
artifact-copy-completeness.md),
but the crate registry and every build’s target tree still live in the
throwaway container, so every fresh container rebuilds all Rust dependencies
from scratch. Point it at a named volume instead:
nerdctl volume create cargo-cache # once
MSYS_NO_PATHCONV=1 MSYS2_ARG_CONV_EXCL='*' rdctl shell nerdctl run --rm --user root \
--mount type=volume,source=cargo-cache,target=/cargo-cache \
-v /mnt/d/path/to/repo:/workspace -w /workspace \
ghcr.io/kataglyphis/kataglyphis_beschleuniger:latest \
bash -c 'bash scripts/linux/cmake-configure-build.sh \
--preset linux-debug-clang --build-dir /tmp/build --cargo-cache-dir /cargo-cache'
Three details that are easy to get wrong:
--build-dirmust be container-native (/tmp/...), not a path on the bind-mounted host tree. CMake’s FetchContentfile RENAMEand cargo’s temp-file cleanup both fail on that filesystem — the build dies partway through with a permission error on a stale artifact.The build driver also redirects
CARGO_TARGET_DIRonto the same volume, so compiled artifacts stay off the host mount for the same reason.--user rootsidesteps volume ownership. To avoid it, chown the volume to the image’s uid once:rdctl shell nerdctl run --rm --user root \ --mount type=volume,source=cargo-cache,target=/cargo-cache \ alpine:3.20 chown -R 1001:1001 /cargo-cache
The long
--mountform is not a style choice. See §-v name:/pathis a bind under Windows nerdctl below: the short form silently mounts a directory instead of the volume.
The MSYS_NO_PATHCONV=1 MSYS2_ARG_CONV_EXCL='*' prefix is required when
invoking nerdctl from Git Bash — without it the -v argument is rewritten
into a Windows path and the run fails with “expected an absolute path”.
After the first build the registry and compiled dependencies are reused and subsequent runs are dramatically faster.
-v name:/path is a bind under Windows nerdctl#
-v name:/path is not a named volume here. Windows nerdctl reads it as a
bind of $PWD/name, creates that directory silently, and mounts it; running
nerdctl volume create name beforehand changes nothing — the volume exists and
is never mounted.
It was measured on a consumer’s local Linux lane: after five runs %TEMP% held
…-workspace-build/ and four siblings at 729 MB each, while the volume of that
name, mounted through --mount type=volume,…, was empty. One run started from a
repo root left a 151 MB directory of that name inside the checkout. The whole
point of the volume — keeping a write-heavy build tree off drvfs — was never in
effect locally, and nothing failed only because the write-heavy steps had already
been moved to /tmp.
Always use the long form, which nerdctl cannot reinterpret as a path:
nerdctl run --rm --mount type=volume,source=<name>,target=/path ...
CI is unaffected: the Linux engine there resolves the short form correctly. One
cosmetic consequence of mounting a build directory: a cleaner (flutter clean,
rm -rf build) then logs Device or resource busy (errno 16) on the mount point
itself — it empties the directory and cannot unlink it. Not a failure to chase.
Registering arm64 emulation in Rancher’s VM (per VM boot)#
Rancher’s VM starts with no emulators at all: binfmt reports
"emulators": null and only linux/amd64 variants under supported. An arm64
container therefore runs x86-64 binaries and dies with rustc: 1: ELF: not found. Register once per VM boot:
nerdctl run --rm --privileged tonistiigi/binfmt --install arm64
nerdctl run --rm --privileged tonistiigi/binfmt # verify: qemu-aarch64 listed
nerdctl run --rm --platform linux/arm64 alpine uname -m # verify: aarch64
Like the D: mount in containerd’s namespace, this does not survive a VM
restart. The arm64 layers are a separate pull — roughly 6 GB over the wire and
30 GB on disk beside the amd64 copy (nerdctl pull --platform linux/arm64 …) —
and every compile then runs under emulation, so expect it to be far slower than
the native x64 path.
Emulated arm64 cannot run bubblewrap or static-PIE#
Under qemu-user, two packaging formats fail for reasons that have nothing to do
with this repository or the image. Both were verified 2026-09-05 on a consumer,
after a full arm64 build that compiled Rust and C++ without a single error:
bwrap: Creating new namespace failed, likely because the kernel does not support user namespaces. The kernel does support them (/proc/sys/user/max_user_namespacesis 123100) and--privilegedwas passed;qemu-usersimply does not carryunshare(CLONE_NEWUSER)through, and flatpak-builder sandboxes every module with bubblewrap./usr/local/bin/appimagetool: cannot execute binary file: Exec format error. The binary is the correct architecture (ELF aarch64, static-pie linked);qemu-usercannot load static-PIE executables.
Real arm64 runners are unaffected, so a CI arm64 row that packages successfully proves nothing about the emulated path and vice versa. Locally, treat a failing flatpak/AppImage step on arm64 as expected and check for those two messages before investigating anything else.
Long-running work: detached containers + tmux#
A build or training run that outlives your terminal should not be tied to it. Start the container detached, then use tmux inside it so the process survives both disconnects.
nerdctl run -dit --gpus '"device=0"' -p 8501:8501 \
-v "$PWD:/workspace" -w /workspace \
--name work ghcr.io/kataglyphis/kataglyphis_beschleuniger:latest
Flag |
Why |
|---|---|
|
Detached — runs in the background |
|
Keeps STDIN open, so a shell attached later is usable |
|
Allocates a pseudo-TTY |
|
Stable handle; without it you are copying container ids around |
Attach, start a tmux session, run the job, then leave:
nerdctl exec -it work bash
tmux # then start the long job, e.g. python train.py
# detach from tmux with Ctrl+B then D, or just `exit` the shell
The container and the tmux session keep running. Reconnect later with:
nerdctl exec -it work bash
tmux attach
Useful handles:
nerdctl ps # what is running
nerdctl stop work
nerdctl start work
nerdctl rm work
tmux ls # inside the container: list sessions
Name the session when you run more than one at a time — tmux alone gives
you an unnamed session that is awkward to pick out later:
tmux new -s build # create
# Ctrl+B then D to detach
tmux attach -t build # come back to that specific one
Two alternatives to tmux, for reference: Ctrl+P then Ctrl+Q detaches from an
attached container without stopping it (this is a container-level detach, not a
process-level one), and nohup cmd > cmd.log 2>&1 & backgrounds a single
process without a session manager. tmux is preferable when you want to look at
the running output again.
nerdctlis largely command-compatible withdocker. In most cases substituting one for the other is enough — but see the namespace caveat above.
Fixing file ownership after a container writes to a bind mount#
A container running as a different uid than your host user leaves files you cannot edit. Rather than chowning from the host, do it from inside the container where you have root.
Find the host uid/gid you want the files to end up as:
id -u && id -g
# ...or read it out of /etc/passwd:
grep "^$USER:" /etc/passwd
# youruser:x:1000:1000::/home/youruser:/bin/bash
# ^^^^ ^^^^ uid gid
Then, inside the container:
chown -R 1000:1000 /workspace/some/path
The same trick applies to named volumes — see
Persisting the cargo cache, which chowns
cargo-cache to the image’s uid 1001 once instead of running everything as
root.
Setting up WSL2 itself#
Rancher Desktop supplies its own distro, but a second WSL distro is often wanted — for a Linux-side toolchain, or to reproduce a CI step outside the container.
Installing without the Microsoft Store#
Store-less installs are the norm on managed or offline machines:
dism.exe /online /enable-feature /featurename:Microsoft-Windows-Subsystem-Linux /all /norestart
dism.exe /online /enable-feature /featurename:VirtualMachinePlatform /all /norestart
Reboot, then import a rootfs tarball from Ubuntu Cloud Images:
mkdir C:\WSL\Ubuntu24.04
wsl --import Ubuntu24.04 C:\WSL\Ubuntu24.04 .\ubuntu-24.04-wsl-rootfs.tar.gz
wsl -d Ubuntu24.04
An imported distro starts as root with no user account:
passwd
adduser <youruser>
usermod -aG sudo <youruser>
wsl -d Ubuntu24.04 -u <youruser>
wsl --set-default Ubuntu24.04
Stop Windows executables shadowing Linux ones#
By default WSL appends the whole Windows PATH, so python, node or cmake
can resolve to a Windows binary inside a Linux shell — with confusing results in
a build script. In /etc/wsl.conf:
[interop]
enabled=true
appendWindowsPath=false
Windows executables remain callable by full path; they just stop winning PATH
lookups. Restart the distro (wsl --shutdown) for it to take effect.
The virtual disk only grows#
WSL2’s ext4 disk never shrinks on its own. Reclaim it the same way as the Docker VHD:
wsl --shutdown
cd C:\WSL\Ubuntu24.04
Optimize-VHD -Path .\ext4.vhdx -Mode Full
No network inside WSL once a VPN connects#
A known WSL networking interaction rather than a VPN misconfiguration. Install the latest WSL from its releases rather than relying on the inbox version:
Add-AppxPackage .\Microsoft.WSL_<version>_x64_ARM64.msixbundle
Then set the networking mode in WSL Settings. Microsoft’s troubleshooting note covers the remaining cases.
USB passthrough (cameras, probes, boards)#
Needed whenever a device has to be visible to a Linux toolchain — see Microsoft’s guide:
usbipd list
usbipd attach --wsl --busid 1-1.2
Attaching is not the whole story: the device appears on the USB bus but the
kernel module still has to claim it. For a UVC camera that shows up with no
/dev/video*:
sudo modprobe uvcvideo videodev
lsmod | grep -E 'uvcvideo|videodev'
ls -l /dev/video*
v4l2-ctl --list-devices
dmesg | tail -n 40
If the device is listed but still unbound, bind its interfaces by hand — replace
the vendor id with your own from lsusb:
for d in /sys/bus/usb/devices/*; do
[ -f "$d/idVendor" ] || continue
[ "$(cat "$d/idVendor")" = "174f" ] || continue
for iface in "$d"/*:*; do
[ -e "$iface" ] || continue
echo -n "$(basename "$iface")" | sudo tee /sys/bus/usb/drivers/uvcvideo/bind
done
done