Automated WSL Setup for Every User

Automated WSL Setup for Every User

I traced WSL’s distro registration through the now-open-source code so you don’t have to.

Pre-install WSL for all users during device provisioning.

There’s always a Registry Key, you just have to find it.

The Problem

Microsoft does not provide a green path for you to prebake WSL during system provisioning.

All three CLI paths (--install, --import, --import-in-place) end up at the same place: HKCU\Software\Microsoft\Windows\CurrentVersion\Lxss. All three need a loaded user profile. None of them can pre-seed a distro for users that don’t exist yet.

Goal: WSL up and running before any user has ever logged in, on fresh Windows 11 hosts; no human in the loop.

How I Figured This Out

Reverse engineering WSL.

For years, the only way to figure out WSL’s registration surface was ProcMon and a lot of rebuilt VMs. The public SDK (wslapi.h) documents 7 API functions and 3 flag bits. That’s it. Everything else was internal to Microsoft.

Then, at Build 2025, Microsoft open-sourced WSL. The entire service layer landed on GitHub at microsoft/WSL. Every registry key, every flag constant, every default value: now traceable to a specific line of C++.

What previously required fuzzing with ProcMon is now a grep away. Let’s dive in.

Prerequisites

  1. Windows 11 with the WSL2 kernel installed (modern builds ship it inbox; on older boxes, run wsl --update once)
  2. An ext4 VHDX of the distro you want (build in CI, or convert any rootfs tarball with qemu-img convert -f raw -O vhdx -o subformat=dynamic)
  3. PowerShell 5.1+, run as administrator

The Registry Schema, From Source

WSL stores distro registration under HKCU\Software\Microsoft\Windows\CurrentVersion\Lxss\{GUID}. The registry path is defined in wslservice.idl:

cpp_quote("#define LXSS_REGISTRY_PATH L\"Software\\\\Microsoft\\\\Windows\\\\CurrentVersion\\\\Lxss\"")

Each distro lives under a GUID subkey. The properties are declared in DistributionRegistration.h, inside the Property namespace. The C++ types tell you exactly which keys are required vs. optional:

  • ExpectedProperty: required, no default. If WSL can’t read these, the distro won’t load.
  • DistributionPropertyWithDefault: optional. WSL falls back to the default value if the key is missing.
  • DistributionProperty: optional, no default. Cosmetic only.

Required Properties

inline ExpectedProperty<LPCWSTR> Name{L"DistributionName"};
inline ExpectedProperty<LPCWSTR> BasePath{L"BasePath"};
Registry value Type Example What WSL does with it
DistributionName REG_SZ Ubuntu-24.04 Shown in wsl -l -v, used for wsl -d <name>
BasePath REG_SZ \\?\C:\ProgramData\WSL\Ubuntu-24.04 Folder containing the VHDX. The \\?\ long-path prefix is required or WSL silently fails on paths with spaces.

Properties With Defaults

These are optional. The source tells you exactly what WSL assumes when they’re absent:

inline DistributionPropertyWithDefault<DWORD> Version{L"Version", LXSS_DISTRO_VERSION_CURRENT};
inline DistributionPropertyWithDefault<DWORD> Flags{L"Flags", LXSS_DISTRO_FLAGS_DEFAULT, &DistributionRegistration::ApplyGlobalFlagsOverride};
inline DistributionPropertyWithDefault<DWORD> DefaultUid{L"DefaultUid", LX_UID_ROOT};
inline DistributionPropertyWithDefault<DWORD> State{L"State", LxssDistributionStateInvalid};
inline DistributionPropertyWithDefault<DWORD> RunOOBE{L"RunOOBE", 0};
inline DistributionPropertyWithDefault<DWORD> Modern{L"Modern", 0};
inline DistributionPropertyWithDefault<LPCWSTR> VhdFileName{L"VhdFileName", LXSS_VM_MODE_VHD_NAME};
Registry value Type Source default What it does
Version REG_DWORD LXSS_DISTRO_VERSION_CURRENT = 2 WSL1 (1) vs WSL2 (2). Almost always 2.
Flags REG_DWORD LXSS_DISTRO_FLAGS_DEFAULT = 0x7 Bitmask of distro capabilities. See the flags table below.
DefaultUid REG_DWORD LX_UID_ROOT = 0 UID for wsl -d <name> without -u. 0 = root, 1000 = first regular user.
State REG_DWORD LxssDistributionStateInvalid = 0 Installation state. Set to 1 (Installed). If missing or 0, WSL ignores the distro entirely.
RunOOBE REG_DWORD 0 1 triggers the first-launch username/password prompt. 0 skips it. This is the key that makes headless work.
Modern REG_DWORD 0 1 marks a Store-distributed distro. Cosmetic: affects wsl --list --online and Windows Terminal.
VhdFileName REG_SZ LXSS_VM_MODE_VHD_NAME = ext4.vhdx Filename inside BasePath.

