Back to Blog
wslsoftware-developmentwsl-2linuxwindows

How I Turned WSL2 Into My Linux-First Development Environment on Windows

Native Linux tooling, a deliberate container setup, a fast Zsh, mise, VS Code, reproducible dotfiles, and enough guardrails that an AI agent can help without “helpfully” deleting half your machine For

Native Linux tooling, a deliberate container setup, a fast Zsh, mise, VS Code, reproducible dotfiles, and enough guardrails that an AI agent can help without “helpfully” deleting half your machine

For a long time I had a fairly ordinary developer dilemma.

I like developing on Linux. I like the shell, the filesystem conventions, the package ecosystem, and the fact that the servers and containers I eventually deploy to also speak Linux. I like the feeling that my development environment is working with me rather than asking which installer wizard I would like to click through today.

I also like playing games.

That second requirement makes the “just install Linux on the laptop” answer considerably less attractive than it sounds in a comment thread.

So instead of choosing one operating system for everything, I gave each one a job:

Windows owns the physical computer, the desktop, the GPU driver, the games, the browser, Windows Terminal, and the VS Code interface.

WSL2 owns source code, compilers, runtimes, Git, SSH, Linux services, containers, project tooling, and the shell where development happens.

That boundary is the point of this setup. The individual tools are replaceable. The ownership model is what keeps the machine understandable six months later.

This guide is for three kinds of reader:

  1. Someone who has never used WSL and wants to know why it exists.

2. Someone whose WSL environment mostly works but has accumulated mystery PATH entries, duplicate runtimes, stale Docker integration, and at least one configuration line copied from a stranger’s laptop.

3. Someone who wants to hand a setup document to an AI agent without giving it permission to improvise a small demolition project.

There is one rule behind nearly everything below:

Configuration is a claim. Run the command that checks it.

The article is opinionated, but it is not a dare. You do not need every optional tool, every shell plugin, or every Kubernetes command on day one. A setup that you understand beats a larger setup that happens to work this afternoon.

How to use this guide

Commands stay grouped by context. When the Windows and Linux boundary matters, this guide uses these labels:

Label => Where to run it

PowerShell, Admin => An elevated Windows PowerShell window

PowerShell =>A normal Windows PowerShell window

WSL =>Inside your Linux distribution

Optional => Useful for some workflows, not required for the baseline

Disruptive =>Stops services, changes ownership, removes packages, or otherwise deserves your attention

Do not paste a PowerShell command into Zsh and then conclude that Windows is haunted. It may be, but this is not evidence.

Choose your route through the article

New to WSL? Read the explanation, ownership model, inventory, installation, baseline setup, and final verification in order

Updating an existing setup? Start with ownership, inventory, and backup; then use only the sections for components you have chosen to change

Using an AI agent? Give it the contract and setup profile below, require an inventory and plan, then make it execute and verify one phase at a time

The guide is intentionally modular. Skipping an optional Kubernetes tool is sensible. Skipping the backup before replacing a container daemon is a different genre of decision.

The contract for an AI agent

If you give this article to an AI agent, give it the following instructions with it:

You are maintaining a WSL development environment.
Before changing anything:
1. Inventory Windows, WSL, the target distribution, installed tools, Docker ownership,
existing configuration files, and current command resolution.
2. State which commands will run in Windows PowerShell and which will run in WSL.
3. Back up every configuration file before editing it.
4. Prefer additive, idempotent, reversible changes.
5. Do not guess usernames, distribution names, paths, Git identity, proxy values,
corporate certificate locations, resource limits, or network ranges.
6. Do not delete a distribution, virtual disk, Docker data, volume, SSH key,
credential file, version-manager directory, dotfile source, or backup without
explicit approval.
7. Do not run broad cleanup commands such as docker system prune, rm -rf on a
tool directory, winget upgrade --all, or wsl --unregister without explicit approval.
8. Do not disable TLS verification, certificate checks, firewalls, endpoint security,
or company controls to make an installation succeed.
9. Do not install native Docker Engine if Docker Desktop or a company-managed daemon
is already the chosen owner, unless the user explicitly approves a migration.
10. Treat browser sign-in, MFA, SSH key passphrases, password-manager unlocks, and
other secret entry as human handoffs. Never request, echo, store, or log the secret.
11. Do not put tokens, passwords, private keys, recovery keys, or certificate private
keys in the setup profile, terminal transcript, completion report, or chat.
12. After each phase, run the verification commands and stop if reality does not
match the intended state.
At the end, report:
- inventory captured
- files changed and their backups
- packages and tools installed
- destructive or disruptive steps performed
- steps skipped and why
- verification results
- unresolved decisions that still require the user

That prompt is deliberately boring. Boring is excellent when a tool hassudo.

Fill in a setup profile before an agent changes anything

A manual reader can use this as a checklist. An agent should treat it as desired state, not as an invitation to infer missing values.

mode: audit                    # audit, install, or update
target_distro: Ubuntu-24.04 # exact registered name for update mode
project_root: ~/projects

work_machine: null # true or false
company_approval: null # approved, not_required, or pending
windows_path: keep # keep or explicit
wsl_networking: nat # nat or mirrored
resource_limits: null # example: {memory: 16GB, processors: 12, swap: 8GB}

container_owner: null # native_docker, docker_desktop, podman, managed, or none
docker_group_access: null # true or false
nvidia_gpu: auto # auto, true, or false

shell: zsh # zsh or keep_existing
tool_manager: mise # mise or keep_existing
runtime_defaults: # use null to leave an existing default alone
python: "3.14"
node: lts
optional_cli_groups: [] # any of: terminal_ui, kubernetes, infrastructure
vscode_stacks: [] # any of: python, javascript, containers, kubernetes

git_name: null
git_email: null
git_host: github.com
ssh_key_action: inspect # inspect, reuse, or create
ssh_key_path: ~/.ssh/id_ed25519
commit_signing: null # true, false, or null to leave unchanged

dotfiles_remote: null
backup_dir_windows: null

approvals:
wsl_shutdown: true
package_removals: false
docker_migration: false
backup_pruning: false

Do not place secrets in this file. A null value means inventory the current state and skip that state-changing choice until a human supplies it. In particular, an agent must not translate container_owner: null into "Docker sounds popular." That is how two daemons meet.

What we are building

The final environment has a simple architecture:

  • Windows remains the host operating system and desktop.
  • WSL2 runs a real Linux kernel in a managed virtual machine.
  • Repositories live on the Linux filesystem under ~/projects.
  • Linux tools operate on Linux files.
  • Windows applications are called from Linux deliberately, not imported wholesale through a giant inherited PATH.
  • One tool owns each runtime or service.
  • Project versions live with the project.
  • Configuration is versioned.
  • Full-distribution backups exist before risky changes.
  • Every important assumption has a verification command.

The baseline in this article uses Ubuntu 24.04 LTS because it is mature, widely supported, and receives standard maintenance through May 2029. Ubuntu 26.04 LTS is also available and supported through May 2031. Most commands are identical, but package availability and third-party repositories can differ, so use the distribution your project and company support rather than selecting the largest number on the menu.

The examples use Ubuntu-24.04 as the distribution name. Your installed name may be Ubuntu, Ubuntu-26.04, or something else. Run wsl --list --verbose and use what your computer actually reports.

This guide targets Windows 11 with current Store-delivered WSL. Windows Home supports WSL2. Much of the baseline also works on supported Windows 10 releases, but features such as mirrored networking and the WSL Settings application are Windows 11 territory. Hardware virtualisation must be enabled for WSL2, normally in UEFI or BIOS firmware.

Why use WSL instead of developing directly on Windows?

If you already know why you are here, skip ahead to the inventory section.

What WSL2 actually is

WSL2 runs a Linux kernel in a lightweight virtual machine managed by Windows. Linux processes run as Linux processes, use Linux system calls, and see Linux filesystem permissions. Windows handles the host hardware and integrates networking, files, graphics, and process launching closely enough that the boundary is usually convenient rather than ceremonial.

It is not just Bash painted onto Windows. It is also not a general-purpose virtual machine with every knob exposed. WSL is a specialised Linux environment designed to cooperate with Windows.

How it differs from nearby options

Native Windows development can be excellent, especially for .NET, Windows desktop work, PowerShell automation, and tools designed around Windows. The friction appears when your deployment target, scripts, containers, CI runners, and teammates expect Linux semantics.

Git Bash, MSYS2, and Cygwin provide Unix-like shells and tools on Windows. They are useful, but the binaries still run in a Windows environment rather than on a Linux kernel. That distinction matters for Linux services, namespaces, containers, package builds, permissions, and software that assumes a Linux userspace.

A full VirtualBox, VMware, or Hyper-V virtual machine gives you an independent Linux computer. That is sometimes exactly what you need, especially for stronger isolation, kernel work, custom networking, or testing a complete server image. It also introduces another desktop, another resource allocation model, and more explicit file sharing.

Dual booting gives Linux the whole machine, then asks you to reboot whenever you need the other half of your life. Technically clean, operationally inconvenient.

WSL1 still exists and has niche advantages, particularly where access to Windows files matters more than full Linux kernel compatibility. For the container-heavy, systemd-enabled environment in this article, use WSL2.

Consistency is the real argument

If your application runs in Linux containers, Linux CI, or Linux servers, developing in Linux removes a long list of small differences:

  • Paths use /, and shell scripts use Linux tools.
  • File permissions behave the way deployment expects.
  • Native dependencies compile for Linux.
  • Python environments contain .venv/bin/activate.
  • Case sensitivity does not depend on which Windows folder you happened to clone into.
  • Docker bind mounts, executable bits, symlinks, and line endings are easier to reason about.

When a README says:

chmod +x ./scripts/bootstrap.sh
./scripts/bootstrap.sh

you can usually run exactly that.

The performance benefit is also real, but specific. Operations that touch thousands of small files, such as git status, dependency installation, test discovery, and file watching, are much faster when Linux tools work on the Linux filesystem rather than crossing into /mnt/c for every operation.

WSL does not make Windows disappear. It gives Windows and Linux a clean place to meet.

Before changing anything, decide who owns what

A surprising number of broken development machines do not have a bad tool. They have two healthy tools both trying to own the same job.

Write down these decisions before installing anything:

Source repositories
WSL Linux filesystem

Git used for those repositories
Git inside WSL

Node, Bun, Java, and similar runtimes
mise, or another manager you deliberately keep

Python interpreter
mise in this guide, with uv managing project environments and dependencies

Containers
Native Docker Engine in WSL, Docker Desktop, Podman, or a company-managed solution

Linux editor process
VS Code Server inside WSL, with the VS Code UI on Windows

SSH keys for Linux Git
OpenSSH inside WSL, unless your company mandates a different provider

The rule is not “one manager for every technology on Earth.” Rust may reasonably remain under rustup, for example. The rule is one active owner per tool.

A note for work computers

Before enabling WSL, installing Docker, changing firewall behaviour, importing a root certificate, or generating credentials on a company machine, check company policy.

A managed PC may have:

  • WSL settings enforced through Intune or Group Policy
  • approved distribution images
  • restricted administrator rights
  • endpoint protection that inspects the WSL virtual disk
  • a corporate proxy or private certificate authority
  • rules about local container daemons
  • required SSH key storage or single sign-on
  • data residency requirements that affect backups and dotfiles

Do not work around those controls. Ask the team that owns the machine. “The blog said so” has never been a particularly strong incident response strategy.

Inventory first

Do this even on a brand-new machine. A new machine can still contain an inbox WSL version, a Store WSL version, a preinstalled distribution, or an IT-managed configuration.

Windows inventory

PowerShell:

wsl --version
wsl --status
wsl --list --verbose
wsl --list --online
wsl --help

(Get-CimInstance Win32_ComputerSystem).TotalPhysicalMemory / 1GB
(Get-CimInstance Win32_Processor).NumberOfLogicalProcessors
Get-NetRoute -AddressFamily IPv4 | Sort-Object DestinationPrefix

Save the output:

$AuditDir = Join-Path $HOME ("wsl-audit-" + (Get-Date -Format "yyyyMMdd-HHmmss"))
New-Item -ItemType Directory -Path $AuditDir | Out-Null

wsl --version | Out-File (Join-Path $AuditDir "wsl-version.txt")
wsl --status | Out-File (Join-Path $AuditDir "wsl-status.txt")
wsl --list --verbose | Out-File (Join-Path $AuditDir "wsl-distributions.txt")
Write-Host "Audit saved to $AuditDir"

If wsl --version is not recognised, you may be using an older inbox version. Update WSL before following current configuration advice.

Linux inventory

Open the target distribution.

WSL:

mkdir -p ~/wsl-setup-audit

{
printf 'captured_at=%s\n' "$(date --iso-8601=seconds)"
printf '\n== os ==\n'
cat /etc/os-release
printf '\n== kernel ==\n'
uname -a
printf '\n== identity ==\n'
id
printf '\n== init ==\n'
ps -p 1 -o pid=,comm=,args=
printf '\n== resources ==\n'
nproc
free -h
printf '\n== filesystem ==\n'
df -hT / "$HOME" /mnt/c 2>/dev/null || true
printf '\n== shell ==\n'
printf 'login_shell=%s\n' "$SHELL"
printf 'current_shell=%s\n' "${0:-unknown}"
printf '\n== locale and time ==\n'
locale
date --iso-8601=seconds
} | tee ~/wsl-setup-audit/baseline.txt{
printf 'captured_at=%s\n' "$(date --iso-8601=seconds)"
printf '\n== os ==\n'
cat /etc/os-release
printf '\n== kernel ==\n'
uname -a
printf '\n== identity ==\n'
id
printf '\n== init ==\n'
ps -p 1 -o pid=,comm=,args=
printf '\n== resources ==\n'
nproc
free -h
printf '\n== filesystem ==\n'
df -hT / "$HOME" /mnt/c 2>/dev/null || true
printf '\n== shell ==\n'
printf 'login_shell=%s\n' "$SHELL"
printf 'current_shell=%s\n' "${0:-unknown}"
printf '\n== locale and time ==\n'
locale
date --iso-8601=seconds
} | tee ~/wsl-setup-audit/baseline.txt

