Day 3 of the CI/CD for Ignition Masterclass.
Bake an Ignition gateway's project, config, and modules into a versioned Docker image, push it to a registry, and deploy by pulling the image and recreating the container — then promote the exact same image you tested on the test gateway to production on a tag, with rollback as easy as running a previous tag.
This is the companion to Lab 04 (file-based deploy). There you docker cp-ed files into a running gateway and triggered a hot scan — fast, great for daily project iteration, but it couldn't touch modules (a scan can't enable/disable them) and gave you no versioned, rollback-able artifact. Image-based deploy is the other half: the deployable state becomes an immutable image. Most mature Ignition workflows use both — file-based for the inner loop, image-based for releases.
How resource.json stays clean: Ignition restamps these manifests constantly.
docs/resource-json-hygiene.htmlexplains what the junk is, the two tools that deal with it, and why an emptygit diffdoes not mean a clean file.
| Lab 04 (file-based) | Lab 05 (image-based) | |
|---|---|---|
| Unit of deploy | Files on disk | A versioned image |
| Mechanism | docker cp + hot scan |
docker pull + recreate container |
| Modules | Can't ship (scan won't apply) | Baked into the image |
| Rollback | Re-deploy old files | Run the previous tag |
| Promotion | Re-copy per environment | Re-tag the tested image |
| Where it shines | Active development | Immutable releases |
The local → test → production story is the same; only the test/production delivery mechanism changes.
- ≥ 8 GB free RAM for Docker — three Ignition gateways at 1 GB each, plus TimescaleDB and Docker overhead.
- Optional, for the stretch challenge only: a fork of this repo. The CI builds publish images to your own GHCR namespace (
ghcr.io/<your-fork-owner>/cicd-lab-05-ignition) — never to Mustry's. Enable Actions in your fork first: forks ship with workflows disabled — open the Actions tab and click the green "I understand my workflows, go ahead and enable them" button. The lab itself needs no fork, no PAT, and no GitHub account. - Background: Lab 04 sets up the Ignition stack and the file-based pattern this lab contrasts with. It helps but isn't required — this lab stands alone.
- No extra registry account: the CI publishes to GitHub Container Registry (GHCR) under your fork, authenticated with the workflow's built-in
GITHUB_TOKEN. The local scripts (build-image.sh/deploy-image.sh) need no registry at all — they build and run images on your machine.
WSL2 (Windows): keep the clone in your Linux home (
~/…), never/mnt/c/…. On the Windows filesystem your Windows user, your WSL user and the gateway's container user are three different identities, so file ownership breaks in wayschowncannot fix and you end up reaching forsudo(which makes it worse).scripts/setup.shrefuses to run from there, and never needssudo. Seedocs/wsl-setup.md.
gh repo clone mustry-academy/cicd-lab-05-ignition-image-based-deploy
cd cicd-lab-05-ignition-image-based-deploy
cp .env.example .env
scripts/setup.sh # brings up the stack, waits for all three gateways, prints credentialsOnce setup finishes you have three Ignition gateways:
| Gateway | URL | What runs there |
|---|---|---|
local |
http://localhost:8088 | Bind-mounted from ./projects/ + ./services/config/ — your authoring gateway (file-based, like Lab 04). |
test |
http://localhost:8089 | The image deploy.yml builds on push to main. Base image (empty) until the first deploy. |
production |
http://localhost:8090 | The image release.yml promotes on tag push v* (cut from main). Base image (empty) until the first release. |
Login with the credentials from .env (GATEWAY_ADMIN_USERNAME_LOCAL/_TEST/_PRODUCTION, default admin / password).
Trial mode: each gateway runs in 2-hour trial mode. Reset via Gateway → Config → Licensing → Reset Trial — unlimited and legal for development. Note: because test/production are recreated from a fresh image on each deploy, their trial clock resets every deploy too.
Stuck? See
docs/TROUBLESHOOTING.md. Before opening a PR, runscripts/validate.sh(mirrors CI) andscripts/build-image.sh(confirms the image builds).
The lab is one exercise in two ordered parts — see exercises/lab.md:
- Build the gateway image — bake projects, config, and modules into a self-contained image.
- Deploy the image — recreate the test gateway from it, ship a change end-to-end, roll back.
Reference reading sits alongside: docs/dockerfile-anatomy.md (part 1) and docs/image-based-deploy-pattern.md (part 2).
cicd-lab-05-ignition-image-based-deploy/
├── README.md
├── Dockerfile ← bakes projects + config + modules into the gateway image
├── .dockerignore ← what NOT to send to the build context (the image-based .deployignore)
├── docker-compose.yaml ← three gateways + TimescaleDB
├── .env.example ← copy to .env before running
├── .gitattributes ← JSON line-ending normalization + binary markers
├── .github/
│ ├── workflows/
│ │ ├── ci.yml ← PR validation: JSON, hadolint, actionlint, build smoke test (ubuntu-latest)
│ │ ├── deploy.yml ← push to main → build+push image, print the tag to deploy by hand
│ │ └── release.yml ← tag v* (on main) → re-tag the test image to :vX.Y.Z + :production
│ └── pull_request_template.md
├── exercises/
│ └── lab.md ← the lab, in two ordered parts: build the image, then deploy it
├── db-init/ ← timescaledb init: create ignition_test + ignition_production databases
├── docs/ ← reference reading
│ ├── dockerfile-anatomy.md
│ ├── image-based-deploy-pattern.md
│ └── TROUBLESHOOTING.md
├── instructor-notes/ ← answer key (read after solo work)
│ └── lab-key.md
├── scripts/
│ ├── setup.sh ← bootstraps the whole stack
│ ├── teardown.sh ← stop the stack (with --volumes to wipe)
│ ├── generate-api-keys.sh ← per-gateway scan-API keys into .env (never committed, never baked; run by setup.sh)
│ ├── install-api-token.sh ← put a gateway's token into its container after a deploy (run by deploy-image.sh)
│ ├── build-image.sh ← local mirror of the CI build (build + tag)
│ ├── deploy-image.sh ← local mirror of the CI deploy (recreate a gateway from an image)
│ ├── trigger-scan.sh ← scan the LOCAL gateway (the file-based inner loop)
│ ├── validate.sh ← mirrors CI (JSON, .dockerignore, hadolint, actionlint)
│ ├── lib.sh ← shared helpers
│ ├── clean-ignition-resource-churn.sh ← undo volatile-only resource.json rewrites (dry-run / --apply)
│ ├── git-diff/ ← textconv normalizer that hides volatile metadata in diffs
│ └── git-hooks/ ← skip-worktree hooks for the machine-local config file
├── projects/ ← project content (baked into the image; bind-mounted into `local`)
│ └── example-project/ ← a real Perspective project (views, templates)
├── services/
│ ├── config/ ← gateway-level config (baked into the image)
│ └── modules.json ← module enablement (baked into the image — this is the Lab 04 payoff)
└── third-party-modules/ ← bundled .modl binaries (baked into the image)
Three Ignition 8.3 gateways + one TimescaleDB, simulating local → test → production:
ignition-localbind-mounts./projects/and./services/config/, exactly like Lab 04. This is where you author content. The image you ship is built from these same files.ignition-testruns whatever imageIGNITION_TEST_IMAGEpoints at (default: the base Ignition image — an empty gateway). It has no bind mounts and no persistent data volume — the container filesystem is the artifact.deploy.ymlsetsIGNITION_TEST_IMAGEto the freshly built tag and recreates the container.ignition-productionis the same shape, fed byrelease.ymlwith the promoted image (the same one the test gateway tested).
The single TimescaleDB hosts ignition_local_development/ignition_test/ignition_production. Historian data lives in Timescale, not in the gateway image — which is exactly why throwing away and recreating the test/production container on every deploy is safe.
name: cicd-lab05 at the top of the compose file pins the project name so the stack always resolves to these containers, whichever directory you run compose from, instead of spinning up a parallel set under a directory-derived name.
This lab uses GitHub flow: one long-lived branch, releases cut by tagging — the same flow as every previous lab.
feature/* ──PR→ main ──push→ deploy.yml ──build+push→ :test image ──→ TEST gateway
│
└─tag vX.Y.Z→ release.yml ──promote :test→ :vX.Y.Z+:production ──→ PRODUCTION gateway
| Branch | Role | What CI does |
|---|---|---|
main |
The only long-lived branch — every merge should be deployable | deploy.yml builds the image and ships it to the test gateway |
feature/* |
Day-to-day work, branched off main |
ci.yml validates the PR into main |
tag vX.Y.Z |
A release — stamp the main state you want in production |
release.yml promotes the tested image to production |
Releasing is promotion, not a rebuild. release.yml promotes the image test already
tested — the :test tag — re-tagging it to :vX.Y.Z + :production. Production runs the exact digest test
validated; rebuilding from the tagged commit could silently pull a newer base layer and ship bytes
test never ran. Tag when test is where you want production to be.
Three workflows under .github/workflows/:
| File | Trigger | Runner | Purpose |
|---|---|---|---|
ci.yml |
PR to main |
ubuntu-latest |
Validate JSON + .dockerignore, lint the Dockerfile (hadolint) and workflows (actionlint), and build the image (no push) so a broken Dockerfile fails the PR. |
deploy.yml |
Push to main (build paths), manual |
ubuntu-latest |
Build + push the image to GHCR (:sha-<short>, :test) and print the tag in the run summary. |
release.yml |
Tag v* (on main), manual |
ubuntu-latest |
Re-tag the :test image (what the test gateway is running) to :vX.Y.Z + :production (no rebuild) and print the tag. |
Every workflow runs on a free GitHub-hosted runner — this lab stands up no self-hosted runner at all. Notice what that means: CI can build and promote images, but it cannot deploy one, because deploying means touching a machine that owns a gateway container. So in this lab you deploy by hand: take the image name the workflow printed, put it in IGNITION_TEST_IMAGE, and run docker compose up -d ignition-test yourself.
That division is the point. The build half is portable and cheap; the last mile needs a privileged runner sitting next to the gateway. Labs 06 and 07 add that runner — here you play its part manually, so you can see exactly what it will be doing for you.
- The build/promote jobs push to your own fork's namespace — the workflows derive
ghcr.io/<owner>/cicd-lab-05-ignitionfromgithub.repository_owner, so a student's fork publishes under their account, not Mustry's. No image ever pushes to a registry you don't own. - Auth is the workflow's built-in
GITHUB_TOKEN(the workflows requestpackages: write). No registry password in.env, no extra account. - The first push creates a GHCR package under your namespace. It defaults to private — so to pull it by hand you need to
docker login ghcr.ioonce (username = your GitHub user, password = arepo+read:packagestoken), or flip the package to public in its settings. - If your fork lives under a GitHub organization, the org may restrict this: Settings → Actions → General → Workflow permissions must allow read and write, and Packages must be enabled. A 403 on push almost always traces back to one of these — see
docs/TROUBLESHOOTING.md. - No GHCR needed for the lab exercises — the local
build-image.sh/deploy-image.shflow runs entirely on your machine. GHCR only enters via the CI workflows.
No GitHub environments are needed here, since nothing deploys from CI. When you do wire up a deploy job (Lab 06/07), putting it behind an environment is what gives you per-stage history and required reviewers as an approval gate before production.
Part 2 of exercises/lab.md walks through the deploy flow by hand — the flow a pipeline will eventually automate for you.
Apache 2.0 — see LICENSE.