Kubernetes on WSL2 and the macOS tunnel

A pragmatic guide to running k3s inside WSL2 on a Windows gaming PC and accessing it securely from a MacBook. No LAN exposure, no fragile port proxies: just SSH and control-plane tunneling.

WORDS: 1103 | CODE BLOCKS: 6 | EXT. LINKS: 3

TL;DR: I run k3s on a Windows gaming PC via WSL2 to master Kubernetes networking without cloud costs. This guide details a secure access model using SSH and kubectl port-forward to bypass NAT boundaries and maintain technical sovereignty.


This guide omits Kubernetes feature tutorials. Instead, it details how to architect an environment where Kubernetes can exist without auxiliary hardware, idle cloud bills, or hidden networking realities.

I wanted to learn Kubernetes properly, which meant understanding how scheduling, access paths, and network boundaries behave under real constraints. The problem was simple: I lacked a reasonable execution environment.

Why my cloud server is full

I maintain a crowded cloud server running a Grafana observability stack, ClickHouse, and Couchbase. While this setup is robust, the machine is effectively full. Mixing orchestration models on a single host would create unnecessary debugging overhead and risk the stability of existing services. Consequently, adding Kubernetes there would violate my goal of learning the system in isolation.

Why a dedicated server was overkill

I evaluated several alternatives, but most provided poor value. A Mac Mini or Intel NUC is expensive for the performance offered, while Raspberry Pi clusters are underpowered and diverge too far from production hardware. Furthermore, used datacenter servers are impractical due to noise and power consumption.

My gaming PC, however, has a modern CPU, high-density RAM, and fast NVMe storage. Although it runs Windows, its raw capacity makes it an ideal Kubernetes host.

Why WSL2 was the least-bad option

On Windows, the choice is between a full Linux VM or WSL2. I chose WSL2 because a full VM adds a redundant virtualization layer and higher overhead, whereas WSL2 provides a real Linux kernel with near‑native performance.

Crucially, WSL2 is a Linux VM with a specific networking model. This detail is critical for cross-device access.

Deep Dive // The 9P Boundary
As I wrote in The WSL2 performance tax, the 9P protocol bridges the Linux guest and Windows host. While it is efficient for file I/O, it does not resolve the NAT boundary. Because WSL2 sits in its own virtual network, its IP is not reachable from the LAN.

Why k3s was the correct engine

I avoided Docker Desktop, Minikube, and MicroK8s because these tools often hide technical complexity or add unnecessary layers. Instead, I chose k3s because it is CNCF‑certified, uses upstream components, and runs efficiently in constrained environments. It behaves like a production cluster without the resource tax.

Installing k3s in a single command

Installing k3s inside WSL2 is trivial:

bash
1curl -sfL https://get.k3s.io | sh -

This command starts the control plane and kubelet, then generates a valid kubeconfig at /etc/rancher/k3s/k3s.yaml.

WSL2 is trapped behind Windows NAT

My primary machine is a MacBook Air. It is where I write manifests and run kubectl, yet the cluster lives inside WSL2, which sits behind Windows NAT. This architecture creates three friction points:

  1. WSL2 IPs are not reachable from the LAN.
  2. NodePorts fail to resolve because the node is not LAN-addressable.
  3. Firewall rules on Windows manage permission but do not establish routing into the WSL2 subsystem.

Tunneling through the control plane

Rather than fighting WSL2 networking, I settled on a model that mirrors private production clusters. Nothing is exposed to the LAN; instead, we secure the Kubernetes API and tunnel application traffic through the control plane using SSH and kubectl port-forward.

Setting up the Windows SSH bridge

The first step is enabling OpenSSH Server on Windows. Run these commands in PowerShell as Administrator:

powershell
1Add-WindowsCapability -Online -Name OpenSSH.Server~~~~0.0.1.0
2Start-Service sshd
3Set-Service sshd -StartupType Automatic

Then, add a firewall rule to restrict SSH to Private networks:

powershell
1New-NetFirewallRule \
2  -Name "OpenSSH-Server-In-TCP" \
3  -DisplayName "OpenSSH Server (Private)" \
4  -Enabled True \
5  -Direction Inbound \
6  -Protocol TCP \
7  -Action Allow \
8  -LocalPort 22 \
9  -Profile Private

At this stage, the only exposed port is 22. Kubernetes remains private.

Mapping the cluster to macOS

From WSL2, retrieve the kubeconfig: sudo cat /etc/rancher/k3s/k3s.yaml. Copy this file to the Mac at ~/.kube/config, then update the server address to use localhost:

yaml
1server: https://127.0.0.1:6443

Keep all other certificates and keys unchanged.

Securing the API endpoint

From macOS, create an SSH tunnel:

bash
1ssh -N -L 6443:127.0.0.1:6443 <windows-user>@<windows-ip>

This forwards the Kubernetes API securely from WSL2 to the Mac’s localhost. With the tunnel active, kubectl get nodes and kubectl get pods -A function normally.

Why NodePorts fail on WSL2

NodePort services assume nodes have routable IPs reachable by clients, but WSL2 violates this assumption. The node lives behind NAT, and Windows does not forward arbitrary ports into the Linux guest. Opening firewall ports only grants permission; it does not establish routing. This behavior is not a Kubernetes bug, but rather a mismatch between environment reality and service expectations.

Forwarding application traffic

Instead of NodePorts, use kubectl port-forward for service access:

bash
1kubectl port-forward svc/app1 8081:8081

The service is now available at http://localhost:8081 on the Mac. Important: Even if the Service defines nodePort: 30081, port-forward ignores it because it tunnels directly to the Service port and the Pod targetPort. This is why attempting to forward 30081 fails.

How traffic flows through the tunnel

When port-forward is active, traffic flows from the Mac to kubectl, through the Kubernetes API over mTLS, to the kubelet, and finally to the Pod port. This is an application-layer tunnel, meaning no node-level networking is involved.

Why this setup is actually secure

The Kubernetes API is not exposed to the LAN. SSH is the only entry point, and it can be restricted to a single client IP. Because Kubernetes authentication remains the primary gatekeeper and application access is bound strictly to localhost, this is more secure than exposing NodePorts or using fragile Windows port‑proxy rules.

The maintenance tax

Every architectural decision has a cost:

  • Persistent Tunneling: You must maintain an active SSH session to interact with the cluster.
  • DNS Resolution: Localhost access bypasses standard Kubernetes DNS, meaning you cannot use internal cluster URLs from the Mac browser.
  • Gaming PC Availability: The cluster is only reachable when the host PC is awake and the WSL2 subsystem is running.

Kubernetes is a system of constraints

This setup allowed me to learn Kubernetes without auxiliary hardware or cloud overhead. It forced me to understand networking boundaries instead of bypassing them with convenience tools. Ultimately, it made Kubernetes less like “Docker with YAML” and more like the distributed system it actually is.