Kubernetes • Jenkins • Helm • K3s

Kubernetes CI/CD with Jenkins, Buildkit, and Helm (K3s)

This is a full, production style setup based on my Jenkins shared library. It builds Docker images with Buildkit, pushes them to a registry, and deploys to Kubernetes using Helm charts. It also creates branch based environments and cleans them on merge.

Updated: February 27, 2026Read time: 14-16 min
Jenkins shared library CI/CD pipeline flow

What You Will Build

  • GitHub push triggers Jenkins.
  • Only changed services build and deploy.
  • Buildkit builds and pushes container images.
  • Helm deploys to K3s with dynamic hostnames.
  • Merged branches are cleaned up automatically.

Prerequisites

  • Jenkins with Kubernetes plugin enabled.
  • Global Pipeline Library configured in Jenkins.
  • K3s or Kubernetes cluster with Helm installed.
  • Container registry like `registry.com`.
  • Ingress controller and a base domain.

Step 0: Configure Jenkins Global Library

In Jenkins, go to Manage Jenkins → Configure System → Global Pipeline Libraries and add a library named `jenkins-global-pipeline`. Set the default version to your main branch and enable “Load implicitly” if you want to avoid repeating the `@Library` line in Jenkinsfiles.

Files in This Setup

Folder layout used in the Jenkins library and Helm chart.

Folder structure for Jenkins shared library and Helm chart

Jump to file sections:kub.groovyK3sEnv.groovyk3sDeploy.groovyChart.yamlvalues.yamldeployment.yamlservice.yamlingress.yaml

Step 1: Entry Point in vars/kub.groovy

This is the pipeline entry. It sets up the environment, creates parallel stages per service, runs audit, build, deploy, and cleanup.

import k3s.k3sDeploy
import k3s.K3sEnv

def call(Map appConfig = [:], List deployemnts = []) {

    def parallelStages = [:]

    // MAIN START
    properties([disableConcurrentBuilds()])

    node {
        // Initialize our unified Environment class
        def k3sEnv = new K3sEnv(this)

        stage("Detect Changes & Setup Env") {
            // Function to configure environment variables and source code
            k3sEnv.initEnv()
            
            // Example of adding a custom value:
            // k3sEnv.setEnv('MY_CUSTOM_VAR', 'my-value')
        }

        deployemnts.each { app ->
            app.each { name, config ->
                def servicePath = config.path

                parallelStages[servicePath] = {
                    if (k3sEnv.shouldRunService(servicePath)) {
                        def deployer = new k3sDeploy(this, appConfig, name, config, servicePath)
                        def deployName = k3sEnv.getEnv('DEPLOY_NAME')
                        
                        stage("Npm Audit ${servicePath}") {
                            deployer.npmAudit()
                        }

                        stage("Build & Push ${servicePath}") {
                            deployer.buildAndPush()
                        }
                        
                        if (deployName == 'dev' || deployName == 'sprint') {
                            stage("Deploy ${servicePath}") {
                                deployer.deploy()
                            }
                        } else {
                            echo "Skipping Deploy for ${servicePath}: Deploy Name is not 'dev' or 'sprint' (Current: ${deployName})"
                        }
                        
                        if (k3sEnv.getEnv('IS_MERGE') == true || k3sEnv.getEnv('IS_MERGE') == 'true') {
                            stage("Cleanup Merged Source ${servicePath}") {
                                deployer.deleteMergedDeploy()
                            }
                        }
                    }
                }
            }
        }

        if (parallelStages) {
            parallel parallelStages
        } else {
            echo "No deployments provided."
        }
    }
}

How it works: builds run in parallel for each service path. Deploy only runs for `dev` and `sprint` branches. Cleanup runs on merge.

Step 2: Environment Logic in src/k3s/K3sEnv.groovy

This file builds the entire runtime context. It detects changed files, extracts repo/org, sets registry and domain, and derives deploy ID from branch names.

package k3s

class K3sEnv implements Serializable {
    def script
    Map envMap = [:]

    K3sEnv(script) {
        this.script = script
    }

