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' } } } } 

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 

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 

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) 

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 

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 

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 

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 

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 

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) 

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) 

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 

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 

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 

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.