Skip to content

Deployment

Docker deployment

This page shows you how to containerize a Wheels app for production. You’ll write a multi-stage Dockerfile that produces a small runtime image, wire up a docker-compose.yml for the app and a Redis sidecar, pass configuration through environment variables, add a health check, and publish a multi-arch image with docker buildx.

You’ll learn:

  • Why Lucee 7 + Java 21 is the recommended base
  • How to structure a multi-stage Dockerfile so the runtime layer stays small
  • What to exclude with .dockerignore and how to pass secrets through environment variables
  • How to add a health check endpoint to a Wheels app
  • How to publish linux/amd64 + linux/arm64 images with buildx

The framework’s CI matrix pins Lucee 7 on Java 21 as the primary production target. Lucee 7 boots faster than Lucee 5 or 6, the JVM footprint on Java 21 is smaller, and the combination matches what the framework’s own test suite runs against — if it passes CI, it runs on this stack. Adobe ColdFusion 2023 and BoxLang are also supported; the Dockerfile pattern is the same, only the base image and CMD change. See the callout at the bottom of this page for the swaps.

Write a Dockerfile at the root of your app. Two stages: a builder that installs any build-time dependencies (node modules for a Tailwind/Vite build, for example), and a runtime that copies only the artifacts it needs onto a slim Lucee image.

Dockerfile
# syntax=docker/dockerfile:1.7
# --- Stage 1: builder ---------------------------------------------------------
# Compile front-end assets and prune anything the runtime doesn't need.
FROM node:20-bookworm-slim AS builder
WORKDIR /build
COPY package.json package-lock.json* ./
RUN npm ci --no-audit --no-fund
COPY . .
RUN npm run build # produces public/assets/ (Vite, Tailwind, etc.)
# --- Stage 2: runtime ---------------------------------------------------------
# Lucee 7 on Java 21. The lucee/lucee image ships a ready webroot at /var/www.
FROM lucee/lucee:7-tomcat10-jre21
LABEL org.opencontainers.image.source="https://github.com/your-org/your-app"
# Wheels reads these at boot. Override per-environment at `docker run` time.
ENV WHEELS_ENV=production \
WHEELS_DATASOURCE=app \
WHEELS_RELOAD_PASSWORD=change-me \
CATALINA_OPTS="-Xms256m -Xmx1g -XX:MaxMetaspaceSize=256m"
WORKDIR /var/www
# Copy the app. Vendor code (framework + activated packages) ships inside the
# image so production boot needs no network access.
COPY --from=builder /build/app ./app
COPY --from=builder /build/config ./config
COPY --from=builder /build/public ./public
COPY --from=builder /build/vendor ./vendor
COPY --from=builder /build/Application.cfc ./Application.cfc
COPY --from=builder /build/index.cfm ./index.cfm
# Tomcat listens on 8080 in the lucee/lucee image.
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=3 \
CMD curl -fsS http://127.0.0.1:8080/health || exit 1

Why multi-stage:

  1. Smaller runtime image. node_modules is several hundred megabytes. Keeping it in the builder stage means it never ships.
  2. Reproducible front-end builds. The builder stage pins Node 20, so the compiled CSS and JS are identical regardless of the host that ran docker build.
  3. One image, any environment. The runtime reads WHEELS_ENV at boot. The same artifact boots against development, staging, or production by swapping env vars — no rebuild.

Keep the build context lean. Everything in .dockerignore is invisible to COPY . ., which means a cleaner image and a faster build.