    // Function to run the logic previously found in kub.groovy
    def initEnv() {
        script.checkout(script.scm)

        // Get changed files from last commit
        def changedFilesStr = script.sh(
            script: "git diff --name-only HEAD~1 HEAD || true",
            returnStdout: true
        ).trim()
        
        def changedFiles = changedFilesStr ? changedFilesStr.split("\n") as List : []
        setEnv('CHANGED_FILES', changedFiles)

        // Get remote configs to extract repository metadata
        def url = script.scm.getUserRemoteConfigs()[0].getUrl()
        
        // REPO NAME
        def repoName = url.tokenize('/.')[-2]
        setEnv('REPO_NAME', repoName)
        
        // ORG NAME
        def gitOrg = url.tokenize('/.')[-3]
        setEnv('GIT_ORG', gitOrg)
        
        // REGISTRY URL
        setEnv('REGISTRY_URL', "registry.com")
        // BASE DOMAIN
        setEnv('BASE_DOMAIN', "deploy.example.com")

        // IDENTIFY DEPLOY ID FROM BRANCH
        def branchName = script.env.BRANCH_NAME ?: 'latest'
        
        // CHECK IF MERGE REQUEST
        def isMerge = false
        def sourceBranch = ""
        def targetBranch = branchName
        
        // Typical git merge commit messages look like: "Merge branch 'feature-x' into 'dev'"
        def commitMsg = script.sh(script: "git log -1 --pretty=%B || true", returnStdout: true).trim()

        script.echo "===== COMMIT MESSAGE ===="
        script.echo "Commit Message: ${commitMsg}"
        script.echo "========================"
        
        // Simple regex to match "Merge branch 'X' into 'Y'" or similar standard merge commits
        def mergeMatcher = (commitMsg =~ /Merge branch '([^']+)' into '([^']+)'/)
        if (mergeMatcher.find()) {
            isMerge = true
            sourceBranch = mergeMatcher.group(1)
            targetBranch = mergeMatcher.group(2)
            script.echo "=== MERGE DETECTED ==="
            script.echo "Branch '${sourceBranch}' merged into branch '${targetBranch}'"
            script.echo "======================"
        } else {
            // Check alternative format "Merge pull request #123 from org/branch"
            def prMatcher = (commitMsg =~ /Merge pull request #[0-9]+ from [^\/]+\/([^\s]+)/)
            if (prMatcher.find()) {
                isMerge = true
                sourceBranch = prMatcher.group(1)
                script.echo "=== PR MERGE DETECTED ==="
                script.echo "Branch '${sourceBranch}' merged into branch '${targetBranch}'"
                script.echo "========================="
            }
        }
        
        setEnv('IS_MERGE', isMerge)
        setEnv('SOURCE_BRANCH', sourceBranch)
        
        // Parse source branch details (the branch that was merged)
        def sourceBranchDetails = getBranchDetails(sourceBranch)
        setEnv('SOURCE_DEPLOY_ID', sourceBranchDetails.deployId)
        setEnv('SOURCE_DEPLOY_NAME', sourceBranchDetails.deployName)
        
        // Parse current branch details
        def currentBranchDetails = getBranchDetails(branchName)
        def deployId = currentBranchDetails.deployId
        setEnv('DEPLOY_ID', deployId)
        setEnv('DEPLOY_NAME', currentBranchDetails.deployName)
        if (currentBranchDetails.sprintNumber) {
            setEnv('SPRINT_NUMBER', currentBranchDetails.sprintNumber)
        }
        
        // Parse target branch details (useful if it's a merge)
        def targetBranchDetails = getBranchDetails(targetBranch)
        setEnv('TARGET_BRANCH', targetBranch)
        setEnv('TARGET_DEPLOY_ID', targetBranchDetails.deployId)
        setEnv('TARGET_DEPLOY_NAME', targetBranchDetails.deployName)

        script.echo "REPO NAME: ${repoName}"
        script.echo "ORG NAME: ${gitOrg}"
        script.echo "BRANCH: ${branchName} | DEPLOY ID: ${deployId}"
        script.echo "Changed files: ${changedFiles}"
    }

    // A simple method to add any custom variable
    def setEnv(String key, def value) {
        this.envMap[key] = value
        // Optional: Keep Jenkins native env in sync so standard plugins can also read it
        if (value instanceof String || value instanceof GString || value instanceof Boolean || value instanceof Integer) {
            script.env[key] = value.toString()
        }
    }

    // Retrieve the variable from class storage or natively from script.env
    def getEnv(String key) {
        return this.envMap[key] ?: script.env[key]
    }

    // Function to check if a specific service should be built/deployed
    def shouldRunService(String servicePath) {
        def branchName = script.env.BRANCH_NAME ?: 'latest'
        
        // Ensure we only build for permitted branches
        if (!(branchName == 'dev' || branchName == 'prod' || branchName.startsWith('sprint-'))) {
            script.echo "Skipping ${servicePath}: Branch '${branchName}' is not dev, prod, or sprint."
            return false
        }

        // Check if build was triggered manually or is the first build
        def isManual = false
        if (script.currentBuild && script.currentBuild.getBuildCauses()) {
            isManual = script.currentBuild.getBuildCauses().any { it._class == 'hudson.model.Cause$UserIdCause' }
        }
        
        def isFirstBuild = script.env.BUILD_NUMBER == '1'
        
        // Ensure exact path matching by expecting a trailing slash (e.g. 'api/') if checking directories
        // or just using exactly matches or startsWith. Depending on git diff output:
        def pathToCheck = servicePath.endsWith('/') ? servicePath : "${servicePath}/"
        def changedFiles = getEnv('CHANGED_FILES') as List ?: []
        def hasChanges = changedFiles.any { it.startsWith(pathToCheck) }
        
        if (isManual || isFirstBuild || hasChanges) {
            script.echo "Service ${servicePath} matches build conditions. (Manual: ${isManual}, FirstBuild: ${isFirstBuild}, Changed: ${hasChanges})"
            return true
        } else {
            script.echo "Skipping ${servicePath}: No changes detected, not triggered manually, and not the first build."
            return false
        }
    }
    // Helper to extract Deploy ID and Deploy Name
    def getBranchDetails(String branch) {
        def deployId = branch
        def deployName = branch
        def sprintNumber = null
        
        if (branch.startsWith('sprint-')) {
            deployName = 'sprint'
            def parts = branch.split('-')
            if (parts.length > 1 && parts[1].matches("\\d+")) {
                deployId = parts[1]
                sprintNumber = parts[1]
            }
        }
        
        return [
            deployId: deployId,
            deployName: deployName,
            sprintNumber: sprintNumber
        ]
    }
}

Replace `registry.com` and `deploy.example.com` with your real registry and base domain before using in production.

Step 3: Build and Deploy in src/k3s/k3sDeploy.groovy

This class handles npm audit, Buildkit image build + push, Helm deployment, and cleanup on merge.

package k3s

class k3sDeploy implements Serializable {
    def script
    Map appConfig
    String name
    Map config
    String servicePath
    String envJsonData = ""

