Skip to content
WSL2 Cheat/sheet

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#

  1. 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).

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

  3. 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/….

  4. 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 -a mentions microsoft-standard-WSL2.
  • git status in that terminal answers instantly on a large repo.

Troubleshoot#

  • code: command not found in 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.localhost from Windows. Close it and run code . 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 curl and 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#

  1. 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 bash

    For zsh, append eval "$(~/.local/bin/mise activate zsh)" to ~/.zshrc instead and run exec zsh.

  2. 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
  3. 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#

WSLRuntimes come from mise, not Windows or apt
which node        # ~/.local/share/mise/installs/node/...
node -v
cd ~/dev/my-app && mise current   # versions the project pins

Troubleshoot#

  • which node shows /mnt/c/...: the Windows Node is leaking in. node or npm is the Windows one.
  • mise: command not found in 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; check mise settings get idiomatic_version_file_enable_tools includes node.

Undo#

WSLRemove mise and everything it installed
mise implode      # removes mise and its installs

Then 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 gh

    On older Ubuntu releases, use GitHub’s own apt repository (instructions at cli.github.com).

Steps#

  1. 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
  2. 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
  3. 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
  4. 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#

WSLAuth and signing work
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 with git remote -v; switch with git 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.signingkey must point to the .pub file.

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.gpgsign and git 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 WSLDocker Desktop
CostFreePaid for larger companies
RAM overheadLowestExtra VM and UI
GUIlazydocker in the terminalDesktop app
SetupA script and two commandsInstaller, 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: systemd

    If it prints init, turn it on in /etc/wsl.conf ([boot] systemd) and run wsl --shutdown from PowerShell.

Steps#

  1. 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
  2. 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#

WSLDocker works without sudo
docker run --rm hello-world
docker compose version
docker info --format '{{.LoggingDriver}}'    # local

Troubleshoot#

  • permission denied while trying to connect to the Docker daemon socket: your shell predates the group change. Close all Ubuntu terminals (or run wsl --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 ls shows 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:

WSLRemove Docker Engine and its data
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.json

Docker

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#

Steps#

  1. Create the Compose file. Ports are bound to 127.0.0.1 so 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: {} }
  2. 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-stopped brings them back whenever Docker starts.

  3. 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#

WSLBoth services answer
devinfra ps                                  # both "running"
devinfra exec postgres pg_isready -U dev     # accepting connections
devinfra exec redis redis-cli ping           # PONG

From Windows, connect TablePlus or DBeaver to localhost:5432, user dev, password dev.

Troubleshoot#

Undo#

WSLStop and remove (-v also deletes the data)
devinfra down        # keep data
devinfra down -v     # delete volumes too

Docker

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 just 127.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#

  1. Map host.docker.internal to the host. With Docker Engine on Linux it isn’t defined by default; the special value host-gateway resolves 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:3000

    For 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
  2. 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#

