Colima with vz on macOS: the quirks worth knowing

#colima #macos #docker #homelab #karkhana

This is a short reference post for anyone running Colima with the vz virtualization backend on macOS as a real workhorse — not as a dev convenience, but as the place where actual services run. Each of the gotchas below cost me at least an hour. Together: an evening.

If you’re running Colima for ad-hoc Docker on your dev machine, none of this matters. If you’re running services that you want reachable from outside the VM, that survive restarts, and that integrate with launchd properly — read on.

What is Colima with vz?

Colima is a CLI for Docker on macOS that uses Lima under the hood to run a Linux VM. The vz backend uses Apple’s Virtualization.framework (introduced in macOS 13), which is faster and lighter than QEMU. With --mount-type virtiofs, file mounts are also significantly faster than the default 9p.

Recommended startup for a real workhorse:

colima start \
  --cpu 6 \
  --memory 8 \
  --disk 100 \
  --vm-type vz \
  --mount-type virtiofs \
  --ssh-port 2222

Note the --ssh-port 2222. That’s the first gotcha.

Gotcha 1: Default SSH port is dynamic

Without --ssh-port, Colima picks a different SSH port every time you restart it. This is fine for ad-hoc use — colima ssh figures out the current port and connects.

It is not fine for anything else that wants stable SSH access to the VM. Examples that break with a dynamic port:

  • launchd services that maintain SSH tunnels
  • Scripts that connect to the VM via SSH
  • IDE remote development setups
  • Backup scripts that need to dump databases from inside the VM

The fix is to set a fixed port at startup. I use 2222, but any unused high port works. Once set, you can build long-lived configurations against it (launchd plists, SSH config aliases, etc.) without worrying about Colima restarts breaking them.

If you’ve already created a Colima profile without --ssh-port, you have to delete and recreate it. Backup any important volume data first. Containers and Docker volumes survive a recreate; SSH keys and machine identity do not.

Gotcha 2: VM IP is not bridged to the host

With vz, the Colima VM’s internal IP (typically 192.168.5.1 from the VM’s perspective) is not reachable from the macOS host. If you curl http://192.168.5.1:8000 from your terminal, it hangs.

This is by design — vz uses NAT, and the VM is behind it. The implication is significant: any service running inside the VM is invisible to processes running on the macOS host unless you explicitly forward its port.

Common things that hit this wall:

  • A reverse proxy on macOS trying to proxy to a service inside the VM.
  • A backup script on macOS trying to talk to a database inside the VM.
  • The macOS browser trying to access a service running in a container.

Three approaches to solve it, in order of how much I like them:

SSH tunnel. The cleanest. Create a long-lived SSH tunnel from macOS to the VM that forwards specific ports to localhost on the host. You don’t have to declare the ports up front when starting Colima — you declare them in your launchd LaunchAgent config and edit it as needed.

ssh -f -N \
  -L 8000:localhost:8000 \
  -i ~/.colima/_lima/_config/user \
  -p 2222 \
  127.0.0.1

Now curl localhost:8000 on macOS reaches the service inside the VM. Wrap this in a launchd LaunchAgent and it survives restarts.

colima --port-forward. Colima supports a port-forward flag at startup. The downside: every port you’ll ever want must be declared at start time. Adding a new port means restarting Colima. For a static set of ports this is fine; for a dynamic homelab where you keep adding services, it’s annoying.

Run reverse proxies inside the VM. Move the macOS-side proxy into the VM. Now it can talk to services using internal Docker networking. The downside: you’ve coupled what was a generic macOS service (cloudflared, Tailscale, etc.) to the VM lifecycle. Personally, I keep cloudflared and Tailscale on macOS itself, and use the SSH tunnel pattern to reach VM services from those.

Gotcha 3: host.docker.internal:host-gateway resolves to the wrong IP from inside containers

This one’s nasty and easy to misdiagnose.

The Docker convention host.docker.internal:host-gateway is supposed to resolve to “the host running Docker.” In a normal Docker setup on Linux, that’s the actual machine. On Docker Desktop for Mac, it’s the Mac.

