Installing minikube on macOS and Running CFML Apps Locally

Running CFML Apps on Kubernetes assumes you already have a cluster. This guide is the step before that: getting a real Kubernetes cluster running on your Mac with minikube, and getting a Lucee/CFML app onto it — no registry, no cloud account, no YAML you can’t delete afterwards.

Everything below was done on macOS with the Docker driver, which is the path that works identically on Intel and Apple Silicon.

Step 0: Prerequisites

You need Homebrew and a container runtime. Docker Desktop is the simplest; Colima works too and is lighter.

bash
# Homebrew — skip if you have it
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"

# Container runtime — pick one
brew install --cask docker      # Docker Desktop, then launch it once from Applications
# or
brew install colima && colima start --cpus 4 --memory 8

docker ps                       # must succeed before continuing

Give the runtime enough resources. Lucee is a JVM app inside Tomcat; a 2 GB Docker Desktop default will make pods die in ways that look like Kubernetes problems but aren’t. In Docker Desktop: Settings → Resources → at least 4 CPUs and 8 GB memory.

Step 1: Install minikube and kubectl

bash
brew install minikube
brew install kubectl

minikube version
kubectl version --client

Both are single binaries — Homebrew is just convenience. On Apple Silicon, brew install minikube gives you the arm64 build automatically.

Step 2: Start the Cluster

bash
minikube start --driver=docker --cpus=4 --memory=8192 --disk-size=40g

A few notes on those flags:

  • --driver=docker — the default when Docker is running, and the only driver I’d recommend on Apple Silicon. The old hyperkit driver is Intel-only and deprecated; qemu works on arm64 but needs socket_vmnet for networking and buys you nothing here.
  • --memory=8192 — this is the cluster’s budget, and it must fit inside what you gave Docker. One Lucee pod wants ~1 GB comfortably; two replicas plus a local Postgres plus system pods fills 4 GB fast.
  • --disk-size — Lucee images are ~600 MB. The 20 GB default runs out sooner than you’d think once you’ve built a dozen tags.

Verify:

bash
kubectl get nodes
# NAME       STATUS   ROLES           AGE   VERSION
# minikube   Ready    control-plane   45s   v1.31.0

minikube status

minikube start also points your kubectl context at the new cluster, so kubectl commands just work. If you juggle clusters: kubectl config use-context minikube.

Useful add-ons, enabled now so they’re ready later:

bash
minikube addons enable ingress          # nginx ingress controller
minikube addons enable metrics-server   # kubectl top, HPA

Step 3: Build the Image Inside the Cluster

This is the one macOS-specific trick that saves the most time. Minikube runs its own container runtime, separate from your Mac’s Docker. An image you build locally is invisible to it, and pods fail with ErrImagePull — even though docker images clearly shows the image.

The fix is to point your shell’s Docker client at minikube’s daemon and build there:

bash
eval $(minikube docker-env)     # this shell now talks to minikube's Docker
docker build -t cfml-app:dev .
docker images | grep cfml-app   # visible inside the cluster

The eval applies only to the current shell. Open a new terminal tab and you’re back to your Mac’s Docker — a common source of confusion when a rebuild “doesn’t take.”

The alternative, which works with every driver and doesn’t hijack your shell:

bash
docker build -t cfml-app:dev .
minikube image load cfml-app:dev

It’s slower (it copies the image into the cluster) but explicit. I use docker-env while iterating and image load in scripts.

Either way, you must set imagePullPolicy: Never in the manifest, or Kubernetes will try to fetch cfml-app:dev from Docker Hub and fail.

The Dockerfile itself is nothing special:

dockerfile
FROM lucee/lucee:6.0-tomcat

COPY ./webroot /var/www

ENV LUCEE_ADMIN_ENABLED="false"
# Let the JVM respect the container limit instead of guessing from the host
ENV JAVA_TOOL_OPTIONS="-XX:MaxRAMPercentage=75"

EXPOSE 8888

Step 4: Deploy the App

One file, k8s/app.yaml, holding the Deployment and a NodePort Service:

yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: cfml-app
spec:
  replicas: 1
  selector:
    matchLabels:
      app: cfml-app
  template:
    metadata:
      labels:
        app: cfml-app
    spec:
      containers:
        - name: cfml-app
          image: cfml-app:dev
          imagePullPolicy: Never # critical for locally-built images
          ports:
            - containerPort: 8888
          resources:
            requests:
              memory: "768Mi"
              cpu: "250m"
            limits:
              memory: "1536Mi"
              cpu: "1"
          readinessProbe:
            httpGet:
              path: /health.cfm
              port: 8888
            initialDelaySeconds: 25
            periodSeconds: 5
          livenessProbe:
            httpGet:
              path: /health.cfm
              port: 8888
            initialDelaySeconds: 60
            periodSeconds: 15
---
apiVersion: v1
kind: Service
metadata:
  name: cfml-app
spec:
  type: NodePort
  selector:
    app: cfml-app
  ports:
    - port: 80
      targetPort: 8888

health.cfm in your webroot can be one line:

cfml
<cfoutput>OK</cfoutput>

Apply and watch it come up:

bash
kubectl apply -f k8s/app.yaml
kubectl rollout status deployment/cfml-app
kubectl logs -f deploy/cfml-app          # Lucee boot log, ~30s the first time

The first start is slow — Lucee compiles and lays down its config on first boot. That’s exactly why initialDelaySeconds is generous above; tighten it and you get a crash loop that looks like an app bug.