.dockerignore
.git
.gitignore
.github
.env
.env.*
node_modules
tests
vendor/wheels/tests
public/assets/*.map
logs
tmp
*.log
Dockerfile
docker-compose.yml
README.md
docs
.claude
.ai
.vscode
.idea

Three rules worth calling out:

  • **.env* — never bake secrets into an image. Pass them at runtime (next section).
  • vendor/wheels/tests — the framework test suite is 80+ MB of fixtures you don’t need in production.
  • public/assets/*.map — JavaScript and CSS sourcemaps leak your source to anyone who opens the browser devtools. Drop them on the builder floor.

Compose is enough for a single-host deployment (a VM, a bare-metal box, a Fly.io or Render machine). Here’s an app + Redis sidecar — Redis is optional, but if you use Wheels session storage or the RateLimiter middleware with storage="database" you may want an in-memory cache in front.

docker-compose.yml
services:
app:
build: .
image: ghcr.io/your-org/your-app:latest
restart: unless-stopped
ports:
- "80:8080"
environment:
WHEELS_ENV: production
WHEELS_DATASOURCE: app
WHEELS_RELOAD_PASSWORD: ${WHEELS_RELOAD_PASSWORD:?required}
DB_HOST: db.internal.example.com
DB_NAME: myapp_prod
DB_USER: myapp
DB_PASSWORD: ${DB_PASSWORD:?required}
REDIS_HOST: redis
REDIS_PORT: "6379"
depends_on:
redis:
condition: service_healthy
healthcheck:
test: ["CMD", "curl", "-fsS", "http://127.0.0.1:8080/health"]
interval: 30s
timeout: 5s
retries: 3
start_period: 60s
redis:
image: redis:7-alpine
restart: unless-stopped
command: ["redis-server", "--appendonly", "yes"]
volumes:
- redis-data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 3s
retries: 3
volumes:
redis-data:

Bring it up:

your shell
export WHEELS_RELOAD_PASSWORD="$(openssl rand -hex 32)"
export DB_PASSWORD="$(pbpaste)"
docker compose up -d
docker compose logs -f app

The :?required suffix on an env var makes Compose refuse to start if the variable is missing — cheap guardrail against pushing an image with a blank reload password.

Wheels reads environment variables at boot through config/settings.cfm and config/environment.cfm. The scaffolded app ships with a small set; your app will add more.

VariableWhere it’s readTypical value
WHEELS_ENVconfig/environment.cfm — selects the environment fileproduction
WHEELS_DATASOURCEconfig/settings.cfm — the datasource name your models useapp
WHEELS_RELOAD_PASSWORDconfig/settings.cfm — guards ?reload=truea 32-byte random string
DB_HOST, DB_NAME, DB_USER, DB_PASSWORDApplication.cfc or your custom datasource configyour DB credentials

Resolve these inside the app with env("DB_HOST") — the canonical accessor reads .env first, then the process environment, and takes a fail-closed default:

config/settings.cfm
<cfscript>
set(dataSourceName=env("WHEELS_DATASOURCE", "app"));
set(reloadPassword=env("WHEELS_RELOAD_PASSWORD", ""));
</cfscript>

Keep secrets out of the image. docker compose reads them from your shell’s environment, from a .env file that’s .gitignore’d, or from whatever secret store your host provides (Fly’s fly secrets set, Render’s dashboard, AWS Parameter Store).

The Dockerfile and Compose file above both call GET /health. That route doesn’t exist in Wheels out of the box — add it yourself. A health action is any controller action that responds quickly without touching external services.

app/controllers/Health.cfc
component extends="Controller" {
function index() {
// Cheap check — no DB call. Kubernetes/Docker probes run this every
// few seconds, so keep it under a millisecond.
renderText(text="ok", status=200);
}
function ready() {
// Readiness check — does touch the DB. Use this for load balancer
// drain/enable logic, not for the fast Docker HEALTHCHECK.
try {
model("User").count();
renderText(text="ready", status=200);
} catch (any e) {
renderText(text="not ready: #e.message#", status=503);
}
}
}

Wire it into the router before the wildcard():

config/routes.cfm
component {
function config() {
mapper()
.get(name="health", pattern="/health", to="health##index")
.get(name="ready", pattern="/ready", to="health##ready")
.wildcard()
.end();
}
}

Two endpoints, two purposes. /health is for the container runtime — fast and dumb, answers “is the process alive.” /ready is for your load balancer — slower, answers “can this instance serve traffic.” Point the HEALTHCHECK at /health and the Relianoid/ALB health probe at /ready.

A clean Wheels runtime image lands around 500–700 MB — most of that is Tomcat, Lucee, and the JRE. You can push it lower:

  • JRE, not JDK. The lucee/lucee:7-tomcat10-jre21 tag is ~150 MB smaller than jdk21.
  • Slim base. If you really need sub-300 MB, build on a slim JRE + install Lucee manually. The prebuilt image is worth the extra MB on every host except the most constrained edge nodes.
  • Drop test fixtures. .dockerignore should always exclude vendor/wheels/tests and your own tests/ directory.
  • Layer cache. COPY package.json package-lock.json ./ before COPY . . so a docs-only change doesn’t re-run npm ci.
  • One RUN per concern. Every RUN creates a layer. Combine apt-get update && apt-get install && rm -rf /var/lib/apt/lists/* into a single line.

Alpine is tempting — the base image is tiny — but Lucee on Alpine has historically had glibc/musl issues with some JDBC drivers. The Debian-slim-based lucee/lucee:7-tomcat10-jre21 is the path of least resistance for production.

If some of your hosts are ARM (a Mac laptop for dev, Graviton EC2 in production) and others are x86, publish a single tag that serves both. docker buildx handles it.

One-time setup:

your shell
docker buildx create --name multiarch --use
docker buildx inspect --bootstrap

Then build and push:

your shell
docker buildx build \
--platform linux/amd64,linux/arm64 \
--tag ghcr.io/your-org/your-app:latest \
--tag ghcr.io/your-org/your-app:v4.0.0 \
--push \
.

The registry now has a manifest list — docker pull on an arm64 Mac fetches the arm64 variant, on an amd64 box it fetches amd64. No other changes needed on the consumer side.

The same Dockerfile pattern works on other CFML engines — only the base image and startup command differ.

  • Adobe ColdFusion 2023: FROM adobecoldfusion/coldfusion:2023-ubi8-latest. The webroot is /app. Set your datasource in the CF admin or via a neo-datasource.xml copied in at build time.
  • BoxLang: FROM ortussolutions/boxlang:latest. Boot with CMD ["boxlang", "miniserver"] and expose 8080.

Everything else — .dockerignore, Compose wiring, env vars, health check action, buildx — is identical. Wheels behaves the same across engines; Docker behaves the same regardless of what’s inside the container.