Tags: docker, proxmox, homelab, windows
I sat down to start a new project and Docker Desktop wouldn't run. I knew why immediately, because I'd already paid this bill once — in the opposite direction.
When I first set up Proxmox nested in VMware, VMware wouldn't run either. I fixed it by turning the Windows hypervisor off so the nested VM could reach VT-x/EPT directly. It worked. What I didn't think about at the time was the other half: Docker Desktop needs that same hypervisor. So I had a working homelab and no development environment, and I'd done it to myself months earlier without noticing.
The two are mutually exclusive on one machine:
- Hypervisor on: Docker Desktop works, nested Proxmox fails.
- Hypervisor off: Proxmox works, Docker Desktop has no engine.
The switch itself is one line in an elevated prompt, and it needs a reboot either way:
# nested Proxmox works, Docker Desktop and WSL2 do not
bcdedit /set hypervisorlaunchtype off
# back to the other side
bcdedit /set hypervisorlaunchtype auto
I didn't just want them to stop fighting, either. I wanted the new development setup to be able to reach PVE, not merely coexist with it.
What didn't work
I went to ChatGPT first. The answers weren't good enough — they were variations on "pick one", and I wanted both.
The idea that actually solved it was mine: build a separate VM whose only job is to be the Docker engine for development. Then PVE keeps direct hardware access, development gets a real Docker daemon, and neither one is Windows' problem any more.
The setup
DEV01 — Ubuntu 24.04.5 in VMware, 4 vCPU, ~8 GB RAM, 100 GB disk. Docker Engine 29.8.0, Compose v5.5.1, containerd 2.3.5.
The Windows Docker CLI talks to it through a context pointing at ssh://dev. Docker Desktop stays installed for the CLI only, and never runs.
That's the whole trick. The daemon isn't on Windows, so it doesn't care what Windows did to its hypervisor.
Passwordless SSH first
The Docker context runs every command over SSH, so key auth has to work before anything else does. From Windows:
# create a key if you don't have one
ssh-keygen -t ed25519
# copy the public key to the VM (this is the last time it asks for a password)
scp "$env:USERPROFILE\.ssh\id_ed25519.pub" <user>@<dev01-ip>:/tmp/master.pub
Then on the VM:
mkdir -p ~/.ssh && chmod 700 ~/.ssh
touch ~/.ssh/authorized_keys
grep -qxFf /tmp/master.pub ~/.ssh/authorized_keys || cat /tmp/master.pub >> ~/.ssh/authorized_keys
chmod 600 ~/.ssh/authorized_keys
rm /tmp/master.pub
Then an alias, so the IP appears exactly once
Add this to %USERPROFILE%\.ssh\config:
Host dev
HostName <dev01-ip>
User <user>
IdentityFile C:/Users/<you>/.ssh/id_ed25519
IdentitiesOnly yes
ServerAliveInterval 30
ServerAliveCountMax 3
ssh dev should now drop you straight into a shell with no password and no IP. ServerAliveInterval matters more than it looks: without it, a long build over a quiet connection can drop halfway.
And finally the context
docker context create dev --docker "host=ssh://dev" --description "DEV01 Docker Engine"
docker context use dev
docker info
If docker info prints the VM's engine version, you're done. Everything below is what that costs you.
The side effect nobody mentions
WSL2 stops existing too.
The annoying part is that it doesn't say so. wsl --status still cheerfully reports Default Distribution: Ubuntu, Default Version: 2, so everything looks registered and fine — but nothing can actually run. Anything you were hosting in WSL silently loses its home.
For the real state, ask Windows whether a hypervisor is present at all:
(Get-CimInstance Win32_ComputerSystem).HypervisorPresent
What breaks when the daemon isn't local
A remote daemon behaves like a local one until it doesn't. Three things bit me.
1. Bind mounts resolve on the daemon host
Not where you typed the command. ./data:/data quietly means "a folder on the VM".
Worse: -v /tmp/script.py:/x made Docker create a directory named /tmp/script.py on the VM and mount that, so the container failed with can't find __main__ module. Nothing warns you. The path you meant is on Windows; the path Docker used is on the VM, and Docker invented it for you.
Piping the file over stdin avoids the whole class of problem, because nothing has to exist on the daemon host at all:
# instead of -v script.py:/x
Get-Content script.py | docker run -i --rm python:3.11-slim python -
# same idea for a shell script
Get-Content setup.sh | docker run -i --rm ubuntu:24.04 bash -s
2. 127.0.0.1 in a port mapping is the VM's loopback now
Published ports are invisible from Windows until you forward them. Everything starts, the health check inside the VM passes, and your browser on the workstation sees nothing.
An SSH tunnel is the smallest fix, and it keeps both ends on loopback so nothing is exposed to the LAN:
ssh -N -o ExitOnForwardFailure=yes `
-L 127.0.0.1:5236:127.0.0.1:5236 `
-L 127.0.0.1:8090:127.0.0.1:8090 dev
ExitOnForwardFailure=yes is the part worth copying. Without it, a port that's already taken fails silently and you get a tunnel that's half open — which looks exactly like the app being broken.
I wrapped that in the same script that starts the stack, so the tunnel opens and closes with it.
3. The build context goes over SSH on every build
Without a .dockerignore, the local virtualenv is uploaded each time you build. With one, my context transfer was 51.64 kB.
This is the one item on the list that's a straight upgrade once you fix it: the feedback is immediate and visible in the build output.
HGFS: why the share looked empty
I share the Windows folder into the VM with a VMware shared folder and bind it into the containers.
First attempt: the share mounted fine as my login user, and was completely invisible to the containers. The bind failed, or the path just looked empty.
The reason isn't a VMware quirk — it's FUSE's default. A FUSE filesystem mounted by an ordinary user is closed to every other uid, including root. dockerd is root. Containers run as root. So they see nothing.
allow_other lifts it, with a catch: a non-root user can only pass allow_other if user_allow_other is uncommented in /etc/fuse.conf, and it's commented out by default on Ubuntu 24.04. Mounting as root from /etc/fstab sidesteps that and survives a reboot:
.host:/<share-name> /mnt/<share-name> fuse.vmhgfs-fuse defaults,allow_other,uid=1000,gid=1000,nofail 0 0
nofail so a missing share never blocks boot.
I recognised the symptom fast, but "looks empty" is a miserable error message to debug cold.
One guard worth having
When the share isn't mounted, the mount point is just an empty directory. Containers happily write into the VM's local disk, everything looks healthy, and the files go nowhere.
So don't check that the directory exists — check that a file you know is in the share is visible through the mount, before starting anything:
ssh dev 'test -f /mnt/<share-name>/README.md' || { echo "share is not mounted"; exit 1; }
An empty directory and a working share are indistinguishable at a glance, and the failure mode is silent data loss.
Six things I proved before trusting it
All of these ran as root inside a container with the share bound in, then were checked from Windows.
| # | Check | Result |
|---|---|---|
| 1 | Write a file, read it from Windows | Works, owner is the Windows user |
| 2 | Replace a file by rename | Works, both in Python and .NET, no temp files left |
| 3 | Set a file's mtime | Works, exact to the tick |
| 4 | Delete a file | Works |
| 5 | Write a 10 MB image | Works, write + fsync in 0.014 s, SHA-256 identical both sides |
| 6 | See a Windows-created file from the container | No measurable delay |
Measuring #6 honestly needs the clock offset first, and the way to get it is the same trick NTP uses: take a timestamp on Windows, ask the VM for its time, take a second timestamp on Windows, and compare the VM's answer against the midpoint of the two.
$t1 = [DateTimeOffset]::UtcNow
$dev = [DateTimeOffset]::Parse((ssh dev 'date -u --iso-8601=ns'))
$t3 = [DateTimeOffset]::UtcNow
($dev - [DateTimeOffset]::FromUnixTimeMilliseconds(
($t1.ToUnixTimeMilliseconds() + $t3.ToUnixTimeMilliseconds()) / 2)).TotalSeconds
Run it three times and take the spread as your error bar. Mine came out at +0.39 s ± 0.1 s — the VM runs ahead of the workstation — so any latency claim smaller than that is noise, not a measurement. It's worth doing this before you quote yourself a number you'll later repeat.
The one real limitation
Appending to a file that already existed took ≈1.4 s to become visible in the container — the FUSE attribute cache. Creating, renaming and deleting are immediate.
So: fine if every writer creates a new file or renames one into place. A problem if anything watches files, or re-reads one mid-edit.
Also worth knowing: metadata over HGFS is synthetic. Files always report uid/gid 1000 and mode 0777, and chown/chmod succeed while changing nothing. If your container logic asserts on permissions, it's asserting on a fiction.
Docker Desktop takes the context back
Mid-session the active context flipped from my SSH context back to desktop-linux on its own. Every command after that failed — Desktop had grabbed the context and then couldn't start, because the hypervisor is off.
The fix is to stop relying on the active context at all:
docker -c dev compose up -d
Pass -c <context> in every script, or set DOCKER_CONTEXT for the session. If the context is an implicit global, something else will eventually change it for you.
Is this permanent?
No. DEV01 gets deleted when PVE moves to dedicated hardware — but that's months away.
PVE still has to prove itself first. The plan is to buy smart-home hardware, let PVE run that, and see whether it earns a server of its own. Until then, this is a good enough answer for months of development, and "good enough for months" is a real category.
What I'd tell someone hitting this
Be creative. If Windows can't solve a problem, an extra VM is a legitimate answer, not a defeat.
The framing I was handed everywhere — including by the chatbot — was choose one. The constraint was real; the conclusion wasn't. Moving the daemon out of the argument entirely cost me one VM, one .dockerignore, one fstab line, and an afternoon of proving the shared folder does what I think it does.
I'm Yahav Tzukerman, a full-stack developer (Angular + .NET). I build things I need and write about what broke along the way — most of it lives in my homelab.
I also build automation for small businesses: Telegram bots, document workflows, and AI agents that handle the repetitive parts. If you've got a process that's eating your week, I'm happy to talk about it.
Find me on Dev.to — or drop a comment below, I answer all of them.
Top comments (0)