    k3sDeploy(script, Map appConfig, String name, Map config, String servicePath) {
        this.script = script
        this.appConfig = appConfig
        this.name = name
        this.config = config
        this.servicePath = servicePath
    }

    private def getDomain(sl1, sl2, sl3) {
        def baseDomain = script.env.BASE_DOMAIN ?: "deploy.example.com"
        return "${sl1}.${sl2}.${sl3}.${baseDomain}"
    }

    def start() {
        script.echo "Starting deployment process for ${servicePath}..."
    }

    def checkLint() {
        script.echo "Running lint checks for ${servicePath}..."
    }

    def npmAudit() {
        script.podTemplate(
            agentContainer: 'npm-audit',
            agentInjection: true,                            
            containers: [
                script.containerTemplate(name: 'node', image: "node:${config.node_version}-alpine", ttyEnabled: true, privileged: true)
            ]
        ) {
            script.node(script.POD_LABEL) {
                script.checkout(script.scm)
                script.dir(servicePath) {
                    if (script.fileExists('package.json')) {
                        script.container('node') {
                            script.sh 'ls -la'                                            
                            // adding || true to prevent pipeline from failing if audit fails, or maintain old behavior
                            script.sh 'npm audit --audit-level=high'
                        }
                    } else {
                        script.echo "Skipping Npm Audit: No package.json found in ${servicePath}"
                    }
                }
            }
        }  
    }

