Getting a Windows machine ready for container work isn’t hard, but it is a sequence: turn on a couple of Windows features, restart, install a Linux distribution, install Docker, and then try to remember which boxes you ticked. Do that by hand on every new laptop and every machine ends up a little different from the last.
The note I left myself when I started this post was one question: can I just use choco for all of this? The short answer is "mostly," and I’ll come back to it. But everything you need is already on a current Windows machine, so this post builds the whole setup as one PowerShell script that you run, restart, and run again. It also covers the new option that arrived in September: containers built into WSL itself.
TL;DR
wsl --install --no-distributionturns on the Windows features WSL needs. They don’t work until you restart.- Install a pinned release with
wsl --install --distribution Ubuntu-24.04. PlainUbuntugets whatever is newest that day. winget install --exact --id Docker.DockerDesktopinstalls Docker Desktop. If your company needs a paid Docker plan and doesn’t have one, install Docker Engine inside the distribution instead.- Write every step to check before it acts. Then the restart is just "run it again."
- Choco can install WSL and Docker Desktop too, but you still install the distribution with
wsl --install.
What the script does
There are four steps, and only the first one needs a restart:
That shape isn’t my invention. WSL’s own installer works the same way: if the Windows features are missing, wsl --install turns them on, skips installing the distribution, and tells you to restart. The script just applies the same rule to every step, so you never have to remember where you left off.
The script
Here it is in full. It targets Windows PowerShell 5.1, the version every Windows machine already has, so there’s nothing to install first.
#Requires -RunAsAdministrator
<#
.SYNOPSIS
Set up WSL 2, a Linux distribution, and a container engine on a Windows dev machine.
.DESCRIPTION
Every step checks before it acts, so the script is safe to run again. Turning on
WSL needs a restart; when it does, the script says so and stops. Restart, run the
same command again, and it carries on from there.
.PARAMETER Distro
The WSL distribution to install. Pin a release (Ubuntu-24.04) rather than plain
"Ubuntu", which installs whichever release is newest on the day you run it.
.PARAMETER Engine
DockerDesktop (the default), DockerEngine (Docker's packages inside the
distribution, no Docker Desktop), or None.
.EXAMPLE
.\Setup-DevMachine.ps1
.EXAMPLE
.\Setup-DevMachine.ps1 -Distro Ubuntu-26.04 -Engine DockerEngine
#>
[CmdletBinding()]
param(
[string] $Distro = 'Ubuntu-24.04',
[ValidateSet('DockerDesktop', 'DockerEngine', 'None')]
[string] $Engine = 'DockerDesktop'
)
$ErrorActionPreference = 'Stop'
# wsl.exe prints UTF-16 unless told otherwise, and PowerShell then sees a NUL between
# every character. With WSL_UTF8=1 its output compares like any other string.
$env:WSL_UTF8 = '1'
function Write-Step([string] $Message) {
Write-Output "==> $Message"
}
function Invoke-Native([string] $FilePath, [string[]] $ArgumentList) {
# Windows PowerShell doesn't stop on a failing program, only on failing cmdlets,
# so check the exit code ourselves.
& $FilePath @ArgumentList
if ($LASTEXITCODE -ne 0) {
throw "$FilePath $($ArgumentList -join ' ') failed with exit code $LASTEXITCODE"
}
}
# 1. Windows features. Windows 11 needs Virtual Machine Platform; Windows 10 also
# needs the original WSL feature. Neither works until Windows restarts.
$features = @('VirtualMachinePlatform')
if ([Environment]::OSVersion.Version.Build -lt 22000) {
$features += 'Microsoft-Windows-Subsystem-Linux'
}
$missing = @($features | Where-Object {
(Get-WindowsOptionalFeature -Online -FeatureName $_).State -ne 'Enabled'
})
if ($missing.Count -gt 0) {
Write-Step "Turning on $($missing -join ' and ')"
Invoke-Native wsl.exe '--install', '--no-distribution'
Write-Warning 'Restart Windows, then run this script again to finish.'
exit 0
}
# 2. WSL itself. Docker Desktop needs WSL 2.1.5 or later; this gets the latest.
Write-Step 'Updating WSL'
Invoke-Native wsl.exe '--update'
# 3. The Linux distribution. Its first launch asks you to create a Linux user and
# then leaves you at a shell prompt; type exit and the script carries on.
$installed = @(& wsl.exe --list --quiet) | ForEach-Object { $_.Trim() }
if ($installed -contains $Distro) {
Write-Step "$Distro is already installed"
}
else {
Write-Step "Installing $Distro. Create your Linux user when asked, then type exit."
Invoke-Native wsl.exe '--install', '--distribution', $Distro
}
# 4. The container engine.
switch ($Engine) {
'DockerDesktop' {
$locations = @(
"$env:ProgramFiles\Docker\Docker", # installed for all users
"$env:LOCALAPPDATA\Programs\DockerDesktop" # installed for one user
)
if (@($locations | Where-Object { Test-Path $_ }).Count -gt 0) {
Write-Step 'Docker Desktop is already installed'
}
else {
Write-Step 'Installing Docker Desktop'
Invoke-Native winget.exe 'install', '--exact', '--id', 'Docker.DockerDesktop',
'--accept-package-agreements', '--accept-source-agreements'
}
Write-Step 'Start Docker Desktop once to finish setup, then try: docker run --rm hello-world'
}
'DockerEngine' {
$linuxUser = "$(& wsl.exe --distribution $Distro --exec whoami)".Trim()
Write-Step "Installing Docker Engine in $Distro for $linuxUser"
Invoke-Native wsl.exe '--distribution', $Distro, '--user', 'root', '--cd', $PSScriptRoot,
'--exec', 'bash', './install-docker-engine.sh', $linuxUser
Write-Step "Open a new $Distro terminal, then try: docker run --rm hello-world"
}
'None' {
Write-Step 'Skipping the container engine'
}
}
Write-Step 'Done. wsl --list --verbose shows what is installed.'Code language: PowerShell (powershell)
A few parts deserve a closer look.
Turning on WSL
wsl --install --no-distribution installs only the Windows pieces WSL needs and leaves the distribution for later. On Windows 11 that’s the Virtual Machine Platform feature. On Windows 10 it’s that plus the original Windows Subsystem for Linux feature, and the script checks for the same two features WSL’s installer does.
Get-WindowsOptionalFeature reports a feature as Enabled only after the restart. Before that it says EnablePending, so a second run before restarting just stops again instead of plowing ahead into steps that would fail.
Pin the distribution
wsl --list --online shows what you can install. Right now plain Ubuntu installs 26.04, while Ubuntu-24.04 installs exactly that release. Two machines set up a year apart with plain Ubuntu won’t match, so pick the release your CI images use and put it in the script.
The WSL_UTF8 line is there because of this step. wsl.exe writes its own output, like the list of installed distributions, as UTF-16. PowerShell reads it as text with a NUL character between every letter, so -contains 'Ubuntu-24.04' never matches. Setting WSL_UTF8=1 makes wsl.exe write UTF-8, and the comparison works.
The first time a distribution starts, it asks you to create a Linux user and password, then leaves you at a shell prompt. Type exit and the script continues. That’s the only other moment you need to be at the keyboard.
Docker Desktop with winget
winget comes with Windows 11 and current Windows 10 as part of the App Installer, so there’s nothing to install before using it. The Docker.DockerDesktop package already passes --accept-license --backend=wsl-2 --no-windows-containers to Docker’s installer and installs it for all users, which is why the script doesn’t add those flags itself.
Docker Desktop doesn’t start on its own after installing, so start it once from the Start menu. Then docker run --rm hello-world in a new terminal is the quickest check.
Two things to know before you make it your team’s default. Docker Desktop needs Windows 10 22H2 or Windows 11 23H2 or later, WSL 2.1.5 or later (the wsl --update step covers that), and hardware virtualization turned on in the firmware. And commercial use at companies with more than 250 employees or more than $10 million in annual revenue requires a paid Docker subscription.
Docker Engine inside the distribution
If Docker Desktop’s license doesn’t fit, or you’d rather work entirely inside Linux, run Docker’s own packages in the distribution. The script hands that job to a short bash script that runs as root inside Ubuntu:
#!/usr/bin/env bash
# Install Docker Engine from Docker's apt repository inside a WSL Ubuntu distribution.
# Run it as root: wsl -d Ubuntu-24.04 -u root --exec bash ./install-docker-engine.sh <linux-user>
set -euo pipefail
user="${1:?usage: install-docker-engine.sh <linux-user>}"
if command -v docker >/dev/null 2>&1; then
echo "Docker is already installed: $(docker --version)"
else
# These steps follow docs.docker.com/engine/install/ubuntu/
apt-get update
apt-get install -y ca-certificates curl
install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
chmod a+r /etc/apt/keyrings/docker.asc
# shellcheck source=/dev/null
codename="$(. /etc/os-release && echo "${UBUNTU_CODENAME:-$VERSION_CODENAME}")"
cat >/etc/apt/sources.list.d/docker.sources <<EOF
Types: deb
URIs: https://download.docker.com/linux/ubuntu
Suites: ${codename}
Components: stable
Architectures: $(dpkg --print-architecture)
Signed-By: /etc/apt/keyrings/docker.asc
EOF
apt-get update
apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
fi
# Let your everyday user run docker without sudo. Membership in the docker group
# is equivalent to root on this distribution, which is fine on a dev machine.
usermod -aG docker "$user"
# Ubuntu on WSL boots with systemd, so this starts Docker now and at every launch.
systemctl enable --now dockerCode language: Bash (bash)
Two wsl.exe options make the hand-off clean. --cd takes an absolute Windows path, so the bash script runs from the folder the PowerShell script lives in. And --exec runs bash directly instead of passing the command line through the Linux shell, so nothing gets re-parsed along the way.
One trap if you keep these scripts in a git repository that Windows users clone: line endings. If git checks the bash script out with Windows line endings, it fails on its first real line:
$ bash install-docker-engine.sh jon
install-docker-engine.sh: line 4: set: pipefail^M: invalid option nameCode language: Shell Session (shell)
That ^M is the carriage return. One line in .gitattributes keeps shell scripts LF on every machine:
*.sh text eol=lfCode language: plaintext (plaintext)
Running it
On a fresh Windows machine, PowerShell won’t run scripts at all; the default policy for Windows clients is Restricted. Open PowerShell as administrator and allow it for this one session:
powershell -ExecutionPolicy Bypass -File .\Setup-DevMachine.ps1Code language: PowerShell (powershell)
Restart when it asks, then run the same command again. For Docker Engine instead of Docker Desktop:
powershell -ExecutionPolicy Bypass -File .\Setup-DevMachine.ps1 -Engine DockerEngineCode language: PowerShell (powershell)
When it finishes, wsl --list --verbose should list your distribution with a 2 in the VERSION column.
Can I just use choco for all of this?
Mostly, yes. Chocolatey’s wsl2 package installs WSL from Microsoft’s own installer, turns on the Windows features, and sets WSL 2 as the default. Its /Retry:true option even schedules a rerun at your next sign-in so the install finishes after the restart. And there’s a docker-desktop package that tracks Docker’s releases closely.
choco install wsl2 --params "/Version:2 /Retry:true" -y
choco install docker-desktop -y
wsl --install --distribution Ubuntu-24.04Code language: PowerShell (powershell)
That last line is the catch. Chocolatey’s Ubuntu packages stop at 22.04, published in 2022, so you install the distribution with wsl --install either way. And Chocolatey itself has to be installed first, while winget is already on the machine.
So my answer to my own question: if your team already manages machines with Chocolatey, swap the two package lines in and keep the rest of the script. If not, winget and wsl.exe do the whole job with nothing extra to install.
The new option: containers built into WSL
On September 29, 2026, WSL 3.0.1 made WSL containers generally available. It’s a container engine built into WSL, driven by a new wslc command (with container as an alias), and it arrives with wsl --update, which the script already runs.
wslc run --rm -it ubuntu:latest
wslc image lsCode language: PowerShell (powershell)
That gives you a third choice, with different trade-offs:
It’s not ready to be a team’s only engine yet. There’s no Compose support (Microsoft says that’s their next focus), there’s no --privileged yet, and early users reported the VS Code Dev Containers extension still looking for a docker command. For a team setup today, Docker Desktop or Docker Engine is the safer default. Run the script with -Engine None on a spare machine and try wslc there.
Wrap-up
A setup script for Windows dev machines comes down to a few rules:
- Make every step check before it acts, so a restart is just "run it again."
- Pin the Linux distribution to the release your CI uses.
- Check exit codes from programs; Windows PowerShell won’t stop on its own.
- Choose Docker Desktop or Docker Engine based on your license and how your team works, and keep an eye on
wslc. - Keep shell scripts LF with
.gitattributes.
How do you set up new Windows dev machines on your team: a script, a package manager, or a golden image? I’d like to hear what’s worked for you.
Keep going
Getting the same toolchain onto every machine, from a new laptop to a CI runner, is a big part of Shipping Firmware Like Software, the book I’m writing on embedded CI/CD.
If you want this set up for your team, the Embedded CI/CD Jumpstart puts a pinned container toolchain behind your builds, the same one your developers use on their own machines. See the Services page or get in touch.
Leave a Reply