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
.dockerignoreand how to pass secrets through environment variables - How to add a health check endpoint to a Wheels app
- How to publish
linux/amd64+linux/arm64images withbuildx
Why Lucee 7 and Java 21
Section titled “Why Lucee 7 and Java 21”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.
The Dockerfile
Section titled “The Dockerfile”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.
# 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 ./appCOPY --from=builder /build/config ./configCOPY --from=builder /build/public ./publicCOPY --from=builder /build/vendor ./vendorCOPY --from=builder /build/Application.cfc ./Application.cfcCOPY --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 1Why multi-stage:
- Smaller runtime image.
node_modulesis several hundred megabytes. Keeping it in the builder stage means it never ships. - 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. - One image, any environment. The runtime reads
WHEELS_ENVat boot. The same artifact boots against development, staging, or production by swapping env vars — no rebuild.
The .dockerignore
Section titled “The .dockerignore”Keep the build context lean. Everything in .dockerignore is invisible to COPY . ., which means a cleaner image and a faster build.
.git.gitignore.github.env.env.*node_modulestestsvendor/wheels/testspublic/assets/*.maplogstmp*.logDockerfiledocker-compose.ymlREADME.mddocs.claude.ai.vscode.ideaThree 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.
docker-compose.yml
Section titled “docker-compose.yml”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.
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:
export WHEELS_RELOAD_PASSWORD="$(openssl rand -hex 32)"export DB_PASSWORD="$(pbpaste)"docker compose up -ddocker compose logs -f appThe :?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.
Environment variables
Section titled “Environment variables”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.
| Variable | Where it’s read | Typical value |
|---|---|---|
WHEELS_ENV | config/environment.cfm — selects the environment file | production |
WHEELS_DATASOURCE | config/settings.cfm — the datasource name your models use | app |
WHEELS_RELOAD_PASSWORD | config/settings.cfm — guards ?reload=true | a 32-byte random string |
DB_HOST, DB_NAME, DB_USER, DB_PASSWORD | Application.cfc or your custom datasource config | your 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:
<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).
Health check
Section titled “Health check”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.
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():
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.
Image size
Section titled “Image size”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-jre21tag is ~150 MB smaller thanjdk21. - 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.
.dockerignoreshould always excludevendor/wheels/testsand your owntests/directory. - Layer cache.
COPY package.json package-lock.json ./beforeCOPY . .so a docs-only change doesn’t re-runnpm ci. - One
RUNper concern. EveryRUNcreates a layer. Combineapt-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.
Multi-arch images with buildx
Section titled “Multi-arch images with buildx”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:
docker buildx create --name multiarch --usedocker buildx inspect --bootstrapThen build and push:
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.
Adobe ColdFusion and BoxLang
Section titled “Adobe ColdFusion and BoxLang”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 aneo-datasource.xmlcopied in at build time. - BoxLang:
FROM ortussolutions/boxlang:latest. Boot withCMD ["boxlang", "miniserver"]and expose8080.
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.