Lesson Β inΒ  ColdFusion 2025: Foundations

CI/CD for CFML Applications

Build automated pipelines for ColdFusion apps using CommandBox, GitHub Actions, and Docker. Package, test, and deploy CFML apps without touching a server manually.

What is CI/CD?

CI (Continuous Integration) means every code change is automatically tested before it is merged. CD (Continuous Delivery) means every passing build is automatically packaged and ready to deploy.

For a ColdFusion application the full pipeline looks like this:

Linear pipeline diagram showing five stages connected by rightward arrows β€” stage 1 "Git push", stage 2 "CI server triggered", stage 3 "box install + box testbox run" with a red X showing failing tests stop the pipeline, stage 4 "docker build -t cfml-app", stage 5 "push to registry and deploy"

Tests gate the build β€” a failing TestBox run stops the Docker image from being created.

Note

πŸ“Œ Scope of this lesson

Running a complete CI/CD pipeline requires a Git server, a container registry, and a CI runner. That infrastructure is out of scope for this foundations course.

In this lesson you will build the two artefacts the pipeline depends on: a box.json manifest and a Dockerfile. Then you will build a Docker image locally β€” the same command the pipeline runs.

The full pipeline β€” Git server, registry, automated tests, and deployments using Gitea β€” is covered in the ColdFusion Advanced course.


1. box.json β€” the project manifest

box.json is the CommandBox project manifest. It declares your app name, version, and ForgeBox dependencies β€” similar to package.json in Node.js or composer.json in PHP.

{
  "name": "helpdesk-app",
  "version": "1.0.0",
  "dependencies": {
    "testbox": "^5.0.0"
  }
}

box install reads this file and installs all declared packages into a modules/ directory. In a CI pipeline the build agent runs box install first β€” this file is the only thing it needs to recreate the full dependency tree on a clean machine.


2. Dockerfile β€” containerise your app

What is a Dockerfile and why does CFML need one?

A Dockerfile is a plain-text recipe that tells Docker how to build a container image β€” a self-contained, portable package that includes your application code, its runtime, and all dependencies. Once built, the image runs identically on any machine that has Docker installed: a developer laptop, a CI server, or a cloud VM.

Why containerise a ColdFusion application?

Without a containerWith a container
"Works on my machine" β€” CF version, JVM flags, and file paths differ per serverOne image runs the same everywhere
Manual server setup β€” install CF, configure datasources, set JVM heapdocker run does it all in one command
Deploying means copying files and restarting CFDeploying means swapping one image tag for another

How it relates to ColdFusion: your CFML files are just files β€” they need a running CF or Lucee engine to execute. The FROM ortussolutions/commandbox:latest base image provides that engine, so you only need to copy your code on top of it and install your dependencies.

A Dockerfile is always a text file named exactly Dockerfile (no extension). Docker reads it top to bottom, executes each instruction as a layer, and produces a tagged image you can push to a registry and pull anywhere.

Annotated Dockerfile with four callout labels β€” FROM ortussolutions/commandbox:latest labelled "Official CommandBox base image (Java + Lucee bundled)"; COPY and WORKDIR labelled "Copy project files"; RUN box install --production labelled "Install dependencies, skip dev packages"; EXPOSE 8888 and CMD labelled "Start server in foreground"

The base image handles the runtime β€” you just copy code and install packages.

FROM ortussolutions/commandbox:latest

COPY . /app
WORKDIR /app

RUN box install --production

EXPOSE 8888
CMD ["box", "server", "start", "--console"]
LineWhat it does
FROM ortussolutions/commandbox:latestOfficial CommandBox base β€” Java + Lucee already bundled
COPY . /appCopies your CFML project files into the image
RUN box install --productionInstalls ForgeBox dependencies, skips dev packages like TestBox
EXPOSE 8888Documents which port the server listens on
CMD [...]Starts the Lucee server in the foreground when the container runs
πŸ’‘ Why not bake credentials into the Dockerfile?

A Docker image is a portable artefact β€” anyone who can pull it can inspect every layer. Credentials baked into the image (datasource passwords, API keys) are exposed to anyone with registry access, and they end up in git history too.

