wslcontainers

Migrating Docker Compose to WSL containers

Updated 2026-07-27 · 12 min read · wslc public preview

There is no wslc compose. This is the single largest gap between WSL containers and Docker Desktop, and no flag or setting works around it. What you can do is translate a Compose file mechanically into wslc commands, because most of what Compose does is declarative sugar over exactly the primitives wslc already has: networks, volumes, environment variables and published ports.

The parts that do not translate are the parts Compose implements itself — dependency ordering, healthchecks and restart policies. Those you rebuild in a script. This guide covers both halves.

The mental model

A Compose file is three things stacked together: a set of container definitions, a network and volume topology, and a small orchestrator that starts things in order and watches their health. wslc gives you the first two directly. The third is yours to write.

So every migration follows the same four steps:

  1. Create the user-defined network once, so containers can resolve each other by name.
  2. Create every named volume up front.
  3. Start each service as a wslc run, using the Compose service name as --name so DNS still works.
  4. Add explicit readiness waits where Compose had depends_on or healthcheck.

Naming discipline matters more than it did with Compose. Compose prefixed container names with the project name and created an implicit default network. With wslc, the container name is the DNS name, and there is no implicit network. Keep the service names identical to your Compose file and your application connection strings will not need to change.

Field-by-field translation table

Compose fieldwslc equivalentStatus
image:positional image argumentDirect
container_name:--nameDirect
ports:-p host:containerDirect
environment:-e KEY=valueDirect
env_file:--env-fileDirect
volumes: (named)wslc volume create + -v name:/pathDirect
volumes: (bind)-v C:/host/path:/pathVirtioFS perms
networks:wslc network create + --networkBridge only
command:trailing command argumentsDirect
entrypoint:--entrypointDirect
working_dir:-wDirect
user:-uDirect
build:wslc build -t as a separate stepTwo steps
build.platform:None
depends_on:ordering in your scriptManual
healthcheck:readiness poll in your scriptManual
restart:None
deploy:None
profiles:script parametersManual
secrets: / configs:mount a file, or -eManual
extends:None

The command converter applies the direct rows automatically and flags the manual ones, which is faster than working through this table by hand for a large file.

Worked example: web + API + Postgres

Here is a typical three-service development stack:

# docker-compose.yml
services:
  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_PASSWORD: devpass
      POSTGRES_DB: appdb
    volumes:
      - pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 5s
      retries: 10

  api:
    build: ./api
    environment:
      DATABASE_URL: postgres://postgres:devpass@db:5432/appdb
    ports:
      - "3000:3000"
    depends_on:
      db:
        condition: service_healthy

  web:
    image: nginx:alpine
    ports:
      - "8080:80"
    volumes:
      - ./web/nginx.conf:/etc/nginx/conf.d/default.conf:ro
    depends_on:
      - api

volumes:
  pgdata:

And the wslc translation, as a PowerShell script:

# up.ps1
$ErrorActionPreference = "Stop"
$NET = "appnet"

# 1. topology
wslc network create $NET 2>$null
wslc volume create pgdata 2>$null

# 2. build what needs building
wslc build -t app-api:dev ./api

