Running Windows and Linux Containers Side by Side post

Crosspose logo

Running Windows and Linux Containers Side by Side

I work on a complex multi-service stack that's a mix of Windows containers and Linux containers. In production it's a Kubernetes cluster with both node types. After we'd finished containerising everything, a few of us got curious about whether the whole stack could be run locally on a dev machine.

When I say "run locally" I mean all of it. Not just the Linux half, not just the Windows half, the whole thing on one laptop. Turns out that's much harder than you'd expect.

Problem: One OS at a time

Docker on Windows can run Windows containers or Linux containers, but not both at once. As soon as your stack contains one of each you're stuck. Most local-Kubernetes options (Minikube, k3s, kind) only run Linux containers, so they don't help either.

Solution: Two engines, one box

WSL2 (Windows Subsystem for Linux) is a lightweight, automatically-managed Linux VM that ships with Windows. Docker Desktop already uses it internally to run Linux images. There's nothing stopping you setting up your own.

So you put Docker Desktop into Windows-containers mode for the Windows half, install Podman inside WSL2 for the Linux half, and run them side by side. Docker handles its workloads, Podman handles its workloads, both happily ignoring each other.

It works, kind of.

Problem: Empty containers

The images are running but nothing actually does anything because nothing is configured. Every container needs environment variables, connection strings, secrets, all the usual things, and you don't know what any of them should be.

You start digging through source code, deployment repos and docs trying to piece it together. If only there was a single source of truth that already described what each container needed...

Solution: Ask the Helm chart

The Helm chart is the single source of truth. It already describes how every workload is configured in production, with environment variables, secrets, services, dependencies, the lot. So you trawl through it and copy the relevant config out into hand-rolled compose files, one for the Linux side, one for the Windows side.

You commit them to git so the next person doesn't have to repeat the exercise, and you feel like you're getting somewhere.

Problem: Where's my database

Some Helm charts ship their dependencies bundled in. Bitnami's common charts will happily spin up their own SQL Server, Redis, RabbitMQ or whatever they need. Most production-targeted charts don't, because in production those are managed services. SQL Server is an Azure SQL instance, Service Bus is Azure Service Bus, storage is a real Storage Account.

So you start seeing connection strings pointing at SQL Server, Azure Service Bus, Storage Accounts, SignalR that have nowhere to actually go. The chart assumes someone else is providing them. Locally, that someone is you.

The gap is chart-dependent (and chart-author-opinion-dependent), so whatever fixes it has to live outside the chart and let me bolt on the infra a given chart doesn't ship.

Solution: More containers

Most of these tools have container images available for local use, including the infrastructure:

So you scaffold all of these into your compose files too, point your connection strings at them, and try again.

Problem: They can't talk

Of course it doesn't work, because the Windows containers and Linux containers are on completely separate virtualisation platforms and have no idea how to find each other. SQL Server is in WSL2, the .NET Framework service that needs it is in Docker Desktop, and they live on different network islands.

Solution: Holes in the wall

After a lot of netsh, a lot of ipconfig output, and some help from the LLM of your choice, you end up with a stack of port-proxy and firewall rules. Both directions look basically the same, the difference is which adapter you listen on. For Windows containers reaching Linux services, you listen on the Docker NAT gateway IP. For Linux containers reaching Windows services, you listen on the WSL2 vEthernet adapter IP. Both forward to 127.0.0.1 because WSL2 handles the localhost-to-VM hop for you:

# Forward inbound traffic on the right adapter to localhost
netsh interface portproxy add v4tov4 `
    listenaddress=<ADAPTER_IP> listenport=1433 `
    connectaddress=127.0.0.1 connectport=1433