Inside Colima with vz, it resolves to 192.168.5.2, which is the macOS host from the VM’s perspective — not the VM that’s actually running Docker. This means containers using host-gateway to reach “the host running Docker” actually reach macOS, where Docker is not running.

I hit this when setting up Coolify, which uses host.docker.internal to SSH from inside its container to the deployment target (which is the VM where Docker runs). The default config silently pointed to macOS, where there was no SSH server, and the deployment failed with confusing errors.

The fix: explicitly set host.docker.internal to point at the VM’s actual gateway from the container’s POV. In my Colima setup this is something like 10.0.x.1 — the Docker bridge gateway address. In Docker Compose:

services:
  myservice:
    extra_hosts:
      - "host.docker.internal:host-gateway"

Actually, the real fix is to override it explicitly:

services:
  myservice:
    extra_hosts:
      - "host.docker.internal:10.0.x.1"

To find the right IP for your setup, exec into a running container and check:

docker exec <container> ip route | grep default

The IP after default via is what you want host.docker.internal to resolve to from inside containers. It will be on the bridge network, typically something like 10.0.x.1, and it’s the address that reaches the VM where Docker is running.

Gotcha 4: launchd autostart is fragile on a headless box

If you want Colima to start at boot on a headless Mac, you face a chicken-and-egg problem.

Colima needs a user session to run because it uses launchd LaunchAgents (user-scope), not LaunchDaemons (system-scope). User sessions only exist after a user logs in. On a headless box without a logged-in user, the LaunchAgent never fires, and Colima never starts.

Options:

Enable auto-login. A user logs in automatically on boot. The LaunchAgent fires. Colima starts. Trade-off: FileVault must be off (auto-login can’t unlock the disk). For a home server with restic backups, this is acceptable.

Move to a LaunchDaemon with launchctl asuser. A LaunchDaemon (system-scope) runs at boot regardless of user session, and uses launchctl asuser <UID> to spawn Colima in the user context. This is more complex but works without auto-login. macOS 15’s SIP makes unsigned LaunchDaemons harder to install — you may need bootstrap from a privileged shell rather than the older load command.

Test before you trust it. I learned this one the hard way, then chose not to learn it again. The first time you set up auto-start, do it when you’re physically next to the machine. Reboot it. Verify everything comes up. Don’t rely on a remote-only setup that you’ve never validated end-to-end. If the autostart fails on a headless box, you have no recovery path remotely.

Gotcha 5: macOS 15 silently breaks some launchd patterns

Tangentially related to Colima, but if you’re setting all this up on a recent macOS:

  • launchctl load is deprecated. Use launchctl bootstrap gui/$(id -u) <plist> for LaunchAgents and launchctl bootstrap system <plist> for LaunchDaemons.
  • LaunchDaemons must be signed for bootstrap system to succeed. Hand-written daemons hit a SIP wall. LaunchAgents (user-scope) don’t have this requirement.
  • Environment variables passed via the plist’s EnvironmentVariables key sometimes silently fail. If a service that needs an env var isn’t getting it, hardcode the value into a config file the service reads instead. (You should be hardcoding secrets into config files anyway, with appropriate permissions, not putting them in launchd plists where they show up in process listings.)
  • Heredocs in zsh containing ! characters break because of history expansion. If you’re scripting plist generation, use single-quoted heredoc delimiters (<< 'EOF') or write the file via a Python <<EOF block instead.

The takeaways

If I were starting from scratch:

  1. Always start Colima with --ssh-port 2222 (or any fixed port).
  2. Plan for an SSH tunnel from macOS to the VM if you have services on macOS that need to reach services inside the VM.
  3. If you use host.docker.internal from inside containers and Docker is running in the VM, set it explicitly via extra_hosts to the bridge gateway.
  4. Test reboot end-to-end before depending on autostart.
  5. On macOS 15, prefer LaunchAgents over LaunchDaemons for user-owned services.

These five rules would have saved me an evening. Maybe they save you one.

This post is part of the Karkhana series, about running solo software work sustainably with high-leverage AI agents.