Run a fully containerized C++ development environment on Windows — via WSL2 and Docker Desktop — where GUI windows opened inside the container (image viewers, 3D plots, debug overlays) show up on your Windows desktop, properly authenticated and firewall-scoped, without ever disabling X11 security.
If you've tried to get cv::imshow(), a Pangolin trajectory viewer, or
any other Linux GUI tool working from inside a Docker devcontainer on
Windows and hit a wall of "Can't open display" errors — this repo is the
result of debugging that wall end to end, documented so you don't have
to repeat the same dead ends.
- Why this exists
- What this actually enables
- How it works (architecture)
- Prerequisites
- Setup
- Day-to-day usage
- Verifying it's actually secure
- Troubleshooting
- Design decisions & dead ends (the boring but useful part)
- FAQ
- License
WSL2 ships with WSLg, which gives native Linux GUI apps running directly in a WSL terminal seamless windows on your Windows desktop, no extra setup. The natural assumption is that a Docker container running "in WSL2" gets this for free too.
It doesn't — at least not with Docker Desktop.
Docker Desktop runs containers through its own internal system distro
(visible as docker-desktop / docker-desktop-data in wsl -l -v),
which is separate from your actual interactive WSL distro (e.g.
Ubuntu-22.04). WSLg's socket-sharing magic is tied to your real distro
and does not reliably bridge into Docker Desktop's internal one. This is
a known, still-open limitation — see
docker/for-win#14403 and
devcontainers/discussions#173.
Most existing guides solve this by running an X server on Windows (commonly VcXsrv) and telling the container to connect to it — but almost all of them do so with "Disable access control" checked, which means any process on your machine or local network can connect to your display: read keystrokes across all your windows, take screenshots, inject fake input. That's a real, well-documented weakness of the X11 protocol, not a hypothetical.
This repo does the same thing (X server on Windows) but with:
- X11 authentication via a rotating secret cookie (
xauth) instead of disabling access control, and - A Windows Firewall rule scoped to only your own machine's internal WSL/Docker networks, so nothing external can even attempt a connection.
Once set up, any GUI code inside your container just works, the same as it would on a native Linux desktop:
- OpenCV image/video debugging —
cv::imshow("frame", img); cv::waitKey(0);pops a real window on your desktop. - 3D visualization for robotics / computer vision / SLAM — libraries like Pangolin (widely used for plotting camera trajectories, point clouds, and pose graphs) render correctly, so you can visually debug a pipeline instead of squinting at raw numbers in a terminal.
- GDB with any GUI front-end, or any tool that opens secondary windows.
- Reproducible onboarding — a teammate can clone this repo, open it in VS Code, and get the identical environment and identical GUI capability, without installing OpenCV/CMake/etc. on their actual Windows machine at all.
┌─────────────────────────┐ TCP :6000 ┌───────────────────────┐
│ Docker container │ (auth cookie required) │ VcXsrv (X server) │
│ (your C++/OpenCV code) │ ───────────────────────▶│ running on Windows │
│ DISPLAY=<docker-ip>:0 │ │ -auth <cookie file> │
└─────────────────────────┘ └───────────┬───────────┘
│
draws actual window
│
▼
your Windows desktop
Two things gate every connection attempt before a window can ever appear:
- Windows Firewall — only allows inbound TCP:6000 traffic from your own machine's WSL/Docker internal subnets. Anything else is dropped before it even reaches VcXsrv.
- X11 auth cookie (
xauth) — even a connection from an allowed subnet is rejected unless it presents the exact matching secret, regenerated on every launch.
- Windows 10/11 with WSL2 installed and a Linux distro set up (e.g. Ubuntu)
- Docker Desktop, WSL2 backend enabled
- VS Code with the Dev Containers extension
- VcXsrv — free, from https://sourceforge.net/projects/vcxsrv/ (install with default options; don't let it auto-launch after install)
wsl -l -vNote the exact name (e.g. Ubuntu-22.04) — case and formatting matter
for the scripts later.
wsl --set-default <YourDistroName>This avoids a common and confusing failure mode where WSL commands silently target Docker Desktop's own internal distro instead of yours (see Design decisions & dead ends).
Get-NetIPAddress -AddressFamily IPv4 | Where-Object { $_.InterfaceAlias -like "*WSL*" }Note the IPAddress (e.g. 172.19.64.1) and its PrefixLength (e.g.
20).
Then, from inside any running container (a throwaway docker run -it ubuntu bash works fine if you don't have one handy yet):
getent ahostsv4 host.docker.internalNote this IP too — it's commonly 192.168.65.254 on Docker Desktop, but
confirm it on your machine, don't assume.
Clone/copy this repo. In WSL, make sure generate-cookie.sh is
executable and reachable at ~/generate-cookie.sh (or update the path
referenced inside start-vcxsrv.ps1 if you place it elsewhere):
cp scripts/generate-cookie.sh ~/generate-cookie.sh
chmod +x ~/generate-cookie.shCopy scripts/start-vcxsrv.ps1 to a convenient Windows path, e.g.
C:\Users\<you>\gui-cpp-devcontainer\scripts\start-vcxsrv.ps1.
In scripts/start-vcxsrv.ps1, edit the top three variables:
$WinUser = "yourname" # your actual Windows username
$WslDistro = "Ubuntu-22.04" # exact name from `wsl -l -v`
$DockerInternal = "192.168.65.254" # from step 2In .devcontainer/devcontainer.json, replace every
YOUR_WINDOWS_USERNAME and REPLACE_WITH_YOUR_DOCKER_INTERNAL_IP
placeholder with your real values.
Using your WSL subnet from step 2 (e.g. 172.19.64.0/20), open an
elevated PowerShell ("Run as administrator") and run:
New-NetFirewallRule -DisplayName "VcXsrv WSL only" `
-Direction Inbound -Program "C:\Program Files\VcXsrv\vcxsrv.exe" `
-Protocol TCP -LocalPort 6000 `
-RemoteAddress <your_wsl_subnet>,192.168.65.0/24 `
-Action AllowThis is the core security step — only your own machine's internal WSL/Docker networks can ever reach the X server.
From this point on, never launch VcXsrv manually from an elevated window. Always let the script start it — see Design decisions & dead ends for why this matters.
- Open the project folder in VS Code.
- Click "Reopen in Container".
- That's it — VS Code silently runs
start-vcxsrv.ps1for you before the container even starts, regenerating the cookie and (re)launching VcXsrv.
Open a terminal inside the container and test:
xeyesA little window with eyes that follow your mouse should appear on your Windows desktop. If it does, any real GUI code will work the same way.
Don't just take the setup's word for it — prove it to yourself.
1. Connections without the cookie should fail:
mv /root/.Xauthority /root/.Xauthority.bak
xeyesExpect an error (Authorization required...), not a working window.
2. Restore it and confirm it works again:
mv /root/.Xauthority.bak /root/.Xauthority
xeyes3. Confirm the firewall rule is actually scoped:
Get-NetFirewallRule -DisplayName "VcXsrv WSL only" | Get-NetFirewallAddressFilterRemoteAddress should list your specific subnets — never Any.
| Symptom | Root cause | Fix |
|---|---|---|
Error: Can't open display: :0 |
Trying to use WSLg's native /mnt/wslg socket path, which Docker Desktop containers can't reliably reach |
Use the VcXsrv + TCP approach in this repo instead of raw WSLg passthrough |
ls: cannot access '/mnt/wslg/.X11-unix/' inside container |
Docker Desktop's internal distro mounts WSLg content at /mnt/host/wslg, not /mnt/wslg |
Not needed with this repo's approach — mentioned here in case you're debugging a different, WSLg-socket-based setup |
Authorization required, but no authorization protocol specified |
XAUTHORITY file is missing, empty, or mounted as an empty directory instead of a file |
Check ls -la /root/.Xauthority inside the container — it must show as a regular file, not a directory. Mount from a plain Windows path (C:\Users\...), not directly from a WSL distro path — the latter can silently resolve to an empty directory under Docker Desktop |
xauth: (argv): bad "add" command line |
Cookie generation produced an empty string, usually because xxd isn't installed on the distro |
This repo uses openssl rand -hex 16 instead, which is far more reliably present |
Cannot establish any listening sockets - Make sure an X server isn't already running (VcXsrv popup) |
A previous VcXsrv process is still holding port 6000 | Task Manager → Details tab → end every vcxsrv.exe process, confirm any admin prompts, then retry |
Stop-Process: Accès refusé / Access denied when killing vcxsrv |
A previous VcXsrv instance was started from an elevated PowerShell window; a non-elevated script can't terminate it | Kill it manually once via Task Manager (elevated), then never launch VcXsrv from an elevated window again |
<3>WSL ... ERROR: getpwuid(0) failed when running wsl.exe commands |
wsl.exe without -d <name> targets whatever your default distro is — which can silently be Docker Desktop's own internal docker-desktop distro instead of yours |
Always pass -d <YourDistroName> explicitly, and/or run wsl --set-default <YourDistroName> |
cat: /some/mounted/file: Is a directory |
Docker created an empty directory at the mount target instead of mounting the actual file — happens with certain WSL-distro-path mount sources under Docker Desktop | Mount from a plain Windows filesystem path instead |
| Connects but times out / generic "Can't open display" with no auth-specific detail | Container genuinely cannot route to the IP you gave it — using the raw WSL gateway IP from inside a container doesn't work, since containers and WSL sit on different internal networks | Use getent ahostsv4 host.docker.internal from inside the container to find the correct address, and widen the firewall rule to cover Docker Desktop's internal subnet (commonly 192.168.65.0/24) too |
host.docker.internal resolves to an IPv6 address |
Some systems return IPv6 by default via plain getent hosts |
Use getent ahostsv4 host.docker.internal specifically to force an IPv4 result, and use that IPv4 address everywhere |
xeyes appears to hang with no prompt returning |
This is often success, not failure — xeyes doesn't return to the prompt while its window is open |
Alt-Tab and check other monitors before assuming it failed |
New-NetFirewallRule: Accès refusé |
PowerShell window isn't running as Administrator | Right-click PowerShell → "Run as administrator", retry |
Get-NetIPAddress -InterfaceAlias "vEthernet (WSL)" returns nothing |
The WSL adapter's exact name varies by Windows/WSL version (vEthernet (WSL), vEthernet (WSL (Hyper-V firewall)), etc.) |
Use a wildcard match instead: Get-NetIPAddress -AddressFamily IPv4 | Where-Object { $_.InterfaceAlias -like "*WSL*" } |
Very slow apt-get install for libopencv-dev (5-10+ minutes) |
Normal — OpenCV pulls in a large dependency tree, and unpacking many small files is slow through Docker Desktop's WSL2-backed overlay filesystem | Let it finish once; it's cached for all future rebuilds. If abnormally slow, exclude your WSL .vhdx from Windows Defender real-time scanning |
| Corporate VPN interferes with WSL DNS / connectivity | VPN software commonly overrides WSL's /etc/resolv.conf, breaking IP-detection tricks that rely on it |
Don't rely on resolv.conf for the WSL gateway IP — this repo uses Get-NetIPAddress on the Windows side instead, which is unaffected |
A few notes on why the setup looks the way it does, for anyone comparing this against other guides or wondering "why not the simpler way":
- Why not just mount
/mnt/wslginto the container? This is the first thing most people try, since it's how WSLg itself works for plain WSL terminals. It doesn't work reliably with Docker Desktop because containers run through Docker Desktop's separate internal distro, which only partially mirrors/mnt/wslg's contents (notably missing.X11-unix) or exposes it at a different path (/mnt/host/wslg) depending on version. This repo bypasses the whole problem by using a TCP-based X server instead. - Why VcXsrv and not another X server? It's free, well-established,
and supports the
-authflag needed for proper cookie-based authentication (unlike some lighter-weight alternatives). - Why regenerate the cookie on every launch, instead of once? It costs nothing (sub-second) and means a stale/leaked cookie from a previous session is never still valid.
- Why two IP addresses registered in the cookie file, not one? Depending on exact Docker Desktop networking behavior, a container may present as connecting from either the raw WSL gateway IP or Docker's internal host IP. Registering both avoids "works on my machine but not yours" style inconsistency.
- Why not just switch Docker Desktop to the native WSL2 engine
(
docker context use default) instead of all this? That is a valid alternative that sidesteps the problem differently, since containers would then run inside your actual WSL distro and could access/mnt/wslgdirectly. It wasn't used here because it requires installing Docker Engine natively inside WSL and carefully managing two potentially-conflicting Docker installations, which is a bigger and riskier change for most people already relying on Docker Desktop for other projects. If you're setting up a machine from scratch and don't mind that trade-off, it's worth investigating as a cleaner long-term alternative.
Does this expose my display to my local network / the internet? No. The firewall rule restricts inbound connections to your own machine's internal WSL and Docker Desktop virtual networks only — these aren't routable from your LAN or the internet. Combined with cookie authentication, an attacker would need code execution on your own machine already to exploit this, at which point they'd have easier avenues anyway.
What happens if my WSL gateway IP changes after a Windows update?
xeyes (or any GUI app) will stop connecting. Re-run the IP detection
command from Setup step 2, update the firewall rule and
devcontainer.json's DISPLAY value if the Docker-internal IP also
changed, and rebuild the devcontainer. This is uncommon but not
impossible.
Can I use this with a different base image / different libraries?
Yes — the x11-apps and xauth packages and the devcontainer.json /
script setup are independent of what else you install. Swap
libopencv-dev / libeigen3-dev for whatever your project needs.
Do I need to keep VcXsrv running all the time?
No. It only needs to be running while you're working in the
devcontainer, and initializeCommand starts it automatically each time
you open the project — no background service, nothing running at
Windows startup.
MIT. Use, modify, and share freely.