    def buildAndPush() {
        script.podTemplate(
            containers: [
                script.containerTemplate(name: 'buildkit', image: 'moby/buildkit:rootless', command: 'sleep', args: '99d', ttyEnabled: true, privileged: true)
            ],
            volumes: [
                script.secretVolume(secretName: 'runner-docker-config', mountPath: '/home/user/.docker')
            ]
        ) {
            script.node(script.POD_LABEL) {
                script.checkout(script.scm)
                script.dir(servicePath) {
                    if (script.fileExists('Dockerfile')) {
                        script.container('buildkit') {
                            def gitOrg = script.env.GIT_ORG ?: 'ORG_NAME'
                            def repoName = script.env.REPO_NAME ?: 'REPO_NAME'                            
                            def deployId = script.env.DEPLOY_ID
                            def imageName = "${script.env.REGISTRY_URL}/${gitOrg}/${repoName}-${servicePath}-${deployId}:latest"

                            // Safe access to appConfig property
                            def apiPath = appConfig?.apiPath ?: ""
                            def API_URL = "https://${getDomain(apiPath, deployId, repoName)}"                              
                            
                            // Save default.json across stages using class property
                            if (script.fileExists("env/default.json")) {
                                this.envJsonData = script.sh(
                                    script: "cat env/default.json | tr -d '[:space:]'", 
                                    returnStdout: true
                                ).trim()
                            }

                            def buildArgsStr = ""
                            if (apiPath && deployId != "prod" && deployId != "master") {
                                buildArgsStr += "--opt build-arg:REACT_APP_BASE_URL=${API_URL} "
                                buildArgsStr += "--opt build-arg:API_BASE_URL=${API_URL} "
                            }

                            script.sh """#!/bin/sh
                                # Handle docker config json mapping
                                if [ -f /home/user/.docker/.dockerconfigjson ]; then
                                    mkdir -p /tmp/.docker
                                    cp /home/user/.docker/.dockerconfigjson /tmp/.docker/config.json
                                    export DOCKER_CONFIG=/tmp/.docker
                                else
                                    export DOCKER_CONFIG=/home/user/.docker
                                fi
                                
                                buildctl-daemonless.sh build \\
                                    --frontend dockerfile.v0 \\
                                    --local context=. \\
                                    --local dockerfile=. \\
                                    --output type=image,name=${imageName},push=true \\
                                    ${buildArgsStr}
                            """
                        }
                    } else {
                        script.echo "Skipping Build & Push: No Dockerfile found in ${servicePath}"
                    }
                }
            }
        }
    }

