Skip to main content
Jenkins · lesson 6 of 7
Jenkins intermediate 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

jenkins jcasc configuration-as-code

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.

  1. Remove plain-credentials from plugins.txt and rebuild, keeping the string credential in jenkins.yaml unchanged. What fails, and why — isn’t credentials the 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 string

    credentials is the storage and UI framework, and it does implement some credential types directly — usernamePassword(...) boots fine with only credentials installed. “Secret text” doesn’t live there; it’s implemented by a separate plugin, plain-credentials, and without it there is no class registered for string, so JCasC rejects the key the same way it rejects a plain typo: UnknownAttributesException, even though nothing is misspelled. Adding plain-credentials:199.v9f8e1f741799 back to plugins.txt and rebuilding is the fix.

  2. Change numExecutors: 2 to numExecutors: "two" (a string, not a number) and reload. Does it fail the same way the missing s did 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. numExecutors is silently 0 instead of 2. 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 — but numExecutors binds to an int field, 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. 0 executors 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.

  3. Point the credential’s secret at 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 script

    Another quiet case: the reload succeeds, and the credential deploy-token still exists — just with an empty secret, logged only as a WARNING, 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=1

    jenkins-withdefault.yaml changes only the secret line to "${DEPLOY_TOKEN_MISSING:-fallback-token}". The reload still succeeds, and — scoping docker logs to 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 code 1 confirms no new UnresolvedLookup line appeared. The default satisfied the lookup, so there was nothing left unresolved to warn about.

  4. POST to reload with a crumb that was never issued — 64 zeros instead of the real value from crumbIssuer — keeping the -X POST and 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"
    403

    403, not 405 — 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 way GET does above. A real crumb from crumbIssuer on 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.yaml change, instead of reloaded in place

Frequently Asked Questions

What is Jenkins Configuration as Code (JCasC)?
JCasC is a plugin that configures Jenkins itself — users, credentials, executors, jobs, and most of Manage Jenkins — from a YAML file read at startup, instead of clicking through the UI. The file is read every boot, so a controller rebuilt from the same image and YAML ends up in the same state.
Why did my jenkins.yaml get rejected at startup?
JCasC validates every key against the Java classes the matching plugin registers. A typo'd key, a key from a plugin you didn't install, or a credential type your credentials plugin doesn't provide all fail the same way: an UnknownAttributesException that names the bad key and lists every valid one, and Jenkins refuses to finish booting.
Can I change a running Jenkins's JCasC config without restarting it?
Yes. Update the YAML file on disk, then POST to /manage/configuration-as-code/reload (or use the button under Manage Jenkins → Configuration as Code). A reload that fails validation leaves the previously-applied configuration running; it does not partially apply or crash the controller.
Where should the secrets referenced in jenkins.yaml come from?
Environment variables or mounted secret files, never literals in the YAML. JCasC resolves ${VAR} against the controller's environment at read time, so the committed file has no secret in it — only the variable's name.
What happens if a jenkins.yaml ${VAR} reference has no matching environment variable?
JCasC does not fail the boot. It logs a WARNING that the variable was unresolved and substitutes an empty string, so a credential can end up silently empty instead of the startup loudly refusing. Use ${VAR:-default} when an empty fallback is not safe.