Daily development
Task recipes for a working setup. Each one has a single outcome, lists what it needs first, and ends with a way to check it worked.
Editors & runtimes
Open a WSL project in your editor#
Run VS Code, Cursor or a JetBrains IDE on Windows while the terminal, Git and language servers run inside Linux.
When to use it#
You want to edit code that lives in ~/dev with a Windows editor, with extensions and tools running against Linux rather than over the slow \\wsl.localhost file share.
Prerequisites#
- WSL and Ubuntu installed: Set up a new machine, steps 1–3.
- The editor installed on Windows, not inside Linux.
- The project on the Linux filesystem (
~/dev/...), not under/mnt/c.
Steps#
-
VS Code or Cursor: install the WSL extension (
ms-vscode-remote.remote-wsl; Cursor ships its own equivalent). Then, from the project folder in Ubuntu:WSLOpen the current folder cd ~/dev/my-app code . # or: cursor .The first run installs a small server inside Linux. Extensions you install from now on go to the WSL side when they need to run there (linters, language servers).
-
JetBrains IDEs (WebStorm, IntelliJ, PyCharm…): open Remote Development → WSL from the welcome screen (or JetBrains Gateway), pick Ubuntu and the project folder. Don’t open
\\wsl.localhost\…paths as a normal local project: indexing and Git go over the file share and are slow. -
Windows Terminal: make Ubuntu the default profile so new tabs start in Linux. Settings → Startup → Default profile → Ubuntu. In the Ubuntu profile, set the command line to:
wsl.exe -d Ubuntu --cd ~so tabs open in your Linux home, not
/mnt/c/Users/…. -
Optional: install a Nerd Font (e.g. JetBrainsMono Nerd Font) on Windows and select it in Windows Terminal and the editor, if you plan to use a prompt with icons (Starship prompt).
Verify#
- The bottom-left corner of VS Code/Cursor shows WSL: Ubuntu.
- The integrated terminal opens a Linux shell:
uname -amentionsmicrosoft-standard-WSL2. git statusin that terminal answers instantly on a large repo.
Troubleshoot#
code: command not foundin WSL: VS Code isn’t installed on Windows, or the Windows PATH is no longer appended to Linux. See code or powershell.exe: command not found.- The status bar shows no WSL indicator: you opened the folder through
\\wsl.localhostfrom Windows. Close it and runcode .from the Linux shell, or use WSL: Open Folder in WSL from the command palette. - Extensions behave as if Node or Python is missing: they run in WSL and need the runtime there. Install runtimes with mise.
Undo#
Remove the WSL extension from the editor. The server it installed lives in ~/.vscode-server (or ~/.cursor-server); delete that folder to remove it from Linux.
Editors & runtimes
Install Node, Python and other runtimes with mise#
One version manager for Node, pnpm, bun, Python and CLIs, with per-project versions that switch when you cd.
When to use it#
You need a language runtime in WSL, or different projects need different versions. Prefer this over apt install nodejs, whose versions are old and shared by every project.
Prerequisites#
- Ubuntu with
curland build tools: Set up a new machine, step 2. - Your shell: Ubuntu’s default is bash. If you switched to zsh (Switch to zsh), use the zsh line below.
Steps#
-
Install mise and activate it in your shell:
WSLInstall mise (bash) curl https://mise.run | sh echo 'eval "$(~/.local/bin/mise activate bash)"' >> ~/.bashrc exec bashFor zsh, append
eval "$(~/.local/bin/mise activate zsh)"to~/.zshrcinstead and runexec zsh. -
Install global defaults. Pick only what you use:
WSLA typical web and mobile toolchain 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 / .node-version -
Pin versions per project. Inside a project, this writes
mise.toml; commit it:WSLDaily use mise use node@22 # pin for THIS project mise install # install what the project asks for mise ls # what's installed and active mise up # upgrade everything
Good defaults once installed:
- 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 CLI tools, e.g.
mise use -g lazydocker(see Modern CLI tools).
Verify#
which node # ~/.local/share/mise/installs/node/...
node -v
cd ~/dev/my-app && mise current # versions the project pinsTroubleshoot#
which nodeshows/mnt/c/...: the Windows Node is leaking in. node or npm is the Windows one.mise: command not foundin a new terminal: the activate line isn’t in the rc file of the shell you’re using (bash vs zsh).- Version doesn’t switch on
cd: the project uses.nvmrc; checkmise settings get idiomatic_version_file_enable_toolsincludesnode.
Undo#
mise implode # removes mise and its installsThen delete the mise activate line from ~/.bashrc (or ~/.zshrc).
Editors & runtimes
Connect Git to GitHub with SSH and signed commits#
One SSH key per machine, used for GitHub authentication and commit signing, so commits show as Verified without GPG.
When to use it#
You need to clone or push to private repositories, or want signed commits. One key per machine: if a machine is lost, you revoke just that key.
Prerequisites#
-
Git with your identity configured: Set up a new machine, step 5.
-
The GitHub CLI. On Ubuntu 24.04 it’s in the standard repositories:
WSLInstall the GitHub CLI sudo apt install -y ghOn older Ubuntu releases, use GitHub’s own apt repository (instructions at cli.github.com).
Steps#
-
Create the key inside WSL, with a passphrase:
WSLA key for this machine ssh-keygen -t ed25519 -C "you@example.com" -f ~/.ssh/id_ed25519 -
Use it for signing as well:
WSLSign commits and tags with SSH 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 -
Upload it to GitHub twice, once as an authentication key and once as a signing key:
WSLUpload the key (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 -
Optional defaults that save typing:
WSLPull, rebase and push defaults git config --global pull.rebase true git config --global rebase.autoStash true git config --global push.autoSetupRemote true
Verify#
ssh -T git@github.com # "Hi <user>! You've successfully authenticated"
git log --show-signature -1 # after your next commit: "Good \"git\" signature"On GitHub, your next pushed commit shows a Verified badge.
Troubleshoot#
Permission denied (publickey): the key wasn’t added as an authentication key, or the remote uses HTTPS. Check withgit remote -v; switch withgit remote set-url origin git@github.com:org/repo.git.- Commits show Unverified: the commit email must match a verified email on your GitHub account, and the key must be uploaded as a signing key.
error: Load key ... invalid format:user.signingkeymust point to the.pubfile.
Undo#
- Delete the keys on GitHub (Settings → SSH and GPG keys) or with
gh ssh-key list/gh ssh-key delete <id>. - Stop signing:
git config --global --unset commit.gpgsignandgit config --global --unset tag.gpgsign.
Docker
Install Docker in WSL#
Run Docker Engine natively inside Ubuntu (or Docker Desktop with the WSL backend), with log and build-cache caps so the disk doesn't balloon.
When to use it#
Your projects use containers: databases, Compose stacks, or images you build. Choose one of the two options; running both leads to two daemons fighting over the same socket.
| 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 | A script and two commands | Installer, enable WSL integration |
This recipe installs Docker Engine. For Docker Desktop, install it on Windows and enable Settings → Resources → WSL integration → Ubuntu; skip to Verify.
Prerequisites#
-
Ubuntu updated: Set up a new machine, step 2.
-
systemd running as PID 1 (the default on current Ubuntu installs). Check:
WSLIs systemd running? ps -p 1 -o comm= # prints: systemdIf it prints
init, turn it on in/etc/wsl.conf([boot] systemd) and runwsl --shutdownfrom PowerShell.
Steps#
-
Install the Engine with Docker’s convenience script, and let your user run it without
sudo:WSLInstall Docker Engine curl -fsSL https://get.docker.com | sh sudo usermod -aG docker $USER # then close and reopen the terminal -
Cap logs and build cache, so the virtual disk doesn’t quietly grow to 200 GB:
File/etc/docker/daemon.json (sudo) { "log-driver": "local", "log-opts": { "max-size": "20m", "max-file": "3" }, "features": { "buildkit": true, "containerd-snapshotter": true }, "builder": { "gc": { "enabled": true, "defaultKeepStorage": "30GB" } } }WSLApply the daemon config sudo systemctl restart docker
Why not the apt repository by hand?Alternative
The convenience script adds Docker’s apt repository and installs docker-ce, the CLI, containerd, Buildx and the Compose plugin. If your company forbids piping scripts to a shell, follow the manual Install using the apt repository steps on docs.docker.com; the result is the same and updates arrive with sudo apt upgrade either way.
Verify#
docker run --rm hello-world
docker compose version
docker info --format '{{.LoggingDriver}}' # localTroubleshoot#
permission denied while trying to connect to the Docker daemon socket: your shell predates the group change. Close all Ubuntu terminals (or runwsl --shutdown) and reopen.Cannot connect to the Docker daemon: the service isn’t running.sudo systemctl status docker; if systemd isn’t PID 1, see Prerequisites.- Both Desktop and Engine installed:
docker context lsshows which one you’re talking to. Uninstall one. - Containers can’t reach a server running in WSL: Docker cannot reach my local API.
Undo#
This deletes all images, containers and volumes:
sudo apt purge -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
sudo rm -rf /var/lib/docker /var/lib/containerd /etc/docker/daemon.jsonDocker
Run shared Postgres and Redis for all projects#
One Compose file in ~/dev/infra gives every project a local Postgres and Redis, reachable from Windows tools on localhost.
When to use it#
Several projects need a database or cache and you’d rather run one long-lived copy than a stack per repo. Windows apps such as TablePlus or DBeaver connect to it on localhost.
Prerequisites#
- Docker working without
sudo: Install Docker in WSL.
Steps#
-
Create the Compose file. Ports are bound to
127.0.0.1so the databases aren’t exposed to your network:File~/dev/infra/docker-compose.yml 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 a shortcut and start it:
WSLStart the shared services echo 'alias devinfra="docker compose -f ~/dev/infra/docker-compose.yml"' >> ~/.bashrc source ~/.bashrc devinfra up -d(With zsh, append the alias to
~/.zshrc.)restart: unless-stoppedbrings them back whenever Docker starts. -
Point projects at them, e.g.
DATABASE_URL=postgres://dev:dev@localhost:5432/dev. Create a database per project:devinfra exec postgres createdb -U dev my_app.
Verify#
devinfra ps # both "running"
devinfra exec postgres pg_isready -U dev # accepting connections
devinfra exec redis redis-cli ping # PONGFrom Windows, connect TablePlus or DBeaver to localhost:5432, user dev, password dev.
Troubleshoot#
port is already allocated: something else holds 5432 or 6379, often a Postgres installed on Windows. A port is already in use.- Windows can’t connect to
localhost:5432: checkdevinfra psshows127.0.0.1:5432->5432. With mirrored networking, see Mirrored networking. - Back up the data before upgrading the image’s major version: Back up and restore a development database.
Undo#
devinfra down # keep data
devinfra down -v # delete volumes tooDocker
Connect a container to a service in WSL#
Let a container call an API or dev server running directly in Ubuntu, via host.docker.internal.
When to use it#
A container (a worker, a proxy, a test runner) needs to call something you run outside Docker, such as pnpm dev on port 3000 in WSL. Inside a container, localhost is the container itself, so it needs another name for the WSL host.
Prerequisites#
- Docker working: Install Docker in WSL. Steps below are for Docker Engine in WSL; Docker Desktop differences are at the end.
- The service in WSL listening on all interfaces (
0.0.0.0), not just127.0.0.1. Most dev servers need a flag for that, e.g.vite --host 0.0.0.0,next dev -H 0.0.0.0,uvicorn --host 0.0.0.0.
Steps#
-
Map
host.docker.internalto the host. With Docker Engine on Linux it isn’t defined by default; the special valuehost-gatewayresolves to the host’s bridge address:Filedocker-compose.yml services: worker: image: my-worker extra_hosts: - "host.docker.internal:host-gateway" environment: API_URL: http://host.docker.internal:3000For a one-off
docker run, the same mapping is a flag:WSLSame thing with docker run docker run --rm --add-host=host.docker.internal:host-gateway curlimages/curl \ -s http://host.docker.internal:3000/health -
Start your service in WSL bound to all interfaces, then start the container.
Use the host's network insteadAlternative
With Docker Engine on Linux, network_mode: host puts the container in WSL’s own network namespace, so localhost:3000 works unchanged and the service can stay on 127.0.0.1. The trade-off: ports: mappings are ignored and the container can bind any port on the host. Good for a quick test, less so for a shared Compose file.
Docker DesktopDetails
Docker Desktop defines host.docker.internal for you, so extra_hosts isn’t needed (adding it does no harm). The name points at the Windows host; a service in your Ubuntu distro is still reachable there as long as it listens on 0.0.0.0. If it isn’t, try the distro’s own IP from hostname -I.
Verify#
docker compose exec worker sh -c 'wget -qO- http://host.docker.internal:3000/health || curl -s http://host.docker.internal:3000/health'You should see your service’s response, and a request line in its logs.
Troubleshoot#
Connection refused: the service listens on127.0.0.1only.ss -tlnp | grep 3000shows the address; it must be0.0.0.0:3000or*:3000.Could not resolve host: theextra_hostsline is missing, or you ran a container that was created before you added it (docker compose up -d --force-recreate).- Timeouts: a firewall inside WSL (
sudo ufw status) is blocking the bridge. Allow the port or disable ufw for dev. - More cases, diagnosis first: Docker cannot reach my local API.
Undo#
Remove the extra_hosts entry (or the --add-host flag) and restart your service without --host 0.0.0.0.
Networking
Turn on mirrored networking#
Make WSL share Windows' IP addresses, so localhost works both ways and devices on your network can reach WSL ports.
When to use it#
- A tool in WSL must reach a service on Windows via
localhost(for exampleadb, a Windows database, a local proxy). - A phone or another computer must reach a dev server running in WSL.
- VPN or corporate network setups break WSL’s default NAT networking.
If you only open WSL dev servers in a Windows browser, you don’t need this: the default NAT mode already forwards localhost from Windows to WSL.
Prerequisites#
- Windows 11 22H2 or later, and a current WSL (
wsl --update). Mirrored mode isn’t available on Windows 10. - Read the networking modes comparison if you’re unsure.
Steps#
-
Create or edit
%USERPROFILE%\.wslconfig(e.g.notepad $env:USERPROFILE\.wslconfigin PowerShell) and add:File%USERPROFILE%.wslconfig [wsl2] 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 [experimental] hostAddressLoopback=true # Windows can reach services bound to WSL's LAN IPKeep any other keys you already have. Every key is explained in the .wslconfig reference.
-
Restart WSL so it applies:
PowerShellApply .wslconfig wsl --shutdown # wait ~8 s, then open Ubuntu again
Verify#
ip -br addr # your Windows LAN IP (e.g. 192.168.1.50) appears hereStart something on Windows (or use a known Windows service) and reach it from WSL with curl http://localhost:<port>. localhost:3000 in a Windows browser still reaches a WSL dev server.
Troubleshoot#
- Nothing changed: the file must be named
.wslconfig(not.wslconfig.txt) and WSL fully shut down. .wslconfig changes do nothing. - A phone still can’t connect: inbound traffic is blocked by the Hyper-V firewall by default. Open a WSL port to your network.
- Docker port mappings stopped working from Windows: restart Docker (
sudo systemctl restart docker) after switching modes, and make sure the port isn’t also used by a Windows process (A port is already in use). - DNS fails on a VPN: DNS fails after connecting to a VPN.
Undo#
Remove the networkingMode=mirrored line (and hostAddressLoopback) from .wslconfig, then wsl --shutdown. WSL returns to NAT mode.
Networking
Open a WSL port to your network#
Allow phones and other devices on your Wi-Fi to reach one dev-server port in WSL through the Hyper-V firewall.
When to use it#
A dev server in WSL works on the PC, but a phone or another machine on the same network gets a timeout. With mirrored networking, inbound connections to WSL go through the Hyper-V firewall, which blocks them by default. This opens exactly one port.
Prerequisites#
- Mirrored networking turned on (for NAT mode, see the alternative below).
- Windows 11 22H2 or later (the Hyper-V firewall cmdlets don’t exist earlier).
- An administrator PowerShell.
- The dev server listening on
0.0.0.0(e.g.vite --host,next dev -H 0.0.0.0; Expo/Metro does by default).
Steps#
-
Allow the port. Example for Metro/Expo on 8081; change the name and port for yours (Vite uses 5173):
PowerShell · AdminAllow inbound TCP 8081 to WSL New-NetFirewallHyperVRule -Name "WSL-8081" -DisplayName "WSL dev 8081" ` -Direction Inbound -VMCreatorId '{40E0AC32-46A5-438A-A0B2-2B479E8F2E90}' ` -Protocol TCP -LocalPorts 8081The
VMCreatorIdis the fixed ID of WSL, so the rule only applies to WSL, not other VMs. -
On the phone, open
http://<PC LAN IP>:8081. Find the IP withipconfigon Windows (the Wi-Fi adapter’s IPv4 address) orip -br addrin WSL.
Default NAT mode (no mirrored networking)Alternative
In NAT mode WSL has its own internal IP, so Windows must forward the port, and the normal Windows firewall must allow it. In an admin PowerShell:
$ip = (wsl hostname -I).Trim().Split(' ')[0]
netsh interface portproxy add v4tov4 listenport=8081 listenaddress=0.0.0.0 connectport=8081 connectaddress=$ip
New-NetFirewallRule -DisplayName "WSL dev 8081" -Direction Inbound -Protocol TCP -LocalPort 8081 -Action AllowThe WSL IP changes after every wsl --shutdown, so the portproxy needs re-running. Undo with netsh interface portproxy delete v4tov4 listenport=8081 listenaddress=0.0.0.0 and Remove-NetFirewallRule -DisplayName "WSL dev 8081".
Allow all inbound traffic to WSLAlternative
Set-NetFirewallHyperVVMSetting -Name '{40E0AC32-46A5-438A-A0B2-2B479E8F2E90}' -DefaultInboundAction Allow opens every port at once. Convenient on a trusted home network, but anything listening in WSL is then exposed. Prefer per-port rules.
Verify#
Get-NetFirewallHyperVRule -Name "WSL-8081" | Select-Object Name, Enabled, LocalPortsFrom the phone’s browser, http://<PC LAN IP>:8081 loads (for Metro it shows packager-status:running at /status).
Troubleshoot#
- Still a timeout: the server is bound to
127.0.0.1. In WSL,ss -tlnp | grep 8081must show0.0.0.0or*. - Works on Ethernet, not on Wi-Fi (or vice versa): the phone and PC must be on the same network, and “client isolation” on guest Wi-Fi blocks device-to-device traffic.
- Full diagnosis: Phone cannot reach the dev server.
Undo#
Remove-NetFirewallHyperVRule -Name "WSL-8081"Networking
Test a local HTTPS site on your phone#
Serve your dev site over HTTPS with a locally trusted certificate that your PC browser and phone both accept.
When to use it#
Your app needs a secure context on a real device: camera or microphone access, geolocation, service workers, Secure cookies, or OAuth redirects that insist on https. Plain http://192.168.x.x doesn’t count as secure on a phone.
Prerequisites#
- The phone can already reach the dev server over HTTP: Open a WSL port to your network (and mirrored networking).
- Your PC’s LAN IP, e.g.
192.168.1.50(ip -br addrin WSL with mirrored networking). Give the PC a DHCP reservation in your router so it doesn’t change. - A dev server that accepts a key and certificate (Vite, Next, webpack-dev-server, most others).
Steps#
-
Install mkcert and create a local certificate authority (CA):
WSLInstall mkcert and its CA sudo apt install -y mkcert libnss3-tools mkcert -install # trusts the CA inside Linux mkcert -CAROOT # folder holding rootCA.pem -
Issue a certificate that covers every name the site is opened by, including the LAN IP:
WSLCertificate for localhost and the LAN IP mkdir -p ~/dev/certs && cd ~/dev/certs mkcert -cert-file dev.pem -key-file dev-key.pem localhost 127.0.0.1 ::1 192.168.1.50 -
Serve with it. For Vite:
Filevite.config.ts import fs from 'node:fs'; import os from 'node:os'; import { defineConfig } from 'vite'; const certs = `${os.homedir()}/dev/certs`; export default defineConfig({ server: { host: true, // listen on all interfaces https: { key: fs.readFileSync(`${certs}/dev-key.pem`), cert: fs.readFileSync(`${certs}/dev.pem`) }, }, });Next.js:
next dev --experimental-https --experimental-https-key ~/dev/certs/dev-key.pem --experimental-https-cert ~/dev/certs/dev.pem -H 0.0.0.0. -
Trust the CA in the Windows browser. Copy
rootCA.pemout of WSL and import it into your user’s root store:WSLCopy the CA to Windows cp "$(mkcert -CAROOT)/rootCA.pem" /mnt/c/Users/<you>/Downloads/mkcert-rootCA.pemPowerShellTrust it for your Windows user certutil -user -addstore Root "$env:USERPROFILE\Downloads\mkcert-rootCA.pem" -
Trust the CA on the phone. Send it
mkcert-rootCA.pem(AirDrop, email, a USB copy), then:- iOS: open the file → Settings → Profile Downloaded → Install. Then Settings → General → About → Certificate Trust Settings → enable full trust for the mkcert CA. Both steps are required.
- Android: Settings → Security (or Security & privacy) → Encryption & credentials → Install a certificate → CA certificate. Chrome trusts user-installed CAs; some apps don’t.
A public HTTPS URL instead (no certificates)Alternative
A tunnel gives you a real certificate and works from any network, at the cost of sending traffic through a third party: cloudflared tunnel --url http://localhost:5173 prints a temporary https://….trycloudflare.com address. Fine for demos and webhooks; don’t expose anything sensitive.
Verify#
- On the PC,
https://localhost:5173loads with a padlock and no warning. - On the phone,
https://192.168.1.50:5173loads with a padlock. - In the browser console on the phone (or via remote debugging),
window.isSecureContextistrue.
Troubleshoot#
NET::ERR_CERT_COMMON_NAME_INVALID: you opened it by a name or IP the certificate doesn’t list. Re-run step 2 with that name.- iOS still warns: the Certificate Trust Settings toggle (step 5) is off.
- Phone can’t connect at all: an HTTP problem first. Phone cannot reach the dev server.
- Hot reload doesn’t connect over HTTPS: some setups need the HMR host set explicitly, e.g. Vite
server.hmr.host: '192.168.1.50'.
Undo#
- Phone: remove the profile (iOS: Settings → General → VPN & Device Management) or the user CA (Android: Trusted credentials → User).
- Windows: run
certmgr.msc, open Trusted Root Certification Authorities → Certificates, and delete the entry whose name starts with mkcert. - Linux:
mkcert -uninstall, then delete~/dev/certs.
Mobile
Develop for Android with the emulator on Windows#
Run Android Studio and the emulator on Windows, where the GPU is, and drive them with adb and React Native/Expo tooling from WSL.
When to use it#
You build Android or React Native/Expo apps and keep the JavaScript toolchain in WSL. The emulator needs hardware acceleration and a GPU, which it gets on Windows; WSL then talks to the Windows adb server instead of running its own.
Prerequisites#
- Mirrored networking: WSL reaches the Windows
adbserver on127.0.0.1:5037only in mirrored mode (NAT alternative below). - Android Studio installed on Windows, with an emulator (AVD) created in Device Manager.
- Node in WSL: Install runtimes with mise.
Steps#
-
Install the
adbclient in WSL:WSLadb client sudo apt install -y adb -
Point it at the Windows adb server instead of starting a Linux one:
WSLUse the Windows adb server (bash) echo 'export ADB_SERVER_SOCKET=tcp:127.0.0.1:5037' >> ~/.bashrc source ~/.bashrcWith zsh, add the same line to
~/.zshrc(or~/.config/shell/common.shif you use the shared shell file). -
Start the emulator from Android Studio on Windows (this also starts the Windows adb server), then run your app from WSL as usual, e.g.
npx expo run:androidornpx react-native run-android. -
For a real phone over USB: enable Developer options → USB debugging, plug it into the PC and accept the prompt. Windows owns USB, so the device shows up through the same Windows adb server. To see and control its screen on Windows, use scrcpy.
Default NAT mode (no mirrored networking)Older Windows
In NAT mode 127.0.0.1 inside WSL is not Windows. Start the Windows server listening on all interfaces and point WSL at the Windows host IP:
adb kill-server
adb -a nodaemon server startexport ADB_SERVER_SOCKET=tcp:$(ip route show default | awk '{print $3}'):5037The Windows firewall may ask to allow adb; allow it on private networks only.
Verify#
adb devices # emulator-5554 deviceRunning the app from WSL installs it on the emulator.
Troubleshoot#
adb devicesis empty, orcannot connect to daemon: adb devices shows nothing in WSL.adb server version (…) doesn't match this client: the WSL and Windows adb are far apart in version. Install the Linux platform-tools from developer.android.com matching Windows’ version, and put them first onPATH.- Metro can’t be reached from the device: Open your Expo app on a phone.
Undo#
Remove the ADB_SERVER_SOCKET line from your shell rc file and sudo apt remove adb.
Mobile
Open your Expo app on a phone#
Run Metro in WSL and load the app on a phone on the same Wi-Fi with Expo Go or a development build.
When to use it#
You develop a React Native/Expo app in WSL and want to run it on a real phone while editing, with fast refresh.
Prerequisites#
- Mirrored networking, so Metro advertises your PC’s real LAN IP.
- Port 8081 open to your network: Open a WSL port to your network (its example is 8081).
- Phone and PC on the same Wi-Fi, without guest-network client isolation.
- Expo Go (or your development build) installed on the phone.
Steps#
-
Start Metro from the project in WSL:
WSLStart the dev server cd ~/dev/my-app npx expo start -
Check the address under the QR code: it should be your PC’s LAN IP (e.g.
exp://192.168.1.50:8081), not a172.xaddress. -
Scan the QR code with the iPhone camera, or from inside Expo Go on Android.
When the LAN path is blockedAlternative
On networks you don’t control (office Wi-Fi, client isolation), npx expo start --tunnel routes Metro through a public tunnel. It’s slower, but needs no firewall rule or mirrored networking.
In NAT mode, Metro advertises WSL’s internal 172.x IP. Set REACT_NATIVE_PACKAGER_HOSTNAME to the Windows LAN IP and forward the port as in the NAT alternative, or use the tunnel.
Verify#
The app opens on the phone. Change some text in a component and save: the phone updates within a second or two.
Troubleshoot#
- The phone times out on the QR code: Phone cannot reach the dev server.
- Fast refresh doesn’t fire when you save: the project must live on the Linux filesystem. Changes do not trigger hot reload.
- Port 8081 in use: A port is already in use, or start Metro on another port with
npx expo start --port 8082(and open that port too).
Undo#
Nothing to undo here; remove the firewall rule as described in Open a WSL port to your network when you’re done.
Debugging & data
Debug a TypeScript application#
Set breakpoints in a Node/TypeScript app running in WSL from VS Code or Chrome DevTools on Windows.
When to use it#
You want to pause a TypeScript program (a script, an API server, a test) at a breakpoint and inspect variables, instead of adding console.log.
Prerequisites#
-
The project open in VS Code connected to WSL: Open a WSL project in your editor. The debugger then runs inside Linux.
-
Node 20.6 or later: Install runtimes with mise.
-
tsxin the project, to run TypeScript without a build step:WSLAdd tsx pnpm add -D tsx # or: npm i -D tsx
Steps#
-
Quickest: the JavaScript Debug Terminal. In VS Code, open the command palette (Ctrl+Shift+P) → Debug: JavaScript Debug Terminal. Anything Node you start in that terminal is attached automatically, including child processes:
WSLRun as usual inside the Debug Terminal pnpm dev # or: npx tsx src/index.ts, pnpm testSet breakpoints in the
.tsfiles; source maps map them to the running code. -
Repeatable: a launch configuration for running the current file with F5:
File.vscode/launch.json { "version": "0.2.0", "configurations": [ { "type": "node", "request": "launch", "name": "Run current TS file", "runtimeExecutable": "node", "runtimeArgs": ["--import", "tsx"], "program": "${file}", "skipFiles": ["<node_internals>/**"], "console": "integratedTerminal" } ] } -
Without VS Code: Chrome DevTools. Start Node with the inspector, then attach from the Windows browser.
localhostforwarding makes port 9229 reachable from Windows:WSLWait for a debugger before running node --inspect-brk --import tsx src/index.tsIn Chrome or Edge on Windows, open
chrome://inspect→ Open dedicated DevTools for Node.
Next.js, test runners and built-in type strippingDetails
- Next.js:
NODE_OPTIONS='--inspect' pnpm dev(the debug terminal also works). Server code appears under thenextprocess. - Vitest/Jest: run them in the JavaScript Debug Terminal, or
vitest --inspect-brk --no-file-parallelism. - Node 22.6+ can strip types itself (
node --experimental-strip-types file.ts; enabled by default from Node 23.6). It doesn’t support every TypeScript feature (enums, namespaces) and doesn’t readtsconfigpaths, which is whytsxis the safer default.
Verify#
Put a breakpoint on a line that runs at startup and press F5 (or run in the Debug Terminal). Execution stops there, the Variables panel shows local values, and stepping (F10/F11) moves through the TypeScript source, not compiled JavaScript.
Troubleshoot#
- Breakpoints are grey (“unbound”): source maps aren’t found. With
tsxthey’re inline; with a build step, enable"sourceMap": trueintsconfig.jsonand pointoutFilesat the build output. chrome://inspectdoesn’t list the target: the inspector is on127.0.0.1:9229in WSL. Check Discover network targets → Configure containslocalhost:9229. In mirrored mode this works directly; in NAT modelocalhostforwarding handles it.Error: Cannot find module 'tsx': install it in the project (Prerequisites), or usenpx tsxasruntimeExecutable.- Port 9229 in use: another process is being debugged.
node --inspect=9230 …, or find what holds the port.
Undo#
Delete .vscode/launch.json (or the configuration) and remove tsx with pnpm remove tsx if nothing else uses it.
Debugging & data
Back up and restore a development database#
Dump a Postgres database running in Docker to a file, and restore it later or on another machine.
When to use it#
Before a risky migration, an image upgrade (e.g. Postgres 16 → 17), or moving to a new machine, and whenever local data took effort to build up. Distro snapshots (Back up your WSL distro) cover everything at once but are coarse; a dump is one database and restores in seconds.
Prerequisites#
- Postgres running in Docker, e.g. shared Postgres and Redis (service
postgres, userdev). Adjust the Compose file path, service, user and database names to yours. - The examples use the
devinfraalias from that recipe; it stands fordocker compose -f ~/dev/infra/docker-compose.yml.
Steps#
-
Dump in Postgres’ custom format (
-Fc): compressed, and it letspg_restorepick tables or clean first.-Tstops Docker from allocating a terminal, which would corrupt the binary output:WSLBack up one database mkdir -p ~/dev/backups devinfra exec -T postgres pg_dump -U dev -Fc my_app > ~/dev/backups/my_app-$(date +%F_%H%M).dump -
Restore into the same database, replacing what’s there:
WSLRestore (drops and recreates objects in my_app) devinfra exec -T postgres pg_restore -U dev -d my_app --clean --if-exists --no-owner < ~/dev/backups/my_app-2026-10-06_1430.dumpTo restore into a fresh database instead, create it first:
devinfra exec postgres createdb -U dev my_app_copyand use-d my_app_copy. -
Keep a copy off the WSL disk, e.g. in your Windows user folder or synced storage:
WSLCopy backups to Windows cp ~/dev/backups/*.dump /mnt/c/Users/<you>/Backups/
RedisDetails
Redis with --appendonly yes keeps its data in the redisdata volume. For a point-in-time copy: devinfra exec redis redis-cli SAVE, then docker cp devinfra-redis-1:/data/dump.rdb ~/dev/backups/redis-$(date +%F).rdb. Restore by stopping Redis, copying the file back to /data/dump.rdb (and moving the appendonlydir aside), then starting it.
Plain SQL dumpsAlternative
pg_dump -U dev my_app > my_app.sql gives readable SQL you can grep or edit, restored with psql -U dev -d my_app < my_app.sql. It’s larger and restores all-or-nothing; use it for seed data you want in Git.
Verify#
devinfra exec -T postgres pg_restore -l < ~/dev/backups/my_app-2026-10-06_1430.dump | head # table of contents
devinfra exec postgres psql -U dev -d my_app -c '\dt' # tables after restoreSpot-check a row count you know, e.g. -c 'select count(*) from users'.
Troubleshoot#
pg_restore: error: input file does not appear to be a valid archive: the dump was made without-T(terminal output got mixed in) or without-Fc. Make a new one.role "xyz" does not existon restore: the dump came from a database with other owners;--no-owner(used above) avoids that.server version mismatchfrompg_dump: you ran a hostpg_dumpolder than the server. Run it inside the container as shown.
Undo#
A restore with --clean replaces data, so take a fresh dump first if the current state matters. Old dumps are plain files; delete them from ~/dev/backups when no longer needed.
Performance
Raise file-watcher limits for large repos#
Increase inotify limits so Vite, Next, Metro, Jest and editors can watch every file in big projects.
When to use it#
Only when you hit it: a watcher fails with ENOSPC: System limit for number of file watchers reached, or hot reload stops working in a large repo or monorepo after it worked in small ones. Small projects don’t need this.
Prerequisites#
- The project on the Linux filesystem (
~/dev). On/mnt/c, raising limits doesn’t help: Changes do not trigger hot reload.
Steps#
-
Write the limits to a sysctl file:
File/etc/sysctl.d/99-dev.conf (sudo) fs.inotify.max_user_watches=524288 # files watched, across all watchers fs.inotify.max_user_instances=1024 # separate watcher processes vm.max_map_count=262144 # only if you run Elasticsearch/OpenSearchFor example:
sudo nano /etc/sysctl.d/99-dev.conf. -
Apply without restarting:
WSLLoad the new limits sudo sysctl --system -
Restart the dev server or editor that was failing.
Verify#
cat /proc/sys/fs/inotify/max_user_watches # 524288
cat /proc/sys/fs/inotify/max_user_instances # 1024The watcher starts without ENOSPC, and saving a file triggers a reload.
Troubleshoot#
- Values reset after
wsl --shutdown: the file isn’t in/etc/sysctl.d/or doesn’t end in.conf; systemd applies it at boot. - Still
ENOSPC: something watches far more files than it should, oftennode_modulesor build output. Exclude them in the tool’s watch config. too many open files(EMFILE) instead: a different limit. Raise open-file limits.
Undo#
sudo rm /etc/sysctl.d/99-dev.confThen wsl --shutdown from PowerShell; the defaults return on the next start.
Performance
Raise open-file limits#
Lift the per-process open-file limit for shells and systemd services when builds or dev servers fail with EMFILE.
When to use it#
Only when a tool fails with EMFILE: too many open files or ulimit -n reports a low number (often 1024). Common with large bundles, test runners running in parallel, and some databases. Diagnose first: Too many open files.
Prerequisites#
- systemd running (default on current Ubuntu;
ps -p 1 -o comm=printssystemd) for the service limit. The shell limit works either way.
Steps#
-
Raise the limit for login shells, and for services started by systemd (Docker, databases):
WSLShells 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 -
Optionally raise the system-wide ceiling (rarely the bottleneck):
WSLSystem-wide maximum echo 'fs.file-max=2097152' | sudo tee /etc/sysctl.d/99-file-max.conf -
Restart WSL so every process picks it up:
PowerShellRestart WSL wsl --shutdown
Verify#
ulimit -n # 65536
systemctl show docker -p LimitNOFILE # if Docker is installedTroubleshoot#
ulimit -nstill 1024 in the terminal: WSL terminals aren’t always login sessions that readlimits.d. The systemd setting covers the user session after a restart; as a quick fix addulimit -n 65536to~/.bashrc(it can raise up to the hard limit).- A single service still low: it sets its own
LimitNOFILE=; override it withsudo systemctl edit <service>.
Undo#
sudo rm /etc/security/limits.d/99-dev.conf /etc/systemd/system.conf.d/99-nofile.conf /etc/sysctl.d/99-file-max.confThen wsl --shutdown.
Performance
Speed up installs with Defender exclusions#
Exclude the WSL virtual disk and processes from Microsoft Defender real-time scanning for faster installs and builds, with the security trade-off spelled out.
When to use it#
Installs and builds are slow even though the project is on the Linux filesystem, and Task Manager shows Antimalware Service Executable busy while you work. This is optional and a security trade-off: skip it on managed (company) machines, where policy usually forbids it anyway.
Prerequisites#
- An administrator PowerShell.
- Defender as your antivirus. Third-party products have their own exclusion settings.
- Diagnose first: Builds and installs are slow covers the more common causes.
Steps#
-
Add the WSL disk folders and processes as exclusions:
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 $_ }The two paths cover both places Ubuntu’s
ext4.vhdxcan live:%LOCALAPPDATA%\wsl(newer installs) and the Store package folder (older ones).
Verify#
(Get-MpPreference).ExclusionPath
(Get-MpPreference).ExclusionProcessTime a clean install before and after, e.g. rm -rf node_modules && time pnpm install.
Troubleshoot#
Add-MpPreference : ... 0x800106baor access denied: the window isn’t elevated, or Tamper Protection/organisation policy manages Defender.- No measurable difference: the bottleneck is elsewhere. Undo it; there’s no point accepting the trade-off for nothing.
Undo#
$paths = @("$env:LOCALAPPDATA\wsl") +
(Resolve-Path "$env:LOCALAPPDATA\Packages\CanonicalGroupLimited.Ubuntu*\LocalState" -EA 0).Path
$paths | ? { $_ } | % { Remove-MpPreference -ExclusionPath $_ }
"vmmemWSL", "vmmem", "wslhost.exe", "wslservice.exe" | % { Remove-MpPreference -ExclusionProcess $_ }Performance
Cap WSL memory and CPU#
Set how much RAM, CPU and swap the WSL VM may use, and let idle memory flow back to Windows.
When to use it#
- Windows gets sluggish while WSL is busy, or Task Manager shows VmmemWSL holding many GB after builds finished.
- The opposite: builds or containers in WSL run out of memory and get killed (OOM).
By default WSL may use up to half of your RAM (or 8 GB on older versions) and all CPU threads. Diagnose first: WSL uses too much memory.
Prerequisites#
- A current WSL (
wsl --update);autoMemoryReclaimneeds WSL 2.0 or later.
Steps#
-
Add resource limits to
.wslconfig(create it if needed; keep other keys):File%USERPROFILE%.wslconfig [wsl2] memory=16GB # ~50–75% of your RAM processors=8 # all threads, or all minus 2 swap=8GB # ~25–50% of memory [experimental] autoMemoryReclaim=dropcache # give idle RAM back to WindowsStarting points by installed RAM:
Your RAM memory swap 16 GB 8–10GB 4GB 32 GB 16–24GB 8GB 64 GB 32–48GB 8–16GB -
Restart WSL:
PowerShellApply .wslconfig wsl --shutdown # wait ~8 s, then reopen Ubuntu
Each key, with defaults and alternatives (e.g. autoMemoryReclaim=gradual), is in the .wslconfig reference.
Verify#
free -h # "total" ≈ your memory= value
nproc # = processors=After a build finishes, VmmemWSL in Task Manager shrinks back over the next minutes instead of staying at its peak.
Troubleshoot#
- Values didn’t change: .wslconfig changes do nothing.
- Processes get killed during builds:
memory=is too low for the workload (dmesg | grep -i oomconfirms). Raise it, or add swap. - Docker Desktop: its containers run in the same WSL VM, so these limits apply to them too.
Undo#
Remove the memory, processors, swap and autoMemoryReclaim lines and run wsl --shutdown; WSL returns to its defaults.
Performance
Shrink the WSL virtual disk#
Make Ubuntu's ext4.vhdx give space back to Windows after you delete files, with sparse mode, TRIM and Docker cleanup.
When to use it#
The virtual disk file (ext4.vhdx) only grows by default: deleting files inside Linux frees space for Linux but not for Windows. Use this when Windows’ drive is filling up and the file is much bigger than what df -h / reports as used. Diagnose first: Windows disk space is running out.
Prerequisites#
- WSL 2.0 or later (
wsl --version) for sparse mode. - systemd running, for the weekly TRIM timer (
ps -p 1 -o comm=printssystemd).
Steps#
-
Free space inside Linux first. Docker is usually the biggest consumer:
WSLSee and reclaim the big items df -h / # used vs size inside Linux docker system df # images, build cache, volumes docker system prune # unused containers, networks, dangling images docker builder prune # build cachedocker system prune --volumesalso deletes unused volumes (database data), so only add it on purpose. -
Turn on sparse mode for the existing distro, so freed blocks go back to Windows automatically. WSL must be stopped:
PowerShellMake the distro's disk sparse wsl --shutdown wsl --manage Ubuntu --set-sparse trueFor distros you create later,
sparseVhd=trueunder[experimental]in.wslconfigdoes this at creation (reference). -
Keep TRIM running, which is what tells the sparse disk which blocks are free:
WSLWeekly TRIM, plus one now sudo systemctl enable --now fstrim.timer sudo fstrim -av
Compact the disk by handAlternative
If sparse mode isn’t available, compact the file while WSL is shut down. On Windows Pro with the Hyper-V module: Optimize-VHD -Path <path>\ext4.vhdx -Mode Full. On any edition, with diskpart in an admin prompt:
select vdisk file="C:\Users\<you>\AppData\Local\wsl\{…}\ext4.vhdx"
attach vdisk readonly
compact vdisk
detach vdisk
exitRun sudo fstrim -av in WSL first, then wsl --shutdown, then compact. Take a backup before your first time.
Verify#
Get-ChildItem "$env:LOCALAPPDATA\wsl", "$env:LOCALAPPDATA\Packages\CanonicalGroupLimited.*" -Recurse -Filter ext4.vhdx -EA 0 |
Select-Object FullName, @{n='GB';e={[math]::Round($_.Length/1GB,1)}}After pruning and an fstrim, the file shrinks toward what df -h / reports as used (allow a few minutes).
Troubleshoot#
--set-sparsefails with “in use”: something still runs WSL (Docker Desktop, an open terminal or editor). Close it,wsl --shutdown, retry.- The file doesn’t shrink: check
systemctl status fstrim.timer, and that the distro is really sparse:wsl --manage Ubuntu --set-sparse trueagain is harmless. - It grows back fast: cap Docker logs and build cache in
daemon.json(Install Docker in WSL, step 2).
Undo#
wsl --shutdown
wsl --manage Ubuntu --set-sparse falsesudo systemctl disable --now fstrim.timer stops the weekly TRIM.
Maintenance
Keep WSL, packages and runtimes up to date#
The handful of commands that update WSL itself, Ubuntu, your runtimes and global CLIs, plus a quick disk check.
When to use it#
Every few weeks, or before reporting a bug: many WSL networking and memory issues are fixed in newer releases.
Prerequisites#
- Nothing beyond a working setup. Runtimes assume mise; skip lines for tools you don’t use.
Steps#
-
Update WSL and its kernel from Windows:
PowerShellWSL itself wsl --update # new WSL + kernel wsl --version wsl -l -v # distros and their state wsl --shutdown # restart into the new version -
Update Ubuntu, runtimes and CLIs inside WSL:
WSLPackages, runtimes, global CLIs sudo apt update && sudo apt full-upgrade # system, docker, gh mise up # runtimes and CLIs from mise npm i -g wrangler@latest eas-cli@latest # global npm CLIs, if you use them -
While you’re there, check what’s eating space:
WSLDisk check docker system df # reclaim with: docker system prune du -xh --max-depth=1 ~ 2>/dev/null | sort -h | tail
Verify#
wsl --version shows the new version, and the health check comes back clean.
Troubleshoot#
wsl --updatehangs or fails: trywsl --update --web-download.- apt complains about held-back packages:
full-upgrade(used above) handles most; check withapt list --upgradable. - Something broke after an update: Run a health check, then the matching Fix a problem entry.
Undo#
Package updates aren’t meant to be rolled back individually. Restore a distro snapshot if an update leaves things broken.
Maintenance
Back up and restore your WSL distro#
Snapshot the whole Ubuntu distro to a file, and restore it next to the current one to test or rescue files.
When to use it#
Before big changes (Ubuntu release upgrade, experimenting with system config), before moving to a new PC, and once after your setup works. Your whole distro is one ext4.vhdx file: if it gets corrupted or someone runs wsl --unregister, everything in it is gone.
A snapshot complements, not replaces:
- Pushing to Git often, the real backup for code.
- Secrets in a password manager too (
.env,.dev.vars,.envrc). The 1Password CLI can render them:op inject -i .env.tpl -o .env. - Database dumps for data you care about: Back up a development database.
Prerequisites#
- Free space on Windows roughly equal to what
df -h /shows as used in WSL (the archive is compressed, but plan for it). - Your default user set in
/etc/wsl.conf([user] default=yourname), so a restored copy logs in as you, not root. See[user] default.
Steps#
-
Export a snapshot. This stops the distro: close terminals and editors and stop containers first.
PowerShellSnapshot (stops the distro) $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 -
Copy the file off the C: drive (external disk, NAS, cloud storage).
-
To restore, import it next to the current distro, under a new name:
PowerShellRestore as a second 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-RestoredIts files are at
\\wsl.localhost\Ubuntu-Restored\in Explorer. To make it your main distro later:wsl --set-default Ubuntu-Restored.
Replacing the original distroDetails
wsl --unregister Ubuntu deletes the current distro and its disk permanently. Only do it after the restored copy works. Then either keep using Ubuntu-Restored, or export it and import it again as Ubuntu.
Verify#
Get-ChildItem "$env:USERPROFILE\WSL-Backups" | Sort-Object LastWriteTime | Select-Object -Last 3 Name, Length
wsl -l -v # Ubuntu-Restored appears after an importIn the restored distro, whoami prints your user and ls ~/dev shows your projects.
Troubleshoot#
- The restored distro logs in as root:
[user] default=wasn’t set before exporting. Add it in the restored distro’s/etc/wsl.confandwsl --terminate Ubuntu-Restored. - Export is very slow or huge: Docker images and caches are included.
docker system prunebefore exporting (Shrink the WSL disk). --format tar.gznot recognised: older WSL. Runwsl --update, or export plain.tar.
Undo#
Remove a restored test copy when you’re done (this deletes it permanently):
wsl --unregister Ubuntu-Restored