Step 5: Reach the App from Your Browser

Here’s where macOS differs from Linux. With the Docker driver, the minikube node’s IP is not routable from macOSminikube ip returns something you cannot curl. You need a tunnel.

The quick way:

bash
minikube service cfml-app --url
# http://127.0.0.1:52194

On macOS this command stays in the foreground holding the tunnel open. Close it and the URL dies. Leave it running in its own terminal tab.

Simpler and more predictable for day-to-day work — a plain port-forward:

bash
kubectl port-forward svc/cfml-app 8888:80
# http://localhost:8888

Ingress, if you want a hostname

With the ingress addon enabled:

yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: cfml-app
  annotations:
    nginx.ingress.kubernetes.io/proxy-read-timeout: "120"
spec:
  ingressClassName: nginx
  rules:
    - host: cfml.local
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: cfml-app
                port:
                  number: 80
bash
kubectl apply -f k8s/ingress.yaml

sudo minikube tunnel        # keep running; asks for your password
echo "127.0.0.1 cfml.local" | sudo tee -a /etc/hosts
# http://cfml.local

On the Docker driver, minikube tunnel is what maps the ingress to 127.0.0.1, so cfml.local points at localhost — not at minikube ip. Getting this backwards is the most common “my ingress doesn’t work on Mac” problem.

Step 6: A Live-Edit Dev Loop

Rebuilding an image for every .cfm edit is miserable. Mount your webroot from the Mac into the cluster instead.

bash
minikube mount "$PWD/webroot:/mnt/webroot"     # foreground; keep it running

Then point the pod at the mount:

yaml
          volumeMounts:
            - name: webroot
              mountPath: /var/www
      volumes:
        - name: webroot
          hostPath:
            path: /mnt/webroot
bash
kubectl rollout restart deployment/cfml-app

Now editing a .cfm file in your editor shows up on refresh — Lucee recompiles changed templates on request. Keep this for development only: the mount is a live dependency on your laptop, and the whole point of the image is that production carries its own code.

Note that minikube mount is a third foreground process, alongside the tunnel. Three terminal tabs is the normal minikube working setup on macOS.

Step 7: A Database for Local Dev

CFML apps almost always need a datasource. Run one in the cluster rather than on your Mac, so the connection string matches production shape:

bash
kubectl create secret generic cfml-db \
  --from-literal=DB_USER=app \
  --from-literal=DB_PASSWORD=devpassword

kubectl create deployment postgres --image=postgres:16
kubectl set env deployment/postgres POSTGRES_PASSWORD=devpassword POSTGRES_USER=app POSTGRES_DB=appdb
kubectl expose deployment postgres --port=5432

Inject the credentials into the app pod:

yaml
env:
  - name: DB_HOST
    value: "postgres" # the Service name is the hostname
  - name: DB_NAME
    value: "appdb"
envFrom:
  - secretRef:
      name: cfml-db

And define the datasource in Application.cfc from the environment, so nothing is hardcoded:

cfml
component {
    this.datasources["appdb"] = {
        class:            "org.postgresql.Driver",
        connectionString: "jdbc:postgresql://#server.system.environment.DB_HOST#/#server.system.environment.DB_NAME#",
        username:         server.system.environment.DB_USER,
        password:         server.system.environment.DB_PASSWORD
    };
}

This is the same pattern the Kubernetes guide uses in production — only the injected values change.

Troubleshooting

Symptom Cause Fix
ErrImagePull / ImagePullBackOff Image built on your Mac’s Docker, not minikube’s eval $(minikube docker-env) and rebuild, or minikube image load; set imagePullPolicy: Never
CrashLoopBackOff, logs stop mid-boot Liveness probe kills Lucee before it finishes booting Raise initialDelaySeconds to 60+
Pod OOMKilled JVM heap sized from the host, not the container -XX:MaxRAMPercentage=75 and a memory limit ≥ 1 Gi
minikube ip doesn’t respond in the browser Docker driver’s node IP isn’t routable on macOS kubectl port-forward or minikube service --url
Ingress hostname times out No tunnel running sudo minikube tunnel, map the host to 127.0.0.1
Cluster won’t start after a Docker restart Stale cluster state minikube delete && minikube start — it’s a dev cluster, deleting it is free

Two commands worth knowing when something is genuinely opaque:

bash
kubectl describe pod <name>     # events: pull failures, OOM kills, probe failures
minikube dashboard              # browser UI over the whole cluster

Cleaning Up

bash
minikube stop      # keeps the cluster, frees CPU/RAM
minikube start     # back in ~20 seconds
minikube delete    # remove entirely

minikube stop is what you want at the end of a workday. delete is the answer whenever the cluster gets weird — there is no state worth preserving in a local dev cluster, and rebuilding costs one command plus an image load.

Summary

The whole loop, once it’s set up:

bash
minikube start                          # 1. cluster
eval $(minikube docker-env)             # 2. point Docker at it
docker build -t cfml-app:dev .          # 3. build inside the cluster
kubectl apply -f k8s/                   # 4. deploy
kubectl port-forward svc/cfml-app 8888:80   # 5. open it

Five commands to a CFML app running on real Kubernetes on a laptop. The value isn’t that minikube is production — it isn’t — it’s that the manifests, probes, config injection, and failure modes are the same ones you’ll meet on a managed cluster. Getting Lucee’s slow boot and container-aware heap right here means you don’t discover them in staging.