WSLCall the WSL service from inside a container
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 on 127.0.0.1 only. ss -tlnp | grep 3000 shows the address; it must be 0.0.0.0:3000 or *:3000.
  • Could not resolve host: the extra_hosts line 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 example adb, 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#

  1. Create or edit %USERPROFILE%\.wslconfig (e.g. notepad $env:USERPROFILE\.wslconfig in 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 IP

    Keep any other keys you already have. Every key is explained in the .wslconfig reference.

  2. Restart WSL so it applies:

    PowerShellApply .wslconfig
    wsl --shutdown     # wait ~8 s, then open Ubuntu again

Verify#

WSLSame IPs as Windows
ip -br addr           # your Windows LAN IP (e.g. 192.168.1.50) appears here

Start 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#

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#

  1. 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 8081

    The VMCreatorId is the fixed ID of WSL, so the rule only applies to WSL, not other VMs.

  2. On the phone, open http://<PC LAN IP>:8081. Find the IP with ipconfig on Windows (the Wi-Fi adapter’s IPv4 address) or ip -br addr in 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:

PowerShell · AdminNAT mode: forward 8081 to WSL and allow it
$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 Allow

The 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#

PowerShellThe rule exists
Get-NetFirewallHyperVRule -Name "WSL-8081" | Select-Object Name, Enabled, LocalPorts

From 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 8081 must show 0.0.0.0 or *.
  • 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#

PowerShell · AdminRemove the rule
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 addr in 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#

  1. 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
  2. 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
  3. 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.

  4. Trust the CA in the Windows browser. Copy rootCA.pem out 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.pem
    PowerShellTrust it for your Windows user
    certutil -user -addstore Root "$env:USERPROFILE\Downloads\mkcert-rootCA.pem"
  5. 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:5173 loads with a padlock and no warning.
  • On the phone, https://192.168.1.50:5173 loads with a padlock.
  • In the browser console on the phone (or via remote debugging), window.isSecureContext is true.

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 adb server on 127.0.0.1:5037 only 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#

  1. Install the adb client in WSL:

    WSLadb client
    sudo apt install -y adb
  2. 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 ~/.bashrc

    With zsh, add the same line to ~/.zshrc (or ~/.config/shell/common.sh if you use the shared shell file).

  3. 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:android or npx react-native run-android.

  4. 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:

PowerShellWindows: adb server on all interfaces
adb kill-server
adb -a nodaemon server start
WSLWSL: use the Windows host's IP
export ADB_SERVER_SOCKET=tcp:$(ip route show default | awk '{print $3}'):5037

The Windows firewall may ask to allow adb; allow it on private networks only.

Verify#

WSLWSL sees the emulator
adb devices         # emulator-5554   device

Running the app from WSL installs it on the emulator.

Troubleshoot#

  • adb devices is empty, or cannot 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 on PATH.
  • 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#

  1. Start Metro from the project in WSL:

    WSLStart the dev server
    cd ~/dev/my-app
    npx expo start
  2. Check the address under the QR code: it should be your PC’s LAN IP (e.g. exp://192.168.1.50:8081), not a 172.x address.

  3. 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#

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#

Steps#

  1. 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 test

    Set breakpoints in the .ts files; source maps map them to the running code.

  2. 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"
        }
      ]
    }
  3. Without VS Code: Chrome DevTools. Start Node with the inspector, then attach from the Windows browser. localhost forwarding makes port 9229 reachable from Windows:

    WSLWait for a debugger before running
    node --inspect-brk --import tsx src/index.ts

    In 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 the next process.
  • 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 read tsconfig paths, which is why tsx is 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 tsx they’re inline; with a build step, enable "sourceMap": true in tsconfig.json and point outFiles at the build output.
  • chrome://inspect doesn’t list the target: the inspector is on 127.0.0.1:9229 in WSL. Check Discover network targets → Configure contains localhost:9229. In mirrored mode this works directly; in NAT mode localhost forwarding handles it.
  • Error: Cannot find module 'tsx': install it in the project (Prerequisites), or use npx tsx as runtimeExecutable.
  • 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, user dev). Adjust the Compose file path, service, user and database names to yours.
  • The examples use the devinfra alias from that recipe; it stands for docker compose -f ~/dev/infra/docker-compose.yml.

Steps#

  1. Dump in Postgres’ custom format (-Fc): compressed, and it lets pg_restore pick tables or clean first. -T stops 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
  2. 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.dump

    To restore into a fresh database instead, create it first: devinfra exec postgres createdb -U dev my_app_copy and use -d my_app_copy.

  3. 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#

WSLThe dump is readable and the data is back
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 restore

Spot-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 exist on restore: the dump came from a database with other owners; --no-owner (used above) avoids that.
  • server version mismatch from pg_dump: you ran a host pg_dump older 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#

Steps#

  1. 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/OpenSearch

    For example: sudo nano /etc/sysctl.d/99-dev.conf.

  2. Apply without restarting:

    WSLLoad the new limits
    sudo sysctl --system
  3. Restart the dev server or editor that was failing.

