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:
-
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. -
Implicit state. Jenkins gives you a persistent workspace directory on the agent, so
.m2cache,node_modulesand the Git checkout "just exist". On Kubernetes, every step is a container in a Pod; nothing persists unless you declare it. -
Agent drift. The
maven-jdk17label 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: trueworkspaces let the same Task run with or without a cache. Local developers can start aPipelineRunwith anemptyDir; CI mounts a PVC. -
Results are files, not environment variables. Write to
$(results.<name>.path)with no trailing newline issues (teeis fine, Tekton trims trailing whitespace).
Versioning Tasks
Treat Tasks like libraries. Two viable strategies:
-
Git + resolver. Keep Tasks in a
platform-tekton-catalogrepo and reference them by tag with the Git resolver. No cluster-side copy to keep in sync. -
OCI bundles. Push Tasks to your registry with
tkn bundle pushand 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 (PipelineRunconcurrency 1 via a mutex Task or an admission policy). -
Externalise the cache: a
cache-fetch/cache-uploadTask pair that pulls a tarball from S3/ODF object storage keyed by the hash ofpom.xmlorpackage-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-runneronly the RBAC it needs (push to its own ImageStream, deploy to its own namespace). The Triggers SA needscreateonpipelinerunsand nothing else — bind the shippedtekton-triggers-eventlistener-rolesClusterRole, notedit. -
Avoid privileged builds. The Red Hat
buildahTask runs rootless with thepipelines-sccSCC. Do not grantprivilegedto work around a storage driver error; setSTORAGE_DRIVER=vfsor usefuse-overlayfsinstead. -
Never pass secrets as
params. Params end up in thePipelineRunspec, visible to anyone withget 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
dockerconfigjsonfor quay.io. If you need an external registry, use a robot account restricted to one repository. -
Validate webhook payloads. The
github/gitlabinterceptors verify the HMAC signature — always configuresecretRef. 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
:latestcatalog Task is a supply-chain backdoor. -
Enable Tekton Chains for SLSA provenance and image signing; it is bundled with OpenShift Pipelines and signs
TaskRunoutputs automatically. - Apply a NetworkPolicy on the CI namespace: allow egress to the registry, Git and artifact proxy; deny lateral traffic to application namespaces.
-
Set
timeoutsand 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 oldPipelineRunobjects.
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 logswill 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)