# Kubernetes on WSL2 and the macOS tunnel

> **Source:** [http://localhost:1313/kubernetes-on-wsl2-macos-access/](http://localhost:1313/kubernetes-on-wsl2-macos-access/)
> **Author:** [Vikash Patel](https://vikashpatel.net)
> **Published:** April 01, 2026
> **Reading Time:** 6 min
> 
> *This is the raw Markdown source of the article from the [Lorbic Technical Journal](http://localhost:1313/).*

---


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

{{< details title="The 9P Boundary" label="Deep Dive //" >}}
As I wrote in [The WSL2 performance tax](/posts/wsl2-performance-tax-go-windows/), 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.
{{< /details >}}

## 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
curl -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
Add-WindowsCapability -Online -Name OpenSSH.Server~~~~0.0.1.0
Start-Service sshd
Set-Service sshd -StartupType Automatic
```

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

```powershell
New-NetFirewallRule \
  -Name "OpenSSH-Server-In-TCP" \
  -DisplayName "OpenSSH Server (Private)" \
  -Enabled True \
  -Direction Inbound \
  -Protocol TCP \
  -Action Allow \
  -LocalPort 22 \
  -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
server: https://127.0.0.1:6443
```

Keep all other certificates and keys unchanged.

## Securing the API endpoint

From macOS, create an **SSH tunnel**:

```bash
ssh -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
kubectl 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.

---

{{< newsletter >}}

