Installing minikube on macOS and Running CFML Apps Locally
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.
# 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 continuingGive 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
brew install minikube
brew install kubectl
minikube version
kubectl version --clientBoth are single binaries — Homebrew is just convenience. On Apple Silicon, brew install minikube gives you the arm64 build automatically.
Step 2: Start the Cluster
minikube start --driver=docker --cpus=4 --memory=8192 --disk-size=40gA few notes on those flags:
--driver=docker— the default when Docker is running, and the only driver I’d recommend on Apple Silicon. The oldhyperkitdriver is Intel-only and deprecated;qemuworks on arm64 but needssocket_vmnetfor 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:
kubectl get nodes
# NAME STATUS ROLES AGE VERSION
# minikube Ready control-plane 45s v1.31.0
minikube statusminikube 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:
minikube addons enable ingress # nginx ingress controller
minikube addons enable metrics-server # kubectl top, HPAStep 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:
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 clusterThe 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:
docker build -t cfml-app:dev .
minikube image load cfml-app:devIt’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:
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 8888Step 4: Deploy the App
One file, k8s/app.yaml, holding the Deployment and a NodePort Service:
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: 8888health.cfm in your webroot can be one line:
<cfoutput>OK</cfoutput>
Apply and watch it come up:
kubectl apply -f k8s/app.yaml
kubectl rollout status deployment/cfml-app
kubectl logs -f deploy/cfml-app # Lucee boot log, ~30s the first timeThe 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 macOS — minikube ip returns something you cannot curl. You need a tunnel.
The quick way:
minikube service cfml-app --url
# http://127.0.0.1:52194On 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:
kubectl port-forward svc/cfml-app 8888:80
# http://localhost:8888Ingress, if you want a hostname
With the ingress addon enabled:
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: 80kubectl 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.localOn 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.
minikube mount "$PWD/webroot:/mnt/webroot" # foreground; keep it runningThen point the pod at the mount:
volumeMounts:
- name: webroot
mountPath: /var/www
volumes:
- name: webroot
hostPath:
path: /mnt/webrootkubectl rollout restart deployment/cfml-appNow 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:
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=5432Inject the credentials into the app pod:
env:
- name: DB_HOST
value: "postgres" # the Service name is the hostname
- name: DB_NAME
value: "appdb"
envFrom:
- secretRef:
name: cfml-dbAnd define the datasource in Application.cfc from the environment, so nothing is hardcoded:
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:
kubectl describe pod <name> # events: pull failures, OOM kills, probe failures
minikube dashboard # browser UI over the whole clusterCleaning Up
minikube stop # keeps the cluster, frees CPU/RAM
minikube start # back in ~20 seconds
minikube delete # remove entirelyminikube 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:
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 itFive 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.