Cosmetic Properties

inline DistributionProperty<LPCWSTR> Flavor{L"Flavor"};
inline DistributionProperty<LPCWSTR> OsVersion{L"OsVersion"};
inline DistributionProperty<LPCWSTR> ShortcutPath{L"ShortcutPath"};
inline DistributionProperty<LPCWSTR> TerminalProfilePath{L"TerminalProfilePath"};

WSL boots regardless of whether these are set. They exist for Start Menu integration and the WSL settings GUI.

The Flags Bitmask

This is the one part that the public SDK got almost right. The public wslapi.h documents three bits:

WSL_DISTRIBUTION_FLAGS_ENABLE_INTEROP        = 0x1
WSL_DISTRIBUTION_FLAGS_APPEND_NT_PATH        = 0x2
WSL_DISTRIBUTION_FLAGS_ENABLE_DRIVE_MOUNTING = 0x4

The internal IDL adds the fourth, and defines the composites that the public SDK never exposed:

cpp_quote("#define LXSS_DISTRO_FLAGS_ENABLE_INTEROP 0x1")
cpp_quote("#define LXSS_DISTRO_FLAGS_APPEND_NT_PATH 0x2")
cpp_quote("#define LXSS_DISTRO_FLAGS_ENABLE_DRIVE_MOUNTING 0x4")
cpp_quote("#define LXSS_DISTRO_FLAGS_VM_MODE 0x8")
cpp_quote("#define LXSS_DISTRO_FLAGS_DEFAULT (... INTEROP | APPEND_NT_PATH | ENABLE_DRIVE_MOUNTING)")
cpp_quote("#define LXSS_DISTRO_FLAGS_ALL    (... INTEROP | APPEND_NT_PATH | ENABLE_DRIVE_MOUNTING | VM_MODE)")
Bit Value Internal name Meaning
0 0x1 LXSS_DISTRO_FLAGS_ENABLE_INTEROP Windows/Linux process interop (wsl.exe, calling Windows binaries from Linux)
1 0x2 LXSS_DISTRO_FLAGS_APPEND_NT_PATH Append Windows %PATH% to Linux $PATH
2 0x4 LXSS_DISTRO_FLAGS_ENABLE_DRIVE_MOUNTING Mount C:\ (and other drives) at /mnt/c
3 0x8 LXSS_DISTRO_FLAGS_VM_MODE WSL2 (real kernel in Hyper-V VM). Not in the public SDK.
Macro Value Meaning
LXSS_DISTRO_FLAGS_DEFAULT 0x7 Interop + PATH + drive mounting. What wsl --install sets.
LXSS_DISTRO_FLAGS_ALL 0xF All four flags. Normal interactive WSL2 distro.
LXSS_DISTRO_FLAGS_UNCHANGED 0xFFFFFFFE Sentinel: “don’t change the current flags.” Used by ConfigureDistribution.

For a standard WSL2 distro, set Flags = 0xF. For WSL1, use 0x7.

Enterprise Override

There’s a mechanism in the source that most people will never encounter. From DistributionRegistration.cpp:

DWORD DistributionRegistration::ApplyGlobalFlagsOverride(DWORD Flags)
{
    DWORD globalFlags = wsl::windows::common::registry::ReadDword(
        HKEY_LOCAL_MACHINE, LXSS_SERVICE_REGISTRY_PATH, L"DistributionFlags", LXSS_DISTRO_FLAGS_ALL);
    // The VM Mode flag cannot be overridden by global flags.
    WI_SetFlag(globalFlags, LXSS_DISTRO_FLAGS_VM_MODE);
    Flags &= (globalFlags & LXSS_DISTRO_FLAGS_ALL);
    return Flags;
}

Admins can set HKLM\...\Lxss\DistributionFlags to globally restrict which capabilities any distro on the machine can use. The per-distro Flags get AND-masked against this value. VM_MODE is force-set and can’t be overridden, so you can’t use this to force WSL1. Handy for locking down shared machines.

The State Enum

The source defines a full state machine in wslservice.idl:

typedef enum _LxssDistributionState
{
    LxssDistributionStateInvalid = 0,
    LxssDistributionStateInstalled,      // 1
    LxssDistributionStateRunning,        // 2
    LxssDistributionStateInstalling,     // 3
    LxssDistributionStateUninstalling,   // 4
    LxssDistributionStateConverting,     // 5
    LxssDistributionStateExporting       // 6
} LxssDistributionState;

Set State = 1 (Installed). Any other value and WSL won’t boot the distro. If you forget it, the default is 0 (Invalid), which means wsl -l won’t show it at all.

Parent Lxss Keys

