Skip to content

Backends: WSL Containers or Docker

Experimental Containers Reviewed 2026-10-02 purview-dev/containers Star on GitHub 3 azurite containers csharp docker dotnet integration-testing mysql nats nuget postgresql rabbitmq redis sql-server testcontainers testing windows wsl wsl-containers wslc

Experimental. This project is an experiment: the public API, defaults and packaging can change between prereleases, and there is no production support guarantee.

Purview.Containers has one API and two interchangeable backends. The same test code, the same service modules (Purview.Containers.PostgreSql, Redis, MsSql, RabbitMq, Azurite, Nats, MySql), and the same connection-string accessors run on either runtime — you choose which one by the package you reference, and can override it in code or from the environment.

  • **Purview.Containers** — the umbrella: Core plus both backends, for one reference with automatic selection.
  • **Purview.Containers.Core** — the backend-neutral abstractions (the API you code against); usually arrives transitively.
  • **Purview.Containers.Wsl** — throwaway containers on Microsoft WSL Containers (WSLC), the Windows runtime with no Docker installation.
  • **Purview.Containers.Docker** — throwaway containers on any reachable Docker daemon, driven by Testcontainers.
PackageUse it when
Purview.ContainersThe umbrella: Core + both backends. One reference, zero config — WSLC on Windows, Docker elsewhere (auto). Recommended default.
Purview.Containers.CoreThe backend-neutral abstractions (the API). Reference it explicitly with one or both backends when you want a minimal dependency surface; otherwise it arrives transitively.
Purview.Containers.WslWSL Containers only — a Windows dev machine with no Docker.
Purview.Containers.DockerDocker only — any platform, CI, Linux/macOS.

Service modules (Purview.Containers.PostgreSql, Redis, …) are backend-neutral: add any backend package (or the umbrella) alongside one to run it.

WSL ContainersDocker
PackagePurview.Containers.WslPurview.Containers.Docker
PrerequisiteWindows 10/11 with WSL Containers (wsl --install --no-distribution)any reachable Docker daemon (Docker Desktop, Docker Engine in WSL2, a VM, or a CI runner)
Host OSWindows onlyWindows, Linux, macOS
Project target frameworknet10.0 or later, any platform (portable facade), or net10.0-windows10.0.19041.0, x64 or arm64 (implementation bound directly)net10.0 or later, any platform
How containers runthe Microsoft.WSL.Containers managed API (daemonless)the Docker Engine API via Testcontainers
Imagesa shared store (%LOCALAPPDATA%\Purview\WslContainers\images) reused across runs; the location is configurablethe daemon’s own image store
Leak protectionsession disposal plus a process-exit hookthe Testcontainers resource reaper (Ryuk)
Check the hostwsl --version, wslc versiondocker info
Typical fitlocal Windows development without Docker Desktop, fastest cold startCI runners, non-Windows hosts, teams already running Docker

By default WSLC images are pulled once into a shared store under the local profile:

%LOCALAPPDATA%\Purview\WslContainers\images

To put the image store somewhere else — a different drive, a project-local cache, or a per-run folder — set the process-wide environment variable (works for every consumer shape, including a platform-neutral net10.0 facade):

Terminal window
$env:PURVIEW_CONTAINERS_STORAGE_PATH = 'D:\wslc-images' # PowerShell
Terminal window
export PURVIEW_CONTAINERS_STORAGE_PATH=/mnt/d/wslc-images # bash / WSL / CI

Or configure the backend in code:

using Purview.Containers;
using Purview.Containers.Wsl;
ContainerBackends.Use(new WslContainerBackend(WslContainerRuntimeOptions.Default with
{
StoragePath = @"D:\wslc-images",
StorageMode = StorageMode.Shared,
}));

StorageMode.Shared (the default) keeps a stable, warm image store at StoragePath (or the default above when it is unset); StorageMode.PerSession gives each session a throwaway store under %LOCALAPPDATA%\Purview\WslContainers\sessions\{name}. The full WslContainerRuntimeOptions set (CPU, memory, GPU, session name, StoragePath, StorageMode, timeout) is honoured on both a platform-neutral net10.0 consumer — where the facade forwards it to the Windows build at run time — and a Windows target framework. The pre-rename WSL_CONTAINERS_STORAGE_PATH is still honoured as a fallback.

Switch between them without touching test code:

Section titled “Switch between them without touching test code:”
Terminal window
# auto (the default) | wsl | docker | <any registered backend name>
export PURVIEW_CONTAINERS_BACKEND=docker # bash / zsh / CI
Terminal window
$env:PURVIEW_CONTAINERS_BACKEND = 'wsl' # PowerShell

Each backend reports its own readiness, so a missing runtime is a clear message instead of a timeout deep inside a test.

WSL Containers:

Terminal window
wsl --version # WSL itself
wslc version # the WSLC runtime
using Purview.Containers.Wsl;
var info = await new WslContainerBackend().GetInfoAsync();
Console.WriteLine($"{info.Name}: usable={info.IsUsable} version={info.Version}");
if (!info.IsUsable)
{
Console.WriteLine(string.Join("; ", info.MissingComponents));
}