Verify#

WSLThe limits are active
cat /proc/sys/fs/inotify/max_user_watches     # 524288
cat /proc/sys/fs/inotify/max_user_instances   # 1024

The 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, often node_modules or build output. Exclude them in the tool’s watch config.
  • too many open files (EMFILE) instead: a different limit. Raise open-file limits.

Undo#

WSLBack to the defaults
sudo rm /etc/sysctl.d/99-dev.conf

Then 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= prints systemd) for the service limit. The shell limit works either way.

Steps#

  1. 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
  2. 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
  3. Restart WSL so every process picks it up:

    PowerShellRestart WSL
    wsl --shutdown

Verify#

WSLNew limits in a fresh shell and for Docker
ulimit -n                                  # 65536
systemctl show docker -p LimitNOFILE       # if Docker is installed

Troubleshoot#

  • ulimit -n still 1024 in the terminal: WSL terminals aren’t always login sessions that read limits.d. The systemd setting covers the user session after a restart; as a quick fix add ulimit -n 65536 to ~/.bashrc (it can raise up to the hard limit).
  • A single service still low: it sets its own LimitNOFILE=; override it with sudo systemctl edit <service>.

Undo#

WSLRemove the overrides
sudo rm /etc/security/limits.d/99-dev.conf /etc/systemd/system.conf.d/99-nofile.conf /etc/sysctl.d/99-file-max.conf

Then 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#

  1. 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.vhdx can live: %LOCALAPPDATA%\wsl (newer installs) and the Store package folder (older ones).

Verify#

PowerShell · AdminList the exclusions
(Get-MpPreference).ExclusionPath
(Get-MpPreference).ExclusionProcess

Time a clean install before and after, e.g. rm -rf node_modules && time pnpm install.

Troubleshoot#

  • Add-MpPreference : ... 0x800106ba or 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#

PowerShell · AdminRemove the exclusions
$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); autoMemoryReclaim needs WSL 2.0 or later.

Steps#

  1. 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 Windows

    Starting points by installed RAM:

    Your RAMmemoryswap
    16 GB8–10GB4GB
    32 GB16–24GB8GB
    64 GB32–48GB8–16GB
  2. 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#

WSLWSL sees the new limits
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 oom confirms). 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= prints systemd).

Steps#

  1. 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 cache

    docker system prune --volumes also deletes unused volumes (database data), so only add it on purpose.

  2. 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 true

    For distros you create later, sparseVhd=true under [experimental] in .wslconfig does this at creation (reference).

  3. 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
exit

Run sudo fstrim -av in WSL first, then wsl --shutdown, then compact. Take a backup before your first time.

Verify#

PowerShellSize of the virtual disk file
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-sparse fails 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 true again is harmless.
  • It grows back fast: cap Docker logs and build cache in daemon.json (Install Docker in WSL, step 2).

Undo#

PowerShellTurn sparse mode off
wsl --shutdown
wsl --manage Ubuntu --set-sparse false

sudo 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#

  1. 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
  2. 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
  3. 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 --update hangs or fails: try wsl --update --web-download.
  • apt complains about held-back packages: full-upgrade (used above) handles most; check with apt 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#

  1. 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
  2. Copy the file off the C: drive (external disk, NAS, cloud storage).

  3. 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-Restored

    Its 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#

PowerShellSnapshot exists and imports
Get-ChildItem "$env:USERPROFILE\WSL-Backups" | Sort-Object LastWriteTime | Select-Object -Last 3 Name, Length
wsl -l -v       # Ubuntu-Restored appears after an import

In 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.conf and wsl --terminate Ubuntu-Restored.
  • Export is very slow or huge: Docker images and caches are included. docker system prune before exporting (Shrink the WSL disk).
  • --format tar.gz not recognised: older WSL. Run wsl --update, or export plain .tar.

Undo#

Remove a restored test copy when you’re done (this deletes it permanently):

PowerShellRemove the restored copy
wsl --unregister Ubuntu-Restored