Coding Best Practices

Automating WSL and Docker Setup on Windows with One PowerShell Script

jrhaws October 26, 2026 24 min read

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-distribution turns on the Windows features WSL needs. They don’t work until you restart.
  • Install a pinned release with wsl --install --distribution Ubuntu-24.04. Plain Ubuntu gets whatever is newest that day.
  • winget install --exact --id Docker.DockerDesktop installs 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:

The setup script checks each step before acting, so it stops for one restart and then finishesRun 1 on a fresh machine turns on the Windows features and stops, asking for a restart. Run 2, after the restart, skips the features, updates WSL, installs the distribution, and installs the container engine. Any later run skips everything except the WSL update.One script, run until it's doneEvery step checks first and skips work that's already done. The restart is the only manual step.1 Windows features2 WSL3 Distribution4 Container enginerun 1fresh machineturn onthen stopnot reachednot reachednot reachedrun 2after restartalready onskipwsl –updateneeds 2.1.5+installcreate Linux userinstallDesktop or Enginerun 3+any time lateralready onskipwsl –updatealways runsinstalledskipinstalledskiprestart Windowsdoes the workchecks, then skipsnot reached yet
The first run turns on the Windows features and stops. After the restart, the same command finishes the job, and later runs skip whatever is already done.

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:

Three ways to run Linux containers on top of WSL: Docker Desktop, Docker Engine inside the distribution, and WSL containersDocker Desktop installs with winget, includes a GUI, Compose, and Dev Containers support, and needs a paid plan for companies over 250 people or 10 million dollars in revenue. Docker Engine inside the distribution uses Docker's apt packages, needs no Docker Desktop license, includes the Compose plugin, and you work from inside the distribution. WSL containers (wslc) are built into WSL 3.0.1 and later, have no Compose support yet, and their Dev Containers support is still settling.Three ways to run containers on WSLThe script installs either of the first two. The third now ships with WSL itself.Docker Desktopdefaultone installer, GUI includedCompose and Dev Containerswork out of the boxpaid plan for companies over250 people or $10M revenue-Engine DockerDesktopDocker Enginein the distributionDocker's own apt packagesno Docker Desktop licenseCompose plugin includedyou work inside the distro,e.g. VS Code's WSL extension-Engine DockerEngineWSL containerswslc, new in WSL 3.0.1built in, arrives withwsl –updateno Compose support yetDev Containers support isstill settling-Engine None, then wslcDocker Desktop terms as of October 2026; check docker.com/pricing for your company.
Docker Desktop and Docker Engine are the script’s two engine options. WSL containers need nothing extra installed, but are still filling in features.

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.

Tags automation docker powershell windows wsl

Leave a Reply

Your email address will not be published. Required fields are marked *