The right pattern is to inject credentials at runtime via environment variables. In a CI pipeline, secrets are stored in the CI server (GitHub Actions Secrets, Gitea Secrets) and passed to the container on start β€” never written into the image.


Activity 1 β€” Create box.json

Note

Why /home/laborant/app/ and not wwwroot/student/?

box.json and Dockerfile are source code artefacts, not served files. They belong in a project directory β€” the kind you would commit to Git and hand to a CI runner. Putting them inside the CF web root (wwwroot/) would expose them over HTTP, which is a security risk.

/home/laborant/app/ is the student's project home β€” it already exists in the lab environment (it was seeded with the CommandBox server.json scaffold when the image was built). In a real project this would be your Git repository root.

Create /home/laborant/app/box.json:

Terminal tab (no sudo needed β€” /home/laborant/app/ is your home directory):

mkdir -p /home/laborant/app
tee /home/laborant/app/box.json << 'EOF'
{
  "name": "helpdesk-app",
  "version": "1.0.0",
  "dependencies": {
    "testbox": "^5.0.0"
  }
}
EOF
✏️ Using the IDE tab instead? Create the file here

In the IDE tab, click File β†’ Open Folder…, type /home/laborant/app and press Enter. Right-click in the Explorer panel β†’ New File β†’ name it box.json, paste the content below, and save with Ctrl+S:

{
  "name": "helpdesk-app",
  "version": "1.0.0",
  "dependencies": {
    "testbox": "^5.0.0"
  }
}

Verify:

cat /home/laborant/app/box.json

Now create a .gitignore so modules/ is never committed to Git:

tee /home/laborant/app/.gitignore << 'EOF'
# CommandBox β€” never commit installed packages
modules/

# CommandBox server state
.server/
server.json.bak

# OS and editor noise
.DS_Store
.vscode/
EOF
✏️ Using the IDE tab instead? Create the file here

In the IDE tab, right-click in the Explorer panel β†’ New File β†’ name it .gitignore, paste the content below, and save with Ctrl+S:

# CommandBox β€” never commit installed packages
modules/

# CommandBox server state
.server/
server.json.bak

# OS and editor noise
.DS_Store
.vscode/
Project directory tree showing two columns β€” left column labelled "Committed to Git" contains box.json, Dockerfile, .dockerignore, .gitignore, server.json, and .cfm source files with green checkmarks; right column labelled "Ignored by Git" contains modules/ folder and .server/ folder with red X marks β€” an arrow from box.json points to modules/ with the label "box install recreates this"

modules/ is never committed β€” box.json is the source of truth. Any developer or CI runner recreates it with box install.

Why must modules/ be in .gitignore?

box install downloads ForgeBox packages into a modules/ directory β€” the CFML equivalent of node_modules/ in Node.js. This directory can easily reach hundreds of MB and contains third-party code that is already versioned on ForgeBox.

Committing it to Git:

  • Bloats your repository permanently
  • Creates merge conflicts when teammates run box install on different platforms
  • Makes git clone painfully slow for new team members

The correct workflow β€” identical to npm:

git clone <repo>        # no modules/ β€” just source code
box install             # recreates modules/ from box.json in seconds

Any CI runner does the same: git clone β†’ box install β†’ run tests β†’ build image. The box.json file is the single source of truth.


Activity 2 β€” Create the Dockerfile

Create /home/laborant/app/Dockerfile:

Terminal tab:

tee /home/laborant/app/Dockerfile << 'EOF'
FROM ortussolutions/commandbox:6.3.4

COPY . /app
WORKDIR /app

RUN box install --production

EXPOSE 8888
CMD ["box", "server", "start", "--console"]
EOF
✏️ Using the IDE tab instead? Create the file here

In the IDE tab, right-click in the Explorer panel β†’ New File β†’ name it Dockerfile, paste the content below, and save with Ctrl+S:

FROM ortussolutions/commandbox:6.3.4

COPY . /app
WORKDIR /app

RUN box install --production

EXPOSE 8888
CMD ["box", "server", "start", "--console"]
πŸ’‘ Why pin the version tag β€” and not use :latest?

FROM ortussolutions/commandbox:latest always pulls whichever version Ortus last published. That means:

  • Two developers building on different days can get different images
  • A pipeline that worked yesterday can silently fail today after an upstream update
  • You cannot reproduce a past build reliably