Then inspect ownership of the commands that commonly get duplicated:

for command_name in git ssh python python3 node npm bun docker kubectl code; do
printf '\n== %s ==\n' "$command_name"
type -a "$command_name" 2>&1 || true
done | tee ~/wsl-setup-audit/command-resolution.txt

If Docker already exists:

docker context ls 2>/dev/null || true
docker context show 2>/dev/null || true
env | grep '^DOCKER_' || true
systemctl status docker --no-pager 2>/dev/null || true

This tells you whether the docker command is pointing at a native daemon, Docker Desktop, a remote host, or nothing useful at all.

Back up before changing the foundation

A dotfile backup is not a distribution backup. A distribution export is not a database backup. A backup stored on the same physical SSD is not protection from SSD failure. These things solve different problems.

Before major changes, make at least one full WSL export and copy anything irreplaceable somewhere off the machine.

Quick manual export

Save your work. Stop databases and containers cleanly if they contain state you care about.

PowerShell:

$Distro = "Ubuntu-24.04"   # Replace with the exact name from: wsl --list --verbose
$BackupDir = "D:\wsl-backups"
$Stamp = Get-Date -Format "yyyy-MM-dd_HHmmss"
$Backup = Join-Path $BackupDir "$Distro-$Stamp.tar"

New-Item -ItemType Directory -Force -Path $BackupDir | Out-Null
wsl --shutdown
wsl --list --running
wsl --export $Distro $Backup

if ($LASTEXITCODE -ne 0 -or -not (Test-Path $Backup)) {
throw "WSL export failed."
}

$Hash = Get-FileHash -Algorithm SHA256 $Backup
$Hash.Hash | Set-Content -Encoding ascii "$Backup.sha256.txt"
Write-Host "SHA256: $($Hash.Hash)"

Get-Item $Backup | Select-Object FullName, Length, LastWriteTime

wsl --shutdown stops every running distribution, so do not run this while another WSL workload is busy. Unlike merely closing the last terminal window, an explicit shutdown does not require a ritual eight-second sleep. Verify with wsl --list --running instead of trusting a timer.

The SHA256 sidecar catches accidental corruption when you check it before restore. Because it is stored beside the archive, it is not proof against a malicious replacement of both files. Use protected or signed backup storage when that threat matters.

An export can reproduce the distribution, but a live database may still require its own logical backup, such as pg_dump, mysqldump, or an application-specific export. A filesystem archive is not automatically an application-consistent snapshot.

A safer reusable backup script

Save this as D:\wsl-backups\Backup-Wsl.ps1 or another location you control:

[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$Distro,

[Parameter(Mandatory = $true)]
[string]$BackupDir,

[ValidateRange(0, 100)]
[int]$Keep = 0
)

$ErrorActionPreference = "Stop"

$KnownDistros = @(wsl --list --quiet) |
ForEach-Object { ($_ -replace "`0", "").Trim() } |
Where-Object { $_ }
if ($LASTEXITCODE -ne 0) {
throw "wsl --list --quiet failed with exit code $LASTEXITCODE."
}

if ($Distro -notin $KnownDistros) {
throw "Distribution '$Distro' was not found. Available: $($KnownDistros -join ', ')"
}

New-Item -ItemType Directory -Force -Path $BackupDir | Out-Null

$Stamp = Get-Date -Format "yyyy-MM-dd_HHmmss"
$Backup = Join-Path $BackupDir "$Distro-$Stamp.tar"
$HashFile = "$Backup.sha256.txt"

Write-Host "Stopping all WSL distributions. Save work before running this script."
wsl --shutdown
if ($LASTEXITCODE -ne 0) {
throw "wsl --shutdown failed with exit code $LASTEXITCODE."
}

$Running = @(wsl --list --running --quiet) |
ForEach-Object { ($_ -replace "`0", "").Trim() } |
Where-Object { $_ }
if ($Running.Count -gt 0) {
throw "WSL still reports running distributions: $($Running -join ', ')"
}

Write-Host "Exporting $Distro to $Backup"
wsl --export $Distro $Backup
if ($LASTEXITCODE -ne 0) {
throw "wsl --export failed with exit code $LASTEXITCODE."
}

$BackupItem = Get-Item $Backup
if ($BackupItem.Length -le 0) {
throw "The export exists but is empty. It will not be treated as a valid backup."
}

$Hash = Get-FileHash -Algorithm SHA256 $Backup
$Hash.Hash | Set-Content -Encoding ascii $HashFile

Write-Host ("Backup complete: {0:N2} GB" -f ($BackupItem.Length / 1GB))
Write-Host "SHA256: $($Hash.Hash)"

if ($Keep -gt 0) {
$OldBackups = Get-ChildItem $BackupDir -Filter "$Distro-*.tar" |
Sort-Object LastWriteTime -Descending |
Select-Object -Skip $Keep

foreach ($OldBackup in $OldBackups) {
Write-Host "Pruning older backup: $($OldBackup.Name)"
Remove-Item -LiteralPath $OldBackup.FullName -Force
Remove-Item -LiteralPath "$($OldBackup.FullName).sha256.txt" -Force -ErrorAction SilentlyContinue
}
}

Run it explicitly:

D:\wsl-backups\Backup-Wsl.ps1 `
-Distro "Ubuntu-24.04" `
-BackupDir "D:\wsl-backups"

The default does not prune anything. Add -Keep 3 only after you have confirmed the destination is correct and at least one export can be restored. Recovery points are a strange place to practise minimalism.

Install or update WSL

New installation

PowerShell, Admin:

wsl --install -d Ubuntu-24.04

Restart Windows if prompted. On first launch, Ubuntu asks you to create a Linux username and password. They are independent of your Windows account. The password is used by sudo, and the terminal deliberately shows no characters while you type it.

If installation reports that virtualisation or a required feature is unavailable, check Task Manager > Performance > CPU > Virtualization. When it says disabled, enable the vendor’s virtualisation setting in UEFI or BIOS, then run the installation again. Do not solve this by downloading a mystery “WSL fixer” from a search result with six exclamation marks.

To see other available distributions:

wsl --list --online

Existing installation

PowerShell:

wsl --update
wsl --set-default-version 2
wsl --version
wsl --status
wsl --list --verbose

If an existing distribution is still on WSL1 and you have confirmed that its workload supports WSL2:

wsl --set-version "Ubuntu-24.04" 2

Conversion can take time and changes the storage model. Export first.

WSL updates separately from the normal Windows servicing cadence. A fully patched Windows installation can still have an older WSL package, so wsl --version belongs in every serious troubleshooting report.

Understand the two configuration files

WSL has two similarly named files that control different layers:

  • %UserProfile%\.wslconfig is a Windows-side global configuration for the WSL2 virtual machine. It affects all WSL2 distributions.
  • /etc/wsl.conf lives inside one Linux distribution and controls that distribution's behaviour.

Think of .wslconfig as the settings around Linux and wsl.conf as the settings inside Linux.

Start with no .wslconfig

This is the first major change from many WSL setup guides, including older versions of this one.

Current WSL defaults already provide roughly half of Windows memory, all logical processors, swap based on host memory, DNS tunnelling, Windows firewall integration, automatic proxy discovery, and nested virtualisation. Restating defaults makes a file look important without changing the machine.

For many developers, the best baseline .wslconfig is no file at all.

Windows also includes a WSL Settings application, and Microsoft recommends it for global configuration because it exposes supported options without relying on a hand-copied INI file. The file is still worth understanding because it is portable and appears in nearly every guide.

Optional: mirrored networking

WSL defaults to NAT networking. On current Windows 11 systems, mirrored networking can improve localhost behaviour, VPN compatibility, IPv6, multicast, and direct LAN access.

Try it because you need one of those behaviours, not because this article contains a code block.

PowerShell:

notepad $HOME\.wslconfig
[wsl2]
networkingMode=mirrored

Do not add dnsTunneling=true, firewall=true, or autoProxy=true merely to make the file look complete. They are current defaults. Add a setting when you intend to change or explicitly document policy.

Mirrored networking is not automatically better on every corporate VPN. Test the networks you actually use. NAT remains a valid choice.

Optional: resource limits

Do not copy somebody else’s numbers.

A resource cap is a policy decision: it reserves breathing room for Windows at the cost of limiting Linux workloads. Leave the settings absent until you have a reason to cap them.

For example, a person with 32 GB of RAM and 16 logical processors might deliberately choose:

[wsl2]
memory=16GB
processors=12
swap=8GB

That is an example, not a formula. On a 16 GB laptop it would be optimistic. On a 64 GB workstation it might be needlessly restrictive.

Measure the host first:

(Get-CimInstance Win32_ComputerSystem).TotalPhysicalMemory / 1GB
(Get-CimInstance Win32_Processor).NumberOfLogicalProcessors

Then verify inside WSL after applying the change:

nproc
free -h

Optional and experimental: sparse virtual disks

WSL stores each distribution in a virtual disk. Deleting files inside Linux does not always produce an immediate, matching reduction in allocated space on the Windows drive.

Current WSL exposes sparse VHD behaviour, but the global sparseVhd option remains under [experimental] and applies to newly created virtual disks. Existing distributions may support a wsl --manage <Distro> --set-sparse true command depending on the installed WSL version.

This guide does not enable sparse mode automatically. Storage changes deserve a current export, a check of wsl --help, and a restore test. Backup software, antivirus, filesystem features, and copying tools can treat sparse files differently.

If disk growth is your problem, first find the real consumer:

WSL:

df -h /
sudo du -xhd1 / 2>/dev/null | sort -h
docker system df -v 2>/dev/null || true
sudo journalctl --disk-usage 2>/dev/null || true

Do not use virtual disk surgery to solve a forgotten container log.

Apply global changes

Save your work first.

PowerShell:

wsl --shutdown
wsl --list --running

When the running list is empty, launch the distribution and verify the setting by behaviour. Editing a file was not the verification step.

Configure Ubuntu with /etc/wsl.conf

First inspect what is already there.

WSL:

if [[ -f /etc/wsl.conf ]]; then
sudo cat /etc/wsl.conf
else
echo "/etc/wsl.conf does not exist yet"
fi

ps -p 1 -o comm=

If PID 1 is already systemd, do not add configuration merely for the satisfaction of adding configuration.

If it is not, back up the existing file and edit it:

if [[ -f /etc/wsl.conf ]]; then
sudo cp -a /etc/wsl.conf "/etc/wsl.conf.backup.$(date +%Y%m%d-%H%M%S)"
fi

sudoedit /etc/wsl.conf

The minimal setting is:

[boot]
systemd=true

Then apply it from Windows:

PowerShell:

wsl --shutdown

Reopen Ubuntu and verify:

WSL:

ps -p 1 -o pid=,comm=,args=
systemctl is-system-running --wait || true
systemctl --failed --no-pager

running is ideal. degraded means at least one unit failed, not that WSL is unusable. Read the failure list before deciding whether a unit matters in this environment.

Do not publish redundant settings as a baseline

Older versions of this setup included:

[user]
default=<your-user>

[network]
generateHosts=true
generateResolvConf=true
hostname=dev-wsl

[time]
useWindowsTimezone=true

Those lines are not inherently wrong. They are simply unnecessary for most Store-installed Ubuntu distributions:

  • The created user is already the default in a normal installation.
  • Host and resolver generation already default to true.
  • The hostname already has a default.
  • Windows timezone synchronisation already defaults to true.

Use the settings when you need to change those behaviours, especially for imported distributions. Do not treat a default value as a compulsory incantation.

Optional: disable Windows PATH injection

By default, WSL appends the Windows PATH to the Linux PATH. For a newcomer, that convenience is useful. It makes code, explorer.exe, clip.exe, and other Windows programs immediately discoverable.

For a mature Linux-first setup, I prefer a deterministic Linux command namespace. When I type python, node, git, or kubectl, I want the Linux command I installed on purpose.

This is optional and more advanced than many guides admit.

To disable automatic Windows PATH injection while retaining Windows process interoperability:

[interop]
enabled=true
appendWindowsPath=false

Apply with wsl --shutdown and then add explicit Windows launchers, as described later. Do not disable the PATH and then wonder why every Windows command vanished. That was the setting doing exactly what it said on the tin.

Check time rather than hardcoding a stranger’s timezone

WSL:

date --iso-8601=seconds
timedatectl status 2>/dev/null || true

If Linux and Windows agree, leave the timezone alone. If they genuinely disagree, use timedatectl set-timezone with your actual timezone. A portable setup guide should not quietly move every reader to Bengaluru.

Install the base Ubuntu packages

APT owns operating-system packages: compilers, system libraries, certificates, services, and utilities tied closely to Ubuntu.

First inspect available updates:

WSL:

sudo apt update
apt list --upgradable

On a personal machine, install the reviewed updates:

sudo apt upgrade

I deliberately omit -y here. An interactive review is useful when a command can replace packages, restart services, or pull in a new kernel component. An AI agent should summarise the proposed upgrade and ask before proceeding on a managed machine.

Install the baseline packages:

sudo apt install -y \
build-essential \
ca-certificates \
curl \
file \
git \
gnupg \
jq \
less \
locales \
openssh-client \
rsync \
unzip \
wslu \
zip \
zsh

A few are less glamorous than the prompt theme, but more useful:

  • build-essential provides the compiler toolchain needed by many native Python and Node dependencies.
  • ca-certificates is the trust store used by HTTPS clients.
  • openssh-client provides ssh, ssh-keygen, and ssh-agent.
  • rsync is invaluable for deliberate copies and backups.
  • wslu provides wslview, which opens a URL in the Windows browser.
  • file, less, zip, and unzip are the small utilities a setup guide tends to use three pages after forgetting to install them.

Keep the existing UTF-8 locale unless you need another one

Check the current locale:

locale
locale -a

Current Ubuntu WSL images normally have a working UTF-8 locale, often C.UTF-8. That is enough for development. Do not force every reader to en_US.UTF-8 unless a tool or team standard requires it.

To add that locale deliberately:

sudo locale-gen en_US.UTF-8
sudo update-locale LANG=en_US.UTF-8

Open a new shell and re-run locale.

Create the directories we will use

mkdir -p ~/.local/bin
mkdir -p ~/.config
mkdir -p ~/projects

Keep code on the Linux filesystem

This is the simplest and most important rule in the setup.

Repositories live here:

~/projects

Not here:

/mnt/c/Users/<you>/source

Microsoft recommends storing files in the WSL filesystem when Linux tools work on them. That keeps Git, package managers, test runners, build tools, and file watchers on the filesystem whose semantics they expect.

Clone from inside WSL:

cd ~/projects
git clone <repository-url>

Open the current Linux directory in Windows Explorer:

explorer.exe .

Or browse from Windows using:

\\wsl.localhost\Ubuntu-24.04\home\<your-linux-user>

Browsing and copying across the boundary are fine. Running a development workload across that boundary all day is what I avoid.

Do not touch the live virtual disk directly

Do not copy, mount, compress, encrypt, or edit a Store-managed ext4.vhdx while the distribution is registered and running. Use supported WSL export, import, disk management, and file access commands.

The VHD is not a large ZIP file wearing a different extension. Treating it like one can turn a storage optimisation into an archaeological dig.

Deliberate Windows interoperability

You have two reasonable choices.

Choice A: keep Windows PATH injection

This is the easiest option and a good starting point. Leave appendWindowsPath at its default and verify which command wins when names overlap:

type -a git python node code explorer.exe clip.exe

If the Linux versions appear first and your environment remains understandable, you may not need to change anything.

Choice B: disable injection and add explicit launchers

If you set appendWindowsPath=false, create launchers only for the Windows programs you use.

WSL:

mkdir -p ~/.local/bin

ln -sfn /mnt/c/Windows/System32/wsl.exe \
~/.local/bin/wsl.exe
ln -sfn /mnt/c/Windows/System32/clip.exe \
~/.local/bin/clip.exe
ln -sfn /mnt/c/Windows/explorer.exe \
~/.local/bin/explorer.exe
ln -sfn /mnt/c/Windows/System32/cmd.exe \
~/.local/bin/cmd.exe
ln -sfn /mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe \
~/.local/bin/powershell.exe

Put the native Linux bin directory near the front of your shell PATH:

export PATH="$HOME/.local/bin:$HOME/bin:$PATH"

The symlinks work because WSL can execute Windows binaries directly. You get intentional interoperability without importing every directory registered by every Windows installer since the machine left the factory.

Verify:

for command_name in wsl.exe clip.exe explorer.exe cmd.exe powershell.exe; do
command -v "$command_name"
done

Configure the VS Code launcher without guessing its path

Install VS Code on Windows, not inside WSL. During installation, enable the option that adds code to the Windows PATH, then install Microsoft's WSL extension.

From Windows, discover the actual launcher directory:

PowerShell:

$CodeCommand = (Get-Command code.cmd -ErrorAction Stop).Source
$CodeBin = Split-Path $CodeCommand
$CodeBin

If Windows reports a path such as:

C:\Users\you\AppData\Local\Programs\Microsoft VS Code\bin

convert that verified directory inside WSL:

CODE_BIN_WINDOWS='C:\Users\you\AppData\Local\Programs\Microsoft VS Code\bin'
CODE_BIN_WSL="$(wslpath -u "$CODE_BIN_WINDOWS")"

if [[ ! -x "$CODE_BIN_WSL/code" ]]; then
printf 'VS Code launcher not found at %s/code\n' "$CODE_BIN_WSL" >&2
exit 1
fi

cat > ~/.local/bin/code <<EOF
#!/usr/bin/env bash
exec "$CODE_BIN_WSL/code" "\$@"
EOF

chmod 0755 ~/.local/bin/code
code --version

The unquoted inner EOF is intentional. It lets the verified WSL path be written into the wrapper now, while \$@ remains literal for later. This is one of those tiny shell details that looks decorative right up until every argument disappears.

Once the WSL extension is installed, this should open the current Linux directory in a WSL-connected VS Code window:

cd ~/projects
code .

The lower-left status indicator in VS Code should say WSL: <distribution-name>. A window that merely happens to display files under \\wsl.localhost is not the same thing.

Useful boundary-crossing commands

explorer.exe .                         # open this directory in Explorer
printf 'hello\n' | clip.exe # copy Linux output to the Windows clipboard
wslview https://example.com # open a URL in the Windows browser
wslpath -w "$PWD" # convert this directory to a Windows path
wslpath -u 'C:\\Users\\you\\Downloads' # convert a Windows path to a Linux path

wslpath is worth remembering. Hand-building /mnt/c/... paths works until a space, UNC path, or non-default mount root turns up and asks whether you had plans for the afternoon.

Use one version manager instead of a small committee

Developer machines collect version managers quickly. Python gets pyenv, Node gets nvm, Java gets SDKMAN, Ruby gets another manager, Bun adds a PATH export, and six months later shell startup resembles a town meeting.

This guide uses mise as the owner of developer runtimes and standalone developer CLIs.

APT still owns system packages. The project still owns its dependencies. Docker still owns container images. One owner per layer.

Install mise without blindly piping the internet into a shell

The official installer is convenient. Download it first so you, or an agent acting for you, can inspect what will run:

installer="$(mktemp)"
curl -fsSL https://mise.run -o "$installer"

less "$installer"
sh "$installer"
rm -f "$installer"

~/.local/bin/mise --version
~/.local/bin/mise doctor

Reading every line is admirable. At minimum, confirm the download came from the expected HTTPS origin, that it is a shell installer rather than an error page wearing a fake moustache, and that it installs into your user account rather than modifying system directories unexpectedly.

Add activation near the top of ~/.zshrc, after your user PATH and before plugins that need the managed tools:

export PATH="$HOME/.local/bin:$HOME/bin:$PATH"
eval "$("$HOME/.local/bin/mise" activate zsh)"

Open a fresh shell and verify without dumping project environment values into the terminal:

command -v mise
mise --version
mise doctor

Install a deliberately small baseline

Do not install thirty tools because a screenshot on the internet looked organised. Start with the runtimes and commands you actually use.

A reasonable language baseline at the time of this review is Python 3.14 plus the active Node.js LTS line:

mise use --global python@3.14
mise use --global node@lts

Project requirements beat this example. Keep an older supported runtime when the repository requires it, and test before moving a working project to a new language release.

Add Bun only when a project needs it:

mise use --global bun@latest

A useful cross-language CLI baseline:

Current mise releases already hold newly published versions back for 24 hours by default. For a more conservative workstation, you can raise that delay in ~/.config/mise/config.toml before using fuzzy selectors such as latest:

[settings]
minimum_release_age = "7d"

This filter works when the backend supplies release timestamps. Exact version pins bypass it, which is useful when you deliberately need a just-released security fix.

mise use --global \
uv@latest \
ripgrep@latest \
fd@latest \
bat@latest \
fzf@latest \
yq@latest \
zoxide@latest \
direnv@latest \
eza@latest \
delta@latest \
gh@latest \
gitleaks@latest \
chezmoi@latest \
starship@latest

Optional tools belong in optional groups. For example:

# Terminal UI tools
mise use --global lazygit@latest neovim@latest fastfetch@latest

# Kubernetes and infrastructure tools
mise use --global \
kubectl@latest \
k9s@latest \
helm@latest \
stern@latest \
opentofu@latest

jq remains APT-owned in this guide because the setup uses it as an early system utility, before mise exists. That is not the only valid ownership choice, but it keeps the dependency order honest.

Then confirm what became active:

mise ls
mise current
command -v python node uv rg fd bat

The point is not that mise must install every command on earth. The point is that each command has a clear owner and a version you can explain.

Pin versions in the project

Global versions are defaults. Project versions are the contract.

Inside a repository:

cd ~/projects/example-app
mise use python@3.14 node@24

That writes a mise.toml similar to:

[tools]
python = "3.14"
node = "24"

Inspect repository configuration before trusting it. A mise configuration can run tasks and influence the environment, so treat it like code:

cat mise.toml
mise config ls
MISE_SAFE=1 mise ls

# Only after reviewing the configuration:
mise trust
mise install
mise current

MISE_SAFE=1 lets mise inspect and resolve tool declarations without executing project tasks, hooks, templates, or environment injection. It is useful for agent-assisted inspection, but it does not replace reading the configuration.

When exact reproducibility matters, enable and commit mise’s lockfile alongside mise.toml:

[settings]
lockfile = true

Then preview, resolve, install from the lock, and review the generated lockfile:

mise lock --dry-run
mise lock
mise install --locked
git diff -- mise.toml mise.lock
git add mise.toml mise.lock

Use mise install --locked in CI and automated bootstrap jobs after the lockfile is committed. It refuses to install a tool whose resolved download information is missing from the lock rather than quietly resolving something new.

Use a broad constraint only when you genuinely want future installs to move. node = "24" means the current Node 24 release may change over time. A lockfile records the resolved release so another machine gets the same one.

Keep uv from becoming a second Python version manager

uv can download Python by itself. That feature is excellent when uv owns Python. In this setup mise owns the base Python runtimes, so tell uv to use an installed interpreter instead of quietly creating another inventory:

export UV_NO_MANAGED_PYTHON=true

Put that next to your mise activation in .zshrc, then verify in a project:

mise current python
uv python find
python --version

They should describe the same base runtime. A project virtual environment is still expected and is not duplication. It owns project packages, not the Python installation itself.

Migrate away from old managers reversibly

Do not begin a migration with:

rm -rf ~/.nvm ~/.pyenv ~/.bun

That is an ending, not a migration plan.

First inventory the old owners:

command -v python python3 node npm bun
print -l -- $path

du -sh ~/.nvm ~/.pyenv ~/.bun 2>/dev/null || true

Then:

  1. Install equivalent versions with mise.
  2. Remove or comment the old activation lines in your shell configuration.
  3. Start a clean login shell and run the project’s tests.
  4. Rename the old directories so rollback remains easy.
stamp="$(date +%Y%m%d-%H%M%S)"

[[ -d ~/.nvm ]] && mv ~/.nvm "$HOME/.nvm.disabled-$stamp"
[[ -d ~/.pyenv ]] && mv ~/.pyenv "$HOME/.pyenv.disabled-$stamp"
[[ -d ~/.bun ]] && mv ~/.bun "$HOME/.bun.disabled-$stamp"

Keep those directories until normal work has exercised the new setup. Delete them later, deliberately, after checking the paths one more time. Disk space is cheaper than an exciting rollback.

Useful mise maintenance commands:

mise ls
mise outdated
mise upgrade
mise prune --dry-run
mise doctor

Run mise prune without --dry-run only after reviewing what it plans to remove.

Build a Zsh setup that is useful before it is clever

A shell should start quickly, configure itself quietly, and leave standard command names alone.

Install Zsh if the baseline package step did not already do it, then make it your login shell:

sudo apt install -y zsh
chsh -s "$(command -v zsh)"

Close every terminal for that distribution and open a new one. Confirm:

printf '%s\n' "$SHELL"
ps -p $$ -o comm=

Install Zinit idempotently

ZINIT_HOME="${XDG_DATA_HOME:-$HOME/.local/share}/zinit/zinit.git"

if [[ ! -d "$ZINIT_HOME/.git" ]]; then
mkdir -p "$(dirname "$ZINIT_HOME")"
git clone https://github.com/zdharma-continuum/zinit.git "$ZINIT_HOME"
fi

The conditional matters for both humans and agents. Re-running setup should converge on the desired state, not fail because yesterday’s directory still exists.

A quiet, ordered .zshrc

This is a baseline, not scripture. Read it before replacing an existing shell configuration, and merge it rather than overwriting custom work.

# ~/.zshrc

# This file is for interactive shells only.
[[ -o interactive ]] || return

export PATH="$HOME/.local/bin:$HOME/bin:$PATH"
export UV_NO_MANAGED_PYTHON=true

# One runtime and CLI manager.
if [[ -x "$HOME/.local/bin/mise" ]]; then
eval "$("$HOME/.local/bin/mise" activate zsh)"
fi

# Plugin manager.
ZINIT_HOME="${XDG_DATA_HOME:-$HOME/.local/share}/zinit/zinit.git"
if [[ -r "$ZINIT_HOME/zinit.zsh" ]]; then
source "$ZINIT_HOME/zinit.zsh"

# Additional completion definitions must be available before compinit.
zinit ice blockf atpull'zinit creinstall -q .'
zinit light zsh-users/zsh-completions

autoload -Uz compinit
compinit

# fzf-tab enhances the completion system, so it comes after compinit.
# It also comes before plugins that wrap line-editor widgets.
zinit light Aloxaf/fzf-tab

zinit ice wait lucid atload'_zsh_autosuggest_start'
zinit light zsh-users/zsh-autosuggestions

zinit ice wait lucid
zinit light zdharma-continuum/fast-syntax-highlighting
else
print -u2 "Zinit is missing at $ZINIT_HOME"
fi

# Configure fzf before loading its shell integration.
export FZF_DEFAULT_COMMAND='fd --type f --hidden --follow --exclude .git'
export FZF_CTRL_T_COMMAND="$FZF_DEFAULT_COMMAND"
export FZF_ALT_C_COMMAND='fd --type d --hidden --follow --exclude .git'
export FZF_CTRL_T_OPTS="--preview 'bat --color=always --line-range :200 {}'"

command -v fzf >/dev/null && source <(fzf --zsh)
command -v zoxide >/dev/null && eval "$(zoxide init zsh)"
command -v direnv >/dev/null && eval "$(direnv hook zsh)"
command -v starship >/dev/null && eval "$(starship init zsh)"

# One agent socket shared by every terminal.
export SSH_AUTH_SOCK="${XDG_RUNTIME_DIR:-/run/user/$UID}/ssh-agent.socket"

A few ordering choices carry most of the value:

  • mise activates before tools or plugins try to use its commands.
  • completion definitions appear before compinit scans them, and the atpull hook registers any new completion files after a plugin update.
  • fzf-tab appears after compinit and before widget-wrapping plugins.
  • fzf variables are set before fzf --zsh reads them.
  • every optional integration checks that its command exists.

