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.1 + Java 25 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 recommended base is the stable Lucee 7.1 Docker image, lucee/lucee:7.1.0.204, which runs on Java 25 (Temurin LTS). Lucee 7 boots faster than Lucee 5 or 6, 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 . .
# npm steps are for apps with a front-end build; they skip when there's no package.json.
RUN if [ -f package.json ]; then npm ci --no-audit --no-fund; fi
RUN if [ -f package.json ]; then npm run build; fi # produces public/assets/ (Vite, Tailwind, etc.)
# --- Stage 2: runtime ---------------------------------------------------------
# Lucee 7.1 (stable) on Java 25. The lucee/lucee image serves /var/www as its
# webroot and listens on 8888 — both corrected below, plus a URL-rewrite valve.
FROM lucee/lucee:7.1.0.204
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
# Point Tomcat's ROOT context at Wheels' public/ webroot, add a URL-rewrite
# valve for clean URLs, and make the connector follow $PORT (default 8888).
RUN sed -i 's#<Context path="" docBase="/var/www/">#<Context path="" docBase="/var/www/public">\n <Valve className="org.apache.catalina.valves.rewrite.RewriteValve" />#' /usr/local/tomcat/conf/server.xml \
&& sed -i 's#port="8888"#port="${http.port}"#' /usr/local/tomcat/conf/server.xml \
&& printf 'export CATALINA_OPTS="$CATALINA_OPTS -Dhttp.port=${PORT:-8888}"\n' > /usr/local/tomcat/bin/setenv.sh \
&& mkdir -p /var/www/public/WEB-INF \
&& printf '%s\n' \
'RewriteRule ^/?$ - [L]' \
'RewriteRule ^/index\.cfm($|/) - [L]' \
'RewriteRule ^/(cf_script|flex2gateway|jrunscripts|CFIDE|lucee|cfformgateway|cffileservlet|files|images|javascripts|miscellaneous|stylesheets|assets|wheels|uploads|robots\.txt|favicon\.ico|sitemap\.xml)(/.*)?$ - [L]' \
'RewriteCond %{HTTP:Upgrade} ^websocket$ [NC]' \
'RewriteRule ^/ws/.*$ - [L]' \
'RewriteRule ^/(.*)$ /index.cfm/$1 [L]' \
> /var/www/public/WEB-INF/rewrite.config
# Install curl for the HEALTHCHECK probe.
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/*
# 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
EXPOSE 8888
HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=3 \
CMD curl -fsS "http://127.0.0.1:${PORT:-8888}/up" || 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 generated config/environment.cfm reads WHEELS_ENV at boot, so the same artifact boots against development, staging, or production by swapping env vars — no rebuild. Apps generated before Wheels 4.2 hardcode the environment instead; change config/environment.cfm before you build, or the container runs in development mode.

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:8888"
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:8888/up"]
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 (apps generated before Wheels 4.2 don’t read WHEELS_ENV or WHEELS_DATASOURCE; see the note above); 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 /up. That route ships with every wheels new app (app/controllers/Up.cfc), so it works out of the box. For richer probes you can add your own controller. 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 purposes, two probes. /up (built-in) and /health (below) answer “is the process alive” for the container runtime; /ready answers “can this instance serve traffic” for your load balancer. Point the HEALTHCHECK at /up (or /health) and the ALB/load-balancer 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 plain lucee/lucee:7.1.0.204 tag is the JRE image — ~150 MB smaller than the -jdk25 variant.
  • 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.1.0.204 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.