Build Daily

Tinley Park · October 14, 2026

Off Docker Desktop, without stopping anything

One single-node Kubernetes cluster inside Docker Desktop runs every backing service behind a small portfolio of products: graph databases, Postgres, authentication, object storage and schedulers. The next step is Rancher with separate beta and production clusters, moved one product at a time so the old setup shrinks as the new one grows and nothing runs twice.

This part is the design, written before the build. Later parts cover building it, GitOps across the two environments, and the product-by-product moves. Anything the design assumes but has not yet proven is marked verify, and gets proven or corrected in the build.

§ 01
CHAPTER

Why move

Docker Desktop's built-in Kubernetes is an excellent way to start. It is one checkbox, and images built with docker build are instantly available to the cluster. Four limits show up once the cluster carries real work:

  • One cluster. Every change lands in the same place the products depend on. There is no beta to break first.
  • Not a Rancher host. Rancher 2.15 supports RKE2, K3s, EKS, AKS, GKE and ACK as its host cluster. Docker Desktop's cluster can be imported into Rancher, but cannot run it.
  • Someone else's upgrade schedule. The Kubernetes version moves when Docker Desktop updates, not when the team decides.
  • One node, one pod ceiling. Kubernetes defaults to 110 pods per node, and a busy single node gets close.
Today versus the target layoutLeft: today, one Docker Desktop VM on the Mac holds one cluster that runs everything: graph databases, Postgres, auth, object storage, schedulers, docs sites, dev servers and CI runners. Right: the target. The Docker Desktop VM shrinks as each product moves out. Beside it, three Lima VMs run K3s: a management VM with Rancher, Argo CD and an image registry, which manages a beta VM and a prod VM. Today Mac Docker Desktop VM · one cluster graph DBs Postgres auth object storage schedulers docs sites dev servers CI runners Target Mac Docker Desktop VM · shrinks each wave mgmt · K3s Rancher · Argo CD · registry beta K3s prod K3s three Lima VMs · no Docker Desktop FIG. 1 — Everything shares one cluster today. The target splits management, beta and prod, and the old cluster gives up one product at a time.
↗ click to enlarge
§ 02
CHAPTER

The options

Every serious way to run Kubernetes on a Mac runs a Linux VM somewhere. The choice is which tool owns that VM, and how much of it stays under the team's control. Two hard requirements narrowed the field: the new setup must run beside Docker Desktop while products move across, and it must cost nothing.

Lima VMs + K3s
Chosen
Full control of each VM's network, ports, size and Kubernetes version. Lima ships an official K3s template, and K3s is a supported Rancher host. No Docker Desktop involved.
Colima
Build engine later
Built on Lima, with a one-flag Kubernetes. Its docs don't cover pinning the version or one cluster per instance. A good Docker-compatible engine for builds once Docker Desktop is gone.
Rancher Desktop
No
A good Docker Desktop replacement, but it runs one cluster and takes over the Docker socket. The design needs three clusters, beside Docker Desktop.
k3d
No
Runs K3s clusters as containers inside a Docker engine, so it keeps the dependency this design removes.
Apple container
Watch
Native Linux containers on macOS 26. Its Kubernetes support is an experimental subcommand with no compatibility promise yet.
OrbStack
No
Fast and polished, but proprietary, and commercial use needs a paid licence.
§ 03
CHAPTER

Three VMs, three jobs

Each VM runs the same pinned K3s, v1.36.5+k3s1, inside Rancher 2.15's supported range of Kubernetes 1.34 to 1.36. Pinning means a cluster upgrades when the team runs the upgrade, and beta always takes the new version first.

mgmt
4 CPU · 8 GiB · 60 GiB disk. Rancher, cert-manager, Argo CD as the hub for both environments, and a private image registry. It runs no product workloads, so a bad product deploy can never take the management plane with it.
beta
Starts at 4 GiB, grows to 8 GiB · 120 GiB disk. Deploys from the beta branch. Where a new Kubernetes version, a new chart or a risky migration runs first.
prod
Starts at 8 GiB, grows to 24 GiB · 200 GiB disk. Deploys from main. Holds the databases. Grows wave by wave into the memory Docker Desktop gives up.

The management VM is deliberately below Rancher's published small-install size of 4 vCPU and 16 GB, which is sized for up to 150 clusters. Three clusters is a fraction of that load, so 8 GiB is the starting point, raised if Rancher's idle memory says otherwise (verify).

