DEV Community

Florient DOGBE
Florient DOGBE

Posted on

OpenShift Pipelines (Tekton): From a Monolithic Jenkinsfile to Reusable Tasks

Most teams that adopt OpenShift arrive with luggage: a Jenkinsfile that started at 60 lines and now weighs 800, with nested script {} blocks, a shared library nobody dares to touch, and three agents whose Docker images were last rebuilt in 2021. It works — until a new service needs the same build logic, and the only migration path is copy-paste.

OpenShift Pipelines (the supported downstream of Tekton) changes the unit of reuse. Instead of one file describing the whole flow, you get Task objects: versioned, namespaced (or clusterwide), testable in isolation, and composed into Pipeline objects. This article is a pragmatic migration guide: how to cut a monolithic Jenkinsfile into Tasks, how to handle workspaces, dependency resolution and build cache, and how to trigger everything from Git with an EventListener.

Context: why the monolith hurts on Kubernetes

A Jenkinsfile is executable Groovy. That is its strength and its curse. Logic, orchestration, and infrastructure assumptions all live in the same file:

pipeline {
  agent { label 'maven-jdk17' }
  stages {
    stage('Checkout') { steps { checkout scm } }
    stage('Build')    { steps { sh 'mvn -B -s settings.xml clean package' } }
    stage('Test')     { steps { sh 'mvn -B verify' } }
    stage('Image')    { steps { sh 'podman build -t $REG/$APP:$TAG . && podman push ...' } }
    stage('Deploy')   { steps { sh 'oc apply -f k8s/ && oc rollout status deploy/$APP' } }
  }
}
Enter fullscreen mode Exit fullscreen mode

Three problems surface once you run this at scale on OpenShift:

  1. No reuse boundary. stage('Image') is identical in 40 repositories, but each repo owns its own copy. One CVE in the build tooling means 40 pull requests.
  2. Implicit state. Jenkins gives you a persistent workspace directory on the agent, so .m2 cache, node_modules and the Git checkout "just exist". On Kubernetes, every step is a container in a Pod; nothing persists unless you declare it.
  3. Agent drift. The maven-jdk17 label points to a VM or image maintained outside Git. Tekton forces each step to name its own image, which is both more verbose and far more auditable.

The goal of migration is not a 1:1 translation. It is to extract the five or six genuinely reusable Tasks your organisation needs, version them, and leave the per-application glue in a thin Pipeline.

Concept mapping

Jenkins Tekton / OpenShift Pipelines
stage Task (or a step inside a Task)
agent / label step.image + podTemplate / taskRunTemplate
Workspace directory Workspace backed by PVC, emptyDir, ConfigMap or Secret
Shared library Catalog of Task / StepAction resources, versioned in Git or OCI bundles
Credentials binding Secret + ServiceAccount, or a Secret-backed workspace
Multibranch webhook EventListener + TriggerBinding + TriggerTemplate
Build history PipelineRun objects (pruned by the operator)

Getting the platform ready

OpenShift Pipelines ships as an operator. Verify it and install the CLI:

# Operator and version
oc get csv -n openshift-operators | grep pipelines

# Cluster-wide Tasks shipped by Red Hat (git-clone, buildah, s2i, ...)
oc get clustertasks 2>/dev/null | head
oc get tasks -n openshift-pipelines | head

# tkn CLI, matched to the cluster
tkn version
Enter fullscreen mode Exit fullscreen mode

Create a dedicated namespace and service account for the pipeline — do not reuse pipeline SA defaults blindly across teams:

oc new-project payments-ci
oc create sa pipeline-runner -n payments-ci
Enter fullscreen mode Exit fullscreen mode

Step 1 — Cut the Jenkinsfile into Tasks

Apply one rule: a Task is reusable only if it takes everything it needs as params and workspaces, and exposes everything downstream needs as results. No hardcoded registry, no hardcoded namespace.

Here is the Maven build extracted as a Task, with an explicit cache workspace:

apiVersion: tekton.dev/v1
kind: Task
metadata:
  name: maven-build
  namespace: payments-ci
  labels:
    app.kubernetes.io/version: "0.4"
