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
- How I Figured This Out
- Prerequisites
- The Registry Schema, From Source
- The Flags Bitmask
- The State Enum
- Parent Lxss Keys
- Putting It Together
- What About Creating a Linux User?
- The Public SDK vs. the Source
- Parting Words
- Related Links
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
- Windows 11 with the WSL2 kernel installed (modern builds ship it inbox; on older boxes, run
wsl --updateonce) - 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) - 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 🐧
Related Links
- Basic commands for WSL
- WSL open-source announcement
- microsoft/WSL on GitHub
- wslapi.h documentation
- Build a custom Linux distro for WSL
- Set up WSL for your company