# Open the port through the Windows firewall
netsh advfirewall firewall add rule name="Expose SQL 1433" `
    dir=in action=allow protocol=TCP localport=1433 localip=<ADAPTER_IP>

Then a /etc/hosts entry inside WSL2 so Linux containers can resolve the service by name. SQL Server in particular insists on it, because it only responds on bound hostnames and the proxying strips that:

<ADAPTER_IP>  mssql

After repeating this for every service that crosses the OS boundary in either direction, you finally have a working hybrid stack on one machine.

Milestone: It Works

You did it. The whole system is up on your laptop. Twenty-three or so containers all configured and talking to each other across two virtualisation platforms.

It is also incredibly tedious to set up. Every new service means another compose entry, another set of env vars, possibly another set of port proxies and firewall rules. You have to manually authenticate to private registries on both Docker and Podman. You're constantly switching shells between Windows docker and WSL podman. And every time WSL or Docker Desktop restarts, the port proxies need rebuilding.

You can see exactly where this is headed: once anyone else finds out you've done it, they'll want it too, and you'll end up owning this process forever. So you start scripting it.

Productionising: Crosspose

I had been scripting all of this in PowerShell from the start, mostly to stop myself forgetting some critical command and breaking what was already working. PowerShell is fine for that, but it's not really the right tool for what this had grown into.

So in my spare time I rebuilt the whole thing as a proper .NET solution. It's called Crosspose (cross-platform compose), and it's three tools that share a single core. Each one has a CLI and a WPF GUI, and they're all configured from a single crosspose.yml so the same setup works for everyone on the team.

Dekompose

An inversion of the existing kompose tool. Where kompose converts Docker Compose files into Kubernetes manifests, Dekompose does the opposite. It takes a Helm chart (or raw Kubernetes manifests) and generates working compose files. It splits workloads by OS, remaps ports, rewrites cross-OS URLs to use the right gateway IPs, replaces secrets, and scaffolds infra containers in.

All the chart-specific bits (which infra containers to scaffold in, how to rewrite secret references with literal values for local dev, which cross-OS URLs to remap) live in a dekompose.yml rules file. You can pass one explicitly with --dekompose-config, or charts can bundle their own defaults under a crosspose/ directory so anyone pulling the chart gets a working local-dev setup out of the box:

my-chart/
├── Chart.yaml
├── values.yaml
├── templates/
└── crosspose/
    ├── values.yaml     # local-dev values overrides
    └── dekompose.yml   # rules for this chart

When the chart is pulled, the bundled files are extracted as siblings of the tgz and the GUI picks them up automatically. External files passed on the command line take precedence, so chart authors ship sensible defaults and consumers can still override. The full schema and chart-authoring guide is in docs/helm-chart-authoring.md.

Dekompose GUI converting a Helm chart

Two related concepts sit around Dekompose in the GUI. Charts is the source management view, where you add Helm repositories or OCI registries (with authentication where it's needed) and pull charts from them. Bundles is the versioned output, so you can dekompose the same chart multiple times with different values, roll back to a previous bundle, or run a couple of versions in parallel.

Chart sources view showing Helm repos and OCI registries

Compose Bundles view showing versioned dekompose outputs

Crosspose Core

A shim that brokers compose commands across Docker and Podman. It's a drop-in replacement for running them separately. crosspose up brings both halves up in parallel, crosspose ps aggregates containers from both runtimes into one view.

Windows and Linux containers in one dashboard, both reported healthy

Crosspose Doctor

Shamelessly inspired by flutter doctor. Runs a stack of prerequisite checks (Docker mode, WSL2 config, Podman, port proxies, firewall rules, registry authentication, and so on), and every check has a one-click fix. Some checks run as background monitors and auto-heal in place.

Crosspose Doctor running its checks

AI Acceleration

I leaned heavily on Claude Code to translate the PowerShell into the C# project. The architecture, the design decisions and the problem-solving were mine. The PowerShell PoC was effectively the design document, every script became a class, every variable became configuration, every edge case became a test. Claude handled the implementation velocity, which is how something that should have been a year of evenings ended up being a few weeks.

Beyond the initial build, it's also kept iteration cheap. Adding a new doctor check, refactoring how compose orchestration runs, or trying out a GUI for something that started CLI-only are all things that would normally cost an evening just to scope. Now they're hunches I can try and back out of if they don't pan out. The codebase is the size it is because exploring something doesn't really cost anything.

Hello, World

To prove out the whole flow without dragging anyone's real workload into it, I also wrote a cross-platform hello world Helm chart. It deploys two services, one Windows and one Linux, that each render a status page with green/red indicators for the four hardest things to get right:

  • Connection to MSSQL (running as a Linux container in WSL2)
  • Connection to Azure Service Bus Emulator (Linux container in WSL2)
  • Connection to Azurite for Storage (Linux container in WSL2)
  • A direct HTTP call to the other OS's container (Windows ↔ Linux via the NAT gateway and WSL2 host IP tricks)

When everything works, both pages light up green:

Hello world chart running on both Windows and Linux, all four checks green

The point isn't the chart itself. It's that if Crosspose can take that one Helm chart and produce two healthy containers that successfully talk to each other and to three pieces of emulated infrastructure, on a single dev machine, without a Kubernetes cluster, it can probably do the same for whatever you're working on.

Built To Be Deprecated

The thing I'm most proud of in Crosspose isn't a feature, it's the exit strategy.

Crosspose is a migration bridge. The whole reason it exists is the awkward in-between state where your stack has Windows containers in it. As workloads move off Windows, the need for the Windows half disappears, and Crosspose deliberately steps back as that happens:

Phase 1: Full Crosspose

You have Windows containers that can't run on Linux Kubernetes. Crosspose brokers Docker Desktop and Podman side-by-side, handles the cross-OS networking, and gives you the unified GUI and CLI. This is where most stacks start.

Phase 2: Dekompose only

All your workloads are Linux containers, but you're still not running local Kubernetes. Dekompose converts the Helm chart into compose files and you run them with plain docker compose or podman compose. None of the Windows-specific tooling is in play any more.

Phase 3: Native Helm

You can run the Helm chart directly against a local cluster (Minikube, k3s, kind, whatever). Crosspose is gone. You're running the same chart locally that runs in production.

Throughout all three phases, the Helm chart stays the single source of truth. The same chart that runs in production drives the local developer environment at every stage. Crosspose just bridges the gap until the chart can run natively.

In other words, the goal is for nobody to need Crosspose, and the design makes that path obvious instead of fighting it. That feels like the right shape for what is genuinely a niche problem: a tool that's eager to step aside, not one that entrenches itself.

Closing

I haven't found another tool that does Windows and Linux compose orchestration side-by-side like this. That might mean it's a niche problem nobody else has, or that everyone else solved it better and I missed it. If you know of the latter, please tell me.

If you've got a hybrid stack you want to run locally, all of this is open source. The hello world chart is the fastest way to see whether it might fit against your own workload.

Repos and docs

Categories: devops

Tags: crosspose, docker, podman, kubernetes, helm, wsl