spec:
  description: Build a Maven project, reusing a persistent ~/.m2 cache.
  params:
    - name: GOALS
      type: array
      default: ["-B", "clean", "package"]
    - name: MAVEN_IMAGE
      type: string
      default: registry.access.redhat.com/ubi9/openjdk-17:1.20
    - name: CONTEXT_DIR
      type: string
      default: "."
  workspaces:
    - name: source
      description: Checked-out sources.
    - name: maven-cache
      description: Persistent local repository.
      optional: true
    - name: maven-settings
      description: ConfigMap or Secret holding settings.xml.
      optional: true
  results:
    - name: ARTIFACT_VERSION
      description: Project version read from the POM.
  steps:
    - name: build
      image: $(params.MAVEN_IMAGE)
      workingDir: $(workspaces.source.path)/$(params.CONTEXT_DIR)
      args: ["$(params.GOALS[*])"]
      env:
        - name: MAVEN_OPTS
          value: -Dmaven.repo.local=$(workspaces.maven-cache.path)/repository
      script: |
        #!/usr/bin/env bash
        set -euo pipefail
        SETTINGS=""
        if [ -f "$(workspaces.maven-settings.path)/settings.xml" ]; then
          SETTINGS="-s $(workspaces.maven-settings.path)/settings.xml"
        fi
        mvn ${SETTINGS} "$@"
        mvn -q help:evaluate -Dexpression=project.version -DforceStdout \
          | tee $(results.ARTIFACT_VERSION.path)
Enter fullscreen mode Exit fullscreen mode

Two details matter here:

  • optional: true workspaces let the same Task run with or without a cache. Local developers can start a PipelineRun with an emptyDir; CI mounts a PVC.
  • Results are files, not environment variables. Write to $(results.<name>.path) with no trailing newline issues (tee is fine, Tekton trims trailing whitespace).

Versioning Tasks

Treat Tasks like libraries. Two viable strategies:

  1. Git + resolver. Keep Tasks in a platform-tekton-catalog repo and reference them by tag with the Git resolver. No cluster-side copy to keep in sync.
  2. OCI bundles. Push Tasks to your registry with tkn bundle push and reference them by digest. Best for air-gapped clusters and for signing.
# OCI bundle, immutable reference
tkn bundle push quay.io/acme/tekton-catalog/maven-build:0.4 -f task-maven-build.yaml
Enter fullscreen mode Exit fullscreen mode

In a Pipeline, pin the version:

- name: build
  taskRef:
    resolver: bundles
    params:
      - name: bundle
        value: quay.io/acme/tekton-catalog/maven-build@sha256:3f1d...   # digest, not tag
      - name: name
        value: maven-build
      - name: kind
        value: task
Enter fullscreen mode Exit fullscreen mode

Never reference a mutable tag in production pipelines. A digest is the Tekton equivalent of a pinned shared-library version.

Step 2 — Workspaces, dependency resolution and cache

This is where most migrations fail. Jenkins gave you a sticky filesystem; Tekton gives you declarative volumes.

Source workspace

Use a volumeClaimTemplate so each PipelineRun gets a fresh, automatically deleted PVC:

workspaces:
  - name: source
    volumeClaimTemplate:
      spec:
        accessModes: ["ReadWriteOnce"]
        resources:
          requests:
            storage: 2Gi
Enter fullscreen mode Exit fullscreen mode

With RWO storage, every Task sharing that PVC must be scheduled on the same node. Tekton's affinity assistant handles this automatically (it creates a placeholder StatefulSet that pins the Pods). Check the feature flag before you fight phantom scheduling issues:

oc get cm feature-flags -n openshift-pipelines -o jsonpath='{.data.coschedule}{"\n"}'
# expected: workspaces  (default) — pins Pods sharing a workspace to one node
Enter fullscreen mode Exit fullscreen mode

Cache workspace

The source workspace dies with the run; the cache must not. Create a long-lived PVC per team and language runtime:

apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: maven-cache
  namespace: payments-ci
spec:
  accessModes: ["ReadWriteMany"]   # RWX: concurrent PipelineRuns
  resources:
    requests:
      storage: 20Gi
  storageClassName: ocs-storagecluster-cephfs
Enter fullscreen mode Exit fullscreen mode

If you only have RWO storage (common on bare metal with LVM or on some cloud CSI drivers), you have two options:

  • One cache PVC per pipeline, ReadWriteOnce, and accept serialised runs (PipelineRun concurrency 1 via a mutex Task or an admission policy).
  • Externalise the cache: a cache-fetch / cache-upload Task pair that pulls a tarball from S3/ODF object storage keyed by the hash of pom.xml or package-lock.json. Slower cold start, but fully parallel and node-independent.

The key-based approach is worth the extra two Tasks once you exceed ~10 concurrent builds:

- name: cache-key
  image: registry.access.redhat.com/ubi9/ubi-minimal:9.4
  script: |
    #!/bin/sh
    set -e
    sha256sum $(workspaces.source.path)/pom.xml | cut -c1-16 \
      > $(results.CACHE_KEY.path)
Enter fullscreen mode Exit fullscreen mode

Step 3 — Compose the Pipeline

The Pipeline is now thin: ordering, parameter plumbing, and workspace binding.

apiVersion: tekton.dev/v1
kind: Pipeline
metadata:
  name: payments-api
  namespace: payments-ci
spec:
  params:
    - name: git-url
    - name: git-revision
      default: main
    - name: image
      default: image-registry.openshift-image-registry.svc:5000/payments-ci/payments-api
  workspaces:
    - name: source
    - name: maven-cache
    - name: maven-settings
    - name: dockerconfig
  results:
    - name: image-digest
      value: $(tasks.build-image.results.IMAGE_DIGEST)
  tasks:
    - name: clone
      taskRef: { name: git-clone, kind: Task }
      params:
        - name: url
          value: $(params.git-url)
        - name: revision
          value: $(params.git-revision)
      workspaces:
        - name: output
          workspace: source

    - name: build
      runAfter: ["clone"]
      taskRef: { name: maven-build, kind: Task }
      params:
        - name: GOALS
          value: ["-B", "clean", "verify"]
      workspaces:
        - name: source
          workspace: source
        - name: maven-cache
          workspace: maven-cache
        - name: maven-settings
          workspace: maven-settings

    - name: build-image
      runAfter: ["build"]
      taskRef: { name: buildah, kind: Task }
      params:
        - name: IMAGE
          value: $(params.image):$(tasks.build.results.ARTIFACT_VERSION)
        - name: TLSVERIFY
          value: "true"
      workspaces:
        - name: source
          workspace: source
        - name: dockerconfig
          workspace: dockerconfig

  finally:
    - name: report
      taskRef: { name: slack-notify, kind: Task }
      params:
        - name: status
          value: $(tasks.status)
Enter fullscreen mode Exit fullscreen mode

finally replaces the Jenkins post { always { ... } } block, and $(tasks.status) gives you Succeeded, Failed or Completed.

Run and watch it with the CLI:

tkn pipeline start payments-api \
  -p git-url=https://github.com/acme/payments-api.git \
  -p git-revision=main \
  -w name=source,volumeClaimTemplateFile=./wsp-source.yaml \
  -w name=maven-cache,claimName=maven-cache \
  -w name=maven-settings,config=maven-settings \
  -w name=dockerconfig,secret=registry-push \
  --serviceaccount pipeline-runner \
  --use-param-defaults --showlog

tkn pr list
tkn pr logs -f --last
tkn pr describe --last
tkn taskrun logs payments-api-run-abc123-build -f
Enter fullscreen mode Exit fullscreen mode

tkn pr logs -f --last is the command you will type a thousand times; make an alias.

Step 4 — Trigger from Git with an EventListener

Triggers replace the Jenkins multibranch webhook. Three objects: a TriggerBinding (extract fields from the payload), a TriggerTemplate (render a PipelineRun), an EventListener (HTTP endpoint + interceptors).

apiVersion: triggers.tekton.dev/v1beta1
kind: TriggerBinding
metadata:
  name: github-push
  namespace: payments-ci
spec:
  params:
    - name: git-url
      value: $(body.repository.clone_url)
    - name: git-revision
      value: $(body.after)
---
apiVersion: triggers.tekton.dev/v1beta1
kind: TriggerTemplate
metadata:
  name: payments-api-template
  namespace: payments-ci
spec:
  params:
    - name: git-url
    - name: git-revision
  resourcetemplates:
    - apiVersion: tekton.dev/v1
      kind: PipelineRun
      metadata:
        generateName: payments-api-
      spec:
        taskRunTemplate:
          serviceAccountName: pipeline-runner
        pipelineRef:
          name: payments-api
        params:
          - name: git-url
            value: $(tt.params.git-url)
          - name: git-revision
            value: $(tt.params.git-revision)
        timeouts:
          pipeline: "1h0m0s"
        workspaces:
          - name: source
            volumeClaimTemplate:
              spec:
                accessModes: ["ReadWriteOnce"]
                resources:
                  requests:
                    storage: 2Gi
          - name: maven-cache
            persistentVolumeClaim:
              claimName: maven-cache
          - name: maven-settings
            configMap:
              name: maven-settings
          - name: dockerconfig
            secret:
              secretName: registry-push
