Must doThe first hour
Do these in order on a fresh machine. Ticks are saved in this browser only.
VM resources.wslconfig
One file for the whole WSL2 virtual machine. It lives on the Windows side and applies to every distro.
# Applies after: wsl --shutdown (wait ~8 s, then reopen a terminal) [wsl2] memory=16GB # ~50–75% of your RAM processors=8 # all threads, or all minus 2 swap=8GB # ~25–50% of memory networkingMode=mirrored # WSL shares Windows' IPs; localhost works both ways dnsTunneling=true # fixes DNS on VPNs and odd networks firewall=true # Windows firewall rules apply to WSL autoProxy=true # use the Windows proxy settings guiApplications=true # WSLg: run Linux GUI apps nestedVirtualization=true # KVM / Android emulators inside WSL [experimental] autoMemoryReclaim=dropcache # give idle RAM back to Windows sparseVhd=true # virtual disk shrinks when you delete files hostAddressLoopback=true # Windows can reach services bound to WSL's LAN IP
| Your RAM | memory | swap |
|---|---|---|
| 16 GB | 8–10GB | 4GB |
| 32 GB | 16–24GB | 8GB |
| 64 GB | 32–48GB | 8–16GB |
Start menu → WSL Settings is a GUI for this same file. Handy for checking which keys your WSL version supports.
sparseVhd only applies to distros created after you set it. For an existing one, run wsl --manage Ubuntu --set-sparse true while WSL is shut down.
Per distro/etc/wsl.conf
Settings for one distro, edited from inside Linux.
[boot] systemd=true [user] default=yourname # also used when you restore a backup [interop] enabled=true appendWindowsPath=false # keeps Windows' PATH out: faster shell, no npm/node mixups [automount] enabled=true options="metadata,umask=22,fmask=11" # real Linux permissions on /mnt/c
With appendWindowsPath=false, code, explorer.exe and powershell.exe are no longer found. Add the few you need back by hand (see Shell).
Make a backup copy before editing: sudo cp /etc/wsl.conf /etc/wsl.conf.bak.
The rule that matters mostWhere files live
Linux files sit on an ext4 virtual disk. /mnt/c is the Windows drive, reached over a network-style bridge.
| ~/dev (Linux disk) | /mnt/c (Windows disk) | |
|---|---|---|
| pnpm install, builds, git status | Native speed | 5–20× slower |
| File watchers (Vite, Next, Metro) | Instant reload | Often miss changes |
| Permissions, symlinks, exec bits | Correct | Emulated, git noise |
| Docker bind mounts | Fast | Slow |
| Line endings | LF | CRLF slips in |
~/dev/ ├── projects/ # repos, optionally projects/<org>/<repo> ├── infra/ # shared docker-compose (Postgres, Redis…) └── sandbox/ # throwaway experiments
- From Explorer:
\\wsl.localhost\Ubuntu\home\<you>\dev. Pin it to Quick Access. - Use Windows Git and Node on Windows files and WSL Git and Node on WSL files. Mixing them breaks lockfiles and line endings.
/mnt/cis fine for downloads, design assets and one-off copies:cp /mnt/c/Users/<you>/Downloads/x.zip ~/dev/sandbox/.
PerformanceKernel & limits
Defaults are tuned for small servers, not monorepos with 200k files.
fs.inotify.max_user_watches=524288 # file watchers on big repos fs.inotify.max_user_instances=1024 fs.file-max=2097152 vm.max_map_count=262144 # Elasticsearch, some JVM tools vm.swappiness=10 # prefer RAM over swap
sudo sysctl --system # open files (login shells and systemd services) printf '* soft nofile 65536\n* hard nofile 524288\n' | sudo tee /etc/security/limits.d/99-dev.conf sudo mkdir -p /etc/systemd/system.conf.d printf '[Manager]\nDefaultLimitNOFILE=65536:524288\n' | sudo tee /etc/systemd/system.conf.d/99-nofile.conf # TRIM lets the sparse disk actually shrink sudo systemctl enable --now fstrim.timer
Defender exclusions
Defender scans the whole virtual disk file on every write. Excluding it makes installs and builds noticeably faster. Files inside Linux aren't scanned by Defender either way.
$paths = @("$env:LOCALAPPDATA\wsl") +
(Resolve-Path "$env:LOCALAPPDATA\Packages\CanonicalGroupLimited.Ubuntu*\LocalState" -EA 0).Path
$procs = "vmmemWSL", "vmmem", "wslhost.exe", "wslservice.exe"
$paths | ? { $_ } | % { Add-MpPreference -ExclusionPath $_ }
$procs | % { Add-MpPreference -ExclusionProcess $_ }
(Get-MpPreference).ExclusionPath # verifyMirrored modeNetworking
With networkingMode=mirrored, WSL uses the same IP addresses as Windows. Ports opened in WSL show up on Windows and on your LAN.
localhost:3000in a Windows browser reaches a dev server in WSL, and WSL reaches Windows services onlocalhosttoo.- Phones on the same Wi-Fi can open your dev server at the PC's LAN IP. Good for Expo and responsive testing.
- VPNs work better with
dnsTunneling=trueandautoProxy=true.
Mirrored mode exposes your databases to the LAN too. Bind dev services to loopback: ports: ["127.0.0.1:5432:5432"], and run dev servers with --host 127.0.0.1 unless you need your phone to reach them.
Letting LAN devices in
If a phone can't reach a WSL port, the Hyper-V firewall is blocking it. Open just that port:
New-NetFirewallHyperVRule -Name "WSL-8081" -DisplayName "WSL Metro 8081" `
-Direction Inbound -VMCreatorId '{40E0AC32-46A5-438A-A0B2-2B479E8F2E90}' `
-Protocol TCP -LocalPorts 8081ss -tlnp # 0.0.0.0 / [::] = reachable from LAN; 127.0.0.1 = local onlyContainersDocker
Two good options. Pick one.
| Docker Engine in WSL | Docker Desktop | |
|---|---|---|
| Cost | Free | Paid for larger companies |
| RAM overhead | Lowest | Extra VM and UI |
| GUI | lazydocker in the terminal | Desktop app |
| Setup | apt repo, a few commands | Installer, enable WSL integration |
curl -fsSL https://get.docker.com | sh
sudo usermod -aG docker $USER # then close and reopen the terminal
docker run --rm hello-world{
"log-driver": "local",
"log-opts": { "max-size": "20m", "max-file": "3" },
"features": { "buildkit": true, "containerd-snapshotter": true },
"builder": { "gc": { "enabled": true, "defaultKeepStorage": "30GB" } }
}Capped logs and a build-cache limit keep the virtual disk from quietly growing to 200 GB.
name: devinfra
services:
postgres:
image: postgres:17-alpine
restart: unless-stopped
environment: { POSTGRES_USER: dev, POSTGRES_PASSWORD: dev, POSTGRES_DB: dev }
ports: ["127.0.0.1:5432:5432"]
volumes: [pgdata:/var/lib/postgresql/data]
redis:
image: redis:7-alpine
restart: unless-stopped
command: ["redis-server", "--appendonly", "yes"]
ports: ["127.0.0.1:6379:6379"]
volumes: [redisdata:/data]
volumes: { pgdata: {}, redisdata: {} }Add alias devinfra="docker compose -f ~/dev/infra/docker-compose.yml", then devinfra up -d. Windows apps like TablePlus or DBeaver connect to localhost:5432.
Node, Python & friendsRuntimes
mise is one version manager for everything. It reads .nvmrc, .node-version and mise.toml, and switches when you cd.
curl https://mise.run | sh echo 'eval "$(~/.local/bin/mise activate zsh)"' >> ~/.zshrc # or bash exec $SHELL mise use -g node@lts pnpm@latest bun@latest python@3.13 uv@latest mise settings add idiomatic_version_file_enable_tools node # respect .nvmrc
mise ls # what's installed and active mise use node@22 # pin for THIS project (writes mise.toml) mise install # install what the project asks for mise up # upgrade everything
- pnpm for your own projects. It's fast, and its hard links work well on ext4.
- bun runs TypeScript directly:
bun run script.ts. - uv for Python:
uv run script.py,uv add requests. - mise also installs CLIs:
mise use -g atuin dust lazydocker 1password.
VS Code · Cursor · JetBrainsEditors
The editor UI runs on Windows. The terminal, Git, extensions and language servers run inside Linux.
- Install the editor on Windows and add the WSL extension. Don't install it inside Linux.
- From a project folder in WSL, run
code .orcursor .. The status bar shows WSL: Ubuntu. - JetBrains IDEs: use Remote Development → WSL rather than opening
\\wsl.localhostpaths directly. - Install a Nerd Font (e.g. JetBrainsMono Nerd Font) on Windows and select it in Windows Terminal and the editor so prompt icons render.
Make Ubuntu the default Windows Terminal profile and set its command to wsl.exe -d Ubuntu --cd ~ so tabs open in your Linux home, not /mnt/c/Users/….
Daily driverShell
zsh, a fast prompt, and modern replacements for old Unix tools. Measure startup with time zsh -i -c exit and aim for under 0.2 s.
sudo apt install -y zsh zsh-autosuggestions zsh-syntax-highlighting \ fzf zoxide direnv eza bat fd-find ripgrep jq btop tmux git-delta lazygit chsh -s $(which zsh) curl -sS https://starship.rs/install.sh | sh -s -- -b ~/.local/bin
source ~/.config/shell/common.sh # aliases + env shared with bash autoload -Uz compinit && compinit -C eval "$(mise activate zsh)" eval "$(starship init zsh)" eval "$(direnv hook zsh)" source /usr/share/doc/fzf/examples/key-bindings.zsh eval "$(atuin init zsh --disable-up-arrow)" eval "$(zoxide init zsh --cmd cd)" # late: after other cd/chpwd hooks source /usr/share/zsh-autosuggestions/zsh-autosuggestions.zsh source /usr/share/zsh-syntax-highlighting/zsh-syntax-highlighting.zsh # must be last
_win=/mnt/c/Windows _winuser=$(wslpath "$($_win/System32/cmd.exe /c 'echo %USERPROFILE%' 2>/dev/null | tr -d '\r')") _apps="$_winuser/AppData/Local/Programs" [ -d "$_apps/Microsoft VS Code/bin" ] && export PATH="$PATH:$_apps/Microsoft VS Code/bin" [ -d "$_apps/cursor/resources/app/bin" ] && export PATH="$PATH:$_apps/cursor/resources/app/bin" alias open="$_win/explorer.exe" # open . → Explorer here alias clip="$_win/System32/clip.exe" # echo hi | clip alias pwsh="$_win/System32/WindowsPowerShell/v1.0/powershell.exe" alias winget="$_winuser/AppData/Local/Microsoft/WindowsApps/winget.exe" alias bat=batcat fd=fdfind # Ubuntu package names alias ll="eza -la --git --group-directories-first" alias dc="docker compose" lg=lazygit unset _win _winuser _apps
The cmd.exe lookup costs ~50 ms. Once it works, replace _winuser with your literal path to keep startup fast.
| Keys / command | What it does |
|---|---|
| → on grey text | Accept autosuggestion from history |
| Ctrl+R | Fuzzy history search (atuin: folder, exit code, duration) |
| Ctrl+T / Alt+C | Fuzzy-pick a file path / jump into a subfolder |
cd proj | zoxide: jump to your most-used folder matching "proj" |
.envrc + direnv allow | Per-project env vars, loaded on cd in and unloaded on the way out |
tmux new -s app / tmux a -t app | Sessions that survive closing the terminal tab |
IdentityGit & SSH
Create a key per machine inside WSL. Use it for GitHub auth and commit signing, so you get the "Verified" badge without GPG.
ssh-keygen -t ed25519 -C "you@example.com" -f ~/.ssh/id_ed25519
git config --global user.name "Your Name"
git config --global user.email "you@example.com"
git config --global gpg.format ssh
git config --global user.signingkey ~/.ssh/id_ed25519.pub
git config --global commit.gpgsign true
git config --global tag.gpgsign true
echo "you@example.com $(cat ~/.ssh/id_ed25519.pub)" > ~/.ssh/allowed_signers
git config --global gpg.ssh.allowedSignersFile ~/.ssh/allowed_signers
git config --global init.defaultBranch main
git config --global pull.rebase true
git config --global rebase.autoStash true
git config --global push.autoSetupRemote true
git config --global core.autocrlf input # never commit CRLF
git config --global core.pager delta
git config --global delta.navigate truegh auth login # GitHub.com → SSH → skip key upload
gh auth refresh -s admin:public_key,admin:ssh_signing_key
gh ssh-key add ~/.ssh/id_ed25519.pub --title "$(hostname) WSL" --type authentication
gh ssh-key add ~/.ssh/id_ed25519.pub --title "$(hostname) WSL signing" --type signing
ssh -T git@github.comAdd AddKeysToAgent yes under Host * in ~/.ssh/config, and give the key a passphrase: ssh-keygen -p -f ~/.ssh/id_ed25519.
React Native · Expo · AndroidMobile dev
Run Android Studio and the emulator on Windows, where the GPU is. Run the JS toolchain in WSL and connect the two.
export ADB_SERVER_SOCKET=tcp:127.0.0.1:5037 # works because of mirrored localhost- Start the emulator from Android Studio.
adb devicesin WSL lists it. npx expo start: a phone on the same Wi-Fi connects directly. If it can't, open port 8081 (see Networking).- Mirror a real phone on Windows with scrcpy (USB debugging on).
eas buildandeas submitrun in the cloud. iOS simulators need a Mac, so test on a real iPhone with Expo Go.
Before you need itBackups
Your whole distro is one ext4.vhdx file. If it gets corrupted or someone runs wsl --unregister, everything in it is gone.
- Push to git often. That's the real backup.
- Keep secrets in a password manager too (
.env,.dev.vars,.envrc). The 1Password CLI can render them:op inject -i .env.tpl -o .env. - Snapshot the distro after big changes, and copy snapshots off C:.
$dir = "$env:USERPROFILE\WSL-Backups"; mkdir $dir -Force | Out-Null wsl --terminate Ubuntu wsl --export Ubuntu "$dir\Ubuntu-$(Get-Date -f yyyy-MM-dd_HHmm).tar.gz" --format tar.gz
wsl --import Ubuntu-Restored C:\WSL\Ubuntu-Restored "$env:USERPROFILE\WSL-Backups\Ubuntu-<date>.tar.gz" wsl -d Ubuntu-Restored
Imported distros log in as root unless [user] default= is set in /etc/wsl.conf. It's included in the snapshot if you set it before exporting.
Keep it healthyMaintenance
sudo apt update && sudo apt full-upgrade # system, docker, gh mise up # runtimes and CLIs npm i -g wrangler@latest eas-cli@latest # global npm CLIs, if you use them docker system df && docker system prune # reclaim image/cache space dust -d 2 ~ # what's eating the disk
wsl --update # new WSL + kernel wsl --version wsl -l -v # distros and state wsl --shutdown # apply .wslconfig, free all WSL RAM
free -h && nproc # matches .wslconfig? systemctl is-system-running # "running", not "degraded" systemctl --failed ip -br addr # mirrored: same IPs as Windows docker ps # works without sudo?
Hours savedGotchas
systemctl is-system-runningsays degraded- Usually
systemd-binfmt.service, which can't write to WSL's read-only binfmt_misc. Runsudo systemctl mask systemd-binfmt.service. Windows.exeinterop keeps working. .wslconfigchanges do nothing- Run
wsl --shutdown(not just closing tabs), wait about 8 seconds, then reopen. Check that the file is named.wslconfigand not.wslconfig.txt. - Hot reload doesn't fire
- The project is on
/mnt/c, or inotify limits are too low. Move it to~/devand raise the limits. sudo -Efails or is ignored- Newer Ubuntu ships sudo-rs, which doesn't support everything classic sudo does. Pass variables explicitly:
sudo env FOO=bar cmd. powershell.exe: command not found- Expected with
appendWindowsPath=false. Use the full path or an alias:/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe. nodeornpmis the Windows one- The Windows PATH leaked in, and
which nodeshows/mnt/c/…. SetappendWindowsPath=false, or uninstall Node on Windows. - Clock is wrong after sleep, TLS errors
- Clock drift.
sudo hwclock -sorsudo systemctl restart systemd-timesyncd.wsl --shutdownalways fixes it. - DNS stops resolving on VPN
- Turn on
dnsTunneling=true. Don't hand-edit/etc/resolv.conf, because WSL regenerates it. - The vhdx keeps growing
- Turn on sparse mode for the distro, keep
fstrim.timeron, cap Docker logs and build cache, and rundocker system pruneregularly. vmmemeats all my RAM- That's Linux page cache. Use
autoMemoryReclaim=dropcache, a sanememory=cap, orwsl --shutdownwhen you're done. - Line endings shown as changed on every file
- Something touched the repo from Windows.
git config --global core.autocrlf inputin WSL, and add a.gitattributeswith* text=auto eol=lf. - GPU / CUDA not found
- Install only the Windows NVIDIA driver. Never install a Linux GPU driver inside WSL.
nvidia-smishould work out of the box.
Small things, big comfortTips & tricks
explorer.exe . opens the current Linux folder in Windows.
cat key.pub | clip.exe. In tmux copy mode, pipe to clip.exe.
wslpath -w . gives a Windows path. wslpath 'C:\Users' goes the other way.
wsl -e htop, wsl --cd ~ -e pnpm dev, or wsl ls -la.
Install wslu and set export BROWSER=wslview so CLI logins open your Windows browser.
WSLg runs GUI apps like gitk or meld as normal Windows windows.
cloudflared tunnel --url http://localhost:3000 for webhooks and demos.
wsl --install -d Debian or import a tarball to try risky setups away from your main box.
lazydocker shows logs, stats and restarts with single keys.
Starship shows duration for anything over 2 s. atuin stats shows what you type most.
Start a line with a space (HIST_IGNORE_SPACE) and atuin skips it. atuin search --delete "text" removes past entries.
PowerToys FancyZones: terminal, editor and browser in fixed zones. Command Palette on Win+Alt+Space.
Windows Terminal keys
| Ctrl+Shift+T | New tab (default profile) |
| Ctrl+Shift+Space | Profile menu |
| Alt+Shift+D | Split pane |
| Alt+arrows | Move between panes |
| Ctrl+, | Settings |
What changed, newest firstChangelog
-
v1.2.0
- Version badge and this changelog.
- Light theme by default; dark is one click away and remembered.
-
v1.1.0
- Light/dark theme toggle.
- Published at wsl2-cheatsheet.dedic.dev.
-
v1.0.0
- First release: setup checklist, configs, networking, Docker, runtimes, editors, shell, Git, mobile dev, backups, gotchas and tips.