# 3. db first
wslc run -d --name db --network $NET `
  -e POSTGRES_PASSWORD=devpass -e POSTGRES_DB=appdb `
  -v pgdata:/var/lib/postgresql/data `
  postgres:16-alpine

# 4. replace healthcheck + depends_on with an explicit wait
Write-Host "waiting for db..." -NoNewline
for ($i = 0; $i -lt 30; $i++) {
    wslc exec db pg_isready -U postgres *> $null
    if ($LASTEXITCODE -eq 0) { Write-Host " ready"; break }
    Write-Host "." -NoNewline
    Start-Sleep -Seconds 2
}
if ($LASTEXITCODE -ne 0) { throw "db failed to become ready" }

# 5. api
wslc run -d --name api --network $NET -p 3000:3000 `
  -e DATABASE_URL="postgres://postgres:devpass@db:5432/appdb" `
  app-api:dev

# 6. web — bind mount uses forward slashes
wslc run -d --name web --network $NET -p 8080:80 `
  -v "$($PWD.Path -replace '\\','/')/web/nginx.conf:/etc/nginx/conf.d/default.conf:ro" `
  nginx:alpine

wslc container list

Four details in that script are the whole point of this guide. The network is created explicitly because wslc has no implicit default network. The --name values match the Compose service names so db still resolves from the API container. The healthcheck became a poll loop with a hard failure after 60 seconds. And the bind mount converts Windows backslashes to forward slashes, because the wslc argument parser treats a backslash as an escape character.

Progress output is not decoration. A migration script that runs silently for a minute while an image pulls looks identical to a hung script. Print a line before every long-running step and a dot per poll iteration — you will save yourself far more debugging time than the three lines of code cost.

Replacing depends_on and healthcheck

Compose's depends_on: condition: service_healthy is the feature people miss most, and it is worth being precise about what it actually did: it blocked the dependent container's start until the dependency's own healthcheck passed. Two mechanisms, one outcome.

In wslc you rebuild the outcome. The pattern is always the same — run the dependency's health probe from outside via wslc exec, poll on an interval, and fail hard on timeout:

function Wait-Ready($name, $probe, $timeoutSec = 60) {
    $deadline = (Get-Date).AddSeconds($timeoutSec)
    while ((Get-Date) -lt $deadline) {
        wslc exec $name @probe *> $null
        if ($LASTEXITCODE -eq 0) { return }
        Start-Sleep -Seconds 2
    }
    throw "$name not ready after ${timeoutSec}s"
}

Wait-Ready "db"    @("pg_isready","-U","postgres")
Wait-Ready "redis" @("redis-cli","ping")
Wait-Ready "api"   @("curl","-fsS","http://localhost:3000/health")

The equivalent probes for common images: pg_isready -U postgres for Postgres, mysqladmin ping for MySQL and MariaDB, redis-cli ping for Redis, mongosh --eval "db.runCommand('ping')" for MongoDB, and curl -fsS localhost:PORT/health for anything with an HTTP endpoint.

Note the hard throw on timeout. A silent continue means the next service starts against a database that is not up, and you debug a confusing application error instead of a clear infrastructure one.

Service discovery between containers

Containers on the same user-defined wslc network resolve each other by container name, exactly as they did on a Compose network. postgres://db:5432 works unchanged provided the container is named db and both containers joined the same --network appnet.

Two failure modes to know. First, containers on the default network do not get name resolution — forgetting --network on one service produces a "could not resolve host" that looks like a DNS bug but is a topology bug. Second, --network host is meaningless here: wslc already routes container traffic through the Windows host network stack, so there is no separate host mode to opt into, and passing it is silently ignored.

That host-stack routing is also a genuine upgrade over Docker Desktop for corporate machines. Container traffic inherits your VPN routes, proxy settings and firewall rules automatically, so the usual dance of configuring proxy environment variables inside every container mostly disappears.

Teardown and rebuild scripts

Compose gave you down, down -v and up --build for free. Write them once per project and they stay useful:

# down.ps1 — stop and remove containers, keep data
$services = @("web","api","db")
foreach ($s in $services) {
    wslc stop $s 2>$null
    wslc container remove $s 2>$null
}
Write-Host "stopped: $($services -join ', ')"

# down.ps1 -Volumes — also destroy data
if ($Volumes) {
    wslc volume remove pgdata 2>$null
    wslc network remove appnet 2>$null
    Write-Host "removed volumes and network"
}

Stop in reverse dependency order (web, api, db) so the application layer does not spend its last seconds throwing connection errors into your logs. Keep the volume removal behind an explicit flag — the equivalent of -v on compose down — so a routine restart never destroys your development database.

When not to migrate

Translation is mechanical but it is not free, and there are stacks where the honest answer is to stay on Docker Desktop:

  • More than about six services. The script becomes a small orchestrator you now maintain. That is real ongoing cost against a Compose file that already works.
  • Compose profiles driving several environments. Reimplementing profile selection in script parameters is doable and tedious.
  • Testcontainers or CI depending on a Docker socket. wslc exposes no Docker-compatible socket, so these do not work at all.
  • Multi-arch builds. No buildx, no platform.
  • Containers expected to survive reboot. No restart policies.

A reasonable middle path: migrate the single-container projects to wslc, keep Docker Desktop installed but not starting on login, and launch it only for the Compose stacks. You get most of the idle-memory and file-I/O benefit without rewriting orchestration you already trust. The comparison guide covers the coexistence trade-offs in more detail.

FAQ

Does wslc support docker compose?
No. The wslc public preview has no Compose runtime and no wslc compose subcommand. Multi-service stacks must be translated into individual wslc run commands, usually driven by a PowerShell or bash script, or kept on Docker Desktop.
How do I run a docker-compose.yml with WSL containers?
Translate it manually: create a user-defined network with wslc network create, create named volumes with wslc volume create, then start each service as a wslc run command in dependency order, using the service name as the container name so DNS resolution between containers works.
Which Compose fields have no wslc equivalent?
deploy, restart, healthcheck, depends_on with condition, profiles, configs, secrets, extends and build.platform have no direct equivalent. depends_on ordering must be enforced by your script, and healthchecks must be replaced with a readiness poll loop.
Can I keep using Compose alongside wslc?
Yes. Docker Desktop and wslc use separate utility VMs and image stores, so you can keep Docker installed for Compose projects and use wslc for single-container work. The cost is duplicated images and disk usage.

Related guides