Memory decides the order of work. Running the whole stack twice would leave a 128 GB machine with almost nothing free. Moving one product at a time keeps the peak small: the new VMs start lean, and each wave hands memory from Docker Desktop to prod.

Today
one cluster
45
26
~57
Foundation
mgmt 8 · prod 8 · beta 4
45
26
20
~37
Mid-way
half the products moved
28
26
28
~46
Done
Docker Desktop off
26
40
~62

Docker Desktop VMlocal modelLima VMsfree

FIG. 2 — Approximate GB of a 128 GB Mac. The extra footprint peaks at the start, about 20 GB, then the old VM shrinks as prod grows.

One catch: Docker Desktop does not reliably hand memory back to macOS while it runs. After each wave its memory limit is lowered in settings, which restarts it briefly for whatever is still on it (verify).

§ 04
CHAPTER

Two networks per VM

The networking decision comes down to one line in Lima's configuration reference: "The vzNAT IP address is accessible from the host, but not from other guests." Rancher needs both directions. The Mac's browser has to reach Rancher, and every cluster's agent has to reach Rancher from inside another VM. So each VM gets two networks.

Two networks per VMThe Mac reaches each of the three VMs over vzNAT, one way, from host to VM. The three VMs reach each other over a shared user-v2 network, where each VM resolves the others as lima-name.internal. vzNAT addresses are not reachable between VMs. Mac vzNAT vzNAT vzNAT mgmt lima-mgmt.internal beta lima-beta.internal prod lima-prod.internal user-v2 · VM ↔ VM · internet FIG. 3 — vzNAT lets the Mac in. user-v2 lets the VMs talk to each other. Neither does both.
↗ click to enlarge
# In each Lima VM template
networks:
  - vzNAT: true
  - lima: user-v2

Neither network needs sudo or an extra daemon. Each K3s API server gets its certificate issued for both its in-VM name and the Mac's forwarded address (--tls-san lima-<name>.internal --tls-san 127.0.0.1), so kubectl works from the Mac and Argo CD works from the management VM.

§ 05
CHAPTER

One Rancher address, three callers

Rancher has exactly one server URL, and every cluster agent dials it. The callers live in three different places. The fix is one name and one port that each place resolves its own way:

https://rancher.home.internal:8444

The Mac

/etc/hosts → 127.0.0.1

A static Lima port forward carries Mac port 8444 into the management VM.

Pods in beta and prod

CoreDNS → mgmt's user-v2 address

A K3s coredns-custom entry. Traffic goes VM to VM, never through the Mac.

Pods in Docker Desktop

→ host.docker.internal

Optional, to watch the old cluster from Rancher while products move. Through the Mac's port 8444 (verify).

FIG. 4 — One URL everywhere; three local answers to "where is that?"

Rancher issues its own certificate for that name (ingress.tls.source=rancher), and each agent pins it by checksum. Changing the name or the certificate authority later means re-registering every cluster, so the name is chosen once, here.

§ 06
CHAPTER

Ports that can't collide

Lima forwards any port a VM listens on to the same port on the Mac, automatically. That convenience is a hazard here: about forty services from the Docker Desktop cluster already own Mac ports such as 5434 (Postgres), 7687 (Neo4j) and 6379 (Redis). A new prod cluster publishing the same ports would race the old one for them.

So every new VM turns automatic forwarding off and forwards only what it names. A product that is moving gets tested on an offset port first; once its old copy is scaled to zero, its familiar port moves across. Lima checks rules top to bottom, and the last two rules ignore everything else. It takes two: a rule with the default guest address also matches wildcard binds, while one on 0.0.0.0 with guestIPMustBeZero: false catches any other interface. Either alone lets something through, such as the kubelet or K3s's localhost ports, and proto: any covers UDP as well:

portForwards:
  - guestPort: 6443        # K3s API
    hostPort: 36443        # mgmt 16443 · beta 26443 · prod 36443
  - guestPort: 5434        # Postgres
    hostPort: 25434        # prod, while testing
  - proto: any
    guestPortRange: [1, 65535]
    ignore: true           # loopback and wildcard binds
  - guestIP: 0.0.0.0
    guestIPMustBeZero: false
    proto: any
    guestPortRange: [1, 65535]
    ignore: true           # any other address
