🌐 marina.run · 📚 Documentation · 🚀 Getting started · 📖 CLI reference
Fast, efficient, and opinionated multi-cluster manager for macOS Silicon laptops.
marina is an dependency-free CLI that manages a macOS Virtualization.framework (VZ) Lima VM, installs Docker inside it, creates and manages multiple kind clusters, and wires up pure L3 routing from your Mac into the kind bridge subnet — no SNAT, no VPN, direct IP access to pods and LoadBalancer services.
Marina is self-contained, clean, and can work alongside your current Docker setup without conflict (Orbstack, Colima, Rancher Desktop, etc.). More details below.
For lower-level design, see docs/MARINA-LLD-architecture.png and the Architecture guide.
- Documentation
- Demo
- Prerequisites
- Installation
- Quick start
- What it does
- Configuration reference
- CLI reference
- Networking deep-dive
- Running alongside Orbstack, Rancher Desktop, or Colima
- Project layout
This README is the quick tour. The full documentation lives at marina.run/docs:
| Guide | What it covers |
|---|---|
| Getting started | Install marina, bring up the VM, create your first routable kind cluster |
| Managing clusters | Create, list, switch, label, verify, and delete individual clusters |
| Fleet management | Declaratively create and tear down whole fleets from one manifest |
| Fleet resource reference | Every field of the marina.run/v1alpha1 Fleet manifest, with defaults and validation rules |
| Configuration | ~/.marina/config.yaml — VM size, bridge CIDR, kind versions, DNS, mirrors |
| CLI reference | Every command and flag |
| Registry mirrors | Pull-through caches, dodging Docker Hub rate limits, adding your own mirror |
| Networking & L3 routing | vzNAT, the host route, ip_forward, and the iptables SNAT exemptions |
| Helm with marina | Charts with LoadBalancer services, reachable from your Mac via MetalLB |
| Architecture | How the VM, Docker, kind, MetalLB, CoreDNS, and mirrors fit together |
| Upgrading | Upgrade without losing your image cache or config |
| Reset & reinstall | Three levels, from rebuilding the VM to a full clean install |
| Marina UI | The native SwiftUI companion app |
| Agent Skill | Teach AI coding tools to drive marina |
| marina as a Docker host | Use the VM's Docker daemon on its own, with published ports on localhost and Hub pulls through the cache |
| marina vs. other tools | How it lines up against Rancher Desktop, Colima, and OrbStack — including what they do better |
| Changelog | Release history |
- macOS 13 Ventura or later — Apple Virtualization.framework is required (
vmType: vz) sudoaccess — needed only whenmarina upfirst adds the macOS route (re-runs skip it if already correct) and formarina destroy;marina downdoes not require sudo- Go 1.26+ — only if building from source; not needed for the pre-built binary (the exact floor is the
godirective in go.mod, whichgo mod tidyderives from the dependency graph) - kubectx (optional) — for easier kubeconfig context switching, or use
marina kubeconfig use <name>
marina is self-contained. On first
marina upit automatically downloads and caches the Lima guest agent binary. No separate Lima installation required.
| Tool | Version | Purpose |
|---|---|---|
| Docker | latest via get.docker.com | Container runtime for kind and registries |
| kind | v0.32.0 | Kubernetes-in-Docker cluster manager |
| kubectl | latest stable | Cluster management from within the VM |
| jq, iptables, curl, net-tools, python3 | distro packages | Tooling for scripts and routing rules |
📚 Full guide: Getting started
brew tap bcollard/marina
brew trust --tap bcollard/marina
brew install --cask marinamarina was called klimax before v0.3. Moving over is one command:
brew update && brew upgrade --cask klimax # installs marina; `klimax` stays as an alias for now
marina migrate # deletes the klimax VM, moves the config and registry cache
marina upWhat changes for you: hostnames *.klimax.internal → *.demo.internal (unchanged if you were already on demo.internal, the default since v0.2.5), the fleet label klimax.dev/fleet → marina.run/fleet, Fleet manifests' apiVersion → marina.run/v1alpha1, and a new local CA to trust (one sudo prompt). Clusters are re-created; cached images are not downloaded again. marina migrate --dry-run shows the plan first.
📚 Full guide: Upgrading marina
brew upgrade --cask marina
⚠️ Rebuild the VM after upgrading. marina bakes a specific kind CLI and default Kubernetes node image into the VM at creation time; upgrading the binary does not re-provision an existing VM. After abrew upgrade, run:marina destroy && marina upthen recreate your clusters (
marina cluster create <name>). Skipping this can leave you on an older kind that cannot create the new default node version.
CGO is required because Lima's VM management packages link against macOS frameworks:
git clone https://github.com/bcollard/marina
cd marina
CGO_ENABLED=1 go build -o marina ./cmd/marina
sudo mv marina /usr/local/bin/# zsh (add to ~/.zshrc for persistence)
source <(marina completion zsh)
# bash
source <(marina completion bash)
# fish
marina completion fish > ~/.config/fish/completions/marina.fish📚 Walkthrough with explanations: Getting started
# 1. Bring up the VM + Docker + networking + registries
marina up # prompts for sudo once to add the macOS route; re-running up on a live VM skips it and won't prompt again
# 2. Create a kind cluster
marina cluster create dev
# 3. Use the cluster (kubeconfig auto-merged into ~/.kube/config)
kubectl config use-context dev
# or: kubectx dev
# 4. Test cluster connectivity by deploying nginx and exposing it with a LoadBalancer service (MetalLB will assign a VIP in the kind bridge subnet)
marina cluster e2e-test-nginx
# additionally, you can run a curl command from your Mac directly to the MetalLB VIP without port-forwarding:
# kubectl get svc nginx -o jsonpath='{.status.loadBalancer.ingress[0].ip}' # get VIP; by default in the 172.30.0.0/16 subnet
# curl http://<VIP>/ # should return the nginx welcome page
# 5. Create a second cluster
marina cluster create staging
# 6. List all clusters
marina cluster list
# 7. Delete one or more clusters
marina cluster delete # with interactive picker (space to select)
# 8. Stop the VM when you're done (preserves clusters and registry cache)
marina down
# 9. Destroy the VM and all clusters when you no longer need them
marina destroy # this also removes the macOS route, so sudo is requiredAfter marina up, the kind bridge CIDR is routed from your Mac directly to the VM. You can reach any pod IP, Service ClusterIP, or MetalLB LoadBalancer IP without port-forwarding.
| Concern | What marina does |
|---|---|
| VM | Creates/starts/stops/deletes a Lima VZ instance |
| Docker | Installs Docker in the VM; forwards the socket to ~/.<vmname>.docker.sock |
| kind | Creates/deletes multiple kind clusters; each gets its own subnet slice and API port |
| Registries | Runs pull-through mirrors for docker.io, quay.io, gcr.io, and Google Artifact Registry (us-docker.pkg.dev, us-central1-docker.pkg.dev); mirror data cached persistently |
| Networking | Routes kindBridgeCIDR from macOS → VM via lima0; no SNAT so source IPs are preserved |
| MetalLB | Installed in every cluster with a dedicated IP pool slice |
| CoreDNS | Adds custom domain forwarding (e.g. runlocal.dev) at cluster creation |
| Local DNS | Publishes every LoadBalancer Service as <service>.<namespace>.<cluster>.demo.internal, resolvable from the Mac, the VM and pods — no domain needed (ExternalDNS → etcd → CoreDNS, /etc/resolver on the Mac) |
| kubeconfig | Exports per-cluster kubeconfig to ~/.kube/marina/<name>.kubeconfig; auto-merges into ~/.kube/config |
📚 Full reference: Configuration
The default config path is ~/.marina/config.yaml. Use marina config edit to open it in your $EDITOR, or copy config.example.yaml to get started.
# ── VM ──────────────────────────────────────────────────────────────────────
vm:
name: "marina" # Lima instance name; Docker socket at ~/.<name>.docker.sock
# cpus and memory default to a share of this Mac — 3/4 of the cores and half
# the RAM. Set them to pin a value.
# cpus: 8 # 10-core M1 Pro -> 8
# memory: "16GiB" # 16GiB -> 8GiB, 32GiB -> 16GiB, 64GiB -> 32GiB
disk: "20GiB" # default; grow later with: marina disk resize 40GiB
imageDisk: "30GiB" # default; persistent image store, survives `marina destroy`
# caCerts: # extra CAs — TLS-intercepting proxy, private registry
# files: ["~/corp-root-ca.pem"]
# rosetta: false # enable Rosetta 2 for amd64 containers (ARM64 only)
# Host directories shared into the guest over virtiofs. Empty by default.
# Each appears in the VM at the same absolute path, which is what makes a
# host-path `docker run -v ~/projects/conf:/etc/app` bind resolve.
mounts: []
# mounts:
# - location: "~/projects"
# writable: true # default false
# ── Networking ───────────────────────────────────────────────────────────────
network:
kindBridgeCIDR: "172.30.0.0/16" # routed from macOS → VM; no SNAT
# proxy: # optional; macOS system proxy is used by default
# http: "http://proxy.corp:3128"
# https: "http://proxy.corp:3128"
# noProxy: ["*.corp.example"] # appended to the list marina computes
# Defaults to true: Lima's TCP port mirroring is disabled, so marina coexists with
# other Lima VMs (kind-on-lima, Rancher Desktop) that also manage kind clusters —
# otherwise two VMs racing to mirror the same port (e.g. 7001) to 127.0.0.1 conflict.
# With disablePortMirroring: true, kubeconfigs use the VM's direct lima0 IP instead
# of 127.0.0.1. Set to false to force loopback (127.0.0.1) — e.g. host security
# software (CrowdStrike) blocking vzNAT IPs. On an existing VM, `marina up` offers
# a restart to apply a change; clusters keep the API address they were created with.
# disablePortMirroring: true
dns: # local DNS for LoadBalancer Services
enabled: true # default; false removes the containers, rules and resolver file
domain: "demo.internal" # names: <service>.<namespace>.<cluster>.<domain>
nameTemplate: "{{.Name}}.{{.Namespace}}" # automatic name, relative to <cluster>.<domain>
ingress: false # true also publishes Ingress hosts (and a template name per Ingress)
tls:
enabled: true # local CA: trusted root + a *.<cluster>.<domain> wildcard per cluster
# ── Kind defaults (applied to every `marina cluster create`) ─────────────────
kind:
nodeVersion: "v1.36.1"
metalLBVersion: "v0.16.1"
customDnsResolvers:
- domain: "runlocal.dev" # forward to 8.8.8.8/8.8.4.4 (default resolvers)
# - domain: "corp.internal"
# resolvers: ["10.0.0.53"] # private resolver for internal zones
autoMergeKubeconfig: true # merge context into ~/.kube/config after create
autoRemoveKubeconfig: true # remove context from ~/.kube/config after delete
# ── Registries ───────────────────────────────────────────────────────────────
registries:
# "host" (default): cache at ~/.marina/registry-cache/ — survives marina destroy
# "guest": cache inside the VM — wiped on marina destroy
cacheStorage: "host"
mirrors:
- name: "registry-dockerio"
port: 5030
remoteURL: "https://registry-1.docker.io"
# username/password: optional, avoids Docker Hub rate limits
- name: "registry-quayio"
port: 5010
remoteURL: "https://quay.io"
- name: "registry-gcrio"
port: 5020
remoteURL: "https://gcr.io"
- name: "registry-us-docker-pkgdev"
port: 5040
remoteURL: "https://us-docker.pkg.dev"
- name: "registry-us-central1-docker-pkgdev"
port: 5050
remoteURL: "https://us-central1-docker.pkg.dev"Cluster lifecycle is managed exclusively via
marina clustersubcommands — there is no cluster list in the config file.
📚 Every command and flag: CLI reference
marina [--config ~/.marina/config.yaml] [--debug] [--lima-log-level <level>] <command>
By default only marina's own logs are shown; the underlying Lima VM logs are
hidden. Use --lima-log-level trace|debug|info|warn|error|off to surface them
(or --debug, which shows Lima at info).
| Command | Description |
|---|---|
marina up |
Create/start the VM, provision Docker, set up networking and registries (idempotent). Alias: marina start |
marina down |
Stop the VM — preserves all clusters and registry cache data. Aliases: marina stop, marina d |
marina down --remove-route |
Stop the VM and remove the macOS host route (requires sudo) |
marina destroy |
Stop + delete VM, delete all clusters, remove host route |
marina status |
Show VM state, host mounts, clusters, route, and iptables rule presence (-o text|json|yaml) |
marina doctor |
Diagnose common issues (VM, route, iptables, IP forwarding, Rosetta, local DNS through the macOS resolver); --fix applies what marina can repair, -o text|json|yaml |
marina version |
Print the marina version |
marina shell |
Open an interactive SSH session in the VM |
marina shell <cmd> [args...] |
Run a command in the VM and exit with its exit code |
marina copy <src>... <dst> |
Copy files between host and VM (vm: marks the VM side; -r for directories) |
marina config edit |
Open the config file in $VISUAL / $EDITOR |
marina disk resize <size> |
Grow the VM disk (e.g. 80GiB) — applied on the next VM start |
marina disk resize-image <size> |
Grow the persistent image-cache disk (vm.imageDisk) in place, keeping the cached images |
marina prune |
Remove reclaimable cached files (--dry-run, --downloads, -y) |
marina sudoers |
Print a sudoers snippet so marina up never prompts for the host route (--check) |
marina autostart install|uninstall|status |
Manage a launchd agent that starts the VM at login |
marina shell doubles as a non-interactive runner, so VM-side work composes in
scripts and pipelines without needing limactl:
marina shell docker ps # flags after the command are passed through
marina shell -- bash -c 'kind get clusters' # use -- when flags could look like marina's
cat setup.sh | marina shell bash -s # stdin is piped through
marina shell -t htop # -t forces a pseudo-terminalThe exit code is the remote command's, so if marina shell test -e /run/docker.sock; then ... works.
marina copy ./script.sh vm:/tmp/script.sh # host → VM
marina copy vm:/tmp/out.json ./out.json # VM → host
marina copy -r ./manifests vm:/tmp/manifests # directoriesmarina disk resize 80GiB # updates vm.disk and the Lima instance config
# Grow the persistent image-cache disk (vm.imageDisk). Raising imageDisk in the
# config only affects a disk that does not exist yet — an existing one must be
# resized explicitly, which keeps the cached images rather than re-pulling them:
marina down
marina disk resize-image 30GiB
marina up
marina down && marina up # Lima expands the image; the guest FS grows on bootShrinking is not supported. Node images are large (~2.7GB each), so check
headroom with marina shell df -h /.
📚 See also: Reset & reinstall for deeper cleanups
marina prune --dry-run # show what would go
marina prune --downloads # also clear Lima's shared image download cacheIt removes superseded Lima guest agents and registry cache directories whose
mirror is no longer configured. Caches of configured mirrors are never touched
— use marina registry clean-cache for those.
marina up needs root for exactly one thing: the macOS route for the kind bridge
CIDR. Grant just that, and up stops prompting:
marina sudoers | sudo tee /etc/sudoers.d/marina >/dev/null
sudo chmod 0440 /etc/sudoers.d/marina
marina sudoers --check # inspects the sudo policy for the two rulesThat is also what makes autostart useful, since launchd cannot answer a prompt:
marina autostart install # runs 'marina up' at login; --print shows the plist
marina autostart status
marina autostart uninstallAutostart output is appended to ~/.marina/logs/autostart.log.
Option A — environment variable (current shell only)
eval $(marina docker-env) # export DOCKER_HOST=unix://...
eval $(marina docker-env --unset) # unset DOCKER_HOSTOption B — Docker context (persistent across shells)
marina docker-context # create/update "marina" context
marina docker-context --unset # docker context use defaultIf
DOCKER_HOSTis set it overrides the active Docker context — use one or the other.marina docker-contextwarns when both are active.
The Docker daemon runs inside the VM, so it resolves a bind-mount source inside the VM. marina shares nothing of yours by default, and a bind for an unshared path does not fail — dockerd creates the missing directory in the guest and the container sees an empty directory:
# Without a matching vm.mounts entry, /etc/app is empty in the container.
docker run -v ~/projects/conf:/etc/app alpine ls /etc/appList the directories you want shared, and they appear in the VM at the same absolute path — which is exactly what makes a host-path -v work:
vm:
mounts:
- location: "~/projects"
writable: truemarina up # spots the change and offers to restart the VM
marina status # shows the VM's current shareslocation |
Host directory. ~ is expanded. Must exist — marina up refuses a path that doesn't, rather than letting it fail deep inside the VM start. |
writable |
Defaults to false, matching Lima. Opt in per directory. |
mountPoint |
Optional guest path. Leave it out unless you mean it: a guest path that differs from the host path is the one case where -v <host path> stops working. |
Unlike vm.imageDisk, this list does not need the VM recreated. marina up compares it against the VM's current shares and offers to restart — a restart still stops every kind cluster on the VM, so it asks first, and does nothing when run non-interactively.
Mounts are host-directory shares only. The VM's root disk (
vm.disk) and the image store (vm.imageDisk) are virtio-blk block devices, not virtiofs mounts, andmountTypehas no bearing on them.
📚 Full guide: Managing clusters
# Create
marina cluster create <name>
marina cluster create <name> --region us-east1 --zone us-east1-a
marina cluster create <name> -l team=platform -l env=dev # extra node labels
# Apply a fleet from a Fleet manifest (see below)
marina cluster apply -f fleet.yaml
marina cluster apply -f fleet.yaml --dry-run
marina cluster apply -f fleet.yaml --max-parallel 3
# Delete — interactive multi-select picker when no name given
marina cluster delete <name>
marina cluster delete
marina cluster delete -f fleet.yaml --yes # tear down a whole fleet
marina cluster delete -l env=test --yes # delete by label selector
# List
marina cluster list
marina cluster list -o json
marina cluster list -l marina.run/fleet=dev-fleet # filter by label selector
# Label an existing cluster's nodes
marina cluster label <name> -l team=platform -l env=prod # set/overwrite
marina cluster label <name> -l env- # remove
# Kubeconfig
marina kubeconfig use <name> # merge + switch active kubectl context to it
marina kubeconfig merge <name> # merge into ~/.kube/config (don't switch)
eval $(marina kubeconfig env <name>) # or point KUBECONFIG at the isolated file
marina kubeconfig path <name> # print the kubeconfig file path
marina kubeconfig remove <name> # remove the context from ~/.kube/config
# E2E smoke test (uses current kubectl context)
marina cluster e2e-test-nginx
marina cluster e2e-test-nginx --cleanup # remove nginx pod/svc onlyThe interactive delete picker supports multi-select:
Delete kind clusters (↑/↓ navigate · Space toggle · a=all · Enter confirm · q quit)
[ ] dev port 7001
[x] staging port 7002
[x] prod port 7003
2 cluster(s) selected — press Enter to delete
📚 Full guide: Fleet management · every manifest field: Fleet resource reference
Create and manage several clusters at once from a declarative Fleet manifest. The minimal manifest lists only names — everything else defaults:
# fleet.yaml
apiVersion: marina.run/v1alpha1
kind: Fleet
metadata:
name: dev-fleet
spec:
clusters:
- dev
- stagingmarina fleet create -f fleet.yaml # create the clusters (--dry-run to preview, --max-parallel N)
marina fleet list # show fleets and their member clusters
marina fleet describe dev-fleet # members with num, ports, node version/readiness, labels
marina fleet export dev staging > f.yaml # capture live clusters as a Fleet manifest
marina fleet export -l env=dev # ...by label selector
marina fleet export # ...or pick them interactively
marina fleet label dev-fleet -l tier=gold # label every cluster in the fleet (key- to remove)
marina fleet delete dev-fleet --yes # delete every cluster in the fleet
marina fleet delete -f fleet.yaml --yes # ...or tear down exactly what the manifest lists
marina fleet adopt dev-fleet legacy1 # pull an existing standalone cluster into the fleetIf fleet create finds a listed cluster that already exists but isn't part of the
fleet, it warns and skips it (never silently relabels). Re-run with --adopt
to pull those clusters into the fleet, or use marina fleet adopt <fleet> <cluster>….
Fleet membership is tracked by the marina.run/fleet=<name> node label, so
fleet list/label/delete work on the live clusters regardless of the manifest.
create is additive: clusters that already exist are skipped. Each entry can
optionally set dependsOn (ordering), num, nodeVersion, region/zone,
registries (cherry-pick mirrors),
addons.metricsServer, and labels. Independent clusters build in parallel up to
spec.maxParallel; dependsOn is always respected. See
examples/fleet.yaml for the full reference.
marina cluster apply -f/cluster delete -fremain as lower-level equivalents offleet create/fleet delete -f.
| Resource | Value |
|---|---|
| API server host port | 700N (e.g. 7001 for num 1) |
| Service subnet | 10.N.0.0/16 |
| Pod subnet | 10.1N.0.0/16 |
| MetalLB pool | <kindPrefix>.N.1–7 and <kindPrefix>.N.16–254 |
| kubeconfig | ~/.kube/marina/<name>.kubeconfig |
| topology labels | topology.kubernetes.io/region=europe-westN, zone=europe-westN-b |
| default node labels | managed-by=marina (always); marina.run/fleet=<name> (via apply -f); plus any -l key=value / Fleet labels |
📚 Full guide: Registry mirrors
marina registry clean-cache # remove all mirror cache dirs + containers; run 'marina up' to restartMirror cache data is stored at ~/.marina/registry-cache/<mirror-name>/ by default (cacheStorage: "host"), virtiofs-mounted into the VM and bind-mounted into each registry container. Blobs survive marina down/up cycles and even marina destroy.
Every LoadBalancer Service gets a name, with no domain to buy and no DNS provider:
marina cluster create dev
kubectl create deploy web --image=nginx && kubectl expose deploy web --port 80 --type LoadBalancer
curl http://web.default.dev.demo.internal/ # from the Mac, the VM, or any pod
marina dns list # every published name and its VIP (-o json|yaml)
marina dns attach <cluster>... # add a cluster created before network.dns was on- Names:
<service>.<namespace>.<cluster>.demo.internalautomatically, for LoadBalancer Services only — ClusterIP, headless and NodePort Services are not published (their addresses are not reachable from the Mac). Add a custom one with the annotationexternal-dns.kubernetes.io/hostname: app.dev.demo.internal(it must sit under the cluster's own zone). Ingress hosts under*.<cluster>.demo.internalare published too. - How:
marina upruns etcd and CoreDNS on the kind network (172.30.255.52/.53); each cluster runs ExternalDNS, which writes its Services into etcd;/etc/resolver/demo.internalsends the Mac's lookups to CoreDNS through the existing host route. The resolver file needs sudo once — its address never changes, so it is never rewritten. - Fleet-wide names: members of a fleet can also publish under
<fleet>.demo.internal— annotate a Service withexternal-dns.kubernetes.io/hostname: gateway.lab.demo.internal. The first member to publish a name owns it; if that member is deleted, another member that claims the name takes it over. A fleet and a cluster may not share a name (both would own<name>.demo.internal). - Turn it off with
network.dns.enabled: falseandmarina up: the containers, the iptables exemption and the resolver file are removed. - A new name appears within ~15 s (ExternalDNS's sync interval). Don't look it up before then: macOS keeps a "no such name" answer for about 75 s, whatever the zone's TTL says.
marina dns listshows when it is published;sudo killall -HUP mDNSResponderclears the cache. - Caveats: tools that do their own DNS skip
/etc/resolver—dig(usedig @172.30.255.53ordscacheutil -q host -a name <name>), Go programs built with the pure-Go resolver, and Chrome with a custom Secure DNS provider. No public CA issues certificates for.internal; use a private CA. - MDM encrypted-DNS profiles (
com.apple.dnsSettings.managed, pushed by Kandji, Jamf, Intune…) send every lookup to their DoH/DoT server, and/etc/resolveris never consulted:dig @172.30.255.53answers, curl and browsers get "no such name". The user cannot override a managed profile; the MDM admin has to exempt the zone with anOnDemandRulesrule (Action: EvaluateConnection,DomainAction: NeverConnect,Domains: [demo.internal]).marina doctordetects it (checkdns-system-resolver) andmarina upwarns.
marina runs its own CA for the zone — no mkcert, no homepki binary, no cert-manager required:
marina up # creates the root, trusts it in the System keychain (sudo, once)
marina cluster create dev # issues *.dev.demo.internal → Secret default/marina-wildcard-tls
curl https://shop.dev.demo.internal/ # serve that Secret; browsers trust it| Command | What it does |
|---|---|
marina ca status [-o json|yaml] |
Root path, expiry, keychain trust, each cluster's wildcard |
marina ca cert |
Print the root (PEM) — e.g. for a pod's trust store |
marina ca attach <cluster>... |
Issue/renew a cluster's wildcard; adds a marina-ca ClusterIssuer when cert-manager is installed |
marina ca secret <cluster> -n <ns> [--fleet] |
Copy the cluster's (or, with --fleet, its fleet's) wildcard Secret into another namespace |
marina ca trust / untrust |
Add or remove the root's keychain trust (sudo) |
- Constrained by design. The root may only issue under
.demo.internaland each cluster's intermediate only under.<cluster>.demo.internal, so trusting the root cannot be abused for any other site. The root's key never leaves~/.marina/pki/. - One label.
*.dev.demo.internalcovers annotated names and Ingress hosts (shop.dev.demo.internal), not the automaticweb.default.dev.demo.internal. For those, use cert-manager with themarina-caClusterIssuer, or setnameTemplate: "{{.Name}}-{{.Namespace}}". - Fleets get their own intermediate, constrained to
.<fleet>.demo.internal, and a*.<fleet>.demo.internalwildcard in every member asdefault/marina-fleet-wildcard-tls(plus amarina-fleet-caClusterIssuer with cert-manager). Neither a member's intermediate nor the fleet's can sign for the other's zone. - Upgrading from v0.2.2/0.2.3: run
marina ca attach <cluster>on each cluster. Those versions issued certificates macOS rejects (a name-constraint quirk in Apple's verifier);attachre-issues and re-installs them. - Pods trust the root once it is mounted: the ConfigMap
default/marina-root-caholds it. marina destroykeeps the root (like the registry cache); cluster intermediates are removed with their clusters.
📚 Full guide: Agent Skill for AI coding tools
marina ships an Agent Skill that teaches AI coding tools how to drive marina — spinning up ephemeral kind clusters for scripts, demos, and e2e tests. Install it once and every future agent session knows how to use marina without you explaining it each time:
marina skill install # → ~/.claude/skills/marina/SKILL.md
marina skill install --force # overwrite an existing copy (e.g. after upgrading marina)
marina skill path # print the install path
marina skill install --print # emit the skill to stdout (pipe it anywhere)The skill is embedded in the binary, so no download is needed. Start a new agent session after installing to pick it up.
📚 Full guide: Networking & L3 routing
macOS host
bridge1xx (<host-IP>, macOS-assigned)
│ vzNAT — Apple VZNATNetworkDeviceAttachment
▼
Lima VZ guest
lima0 (<guest-IP>, macOS-assigned)
br-<id> (172.30.0.1/16) ← Docker bridge "kind"
│
kind cluster nodes (172.30.N.x)
vzNAT IPs are assigned by macOS and cannot be configured. marina detects the VM's
lima0IP at runtime — nothing is hardcoded. Multiple Lima VMs (marina, limactl, colima…) each get a distinct IP on their ownbridge1xx, so they coexist without conflict.
marina upadds a macOS route:172.30.0.0/16 → <lima0-IP>(viasudo /sbin/route).- Inside the VM,
ip_forward=1and a systemd oneshot apply iptables rules idempotently:- nat exemption (before Docker's MASQUERADE): kind→host traffic exits
lima0without SNAT. - DOCKER-USER forward rules: host→kind and established returns are explicitly allowed.
- nat exemption (before Docker's MASQUERADE): kind→host traffic exits
- A
docker.service.ddrop-in reruns the rules after every Docker restart.
The result: curl http://172.30.1.200/ on your Mac reaches the MetalLB VIP directly.
marina supports two modes for kubeconfig API server addresses:
Default (direct IP mode — network.disablePortMirroring: true)
Lima's TCP port mirroring is disabled for the marina VM, so marina coexists cleanly with other Lima-based VMs (kind-on-lima, Rancher Desktop) that also manage kind clusters — otherwise two VMs racing to mirror the same port (e.g. 7001) to 127.0.0.1 conflict. Kubeconfigs use the VM's direct lima0 IP (e.g. 192.168.64.3:700N), which is L2-reachable from the host via vzNAT. The API server cert automatically includes the lima0 IP as a SAN.
Note: the lima0 IP is assigned dynamically by macOS and may change on VM restart. Run
marina kubeconfig merge <name>after a restart to refresh the address in~/.kube/config.
Loopback mode (network.disablePortMirroring: false)
Cluster API servers listen on 0.0.0.0:700N inside the VM. Lima's hostagent forwards these ports to 127.0.0.1:700N on the host, and exported kubeconfigs point at https://127.0.0.1:700N — a stable address that survives VM restarts. Use this when running only a single marina VM, or when host-based security software (e.g. endpoint agents like CrowdStrike) blocks direct vzNAT IP access.
Changing it on an existing VM: marina up notices the difference and offers to restart the VM to apply it (non-interactive runs only warn). The setting is also read at marina cluster create, so a cluster keeps the API address it was created with — recreate it to switch modes. Going back to direct mode breaks clusters created in loopback mode: their certificates have no lima0 SAN.
Every kind cluster is configured via containerd patches to cache pulls from docker.io, quay.io, gcr.io, and Google Artifact Registry (us-docker.pkg.dev, us-central1-docker.pkg.dev) transparently through pull-through mirrors, avoiding rate limits and accelerating cluster creation.
All mirror containers are attached to the kind Docker network so cluster nodes resolve them by hostname.
📚 Feature-by-feature comparison: marina vs. other tools
marina is designed to coexist with other Lima-based tools on the same Mac. Each Lima VM gets
its own vzNAT interface (bridge1xx) and a distinct macOS-assigned IP, so there is no
IP-level conflict between VMs.
The one friction point would be Lima's TCP port mirroring: Lima's hostagent can
forward every TCP port that a process in the VM listens on to 127.0.0.1 on the host. When
two VMs independently manage kind clusters, both hostagents would try to mirror the same
API-server ports (e.g. 7001) to 127.0.0.1 simultaneously, breaking connectivity for both.
marina bypasses this by default — network.disablePortMirroring is true:
# ~/.marina/config.yaml
network:
disablePortMirroring: true # defaultWith this setting (the default):
- Lima does not forward any TCP port from the marina VM to
127.0.0.1. - Cluster kubeconfigs use the VM's direct
lima0IP (e.g.https://192.168.64.3:7001) — reachable from the host over vzNAT without any routing or VPN. - The API server cert automatically includes the
lima0IP as a SAN, so TLS verification works out of the box. - Every other Lima VM (Rancher Desktop, Colima, kind-on-lima) keeps forwarding its own ports to
127.0.0.1completely unaffected.
Set it to false if you need stable 127.0.0.1 kubeconfigs or your host security software
blocks vzNAT IPs. On an existing VM, marina up offers a restart to apply the change; create
clusters afterwards so their kubeconfigs use the new address.
The
lima0IP is assigned dynamically by macOS and may change on VM restart. Runmarina kubeconfig merge <name>after a restart to refresh kubeconfigs.
See docs/marina-vs-other-lima-based-tools.md for a detailed comparison with Rancher Desktop, Colima, and kind-on-lima, and docs/marina-vs-orbstack.md for OrbStack — which includes an honest list of what OrbStack does better.
📚 How the runtime pieces fit together: Architecture
marina/
├── cmd/marina/ # main entrypoint
├── internal/
│ ├── cli/ # Cobra commands
│ ├── config/ # YAML schema, defaults, validation
│ ├── vm/ # Lima instance manager + guest agent download
│ ├── limatemplate/ # Builds limatype.LimaYAML (VZ, mounts, provision script)
│ ├── guest/ # SSH client for running commands/scripts in the VM
│ ├── docker/ # Docker network management in guest
│ ├── registry/ # Pull-through mirror lifecycle
│ ├── kind/ # kind cluster create/delete/list; MetalLB, CoreDNS, kubeconfig
│ └── routing/ # iptables no-NAT rules; macOS route management
├── config.example.yaml
├── .goreleaser.yaml
└── Makefile
MIT

