Table of Contents#
- Where Jenkins Fits — the Self-Hosted Original
- Jenkins Architecture — Controller and Agents
- Freestyle Jobs vs. Pipeline — Why Declarative Won
- Anatomy of a Declarative Jenkinsfile
- A Minimal Pipeline, Built Up Step by Step
- Agents —
any, Labels, Docker, and Kubernetes - Dynamic Kubernetes Agents — Pod Templates
- Parallel Stages and the Matrix Directive
- Post Conditions —
success,failure,always - Parameters, Triggers, and Multibranch Pipelines
- Shared Libraries — Jenkins's Reusability Model
- Credentials Binding
- Security Hardening — CSRF, RBAC, and Script Security
- Jenkins Configuration as Code (JCasC)
- The Plugin Ecosystem — Blessing and Curse
- Jenkins vs. the SaaS Platforms
- A Full Realistic Multi-Stage Pipeline
- Common Mistakes
- Worked Practice Problems
- Summary and What's Next
Where Jenkins Fits — the Self-Hosted Original#
Every platform covered in Parts 4-8 is a SaaS-first (or SaaS-capable) product bundled with a git host. Jenkins is fundamentally different, and worth understanding on genuinely different terms: Jenkins predates almost every platform in this series (it forked from the Hudson project in 2011, itself already years old by then), is entirely self-hosted by design, and is git-host-agnostic — it doesn't ship its own repository hosting at all, and can build from GitHub, GitLab, Bitbucket, Azure Repos, or any generic Git server, similar in spirit to Azure Pipelines' cross-host flexibility from Part 8, but predating it by well over a decade.
Diagram
Why Jenkins is still genuinely relevant, not just legacy inertia: full control over where the CI server runs (air-gapped networks, on-prem data centers, regulatory environments where no code or metadata may ever leave a specific network boundary), zero per-minute or per-seat SaaS billing, and — the double-edged sword covered later in this chapter — the largest, oldest, most exhaustive plugin ecosystem of any CI/CD tool, capable of integrating with almost anything ever built. Why many organizations are actively migrating away from it: the operational burden of running, patching, and scaling the Jenkins server itself falls entirely on the adopting team, and the plugin ecosystem's breadth comes with real security and maintenance costs, covered honestly later in this chapter.
Read this chapter with a slightly different lens than Parts 4-8: those four chapters mostly mapped the same underlying concepts onto different vendor syntax. Jenkins genuinely changes the underlying model in places — there is no vendor managing hosted execution for you, no bundled git platform, and the reusability/extension story (Shared Libraries, plugins) is built on general-purpose code rather than a constrained, typed schema. The comparisons to Parts 4-8 throughout this chapter are there to anchor unfamiliar territory in already-covered ground, not to suggest Jenkins is simply "GitHub Actions with different keywords."
Jenkins Architecture — Controller and Agents#
Every other platform in this series abstracts away "where does my job actually run" almost entirely (a GitHub-hosted runner, a GitLab shared runner). Jenkins makes this a first-class architectural concept a team must actively design around.
Diagram
- Controller — the always-running Jenkins server itself: web UI, job/pipeline definitions, plugin installations, the credentials store, and the scheduler that dispatches work to agents. Running actual build steps directly on the controller is universally considered a bad practice (security risk, resource contention with the scheduler itself) — the controller's job is to orchestrate, not execute.
- Agent (historically "slave," terminology retired industry-wide) — a separate machine or ephemeral pod that actually executes a pipeline's steps, connecting back to the controller over its own agent protocol (JNLP or SSH). Agents can be permanent, long-lived VMs registered once and reused indefinitely, or — the modern, most common pattern, covered in depth shortly — dynamically provisioned Kubernetes pods, created fresh per build and destroyed afterward.
This controller/agent split is architecturally the closest analogue in this series to a self-hosted runner setup (GitHub/GitLab/Bitbucket/Azure's self-hosted runner options from Parts 4-8) — except for Jenkins, self-hosted execution isn't an optional escape hatch from a SaaS default, it's the entire, only model. Every Jenkins installation, from a single-developer hobby project to an enterprise-scale deployment, is built on this same controller/agent architecture from day one.
A practical consequence worth naming, since it shapes almost every operational decision covered later in this chapter: because there is no vendor managing hosted execution capacity, every scaling, security, and availability property of the whole CI/CD system — how many builds can run concurrently, how quickly a compromised build environment is torn down and replaced, whether the controller itself is highly available — is a decision the adopting team has to make and operate, not a property purchased off the shelf. This is the single throughline distinguishing nearly every difference in this chapter from Parts 4-8's platforms.
Freestyle Jobs vs. Pipeline — Why Declarative Won#
Jenkins carries its own legacy-vs-modern split, directly analogous to Azure DevOps's Classic-vs-YAML split from Part 8, and worth understanding for the same reason: a lot of real-world Jenkins usage still predates the modern approach.
| Freestyle Jobs | Pipeline (Scripted or Declarative) | |
|---|---|---|
| Defined as | Point-and-click UI configuration | A Jenkinsfile, checked into the repo |
| Version-controlled with the code? | No | Yes |
| Current guidance | Legacy — still common in older installations | The recommended approach for any new job |
Within Pipeline itself, there's a second, smaller split worth knowing precisely:
| Scripted Pipeline | Declarative Pipeline | |
|---|---|---|
| Syntax | Arbitrary Groovy code, wrapped in a node { } block | A fixed, structured schema (pipeline { agent { } stages { } }) |
| Flexibility | Maximum — full general-purpose scripting | Deliberately constrained |
| Error handling | Manual (try/catch blocks) | Built-in (post blocks, covered later) |
| Current guidance | Legacy, still used for genuinely complex custom logic | The default choice for virtually all new pipelines |
Declarative Pipeline should be the default for the exact same reason YAML Pipelines are recommended over Classic in Azure DevOps (Part 8) — structure and constraint make a pipeline easier to read, review, and reason about than a general-purpose script, even at some cost to raw flexibility. Scripted Pipeline remains available as an escape hatch (Declarative Pipeline can embed a script { } block containing arbitrary Scripted-style Groovy for genuinely complex logic that doesn't fit the declarative schema cleanly), so the two aren't mutually exclusive — a real Jenkinsfile is very commonly 95% declarative structure with a small script { } block for one piece of custom logic.
Anatomy of a Declarative Jenkinsfile#
Diagram
pipeline { agent any stages { stage('Build') { steps { sh 'npm ci' sh 'npm run build' } } stage('Test') { steps { sh 'npm test' } } } }
pipeline { }— the top-level block; everything else nests inside it.agent— declares where the pipeline (or an individual stage, which can override it) executes; covered in depth shortly.stages { }— an ordered list of namedstage()blocks, each running sequentially by default (parallel execution is opt-in, covered later) — directly analogous to GitLab's stage-sequential-by-default model from Part 6, more than GitHub's parallel-by-default jobs.steps { }— inside each stage, the actual work:sh(shell commands), or a plugin-provided step (checkout, credential binding, notifications).post { }— the pipeline's (or a stage's) equivalent of GitHub'sif: failure()/if: always()conditions from Part 4, structured as named blocks instead of conditional expressions.
A Minimal Pipeline, Built Up Step by Step#
Step 1 — bare minimum:
pipeline { agent any stages { stage('Hello') { steps { echo 'hello' } } } }
Step 2 — a real Node project:
pipeline { agent any stages { stage('Build') { steps { sh 'npm ci' sh 'npm run build' } } stage('Test') { steps { sh 'npm test' } } } }
Step 3 — environment variables, and explicit source checkout (Jenkins doesn't implicitly clone the repo the way GitLab/Bitbucket do — a Multibranch Pipeline job handles this automatically, but it's worth seeing the explicit form):
pipeline { agent any environment { NODE_ENV = 'test' } stages { stage('Checkout') { steps { checkout scm // 'scm' = whatever source config this job was triggered from } } stage('Build') { steps { sh 'npm ci' sh 'npm run build' } } stage('Test') { steps { sh 'npm test' } } } }
Step 4 — adding a post block for cleanup and notification:
pipeline { agent any environment { NODE_ENV = 'test' } stages { stage('Checkout') { steps { checkout scm } } stage('Build') { steps { sh 'npm ci' sh 'npm run build' } } stage('Test') { steps { sh 'npm test' } } } post { always { junit 'test-results/**/*.xml' // publish test results regardless of outcome cleanWs() // clean the workspace so the next build starts fresh } failure { mail to: 'team@example.com', subject: "Build failed: ${env.JOB_NAME}" } } }
Agents — any, Labels, Docker, and Kubernetes#
The agent directive is where Jenkins's architectural flexibility (and complexity) is most visible — four genuinely distinct ways to specify where a pipeline or stage runs:
pipeline { agent any // run on ANY available agent, no specific requirement // -- or -- agent { label 'linux-docker' } // run on an agent carrying this specific label // -- or -- agent { docker { image 'node:20' } // run inside a fresh Docker container, on any Docker-capable agent } // -- or -- agent { kubernetes { yaml podTemplateYaml } } // dynamically provision a Kubernetes pod (next section) stages { /* ... */ } }
A stage can also override the top-level agent, useful when different stages have genuinely different execution needs within one pipeline:
pipeline { agent none // no default — every stage MUST specify its own stages { stage('Build (Linux)') { agent { label 'linux' } steps { sh 'make build' } } stage('Test (Windows)') { agent { label 'windows' } steps { bat 'run-tests.bat' } } } }
agent none at the top level, combined with per-stage agent blocks, is the correct pattern whenever a pipeline's stages genuinely need different execution environments — declaring one broad agent any at the top and hoping every stage's needs happen to be satisfied by whatever agent gets picked is a common source of "works on some builds, fails mysteriously on others" flakiness, since different available agents can have different installed toolchains.
Dynamic Kubernetes Agents — Pod Templates#
The modern, most commonly recommended agent strategy at real scale: rather than maintaining a fixed pool of long-lived VM agents (idle capacity when unused, a scaling bottleneck under load), the Kubernetes plugin provisions a fresh pod per build, torn down immediately afterward — directly analogous to GitHub-hosted runners' fresh-VM-per-job model, but self-hosted, on a team's own Kubernetes cluster.
pipeline { agent { kubernetes { yaml ''' apiVersion: v1 kind: Pod spec: containers: - name: node image: node:20 command: ['cat'] tty: true - name: docker image: docker:24 command: ['cat'] tty: true ''' } } stages { stage('Build') { steps { container('node') { sh 'npm ci && npm run build' } } } stage('Build Image') { steps { container('docker') { sh 'docker build -t myapp .' } } } } }
Diagram
The multi-container pod pattern (node and docker as separate containers in the same pod) directly mirrors the sidecar pattern this course's Kubernetes deep-dive covers for runtime workloads, applied here to build infrastructure — each container brings its own toolchain, and the container('name') step directs which specific container a given set of shell commands actually runs inside, while all containers in the pod share the same network namespace and workspace volume. This is Jenkins's answer to the "how do I get multiple, potentially conflicting toolchains into one build without a bloated, hard-to-maintain single image" problem, and it composes cleanly with the same Kubernetes-native autoscaling this course's Kubernetes deep-dive already covers — idle capacity drops to zero between builds, since there are no idle agent pods at all when nothing is building.
Parallel Stages and the Matrix Directive#
By default, stages { } runs sequentially — Jenkins's version of GitLab's stage-sequential default from Part 6. The parallel block breaks free of that for independent stages:
stage('Test') { parallel { stage('Unit Tests') { steps { sh 'npm test' } } stage('Lint') { steps { sh 'npm run lint' } } stage('Security Audit') { steps { sh 'npm audit' } } } }
The matrix directive is Jenkins's equivalent of GitHub's strategy.matrix and GitLab's parallel:matrix — expanding one stage definition across every combination of a declared axes set:
stage('Cross-Platform Test') { matrix { axes { axis { name 'NODE_VERSION'; values '18', '20', '22' } axis { name 'PLATFORM'; values 'linux', 'windows' } } stages { stage('Test') { agent { label "${PLATFORM}" } steps { sh "nvm use ${NODE_VERSION} && npm test" } } } } }
This expands into 6 parallel stage instances (3 Node versions × 2 platforms), each independently visible in the Jenkins UI's build graph — functionally equivalent to the matrix builds already covered for every prior platform in this series, with Jenkins's own axis/axes naming.
A matrix block also supports an excludes section for skipping specific known-bad combinations, mirroring GitHub's exclude: from Part 4 — the same "drop one cell without restructuring the whole axis set" convenience, applied here with Jenkins's own syntax.
Post Conditions — success, failure, always#
Jenkins's post { } block is structurally similar to GitHub's if: success()/if: failure()/if: always() conditions from Part 4, but organized as named blocks rather than conditional expressions attached to individual steps:
post { always { cleanWs() // runs REGARDLESS of outcome } success { slackSend message: "Build succeeded: ${env.BUILD_URL}" } failure { slackSend message: "Build FAILED: ${env.BUILD_URL}" mail to: 'oncall@example.com', subject: "Pipeline failure: ${env.JOB_NAME}" } unstable { echo 'Build is unstable (e.g. some tests failed but the build itself succeeded)' } changed { echo 'This run's status DIFFERS from the previous run — useful for "back to healthy" notifications' } }
The changed condition is worth calling out specifically as something no other platform in this series has a direct built-in equivalent for — it fires only when this run's overall result differs from the immediately preceding run's result, which is precisely the shape needed for a "stopped failing" or "just started failing" notification without hand-rolling state comparison logic against a stored previous result. unstable is also Jenkins-specific terminology worth knowing: a build can complete without a hard failure but still be marked "unstable" (most commonly, some test failures were tolerated per a configured threshold) — a third outcome state between clean success and outright failure that most other platforms in this series collapse into a simple pass/fail.
post blocks can also be attached to an individual stage, not only the top-level pipeline, giving fine-grained per-stage cleanup or notification behavior without waiting for the entire pipeline to finish.
Parameters, Triggers, and Multibranch Pipelines#
Parameters make a pipeline interactively configurable, Jenkins's equivalent of GitHub's workflow_dispatch: inputs: from Part 4:
pipeline { agent any parameters { choice(name: 'ENVIRONMENT', choices: ['staging', 'production'], description: 'Target environment') string(name: 'VERSION', defaultValue: 'latest', description: 'Version to deploy') booleanParam(name: 'SKIP_TESTS', defaultValue: false) } stages { stage('Deploy') { steps { sh "./deploy.sh --env ${params.ENVIRONMENT} --version ${params.VERSION}" } } } }
Triggers control when a pipeline runs automatically:
pipeline { agent any triggers { cron('H 2 * * *') // nightly, Jenkins staggers the exact minute via the 'H' hash to spread load pollSCM('H/5 * * * *') // poll the SCM for changes every ~5 minutes (webhook-based triggering is preferred where available) } stages { /* ... */ } }
A Multibranch Pipeline job is what makes Jenkins automatically discover and build every branch (and pull/merge request) in a repository, each with its own independent build history, without manually creating a separate job per branch — the closest Jenkins equivalent to how GitHub/GitLab/Bitbucket automatically trigger on any branch or PR by default. It's configured once, pointed at a repository, and Jenkins periodically (or via webhook) scans for branches containing a Jenkinsfile, automatically creating and removing per-branch sub-jobs as branches are created and deleted.
An Organization Folder takes this one level further — rather than pointing at a single repository, it points at an entire GitHub organization, GitLab group, or Bitbucket workspace, and Jenkins automatically creates a Multibranch Pipeline job for every repository within it that contains a Jenkinsfile, adding and removing entire projects' worth of jobs as repositories are created and archived. This is the closest Jenkins gets to the zero-configuration, organization-wide pipeline discovery every SaaS platform in this series gets essentially for free by virtue of being the same product as the git host itself.
Shared Libraries — Jenkins's Reusability Model#
A Shared Library is Jenkins's reusability mechanism — closest in spirit to a GitHub reusable workflow or a GitLab CI/CD Component (Parts 4 and 6), but implemented as genuine Groovy code in a separate Git repository, imported into any Jenkinsfile.
// vars/standardBuild.groovy — in a separate "shared-library" repo def call(Map config) { pipeline { agent { kubernetes { yaml config.podTemplate } } stages { stage('Build') { steps { sh "npm ci && npm run build" } } stage('Test') { steps { sh "npm test" } } stage('Deploy') { when { branch 'main' } steps { sh "./deploy.sh --env ${config.environment}" } } } } }
Consumed from any project's Jenkinsfile, across the entire organization:
@Library('my-shared-library@v2.1.0') _ // pinned to a specific tag, same discipline as SHA-pinning a GitHub Action standardBuild(environment: 'production', podTemplate: readTrusted('pod-templates/node.yaml'))
Diagram
Because a Shared Library is genuine Groovy code, not a constrained YAML schema, it's the single most flexible reusability mechanism covered anywhere in this series — capable of arbitrary logic, custom step definitions (vars/*.groovy, callable directly as pipeline steps), and full class-based libraries (src/ directory, following normal Groovy/Java package conventions) for anything genuinely complex. This flexibility is a double-edged sword, covered further in the plugin-ecosystem section: a shared library can become an unreviewable, undocumented "framework" of its own if not actively curated, in exactly the way a constrained, declarative-only reuse mechanism (GitLab's typed CI/CD Component inputs, Azure's extends: templates) structurally discourages.
A library can also be marked "Load implicitly" and scoped as globally trusted (configured once, centrally, by an admin), letting every pipeline in the organization call its functions without even an explicit @Library import — a genuine convenience at scale, but one that concentrates even more trust in whoever controls that central configuration and the library's own repository, reinforcing why the pinning and access-control discipline below is non-negotiable for anything globally trusted.
Pinning @Library('my-shared-library@v2.1.0') to an exact tag (or better, a commit SHA) is exactly as security-critical as pinning a third-party GitHub Action — an unpinned @Library('my-shared-library') (implicitly using the library's default branch) means every pipeline using it silently picks up whatever the library's main branch currently contains, on every run, with the exact same supply-chain risk profile already covered in depth in Part 5.
Credentials Binding#
Jenkins's Credentials store centralizes secrets (username/password pairs, secret text, SSH keys, certificates) with scoped access, injected into a pipeline via the credentials() helper or the withCredentials step — never referenced as plain environment variables sourced from elsewhere.
pipeline { agent any environment { // Injects DOCKER_CREDS_USR and DOCKER_CREDS_PSW automatically DOCKER_CREDS = credentials('docker-hub-creds') } stages { stage('Push Image') { steps { sh 'echo $DOCKER_CREDS_PSW | docker login -u $DOCKER_CREDS_USR --password-stdin' } } } }
// Alternative, more explicit form — scopes the credential's availability // to ONLY the block it's used in, rather than the whole pipeline's environment withCredentials([usernamePassword( credentialsId: 'docker-hub-creds', usernameVariable: 'DOCKER_USER', passwordVariable: 'DOCKER_PASS' )]) { sh 'echo $DOCKER_PASS | docker login -u $DOCKER_USER --password-stdin' }
Jenkins automatically masks credential values in build logs, with the same caveat already established for every other platform in this series: masking is pattern-based, not semantic, so a credential that gets encoded, split, or transformed before being echoed can still leak through. withCredentials scoping the binding to only the specific block that needs it — rather than the whole pipeline's environment { } — is the least-privilege-equivalent practice here, directly mirroring the per-job permissions: scoping already established for GitHub Actions in Part 5: the smaller the window a credential is actually bound and in-scope, the smaller the surface for it to be accidentally leaked by an unrelated later step.
Credentials can also be scoped at the folder level rather than globally — a credential defined inside a specific folder is only visible to jobs within that folder, not the entire Jenkins instance, giving a coarser but genuinely useful team/project-level isolation boundary without needing a separate Jenkins controller per team.
Security Hardening — CSRF, RBAC, and Script Security#
Given Jenkins's self-hosted, plugin-heavy nature, its security model requires more active, ongoing configuration than a SaaS platform where the vendor owns most of the underlying hardening — worth covering the highest-leverage controls explicitly.
CSRF protection ("crumbs") — enabled by default in modern Jenkins, but worth confirming explicitly on any older installation. Every state-changing request must carry a per-session "crumb" token; a request without a valid crumb is rejected, closing the same category of attack CSRF protection addresses in any web application.
Role-Based Authorization (Matrix/Role Strategy) — Jenkins ships with a basic authorization model, but the Role Strategy plugin (or Matrix Authorization, its simpler built-in cousin) is what most real organizations use to implement genuine least-privilege access:
Diagram
Never leave anonymous access enabled on an internet-reachable Jenkins controller — this is a genuinely common, genuinely severe real-world misconfiguration; an unauthenticated Jenkins instance with the Script Console reachable (covered next) is close to unauthenticated remote code execution. Search engines that index exposed services have repeatedly found large numbers of internet-facing Jenkins instances with exactly this misconfiguration, making it worth treating as a near-certain, not merely theoretical, real-world risk.
Script Security — Jenkins's Groovy-based pipelines and its built-in Script Console (an admin feature allowing arbitrary Groovy execution directly on the controller, intended for emergency debugging) are an inherent, structural risk unique to Jenkins among the platforms in this series: no other platform covered gives an authenticated user a built-in, sanctioned arbitrary-code-execution console by default. The Script Security plugin sandboxes untrusted pipeline scripts (rejecting or requiring explicit admin approval for potentially dangerous Groovy method calls), and access to the Script Console itself should be restricted to the smallest possible set of true administrators.
Agent-to-controller security — an agent is, by design, a trusted extension of the controller with the ability to execute code the controller dispatches to it; a compromised agent (or a build running attacker-controlled code on an agent, similar in spirit to the untrusted-fork-code risk covered for GitHub self-hosted runners in Part 5) can potentially attack the controller over the agent protocol unless network-level segmentation (agents on a separate, restricted network segment from the controller) and the "Agent to Controller Access Control" feature are actively configured.
Taken together, these controls form the same layered defense-in-depth model this series has already applied to GitHub in Part 5 — no single control here is sufficient alone; CSRF protection defends the web UI, role-based authorization limits what an authenticated user can do, Script Security constrains what a pipeline's own Groovy can execute, and agent-to-controller segmentation limits the blast radius if a build environment is ever compromised despite the other layers holding. The meaningful difference from the SaaS platforms in this series is ownership: every one of these layers is the adopting team's own responsibility to configure and keep current, not a vendor-managed default.
Jenkins Configuration as Code (JCasC)#
Everything covered so far addresses pipeline definitions as code (the Jenkinsfile). The controller's own configuration — security realm, authorization strategy, credential definitions, plugin settings, agent cloud configuration — has historically been the one part of a Jenkins installation that lived purely as UI-driven, unversioned state, the exact same "configuration outside version control" problem Azure DevOps's Classic pipelines represent (Part 8) — except here applied to the entire server, not just one pipeline.
JCasC (Jenkins Configuration as Code) closes this gap: the controller's full configuration expressed as a single YAML file, applied at startup (or reloaded live), version-controlled exactly like a Jenkinsfile.
# jenkins.yaml — the ENTIRE controller configuration, checked into git jenkins: securityRealm: ldap: server: "ldap://ldap.internal:389" authorizationStrategy: roleBased: roles: global: - name: "admin" permissions: ["Overall/Administer"] - name: "developer" permissions: ["Job/Build", "Job/Read", "Job/Workspace"] clouds: - kubernetes: name: "k8s-agents" serverUrl: "https://kubernetes.default" namespace: "jenkins-agents" credentials: system: domainCredentials: - credentials: - usernamePassword: scope: GLOBAL id: "docker-hub-creds" username: "ci-bot" password: "${DOCKER_HUB_PASSWORD}" # injected from an env var, never hardcoded
Diagram
Why this matters specifically for the security-hardening discussion earlier in this chapter: every control covered there — role-based authorization, disabled anonymous access, credential scoping — is only as durable as it is reviewable and reproducible. Without JCasC, "we configured RBAC correctly" is a claim that can silently drift the moment anyone with admin access makes an undocumented UI change; with JCasC, the actual authorization strategy is a diffable, PR-reviewable YAML block, and a fresh disaster-recovery controller can be stood up with byte-identical security configuration rather than someone's best manual reconstruction from memory. This directly extends the same "config as code beats config in a UI" argument this course's Part 2 (Infrastructure as Code) already made generally, applied here specifically to the CI/CD controller's own security posture.
The Plugin Ecosystem — Blessing and Curse#
Jenkins's plugin ecosystem — over 1,800 plugins at the time of writing, covering nearly every conceivable integration (every major cloud provider, every notification system, every SCM, every artifact repository) — is simultaneously Jenkins's greatest practical strength and its most commonly cited operational liability, and a fair treatment of Jenkins needs to be honest about both sides.
Diagram
The practical governance discipline this demands, worth stating explicitly since it directly extends the "vet a third-party Action before adopting it" checklist from Part 5: before installing any plugin, check its maintenance signal (recent releases, open security advisories on the Jenkins Security Advisory feed, active issue response) with the same rigor applied to a third-party GitHub Action — a plugin runs with significant access to the controller itself (not a sandboxed, per-job scope the way a GitHub Action's permissions can be constrained), making an abandoned or compromised plugin a meaningfully higher-blast-radius risk than an equivalent SaaS-platform integration. Keeping plugins updated (Jenkins's own update center flags available updates and known security advisories directly in the UI) is genuinely non-optional operational hygiene, not a nice-to-have — the majority of real Jenkins security incidents trace back to an outdated plugin with a known, published CVE, not a zero-day.
A related, easy-to-underestimate cost worth naming explicitly: every plugin installed is also a future upgrade dependency — a Jenkins core upgrade can be blocked or complicated by a plugin that hasn't been updated for compatibility, creating real pressure to either delay a core security upgrade (bad) or drop a plugin the organization still depends on (disruptive). The fewer, better-maintained plugins a Jenkins installation runs, the less this compounding cost bites over the installation's lifetime — a genuine argument for periodically auditing and pruning installed plugins, not just vetting new ones.
Jenkins vs. the SaaS Platforms#
A direct, honest comparison against the four platforms covered in Parts 4-8, extending this series' running comparison:
| Jenkins | GitHub/GitLab/Bitbucket/Azure | |
|---|---|---|
| Hosting | Self-hosted only | SaaS-first (self-hosted optional on some) |
| Operational burden | Full — you patch, scale, and secure the controller yourself | Vendor-managed |
| Git host coupling | None — works with any git host | Bundled (except Azure Pipelines) |
| Reusability mechanism | Shared Libraries (full Groovy, maximum flexibility) | Reusable workflows/Components/templates (constrained, structured) |
| Extension ecosystem | 1,800+ plugins, uneven quality | Marketplace/Catalog, more centrally curated |
| Built-in security scanning | None natively — entirely plugin-dependent | Native (GitLab) to opt-in ecosystem (GitHub/Azure) to thin (Bitbucket) |
| Cost model | Infrastructure cost only, no per-minute/seat billing | Usage-based billing |
| Best fit | Air-gapped/regulated environments, deep legacy tool integration, teams wanting zero vendor lock-in | Teams wanting less operational overhead, native platform integration |
Why teams still actively choose Jenkins in 2026, despite the operational burden: genuine regulatory/air-gap requirements no SaaS platform can satisfy, extremely deep integration needs with legacy or niche internal tooling that only has a Jenkins plugin (or none at all, requiring custom Groovy anyway — which Jenkins supports natively), and organizations with existing, mature Jenkins infrastructure and institutional Groovy expertise where a migration's cost genuinely outweighs its benefit. Why teams actively migrate away: the ongoing controller maintenance burden, the plugin-security governance overhead just covered, and — increasingly — a preference for the "pipeline lives natively in the same product as the code" model every other platform in this series offers, removing an entire category of integration surface (webhooks, credential-sharing between two separate systems) that Jenkins's git-host-agnostic design inherently requires.
A migration decision worth reasoning about precisely, rather than defaulting to whichever direction is currently fashionable: the honest calculus almost always comes down to comparing the known, current cost of running Jenkins (headcount already allocated to it, existing plugin/Shared-Library investment) against the estimated, future cost of a SaaS platform (per-minute/seat billing at the organization's actual scale, plus the one-time migration effort of porting every Jenkinsfile and Shared Library to a new platform's syntax and reusability model). Neither number is free, and a rushed migration driven by "Jenkins feels old" without actually running this comparison is a common, avoidable mistake — the right call genuinely varies by organization, and this chapter's job is equipping the comparison, not prescribing the answer.
A Full Realistic Multi-Stage Pipeline#
The same build → test (parallel) → security scan → deploy-staging → approval → deploy-production shape from every prior platform chapter, in Jenkins's Declarative model with a dynamic Kubernetes agent:
pipeline { agent { kubernetes { yaml ''' apiVersion: v1 kind: Pod spec: containers: - name: node image: node:20 command: ['cat'] tty: true ''' } } environment { DEPLOY_CREDS = credentials('deploy-service-account') } stages { stage('Build') { steps { container('node') { sh 'npm ci && npm run build' } } } stage('Verify') { parallel { stage('Unit Tests') { steps { container('node') { sh 'npm test' } } } stage('Security Audit') { steps { container('node') { sh 'npm audit --audit-level=high' } } } } } stage('Deploy Staging') { steps { container('node') { sh './deploy.sh --env staging' } } } stage('Smoke Test') { steps { sh 'curl -f https://staging.example.com/healthz' } } stage('Approval') { steps { input message: 'Deploy to production?', ok: 'Deploy' } } stage('Deploy Production') { steps { container('node') { sh './deploy.sh --env production' } } } } post { failure { slackSend message: "Pipeline failed: ${env.BUILD_URL}" } always { cleanWs() } } }
Diagram
The input step is Jenkins's manual-approval-gate mechanism — the direct equivalent of GitHub's Environment required reviewers, GitLab's protected-environment approval, and Azure's Environment Approvals, expressed as an explicit pipeline step that literally pauses execution until a human interacts with it via the Jenkins UI, rather than a property configured on a separate "environment" object the way every SaaS platform in this series models it.
An input step can also restrict who is allowed to respond (submitter: 'release-managers'), the closest Jenkins gets to the named-approver-group concept every SaaS platform's Environment approvals already provide natively. Worth noting as a genuine operational caveat, not just a syntax detail: a paused input step holds the agent (and, for some agent types, the pod itself) allocated and waiting for the entire duration a human takes to respond — at scale, this can tie up build capacity if approvals are commonly slow, a resource-cost consideration none of the SaaS platforms' separately-modeled approval gates carry in quite the same way, since their approval wait doesn't hold an active compute allocation open.
Common Mistakes#
| Mistake | Why it's a problem | Fix |
|---|---|---|
| Running build steps directly on the controller | Resource contention with the scheduler itself, and a security risk (build code runs with controller-level access) | Always use agent to dispatch actual work to a separate agent, never the controller |
| Long-lived, statically provisioned agent VMs at scale | Idle capacity when unused, a scaling bottleneck under sudden load | Use dynamic Kubernetes agents — a fresh pod per build, zero idle cost between builds |
An unpinned @Library('my-lib') reference | Silently picks up whatever the library's default branch currently contains, on every run | Pin to an exact tag or commit SHA, exactly like a third-party GitHub Action |
| Installing a plugin without checking its maintenance/security signal | An abandoned or compromised plugin runs with significant controller-level access | Check the Jenkins Security Advisory feed and recent release activity before installing anything |
| Anonymous access left enabled on an internet-reachable controller | Combined with a reachable Script Console, close to unauthenticated remote code execution | Disable anonymous access; restrict Script Console access to true admins only |
Credentials injected via a pipeline-wide environment { } block when only one step needs them | Wider exposure window for accidental leakage by an unrelated step | Use withCredentials scoped to only the specific block that needs the value |
agent any at the top level for a pipeline with genuinely different per-stage toolchain needs | "Works on some builds, fails on others" flakiness depending on which agent gets picked | Use agent none at the top with explicit per-stage agent blocks |
| Controller configuration (RBAC, credentials, clouds) left as unversioned, UI-only state | Silent drift, no audit trail, un-reproducible in a disaster-recovery rebuild | Adopt JCasC — the entire controller configuration as reviewable, version-controlled YAML |
| Leaving the Script Console reachable to more than a small trusted admin set | Combined with weak authentication, close to unauthenticated remote code execution on the controller | Restrict to true administrators only; treat it with the same caution as root shell access |
| Choosing Freestyle jobs or Scripted Pipeline for a brand-new pipeline in 2026 | Neither is version-controlled/structured the way Declarative Pipeline is; both are legacy patterns | Default to Declarative Pipeline; reach for a script { } block only for genuinely complex logic that doesn't fit cleanly |
Worked Practice Problems#
Problem 1: A team runs a fixed pool of 10 long-lived Jenkins agent VMs, sized for their peak daily build load. Most of the day, 7-8 of those agents sit idle. What's the modern fix, and what tradeoff does it involve?
Answer: Migrate to dynamic Kubernetes agents via the Kubernetes plugin — pods are provisioned fresh per build and torn down immediately after, so idle capacity between builds drops to zero rather than paying for 10 VMs' worth of standing capacity around the clock. The tradeoff: this requires an underlying Kubernetes cluster to provision pods into (real infrastructure to run and maintain, if one doesn't already exist for other purposes) and a small per-build cold-start latency for pod scheduling/startup that a warm, already-running VM agent doesn't have — generally a strongly favorable tradeoff at real scale, less clearly so for a very small team with only a handful of daily builds.
Problem 2: A security review finds a Jenkinsfile using @Library('shared-ci-lib') _ with no version pin, and the shared library repository has open write access for the entire engineering organization. What's the exploitable risk, and what's the fix?
Answer: Any pipeline referencing this library picks up whatever is currently on its default branch on every single run — combined with broad write access to the library repo, any engineer (or a compromised account belonging to any engineer) could modify the shared library's Groovy code to inject malicious logic that then silently executes inside every pipeline across the organization that uses the library, the next time each one runs. This is structurally the same risk as an unpinned, broadly-writable GitHub Action from Part 5. Fix: pin every consuming Jenkinsfile to an exact tag or commit SHA (@Library('shared-ci-lib@a1b2c3d')), and restrict write access to the library repository itself to a small, trusted set of maintainers with changes going through required review — the same governance discipline this series has applied to every other reusability mechanism.
Problem 3: An organization is deciding between Jenkins and GitHub Actions for a new internal tool that needs to integrate with three genuinely obscure, internally-built legacy systems, each with an existing (if unmaintained) Jenkins plugin and no GitHub Actions equivalent at all. What's the actual tradeoff, stated precisely?
Answer: The immediate integration cost favors Jenkins heavily — three existing plugins vs. three from-scratch custom GitHub Actions that would need to be authored and maintained in-house, a real and substantial difference in initial effort. The tradeoff is ongoing, not one-time: those existing plugins are described as unmaintained, meaning the org would be inheriting real security/compatibility risk (the plugin-ecosystem caveat covered in this chapter) alongside the integration convenience, and would likely end up maintaining forks of those plugins themselves anyway once they hit a compatibility issue with a newer Jenkins core version. A fair recommendation names both sides explicitly rather than defaulting to whichever tool is more familiar: if these three legacy integrations are a genuinely core, long-term requirement, Jenkins's plugin availability is a real, current advantage worth taking even with the maintenance risk; if they're a one-time migration hurdle for systems already planned for retirement, custom Actions (a one-time cost, then zero ongoing plugin-security governance burden) may be the better long-term bet despite the higher upfront effort.
Problem 4: A Jenkins controller suffers a total disk failure with no recent backup of its configuration. The team has all Jenkinsfiles safely in their respective application repos, but the controller's RBAC setup, credential definitions, and Kubernetes cloud configuration are gone. What would JCasC have changed about this incident's severity?
Answer: Without JCasC, this is a severe incident — the controller's security and infrastructure configuration existed only as internal state on the now-destroyed disk, meaning it has to be manually reconstructed from whatever documentation exists (often incomplete or stale) or from institutional memory of whoever originally configured it, a slow and error-prone process during which the team likely has no working CI at all. With JCasC, the jenkins.yaml configuration file lives in version control just like the Jenkinsfiles already do — recovery is standing up a fresh controller instance and pointing it at the same JCasC file, reproducing the exact prior RBAC, credential structure (values re-injected from a secrets manager, never the YAML itself), and cloud configuration automatically. This is the same argument Part 2's Infrastructure as Code chapter already made for infrastructure generally — the difference between "we hope someone remembers how this was configured" and "the configuration is a file we can re-apply" — applied here to the CI/CD controller specifically.
Summary and What's Next#
Jenkins predates and structurally differs from every other platform in this series: fully self-hosted by design, git-host-agnostic, and built around an explicit controller/agent architecture rather than an abstracted "runner" concept. Declarative Pipeline (structured pipeline { stages { steps { } } }, with post { } for outcome-based actions) is the modern default over both legacy Freestyle jobs and unstructured Scripted Pipeline; dynamic Kubernetes agents (a fresh pod per build) are the modern answer to the idle-capacity problem long-lived VM agent pools create at scale. Shared Libraries provide the most flexible reusability mechanism in this series (genuine Groovy code, not a constrained schema) at the cost of requiring the same supply-chain discipline (pinning, access control) as any other reused artifact. Jenkins's security model — CSRF protection, role-based authorization, Script Security, agent-to-controller access control — requires meaningfully more active, ongoing configuration than a SaaS platform, and JCasC extends the "config as code" discipline this course already argued for infrastructure generally (Part 2) to the controller's own security posture. Its 1,800+-plugin ecosystem is a genuine strength in integration breadth and a genuine, honestly-acknowledged operational and security governance cost.
The throughline worth carrying forward from this chapter specifically: every mechanism a SaaS platform in this series hands you by default — hosted execution, a curated extension marketplace, built-in RBAC — Jenkins instead hands you the tools to build yourself, at the cost of actually having to build and maintain it. That's neither a strictly better nor strictly worse tradeoff in the abstract; it's the correct tradeoff for a specific, real set of organizational constraints (regulatory isolation, deep legacy integration, a team with the operational capacity and inclination to own it), and the wrong one otherwise.
Part 10 moves to CircleCI — a cloud-native SaaS platform built around a reusable "Orbs" ecosystem and a strong performance/caching-focused engineering culture, offering a useful contrast to both Jenkins's self-hosted flexibility and the git-host-bundled platforms already covered.
Notice the shape of that contrast before reading it: CircleCI sits at close to the opposite end of the spectrum this chapter opened with — vendor-managed hosting, a curated Orb registry rather than an open plugin marketplace, and effectively zero controller operations for the adopting team. Reading the two chapters back to back is the fastest way to internalize exactly what a team gains and gives up at each end of that spectrum.