Skip to main content
Jenkins · lesson 4 of 5
Jenkins advanced Lesson 4 of 5

Jenkins Shared Libraries

Eliminate copy-pasted Jenkinsfiles by extracting common pipeline logic into a Shared Library—versioned, tested, and available to all team pipelines.

By SkillByExample · Updated

As your team grows, you end up copy-pasting the same Jenkinsfile stages across dozens of repos. Shared Libraries let you write pipeline steps once, version them, and use them everywhere.

Learning outcomes

By the end you can:

  • create a Shared Library repository with the correct structure
  • write and use global vars (steps)
  • load a library in a Jenkinsfile
  • use implicit and explicit library loading

1) Why Shared Libraries?

Without shared libraries:

  • Every repo has its own Jenkinsfile with duplicated Docker build/push logic
  • Fixing a bug means updating every repo
  • No central version control or testing for CI logic

With shared libraries:

  • One library repo contains reusable pipeline steps
  • Pipelines import the library—one fix propagates everywhere
  • The library itself is versioned, testable, and code-reviewed

2) Shared Library directory structure

jenkins-shared-library/
├── vars/
│   ├── buildDockerImage.groovy   # global step: buildDockerImage(...)
│   ├── deployToKubernetes.groovy # global step: deployToKubernetes(...)
│   └── notifySlack.groovy        # global step: notifySlack(...)
├── src/
│   └── org/
│       └── example/
│           └── PipelineUtils.groovy  # Groovy class
├── resources/
│   └── org/example/
│       └── deploy.sh             # scripts or templates
└── README.md

3) Writing a global step (vars/)

Each file in vars/ defines a call() method that becomes the step name.

// vars/buildDockerImage.groovy

def call(Map config = [:]) {
  // Provide defaults
  def registry  = config.registry  ?: 'registry.example.com'
  def imageName = config.imageName ?: error('imageName is required')
  def tag       = config.tag       ?: env.BUILD_NUMBER

  def fullImage = "${registry}/${imageName}:${tag}"

  echo "Building Docker image: ${fullImage}"

  sh "docker build -t ${fullImage} ."
  sh "docker push ${fullImage}"

  return fullImage
}
// vars/notifySlack.groovy

def call(String message, String color = 'good') {
  slackSend(
    channel: '#deployments',
    color: color,
    message: "${message} — ${env.JOB_NAME} #${env.BUILD_NUMBER} (<${env.BUILD_URL}|Open>)"
  )
}

4) Register the library in Jenkins

  1. Go to Manage Jenkins → Configure System → Global Pipeline Libraries
  2. Click Add:
    • Name: my-shared-lib
    • Default version: main
    • Retrieval method: Modern SCM → GitHub → your repo URL
  3. Tick Load implicitly (optional, auto-loads in all pipelines) or leave unchecked (explicit @Library)

5) Using the library in a Jenkinsfile

Explicit loading

@Library('my-shared-lib@main') _   // underscore imports all vars/

pipeline {
  agent any

  stages {
    stage('Build Image') {
      steps {
        script {
          def image = buildDockerImage(
            registry: 'registry.example.com',
            imageName: 'myapp',
            tag: env.BUILD_NUMBER
          )
          env.DOCKER_IMAGE = image
        }
      }
    }

    stage('Deploy') {
      when { branch 'main' }
      steps {
        deployToKubernetes(
          image: env.DOCKER_IMAGE,
          namespace: 'production',
          deployment: 'myapp'
        )
      }
    }
  }

  post {
    success { notifySlack("✅ Deploy succeeded") }
    failure { notifySlack("❌ Deploy failed", 'danger') }
  }
}

6) Kubernetes deploy step example

// vars/deployToKubernetes.groovy

def call(Map config = [:]) {
  def image      = config.image      ?: error('image is required')
  def namespace  = config.namespace  ?: 'default'
  def deployment = config.deployment ?: error('deployment is required')

  withCredentials([file(credentialsId: 'kubeconfig', variable: 'KUBECONFIG')]) {
    sh """
      kubectl set image deployment/${deployment} \
        ${deployment}=${image} \
        --namespace=${namespace}
      kubectl rollout status deployment/${deployment} \
        --namespace=${namespace} \
        --timeout=120s
    """
  }
}

