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.
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.
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.
Chosen
Build engine later
No
No
Watch
No
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
betabranch. 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.
one cluster
mgmt 8 · prod 8 · beta 4
half the products moved
Docker Desktop off
Docker Desktop VMlocal modelLima VMsfree
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).
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.
# 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.
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).
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.
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
| Service | Docker Desktop | prod, while testing | beta | prod, after its wave |
|---|---|---|---|---|
| Postgres | 5434 | 25434 | 35434 | 5434 |
| Neo4j (bolt) | 7687 | 27687 | 37687 | 7687 |
| Redis | 6379 | 26379 | 36379 | 6379 |
| Kubernetes API | 6443 | 36443 | 26443 | 36443 |
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.
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.
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.
- 1Foundation. Install Lima. Build the management VM (K3s, cert-manager, Rancher, registry, Argo CD) and lean beta and prod VMs. Nothing moves yet.
- 2Wave 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.
- 3The 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.
- 4The 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.
- 5Docker Desktop off. Stopped but installed for two weeks as the final rollback, then removed.
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.