Pin to a specific version tag instead:

FROM ortussolutions/commandbox:6.3.4

Now every build β€” locally, on CI, in production β€” uses the exact same base. If you need to upgrade, you change the tag deliberately and test the result. latest is convenient for demos; pinned tags are mandatory for production.

Now create a .dockerignore so COPY . /app doesn't bloat the image:

tee /home/laborant/app/.dockerignore << 'EOF'
# Never copy installed packages into the image β€” box install runs inside the build
modules/

# Git history has no place in a production image
.git/
.gitignore

# Local server state β€” not needed in the image
.server/
server.json.bak

# Editor files
.vscode/
.DS_Store
EOF
✏️ Using the IDE tab instead? Create the file here

In the IDE tab, right-click in the Explorer panel β†’ New File β†’ name it .dockerignore, paste the content below, and save with Ctrl+S:

# Never copy installed packages into the image β€” box install runs inside the build
modules/

# Git history has no place in a production image
.git/
.gitignore

# Local server state β€” not needed in the image
.server/
server.json.bak

# Editor files
.vscode/
.DS_Store
Why does .dockerignore matter β€” what happens without it?

COPY . /app copies everything in the build context to the image. Without a .dockerignore:

What gets copiedProblem
modules/Hundreds of MB of packages β€” then RUN box install adds them again. Double the size.
.git/Full Git history baked into the image β€” leaks commit messages, author names, and potentially secrets from past commits
.vscode/, .DS_StoreNoise β€” no effect but adds unnecessary bytes

A .dockerignore works exactly like .gitignore β€” patterns listed there are excluded from the build context before Docker even starts processing the Dockerfile. The result is a smaller, cleaner, faster-to-push image.

Verify:

cat /home/laborant/app/Dockerfile
cat /home/laborant/app/.dockerignore

Activity 3 β€” Build the Docker image

Build the image tagged cfml-app:

docker build -t cfml-app /home/laborant/app/

You will see Docker work through the Dockerfile line by line β€” each instruction becomes a layer:

Step 1/5 : FROM ortussolutions/commandbox:6.3.4
 ---> pulling base image ...
Step 2/5 : COPY . /app
 ---> copied project files
Step 3/5 : WORKDIR /app
 ---> set working directory
Step 4/5 : RUN box install --production
 ---> installing ForgeBox dependencies ...
Step 5/5 : CMD ["box", "server", "start", "--console"]
 ---> set default start command
Successfully built a1b2c3d4e5f6
Successfully tagged cfml-app:latest
Diagram showing the docker build process β€” on the left a Dockerfile with five instructions (FROM, COPY, WORKDIR, RUN, CMD); each instruction has an arrow pointing right to a stacked layer in the centre labelled "Image layers (read-only)"; the final stack is sealed with a tag label "cfml-app:latest" on the right β€” a clock icon on the FROM layer is labelled "~500 MB, downloaded once then cached", and a lightning bolt on layers 2-5 is labelled "cache hit on rebuild if unchanged"

Each Dockerfile instruction creates one immutable layer β€” unchanged layers are reused from cache on every subsequent build.

Run the build a second time immediately β€” every step shows ---> Using cache. Docker detected that nothing changed and reused all five layers. This is why CI builds after the first are fast.

Confirm the image exists:

docker images | grep cfml
🐒 First build is slow β€” here is why and what each layer caches.

Docker pulls the ortussolutions/commandbox:6.3.4 base image on the first build β€” roughly 500 MB. After that it is cached locally and never downloaded again unless you change the FROM tag.

Each subsequent instruction is also cached independently:

LayerInvalidated when...
FROMYou change the base image tag
COPY . /appAny file in the build context changes
RUN box install --productionbox.json changes (because COPY runs first)

This is why COPY comes before RUN box install β€” if the order were reversed, a single .cfm file change would invalidate the box install cache and re-download all packages on every build.


When all the checks above are green, this lesson is complete. Your progress is saved automatically β€” move straight on to the next lesson.

Note

Found a bug or an issue with this lesson? Please reach out β€” your feedback helps improve the course for everyone.

πŸ“§ Alex β€” mercadoalexatgmail.com