This setup also prints nothing during normal startup. A shell is an interface and a protocol endpoint, not a morning television show.

Every plugin above executes code inside your interactive shell. Review the repositories before first use, update them deliberately, and pin revisions when your work policy requires a reproducible or audited plugin supply chain. Do not make plugin self-updates part of normal shell startup.

Keep standard commands standard

Do not do this:

alias grep='rg'
alias find='fd'
alias cat='bat'

rg is not a drop-in replacement for grep, fd is not find, and bat is not cat. Their defaults, flags, exit behaviour, and treatment of ignored files differ.

Use their real names. That makes pasted documentation, scripts, and your own muscle memory agree about what a command means.

Aliases should abbreviate or add a new name:

alias ll='eza -lah --git'
alias la='eza -a'
alias lt='eza --tree --level=2'
alias l='eza -1'

alias g='git'
alias gs='git status -sb'
alias gd='git diff'
alias lg='lazygit'

alias d='docker'
alias dc='docker compose'
alias dps='docker ps --format "table {{.Names}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}"'
alias k='kubectl'

alias sysinfo='fastfetch'
alias wopen='explorer.exe .'
alias wsl-shutdown='wsl.exe --shutdown'

alias ..='cd ..'
alias ...='cd ../..'

Add only aliases whose target commands you installed. A dead alias is merely a typo with documentation.

I no longer alias vim to nvim in a portable baseline. Some systems and recovery environments really do mean Vim, and an alias should not rewrite a standard tool's identity behind your back. Use nvim, or set EDITOR=nvim after verifying it exists.

Do not run system information on every prompt

This is fun once:

fastfetch

This is a tax forever:

fastfetch  # placed directly in .zshrc

GPU, disk, network, and package probes do not become more useful because a new split pane appeared. Keep sysinfo as an on-demand alias.

Check completion security instead of disabling it

Plain compinit performs ownership and permission checks. Do not silence warnings with compinit -u just to make the terminal look clean.

Audit first:

autoload -Uz compaudit
compaudit

If it reports directories, inspect who owns them and why they are writable. Fix the permissions or remove the untrusted path. The warning is the feature.

compinit -C is a separate optimisation that reuses an existing dump and skips some checks. Start with plain compinit. After changing installed tools or completion plugins, rebuild cleanly:

rm -f ~/.zcompdump ~/.zcompdump.zwc
exec zsh

Those are generated cache files, so removing them is safe and specific. Specific deletion is much less exciting than rm -rf ~ and we prefer it that way.

Measure shell startup

for run in {1..5}; do
/usr/bin/time -f '%e seconds' zsh -lic exit
done

A single run includes cold filesystem caches and plugin updates, so look at several runs. If startup is consistently slow, profile instead of guessing:

# Temporarily put near the top of ~/.zshrc
zmodload zsh/zprof

# Temporarily put at the very bottom
zprof

Open a new shell, read the report, then remove both profiling lines. The slowest line is often less glamorous than expected. Network-aware prompt modules and repeated version-manager initialisation are regular offenders.

Configure Git and SSH on the Linux side

If the repositories live in Linux and Linux tools modify them, use Linux Git and Linux SSH. A Windows Git installation can still exist for Windows-only work, but it should not quietly own credentials or line-ending policy for the repositories under ~/projects.

Set identity without letting an agent invent it

Inspect first:

git --version
git config --global --get user.name || true
git config --global --get user.email || true

If either value is missing, set it to the identity you actually use for commits:

git config --global user.name  "Your Name"
git config --global user.email "you@example.com"

An AI agent should not guess these values from the Windows account name, hostname, or a random repository. A plausible-looking wrong email produces perfectly valid commits attributed to the wrong person, which is a very efficient way to create boring problems.

Verify the origin as well as the value:

git config --show-origin --get user.name
git config --show-origin --get user.email

Apply useful, explainable defaults

git config --global init.defaultBranch main

git config --global fetch.prune true
git config --global push.autoSetupRemote true
git config --global rerere.enabled true
git config --global merge.conflictStyle zdiff3
git config --global diff.algorithm histogram
git config --global branch.sort -committerdate

git config --global core.pager delta
git config --global interactive.diffFilter 'delta --color-only'
git config --global delta.navigate true
git config --global delta.line-numbers true

What the less obvious ones do:

  • fetch.prune removes remote-tracking branches that disappeared upstream.
  • push.autoSetupRemote makes the first plain git push establish the upstream branch.
  • rerere remembers how you resolved a conflict and can reuse that resolution later.
  • zdiff3 shows the common ancestor inside conflict markers, which turns a two-sided argument into evidence.
  • histogram often produces more readable diffs when code moved around.

I deliberately do not publish a global pull.rebase, pull.ff, or pull.ff only setting as universally correct. Teams differ. Choose the policy that matches the repository, and put repository-specific policy in repository configuration.

Check the effective configuration:

git config --global --list --show-origin

Encode line endings in the repository

For a Linux-first working tree, this global setting is reasonable:

git config --global core.autocrlf input

It converts CRLF to LF when committing but does not rewrite LF to CRLF on checkout.

The stronger solution is a committed .gitattributes, because it follows the project to every machine:

* text=auto

*.sh text eol=lf
*.bash text eol=lf
*.zsh text eol=lf
*.py text eol=lf
*.js text eol=lf
*.ts text eol=lf
*.yaml text eol=lf
*.yml text eol=lf
*.toml text eol=lf
Dockerfile text eol=lf

*.bat text eol=crlf
*.cmd text eol=crlf
*.ps1 text eol=crlf

*.png binary
*.jpg binary
*.jpeg binary
*.gif binary
*.pdf binary
*.zip binary

Adjust that list to the project. Do not mark an unfamiliar format as text because its extension looked friendly.

After adding or changing .gitattributes, ask Git what it thinks before normalising an existing repository:

git check-attr --all -- path/to/file

git add --renormalize .
git status --short

Review the diff before committing. --renormalize can touch many files, and a large line-ending-only change deserves its own commit rather than being smuggled into a feature branch wearing a trench coat.

Why this matters: a CRLF shebang can make a script inside Linux fail with not found while the file is visibly present. The hidden carriage return became part of the interpreter path.

Audit credential helpers left by another setup

SSH remotes do not use Git credential helpers. HTTPS remotes do.

See what Linux Git is configured to call:

git config --show-origin --get-all credential.helper || true
git remote -v 2>/dev/null || true

A stale Windows helper can remain after changing your Git setup. It may appear as an .exe path or a Git Credential Manager entry. This is separate from Docker's credsStore setting, even though both problems involve credentials and both enjoy arriving at inconvenient times.

Do not remove a helper merely because a guide says so. Back up ~/.gitconfig, confirm the exact value is obsolete, then remove only that reviewed value:

cp -a ~/.gitconfig "$HOME/.gitconfig.before-credential-change-$(date +%Y%m%d-%H%M%S)"

helper_to_remove='<copy the exact credential.helper value shown above>'
git config --global --fixed-value --unset-all credential.helper "$helper_to_remove"

If SSH is your primary transport, an HTTPS helper may still be useful for another Git host. Inspect the remote URLs before changing it.

For GitHub over HTTPS, GitHub CLI can configure the helper after authentication:

gh auth login
gh auth setup-git

For the rest of this guide, SSH is the primary transport.

Create and manage an SSH key without overwriting one

Start by looking:

install -d -m 0700 ~/.ssh
find ~/.ssh -maxdepth 1 -type f -printf '%f\n' | sort

If you already have a suitable key, reuse it or create a clearly named second key. Never run ssh-keygen -f ~/.ssh/id_ed25519 non-interactively over an existing private key.

To create a new personal key:

ssh-keygen \
-t ed25519 \
-a 64 \
-C "$(git config --global user.email)" \
-f "$HOME/.ssh/id_ed25519"

Use a passphrase unless your organisation explicitly provides another protected key mechanism. The private key stays in Linux. The public key is the file ending in .pub.

Check permissions:

chmod 0700 ~/.ssh
chmod 0600 ~/.ssh/id_ed25519
chmod 0644 ~/.ssh/id_ed25519.pub

Run one ssh-agent through systemd

Create ~/.config/systemd/user/ssh-agent.service:

[Unit]
Description=SSH authentication agent
Documentation=man:ssh-agent(1)

[Service]
Type=simple
ExecStart=/usr/bin/ssh-agent -D -a %t/ssh-agent.socket

[Install]
WantedBy=default.target

Enable and verify it:

systemctl --user daemon-reload
systemctl --user enable --now ssh-agent.service
systemctl --user status ssh-agent.service --no-pager

The shell configuration earlier exports the matching socket:

export SSH_AUTH_SOCK="${XDG_RUNTIME_DIR:-/run/user/$UID}/ssh-agent.socket"

Load the key:

ssh-add ~/.ssh/id_ed25519
ssh-add -l

The agent process comes back automatically. The decrypted key does not survive a full WSL shutdown or Windows restart, because it lives in volatile memory. That is a security property, not a bug with unusually good timing.

Use host-specific SSH configuration rather than forcing one key on every server. For GitHub, ~/.ssh/config can contain:

Host github.com
HostName github.com
User git
AddKeysToAgent yes
IdentityFile ~/.ssh/id_ed25519
IdentitiesOnly yes

Then:

chmod 0600 ~/.ssh/config
ssh -G github.com | grep -E '^(user|hostname|identityfile) '

Add separate host blocks for work GitHub, GitLab, bastions, or multiple identities. A common pattern is a host alias such as github-work, then a remote like git@github-work:company/repo.git.

Add the public key to GitHub

The manual path is:

clip.exe < ~/.ssh/id_ed25519.pub

Then paste it into the account’s SSH key settings.

With GitHub CLI:

gh auth login

gh ssh-key add ~/.ssh/id_ed25519.pub \
--type authentication \
--title "$(hostname)-WSL authentication"

Test:

ssh -T git@github.com

GitHub deliberately does not provide an interactive shell, so a successful greeting can still accompany a non-zero exit code. Read the message rather than making a setup script trust the exit status alone.

Sign commits with the same SSH key

Git can use SSH keys for commit signatures, so there is no requirement to add GPG solely for this purpose.

git config --global gpg.format ssh
git config --global user.signingkey "$HOME/.ssh/id_ed25519.pub"
git config --global commit.gpgsign true

Create a local allowed-signers file so Git can verify the signature it just made:

email="$(git config --global user.email)"
printf '%s %s\n' "$email" "$(cat ~/.ssh/id_ed25519.pub)" \
> ~/.ssh/allowed_signers
chmod 0644 ~/.ssh/allowed_signers

git config --global gpg.ssh.allowedSignersFile "$HOME/.ssh/allowed_signers"

For GitHub to mark commits as verified, add the public key a second time in the signing role:

gh ssh-key add ~/.ssh/id_ed25519.pub \
--type signing \
--title "$(hostname)-WSL signing"

Authentication and signing are separate records even when the public key text is identical. Adding only one is a wonderfully convincing imitation of a broken signing setup.

Test locally in a disposable repository:

test_repo="$(mktemp -d)"