    def deploy() {
        script.podTemplate(
            containers: [
                script.containerTemplate(name: 'helm', image: 'alpine/helm:3.14.3', command: 'sleep', args: '99d', ttyEnabled: true)
            ],
            volumes: [
                script.secretVolume(secretName: 'runner-docker-config', mountPath: '/home/user/.docker')
            ]
        ) {
            script.node(script.POD_LABEL) {
                script.container('helm') {
                    def gitOrg = script.env.GIT_ORG ?: 'ORG_NAME'
                    def repoName = script.env.REPO_NAME ?: 'REPO_NAME'                    
                    def deployId = script.env.DEPLOY_ID
                    def imageName = "${script.env.REGISTRY_URL}/${gitOrg}/${repoName}-${servicePath}-${deployId}"

                    def service_name = "${repoName}-${servicePath}-${deployId}"
                    def url = getDomain(servicePath, deployId, repoName)

                    script.sh """#!/bin/sh
                       # Setup helm registry credentials from the mounted docker config secret
                       mkdir -p ~/.config/helm/registry
                       if [ -f /home/user/.docker/.dockerconfigjson ]; then
                           cp /home/user/.docker/.dockerconfigjson ~/.config/helm/registry/config.json
                       fi

                       ENV_FLAG=""
                       # Retrieve envJsonData class property populated in buildAndPush
                       if [ -n '${this.envJsonData}' ]; then
                           echo '${this.envJsonData}' | tr -d '[:space:]' > ${service_name}-env.json
                           ENV_FLAG="--set-file env=${service_name}-env.json"
                       fi
                       
                       PERSISTENCE_FLAG=""
                       if [ -n '${config.volume}' ]; then
                           PERSISTENCE_FLAG="--set persistence.enabled=true --set persistence.mountPath='${config.volume}'"
                       fi

                       helm upgrade --install ${service_name} oci://${script.env.REGISTRY_URL}/helm/${name.toLowerCase()} \\
                       --set fullnameOverride=${service_name} \\
                       --set ingress.host=${url} \\
                       --set image.repository=${imageName} \\
                       --set image.tag=latest \\
                       \$ENV_FLAG \\
                       \$PERSISTENCE_FLAG \\
                       --namespace ms-dev
                    """
                }
            }
        }
    }

