WSL2 Setup Cheatsheet

Windows 11 · WSL2 · Ubuntu · for web & mobile devs

WSL2 Setup
Cheat/sheet

A tuned Linux dev box inside Windows: what to set up first, the config files that matter, and the gotchas that cost hours. Copy, paste, adjust the numbers to your machine.

Where each command runs

PowerShell Windows side. Some blocks need an admin window.
WSL Inside Ubuntu (bash or zsh).
File A config file to create or edit.

Must doThe first hour

Do these in order on a fresh machine. Ticks are saved in this browser only.

0 / 12 done

VM resources.wslconfig

One file for the whole WSL2 virtual machine. It lives on the Windows side and applies to every distro.

File%USERPROFILE%\.wslconfig
# 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 RAMmemoryswap
16 GB8–10GB4GB
32 GB16–24GB8GB
64 GB32–48GB8–16GB
Tip

Start menu → WSL Settings is a GUI for this same file. Handy for checking which keys your WSL version supports.

Gotcha

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.

File/etc/wsl.conf (sudo)
[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
Gotcha

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 statusNative speed5–20× slower
File watchers (Vite, Next, Metro)Instant reloadOften miss changes
Permissions, symlinks, exec bitsCorrectEmulated, git noise
Docker bind mountsFastSlow
Line endingsLFCRLF slips in
WSLA layout that scales
~/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/c is 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.

File/etc/sysctl.d/99-dev.conf
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
WSLApply, raise open-file limits, enable weekly TRIM
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.

PowerShell · AdminExclude the WSL disk and processes
$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   # verify

Mirrored 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:3000 in a Windows browser reaches a dev server in WSL, and WSL reaches Windows services on localhost too.
  • 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=true and autoProxy=true.
Gotcha

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:

PowerShell · AdminAllow inbound TCP 8081 to WSL (Metro / Expo)
New-NetFirewallHyperVRule -Name "WSL-8081" -DisplayName "WSL Metro 8081" `
  -Direction Inbound -VMCreatorId '{40E0AC32-46A5-438A-A0B2-2B479E8F2E90}' `
  -Protocol TCP -LocalPorts 8081
WSLWhat's listening, and on which interface?
ss -tlnp                     # 0.0.0.0 / [::] = reachable from LAN; 127.0.0.1 = local only

ContainersDocker

Two good options. Pick one.

Docker Engine in WSLDocker Desktop
CostFreePaid for larger companies
RAM overheadLowestExtra VM and UI
GUIlazydocker in the terminalDesktop app
Setupapt repo, a few commandsInstaller, enable WSL integration
WSLNative Engine (official convenience script) and no-sudo access
curl -fsSL https://get.docker.com | sh
sudo usermod -aG docker $USER     # then close and reopen the terminal
docker run --rm hello-world
File/etc/docker/daemon.json (restart: sudo systemctl restart docker)
{
  "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.

File~/dev/infra/docker-compose.yml: shared Postgres + Redis
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.

WSLInstall mise and a typical web/mobile toolchain
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
WSLDaily use
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 . or cursor .. The status bar shows WSL: Ubuntu.
  • JetBrains IDEs: use Remote Development → WSL rather than opening \\wsl.localhost paths 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.
Tip

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.

WSLThe stack
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
File~/.zshrc: init order matters
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
File~/.config/shell/common.sh: Windows interop without the Windows PATH
_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
Tip

The cmd.exe lookup costs ~50 ms. Once it works, replace _winuser with your literal path to keep startup fast.

Keys / commandWhat it does
→ on grey textAccept autosuggestion from history
Ctrl+RFuzzy history search (atuin: folder, exit code, duration)
Ctrl+T / Alt+CFuzzy-pick a file path / jump into a subfolder
cd projzoxide: jump to your most-used folder matching "proj"
.envrc + direnv allowPer-project env vars, loaded on cd in and unloaded on the way out
tmux new -s app / tmux a -t appSessions 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.

WSLKey, identity, signing, sane defaults
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 true
WSLUpload the key to GitHub (auth + signing)
gh 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.com
Tip

Add 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.

File~/.config/shell/common.sh: WSL adb talks to the Windows adb server
export ADB_SERVER_SOCKET=tcp:127.0.0.1:5037    # works because of mirrored localhost
  • Start the emulator from Android Studio. adb devices in 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 build and eas submit run 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.

  1. Push to git often. That's the real backup.
  2. Keep secrets in a password manager too (.env, .dev.vars, .envrc). The 1Password CLI can render them: op inject -i .env.tpl -o .env.
  3. Snapshot the distro after big changes, and copy snapshots off C:.
PowerShellSnapshot (stops the distro: terminals, editors, containers)
$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
PowerShellRestore next to the current distro, to test or rescue files
wsl --import Ubuntu-Restored C:\WSL\Ubuntu-Restored "$env:USERPROFILE\WSL-Backups\Ubuntu-<date>.tar.gz"
wsl -d Ubuntu-Restored
Gotcha

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

WSLUpdates
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
PowerShellWSL itself
wsl --update          # new WSL + kernel
wsl --version
wsl -l -v             # distros and state
wsl --shutdown        # apply .wslconfig, free all WSL RAM
WSLHealth check after a change
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-running says degraded
Usually systemd-binfmt.service, which can't write to WSL's read-only binfmt_misc. Run sudo systemctl mask systemd-binfmt.service. Windows .exe interop keeps working.
.wslconfig changes do nothing
Run wsl --shutdown (not just closing tabs), wait about 8 seconds, then reopen. Check that the file is named .wslconfig and not .wslconfig.txt.
Hot reload doesn't fire
The project is on /mnt/c, or inotify limits are too low. Move it to ~/dev and raise the limits.
sudo -E fails 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.
node or npm is the Windows one
The Windows PATH leaked in, and which node shows /mnt/c/…. Set appendWindowsPath=false, or uninstall Node on Windows.
Clock is wrong after sleep, TLS errors
Clock drift. sudo hwclock -s or sudo systemctl restart systemd-timesyncd. wsl --shutdown always 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.timer on, cap Docker logs and build cache, and run docker system prune regularly.
vmmem eats all my RAM
That's Linux page cache. Use autoMemoryReclaim=dropcache, a sane memory= cap, or wsl --shutdown when you're done.
Line endings shown as changed on every file
Something touched the repo from Windows. git config --global core.autocrlf input in WSL, and add a .gitattributes with * text=auto eol=lf.
GPU / CUDA not found
Install only the Windows NVIDIA driver. Never install a Linux GPU driver inside WSL. nvidia-smi should work out of the box.

Small things, big comfortTips & tricks

Open Explorer here

explorer.exe . opens the current Linux folder in Windows.

Clipboard both ways

cat key.pub | clip.exe. In tmux copy mode, pipe to clip.exe.

Translate paths

wslpath -w . gives a Windows path. wslpath 'C:\Users' goes the other way.

Run Linux from PowerShell

wsl -e htop, wsl --cd ~ -e pnpm dev, or wsl ls -la.

Open URLs in Windows

Install wslu and set export BROWSER=wslview so CLI logins open your Windows browser.

Linux GUI apps

WSLg runs GUI apps like gitk or meld as normal Windows windows.

Public URL in one line

cloudflared tunnel --url http://localhost:3000 for webhooks and demos.

Throwaway distros

wsl --install -d Debian or import a tarball to try risky setups away from your main box.

Containers in a TUI

lazydocker shows logs, stats and restarts with single keys.

Time any command

Starship shows duration for anything over 2 s. atuin stats shows what you type most.

Keep secrets out of history

Start a line with a space (HIST_IGNORE_SPACE) and atuin skips it. atuin search --delete "text" removes past entries.

Snap windows

PowerToys FancyZones: terminal, editor and browser in fixed zones. Command Palette on Win+Alt+Space.

Windows Terminal keys

Ctrl+Shift+TNew tab (default profile)
Ctrl+Shift+SpaceProfile menu
Alt+Shift+DSplit pane
Alt+arrowsMove between panes
Ctrl+,Settings

What changed, newest firstChangelog

  1. v1.2.0

    • Version badge and this changelog.
    • Light theme by default; dark is one click away and remembered.
  2. v1.1.0

    • Light/dark theme toggle.
    • Published at wsl2-cheatsheet.dedic.dev.
  3. v1.0.0

    • First release: setup checklist, configs, networking, Docker, runtimes, editors, shell, Git, mobile dev, backups, gotchas and tips.