Cheat Sheets

wslc Cheat Sheet: WSL Containers Commands vs Docker (PDF)

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.

Original content from computingforgeeks.com - post 172378

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

CommandWhat it does
wsl --versionWSL, kernel and Windows build; wslc needs WSL 2.9.3 or later
wslc versionPrint the wslc CLI version
wslc system infoClient and session manager versions, settings file path (also wslc info)
wslc system session listList session VMs, e.g. wslc-cli-admin-victor from an elevated shell
wslc system session run ps -eo pid,comm,argsRun a command inside the session VM itself
wslc system session terminateStop the session VM; new settings apply on the next start
wslc settingsCreate and open %LOCALAPPDATA%\wslc\settings.yaml (from --help)
wslc eventsStream container and network lifecycle events

Images and builds

CommandWhat it does
wslc imagesList 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 pruneRemove unused images such as dangling <none> layers; asks y/N, add -f in scripts (from --help)

Run and manage containers

CommandWhat 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:latestBackground container, published on 127.0.0.1:8080 only
wslc run -d -p 0.0.0.0:8080:80 --name web nginx:latestPublish on every host interface (add a firewall rule too)
wslc run -d --name api -p 5000:5000 -e GREETING=karibu hello-api:1.0Set an environment variable and publish a port
wslc listRunning containers (same as wslc container ps)
wslc restart webRestart a container
wslc rm -f webStop and remove a container in one step
wslc container pruneRemove all stopped containers; asks y/N, add -f in scripts (from --help)

Inspect, logs and exec

CommandWhat it does
wslc exec web nginx -vRun a command in a running container
wslc logs webPrint a container’s log output
wslc statsOne snapshot of CPU, memory, network and block I/O per container
wslc inspect webFull 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

CommandWhat it does
wslc network lsList networks: bridge, host and none exist by default
wslc network create appnetUser network with name-based DNS between containers
wslc network connect appnet apiAttach a running container to a network
wslc network inspect appnetDriver, IPAM config and attached containers with their IPs, as JSON
wslc network pruneRemove all unused networks; asks y/N, add -f in scripts (from --help)

Volumes and Windows mounts

CommandWhat it does
wslc volume create pgdataNamed volume on the default guest driver, inside the session disk
wslc volume create --driver vhd -o SizeBytes=4000000000 benchvhdVolume on its own ext4 VHD with a hard size cap
wslc volume lsList volumes and their drivers
wslc volume rm pgdataDelete 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 pruneRemove 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 habitWhat wslc does instead
docker compose upUnrecognized command: no Compose in wslc yet; script wslc network create plus wslc run
docker run -p 8080:80wslc binds 127.0.0.1 unless you write -p 0.0.0.0:8080:80
docker cpwslc 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-streamFlag rejected; wslc stats already prints one snapshot and exits
docker system pruneNo top-level prune; run the container, image, network and volume prunes, each with a y/N prompt
docker run --platform linux/arm64Rejected by run and build: no emulated or multi-arch images
docker pswslc list or wslc container ps
docker versionNot 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, down and logs across services become a PowerShell script you maintain, and it drifts from the team’s compose.yaml unless someone owns it.
  • Project isolation: Compose prefixes names per project; with plain wslc, two checkouts that both create appnet and db collide 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.

wsl --version showing WSL 3.0.1 and wslc running an Ubuntu 26.04 container in Windows Terminal

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:

wslc run nginx with port 8080 published on 127.0.0.1 and curl returning HTTP 200

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

Welcome to nginx page in Microsoft Edge served by a WSL container on localhost port 8080

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:

wslc list showing a Flask container built from a Dockerfile reporting healthy, with curl returning JSON

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:

wslc bind mount of a Windows folder showing virtiofs in the container mount table and a read-only mount rejecting writes

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:

wslc network create and connect with container name DNS, PostgreSQL 18 version query and wslc stats output

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 pathCreate 2,000 filesStatReadDeleteWrite 256 MBRead 256 MB
wslc bind mount of C:\ (virtiofs)21,7129,02526,39710,7717,9021,784
WSL distro /mnt/c (9P)51,85025,55075,65515,36522,32315,110
Docker in WSL, /mnt/c bind mount (9P)51,94625,66376,79516,77524,74514,632
wslc container filesystem29748142752,31657
wslc VHD volume (ext4)1805094522,55961
WSL distro ext4 disk20969108592,57968
Docker in WSL, overlay filesystem26755119602,26466

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.

Testwslc 3.0.1Docker 29.8.2 in WSL Ubuntu
Cold start to first container exit34.1 s (28.5 to 36.6)44.9 s (38.0 to 47.7)
Next container, VM already running5.9 s4.2 s
Guest memory in use, one idle nginx container (1 reading)341 MB603 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 s11.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.

wslc errors for compose, the platform flag, cp and inspect format, plus docker not recognized on Windows

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.

Workloadwslc 3.0.1What we would run instead
Single service, Dockerfile builds, health checksWorks as testedwslc
App plus database on a user networkWorks, scripted with wslc runwslc for two or three services
Multi-service compose.yaml stacksNo ComposeDocker Desktop or Docker Engine in a WSL distro
Testcontainers, tools that need DOCKER_HOSTNo Docker API socketDocker
arm64 images, multi-arch buildsNo --platformDocker buildx, or build in CI
Heavy node_modules or build cachesFast on volumes, slow on bind mountswslc with dependencies on a VHD volume
Windows apps that embed Linux containersMicrosoft.WSL.Containers NuGet APIwslc
Managed fleets that restrict registriesIntune on/off switch and registry allow listwslc

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.

Keep reading

Claude Code Cheat Sheet: Commands, Shortcuts and Flags (PDF) AI Claude Code Cheat Sheet: Commands, Shortcuts and Flags (PDF) Create a Bootable Windows 11 USB and Install Windows 11 Windows Create a Bootable Windows 11 USB and Install Windows 11 OpenAI Codex CLI Cheat Sheet: Shortcuts, Commands, Flags (PDF) AI OpenAI Codex CLI Cheat Sheet: Shortcuts, Commands, Flags (PDF) Kubectl Cheat Sheet for Kubernetes Admins and CKA Exam Prep Kubernetes Kubectl Cheat Sheet for Kubernetes Admins and CKA Exam Prep Best PC for Blue Iris: Quick Sync, AI GPU and Drives by Camera Count Security Best PC for Blue Iris: Quick Sync, AI GPU and Drives by Camera Count Install and Use Teams for Linux on OpenSUSE Leap 15 Desktop Install and Use Teams for Linux on OpenSUSE Leap 15

Leave a Comment

Press ESC to close