    def deleteMergedDeploy() {
        def isMerge = script.env.IS_MERGE == 'true' || script.env.IS_MERGE == true
        if (!isMerge) {
            script.echo "Skipping Merge Cleanup: Not a merge event."
            return
        }

        def sourceBranch = script.env.SOURCE_BRANCH
        if (!sourceBranch) {
            script.echo "Skipping Merge Cleanup: SOURCE_BRANCH is empty."
            return
        }

        // Get the Deploy ID that would have been used for the source branch
        // We reuse the pre-calculated value from K3sEnv
        def sourceDeployId = script.env.SOURCE_DEPLOY_ID ?: sourceBranch

        script.podTemplate(
            containers: [
                script.containerTemplate(name: 'helm', image: 'alpine/helm:3.14.3', command: 'sleep', args: '99d', ttyEnabled: true)
            ],
            volumes: [
                script.secretVolume(secretName: 'runner-docker-config', mountPath: '/home/user/.docker')
            ]
        ) {
            script.node(script.POD_LABEL) {
                script.container('helm') {
                    def repoName = script.env.REPO_NAME ?: 'REPO_NAME'
                    def service_name = "${repoName}-${servicePath}-${sourceDeployId}"

                    script.echo "Attempting to cleanup merged deployment: ${service_name} (from branch ${sourceBranch})"

                    script.sh """#!/bin/sh
                       # Setup helm registry credentials from the mounted docker config secret
                       mkdir -p ~/.config/helm/registry
                       if [ -f /home/user/.docker/.dockerconfigjson ]; then
                           cp /home/user/.docker/.dockerconfigjson ~/.config/helm/registry/config.json
                       fi

                       # Run helm uninstall
                       helm uninstall ${service_name} --namespace ms-dev || echo "Deployment ${service_name} not found or already deleted."
                    """
                }
            }
        }
    }
}

How it works: image tags include repo + service + deployId, so each branch gets an isolated deployment and URL.

Helm chart structure for deployment, service, and ingress

Step 4: Helm Chart Metadata (Chart.yaml)

apiVersion: v2
name: nextjs
description: A Helm chart for deploying generic NextJS fullstack applications
type: application
version: 0.1.0
appVersion: "1.0.0"

# UPDATE HELM REPO: helm registry login registry.com --username USERNAME --password PASSWORD
# PUSH HELM CHART: helm package . && helm push nextjs-0.1.0.tgz oci://registry.com/helm

Step 5: Helm Values (values.yaml)

These values are what the pipeline overrides at deploy time.

fullnameOverride: "next-js-test"
namespace: "ms-dev"

replicaCount: 1

image:
  repository: registry.com/test
  tag: latest

service:
  port: 3000

ingress:
  enabled: true
  host: test.dev.example.com

Step 6: Deployment Template (deployment.yaml)

apiVersion: apps/v1
kind: Deployment
metadata:
  name: "{{ include \"nextjs.fullname\" . }}"
  namespace: "{{ .Values.namespace }}"
spec:
  replicas: {{ .Values.replicaCount }}
  selector:
    matchLabels:
      app: "{{ include \"nextjs.fullname\" . }}"
  template:
    metadata:
      annotations:
        rollout: "{{ now | unixEpoch }}"
      labels:
        app: "{{ include \"nextjs.fullname\" . }}"
    spec:
      imagePullSecrets:
        - name: msdev-k303-registry-secret
      containers:
        - name: nextjs
          image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
          imagePullPolicy: Always
          ports:
            - containerPort: {{ .Values.service.port }}

Step 7: Service Template (service.yaml)

apiVersion: v1
kind: Service
metadata:
  name: "{{ include \"nextjs.fullname\" . }}"
  namespace: "{{ .Values.namespace }}"
spec:
  type: ClusterIP
  selector:
    app: "{{ include \"nextjs.fullname\" . }}"
  ports:
    - port: {{ .Values.service.port }}
      targetPort: {{ .Values.service.port }}

Step 8: Ingress Template (ingress.yaml)

{{- if .Values.ingress.enabled -}}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: "{{ include \"nextjs.fullname\" . }}"
  namespace: "{{ .Values.namespace }}"
  annotations:
    cert-manager.io/cluster-issuer: "letsencrypt-prod"
    nginx.ingress.kubernetes.io/ssl-redirect: "true"
    nginx.ingress.kubernetes.io/proxy-body-size: "2G"
spec:
  ingressClassName: nginx
  tls:
    - hosts:
        - {{ .Values.ingress.host | quote }}
      secretName: "{{ include \"nextjs.fullname\" . }}-tls"
  rules:
    - host: {{ .Values.ingress.host | quote }}
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: "{{ include \"nextjs.fullname\" . }}"
                port:
                  number: {{ .Values.service.port }}
{{- end }}

Step 9: Jenkinsfile Example

Each app repo only needs a simple Jenkinsfile to call the shared library.

@Library('jenkins-global-pipeline') _

def appConfig = [apiPath: \"api\"]
def deployments = [
  [web: [path: \"web\", node_version: \"20\", volume: \"/data\"]],
  [api: [path: \"api\", node_version: \"20\"]]
]

kub(appConfig, deployments)

Step 10: Setup Checklist

  • Configure the Jenkins Global Pipeline Library.
  • Create Kubernetes secret for registry pull.
  • Configure `runner-docker-config` secret for Buildkit.
  • Ensure Helm registry login works for your OCI chart.
  • Set base domain and registry URL for your environment.

What Each File Does and What You Might Customize

  • `vars/kub.groovy` defines the pipeline entry point, runs stages in parallel per service, and controls when deploy and cleanup happen.
  • `src/k3s/K3sEnv.groovy` builds runtime context: repo/org, changed files, branch rules, deploy IDs, and merge detection.
  • `src/k3s/k3sDeploy.groovy` executes npm audit, Buildkit image build, Helm deploy, and merge cleanup.
  • `deployments/nextjs/Chart.yaml` sets chart metadata and OCI registry usage for Helm.
  • `deployments/nextjs/values.yaml` holds defaults that Jenkins overrides at deploy time (image, port, ingress host).
  • `templates/deployment.yaml` defines the Kubernetes Deployment and imagePullSecrets.
  • `templates/service.yaml` exposes the app internally via ClusterIP.
  • `templates/ingress.yaml` enables TLS and routes traffic to the service.

Optional additions: add resource limits, liveness/readiness probes, autoscaling (HPA), and monitoring alerts. For multi-env setups, extend branch rules and set separate namespaces or base domains per environment.

Replace These Placeholders

Update these values before using the pipeline in your organization:

  • `registry.com` → your registry domain
  • `deploy.example.com` → your base domain
  • `test.dev.example.com` → your ingress host
  • `ms-dev` → your target namespace
  • `msdev-k303-registry-secret` → your imagePullSecret name
  • `runner-docker-config` → your Docker auth secret