git -C "$test_repo" init -q
git -C "$test_repo" commit --allow-empty -m "test: verify SSH signing"
git -C "$test_repo" log --show-signature -1
case "$test_repo" in
/tmp/*) rm -rf -- "$test_repo" ;;
*) printf 'Refusing to remove unexpected path: %s\n' "$test_repo" >&2 ;;
esac

The path guard is not theatre. Agents should validate deletion targets immediately before deletion, even when the target came from mktemp.

Use repository hooks without making two hook systems fight

The earlier article used a global core.hooksPath for Gitleaks and later recommended the pre-commit framework. Those ideas conflict. core.hooksPath redirects Git away from .git/hooks, which is exactly where pre-commit install normally places its launcher.

This version picks one model: commit hook policy lives in each repository and is managed by pre-commit.

That means:

  • the configuration is versioned with the project;
  • every contributor can run the same checks;
  • a project can choose tools appropriate to its stack;
  • CI can run the same configuration;
  • one global hook does not silently disable a repository’s own hooks.

Add pre-commit to the CLI inventory:

mise use --global pre-commit@latest
pre-commit --version

A solid cross-platform baseline

At the repository root, create .pre-commit-config.yaml:

repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v6.0.0
hooks:
- id: check-case-conflict
- id: check-merge-conflict
- id: check-symlinks
- id: check-added-large-files
args: ["--maxkb=1024"]
- id: detect-private-key
- id: end-of-file-fixer
- id: trailing-whitespace
- id: mixed-line-ending
args: ["--fix=no"]

- repo: https://github.com/gitleaks/gitleaks
rev: v8.30.1
hooks:
- id: gitleaks

For a Python repository, add Ruff after those general checks:

- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.16.3
hooks:
- id: ruff-check
args: ["--fix"]
- id: ruff-format

Those revisions were current during this article’s technical review on 20 August 2026. They are examples of pinned, known versions, not a request to freeze forever.

Install and run:

pre-commit install
pre-commit run --all-files

The first run downloads isolated hook environments and is slower than later runs. That is expected.

Keep versions current through a reviewed change:

pre-commit autoupdate
git diff -- .pre-commit-config.yaml
pre-commit run --all-files

Commit the version update only after the checks pass.

check-case-conflict is especially useful in a mixed Windows and Linux team. Linux treats Widget.ts and widget.ts as different files. Windows commonly does not. Finding that disagreement before checkout day is preferable.

mixed-line-ending detects inconsistent endings without rewriting files. Let .gitattributes own the repository's canonical policy, including any intentional CRLF exceptions.

Run Gitleaks directly when needed

For the staged change:

gitleaks git --pre-commit --staged --redact --verbose

For the repository history:

gitleaks git --redact --verbose

Current Gitleaks uses the git subcommand. Older guides often show gitleaks protect or gitleaks detect; those commands were deprecated and are not the baseline here.

A secret scanner is an alarm, not a vault. If a real credential was committed, rotate it. Removing the line in a later commit does not remove the credential from history, clones, caches, or somebody’s extremely enthusiastic bot.

Hooks help locally, CI enforces policy

Any Git hook can be bypassed with --no-verify, and a developer may not have installed it at all. Put security, tests, and policy checks that truly matter in CI as well.

Keep hooks fast. A ninety-second pre-commit check does not build discipline. It teaches a shortcut.

Connect VS Code to WSL correctly

VS Code in this setup has two halves:

  • the Windows application draws the interface;
  • the VS Code Server and most development extensions run inside WSL, next to the code and toolchain.

Install VS Code on Windows and install the WSL extension on the Windows side:

PowerShell:

code --install-extension ms-vscode-remote.remote-wsl

Then, inside WSL:

cd ~/projects/example-app
code .

Confirm the lower-left indicator says WSL: <distribution-name> and open a terminal in VS Code:

uname -a
pwd
command -v git python node

The paths should be Linux paths and the commands should resolve to Linux tools.

Extensions have a side

Themes and UI-only extensions usually stay local on Windows. Language servers, formatters, linters, debuggers, and tools that read the repository usually belong in WSL.

While connected to WSL, the Extensions view shows separate groups such as Local — Installed and WSL: Installed. Use the Install in WSL button for development extensions.

A small stack-aware set is better than copying somebody’s 70-extension profile.

For any repository:

code --install-extension editorconfig.editorconfig

For Python:

code --install-extension ms-python.python
code --install-extension ms-python.vscode-pylance
code --install-extension charliermarsh.ruff

For JavaScript and TypeScript:

code --install-extension dbaeumer.vscode-eslint
code --install-extension esbenp.prettier-vscode

For containers and YAML:

code --install-extension ms-azuretools.vscode-containers
code --install-extension redhat.vscode-yaml

Optional, depending on the work:

code --install-extension eamodio.gitlens
code --install-extension usernamehw.errorlens
code --install-extension streetsidesoftware.code-spell-checker
code --install-extension tamasfe.even-better-toml
code --install-extension ms-kubernetes-tools.vscode-kubernetes-tools
code --install-extension github.vscode-github-actions

List the effective extension inventory from a connected WSL window:

code --list-extensions --show-versions | sort

Put project recommendations in the project

A repository can recommend, rather than force, the extensions it needs. Create .vscode/extensions.json:

{
"recommendations": [
"editorconfig.editorconfig",
"ms-python.python",
"ms-python.vscode-pylance",
"charliermarsh.ruff"
]
}

Adjust it to the stack and commit it. This is better than relying on a new contributor to remember a blog article they read three laptops ago.

Edit Remote Settings through VS Code

Do not publish ~/.vscode-server/data/Machine/settings.json as a user-facing configuration path. It is an implementation detail under a server-managed directory.

While connected to WSL, open the Command Palette and run:

Preferences: Open Remote Settings (JSON)

A modest remote baseline:

{
"terminal.integrated.defaultProfile.linux": "zsh",
"files.eol": "\n",
"editor.formatOnSave": true
}

Keep language-specific formatter choices in workspace settings when the repository has an opinion. For Python with Ruff:

{
"[python]": {
"editor.defaultFormatter": "charliermarsh.ruff",
"editor.formatOnSave": true,
"editor.codeActionsOnSave": {
"source.fixAll": "explicit",
"source.organizeImports": "explicit"
}
}
}

Do not set one global Python interpreter path to mise’s shim. Mise chooses the base Python version. The project virtual environment owns packages. Let the Python extension discover .venv, use Python: Select Interpreter, or add a workspace-specific setting only when discovery fails.

Do not blanket-exclude dependency directories from watchers

Older WSL and remote-development guides often add broad watcher exclusions or polling workarounds. WSL2 on the Linux filesystem has native Linux file watching. Excluding node_modules or generated directories can reduce load in a truly enormous repository, but it can also prevent tools from noticing changes they rely on.

Measure first. Use Developer: Show Running Extensions, the Process Explorer, and the WSL-side top or htop before tuning. A silent optimisation that disables a feature is just a bug with excellent posture.

Proxy note for VS Code Server installation

Opening a WSL window from the Windows interface and opening it by running code . can use different network paths for downloading the VS Code Server. On a corporate proxy, one may work while the other fails.

Configure the Windows proxy and the WSL command-line proxy deliberately. Do not respond by disabling TLS verification in Git, npm, pip, curl, or VS Code. The correct fix is the organisation’s trusted proxy or CA configuration, covered later.

Configure a prompt that reports useful things, not everything

Starship is already in the mise baseline. Create ~/.config/starship.toml:

add_newline = false

[cmd_duration]
min_time = 2000

[aws]
disabled = true

[azure]
disabled = true

[gcloud]
disabled = true

[kubernetes]
disabled = false
symbol = "k8s "
format = '[$symbol$context( \($namespace\))]($style) '

This disables cloud modules that are not part of the workflow and keeps the Kubernetes context visible. A production cluster name in the prompt is not decoration. It is a small sign placed between you and an ambitious kubectl delete.

Do not copy this blindly if you never use Kubernetes or work heavily in a cloud CLI. Enable modules that answer a question you repeatedly need answered. Disable modules that run commands or inspect files without earning the cost.

Check the prompt and its timings:

starship explain
starship timings

A Nerd Font is optional, not a requirement for WSL. Install one in Windows Terminal only if your prompt, file listing, or editor theme actually uses its glyphs. The configuration above deliberately uses ordinary text so a fresh terminal does not begin its life speaking square.

Python projects with uv, while mise owns Python

The layers are:

  • mise installs and selects the base Python interpreter;
  • uv creates the project environment, resolves dependencies, and runs commands;
  • the repository records the Python constraint and lockfile.

Start a new project

mkdir -p ~/projects/example-python
cd ~/projects/example-python

mise use python@3.14
uv init
uv add requests
uv add --dev pytest ruff

This creates or updates pyproject.toml, creates uv.lock, and prepares .venv when needed.

Run without manually activating the environment:

uv run python -c 'import sys; print(sys.executable)'
uv run pytest
uv run ruff check .

Activation remains available for tools that expect it:

source .venv/bin/activate

Inside WSL the path is .venv/bin/activate. A tutorial showing .venv\Scripts\activate is giving Windows-native instructions.

Work with an existing project

For a uv-managed project:

uv sync
uv run pytest

For an older requirements.txt project:

uv venv
uv pip install -r requirements.txt

Do not mechanically convert a production project to a new dependency workflow while setting up a laptop. First reproduce the project as it exists. Migrations deserve their own review, test run, and pull request.

Commit the contract, not the environment

For an application or service, commit:

mise.toml
mise.lock
pyproject.toml
uv.lock

Ignore:

.venv/
__pycache__/
.pytest_cache/
.ruff_cache/

In CI, use both locks as checks:

mise install --locked
uv sync --locked
uv run pytest

A lockfile is only useful when the build refuses to quietly rewrite it.

Verify the ownership model:

mise current python
uv python find
uv run python --version
uv run python -c 'import sys; print(sys.prefix)'

The base interpreter should come from mise. The project prefix should point into .venv.

Choose exactly one Docker daemon

This article uses Docker Engine directly inside Ubuntu. Docker Desktop remains a valid choice, particularly on managed workstations where its GUI, policy controls, credential integration, support model, or company standard matter.

What is not a good choice is an accidental hybrid:

  • the Docker CLI from one installation;
  • a daemon from another;
  • credentials from Docker Desktop;
  • a stale DOCKER_HOST variable;
  • VS Code connected to whichever socket answered first.

Before installing anything:

command -v docker || true
docker context ls 2>/dev/null || true
docker context show 2>/dev/null || true
printf 'DOCKER_HOST=%s\n' "${DOCKER_HOST-}"
systemctl status docker --no-pager 2>/dev/null || true

If Docker Desktop is currently integrated with this distribution, disable that integration before installing a second daemon. Preserve any images, volumes, and registry access you need first. Docker images are reproducible more often than databases are. Treat the distinction with respect.

Preview package conflicts before removing them

Docker’s official packages conflict with several distribution packages. Inventory them:

conflicts=(
docker.io
docker-doc
docker-compose
docker-compose-v2
podman-docker
containerd
runc
)

dpkg-query -W -f='${binary:Package}\t${Version}\n' "${conflicts[@]}" 2>/dev/null || true

Simulate removal:

sudo apt-get --simulate remove "${conflicts[@]}"

Read the proposed changes. If podman-docker, containerd, or another package is part of a workflow you still use, stop and choose an ownership model rather than approving the removal on momentum.

After review, remove only installed conflicting packages that you have decided to replace:

installed_conflicts=()

for package_name in "${conflicts[@]}"; do
if dpkg-query -W -f='${Status}' "$package_name" 2>/dev/null \
| grep -q 'install ok installed'; then
installed_conflicts+=("$package_name")
fi
done

if ((${#installed_conflicts[@]})); then
printf 'Removing: %s\n' "${installed_conflicts[*]}"
sudo apt-get remove "${installed_conflicts[@]}"
else
printf 'No conflicting packages are installed.\n'
fi

An unattended agent should present the simulation and obtain explicit approval before this step. Package removal is not where initiative earns bonus points.

Add Docker’s official Ubuntu repository

sudo apt-get update
sudo apt-get install -y ca-certificates curl
sudo install -m 0755 -d /etc/apt/keyrings

sudo curl -fsSL \
https://download.docker.com/linux/ubuntu/gpg \
-o /etc/apt/keyrings/docker.asc

sudo chmod a+r /etc/apt/keyrings/docker.asc

Create the deb822 source:

sudo tee /etc/apt/sources.list.d/docker.sources > /dev/null <<EOF
Types: deb
URIs: https://download.docker.com/linux/ubuntu
Suites: $(. /etc/os-release && echo "${UBUNTU_CODENAME:-$VERSION_CODENAME}")
Components: stable
Architectures: $(dpkg --print-architecture)
Signed-By: /etc/apt/keyrings/docker.asc
EOF

Inspect what was written:

cat /etc/apt/sources.list.d/docker.sources
gpg --show-keys --with-fingerprint /etc/apt/keyrings/docker.asc

The codename fallback matters on Ubuntu derivatives. Plain Ubuntu normally provides both values; a derivative may expose its own VERSION_CODENAME, which Docker does not necessarily publish.

Install Engine and the supported plugins:

sudo apt-get update
sudo apt-get install -y \
docker-ce \
docker-ce-cli \
containerd.io \
docker-buildx-plugin \
docker-compose-plugin

Enable and check the services:

sudo systemctl enable --now docker.service containerd.service
systemctl is-enabled docker.service
systemctl is-active docker.service
sudo docker version

Systemd starts Docker when the distribution starts. It does not make WSL a permanently running server, and systemd services do not keep a WSL instance alive by themselves. A full wsl --shutdown still stops the daemon. That distinction matters for scheduled jobs and anything described as "always on."

Decide how the client gets daemon access

The common personal-laptop option is membership in the docker group:

sudo usermod -aG docker "$USER"

Apply the new group in a fresh login session, or start a temporary subshell:

newgrp docker

Verify without sudo:

id -nG
docker run --rm hello-world

Membership in the docker group is effectively root-level access. A user who can control the daemon can mount the host filesystem into a container and modify it as root. This may be an acceptable trade on a personal development machine. It is not a harmless convenience group on a shared or hardened host.

Rootless Docker reduces that daemon privilege, but it changes networking, UID mapping, low ports, cgroup delegation, bind mounts, and GPU setup. Treat rootless mode as a separate architecture, not a checkbox pasted onto the rootful instructions. The official rootless setup is a good option when its limitations fit your work.

Confirm the CLI is talking to the daemon you chose

For the rootful native setup:

unset DOCKER_HOST
docker context use default

docker context inspect default
printf 'Context=%s\n' "$(docker context show)"
docker info --format 'RootDir={{.DockerRootDir}} Driver={{.Driver}}'

Do not add DOCKER_HOST to .zshrc unless you intentionally use a non-default or remote daemon. A stale socket variable can make a perfectly healthy service look dead.

Put a ceiling on logs

First check whether /etc/docker/daemon.json already exists:

sudo test -f /etc/docker/daemon.json \
&& sudo cat /etc/docker/daemon.json \
|| printf 'No existing daemon.json\n'

If it exists, back it up and merge the keys. Do not overwrite unrelated settings:

sudo cp -a /etc/docker/daemon.json \
"/etc/docker/daemon.json.before-logging-$(date +%Y%m%d-%H%M%S)"

A good baseline for a development machine is Docker’s local logging driver with explicit rotation:

{
"log-driver": "local",
"log-opts": {
"max-size": "10m",
"max-file": "3"
}
}

Write or merge it with sudoedit /etc/docker/daemon.json, then validate before restarting:

sudo dockerd --validate --config-file=/etc/docker/daemon.json
sudo systemctl restart docker
systemctl is-active docker

docker info --format '{{.LoggingDriver}}'
docker run --rm hello-world

The default logging driver changes only newly created containers. Recreate existing Compose services if you want them to inherit it:

docker compose up -d --force-recreate

Do not edit files under Docker’s internal log storage by hand. The daemon owns them.

Publish development ports to loopback by default

This:

docker run --rm -p 8080:80 nginx

publishes on all host addresses by default.

For a service intended only for this laptop, bind explicitly:

docker run --rm -p 127.0.0.1:8080:80 nginx

In Compose:

services:
web:
image: nginx
ports:
- "127.0.0.1:8080:80"

Docker’s port-publishing rules can bypass assumptions made from Ubuntu’s ufw configuration. Bind to loopback unless another device genuinely needs access, then add the Windows and Hyper-V firewall rules deliberately.

Never expose the Docker daemon TCP API without mutual TLS and a clear reason. Possession of that API is possession of the machine.

Test more than docker --version

docker run --rm hello-world

docker run --rm busybox:1.36 nslookup github.com
docker buildx version
docker compose version

The DNS test catches failures that docker ps cannot. The plugin checks catch a CLI assembled from mismatched packages.

Clean up Docker Desktop leftovers selectively

Inspect the client configuration:

jq '{credsStore, credHelpers, currentContext}' ~/.docker/config.json 2>/dev/null || true
docker context ls

A stale setting such as this can make every pull fail after Desktop integration is gone:

{
"credsStore": "desktop.exe"
}

Back up the file and remove the key only when its value is exactly the retired Desktop helper:

config="$HOME/.docker/config.json"
current_store="$(jq -r '.credsStore // empty' "$config")"
if [[ "$current_store" == "desktop.exe" ]]; then
cp -a "$config" \
"$config.before-desktop-cleanup-$(date +%Y%m%d-%H%M%S)"
temp_config="$(mktemp)"
jq 'del(.credsStore)' "$config" > "$temp_config"
install -m 0600 "$temp_config" "$config"
rm -f "$temp_config"
else
printf 'No desktop.exe credsStore found. Current value: %s\n' \
"${current_store:-<unset>}"
fi

Validate it:

jq empty ~/.docker/config.json
docker pull hello-world

Do not interpret this as advice to store registry passwords in plain JSON. Without Docker Desktop, configure a Linux credential helper such as pass or Secret Service before docker login, especially on a work machine. Docker falls back to base64-encoded credentials in config.json when no helper exists, which is encoding, not protection.

Inspect dangling command symlinks rather than deleting all of them:

find /usr/local/bin -xtype l -print

For each candidate:

ls -l /usr/local/bin/<name>
readlink /usr/local/bin/<name>

Remove only links you can attribute to the retired integration.

Avoid routine blind pruning

Start with an inventory:

docker system df -v
docker ps -a
docker image ls
docker volume ls

docker system prune removes stopped containers, unused networks, dangling images, and build cache. It has no true dry-run mode. Keep the confirmation prompt, read the list it prints, and do not put -f, -a, or --volumes in a scheduled housekeeping command.

Named volumes often contain the one local database nobody remembered was important until it became a learning opportunity.

Optional: NVIDIA GPU containers

Skip this section unless the machine has a supported NVIDIA GPU and you run local CUDA workloads.

The important WSL rule is simple: install the NVIDIA display driver on Windows, not a normal Linux NVIDIA display driver inside Ubuntu. Windows exposes GPU support into WSL.

Check the Windows driver path from WSL:

if command -v nvidia-smi >/dev/null; then
nvidia-smi
elif [[ -x /usr/lib/wsl/lib/nvidia-smi ]]; then
/usr/lib/wsl/lib/nvidia-smi
else
printf 'nvidia-smi is not available in WSL. Check the Windows NVIDIA driver.\n' >&2
fi

If this fails, do not continue by installing nvidia-driver-* inside Ubuntu. Fix the Windows driver and WSL update first.

The following assumes the rootful Docker Engine setup above.

Install NVIDIA Container Toolkit

curl -fsSL https://nvidia.github.io/libnvidia-container/gpgkey \
| sudo gpg --dearmor --yes \
-o /usr/share/keyrings/nvidia-container-toolkit-keyring.gpg

curl -fsSL \
https://nvidia.github.io/libnvidia-container/stable/deb/nvidia-container-toolkit.list \
| sed 's#deb https://#deb [signed-by=/usr/share/keyrings/nvidia-container-toolkit-keyring.gpg] https://#g' \
| sudo tee /etc/apt/sources.list.d/nvidia-container-toolkit.list > /dev/null
sudo apt-get update
sudo apt-get install -y nvidia-container-toolkit

Inspect the repository and installed version:

cat /etc/apt/sources.list.d/nvidia-container-toolkit.list
nvidia-ctk --version

Back up Docker’s configuration before allowing a tool to modify it:

if sudo test -f /etc/docker/daemon.json; then
sudo cp -a /etc/docker/daemon.json \
"/etc/docker/daemon.json.before-nvidia-$(date +%Y%m%d-%H%M%S)"
fi

Configure the runtime:

sudo nvidia-ctk runtime configure --runtime=docker
sudo dockerd --validate --config-file=/etc/docker/daemon.json

Review what changed against the newest backup:

latest_backup="$(
sudo find /etc/docker -maxdepth 1 \
-name 'daemon.json.before-nvidia-*' \
-printf '%T@ %p\n' \
| sort -n \
| tail -n 1 \
| cut -d' ' -f2-
)"

if [[ -n "$latest_backup" ]]; then
sudo diff -u "$latest_backup" /etc/docker/daemon.json || true
fi

Then restart and test:

sudo systemctl restart docker
systemctl is-active docker

docker run --rm --gpus all ubuntu:24.04 nvidia-smi

You do not need --runtime=nvidia when --gpus all is working with the configured runtime. Seeing the GPU from inside the container is the verification step. A toolkit package sitting politely in the package database is not.

Rootless Docker requires a different NVIDIA configuration and cgroup treatment. Follow NVIDIA’s rootless instructions rather than mixing the two paths in one daemon.json.

Networking that you test by behaviour

WSL’s default NAT networking is good enough for many people. Mirrored networking can improve VPN compatibility, IPv6, multicast, direct LAN access, and Windows-to-WSL connectivity on supported Windows versions.

The setting is not the test. The application path is the test.

Test Windows reaching WSL

Inside WSL:

mkdir -p ~/tmp/wsl-network-test
cd ~/tmp/wsl-network-test
printf 'WSL is reachable\n' > index.html
python3 -m http.server 8000 --bind 127.0.0.1

From Windows PowerShell:

Invoke-WebRequest http://127.0.0.1:8000

Stop the server with Ctrl+C when done.

Use 127.0.0.1 in scripts where loopback is what you mean. In mirrored mode, IPv6 loopback ::1 has documented limitations, and some clients try IPv6 before IPv4. Browsers often recover quickly; command-line clients may simply contemplate the void until timeout.

Test WSL reaching Windows

Start a Windows service bound to loopback, then from WSL:

curl -v http://127.0.0.1:<port>

For NAT mode, you may need the Windows host address instead. Discover it rather than hardcoding yesterday’s IP:

ip route show default

LAN access is a firewall decision

Making a WSL service reachable from another device is different from making it reachable from Windows itself.

You need all of the following:

  • the application bound to a non-loopback address;
  • the WSL networking mode to support the path;
  • Docker, if used, publishing to the intended host address;
  • Windows Firewall and the Hyper-V firewall allowing the port;
  • the current Wi-Fi or Ethernet network profile to be appropriate.

Do not disable the firewall to prove the application works. Add one narrow inbound rule for one port and remove it when the experiment ends.

Inspect routes before choosing Docker address pools

Only configure custom Docker address pools after proving there is a collision with a LAN or VPN.

Inside WSL:

ip -4 route
ip -6 route

From Windows PowerShell:

Get-NetRoute -AddressFamily IPv4 | Sort-Object DestinationPrefix

Connect every VPN you regularly use and check again. Private ranges are not reservation tickets. Your employer is entirely allowed to have chosen the same aesthetically pleasing 172.30.0.0/16 as a blog author.

DNS troubleshooting order

getent ahosts github.com
resolvectl status
cat /etc/resolv.conf

Then compare Windows:

Resolve-DnsName github.com

Current WSL versions enable DNS tunnelling by default on supported systems. Do not make /etc/resolv.conf immutable or hardcode public resolvers as the first response to a corporate DNS problem. That often fixes the public internet by breaking private names, which is not a complete victory.

Corporate proxies and certificate authorities

Inspect before changing:

env | grep -iE '^(http|https|all|no)_proxy=' || true
curl -I https://github.com

From Windows:

netsh winhttp show proxy

WSL’s automatic proxy integration is enabled by default on current supported configurations, but enterprise proxy products vary.

If your organisation intercepts TLS, obtain its root CA from IT and install that trust anchor in Ubuntu. For a PEM certificate with a .crt extension:

sudo install -m 0644 /path/to/company-root-ca.crt \
/usr/local/share/ca-certificates/company-root-ca.crt
sudo update-ca-certificates

curl -I https://your-internal-service.example

Do not “fix” certificate errors with any of these:

git config --global http.sslVerify false
NODE_TLS_REJECT_UNAUTHORIZED=0
pip --trusted-host ...
curl -k

Those switches turn a trust problem into an unverified connection. They are useful only as tightly controlled diagnostics, never as the setup.

Make Windows Terminal open where the work is

Set the Ubuntu profile as the default in Windows Terminal if most terminal work starts in WSL. Otherwise every new tab begins with the tiny ceremony of typing wsl, which is not difficult, just suspiciously persistent.

In the Ubuntu profile:

  • set the starting directory to ~;
  • choose a font you can read for several hours;
  • use a Nerd Font only if your actual prompt or tools require one;
  • keep copy and paste shortcuts consistent with the rest of Windows;
  • avoid a startup command that automatically changes into one particular repository.

A new tab should open in the Linux home directory:

pwd

Expected shape:

/home/<your-linux-user>

Not:

/mnt/c/Windows/System32

Fix the Terminal profile rather than adding a cd ~ hack to .zshrc. Shell configuration should not silently move you when a script intentionally starts in another directory.

To launch a particular distribution and directory from a shortcut or script:

wsl.exe -d Ubuntu-24.04 --cd ~

Use the exact distribution name reported by wsl --list --verbose.

Understand the security boundary

WSL is a useful operating-system boundary. It is not a vault isolated from the Windows account that owns it.

Your Windows user can access Linux files through WSL integration. Windows administrators control the host. Malware running as you on Windows may be able to reach development files and credentials. Conversely, Windows programs launched from WSL run with your Windows identity.

That leads to a few practical rules:

  • keep the Windows host patched and protected;
  • do not put production secrets in a development distribution because Linux permissions feel reassuring;
  • use passphrases, credential managers, short-lived tokens, and organisation-approved secret stores;
  • do not mount the Docker socket into an untrusted container;
  • do not run repository scripts, .envrc, mise tasks, dev containers, or editor workspaces before reading what they execute;
  • treat a WSL export as sensitive data because it can contain keys, tokens, source code, browserless credentials, and local databases.

The Linux filesystem is the right place for Linux development. It is not magically outside the threat model of the host.

Optional: Dev Containers are a project decision

VS Code Dev Containers can make a project’s compiler, libraries, and supporting services reproducible beyond the host setup. They are useful when the repository commits a reviewed .devcontainer definition and the team supports it.

They also add another environment layer. A weakly maintained dev container can become a second setup guide hiding inside the first one.

Use them when the project benefits from that isolation and consistency, not because every WSL installation needs a container around its container tools. Remember that a dev container with access to the host Docker socket can control the host daemon. It is convenient, not a sandbox from Docker.

Make the environment reproducible with chezmoi

A full WSL export restores one machine-sized snapshot. Dotfiles rebuild the intended configuration on a clean machine. You want both.

Chezmoi is already installed through mise. Begin by checking what exists:

chezmoi --version
chezmoi doctor

Initialise a local source state:

chezmoi init
chezmoi source-path

Add only reviewed files:

chezmoi add ~/.zshrc
chezmoi add ~/.gitconfig
chezmoi add ~/.config/starship.toml
chezmoi add ~/.config/mise/config.toml
chezmoi add ~/.config/systemd/user/ssh-agent.service
chezmoi add ~/.ssh/config

Before adding ~/.ssh/config, check that it does not contain private hostnames, usernames, jump hosts, or company details that do not belong in the destination repository.

Never add private keys:

~/.ssh/id_ed25519
~/.ssh/id_rsa
~/.ssh/*_key

Public keys are not secret, but they are still identity metadata. Track them only when that is intentional.

Review the source state:

chezmoi status
chezmoi diff
chezmoi cd
git status --short

Then create a private remote repository, commit, and push from the chezmoi source directory.

The important daily direction is:

chezmoi diff                 # compare live files with managed source
chezmoi add ~/.zshrc # bring this reviewed live file into source
chezmoi apply --dry-run --verbose
chezmoi apply --verbose # apply source state to the home directory
chezmoi cd

Avoid a blind chezmoi re-add across everything after a long period of drift. Review file by file so a generated token, machine-only path, or temporary debugging line does not quietly become portable configuration.

Restore dotfiles without applying them blindly

On a new distribution:

chezmoi init https://github.com/<you>/<private-dotfiles-repository>.git
chezmoi diff

Read the diff. Then:

chezmoi apply --dry-run --verbose
chezmoi apply --verbose

Run the health checks later in this article. A successful chezmoi apply proves that files were written, not that the shell, Git, agent, and prompt all agree with them.

Encrypt only with a recovery plan

Chezmoi can encrypt files with age or GPG. Encryption is useful for selected secrets that genuinely need to travel with the dotfiles repository.

It is not useful if the only decryption key lives inside the WSL distribution being backed up by that same repository.

Before using chezmoi add --encrypt:

  1. configure the encryption provider;
  2. store the recovery key in an independent password manager or secure backup;
  3. test decryption on a clean environment;
  4. scan the repository for secrets before making it public or sharing it.

Whenever possible, store a reference to a secret manager rather than the secret itself.

Record enough state to explain the machine

A package inventory is not a perfect rebuild script. It is still extremely helpful when the question is, “What did this laptop have that the new one does not?”

Create an audit directory:

mkdir -p ~/.local/state/wsl-setup

Save a dated inventory that avoids obvious secret files but may still contain sensitive metadata:

stamp="$(date +%Y%m%d-%H%M%S)"
report="$HOME/.local/state/wsl-setup/inventory-$stamp.txt"

{
printf 'Captured: '; date --iso-8601=seconds
printf '\n## OS\n'
cat /etc/os-release
uname -a

printf '\n## Resources\n'
nproc
free -h
df -h /

printf '\n## APT manual packages\n'
apt-mark showmanual | sort

printf '\n## mise\n'
mise --version 2>/dev/null || true
mise ls 2>/dev/null || true

printf '\n## Git\n'
git --version
[[ -n "$(git config --global --get user.name 2>/dev/null || true)" ]] \
&& printf 'user.name=configured\n' \
|| printf 'user.name=unset\n'
[[ -n "$(git config --global --get user.email 2>/dev/null || true)" ]] \
&& printf 'user.email=configured\n' \
|| printf 'user.email=unset\n'
for key in \
init.defaultBranch \
fetch.prune \
push.autoSetupRemote \
rerere.enabled \
merge.conflictStyle \
diff.algorithm \
core.autocrlf \
gpg.format \
commit.gpgsign; do
value="$(git config --global --get "$key" 2>/dev/null || true)"
[[ -n "$value" ]] && printf '%s=%s\n' "$key" "$value"
done

printf '\n## VS Code extensions\n'
code --list-extensions --show-versions 2>/dev/null | sort || true

printf '\n## Docker\n'
docker version 2>/dev/null || true
docker context show 2>/dev/null || true
docker info --format 'RootDir={{.DockerRootDir}} Driver={{.Driver}} Logging={{.LoggingDriver}}' 2>/dev/null || true
printf '\n## systemd failures\n'
systemctl --failed --no-pager 2>/dev/null || true
} > "$report"

printf 'Wrote %s\n' "$report"

Read the report before committing or uploading it. The command deliberately avoids dumping the full Git configuration or every Docker context because either can contain sensitive values or internal endpoints. Package names, extension names, hostnames, and company-specific configuration can still reveal more than expected.

For project reproducibility, the more valuable records remain inside each project:

mise.toml
mise.lock
pyproject.toml
uv.lock
package.json
package-lock.json, pnpm-lock.yaml, or bun.lock
Dockerfile
compose.yaml
.devcontainer/
.gitattributes
.editorconfig
.pre-commit-config.yaml

A laptop manifest tells you what happened. Project configuration tells another machine what should happen.

Back up stateful services separately

A WSL export is a broad safety net. Application-aware backups are the reliable way to preserve stateful services.

For PostgreSQL, prefer a logical dump while the service is healthy:

pg_dump --format=custom --file=app.dump app_database

For a Compose-managed PostgreSQL container, adapt the service and database names:

docker compose exec -T db \
pg_dump -U app_user --format=custom app_database \
> app-$(date +%Y%m%d-%H%M%S).dump

For a generic non-database Docker volume, stop the writer first, then archive the volume read-only:

backup_dir="$HOME/backups/docker-volumes"
mkdir -p "$backup_dir"

docker run --rm \
--mount type=volume,src=my_volume,dst=/source,readonly \
--mount type=bind,src="$backup_dir",dst=/backup \
busybox:1.36 \
tar -C /source -czf /backup/my_volume-$(date +%Y%m%d-%H%M%S).tar.gz .

Do not use that generic tar recipe as a substitute for a database dump while the database is actively writing. A pile of database files is not necessarily a consistent database backup merely because tar looked pleased with itself.

Copy important backups off the internal drive and test restoration.

Test a WSL export by restoring it side by side

A backup that has never been restored is an optimistic file.

Use an empty test directory and a clearly temporary distribution name.

PowerShell:

$Backup = "D:\wsl-backups\Ubuntu-24.04-2026-08-20_120000.tar"
$TestName = "Ubuntu-restore-test"
$InstallDir = "D:\wsl-restore-tests\$TestName"

if (Test-Path $InstallDir) {
throw "Test install directory already exists: $InstallDir"
}

if (-not (Test-Path $Backup)) {
throw "Backup file does not exist: $Backup"
}

$HashFile = "$Backup.sha256.txt"
if (Test-Path $HashFile) {
$ExpectedHash = (Get-Content -Raw $HashFile).Trim()
$ActualHash = (Get-FileHash -Algorithm SHA256 $Backup).Hash
if ($ActualHash -ne $ExpectedHash) {
throw "SHA256 mismatch. Refusing to import a backup whose contents changed."
}
} else {
Write-Warning "No SHA256 sidecar was found. The restore test can continue, but integrity was not independently checked."
}

$Existing = @(wsl --list --quiet) |
ForEach-Object { ($_ -replace "`0", "").Trim() } |
Where-Object { $_ }

if ($TestName -in $Existing) {
throw "A distribution named $TestName already exists."
}

$InstallParent = Split-Path -Parent $InstallDir
New-Item -ItemType Directory -Force -Path $InstallParent | Out-Null

wsl --import $TestName $InstallDir $Backup --version 2
if ($LASTEXITCODE -ne 0) {
throw "Import failed."
}

wsl -d $TestName -- cat /etc/os-release
wsl -d $TestName -- id
wsl -d $TestName -- bash -lc "test -d /home && df -h /"

Launch it and inspect important files and services. An imported distribution may start as root if it does not contain a valid [user] default=... setting in /etc/wsl.conf. That is a restore-specific reason to add the user section.

Only after confirming the restore should you remove the test copy.

Disruptive, PowerShell:

$ExpectedTestName = "Ubuntu-restore-test"
$Actual = @(wsl --list --quiet) |
ForEach-Object { ($_ -replace "`0", "").Trim() } |
Where-Object { $_ }

if ($ExpectedTestName -notin $Actual) {
throw "Refusing to unregister a name that is not currently registered."
}

Write-Host "This permanently deletes the imported test distribution: $ExpectedTestName"
$Confirmation = Read-Host "Type the exact distribution name to continue"

if ($Confirmation -ne $ExpectedTestName) {
throw "Confirmation did not match. Nothing was removed."
}

wsl --unregister $ExpectedTestName

The confirmation is intentionally awkward. wsl --unregister is immediate and permanent. Autocomplete has no concept of regret.

Update in layers, with a check between layers

Do not turn monthly maintenance into one enormous command that updates Windows, WSL, Ubuntu, every runtime, every editor extension, every container image, and every project lockfile before lunch.

Update one owner at a time.

WSL platform

PowerShell:

wsl --version
wsl --update
wsl --version
wsl --status

Use pre-release WSL only to evaluate a feature you specifically need. As of this article’s review, Microsoft’s built-in WSL container feature is a public preview in pre-release WSL. It is interesting and may become a strong option, but a preview container stack is not the default recommendation for a daily development machine with working Docker projects.

Ubuntu packages

sudo apt update
apt list --upgradable
sudo apt upgrade
sudo apt autoremove --dry-run

Review autoremove before running it without --dry-run. A package marked automatic can still be part of your unusual but perfectly legitimate workflow.

Check after an update:

systemctl --failed --no-pager
sudo journalctl -p warning -b --no-pager | tail -n 100

mise-managed tools

mise outdated
mise upgrade --dry-run
mise upgrade
mise doctor

Project lockfiles should move through project pull requests, not a global housekeeping command. Run project tests after changing a runtime.

Hook versions

Inside each repository:

pre-commit autoupdate
git diff -- .pre-commit-config.yaml
pre-commit run --all-files

Docker

Docker Engine packages update through APT. Review release notes when crossing a major version and verify:

docker version
docker info
docker run --rm hello-world
docker run --rm busybox:1.36 nslookup github.com

Container images are project dependencies. Update them through reviewed Dockerfile or Compose changes, then rebuild and test. A blanket docker pull is not a dependency policy.

VS Code and extensions

Update the Windows application through its supported channel. In a connected WSL window, review extension updates in VS Code. Do not assume an extension update belongs on both sides simply because it has the same name.

Distribution upgrades

For an LTS-to-LTS move, a side-by-side distribution is often safer than an in-place do-release-upgrade:

  1. export the old distribution;
  2. install the new LTS under a different WSL name;
  3. apply dotfiles;
  4. install project tools from manifests;
  5. clone repositories rather than copying .git directories through Windows;
  6. restore databases and other state explicitly;
  7. run the verification pass;
  8. keep the old distribution until normal work succeeds.

This takes longer than one command and produces a much better answer to the question, “Can I rebuild this environment?”

Add a non-destructive health check

The setup should be inspectable without rereading the whole article.

Save this as ~/.local/bin/wsl-healthcheck:

#!/usr/bin/env bash

set -u

passes=0
warnings=0
failures=0

section() {
printf '\n## %s\n' "$1"
}

pass() {
passes=$((passes + 1))
printf '[PASS] %s\n' "$1"
}

warn() {
warnings=$((warnings + 1))
printf '[WARN] %s\n' "$1"
}

fail() {
failures=$((failures + 1))
printf '[FAIL] %s\n' "$1"
}

have() {
command -v "$1" >/dev/null 2>&1
}

section "WSL and operating system"

if grep -qi microsoft /proc/sys/kernel/osrelease 2>/dev/null; then
pass "Running under a Microsoft WSL kernel"
else
warn "Kernel release does not identify itself as WSL"
fi

printf 'Kernel: %s\n' "$(uname -r)"
printf 'Distribution: '
. /etc/os-release
printf '%s %s\n' "${NAME:-unknown}" "${VERSION_ID:-unknown}"

home_fs="$(findmnt -T "$HOME" -n -o FSTYPE 2>/dev/null || true)"
printf 'Home filesystem: %s\n' "${home_fs:-unknown}"

if [[ "$HOME" == /mnt/* ]]; then
fail "HOME is under a Windows-mounted filesystem: $HOME"
else
pass "HOME is on the Linux side"
fi

if [[ -d "$HOME/projects" ]]; then
projects_fs="$(findmnt -T "$HOME/projects" -n -o FSTYPE 2>/dev/null || true)"
printf 'Projects filesystem: %s\n' "${projects_fs:-unknown}"
if [[ "$HOME/projects" == /mnt/* ]]; then
fail "Projects directory is under /mnt"
else
pass "Projects directory is on the Linux side"
fi
else
warn "$HOME/projects does not exist"
fi

section "Resources and time"

printf 'Logical processors: %s\n' "$(nproc)"
free -h | sed -n '1,2p'
date --iso-8601=seconds

section "systemd"

pid1="$(ps -p 1 -o comm= 2>/dev/null | xargs)"
if [[ "$pid1" == systemd ]]; then
pass "systemd is PID 1"
else
fail "PID 1 is '$pid1', not systemd"
fi

if have systemctl; then
system_state="$(systemctl is-system-running 2>/dev/null || true)"
printf 'System state: %s\n' "${system_state:-unknown}"

failed_units="$(systemctl --failed --no-legend 2>/dev/null | sed '/^[[:space:]]*$/d' || true)"
if [[ -z "$failed_units" ]]; then
pass "No failed systemd units"
else
warn "Failed systemd units exist"
printf '%s\n' "$failed_units"
fi
fi

section "Command ownership"

for command_name in git zsh mise python node uv rg fd bat jq; do
if have "$command_name"; then
printf '%-10s %s\n' "$command_name" "$(command -v "$command_name")"
else
warn "$command_name is not installed or not on PATH"
fi
done

if [[ ":$PATH:" == *":/mnt/"* ]]; then
warn "Linux PATH contains one or more /mnt entries"
else
pass "Linux PATH contains no /mnt entries"
fi

if have zsh; then
for standard_name in grep find cat; do
identity="$(zsh -lic "whence -w $standard_name" 2>/dev/null | tail -n 1)"
if [[ "$identity" == "$standard_name: command" ]]; then
pass "$standard_name remains a command in Zsh"
else
warn "$standard_name resolves as: ${identity:-unknown}"
fi
done

startup_output="$(zsh -lic exit 2>&1 || true)"
if [[ -z "$startup_output" ]]; then
pass "Zsh startup is quiet"
else
warn "Zsh startup printed output"
printf '%s\n' "$startup_output"
fi
fi

if have mise; then
if mise doctor >/dev/null 2>&1; then
pass "mise doctor completed successfully"
else
warn "mise doctor reported a problem"
mise doctor || true
fi
fi

section "Git and SSH"

git_name="$(git config --global --get user.name 2>/dev/null || true)"
git_email="$(git config --global --get user.email 2>/dev/null || true)"

[[ -n "$git_name" ]] && pass "Git user.name is configured" || fail "Git user.name is missing"
[[ -n "$git_email" ]] && pass "Git user.email is configured" || fail "Git user.email is missing"

printf 'Git identity values are omitted from this report.\n'

if [[ -n "${SSH_AUTH_SOCK-}" && -S "${SSH_AUTH_SOCK-}" ]]; then
pass "SSH_AUTH_SOCK points to a live socket"
else
warn "SSH_AUTH_SOCK is missing or not a socket"
fi

if ssh-add -l >/dev/null 2>&1; then
pass "At least one SSH key is loaded"
else
warn "No SSH key is currently loaded"
fi

if [[ "$(git config --global --get gpg.format 2>/dev/null || true)" == ssh ]]; then
signing_key="$(git config --global --get user.signingkey 2>/dev/null || true)"
if [[ -n "$signing_key" && -f "$signing_key" ]]; then
pass "SSH commit-signing key exists"
else
fail "SSH signing is enabled but its public key is missing"
fi
fi

section "Windows interoperability and editor"

for command_name in explorer.exe clip.exe wsl.exe code; do
if have "$command_name"; then
printf '%-12s %s\n' "$command_name" "$(command -v "$command_name")"
else
warn "$command_name is not available"
fi
done

section "Docker"

if have docker; then
printf 'Context: %s\n' "$(docker context show 2>/dev/null || echo unknown)"
printf 'DOCKER_HOST: %s\n' "${DOCKER_HOST-unset}"

if docker info >/dev/null 2>&1; then
pass "Docker daemon is reachable"
printf 'Logging driver: %s\n' "$(docker info --format '{{.LoggingDriver}}' 2>/dev/null || echo unknown)"
printf 'Docker root: %s\n' "$(docker info --format '{{.DockerRootDir}}' 2>/dev/null || echo unknown)"
else
fail "Docker CLI exists but the daemon is not reachable"
fi
else
warn "Docker is not installed, which is fine if another container owner was chosen"
fi

section "Optional GPU"

if have nvidia-smi; then
gpu_smi="$(command -v nvidia-smi)"
elif [[ -x /usr/lib/wsl/lib/nvidia-smi ]]; then
gpu_smi="/usr/lib/wsl/lib/nvidia-smi"
else
gpu_smi=""
fi

if [[ -n "$gpu_smi" ]]; then
if "$gpu_smi" >/dev/null 2>&1; then
pass "nvidia-smi can reach the GPU"
else
warn "nvidia-smi exists but failed"
fi
else
printf 'No NVIDIA CLI detected.\n'
fi

section "Summary"
printf 'PASS=%d WARN=%d FAIL=%d\n' "$passes" "$warnings" "$failures"

if ((failures > 0)); then
exit 1
fi

Make it executable and run it:

chmod 0755 ~/.local/bin/wsl-healthcheck
wsl-healthcheck

Warnings describe optional tools, unloaded keys, or choices worth reviewing. Failures describe contradictions in the baseline. The script does not run setup, cleanup, or repair commands. Starting your interactive Zsh configuration can still refresh normal caches or fetch a missing plugin if your own .zshrc is written to do that, so "non-destructive" is more accurate than "mathematically read-only."

An agent should attach this output to its completion report rather than summarising every warning as “all good.”

The final verification pass

The health check is useful. Some important behaviours still deserve direct tests.

Windows side

PowerShell:

wsl --version
wsl --status
wsl --list --verbose
wsl --list --running

Confirm:

  • the intended distribution is WSL2;
  • the WSL version is current for your update channel;
  • there are no mystery distributions you are about to edit by accident.

Linux basics

whoami
pwd
findmnt -T "$HOME"
nproc
free -h
date --iso-8601=seconds
ps -p 1 -o pid=,comm=,args=
systemctl is-system-running || true
systemctl --failed --no-pager

Tool ownership

mise ls
mise doctor

command -v python node uv rg fd bat
type grep find cat

python --version
node --version
uv --version

Windows interoperability

command -v explorer.exe clip.exe wsl.exe code
printf 'clipboard test\n' | clip.exe
code --version

Git, SSH, and signing

git --version
git config --show-origin --get user.name >/dev/null && echo 'user.name is configured'
git config --show-origin --get user.email >/dev/null && echo 'user.email is configured'
git config --show-origin --get gpg.format || true
git config --show-origin --get commit.gpgsign || true
ssh-add -l
ssh -T git@github.com
git log --show-signature -1

Avoid attaching a full git config --list dump to an agent report. Git configuration can contain internal URLs, proxy settings, HTTP headers, or credential-helper commands even when the common settings are harmless.

Run the last command in a repository with a signed commit. Remember that GitHub’s SSH authentication test can print a success greeting and still return a non-zero status because shell access is disabled.

Docker

docker context show
docker info --format 'RootDir={{.DockerRootDir}} Driver={{.Driver}} Logging={{.LoggingDriver}}'
docker run --rm hello-world
docker run --rm busybox:1.36 nslookup github.com

Use the formatted summary in shared reports. A full docker info can reveal internal registry, proxy, hostname, and network details that are useful locally but unnecessary in a chat transcript.

For NVIDIA:

docker run --rm --gpus all ubuntu:24.04 nvidia-smi

Networking

Inside WSL:

cd ~/tmp/wsl-network-test
python3 -m http.server 8000 --bind 127.0.0.1

From Windows:

Invoke-WebRequest http://127.0.0.1:8000

Shell

for run in {1..5}; do
/usr/bin/time -f '%e seconds' zsh -lic exit
done

Record the median in your inventory. The goal is not to match somebody else’s laptop. The goal is to notice when your own shell doubles in cost later.

What an AI agent should report at the end

A useful completion report looks like this:

Target
- Windows host: <version/build>
- WSL version: <version>
- Distribution: <exact name and version>
- Chosen owners: runtimes=<...>, containers=<...>, editor=<...>

Backups
- WSL export: <path, size, SHA256>
- Config backups: <paths>
- Restore test: passed / not run, with reason

Changes
- Windows files changed: <paths>
- Linux files changed: <paths>
- APT packages installed or removed: <names>
- mise tools installed or changed: <names and versions>
- Services enabled or restarted: <names>

Disruptive actions
- <package removal, shutdown, group change, none>

Verification
- wsl-healthcheck: PASS=<n> WARN=<n> FAIL=<n>
- systemd: <state and failed units>
- Docker: <context, daemon, DNS, logging driver>
- Git and SSH: <identity present, key loaded, signing test>
- VS Code: <WSL connection and extensions>
- Networking: <Windows to WSL result>

Skipped or unresolved
- <Git identity, proxy CA, resource cap, GPU, company approval, none>

A report that says only “setup complete” is not a report. It is a positive thought.

Troubleshooting by symptom

.wslconfig change did nothing
Confirm the file is %UserProfile%\.wslconfig, run wsl --shutdown, then verify behaviour after relaunch.

A /etc/wsl.conf change did nothing
Check the file inside the intended distribution, shut WSL down from Windows, and inspect PID 1 after relaunch.

systemctl says degraded
Run systemctl --failed --no-pager; inspect the named unit rather than treating the word as a diagnosis.

WSL has only a few CPU threads
Check copied processors= limits, then compare PowerShell hardware inventory with nproc.

WSL uses less memory than expected
Check memory= in .wslconfig, then compare with free -h.

python, node, or another runtime resolves unexpectedly
Run type -a <command>, mise current, and inspect PATH for old manager activation lines.

command not found after disabling Windows PATH injection
Add an explicit launcher or restore appendWindowsPath=true; do not append all of C: by hand.

code . opens a normal Windows folder window
Confirm the WSL extension is installed and the status indicator says WSL: <distribution>.

VS Code has no IntelliSense
Check whether the language extension is installed in WSL rather than only under Local — Installed.

VS Code Server install fails behind a proxy
Compare the Windows and WSL proxy paths, then install the organisation’s CA instead of disabling TLS.

Zsh prints text on startup
Run zsh -lic exit, remove banners and unguarded commands, then profile with zprof.

Completion disappeared after installing a tool
Remove only ~/.zcompdump*, reopen Zsh, and check compaudit.

grep, find, or cat behaves strangely
Run type <command> and remove aliases that replace standard commands.

ssh-add -l is empty after reboot
Reload the passphrase-protected key; the agent process survives restarts better than its decrypted memory does.

GitHub says a signed commit is unverified
Add the public key in the signing role as well as authentication, and check user.signingkey.

Docker says permission denied on its socket
Start a new login session after joining the docker group, then verify id -nG.

Docker CLI exists but cannot reach the daemon
Check docker context show, DOCKER_HOST, systemctl status docker, and whether Desktop or native Engine owns the socket.

Pulls fail mentioning docker-credential-desktop.exe
Inspect ~/.docker/config.json; remove only a confirmed stale Desktop credential setting and configure a Linux helper.

Docker starts but containers cannot resolve names
Run the BusyBox nslookup test, then inspect WSL DNS and VPN behaviour.

A published container port is reachable from the LAN unexpectedly
Bind it to 127.0.0.1, inspect Docker publishing, and review Windows plus Hyper-V firewall rules.

localhost hangs but 127.0.0.1 works
The client may prefer IPv6; use explicit IPv4 loopback where that is the intended path.

A corporate hostname fails but public DNS works
Do not hardcode a public resolver; inspect DNS tunnelling, VPN routes, and company DNS requirements.

A shell script says not found although the file exists
Inspect the shebang and line endings with file and sed -n l; add .gitattributes.

Git sees case-only filename changes differently across machines
Use a two-step git mv, and enable the check-case-conflict hook.

WSL disk usage keeps growing
Start with du, docker system df -v, and journal usage; do not assume VHD compaction is the first fix.

Deleted Linux files did not shrink the Windows VHD
Verify current sparse support and backups before wsl --manage ... --set-sparse true; space reclaim is not always immediate.

Files under /mnt/c have odd permissions or poor performance
Move active Linux projects under ~/projects; boundary access is for exchange, not the hot path.

Time is wrong
Compare date, timedatectl, and Windows time; do not paste another person's timezone.

What changed on my machine

The original audit that produced this guide was measured on one laptop with an Intel i5–12500H, 32 GB of RAM, and an RTX 3050.

Zsh startup
Before: 1.25 s
After: 0.20 s

CPU threads visible to WSL
Before: 4 of 16
After: 12 of 16

RAM visible under the chosen cap
Before: 7.8 GB
After: about 15 GB

Docker
Before: broken Desktop integration
After: native Engine with GPU support

Old manager disk space reclaimed
Before: 0
After: 2.27 GB

Clock
Before: 5 h 30 m wrong
After: correct

VS Code extensions installed in WSL
Before: 1
After: 23

I’d actually make the Before and After labels bold in Medium:

Before: 1.25 s
After: 0.20 s

Those are measurements from one machine, not targets.

The most important row is the CPU one. The old configuration had been copied from another laptop and had quietly limited builds to a quarter of the processor for months. Nothing crashed. There was no red warning. The computer simply performed worse with great professionalism.

That is why the verification commands matter more than the sample values.

The everyday command set

Once configured, the machinery mostly gets out of the way:

z my-project          # jump to a frequently used project
code . # open the project in a WSL-connected VS Code window
gs # concise Git status
lg # lazygit, if installed
dc up -d # start Compose services
uv run pytest # run Python tests in the project environment
pre-commit run -a # run repository hooks across all files
k9s # inspect a Kubernetes cluster, if that is your work
wopen # open the current directory in Explorer
sysinfo # system information on demand
wsl-healthcheck # run the non-destructive setup check

The setup can be detailed. Using it should not be.

Cheat sheet

The five rules

  1. Code lives on the Linux filesystem. Use ~/projects, not /mnt/c, for Linux development.
  2. One owner per tool. Do not let Windows, APT, mise, an old version manager, and Docker Desktop all compete for the same command.
  3. Start with defaults. Add WSL settings only to solve a measured problem or record an intentional policy.
  4. Back up before changing foundations. Export WSL, back up edited files, and test a restore.
  5. Configuration is a claim. Run the command that checks it.

Daily navigation

Open the current directory in VS Code
code .

Open it in Explorer
wopen or explorer.exe .

Convert the current path for Windows
wslpath -w "$PWD"

Fuzzy history
Ctrl+R

Fuzzy file insertion
Ctrl+T

Fuzzy directory change
Alt+C

Jump to a frequent directory
z <fragment>

Copy to Windows clipboard
clip.exe

Open a URL in Windows
wslview https://...

Shut down all WSL distributions
wsl-shutdown

Versions and projects

mise current
mise ls
mise doctor
mise install

uv sync
uv run pytest
pre-commit run --all-files

Docker

docker context show
docker ps
docker compose up -d
docker compose logs -f
docker compose down

docker system df -v

Do not add docker system prune -af --volumes to the cheat sheet. Some commands are better remembered slowly.

Git and SSH

gs
gd
git log --graph --decorate --oneline --all

ssh-add -l
ssh -T git@github.com
git log --show-signature -1

Health and backup

wsl-healthcheck
chezmoi diff
chezmoi apply --dry-run --verbose
wsl --version
wsl --status
wsl --list --verbose

D:\wsl-backups\Backup-Wsl.ps1 `
-Distro "Ubuntu-24.04" `
-BackupDir "D:\wsl-backups"

A calm maintenance sequence

sudo apt update
apt list --upgradable

mise outdated
pre-commit autoupdate
chezmoi diff
docker system df -v
wsl-healthcheck

Review each category before applying changes.

The design principles worth keeping

The exact prompt, shell, runtime manager, and container daemon can change. The architecture survives them.

Develop where the tools are native. Linux tools should operate on Linux files. Windows tools can access those files through supported integration when needed.

Give each concern one owner. Duplicate healthy installations create unhealthy systems.

Prefer defaults until evidence argues otherwise. A smaller configuration has fewer stale assumptions and fewer future migrations.

Make changes reversible. Back up files, simulate removals, quarantine old managers, and test side by side.

Keep project policy with the project. Runtime constraints, lockfiles, line endings, hooks, editor recommendations, and container definitions should travel with the repository.

Treat hooks and automatic activation as code execution. Read .envrc, mise.toml, shell plugins, dev-container definitions, and repository setup scripts before trusting them.

Separate convenience from enforcement. Local hooks are useful. CI is the control. Docker group membership is convenient. It is also privileged.

Secure the connection instead of disabling the check. Install the correct CA, configure the proxy, bind the intended interface, and add a narrow firewall rule.

Back up intent and state. Dotfiles describe the environment. WSL exports and application backups preserve what cannot be recreated cheaply.

Verify behaviour. CPU counts, DNS, Docker pulls, signatures, networking, shell output, and restore tests all have commands that can prove the claim.

Closing thoughts

I wanted Windows to remain Windows.

I also wanted development to feel like Linux.

WSL2 became a very good boundary between those goals once I stopped treating it as a Bash window attached to Windows and started treating it as the development machine.

The biggest improvements did not come from adding more tools. They came from removing ambiguity:

  • repositories moved off C:;
  • runtimes got one owner;
  • Docker got one daemon;
  • Windows interoperability became explicit;
  • shell startup stopped performing a variety show;
  • project policy moved into version control;
  • backups gained a restore test;
  • configuration gained verification.

You may prefer Docker Desktop. You may use Fish, Nushell, Neovim, JetBrains Gateway, Podman, Dev Containers, or the emerging built-in WSL container stack. Those can all fit a good architecture.

The test is not whether your dotfiles match mine.

The test is whether you can explain which system owns each part, update it without crossing your fingers, hand the procedure to another person or an agent, and recover when the laptop decides it would prefer a career in abstract sculpture.

Take one sentence from the article:

Configuration is a claim. Run the command that checks it.

Then go play something. That was the whole point.

References

Primary documentation used for this revision:

Technical review date: 20 August 2026. The examples target current Store-delivered WSL 2.7.x, Ubuntu 24.04 LTS or 26.04 LTS, and supported upstream installation methods. Versions, defaults, previews, extension identifiers, and package repositories move. Inventory first and verify yours.

Related Posts

Making Windows Disappear: A Linux Developer’s Guide to a Production-Grade WSL2 Setup

I’ve been a Linux user my entire career. My development workflow lives and breathes in the terminal, Zsh, Python environments, Docker, AWS, Git, the whole ecosystem. When my office handed me a Windows

windows-11ubuntuwsl-2+2 more
Read More

Design & Developed by Ramxcodes
© 2026. All rights reserved.