Jenkins · lesson 6 of 7
Jenkins Controller as Code: Plugins and JCasC
Build a Jenkins controller image with pinned plugins and Configuration as Code: an admin user, a credential, executors, and a seed job, all from YAML.
By SkillByExample · Updated
A Jenkins controller built from clicked-through UI settings cannot be reproduced, reviewed,
or rolled back. Configuration as Code (JCasC) fixes that for Jenkins’s own settings, the
same way a plugins.txt fixes it for which plugins are installed.
1) The image: plugins pinned by exact version
jenkins/jenkins:lts-jdk21 is the official LTS image built on JDK 21. Pulled on
2026-10-26, its JENKINS_VERSION is 2.580.1, running on Temurin
21.0.12.1+1. It ships with no plugins at all — jenkins-plugin-cli installs exactly
what plugins.txt lists, and nothing else.
# plugins.txt
configuration-as-code:2136.veb_3a_554550b_f
job-dsl:3732.v9a_c49a_61a_313
credentials:1514.vb_01eb_014e7d4
plain-credentials:199.v9f8e1f741799
# Dockerfile
FROM jenkins/jenkins:lts-jdk21
ENV JAVA_OPTS="-Djenkins.install.runSetupWizard=false"
ENV CASC_JENKINS_CONFIG=/var/jenkins_casc/jenkins.yaml
COPY plugins.txt /usr/share/jenkins/ref/plugins.txt
RUN jenkins-plugin-cli --plugin-file /usr/share/jenkins/ref/plugins.txt
COPY jenkins.yaml /var/jenkins_casc/jenkins.yaml
$ docker build -t skillbyexample-jenkins-jcasc:good . > build-good.log 2>&1; echo "exit=$?"
exit=0
$ grep -E "^#7|^#9 writing" build-good.log
#7 [3/4] RUN jenkins-plugin-cli --plugin-file /usr/share/jenkins/ref/plugins.txt
#7 11.81 Done
#7 DONE 12.0s
#9 writing image sha256:4016f7e2bca7db60708cda9079e343e4c41c7745ab0947ad73edf1710b9d1f7e done
Every version in plugins.txt is an exact release, not :latest — a rebuild six months
from now installs the same four plugins, byte for byte. JAVA_OPTS turns off the
first-run setup wizard, and CASC_JENKINS_CONFIG points at the YAML file baked in by the
last COPY, placed outside /var/jenkins_home so it survives independently of the
Jenkins data volume.
2) jenkins.yaml: the controller’s own settings
# jenkins.yaml
jenkins:
systemMessage: "Managed by JCasC. Edit jenkins.yaml, not the UI."
numExecutors: 2
securityRealm:
local:
allowsSignup: false
users:
- id: "${JENKINS_ADMIN_ID}"
password: "${JENKINS_ADMIN_PASSWORD}"
authorizationStrategy:
loggedInUsersCanDoAnything:
allowAnonymousRead: false
unclassified:
location:
url: "http://host.docker.internal:8080/"
credentials:
system:
domainCredentials:
- credentials:
- string:
scope: GLOBAL
id: "deploy-token"
secret: "${DEPLOY_TOKEN}"
description: "Deploy token, set from JCasC"
jobs:
- script: >
job('seed-job') {
description('Created by the JCasC job-dsl seed script')
steps {
shell('echo hello from seed-job')
}
}
${JENKINS_ADMIN_ID}, ${JENKINS_ADMIN_PASSWORD}, and ${DEPLOY_TOKEN} are never
literals — JCasC resolves ${VAR} against the controller’s environment when it reads the
file, so the committed YAML carries only variable names. The jobs: block hands a
job-dsl script to the job-dsl plugin, which creates seed-job as part of the same boot;
it needs no separate seed step. unclassified.location.url sets Jenkins’s own idea of its
root URL. The crumb-protected REST endpoints used below don’t check it — export and
reload work the same with or without it — but the controller itself compares an incoming
CLI request’s origin against this value, and jenkins-cli.jar (section 2 of the next
lesson) is what surfaces the rejection:
$ docker run --rm -v "$(pwd):/work" eclipse-temurin:21 java -jar /work/jenkins-cli.jar -s http://host.docker.internal:8080/ -auth admin:s3cr3t-admin-pw list-plugins 2>&1 | grep -E "handshake failed|X-CLI-Error"
CLI handshake failed with status code 403
X-CLI-Error: Unexpected request origin (check your reverse proxy settings)
That’s what happens with location.url set to http://localhost:8080/ while connecting
through host.docker.internal instead — a mismatch, not a missing value. Set it to
whatever hostname you actually connect through (host.docker.internal is how a sibling
container reaches a port Docker published on the Windows/Mac host; on Linux, localhost
or --network host works directly), and the same command returns the plugin list.
The secrets themselves live in a file kept outside the repo:
# secrets.env (not committed)
JENKINS_ADMIN_ID=admin
JENKINS_ADMIN_PASSWORD=s3cr3t-admin-pw
DEPLOY_TOKEN=s3cr3t-deploy-token-xyz
3) Booting it
$ docker run -d --name jcasc-demo -p 8080:8080 --env-file secrets.env skillbyexample-jenkins-jcasc:good
$ docker logs jcasc-demo 2>&1 | grep -E "System config|DSL script|seed-job|Loaded all jobs|Completed initialization|fully up and running"
2026-10-27 03:09:09.625+0000 [id=58] INFO jenkins.InitReactorRunner$1#onAttained: System config loaded
Processing provided DSL script
2026-10-27 03:09:10.913+0000 [id=58] INFO j.j.plugin.JenkinsJobManagement#createOrUpdateConfig: createOrUpdateConfig for seed-job
2026-10-27 03:09:11.053+0000 [id=58] INFO jenkins.InitReactorRunner$1#onAttained: System config adapted
2026-10-27 03:09:11.054+0000 [id=58] INFO jenkins.InitReactorRunner$1#onAttained: Loaded all jobs
2026-10-27 03:09:11.093+0000 [id=71] INFO jenkins.InitReactorRunner$1#onAttained: Completed initialization
2026-10-27 03:09:11.119+0000 [id=42] INFO hudson.lifecycle.Lifecycle#onReady: Jenkins is fully up and running
No setup-wizard prompt, no unlock key — JAVA_OPTS skipped it. seed-job is created
right after System config loaded, and System config adapted follows immediately —
both finish before Completed initialization, so the job exists the first time anyone
can reach the controller.
4) A typo gets rejected — at startup, not silently
Change one key: numExecutors becomes numExecutor (missing the s), everything else
identical, and boot a fresh container from that image.
$ docker run -d --name jcasc-typo -p 8081:8080 --env-file secrets.env skillbyexample-jenkins-jcasc:typo
$ docker logs jcasc-typo 2>&1 | grep -E "onTaskFailed|UnknownAttributesException|Available attributes|BootFailure#publish"
2026-10-27 03:44:56.268+0000 [id=56] SEVERE jenkins.InitReactorRunner$1#onTaskFailed: Failed ConfigurationAsCode.init
io.jenkins.plugins.casc.UnknownAttributesException: jenkins: Invalid configuration elements for type: class jenkins.model.Jenkins : numExecutor.
[LF]> Available attributes : authorizationStrategy, clouds, crumbIssuer, disableRememberMe, disabledAdministrativeMonitors, globalNodeProperties, labelAtoms, labelString, log, markupFormatter, mode, noUsageStatistics, nodeMonitors, nodeName, nodeProperties, nodes, numExecutors, primaryView, projectNamingStrategy, proxy, quietPeriod, remotingSecurity, scmCheckoutRetryCount, securityRealm, slaveAgentPort, systemMessage, updateCenter, views, viewsTabBar
2026-10-27 03:44:56.272+0000 [id=42] SEVERE hudson.util.BootFailure#publish: Failed to initialize Jenkins
io.jenkins.plugins.casc.UnknownAttributesException: jenkins: Invalid configuration elements for type: class jenkins.model.Jenkins : numExecutor.
[LF]> Available attributes : authorizationStrategy, clouds, crumbIssuer, disableRememberMe, disabledAdministrativeMonitors, globalNodeProperties, labelAtoms, labelString, log, markupFormatter, mode, noUsageStatistics, nodeMonitors, nodeName, nodeProperties, nodes, numExecutors, primaryView, projectNamingStrategy, proxy, quietPeriod, remotingSecurity, scmCheckoutRetryCount, securityRealm, slaveAgentPort, systemMessage, updateCenter, views, viewsTabBar
$ docker ps -a --filter name=jcasc-typo --format '{{.Status}}'
Exited (5) 7 seconds ago
JCasC doesn’t guess. numExecutor isn’t a key jenkins.model.Jenkins has, so it’s
rejected with the exact bad key and every key that would have worked, and the whole boot
fails — the container exits with code 5, never serving a single request. Restoring the
s and rebuilding is the entire fix; there is nothing else to debug once the message
names the attribute.
5) Proving the config actually applied
The REST API reflects the YAML directly:
$ curl -s -u admin:s3cr3t-admin-pw "http://localhost:8080/api/json?pretty=true&tree=mode,numExecutors,description,jobs%5Bname,url,color%5D"
{
"_class" : "hudson.model.Hudson",
"mode" : "NORMAL",
"numExecutors" : 2,
"description" : "Managed by JCasC. Edit jenkins.yaml, not the UI.",
"jobs" : [
{
"_class" : "hudson.model.FreeStyleProject",
"name" : "seed-job",
"url" : "http://host.docker.internal:8080/job/seed-job/",
"color" : "notbuilt"
}
]
}
description is JCasC’s systemMessage, numExecutors matches the YAML, and seed-job
exists. For the full picture — including the credential — the Configuration as Code
plugin can export what it currently holds, but only to a POST with a CSRF crumb:
$ curl -s -o /dev/null -w "%{http_code}\n" -u admin:s3cr3t-admin-pw "http://localhost:8080/manage/configuration-as-code/export"
405
$ CRUMB=$(curl -s -c cookies.txt -u admin:s3cr3t-admin-pw "http://localhost:8080/crumbIssuer/api/json" | sed -n 's/.*"crumb":"\([^"]*\)".*/\1/p')
$ curl -s -b cookies.txt -u admin:s3cr3t-admin-pw -H "Jenkins-Crumb: $CRUMB" -X POST "http://localhost:8080/manage/configuration-as-code/export" | head -18
configuration-as-code:
deprecated: "reject"
restricted: "reject"
unknown: "reject"
version: "1"
credentials:
folder: {
}
providerImpl: {
}
system:
domainCredentials:
- credentials:
- string:
description: "Deploy token, set from JCasC"
id: "deploy-token"
scope: GLOBAL
secret: "{AQAAABAAAAAgGBMZ8+KTba6LjhL4eq6rMZxAkDPwzkLGp0p+TvYSJ0j64EBrkP4tsN2GDpTvYtVp}"
The exported secret is Jenkins’s own encrypted form, never the plaintext DEPLOY_TOKEN
value — the export proves a credential with that ID and description exists, not what it
holds. The crumb has to be issued and sent in the same cookie session as the request it
protects; a crumb from a different session is rejected the same way a missing one is.
6) Reloading without a restart
Edit the YAML, copy it into the running container, and reload:
$ docker cp jenkins-v2.yaml jcasc-demo:/var/jenkins_casc/jenkins.yaml
$ curl -s -b cookies.txt -u admin:s3cr3t-admin-pw -H "Jenkins-Crumb: $CRUMB" -X POST "http://localhost:8080/manage/configuration-as-code/reload" -w "%{http_code}\n"
302
$ curl -s -u admin:s3cr3t-admin-pw "http://localhost:8080/api/json?pretty=true" | grep -E "numExecutors|description"
"numExecutors" : 3,
"description" : "Managed by JCasC. Edit jenkins.yaml, not the UI. (reloaded)",
numExecutors went from 2 to 3 and the message picked up “(reloaded)” with no container
restart. Put the numExecutor typo back and reload instead of rebuilding, and the result
looks nothing like that 302:
$ docker cp jenkins-typo.yaml jcasc-demo:/var/jenkins_casc/jenkins.yaml
$ curl -s -o reload-bad.html -w "%{http_code}\n" -b cookies.txt -u admin:s3cr3t-admin-pw -H "Jenkins-Crumb: $CRUMB" -X POST "http://localhost:8080/manage/configuration-as-code/reload"
200
$ grep -o "<title>[^<]*</title>" reload-bad.html
<title>Error loading configuration - Jenkins</title>
$ curl -s -u admin:s3cr3t-admin-pw "http://localhost:8080/api/json?pretty=true" | grep -E "numExecutors|description"
"numExecutors" : 3,
"description" : "Managed by JCasC. Edit jenkins.yaml, not the UI. (reloaded)",
200, not 302 — a failed reload isn’t an HTTP error, it’s an ordinary page reporting the
same UnknownAttributesException from section 4. numExecutors and description are
still the values the previous, good reload set, unchanged; a bad reload cannot leave the
controller half-configured.
Practice
These ran against the same skillbyexample-jenkins-jcasc:good image and jcasc-demo
container as above (Jenkins 2.580.1, the four plugins from plugins.txt), using
docker cp to swap jenkins.yaml and the crumb/cookie dance from section 5 to reload.
-
Remove
plain-credentialsfromplugins.txtand rebuild, keeping thestringcredential injenkins.yamlunchanged. What fails, and why — isn’tcredentialsthe plugin that provides credential types?Solution
$ docker run -d --name jcasc-nostring -p 8082:8080 --env-file secrets.env skillbyexample-jenkins-jcasc:nostring $ docker logs jcasc-nostring 2>&1 | grep -E "onTaskFailed|UnknownAttributesException|BootFailure#publish" 2026-10-27 03:10:12.756+0000 [id=72] SEVERE jenkins.InitReactorRunner$1#onTaskFailed: Failed ConfigurationAsCode.init io.jenkins.plugins.casc.UnknownAttributesException: credentials: No com.cloudbees.plugins.credentials.Credentials implementation found for string 2026-10-27 03:10:12.760+0000 [id=42] SEVERE hudson.util.BootFailure#publish: Failed to initialize Jenkins io.jenkins.plugins.casc.UnknownAttributesException: credentials: No com.cloudbees.plugins.credentials.Credentials implementation found for stringcredentialsis the storage and UI framework, and it does implement some credential types directly —usernamePassword(...)boots fine with onlycredentialsinstalled. “Secret text” doesn’t live there; it’s implemented by a separate plugin,plain-credentials, and without it there is no class registered forstring, so JCasC rejects the key the same way it rejects a plain typo:UnknownAttributesException, even though nothing is misspelled. Addingplain-credentials:199.v9f8e1f741799back toplugins.txtand rebuilding is the fix. -
Change
numExecutors: 2tonumExecutors: "two"(a string, not a number) and reload. Does it fail the same way the missingsdid in section 4?Solution
$ docker cp jenkins-badtype.yaml jcasc-demo:/var/jenkins_casc/jenkins.yaml $ curl -s -b cookies.txt -u admin:s3cr3t-admin-pw -H "Jenkins-Crumb: $CRUMB" -X POST "http://localhost:8080/manage/configuration-as-code/reload" -w "%{http_code}\n" 302 $ curl -s -u admin:s3cr3t-admin-pw "http://localhost:8080/api/json?pretty=true" | grep numExecutors "numExecutors" : 0,No — the reload reports success (
302), and nothing in the log calls out the value.numExecutorsis silently0instead of2. An unknown key is loud because JCasC can check it against a known list of attributes; a wrong type for a valid key is quiet. YAML itself reads"two"fine — it’s a perfectly valid string — butnumExecutorsbinds to anintfield, and the Stapler-based conversion JCasC uses to set it silently falls back to a default instead of raising when the string can’t convert.0executors on the built-in node means no build can ever start there — worth checking for after any JCasC change that touches a number, not just a typo. -
Point the credential’s
secretat an environment variable that is never set —secret: "${DEPLOY_TOKEN_MISSING}", no default — and reload.Solution
$ docker cp jenkins-nodefault.yaml jcasc-demo:/var/jenkins_casc/jenkins.yaml $ curl -s -b cookies.txt -u admin:s3cr3t-admin-pw -H "Jenkins-Crumb: $CRUMB" -X POST "http://localhost:8080/manage/configuration-as-code/reload" -w "%{http_code}\n" 302 $ docker logs jcasc-demo 2>&1 | grep -E "UnresolvedLookup|Processing provided DSL script" | tail -3 2026-10-27 03:18:05.168+0000 [id=112] WARNING i.j.p.c.SecretSourceResolver$UnresolvedLookup#lookup: Configuration import: Found unresolved variable 'DEPLOY_TOKEN_MISSING'. Will default to empty string 2026-10-27 03:18:05.241+0000 [id=112] WARNING i.j.p.c.SecretSourceResolver$UnresolvedLookup#lookup: Configuration import: Found unresolved variable 'DEPLOY_TOKEN_MISSING'. Will default to empty string Processing provided DSL scriptAnother quiet case: the reload succeeds, and the credential
deploy-tokenstill exists — just with an empty secret, logged only as aWARNING, not a failure. A deploy step that binds this credential later would get a working-looking empty string, not an error. Supplying a fallback instead avoids this:$ docker cp jenkins-withdefault.yaml jcasc-demo:/var/jenkins_casc/jenkins.yaml $ curl -s -b cookies.txt -u admin:s3cr3t-admin-pw -H "Jenkins-Crumb: $CRUMB" -X POST "http://localhost:8080/manage/configuration-as-code/reload" -w "%{http_code}\n" 302 $ docker logs --since 20s jcasc-demo 2>&1 | grep UnresolvedLookup $ echo "exit=$?" exit=1jenkins-withdefault.yamlchanges only thesecretline to"${DEPLOY_TOKEN_MISSING:-fallback-token}". The reload still succeeds, and — scopingdocker logsto the seconds since this reload, since the old warning from task 3 is still further back in the same long-running container’s log — grep’s exit code1confirms no newUnresolvedLookupline appeared. The default satisfied the lookup, so there was nothing left unresolved to warn about. -
POSTto reload with a crumb that was never issued — 64 zeros instead of the real value fromcrumbIssuer— keeping the-X POSTand the cookie jar otherwise unchanged.Solution
$ curl -s -o /dev/null -w "%{http_code}\n" -b cookies.txt -u admin:s3cr3t-admin-pw -H "Jenkins-Crumb: 0000000000000000000000000000000000000000000000000000000000000000" -X POST "http://localhost:8080/manage/configuration-as-code/reload" 403403, not405— the method is right this time, so Jenkins gets far enough to check the crumb itself and reject it outright, rather than rejecting the request shape the wayGETdoes above. A real crumb fromcrumbIssueron the matching cookie session is what the-H "Jenkins-Crumb: $CRUMB"lines elsewhere in this lesson are actually carrying.
Next steps
- Jenkins plugin pinning and upgrades: resolving dependency conflicts and testing before a core bump
- Shared Libraries for pipeline logic that shouldn’t live in JCasC
- GitOps pattern: a controller image rebuilt and redeployed on every
jenkins.yamlchange, instead of reloaded in place