Docker:

Terminal window
docker info
using Purview.Containers.Docker;
var info = await new DockerContainerBackend().GetInfoAsync();
Console.WriteLine($"{info.Name}: usable={info.IsUsable} version={info.Version}");
if (!info.IsUsable)
{
Console.WriteLine(string.Join("; ", info.MissingComponents));
}

ContainerBackends.ProbeAllAsync() reports every registered backend at once, which is the quickest way to answer “what can this machine run?”:

foreach (var backend in await ContainerBackends.ProbeAllAsync())
{
Console.WriteLine($"{backend.Name}: usable={backend.IsUsable} version={backend.Version}");
}

The test body never names a backend, so it is identical on both runtimes:

using Purview.Containers;
using Purview.Containers.Waiting;
public class CacheTests
{
[Test]
public async Task Cache_IsReachableOnItsMappedPort()
{
await using var container = new ContainerBuilder()
.WithImage("redis:7")
.WithPortBinding(6379, assignRandomHostPort: true)
.WithWaitStrategy(Wait.ForTcpPort(6379))
.Build();
await container.StartAsync();
ushort port = container.GetMappedPublicPort(6379);
await Assert.That(port).IsGreaterThan((ushort)0);
}
}

The package reference decides which runtime executes it. Three project shapes cover every case.

Terminal window
dotnet add package Purview.Containers.Wsl
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework><!-- WSLC only -->
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Purview.Containers.Wsl" Version="1.0.0-prerelease.1" />
</ItemGroup>
</Project>

This project runs WSLC on a Windows host. A platform-neutral net10.0 project binds the portable facade; a Windows target framework (net10.0-windows10.0.19041.0, x64 or arm64) binds the implementation directly. WindowsSdkPackageVersion is supplied by the package. A project older than .NET 10 fails the build with PCC0001, and a 32-bit Windows consumer with PCC0002; see Consumer Requirements.

On a non-Windows host this project has no usable backend (the facade reports wsl unavailable). To also run on Docker there, add Purview.Containers.Docker or use the umbrella — see Option 3.

Terminal window
dotnet add package Purview.Containers.Docker
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Purview.Containers.Docker" Version="1.0.0-prerelease.1" />
</ItemGroup>
</Project>

That project restores and builds on Linux, macOS and Windows — no Windows target framework, no WindowsSdkPackageVersion, no Docker Desktop licence requirement beyond the daemon you already run.

Section titled “Option 3 — both backends, automatic selection (recommended)”

Add the umbrella Purview.Containers: it brings Core plus both backends, so auto selects WSLC on a machine that can run it and Docker otherwise — no multi-targeting and no conditional references:

Terminal window
dotnet add package Purview.Containers
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework><!-- WSLC on Windows, Docker elsewhere -->
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Purview.Containers" Version="1.0.0-prerelease.1" />
</ItemGroup>
</Project>

auto probes the registered backends in priority order (wsl before docker) and uses the first that is usable, so this one project runs on WSLC on a developer’s Windows machine and on Docker in a Linux CI job. A Windows target framework is still supported when you want the implementation bound at compile time, but it is not required.

Prefer the explicit form? Reference Purview.Containers.Core plus both backends instead of the umbrella:

Terminal window
dotnet add package Purview.Containers.Core
dotnet add package Purview.Containers.Wsl
dotnet add package Purview.Containers.Docker

If your code needs a Windows-only API the portable facade does not expose (for example WslContainer, or WslContainerBackend(runtime) to pin a specific WSLC session), guard it with #if WINDOWS — defined by the SDK only for a Windows target framework — so a portable target still compiles:

#if WINDOWS
using Purview.Containers.Wsl;
#endif
// ...
#if WINDOWS
var backend = new WslContainerBackend(runtimeForIsolation);
#else
var backend = new DockerContainerBackend();
#endif

A module is backend-neutral, so only the backend package line changes:

Terminal window
dotnet add package Purview.Containers.PostgreSql # the module
dotnet add package Purview.Containers # ...or Purview.Containers.Wsl / Purview.Containers.Docker
using Npgsql;
using Purview.Containers.PostgreSql;
await using var postgres = new PostgreSqlBuilder()
.WithDatabase("tests")
.WithUsername("postgres")
.WithPassword("postgres")
.Build();
await postgres.StartAsync(); // ready when pg_isready succeeds, on either backend
await using var connection = new NpgsqlConnection(postgres.GetConnectionString());
await connection.OpenAsync();

Selection is resolved once per process, in this order:

#RuleHow to set itBehaviour
1Pinned instanceContainerBackends.Use(new DockerContainerBackend()), or WithBackend(...) on one builderused as-is: never probed, never substituted
2Named backendPURVIEW_CONTAINERS_BACKEND=wsl|docker|<name>, or ContainerBackends.Use(ContainerBackendSelection.Named("docker"))probed; a missing or unusable backend fails with its own diagnostics and no fallback
3Automatic detectionthe default (auto)every registered backend is probed in auto-priority order (wsl at 0, docker at 100); the first available and compatible one wins, so WSLC is preferred over Docker
using Purview.Containers;
// Automatic detection (the default)...
var container = new ContainerBuilder().WithImage("alpine:3.19").Build();
// ...or pin it for this builder only.
var pinned = new ContainerBuilder()
.WithBackend(new DockerContainerBackend())
.WithImage("alpine:3.19")
.Build();
// ...or pin it for the process.
ContainerBackends.Use(ContainerBackendSelection.Named("docker"));

Build() never needs a backend: it validates the configuration and returns a container that resolves the backend when it starts. That is what lets the same test suite run on whichever runtime the machine has.

To fail loudly instead of falling back — the usual choice in CI — name the backend:

PURVIEW_CONTAINERS_BACKEND=docker # if Docker is not reachable, the test fails and says why

A hosted Linux runner already has a Docker daemon, so nothing has to be started alongside your tests — reference Purview.Containers.Docker in a net10.0 test project and run dotnet test:

name: tests
on: [push, pull_request]
jobs:
integration-linux:
name: Integration tests (Docker)
runs-on: ubuntu-latest
env:
# Optional. "auto" (the default) also selects Docker when WSL Containers is absent.
PURVIEW_CONTAINERS_BACKEND: docker
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
dotnet-version: 10.0.x
- run: dotnet restore
- run: dotnet build --configuration Release --no-restore
- run: dotnet test --configuration Release --no-build
  • On a Linux job, target a platform-neutral framework (net10.0 or later). The WSL Containers package is portable — its net10.0 facade loads the WSLC implementation on a Windows host and reports wsl as unavailable elsewhere — so one test project can reference both backends and auto selects WSLC on a Windows developer machine and Docker on the Linux runner.
  • Keep your integration tests in their own project or category if you want the fast unit tests to stay runtime-free; both can run in the same job.
  • Pinning PURVIEW_CONTAINERS_BACKEND=docker makes a runner without a usable daemon fail loudly with the probe report instead of quietly selecting something else.

For a Windows job that should exercise WSLC, run the same code against Purview.Containers.Wsl:

integration-windows:
name: Integration tests (WSL Containers)
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
dotnet-version: 11.0.x
- run: dotnet test --configuration Release

WSL Containers is a Windows developer runtime: hosted GitHub runners do not guarantee a WSL Containers installation, so the practical options are a self-hosted Windows runner, or running WSLC suites locally and Docker suites in CI. Either way the test code is the same.

CapabilityWSL ContainersDocker
Random host ports✅ native (windowsPort=0)✅ assigned by the daemon
Fixed host ports✅✅
IPv6 host mapping❌ IPv4 loopback only✅
UDP port mappings❌ ContainerNotSupportedException✅
Bind mounts of host directories✅✅
Named volumes✅ (session VHD)✅ (Docker volumes)
GPU exposure✅ (EnableGPU session setting)depends on the daemon and host runtime
ExecOptions.WorkingDirectory / Timeout✅✅
ExecOptions.Environment✅❌ ContainerNotSupportedException
Log tailingstreamed while the init process runspolled until the container stops
Leak protectionsession disposal + process-exit hookTestcontainers resource reaper (Ryuk)
Image storeshared WSLC store, warm across runsthe daemon’s store
Lifetime costone process-wide session, cheap startfirst pull per image, per daemon

Unsupported options throw ContainerNotSupportedException rather than being ignored, so a difference between backends never turns into a silently weaker test.

No container backend is registered. — no backend package is referenced by the test project (and the assembly that would register it was never loaded). Add one: dotnet add package Purview.Containers.Docker or dotnet add package Purview.Containers.Wsl.

No usable container backend was found (selection: auto). — every registered backend failed its probe; the report names each one and why:

No usable container backend was found (selection: auto).
wsl: unavailable (Sdk; SdkNeedsUpdate)
docker: unavailable (HttpRequestException: Connection refused)
Install or fix a backend, or set PURVIEW_CONTAINERS_BACKEND to one of: wsl, docker.

Fix the runtime it names (for WSLC: wsl --install --no-distribution; for Docker: start the daemon, or point DOCKER_HOST at it), or pin the backend that does work.

The selected container backend 'docker' is not usable: … — you named a backend that cannot run here. This is intentional: a named backend never falls back to another one, so a CI job cannot pass by silently using the wrong runtime.

The container backend 'docker' is not registered. Registered backends: wsl. — the selection names a backend whose package is not referenced by the project.

PCC0001 / PCC0002 — a build-time guard from the WSL Containers backend package: the consuming project is neither .NET 10+ (Windows or platform-neutral), or is a 32-bit Windows consumer. See Consumer Requirements, or switch the project to the Docker backend.

The same image is pulled twice — expected when both runtimes are used: WSLC and Docker keep separate image stores.

  • Getting Started — install, first container, first typed module.
  • Consumer Requirements — the target-framework contract, the guards and the workarounds.
  • Architecture — how resolution and registration work internally.
  • Modules — the service modules and their readiness strategies.