Two values live on the Lxss key itself, not under a distro GUID.

DefaultDistribution is defined in DistributionRegistration.cpp as a GUID string that tells wsl which distro to launch when called without -d. Match it to your distro’s GUID.

OOBEComplete is defined in the IDL:

cpp_quote("#define LXSS_OOBE_COMPLETE_NAME L\"OOBEComplete\"")

Set it to 1 to tell WSL the user-level first-time setup is done. Without it, even with RunOOBE=0 per-distro, you can hit a separate user-level OOBE flow.

Putting It Together

All of the following need to be true:

1. Enable the Windows features. WSL needs both Microsoft-Windows-Subsystem-Linux and VirtualMachinePlatform. If either was newly enabled, reboot before continuing. WSL will fail with 0x80370102 until the platform is fully online.

2. Stage the VHDX where every user can read it. Anywhere works as long as BasePath points at the folder. I use C:\ProgramData\WSL\<DistroName>\ext4.vhdx.

3. Decide whose hive to write. This is the key decision:

Scope Where to write When to use
Current user only HKCU\Software\Microsoft\Windows\CurrentVersion\Lxss Personal box, single user, want it working immediately
Every future user Load C:\Users\Default\NTUSER.DAT and write there, then unload Imaging, fleet deploys, multi-user boxes

New user profiles copy from C:\Users\Default\ at creation, so every freshly created account inherits the registration. This is how you get WSL to users that don’t exist yet.

4. Write the keys. Generate a fresh GUID per distro registration, uppercase, wrapped in braces. Then:

Location Keys
Lxss\{GUID}\ DistributionName, BasePath, State=1, Version=2, Flags=0xF, DefaultUid=0, RunOOBE=0, VhdFileName=ext4.vhdx, Modern=1
Lxss\ DefaultDistribution={GUID}, OOBEComplete=1

The cosmetic keys (Flavor, OsVersion, ShortcutPath, TerminalProfilePath) are optional. Set them if you want Start Menu integration.

5. Verify with a new user. This is the gotcha that catches everyone. If you wrote to the Default User hive, the current user has no registration yet. Log in as a freshly created account and run wsl -d Ubuntu-24.04 -- whoami. You should see root immediately, no prompt, no installer, no OOBE.

What About Creating a Linux User?

Skipping RunOOBE means there’s no Linux user account created at first launch. DefaultUid=0 drops you into root.

For single-user boxes, build agents, and CI, that’s fine. Root in WSL2 is sandboxed in a Hyper-V VM, not your Windows host.

For multi-user boxes where people need real accounts, bake a placeholder user into the VHDX at build time and rename it on first boot. Two ways to make WSL drop new shells into this user instead of root:

Approach When
Update the DefaultUid registry value to the user’s UID (typically 1000) Read at distro boot. Faster. Best when the user is stable and known at provisioning time.
Write /etc/wsl.conf with [user] default=<name> Read at runtime. Flexible: handy if the username isn’t known until first login.

The wsl.conf approach also lets you enable systemd ([boot] systemd=true), which is what you want if anything inside WSL relies on systemd units.

The Public SDK vs. the Source

Before Build 2025, the public surface for WSL automation was wslapi.h: 7 functions and 3 flag bits. That API is designed for distro launchers (register from tarball, configure, launch), not for pre-seeding registrations into offline user profiles.

The open-sourced service code gives you the rest of the picture:

What the public SDK tells you What the source adds
3 flag bits (0x1, 0x2, 0x4) The WSL2 flag (0x8), composite macros (DEFAULT, ALL, UNCHANGED)
WslRegisterDistribution(name, tarball) The full registry schema: 15+ named properties, their types, and default values
WslConfigureDistribution(uid, flags) The State enum, RunOOBE, Modern, OOBEComplete, DefaultDistribution
Nothing about enterprise policy HKLM\DistributionFlags global override with VM_MODE pinned

Windows Terminal independently confirms the registry structure: it reads HKCU\Software\Microsoft\Windows\CurrentVersion\Lxss, enumerates GUID subkeys, and pulls DistributionName and Modern to generate terminal profiles.

Parting Words

If you’ve ever tried to bake WSL into a corporate image with MDT, SCCM, Packer, or any other unattended pipeline, you’ve felt this pain. The official path expects a human. The registry path doesn’t.

The official path expects an internet connection to the Microsoft Store. The registry path is just setting the Registry Keys Micrososft sets via their wsl binary anyway. Of course, none of this is documented or supported, so it may change. There’s no signed installer manifest. It’s a struct in the registry pointing at a virtual disk.

I can see Microsoft improving the automated setup in the near future, as more enterprises are going to start relying on it more for AI tools. For now, you have this post 🐧


Improve this page:  | Share on:  

Comments 💬