ServiceDocker Desktopprod, while testingbetaprod, after its wave
Postgres543425434354345434
Neo4j (bolt)768727687376877687
Redis637926379363796379
Kubernetes API6443364432644336443
FIG. 5 — Each service is tested 20000 ports up, then takes its familiar port in its wave. Beta stays 30000 up for good.

Offsets keep every client working unchanged while a product moves: the old port keeps pointing at the old copy, and smoke tests point at the offset port. Shared services stay on the old cluster until the last wave, and moved products reach them through the Mac at host.lima.internal (verify). What Lima does if a forwarded port is already taken is a verify item; the design avoids the case instead of relying on the answer.

§ 07
CHAPTER

The hidden dependency: images

The inventory turned up the dependency that is easiest to miss. Docker Desktop shares one image store between docker build and its Kubernetes, so a locally built image is instantly runnable. Fifteen workloads rely on that: images tagged :local, three of them set to imagePullPolicy: Never. K3s runs its own containerd and cannot see any of them.

The fix is a small private registry in the management VM, which every cluster pulls from over user-v2. K3s reads registry settings from one file per node, then needs a restart:

# /etc/rancher/k3s/registries.yaml on each node
mirrors:
  "registry.home.internal:5001":
    endpoint:
      - "http://lima-mgmt.internal:5001"

Images get real tags (registry.home.internal:5001/<name>:<git-sha>, on 5001 because macOS's AirPlay Receiver holds port 5000), and :local and Never go away. While Docker Desktop is still around, its own docker build pushes to the registry through a forwarded port. Once it is gone, a Colima instance with the Docker runtime and no Kubernetes takes over builds, including the CI runners that currently mount docker.sock.

Two smaller dependencies follow the same pattern. Database state moves to volumes on each VM's own disk, never on a shared folder from the Mac; Docker Desktop's shared-folder mounts have already jammed once under load. And the hot-reloading dev servers, which need the Mac's working tree, stay out of prod entirely: they run natively on the Mac or in beta.

§ 08
CHAPTER

The path, and the way back

GitOps stays with Argo CD, moved to the management VM as a hub: the beta branch deploys to beta, main to prod, with manual sync as today. Rancher adds one view, one role model and one audit trail across all of it. Its built-in GitOps engine, Fleet, stays off so that exactly one controller owns each object.

  1. 1
    Foundation. Install Lima. Build the management VM (K3s, cert-manager, Rancher, registry, Argo CD) and lean beta and prod VMs. Nothing moves yet.
  2. 2
    Wave one: elevatedaily.io. The least critical product goes first: its API and its public tunnel. Its docs site and CI runner wait, the first for the shared docs tooling and the second for a Docker-compatible build engine. Its database stays on the shared Postgres, and sign-in keeps using the auth server on the old cluster, both reached through the Mac.
  3. 3
    The next waves. One product per wave, least critical to most, each with the same checklist: images pushed, secrets copied, smoke test on the offset port, old copy scaled to zero, port flipped, Docker Desktop's memory limit lowered.
  4. 4
    The platform wave. The shared pieces go last, together: Postgres with every product's database, the content-ingest pipeline that writes into several of those databases, authentication, cache, object storage, the graphs and the docs sites. Moving one product's database on its own would leave the shared ingest writing to a stale copy. This is the only wave every product feels, and the only one that moves data.
  5. 5
    Docker Desktop off. Stopped but installed for two weeks as the final rollback, then removed.
FIG. 6 — Each wave is small, has its own rollback, and leaves the old cluster lighter than it found it.

Rollback is per wave. Until a product's port flips, rollback is "delete what was deployed"; after it, scale the old copy back up and flip the port back. Product waves move no data, so there is nothing to restore; the platform wave dumps the other way if it has to roll back.

The whole design is one rule applied product by product: never take the old copy down until the new one has proven it can carry the load.

Still to verify in the build: whether moved products can reach the old cluster's shared services through host.lima.internal, whether Docker Desktop gives memory back when its limit drops, whether vzNAT addresses stay stable across restarts, what Lima does with a busy port, whether Docker Desktop's pods can reach the Rancher address through the Mac, how Lima's shared folders hold up under a dev server, and Rancher's real idle memory. The next part builds the foundation and moves elevatedaily.io. For what Rancher itself is and how its pieces fit, the Rancher resource is the reference; for why run Kubernetes at all, see Making the case for Kubernetes.

  • #kubernetes
  • #rancher
  • #k3s
  • #lima
  • #docker-desktop
  • #macos
  • #platform-engineering
  • #architecture

Continue reading