Running CFML Apps on Kubernetes: A Hands-On Walkthrough

CFML (ColdFusion Markup Language) still powers a surprising amount of business software, and a lot of it is stuck on a single fat application server. This guide walks through the practical steps I take to containerize a CFML app — built on Lucee, the open-source CFML engine — and run it on Kubernetes, with the rough edges that actually bite you called out along the way.

This is not a Kubernetes tutorial from zero. It assumes you can run kubectl get pods and have a cluster to talk to (minikube, kind, or a managed cluster all work).

The Mental Model

A CFML app on Kubernetes has the same shape as any other web app, with one quirk: the engine and your code travel together inside the image.

text
Your .cfm/.cfc code  →  Docker image (Lucee + code)  →  Pod  →  Service  →  Ingress  →  user
                          (build once)                  (run N copies)

Everything that isn’t code — database passwords, datasource config, session settings — gets injected at runtime through ConfigMaps and Secrets, never baked into the image. Keep that line clean and the rest follows.

Step 1: Containerize the App

Start from the official Lucee image and copy your webroot in. A minimal Dockerfile:

dockerfile
FROM lucee/lucee:6.0-tomcat

# App code lands in the Tomcat webroot
COPY ./webroot /var/www

# Lucee admin password + deploy-time settings come from env, not the image
ENV LUCEE_ADMIN_ENABLED="false"

# Tomcat serves on 8888 in the Lucee image
EXPOSE 8888

Build and smoke-test it locally before Kubernetes ever enters the picture:

bash
docker build -t myregistry/cfml-app:1.0.0 .
docker run --rm -p 8888:8888 myregistry/cfml-app:1.0.0
# visit http://localhost:8888 — confirm a .cfm page renders
docker push myregistry/cfml-app:1.0.0

Tag with a real version, never latest. Kubernetes caches images aggressively; latest makes rollouts ambiguous and rollbacks impossible.

Step 2: The Deployment

The Deployment declares how many copies of the Pod to run and which image they use.

yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: cfml-app
  labels:
    app: cfml-app
spec:
  replicas: 2
  selector:
    matchLabels:
      app: cfml-app
  template:
    metadata:
      labels:
        app: cfml-app
    spec:
      containers:
        - name: cfml-app
          image: myregistry/cfml-app:1.0.0
          ports:
            - containerPort: 8888
          resources:
            requests:
              cpu: "250m"
              memory: "512Mi"
            limits:
              memory: "1Gi"

A note on memory: Lucee runs on the JVM, and the JVM does not see your container’s memory limit unless you tell it to. Set a heap that fits inside the limit, or the kernel OOM-kills the pod mid-request. Pass it via the JVM options env var:

yaml
env:
  - name: CATALINA_OPTS
    value: "-XX:MaxRAMPercentage=75.0"

MaxRAMPercentage lets the JVM size its heap relative to the container limit — far safer than a hardcoded -Xmx.

Step 3: Expose It With a Service

The Service gives the Pods a stable internal address. Pods come and go; the Service name does not.

yaml
apiVersion: v1
kind: Service
metadata:
  name: cfml-app
spec:
  selector:
    app: cfml-app
  ports:
    - port: 80
      targetPort: 8888

Inside the cluster, anything can now reach the app at http://cfml-app. To let the outside world in, add an Ingress:

yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: cfml-app
  annotations:
    nginx.ingress.kubernetes.io/proxy-body-size: "50m"
spec:
  rules:
    - host: app.example.com
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: cfml-app
                port:
                  number: 80

That proxy-body-size annotation matters for CFML apps that handle file uploads — the default Nginx limit is 1 MB and will silently reject larger forms.

Step 4: Config and Secrets

Never bake the database password into the image. Put non-secret config in a ConfigMap and credentials in a Secret:

yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: cfml-config
data:
  DB_HOST: "postgres.internal"
  DB_NAME: "appdb"
---
apiVersion: v1
kind: Secret
metadata:
  name: cfml-secrets
type: Opaque
stringData:
  DB_PASSWORD: "change-me-in-real-life"
  LUCEE_ADMIN_PASSWORD: "also-change-me"

Wire them into the Deployment’s container with envFrom:

yaml
envFrom:
  - configMapRef:
      name: cfml-config
  - secretRef:
      name: cfml-secrets

Then read them in Application.cfc so the datasource is defined from the environment, not hardcoded:

cfml
component {
    this.name = "myapp";
    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
    };
}

The same image now runs unchanged in dev, staging, and prod — only the injected config differs. That is the whole point.

Step 5: Health Probes

Kubernetes needs to know when a pod is alive and when it is ready to serve. CFML apps can take 20–60 seconds to warm up the engine, so getting probes wrong is the single most common cause of crash-looping deployments.

Add a trivial health.cfm to your webroot that just outputs OK, then:

yaml
readinessProbe:
  httpGet:
    path: /health.cfm
    port: 8888
  initialDelaySeconds: 20
  periodSeconds: 5
livenessProbe:
  httpGet:
    path: /health.cfm
    port: 8888
  initialDelaySeconds: 40
  periodSeconds: 15
  • Readiness controls whether traffic is sent to the pod. Until /health.cfm answers, the pod stays out of the Service.
  • Liveness restarts a pod that has hung. Set initialDelaySeconds generously — kill it too early and Lucee never finishes booting.

Step 6: Apply and Verify

bash
kubectl apply -f k8s/
kubectl rollout status deployment/cfml-app
kubectl get pods -l app=cfml-app
kubectl logs -f deploy/cfml-app          # watch Lucee boot

If a pod is stuck, kubectl describe pod <name> shows the events — image pull failures, OOM kills, and failing probes all surface there.

Step 7: Scaling and Rollouts

Scale horizontally by changing replica count, or let Kubernetes do it based on CPU:

bash
kubectl scale deployment/cfml-app --replicas=4

kubectl autoscale deployment/cfml-app --cpu-percent=70 --min=2 --max=8

One catch unique to stateful CFML apps: session affinity. If your app keeps sessions in JVM memory, a user’s requests must return to the same pod. Either enable sticky sessions at the Ingress:

yaml
nginx.ingress.kubernetes.io/affinity: "cookie"

…or, better, move sessions out of the JVM entirely — store them in Redis or the database so any pod can serve any request. Stateless pods are what make scaling and rolling updates painless; sticky sessions are a workaround, not a destination.

Rolling out a new version is just a new image tag:

bash
kubectl set image deployment/cfml-app cfml-app=myregistry/cfml-app:1.1.0
kubectl rollout undo deployment/cfml-app   # instant rollback if it goes wrong

The Checklist

When I move a CFML app onto Kubernetes, these are the things that actually decide success:

  1. Pin image tags — never latest.
  2. Size the JVM heap to the container limit with MaxRAMPercentage.
  3. Inject all config via ConfigMaps and Secrets; nothing sensitive in the image.
  4. Give probes enough warm-up time — Lucee boots slowly.
  5. Make pods stateless — externalize sessions, or accept sticky-session limits.
  6. Set resource requests so the scheduler places pods sanely.

Get those six right and a CFML app behaves like any other modern workload — scalable, self-healing, and boring in the best way. The legacy reputation of CFML has little to do with the language and everything to do with how it has traditionally been deployed. Kubernetes fixes the deployment.