Moving Linux containers from Docker Desktop to wslc is mostly a week of retraining muscle memory. You need to know which Docker commands carry over unchanged, which need the container noun in front, and which do not exist yet. This wslc cheat sheet answers that from a real lab rather than from the docs, with every WSL containers command grouped by task, a wslc vs Docker table of the differences that break scripts, and the honest state of wslc compose.
Below the reference sits the full hands-on guide. It covers how the session VM is built, install steps, port publishing, Dockerfile builds, virtiofs mounts, networks with PostgreSQL, and a file I/O benchmark against Docker Engine in WSL. The commands in the tables ran on our Windows 11 lab VM with WSL 3.0.1 in October 2026, apart from the rows marked as coming from --help, and we checked the 3.0.2, 3.0.3 and 3.0.4 release notes for anything that changed since, so you can see which rows are lab output and which come from Microsoft’s changelog.
wslc cheat sheet: commands by task
Most rows are the exact commands we ran in PowerShell on the lab VM, with the names (web, api, appnet, pgdata) used throughout this guide. A row marked “from --help” is a subcommand that the captured help output lists but our lab never executed, which matters for the prune commands in particular, because we cannot vouch for what they delete on your machine. Run them with care the first time.
Session and system
| Command | What it does |
|---|---|
wsl --version | WSL, kernel and Windows build; wslc needs WSL 2.9.3 or later |
wslc version | Print the wslc CLI version |
wslc system info | Client and session manager versions, settings file path (also wslc info) |
wslc system session list | List session VMs, e.g. wslc-cli-admin-victor from an elevated shell |
wslc system session run ps -eo pid,comm,args | Run a command inside the session VM itself |
wslc system session terminate | Stop the session VM; new settings apply on the next start |
wslc settings | Create and open %LOCALAPPDATA%\wslc\settings.yaml (from --help) |
wslc events | Stream container and network lifecycle events |
Images and builds
| Command | What it does |
|---|---|
wslc images | List local images (same as wslc image ls) |
wslc build -t hello-api:1.0 . | Build the Dockerfile in the current folder and tag it |
wslc build --no-cache -t hello-api:bench . | Rebuild every layer, ignoring the cache |
wslc build --target build --build-arg KEY=VALUE -t app . | Multi-stage target and build args (from --help) |
wslc image prune | Remove unused images such as dangling <none> layers; asks y/N, add -f in scripts (from --help) |
Run and manage containers
| Command | What it does |
|---|---|
wslc run --rm ubuntu:latest bash -c "cat /etc/os-release | head -2; uname -r" | One-off container, removed on exit; pulls the image if missing |
wslc run -d -p 8080:80 --name web nginx:latest | Background container, published on 127.0.0.1:8080 only |
wslc run -d -p 0.0.0.0:8080:80 --name web nginx:latest | Publish on every host interface (add a firewall rule too) |
wslc run -d --name api -p 5000:5000 -e GREETING=karibu hello-api:1.0 | Set an environment variable and publish a port |
wslc list | Running containers (same as wslc container ps) |
wslc restart web | Restart a container |
wslc rm -f web | Stop and remove a container in one step |
wslc container prune | Remove all stopped containers; asks y/N, add -f in scripts (from --help) |
Inspect, logs and exec
| Command | What it does |
|---|---|
wslc exec web nginx -v | Run a command in a running container |
wslc logs web | Print a container’s log output |
wslc stats | One snapshot of CPU, memory, network and block I/O per container |
wslc inspect web | Full container details as indented JSON |
wslc container cp api:/app/app.py "$env:USERPROFILE\app-copy.py" | Copy a file out of a container to Windows |
Networks
| Command | What it does |
|---|---|
wslc network ls | List networks: bridge, host and none exist by default |
wslc network create appnet | User network with name-based DNS between containers |
wslc network connect appnet api | Attach a running container to a network |
wslc network inspect appnet | Driver, IPAM config and attached containers with their IPs, as JSON |
wslc network prune | Remove all unused networks; asks y/N, add -f in scripts (from --help) |
Volumes and Windows mounts
| Command | What it does |
|---|---|
wslc volume create pgdata | Named volume on the default guest driver, inside the session disk |
wslc volume create --driver vhd -o SizeBytes=4000000000 benchvhd | Volume on its own ext4 VHD with a hard size cap |
wslc volume ls | List volumes and their drivers |
wslc volume rm pgdata | Delete a volume and its data; rm is an alias of volume remove in the 3.0.1 source |
wslc run --rm -v "$env:USERPROFILE\src\hello-api:/src" ubuntu:latest bash -c "mount | grep /src; ls -la /src" | Bind-mount a Windows folder over virtiofs |
wslc run --rm --mount "type=bind,source=$env:USERPROFILE\src,target=/work,readonly" ubuntu:latest bash -c "touch /work/x" | Read-only bind mount; the write fails with “Read-only file system” |
wslc volume prune | Remove unused local volumes; asks y/N, add -f in scripts (from --help) |
Two rows deserve a warning before you script them. The first wslc run of a fresh session took 132.9 seconds in our lab, because it boots and formats a new session VM before pulling anything. A CI step or onboarding script that assumes Docker’s two-second start will time out on that first run, and because the session VM shuts down after 30 seconds idle by default (session.idleTimeout in settings.yaml), a 30-second-plus cold start comes back after every idle gap. And wslc volume remove has no confirmation step in the 3.0.1 source, exactly like Docker, but a guest volume lives inside the session’s storage.vhdx rather than as files you can browse in Explorer, so copy anything you care about out with wslc container cp first.
wslc vs Docker: the command differences
Inside the session VM, wslc drives a stock dockerd and containerd, so images, layers and container behaviour match Docker exactly. The differences are all in the client, which is why a Dockerfile moves over untouched while a shell script full of docker one-liners usually does not. These are the habits that broke when we replayed Docker commands against wslc:
| Docker habit | What wslc does instead |
|---|---|
docker compose up | Unrecognized command: no Compose in wslc yet; script wslc network create plus wslc run |
docker run -p 8080:80 | wslc binds 127.0.0.1 unless you write -p 0.0.0.0:8080:80 |
docker cp | wslc container cp; a top-level wslc cp alias only arrives in WSL 3.0.4 (preview channel) |
docker inspect -f '{{.NetworkSettings.IPAddress}}' | Rejected: --format takes only json; parse it with ConvertFrom-Json |
docker stats --no-stream | Flag rejected; wslc stats already prints one snapshot and exits |
docker system prune | No top-level prune; run the container, image, network and volume prunes, each with a y/N prompt |
docker run --platform linux/arm64 | Rejected by run and build: no emulated or multi-arch images |
docker ps | wslc list or wslc container ps |
docker version | Not recognized: wslc installs no docker shim and no DOCKER_HOST socket |
The localhost default is the one that surprises people most, and it is also the one we would keep. A Postgres container started with -p 5432:5432 under Docker listens on every interface of the laptop, while the same flag under wslc keeps it on loopback, which is the safer behaviour on hotel Wi-Fi. Microsoft documents a session.defaultBindingAddress key in settings.yaml to change the default, though we only used the explicit 0.0.0.0: prefix in our lab.
The missing Docker socket is the one that decides migrations. Testcontainers, the standalone docker-compose binary and IDE plugins that talk to the Docker API all look for DOCKER_HOST or /var/run/docker.sock, and wslc exposes neither to Windows, so those tools fail before they send a request. If your build or test suite depends on one of them, that workload stays on Docker for now, whatever the licence saving looks like.
wslc compose: what to run until Compose ships
There is no wslc compose today. On WSL 3.0.1 the command fails at the parser with the same message any unknown verb gets:
wslc compose up
Nothing reaches the session VM, so there is no partial start to clean up afterwards:
Unrecognized command: 'compose'
We did not re-run that on newer builds, but the later releases do not change the picture on paper. The WSL release notes for 3.0.2, 3.0.3 (the current stable release, October 9) and the 3.0.4 build on the preview channel (October 20) add JSON event output, improved event filters, build context from stdin and the wslc cp alias, and none of them lists a compose command. Microsoft’s GA announcement calls Compose its top feature request and says the goal is for existing compose.yaml files to work unchanged, without giving a date.
Until then, a two-service stack is a short sequence of plain wslc commands. This is the app-plus-database layout from our lab, in the order a compose.yaml would have created it. The hello-api:1.0 image is the Flask app built in the Dockerfile section below, so build it first or swap in any public image:
wslc network create appnet
wslc volume create pgdata
wslc run -d --name db --network appnet -e POSTGRES_PASSWORD=labpass -v pgdata:/var/lib/postgresql postgres:18
wslc run -d --name api -p 5000:5000 -e GREETING=karibu hello-api:1.0
wslc network connect appnet api
From inside api, the name db resolved to 172.18.0.2 on the new network and psql answered, so service discovery works the way Compose users expect. Tearing the stack down is wslc rm -f api db, then wslc volume rm pgdata only when you want the data gone. What you give up compared with Compose is everything that lives in the YAML rather than in the engine:
- Start order: there is no
depends_on, so put a health check on the database and have the app retry its connection, because our first PostgreSQL start in a new session took 74 seconds. - One-command lifecycle:
up,downandlogsacross services become a PowerShell script you maintain, and it drifts from the team’scompose.yamlunless someone owns it. - Project isolation: Compose prefixes names per project; with plain
wslc, two checkouts that both createappnetanddbcollide in the same session.
For two or three services that trade is fine, and we run it that way. Past that, keep the stack on Docker Engine with the Compose plugin inside a WSL distro, and keep our Docker cheat sheet next to this one for the commands on that side.
How WSL containers are put together
WSL containers reuse the WSL plumbing but not the WSL distro model. Inside that VM sits a standard containerd and dockerd pair (we list the processes further down), so images behave exactly as they do under Docker. If the difference between those layers is fuzzy, our Docker vs CRI-O vs containerd breakdown covers it. The privileged wslservice.exe creates a Hyper-V utility VM through the Host Compute Service, then hands it to a per-user wslcsession.exe process that does all the container work (image pulls, mounts, port bindings) with the caller’s rights instead of SYSTEM’s. Microsoft’s architecture deep dive covers the design. What matters in practice is that each session is its own VM with its own disk, so two users on one machine never see each other’s images.
On our VM the first wslc run created a session called wslc-cli-admin-victor (our SSH shell was elevated, hence the “admin” in the name). Task Manager lists its VM as a process named vmmemwslc-cli-admin-victor, next to wslservice.exe and wslcsession.exe. The session’s state lives in two VHDX files under %LOCALAPPDATA%\wslc\sessions\<session>\: storage.vhdx held 716 MB after pulling the Ubuntu image, and swap.vhdx sat at 36 MB. Deleting that folder resets a broken session completely, but it also destroys every image, container and volume in it (vhd volumes sit in a volumes subfolder there too), so run wslc system session terminate first and only do it when you mean it.
Networking is new as well. Instead of the NAT or mirrored modes that WSL distros use, sessions run a user-mode stack Microsoft calls Consommé. The VM’s Ethernet frames go over a virtio queue to a Windows process running as you, and that process answers DNS and routes TCP and UDP. The practical upside is that container traffic leaves the machine as ordinary traffic from your user, so corporate VPN clients and host firewalls treat it like any other app. We would expect far fewer “works off VPN, breaks on VPN” tickets than with classic WSL networking, although our lab had no VPN to prove it.
Install WSL containers on Windows 11
The container feature needs WSL 2.9.3 or later, and WSL 3.0.1 is the first stable release where it is not labelled preview. The stable channel had moved on to 3.0.3 by October 9, with a 3.0.4 build on the preview channel after it, and we found nothing in the 3.0.2 to 3.0.4 release notes that should change these commands. 3.0.3 does tighten argument checking and ships a newer kernel, and 3.0.4 reworks -v parsing to match Docker, none of which we tested. On a machine that already runs WSL from the Microsoft Store, one command from an elevated PowerShell prompt is the documented upgrade path:
wsl --update
Our image was a fresh Windows 11 install with neither WSL feature enabled. Over a remote SSH session, both wsl --install --no-distribution and wsl --update only answered “The Windows Subsystem for Linux is not installed.” The inbox wsl.exe is a stub until the real package lands. What worked reliably, and what we would script for a fleet or a golden image, is enabling the two Windows features and installing the MSI from the GitHub release, starting with the features from an elevated PowerShell prompt:
dism.exe /online /enable-feature /featurename:VirtualMachinePlatform /all /norestart
dism.exe /online /enable-feature /featurename:Microsoft-Windows-Subsystem-Linux /all /norestart
Reboot, then download and install the 350 MB package silently. Turning off the progress bar matters here, because Windows PowerShell’s Invoke-WebRequest crawls on large downloads while it draws one:
$ProgressPreference = 'SilentlyContinue'
Invoke-WebRequest https://github.com/microsoft/WSL/releases/download/3.0.1/wsl.3.0.1.0.x64.msi -OutFile "$env:TEMP\wsl.msi" -UseBasicParsing # pin: the release tested here, newer MSIs at https://github.com/microsoft/WSL/releases
Start-Process msiexec.exe -ArgumentList "/i `"$env:TEMP\wsl.msi`" /qn /norestart" -Wait
We stayed on 3.0.1 for every measurement in this guide. With the real package installed, wsl --update is Microsoft’s documented route to the current stable release, although we did not run that upgrade on this VM. Confirm the version and that the CLI is on the path. The installer drops two front ends into C:\Program Files\WSL: wslc.exe and container.exe.
wsl --version
wslc version
Both report 3.0.1, running the 6.18 WSL kernel:
WSL version: 3.0.1.0
Kernel version: 6.18.40.1-1
WSLg version: 1.0.79
MSRDC version: 1.2.7214
Direct3D version: 1.611.1-81528511
DXCore version: 10.0.26100.1-240331-1435.ge-release
Windows version: 10.0.26100.9457
wslc 3.0.1.0
You do not need a distro installed for any of this. wslc never touches wsl -l; its VM is separate from Ubuntu or any other WSL distro you already have. Distros keep working side by side, whether that is Ubuntu or the RHEL and Fedora images for WSL.
Run a first container with wslc
The CLI copies Docker’s verbs closely enough that muscle memory mostly works. A throwaway Ubuntu container proves the whole chain (session VM, registry pull, runtime):
wslc run --rm ubuntu:latest bash -c "cat /etc/os-release | head -2; uname -r"
The very first run took 132.9 seconds on our VM, because it boots a brand-new session VM, formats its disk and pulls the image. The same command a second time finished in 2.81 seconds. Budget for that first-run delay in onboarding docs, otherwise someone will assume it hung.

Everything a container prints comes back to your Windows terminal. Sessions appear to be split by user and elevation level, which is why ours carries “admin” in its name. Judging by that naming, images pulled from an elevated prompt will not show up in a normal one, so pick one kind of shell for daily work. Beyond that, wslc system session list shows the session that now exists. wslc info (also wslc system info) adds the path to the per-user settings file, %LOCALAPPDATA%\wslc\settings.yaml. That file does not exist until you run wslc settings once, which creates it with every option commented out at its default. Microsoft’s docs use wslc container ps and wslc image ls, and both work alongside the shorter wslc list and wslc images used in this guide.
Publish ports: wslc binds to localhost by default
The first behaviour difference from Docker that trips people up shows up as soon as you publish a port, so run nginx with the familiar -p flag exactly as you would under Docker:
wslc run -d -p 8080:80 --name web nginx:latest
List it with wslc list and look at the PORTS column. Docker would publish on 0.0.0.0; wslc publishes on the loopback address only, and netstat agrees:
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
1e5f1a70f6b8 nginx:latest "/docker-entrypoint.…" 6 seconds ago Up 3 seconds 127.0.0.1:8080->80/tcp web
TCP 127.0.0.1:8080 0.0.0.0:0 LISTENING 5436
We think that is the right default for a developer laptop, since it stops a test database from being reachable on the coffee-shop Wi-Fi. When a phone or another machine on the LAN does need to reach the container, say so explicitly:
wslc rm -f web
wslc run -d -p 0.0.0.0:8080:80 --name web nginx:latest
After that, netstat shows listeners on both 0.0.0.0:8080 and 127.0.0.1:8080, and you still need a Windows Firewall rule for inbound traffic. The screenshot shows the loopback-only run, with curl.exe getting a 200 from nginx inside the container:

Microsoft Edge on the same machine renders the nginx welcome page from http://localhost:8080, served from inside the WSL container VM:

wslc exec web nginx -v reported nginx 1.31.6 and wslc logs web streamed the entrypoint output, both exactly as they would behave under Docker.
Build an image from a Dockerfile with health checks
A static nginx page proves little, so we built a small Flask API with a health endpoint, the kind of service a team would actually develop on Windows. On our machine the project lives in C:\Users\victor\src\hello-api (the commands below use $env:USERPROFILE so they work under any account) with three files, and the first one is the application itself:
mkdir "$env:USERPROFILE\src\hello-api"; cd "$env:USERPROFILE\src\hello-api"
code app.py
Paste in the application code, which returns the container hostname and a greeting read from an environment variable:
import os, socket
from flask import Flask, jsonify
app = Flask(__name__)
@app.get("/")
def index():
return jsonify(host=socket.gethostname(), greeting=os.environ.get("GREETING", "hello"))
@app.get("/healthz")
def healthz():
return "ok", 200
Pin Flask in requirements.txt with a single line, so rebuilds stay reproducible:
Set-Content requirements.txt 'flask==3.1.2'
Then open a new Dockerfile in the same folder, since wslc build picks up a Dockerfile in the build context by default (Microsoft’s own tutorial uses a Containerfile instead):
code Dockerfile
Below is the exact file we built and ran in the lab. The HEALTHCHECK uses Python’s standard library because the slim image has no curl:
FROM python:3.13-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app.py .
EXPOSE 5000
HEALTHCHECK --interval=10s --timeout=3s --retries=3 CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:5000/healthz')"
CMD ["python", "-m", "flask", "--app", "app", "run", "--host", "0.0.0.0"]
Build and tag the image from the project folder, which becomes the build context:
wslc build -t hello-api:1.0 .
The output is BuildKit-style, one numbered line per Dockerfile step, ending with the image name:
[1/5] FROM docker.io/library/python:3.13-slim@sha256:7c61056e61ac89e852de05f3dc6fa51a6dd2181797bceed46aa725dd7cb2cd3b
[2/5] WORKDIR /app
[3/5] COPY requirements.txt .
[4/5] RUN pip install --no-cache-dir -r requirements.txt
| Successfully installed blinker-1.9.0 click-8.5.0 flask-3.1.2 itsdangerous-2.2.0 jinja2-3.1.6 markupsafe-3.0.3 werkzeug-3.1.9
[5/5] COPY app.py .
exporting to image
| exporting layers
| writing image sha256:5371045de193f1e72b210f7e9eac653da7c5d6d3a801eff4857f78921e9c51e0
| naming to docker.io/library/hello-api:1.0
That cold build took 94.4 seconds including the base image pull. Rebuilding with nothing changed printed CACHED for every step after FROM and still took 10 seconds, most of it spent re-exporting layers. That is slow next to the near-instant no-op rebuilds Docker users are used to. wslc build also understands --target, --build-arg, --secret and -o type=local,dest=..., so multi-stage builds and BuildKit secrets carry over. Run the result with an environment variable and a published port:
wslc run -d --name api -p 5000:5000 -e GREETING=karibu hello-api:1.0
After about 25 seconds, the health check had passed and wslc list showed the container as (healthy). A request from Windows came back from inside the container, with the container ID as its hostname:

Health checks can also be set per run with --health-cmd, --health-interval, --health-retries and --health-start-period, or switched off with --no-healthcheck. --stop-timeout -1 waits forever for a graceful stop, which is useful for databases that flush on SIGTERM.
Mount Windows folders into containers over virtiofs
Bind mounts take plain Windows paths, with no /mnt/c translation. Inside the container, the mount table shows the share is virtiofs, not the Plan 9 (9P) protocol that WSL distros use for /mnt/c:
wslc run --rm -v "$env:USERPROFILE\src\hello-api:/src" ubuntu:latest bash -c "mount | grep /src; ls -la /src"
Every file shows up as root-owned with rwxrwxrwx permissions, which is normal for a Windows-backed share but worth remembering when a tool inside the container insists on strict key-file permissions:
drvfs on /src type virtiofs (rw,relatime)
total 4
drwxrwxrwx+1 root root 512 Sep 30 21:05 .
drwxr-xr-x 1 root root 4096 Sep 30 21:07 ..
-rwxrwxrwx 1 root root 358 Sep 30 21:05 Dockerfile
-rwxrwxrwx 1 root root 253 Sep 30 21:05 app.py
-rwxrwxrwx 1 root root 14 Sep 30 21:05 requirements.txt
The long-form --mount syntax works too, including read-only mounts, and the kernel enforces the flag:

Microsoft says virtiofs is “about twice as fast” as Plan 9 without publishing a method or hardware, so we measured it ourselves. The results are in the benchmark section below, and the headline is less flattering for bind mounts than the marketing suggests.
Connect containers with networks and volumes
A new session comes with the usual bridge, host and none networks. User-defined networks give you name-based DNS between containers, which is what an app talking to its database needs. Create a network and a named volume, then start PostgreSQL on it:
wslc network create appnet
wslc volume create pgdata
wslc run -d --name db --network appnet -e POSTGRES_PASSWORD=labpass -v pgdata:/var/lib/postgresql postgres:18
Attach the already running API container to the same network, then resolve the database by name from inside it:
wslc network connect appnet api
wslc exec api python -c "import socket; print(socket.gethostbyname('db'))"
The name resolved to the database container’s address on the new 172.18.0.0/16 bridge, and psql answered with the server version:

Two details from that run shape capacity planning. Memory comes first: wslc stats reported a 5.778 GiB limit for every container on a 12 GB VM, so the session VM gets roughly half of the host’s RAM by default, the same rule classic WSL uses. First-boot time is the other one. PostgreSQL took 74 seconds from container start to “ready to accept connections” in that brand-new session, nearly all of it initdb writing to the volume, and later runs were much faster, as the benchmark shows. The default volume driver is guest, which keeps data inside the session’s storage.vhdx, while wslc volume create --driver vhd -o SizeBytes=... gives a volume its own ext4 VHD with a hard size cap.
For debugging start-up order between services, wslc events streams lifecycle events the way docker events does. Running a throwaway container in a second terminal produced timestamped container create, network connect (to the default bridge) and container start lines, with the container events carrying the image’s OCI labels.
Copying files out of a running container works, but only under the container noun (the error for the short form is covered below). This pulled the app source back to Windows:
wslc container cp api:/app/app.py "$env:USERPROFILE\app-copy.py"
The command printed nothing at all in our lab, neither a byte count nor a path, so list the target folder with dir before a script relies on the copy being there.
WSL containers vs Docker in WSL: file I/O benchmark
Bind-mount speed decides how a Windows dev loop feels, because that is where your source tree, node_modules and build output end up. So we ran the same script through seven storage paths on the same VM. The three wslc paths were a wslc bind mount of a Windows folder (virtiofs), the container’s own filesystem, a wslc VHD volume. The comparison paths were a WSL Ubuntu distro reading /mnt/c (9P), that distro’s own ext4 disk, and Docker Engine 29.8.2 inside the distro with a /mnt/c bind mount and with its overlay filesystem. The script creates 2,000 small files, stats them, reads them back and deletes them, then writes and reads a 256 MB file with dd. Every figure below is the median of three runs, in milliseconds.
| Storage path | Create 2,000 files | Stat | Read | Delete | Write 256 MB | Read 256 MB |
|---|---|---|---|---|---|---|
| wslc bind mount of C:\ (virtiofs) | 21,712 | 9,025 | 26,397 | 10,771 | 7,902 | 1,784 |
| WSL distro /mnt/c (9P) | 51,850 | 25,550 | 75,655 | 15,365 | 22,323 | 15,110 |
| Docker in WSL, /mnt/c bind mount (9P) | 51,946 | 25,663 | 76,795 | 16,775 | 24,745 | 14,632 |
| wslc container filesystem | 297 | 48 | 142 | 75 | 2,316 | 57 |
| wslc VHD volume (ext4) | 180 | 50 | 94 | 52 | 2,559 | 61 |
| WSL distro ext4 disk | 209 | 69 | 108 | 59 | 2,579 | 68 |
| Docker in WSL, overlay filesystem | 267 | 55 | 119 | 60 | 2,264 | 66 |
The Microsoft claim holds up. A virtiofs bind mount beat the 9P path on every operation. It was 2.4 times faster to create files and 2.9 times faster to read them, then 2.8 times faster on the sequential write and 8.5 times faster on the sequential read (143 MB/s against 17 MB/s). The Docker /mnt/c bind mount landed within about 10% of the bare distro on every operation, which confirms the bottleneck is 9P itself and not the container runtime.
The more useful number is the gap between any Windows-backed mount and storage that lives inside the VM. Creating the same 2,000 files took 21.7 seconds on the virtiofs mount and 0.18 seconds on a wslc VHD volume, about 120 times slower, and reading them back was around 280 times slower. On storage inside the VM, wslc and Docker are effectively tied. In practice this means keeping dependency trees and build caches off the bind mount: mount the source folder, but put node_modules, .venv or target/ on a named or VHD volume. That single layout decision moves a small-file workload by two orders of magnitude, far more than the choice between wslc and Docker.
Two caveats keep these numbers honest. Our Windows 11 machine is itself a VM on Proxmox, so the container VM runs under nested virtualization and every file operation that crosses the Windows boundary pays for extra VM exits. Expect lower absolute times on bare-metal hardware, with similar ratios. The 256 MB reads on VM-local storage came from the page cache, so treat those as a ceiling rather than disk speed. The very first virtiofs run after a fresh session took 471 seconds to create the files before settling at around 21 seconds on the next two runs, so warm the session before measuring anything yourself. The script is short enough to rerun on your own hardware. Save it in a scratch folder under your profile:
mkdir "$env:USERPROFILE\bench"; code "$env:USERPROFILE\bench\io.sh"
The exact script behind the table follows. It takes a directory, a label, and optionally a file count and size in MB, then prints one CSV line of timings:
#!/bin/bash
# usage: io.sh <dir> <label> [files] [MB]
D=$1; L=$2; N=${3:-2000}; MB=${4:-256}; cd "$D" || exit 1
ms(){ echo $(( ($(date +%s%N) - $1) / 1000000 )); }
T=t-$L; B=big-$L.bin; rm -rf $T $B; mkdir $T
s=$(date +%s%N); for i in $(seq 1 $N); do echo "$i" > $T/f$i; done; c=$(ms $s)
s=$(date +%s%N); find $T -type f | xargs stat -c %s > /dev/null; st=$(ms $s)
s=$(date +%s%N); cat $T/* > /dev/null; r=$(ms $s)
s=$(date +%s%N); rm -rf $T; dl=$(ms $s)
s=$(date +%s%N); dd if=/dev/zero of=$B bs=1M count=$MB conv=fsync status=none; w=$(ms $s)
s=$(date +%s%N); dd if=$B of=/dev/null bs=1M status=none; sr=$(ms $s)
rm -f $B
echo "$L,files=$N,create_ms=$c,stat_ms=$st,read_ms=$r,delete_ms=$dl,write${MB}M_ms=$w,read${MB}M_ms=$sr"
Save it with LF line endings (click CRLF in the VS Code status bar to switch), because a CRLF copy fails in bash with $'\r': command not found. Then run it once per storage path, for example against a bind mount:
wslc run --rm -v "$env:USERPROFILE\bench:/b" ubuntu:latest bash /b/io.sh /b wslc-virtiofs
Swap the mount and the label for each path you want to compare, and run each one three times, because the first pass after a fresh session is the slowest by far.
Cold start, memory and build times against Docker
File I/O is only half of the day-to-day experience, so we also timed the things a developer notices when opening the laptop in the morning. Cold start means both VMs fully stopped (wslc system session terminate and wsl --shutdown) and the image already cached, then run --rm ubuntu:latest true. Timings are the median of three runs unless noted (memory is a single reading 90 seconds after start), measured from PowerShell with Measure-Command.
| Test | wslc 3.0.1 | Docker 29.8.2 in WSL Ubuntu |
|---|---|---|
| Cold start to first container exit | 34.1 s (28.5 to 36.6) | 44.9 s (38.0 to 47.7) |
| Next container, VM already running | 5.9 s | 4.2 s |
| Guest memory in use, one idle nginx container (1 reading) | 341 MB | 603 MB |
Flask image build with --no-cache, context on C:\ | 54.0 s (46.6 to 56.6) | 63.2 s (40.6 to 82.2) |
| PostgreSQL 18 start to ready, fresh volume (2 runs) | 11.8 and 11.9 s | 11.9 and 20.5 s |
wslc comes up about ten seconds faster from cold, because it boots a purpose-built VM with nothing but the container stack on it, while the Docker route boots a full Ubuntu userland with systemd before dockerd even starts. That same leaner guest explains the memory gap: 341 MB in use against 603 MB, as reported by free -m inside each VM, with Windows showing the wslc VM at a 706 MB working set. Once both VMs are running, Docker launches the next container slightly faster, and the build times overlap enough (pip downloads dominate them) that we would call builds a tie.
That PostgreSQL row also corrects our own first impression. The very first database start in a brand-new session took 74 seconds, which looked alarming, but on a warm session with a fresh volume both runtimes initialized the cluster in about 12 seconds. Schedule the first wslc run of the day before the stand-up, not during it.
The similarity inside the VM is not a coincidence. wslc system session run executes a command directly in the session VM, and listing its processes shows what the CLI is driving:
wslc system session run ps -eo pid,comm,args
Apart from kernel threads, the session runs Microsoft’s networking and port-relay helpers next to a stock containerd and dockerd pair:
PID COMMAND COMMAND
1 init /init
108 GnsEngine /gns --socket 6
110 PortRelay /init
118 containerd /usr/bin/containerd --address /run/containerd/containerd.sock --root /var/lib/docker/containerd/daemon --state /run/docker/containerd/daemon
119 dockerd /usr/bin/dockerd --containerd /run/containerd/containerd.sock
So WSL containers are the Moby engine in a VM that Microsoft builds, boots and patches, fronted by a Windows-native CLI that deliberately does not expose the Docker socket to Windows. That explains the Docker-shaped pull output and the Docker-style JSON from wslc inspect. It also explains why most of the missing pieces sit on the client side: Compose, DOCKER_HOST and Go templates are features of the Docker client and API, not the engine (multi-platform builds are the exception).
Docker commands that fail in wslc
The wslc help text lists 35 commands and most take Docker’s flags, but the gaps below are the ones that decide whether an existing project moves over. Each heading is the exact message wslc 3.0.1 printed.

The screenshot shows them in the order we hit them while replaying a Docker workflow, and each one is explained below with the workaround we used.
Unrecognized command: ‘compose’
There is no Compose support in any form: no wslc compose, and no Docker Engine API socket for the standalone docker-compose binary to talk to. A project whose README starts with docker compose up needs the sequence of wslc network create and wslc run lines shown in the wslc compose section, or it stays on Docker. For anything beyond two or three services, that is the single biggest reason to stay on Docker for now.
Option name was not recognized for the current command: ‘–platform’
Both wslc run and wslc build reject --platform, which rules out QEMU emulation of arm64 images on x64 and any multi-arch build. Teams that build images for Graviton or Ampere nodes from Windows laptops should keep that in CI.
Unrecognized command: ‘cp’
Docker accepts docker cp at the top level, but wslc 3.0.1 only has wslc container cp, and the stable 3.0.3 release notes add nothing here. The 3.0.4 build on the preview channel adds a top-level wslc cp alias (PR 41902), so this error goes away once that build reaches the stable channel. The same pattern applies to prune, which exists as wslc container prune, wslc image prune, wslc network prune and wslc volume prune, with no top-level system prune.
Invalid format value: {{.NetworkSettings.IPAddress}} is not a recognized format type
wslc inspect --format accepts only json, so the Go-template one-liners from Docker scripts break. Parse the JSON with ConvertFrom-Json in PowerShell instead; wslc inspect returns a JSON array, the same shape as docker inspect. wslc stats has a similar small incompatibility: it rejects --no-stream because it already prints a single snapshot and exits.
‘docker’ is not recognized as an internal or external command
Installing WSL containers does not add a docker shim, and there is no DOCKER_HOST endpoint, so tools that drive Docker through its API (Testcontainers, Compose, many IDE plugins) cannot see wslc at all. Microsoft’s integration story is its own Microsoft.WSL.Containers NuGet package for Windows apps, documented on the WSL container page. The GA announcement also lists wslc support in the Dev Containers CLI, the VS Code Containers extension and Aspire, although we did not test those integrations in this lab.
Should a Docker Desktop team move to wslc?
For single-container services and Dockerfile builds, yes, and it can happen today. For Compose stacks and anything that talks to the Docker API, not yet. The deciding factor is rarely performance: against Docker Engine in WSL, wslc matched it on storage inside the VM, started faster from cold and used less memory, while warm container launches and builds were comparable. What decides it is which commands your repos and tools depend on.
The cost angle is what makes the question worth asking. Docker Desktop is free for personal use and for companies with fewer than 250 employees and under $10 million in revenue; above either line, every developer needs a paid seat. At the current Docker list prices that is $9 per user per month for Pro, $15 for Team (capped at 100 users) and $24 for Business on annual billing, per the Docker pricing page. A 200-developer organization on Business pays about $57,600 a year. wslc ships with WSL, which is MIT-licensed, so the licence line goes to zero. The real cost is migration effort, and that is concentrated in the gaps above.
| Workload | wslc 3.0.1 | What we would run instead |
|---|---|---|
| Single service, Dockerfile builds, health checks | Works as tested | wslc |
| App plus database on a user network | Works, scripted with wslc run | wslc for two or three services |
Multi-service compose.yaml stacks | No Compose | Docker Desktop or Docker Engine in a WSL distro |
Testcontainers, tools that need DOCKER_HOST | No Docker API socket | Docker |
| arm64 images, multi-arch builds | No --platform | Docker buildx, or build in CI |
Heavy node_modules or build caches | Fast on volumes, slow on bind mounts | wslc with dependencies on a VHD volume |
| Windows apps that embed Linux containers | Microsoft.WSL.Containers NuGet API | wslc |
| Managed fleets that restrict registries | Intune on/off switch and registry allow list | wslc |
The Intune row deserves more weight than its single line suggests. Microsoft added an “Allow WSL containers access” setting and a registry allow list to Intune when WSL containers became generally available, and the Defender for Endpoint WSL plugin now covers container activity. That gives a security team a managed, audited container runtime on every Windows laptop without a third-party agent or licence, which is a stronger argument in a regulated shop than raw speed. Our Podman on Windows Server with WSL2 guide covers another free route if rootless containers matter more than Intune, and Rancher Desktop is the free GUI option when a local Kubernetes cluster is part of the job.
The migration path we would follow is gradual. Move the single-container services and image builds to wslc now, and keep Docker Engine in an Ubuntu distro (see our Docker CE on Ubuntu 26.04 steps, which apply inside WSL too) for the Compose stacks. Revisit the remaining Desktop seats once wslc compose ships. Whichever runtime a project lands on, the benchmark says the same thing: keep dependency trees on a volume, not a Windows bind mount.