---
apiVersion: triggers.tekton.dev/v1beta1
kind: EventListener
metadata:
  name: payments-el
  namespace: payments-ci
spec:
  serviceAccountName: tekton-triggers-sa
  triggers:
    - name: github-push-main
      interceptors:
        - ref:
            name: github
          params:
            - name: secretRef
              value:
                secretName: github-webhook-secret
                secretKey: secretToken
            - name: eventTypes
              value: ["push"]
        - ref:
            name: cel
          params:
            - name: filter
              value: "body.ref == 'refs/heads/main'"
      bindings:
        - ref: github-push
      template:
        ref: payments-api-template
Enter fullscreen mode Exit fullscreen mode

Expose it with a TLS-terminated route (never plain HTTP for a webhook):

oc create secret generic github-webhook-secret \
  --from-literal=secretToken="$(openssl rand -hex 20)" -n payments-ci

oc create route edge payments-el --service=el-payments-el \
  --port=8080 --insecure-policy=Redirect -n payments-ci

oc get route payments-el -o jsonpath='{.spec.host}{"\n"}'
tkn eventlistener logs payments-el -f
Enter fullscreen mode Exit fullscreen mode

Point the GitHub/GitLab webhook at https://<route-host> with the same secret. The github interceptor validates the HMAC signature; the CEL interceptor filters branches so you do not start a build for every tag push.

Security best practices

CI is the most privileged system in most organisations: it can read all source code and push to production registries. Treat it accordingly.

  • One ServiceAccount per pipeline, not a shared one. Give pipeline-runner only the RBAC it needs (push to its own ImageStream, deploy to its own namespace). The Triggers SA needs create on pipelineruns and nothing else — bind the shipped tekton-triggers-eventlistener-roles ClusterRole, not edit.
  • Avoid privileged builds. The Red Hat buildah Task runs rootless with the pipelines-scc SCC. Do not grant privileged to work around a storage driver error; set STORAGE_DRIVER=vfs or use fuse-overlayfs instead.
  • Never pass secrets as params. Params end up in the PipelineRun spec, visible to anyone with get pipelineruns. Mount secrets as workspaces (secret: secretName: ...) or bind them to the ServiceAccount.
  • Scope registry credentials. Prefer the internal registry with a ServiceAccount token over a long-lived dockerconfigjson for quay.io. If you need an external registry, use a robot account restricted to one repository.
  • Validate webhook payloads. The github/gitlab interceptors verify the HMAC signature — always configure secretRef. An unauthenticated EventListener is remote code execution with extra steps.
  • Pin Task references by digest (OCI bundles) or by Git tag with commit verification. A mutable :latest catalog Task is a supply-chain backdoor.
  • Enable Tekton Chains for SLSA provenance and image signing; it is bundled with OpenShift Pipelines and signs TaskRun outputs automatically.
  • Apply a NetworkPolicy on the CI namespace: allow egress to the registry, Git and artifact proxy; deny lateral traffic to application namespaces.
  • Set timeouts and resource limits. A Pipeline without a timeout is a PVC leak and a node-pressure incident waiting to happen. Also configure the operator's pruner to delete old PipelineRun objects.

Conclusion

Migrating from Jenkins to Tekton is not a file conversion, it is a refactoring exercise. The mental shift is straightforward once you accept it: a Task is a function with typed inputs (params, workspaces) and typed outputs (results), and a Pipeline is the call graph.

Key takeaways:

  • Start by extracting 5–6 Tasks, not 30. Build, test, image, deploy, notify. Everything else stays inline until a second consumer appears.
  • Declare your state. The source workspace is ephemeral (volumeClaimTemplate), the dependency cache is persistent (RWX PVC or object storage keyed by lockfile hash). Getting this wrong is the main cause of "Tekton is slower than Jenkins" complaints.
  • Version Tasks as artifacts, referenced by digest through the bundles or git resolver. That is what finally kills the copy-paste problem.
  • Triggers replace the multibranch job: EventListener + interceptors give you signature validation and CEL filtering in a declarative, reviewable form.
  • Live in tkn. tkn pr logs -f --last, tkn pr describe, tkn eventlistener logs will cover 90% of your debugging.

Run both systems in parallel for a few sprints: let Jenkins keep deploying while Tekton builds and publishes images. When the Tekton PipelineRun has been green for two weeks, flip the webhook and delete the Jenkinsfile. The diff will be the best part of the migration.

Top comments (0)