7) Versioning the library

Pin to a specific git tag for stability:

@Library('my-shared-lib@v1.2.0') _

Or always use latest from main (risky for production):

@Library('my-shared-lib@main') _

8) Testing Shared Library code

Use jenkins-spock or plain Groovy unit tests.

Minimum: write a test playbook pipeline that calls each step with representative inputs and verify it runs end-to-end in a test Jenkins instance.

Practice

These solutions ran on a jenkins/jenkins:lts container with the Pipeline and Git plugins. The library is a local git repository at file:///repos/lib, registered as my-shared-lib with default version main and retrieval method Modern SCM → Git. Reading a local file:// repository needs Jenkins started with -Dhudson.plugins.git.GitSCM.ALLOW_LOCAL_CHECKOUT=true; a hosted repo does not. Each Jenkinsfile is a Pipeline job’s pasted script. Library loading adds about 30 lines of git output to each log, so the logs below are filtered with F='^(Loading library|Checking out|Commit message|Building|\[v2\]|\+ |returned|ERROR|Stage |Finished)'.

  1. Create the library repo with vars/buildDockerImage.groovy from section 3. Change its two sh lines to sh "echo docker build -t ${fullImage} ." and sh "echo docker push ${fullImage}", so no Docker daemon is needed. Also add this Groovy class at src/org/example/PipelineUtils.groovy, which task 4 uses:

    package org.example
    
    class PipelineUtils {
      static String imageTag(String version, String buildNumber) {
        return "${version}-b${buildNumber}"
      }
    }

    Commit both files, tag that commit v1.0.0, and call the step with only imageName: 'myapp', printing what it returns.

    Solution
    @Library('my-shared-lib@v1.0.0') _
    
    pipeline {
      agent any
    
      stages {
        stage('Build Image') {
          steps {
            script {
              def image = buildDockerImage(imageName: 'myapp')
              echo "returned: ${image}"
            }
          }
        }
      }
    }
    $ curl -s http://localhost:8080/job/lib-pinned/1/consoleText | grep -E "$F"
    Loading library my-shared-lib@v1.0.0
    Checking out Revision 0c7f8ae8bd126a4632f4d98c62e4181b86bfd850 (v1.0.0)
    Commit message: "buildDockerImage v1"
    Building Docker image: registry.example.com/myapp:1
    + echo docker build -t registry.example.com/myapp:1 .
    + echo docker push registry.example.com/myapp:1
    returned: registry.example.com/myapp:1
    Finished: SUCCESS

    The library is checked out before the pipeline starts. The two missing arguments fell back to their ?: defaults: the default registry, and env.BUILD_NUMBER (1) as the tag. return fullImage hands the string back to the calling script block.

  2. Call the step with registry: 'ghcr.io/acme' but no imageName, and add a Deploy stage after it with steps { echo "deploying" }.

    Solution
    $ curl -s http://localhost:8080/job/lib-noname/1/consoleText | grep -E "$F"
    Loading library my-shared-lib@v1.0.0
    Checking out Revision 0c7f8ae8bd126a4632f4d98c62e4181b86bfd850 (v1.0.0)
    Commit message: "buildDockerImage v1"
    Stage "Deploy" skipped due to earlier failure(s)
    ERROR: imageName is required
    Finished: FAILURE

    config.imageName ?: error(...) only evaluates the right-hand side when the left is missing, and error fails the build there. Nothing after it in the step ran, not even the “Building Docker image” echo, and Deploy was skipped. A required argument checked at the top of a step fails before any side effects.

  3. On main, after the tag, change the step’s log line to echo "[v2] Building Docker image: ${fullImage}" and commit. Build task 1’s job (lib-pinned) again, unchanged, then run the same Jenkinsfile with @Library('my-shared-lib@main') _ as a new job, lib-main.

    Solution
    $ curl -s http://localhost:8080/job/lib-pinned/2/consoleText | grep -E "$F"
    Loading library my-shared-lib@v1.0.0
    Checking out Revision 0c7f8ae8bd126a4632f4d98c62e4181b86bfd850 (v1.0.0)
    Commit message: "buildDockerImage v1"
    Building Docker image: registry.example.com/myapp:2
    + echo docker build -t registry.example.com/myapp:2 .
    + echo docker push registry.example.com/myapp:2
    returned: registry.example.com/myapp:2
    Finished: SUCCESS
    $ curl -s http://localhost:8080/job/lib-main/1/consoleText | grep -E "$F"
    Loading library my-shared-lib@main
    Checking out Revision ff7e6c92d6924b4d1d2b88ab2fdadf6fcec466c9 (main)
    Commit message: "buildDockerImage: prefix log with [v2]"
    [v2] Building Docker image: registry.example.com/myapp:1
    + echo docker build -t registry.example.com/myapp:1 .
    + echo docker push registry.example.com/myapp:1
    returned: registry.example.com/myapp:1
    Finished: SUCCESS

    Both builds ran after the [v2] commit existed, but lib-pinned #2 still checked out the tagged revision. Only its build number changed, so the default tag is now 2. Pinning means a library change reaches a pipeline only when that pipeline’s @Library line is bumped. @main picks up every push immediately.

  4. Still pinned to v1.0.0, import the PipelineUtils class you committed in task 1. Use it to build the tag you pass to buildDockerImage.

    Solution
    @Library('my-shared-lib@v1.0.0') _
    import org.example.PipelineUtils
    
    pipeline {
      agent any
    
      stages {
        stage('Build Image') {
          steps {
            script {
              def tag = PipelineUtils.imageTag('2.4.0', env.BUILD_NUMBER)
              def image = buildDockerImage(imageName: 'myapp', tag: tag)
              echo "returned: ${image}"
            }
          }
        }
      }
    }
    $ curl -s http://localhost:8080/job/lib-src/1/consoleText | grep -E "$F"
    Loading library my-shared-lib@v1.0.0
    Checking out Revision 0c7f8ae8bd126a4632f4d98c62e4181b86bfd850 (v1.0.0)
    Commit message: "buildDockerImage v1"
    Building Docker image: registry.example.com/myapp:2.4.0-b1
    + echo docker build -t registry.example.com/myapp:2.4.0-b1 .
    + echo docker push registry.example.com/myapp:2.4.0-b1
    returned: registry.example.com/myapp:2.4.0-b1
    Finished: SUCCESS

    vars/ steps are available as soon as the library loads. A src/ class must be imported by its package name, exactly like a normal Groovy class. Both came from the same @Library line and the same tagged revision.

  5. Delete the _ after @Library('my-shared-lib@v1.0.0') in task 1’s Jenkinsfile and run it.

    Solution
    $ curl -s http://localhost:8080/job/lib-nounderscore/1/consoleText | head -7
    Started from command line by anonymous
    org.codehaus.groovy.control.MultipleCompilationErrorsException: startup failed:
    WorkflowScript: 3: unexpected token: pipeline @ line 3, column 1.
       pipeline {
       ^
    
    1 error

    An annotation must be attached to something, and _ is the placeholder it attaches to. Without it Groovy can’t parse the file, so the build fails at compile time, before any library is even loaded (there is no “Loading library” line).

Next steps

  • Pair with Ansible: trigger playbooks from shared library steps
  • Jenkins Configuration as Code (JCasC) for managing Jenkins itself via YAML
  • GitOps pattern: replace Jenkins pushes with ArgoCD pull-based deploys

Frequently Asked Questions

Where do I host a Jenkins Shared Library?
In any Git repository (GitHub, GitLab, Bitbucket). You configure it under Manage Jenkins → Configure System → Global Pipeline Libraries. Jenkins checks out the library at pipeline start.
What is the difference between vars/ and src/ in a Shared Library?
Files in vars/ define global variables and are loaded automatically (call them like myFunction()). Files in src/ are Groovy classes you import explicitly with @Library—useful for more structured, testable code.