# Tabernetes: Kubernetes in your browser tab
> A real Kubernetes control plane, compiled to WebAssembly: kube-apiserver, kube-controller-manager, kube-scheduler and kubectl run in your browser. Join a real node with Docker and WebRTC.

Canonical URL: https://jkroepke.de/tabernetes/
Last modified: 2026-11-09


Tabernetes is a real Kubernetes v1.37 control plane. Not a simulator. kube-apiserver, kube-controller-manager, kube-scheduler and kubectl are the upstream Go code, compiled to WebAssembly into one binary. The only replaced part is etcd: a small in-memory implementation of the etcd v3 API. Everything runs in your browser tab. Reload the page and the cluster is gone.

A control plane without nodes is boring. Type `join` and start the printed `docker run` command. The container runs a kubelet and containerd and connects to your browser with WebRTC. Then `kubectl get pods -A` shows flannel, kube-proxy and CoreDNS on a real node.

And yes, it runs Doom. After a join, type `doom`. [KubeDoom](https://github.com/storax/kubedoom) starts on the node, and every pod in the default namespace is a monster. Shoot one, and KubeDoom deletes the pod. The Deployment brings it back, of course.

Your browser tab is a node, too. The control plane runs in a shared worker, and every tab of this page joins the cluster as a node. Open the page in a second tab, and `kubectl get nodes` shows `tab-2`. Close the first tab, and the cluster keeps running.

Type `llm`, and a Deployment runs a language model on the GPU of your tab, with [WebLLM](https://webllm.mlc.ai) and WebGPU. The tab pulls the model like an image, and `kubectl describe pod` shows the events. Then `kubectl exec -it deploy/llm -- chat` talks to it. Close the tab, and the pod moves to another tab with WebGPU.

> **Needs WebGPU**
>
> `llm` needs a browser with WebGPU, for example a current Chrome or Edge. The first start downloads about 1 GB of model weights from Hugging Face. After that, they come from the browser cache.


<link rel="stylesheet" href="/tabernetes/xterm.854a7c0fb70e8b1a083c16797ab827299fb18744f5ad34f227b48337e33293c6.css" integrity="sha256-hUp8D7cOixoIPBZ5ergnKZ&#43;xh0T1rTTyJ7SDN&#43;Myk8Y=">
<style>
  .tabernetes-wrap { position: relative; display: flex; flex-direction: column; border-radius: 6px; background: #0d1117; overflow: hidden; }
  .tabernetes-terminal { height: 70vh; min-height: 420px; background: #0d1117; overflow: hidden; box-sizing: border-box; }
   
  .tabernetes-terminal .xterm { padding: 8px; }
  .tabernetes-status { padding: 4px 12px; font: .8rem ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; color: #8b949e; background: #161b22; border-top: 1px solid #30363d; white-space: nowrap; overflow-x: auto; }
  .tabernetes-status .tabernetes-node + .tabernetes-node, .tabernetes-status .tabernetes-port { margin-left: 1.5em; }
  .tabernetes-status a.tabernetes-port { color: #58a6ff; text-decoration: none; }
  .tabernetes-status a.tabernetes-port:hover { text-decoration: underline; }
  .tabernetes-wrap[hidden] { display: none; }
  .tabernetes-start { display: block; margin: 1rem auto; padding: .6rem 1.2rem; font-size: 1rem; cursor: pointer; }
  .tabernetes-fullscreen { position: absolute; top: 8px; right: 16px; z-index: 5; padding: .2rem .5rem; font-size: .8rem; color: #c9d1d9; background: #21262d; border: 1px solid #30363d; border-radius: 4px; cursor: pointer; opacity: .7; }
  .tabernetes-fullscreen:hover { opacity: 1; }
  .tabernetes-wrap:fullscreen, .tabernetes-wrap.tabernetes-fs { position: fixed; inset: 0; z-index: 9999; background: #0d1117; }
  .tabernetes-wrap:fullscreen, .tabernetes-wrap.tabernetes-fs { border-radius: 0; }
  .tabernetes-wrap:fullscreen .tabernetes-terminal, .tabernetes-wrap.tabernetes-fs .tabernetes-terminal { flex: 1; height: auto; min-height: 0; }
   
  .tabernetes-vnc { position: absolute; inset: 0; z-index: 4; display: flex; background: #000; }
  .tabernetes-vnc-screen { flex: 1; min-width: 0; min-height: 0; }
  .tabernetes-vnc-close { position: absolute; top: 8px; left: 16px; z-index: 5; padding: .2rem .5rem; font-size: .8rem; color: #c9d1d9; background: #21262d; border: 1px solid #30363d; border-radius: 4px; cursor: pointer; opacity: .7; }
  .tabernetes-vnc-close:hover { opacity: 1; }
</style>
<div class="tabernetes-wrap" id="tabernetes-wrap" hidden>
  <div class="tabernetes-terminal" id="tabernetes" data-wasm="/tabernetes/tabernetes.wasm.4e94e7bb099516ab354b2aabfa2e43bb99a235273dc9515a6427120f46a00263.zst" data-worker="/tabernetes/unzstd.417a93815280530820b39bbd718b2b4856b4caf0245cc9ec63ccf7b278a16bf4.js" data-gpu-worker="/tabernetes/gpuworker.9eed2fdeab24acb09418df6c9ff867070bab6ce90aceff18170a8f862ec2c0c1.js" data-cluster="/tabernetes/cluster.7bc2e1f376b52a8c5c022d133211a7e1e5d4829f0e418dda734134a51be5b76a.js" data-size="28554800"></div>
  <div class="tabernetes-status" id="tabernetes-status" aria-live="polite"></div>
  <button class="tabernetes-fullscreen" id="tabernetes-fullscreen" type="button" title="Full screen (Esc to leave)">Full screen</button>
</div>
<button class="tabernetes-start" id="tabernetes-start" type="button">Boot the cluster (downloads ~27 MB)</button>
<script>
  (() => {
    const wrap = document.getElementById("tabernetes-wrap");
    const btn = document.getElementById("tabernetes-fullscreen");
    
    const isFull = () => document.fullscreenElement === wrap || wrap.classList.contains("tabernetes-fs");
    const label = () => { btn.textContent = isFull() ? "Exit full screen" : "Full screen"; };
    btn.addEventListener("click", () => {
      if (document.fullscreenElement === wrap) document.exitFullscreen();
      else if (wrap.classList.contains("tabernetes-fs")) wrap.classList.remove("tabernetes-fs");
      else if (wrap.requestFullscreen) {
        const overlay = () => { if (document.fullscreenElement !== wrap) { wrap.classList.add("tabernetes-fs"); label(); } };
        wrap.requestFullscreen().catch(overlay);
        
        setTimeout(overlay, 500);
      }
      else wrap.classList.add("tabernetes-fs");
      label();
      wrap.querySelector(".xterm-helper-textarea")?.focus();
    });
    document.addEventListener("fullscreenchange", () => {
      if (document.fullscreenElement === wrap) wrap.classList.remove("tabernetes-fs");
      label();
    });
    document.addEventListener("keydown", (e) => {
      
      if (e.key === "Escape" && wrap.classList.contains("tabernetes-fs") && !e.target.closest?.(".tabernetes-vnc")) { wrap.classList.remove("tabernetes-fs"); label(); }
    });
    document.getElementById("tabernetes-start").addEventListener("click", (e) => {
      e.target.remove();
      wrap.hidden = false;
      const s = document.createElement("script");
      s.type = "module";
      s.src = "\/tabernetes\/tabernetes.431294e86350572696569dd9708aea2b380c3a31a4b22b0ff29a1b292b0a700f.js";
      s.integrity = "sha256-QxKU6GNQVyaWVp3ZcIrqKzgMOjGksisP8pobKSsKcA8=";
      document.body.appendChild(s);
    });
  })();
</script>


The boot takes around 15 seconds. WebAssembly in the browser is single threaded, so the whole control plane shares one CPU core. The source is in the [blog repository](https://github.com/jkroepke/blog/tree/main/tabernetes).

