06

Workspaces

Results carry a few bytes between tasks; anything bigger, like a source tree, a build cache or a binary, has to travel on disk. But every TaskRun in a Pipeline is a separate Pod, often on a different node, so a file only survives from one task to the next if it sits on a volume both Pods mount. Workspaces are how Tekton expresses that: a Task declares a named directory, a Pipeline wires tasks to shared names, and the PipelineRun decides what storage backs them. That choice decides what persists, what two runs can trample, and which node your Pods are allowed to land on.
Three layers name the same directory. The Task declares a slot (name, mountPath, readOnly, optional). The Pipeline maps each task's slot to a pipeline workspace. Only the PipelineRun says what volume fills it, and that volume decides whether the next Pod sees the files.
PipelineRun binds
1 · Task declares
1apiVersion: tekton.dev/v1
2kind: Task
3metadata: { name: fetch }
4spec:
5 workspaces:
6 - name: output
7 mountPath: /workspace/output
8 readOnly: false
9 steps:
10 - name: clone
11 image: alpine/git
12 script: git clone $REPO $(workspaces.output.path)
2 · Pipeline maps
1kind: Pipeline
2spec:
3 workspaces:
4 - name: shared # pipeline-level name
5 tasks:
6 - name: fetch
7 taskRef: { name: fetch }
8 workspaces:
9 - name: output # Task's name
10 workspace: shared
11 - name: build
12 runAfter: [fetch]
13 taskRef: { name: go-build }
14 workspaces:
15 - name: source
16 workspace: shared
3 · PipelineRun binds a volume
1kind: PipelineRun
2spec:
3 pipelineRef: { name: fetch-and-build }
4 workspaces:
5 - name: shared
6 emptyDir: {}

two TaskRuns = two Pods

Pod fetch-podpending
/workspace/output ← emptyDir #1
(empty)
runAfter →
Pod build-podpending
/workspace/source ← emptyDir #2
(empty)

Press Run. The fetch TaskRun gets its own Pod; the build TaskRun gets another.

crosses TaskRuns
no
writable
yes
lifecycle
Lives and dies with one Pod
good for
scratch space inside a single TaskRun (shared between its steps)

Try: run with emptyDir and watch build find an empty directory; switch to volumeClaimTemplate and run again. Then pick secret, or tick readOnly, and watch the clone fail on a read-only mount.

clone → build → test on one PVC

ws-volume-claim-templatews-pipeline-mapping
The everyday pattern: git-clone writes the repo, lint and build read it in parallel, build leaves a binary and a cache, test writes reports. Each task is a fresh Pod; the only thing they share is the volume.
t = 0

PipelineRun timeline

clonelintbuildtest0246810

PVC pvc-1d4e9b (one volume for the whole PipelineRun)

mounted by: —
/src
—
/cache
—
/bin
—
/reports
—
files
0
used
0 B
pods mounting
0
status
—
pipeline.yaml
1kind: Pipeline
2spec:
3 workspaces:
4 - name: ws
5 tasks:
6 - name: clone
7 taskRef: { name: git-clone }
8 workspaces: [{ name: output, workspace: ws }]
9 - name: lint
10 runAfter: [clone]
11 workspaces: [{ name: source, workspace: ws }]
12 - name: build
13 runAfter: [clone]
14 workspaces: [{ name: source, workspace: ws }]
15 - name: test
16 runAfter: [build]
17 workspaces: [{ name: source, workspace: ws }]

Try: Step through and watch each TaskRun's Pod mount the same PVC and leave files for the next. At t=4 lint and build mount it at the same time. Then make clone fail and see the rest skipped while the partial files stay.

ReadWriteOnce, many nodes, and the Affinity Assistant

ws-access-modesws-affinity-assistantws-coschedule
Most cloud disks are ReadWriteOnce: attachable to one node at a time. When lint and test fan out and the scheduler spreads their Pods, one gets the disk and the others wait. Tekton's answer is to co-schedule: a placeholder Pod pins everything that shares the volume to one node.
coschedule
access mode
PipelineRuns

node-1

0/3 pods
idle

node-2

0/3 pods
idle

node-3

0/3 pods
idle

not yet on a node

run-a-clone · waiting on depsrun-a-build · waiting on depsrun-a-test · waiting on depsrun-a-lint · waiting on depsrun-a-deploy · waiting on deps
t
0
duration
…
ticks per run (ideal 6)
Multi-Attach stalls
0
pod-ticks in ContainerCreating
nodes used
0
config/feature-flags
1apiVersion: v1
2kind: ConfigMap
3metadata:
4 name: feature-flags
5 namespace: tekton-pipelines
6data:
7 coschedule: "disabled"

No assistant. kube-scheduler spreads Pods across nodes; with an RWO volume, a Pod landing on a second node is stuck in ContainerCreating with a Multi-Attach error until the volume is free.

Try: with disabled + ReadWriteOnce, play and watch test and lint land on other nodes and stall with Multi-Attach. Switch to workspaces: everything follows the assistant to one node. Then tick the 2nd PVC (workspaces mode rejects the run, pipelineruns accepts it), or set 2 runs with isolate-pipelinerun and 1 node.

The assistant only helps with node placement. It can conflict with your own nodeSelector or affinity in the pod template, and pinning a busy Pipeline to one node trades Multi-Attach stalls for a capacity bottleneck. With a ReadWriteMany storage class you can turn co-scheduling off.

volumeClaimTemplate vs a shared static PVC

ws-volume-claim-templatews-pvc
Binding the same claimName in every PipelineRun looks convenient. Then two pushes land a minute apart, both runs write into the same directory, and one build ships the other's code. A volumeClaimTemplate gives each run its own PVC, owned by the PipelineRun.

two PipelineRuns of the same Pipeline, different commits

run-aclonebuildtestrun-bclonebuildtest02468101214

PVC shared-ws

no owner · reused
HEAD: 9e2
main.go
legacy_test.go · untracked leftover from 9e2
bin/app (9e2)

results (after both runs finish)

runwantedbuilttestedverdict
run-aa1fa1f+b7cb7cwrong/torn sources, tested other commit
run-bb7cb7cb7ccorrect
wrong runs
…
PVCs in cluster
1
lives forever
pipelinerun.yaml
1kind: PipelineRun
2metadata: { generateName: build- }
3spec:
4 workspaces:
5 - name: ws
6 persistentVolumeClaim:
7 claimName: shared-ws # same PVC every run

The static PVC has no owner: deleting PipelineRuns leaves it, and its files, for the next run.

Try: on the static PVC, start run-b at t=1 and play: run-a builds a mix of a1f and b7c. Untick deleteExisting to see the stale legacy_test.go survive. Switch to volumeClaimTemplate and both runs are correct; then delete a PipelineRun and its PVC disappears.

subPath: one volume, separate directories

ws-subpath
You don't need a PVC per task to keep outputs apart. A subPath on the binding mounts just one directory of the volume, so two parallel tasks can each write to $(workspaces.output.path) without seeing each other.
look from
7/7 · frontend → $(workspaces.output.path)/dist/

PVC ws (one volume)

/dist/frontend
/node_modules/frontend
/package.jsonclone
/pom.xmlclone
/report.xmlbackend
/target/backend
✕ backend overwrote frontend's report.xml
Both tasks run in parallel against the same directory; the last writer wins and the other task's report is gone.
PVCs needed
1
collisions
1
pipeline.yaml (tasks)
1tasks:
2 - name: clone
3 workspaces:
4 - name: output
5 workspace: ws
6 - name: frontend
7 runAfter: [clone]
8 workspaces:
9 - name: source
10 workspace: ws
11 - name: output
12 workspace: ws
13 - name: backend
14 runAfter: [clone]
15 workspaces:
16 - name: source
17 workspace: ws
18 - name: output
19 workspace: ws

Try: step through all writes without subPath and look at report.xml; then tick use subPath and look from each container: the same /workspace/output path now lands in different directories of one PVC.

Optional and isolated workspaces

ws-optionalws-bound-variablews-isolated
Two refinements on the Task side. An optional workspace may be left unbound, and $(workspaces.<name>.bound) lets a script or a when expression react. Isolated workspaces narrow a mount to the steps and sidecars that list it.

optional workspace + $(workspaces.cache.bound)

task.yaml
1kind: Task
2spec:
3 workspaces:
4 - name: cache
5 optional: true
6 mountPath: /cache
7 steps:
8 - name: build
9 image: golang:1.23
10 script: |
11 if [ "$(workspaces.cache.bound)" = "true" ]; then
12 export GOCACHE=$(workspaces.cache.path)/go-build
13 fi
14 go build ./...
script after substitution
if [ "false" = "true" ]; then
  export GOCACHE=/go-build
fi
go build ./...
no cache mounted, path resolves to "" → cold build 92s (still succeeds)

In a Pipeline the same variable gates whole tasks: when: [{input: "$(workspaces.cache.bound)", operator: in, values: ["true"]}]

isolated workspaces: who can read /workspace/creds?

task.yaml
1spec:
2 workspaces:
3 - name: source
4 - name: creds # bound to a Secret
5 steps:
6 - name: fetch-deps
7 - name: compile
8 - name: third-party-scan
9 - name: push
10 sidecars:
11 - name: docker-daemon
containersourcecreds
fetch-deps stepmountedmounted (doesn't need it)
compile stepmountedmounted (doesn't need it)
third-party-scan stepmountedmounted (doesn't need it)
push stepmountedmounted
docker-daemon sidecar—not mounted (sidecars get only what they list)

3 containers that never need the credentials can still read them, including a third-party scanner image.

Credentials as files, not env vars

ws-secret-filesws-configmap-secretws-isolated
A Secret workspace mounts credentials as read-only files in the format tools already read (a docker config.json, an SSH key directory). Compared with injecting them as env vars, there are fewer ways for them to leak.
deliver the registry password as
env approach
1kind: Task
2spec:
3 stepTemplate:
4 env:
5 - name: REGISTRY_PASSWORD
6 valueFrom:
7 secretKeyRef:
8 name: registry-creds # fixed in the Task
9 key: password
10 steps:
11 - name: compile
12 - name: third-party-scan
13 - name: push
14 script: docker login -u ci -p "$REGISTRY_PASSWORD" ...

what happens when…

$ env
HOME=/tekton/home
REGISTRY_PASSWORD=hunter2-prod   ← now in the TaskRun logs
exposures
4/4
Secret chosen by
Task
secretKeyRef name in the Task
on rotation
stale
env fixed at container start

Try: click each probe under env, then switch to the Secret workspace. A file mounted only into the step that needs it doesn't end up in logs, child processes or other images. Catalog Tasks follow this pattern: git-clone takes ssh-directory / basic-auth workspaces, and buildah / kaniko take a dockerconfig workspace.

Tekton also has a ServiceAccount-based path: Secrets annotated for git or docker are turned into credential files by an init step. That's covered in Auth & Pod config; workspaces are the more explicit, per-run option.
  • takeawayTasks of a Pipeline are separate Pods. Only a volume both Pods mount carries files between them, so an emptyDir dies with its TaskRun.
  • takeawayPrefer volumeClaimTemplate for build directories: a clean PVC per run, garbage-collected with its PipelineRun. Use subPath to split one volume between tasks.
  • takeawayRWO volumes tie Pods to one node, which is why the Affinity Assistant exists. Keep credentials in step-scoped Secret workspaces, not env.

Key concepts · 17

book chapter 6
  1. Workspace

    A named slot for a filesystem that a Task declares and a TaskRun or PipelineRun fills with a real volume.

    It keeps Tasks portable: the Task only says it needs a directory called source; whether that's an emptyDir, a PVC or a Secret is decided at run time.

    spec.workspaces[]$(workspaces.source.path)
    ↑ see it in the visualization
  2. Pipeline workspace mapping

    A Pipeline declares its own workspaces and maps each pipeline task's workspace name to one of them.

    Mapping several tasks onto the same pipeline workspace is how clone, build and test end up sharing one volume, even though each Task names its slot differently.

    tasks[].workspaces[].nametasks[].workspaces[].workspace
    ↑ see it in the visualization
  3. mountPath

    Where the workspace appears inside every step container; defaults to /workspace/<name>.

    Scripts should use $(workspaces.<name>.path) rather than hard-coding the path, so the Task keeps working if someone overrides mountPath.

    workspaces[].mountPath$(workspaces.output.path)
    ↑ see it in the visualization
  4. readOnly workspace

    A Task-side flag that mounts the volume read-only for that Task's steps.

    A test or scan Task that should never modify the source can declare it; writes then fail with 'Read-only file system'. ConfigMap, Secret and projected sources are read-only regardless.

    workspaces[].readOnly: true
    ↑ see it in the visualization
  5. emptyDir

    A scratch volume created with the Pod and deleted with it.

    It is shared by the steps of one TaskRun but not across tasks of a Pipeline: each TaskRun is a separate Pod and gets a fresh empty directory.

    emptyDir: {}
    ↑ see it in the visualization
  6. persistentVolumeClaim

    Bind an existing PVC by claimName; it outlives every run.

    Good for a cache you deliberately keep. Dangerous as a build directory: concurrent runs write into the same files and later runs see earlier runs' leftovers.

    persistentVolumeClaim.claimName
    ↑ see it in the visualization
  7. volumeClaimTemplate

    A PVC spec the controller instantiates once per PipelineRun (or TaskRun), with an ownerReference to it.

    Each run gets a clean, private volume, and deleting or pruning the run garbage-collects the PVC. It is not deleted when the run finishes, so pruning old runs matters for storage quota.

    volumeClaimTemplate.specmetadata.ownerReferencestkn pipelinerun delete --keep 5
    ↑ see it in the visualization
  8. Access modes (RWO / RWX)

    ReadWriteOnce volumes can be attached to one node at a time; ReadWriteMany volumes can be mounted from many nodes.

    Most block-storage classes are RWO only. Parallel TaskRun Pods scheduled to different nodes then stall with a Multi-Attach error, which is why Tekton co-schedules them.

    accessModes: [ReadWriteOnce]ReadWriteMany (NFS, EFS, CephFS)
    ↑ see it in the visualization
  9. Affinity Assistant

    A placeholder StatefulSet Pod the controller creates per PVC workspace (or per PipelineRun); TaskRun Pods get pod affinity to it.

    It pins every Pod that shares the volume to the same node, so an RWO PVC can be used by parallel tasks. The cost: those Pods compete for one node's capacity.

    affinity-assistant-<hash>podAffinity
    ↑ see it in the visualization
  10. coschedule feature flag

    Chooses the co-scheduling policy: workspaces (default), pipelineruns, isolate-pipelinerun, or disabled.

    workspaces pins per PVC and rejects a task binding two PVC workspaces; pipelineruns pins a whole run to one node; isolate-pipelinerun additionally keeps one run per node; disabled leaves placement to the scheduler.

    feature-flags: coscheduledisable-affinity-assistant (deprecated)
    ↑ see it in the visualization
  11. subPath

    Mount only a subdirectory of the bound volume, set on a pipeline task's workspace binding or on the run's binding.

    Lets several tasks share one PVC (one attach, one affinity assistant) while writing into separate directories, so parallel tasks don't overwrite each other's outputs.

    tasks[].workspaces[].subPathworkspaces[].subPath
    ↑ see it in the visualization
  12. Optional workspace

    A workspace declared optional: true may be left unbound by the run.

    Lets one Task support an optional cache or credentials. A required workspace that isn't bound makes the run fail validation before any Pod starts.

    workspaces[].optional: true
    ↑ see it in the visualization
  13. $(workspaces.<name>.bound)

    Resolves to "true" or "false" depending on whether an optional workspace was bound.

    Scripts branch on it, and Pipelines use it in when expressions to skip tasks that need the workspace. When unbound, the path variable resolves to an empty string.

    $(workspaces.cache.bound)$(workspaces.cache.path)$(workspaces.cache.claim)$(workspaces.cache.volume)
    ↑ see it in the visualization
  14. Isolated workspaces

    Listing a workspace under specific steps[].workspaces or sidecars[].workspaces mounts it only into those containers.

    Credentials can reach the one step that pushes, not every image in the Task. Workspaces that no step lists stay visible to all steps, as before.

    steps[].workspacessidecars[].workspaces
    ↑ see it in the visualization
  15. configMap & secret workspaces

    Bind a ConfigMap or Secret so its keys appear as read-only files in the workspace.

    Config and credentials arrive as files the tools already understand, chosen by the run rather than hard-coded in the Task.

    configMap.namesecret.secretNameitems[].key / path
    ↑ see it in the visualization
  16. projected & csi workspaces

    projected merges several ConfigMaps/Secrets into one directory; csi mounts a volume provided by a CSI driver.

    A CSI driver such as the Secrets Store driver can fetch credentials from Vault or a cloud KMS at mount time, so they never live as Kubernetes Secrets.

    projected.sources[]csi.driver: secrets-store.csi.k8s.io
    ↑ see it in the visualization
  17. Secrets via workspaces

    Deliver credentials (docker config, SSH keys, .git-credentials) as files in a Secret workspace instead of environment variables.

    Env vars leak through env dumps, set -x traces, child processes and every step of a stepTemplate. A step-scoped file doesn't, and kubelet refreshes it when the Secret rotates.

    git-clone: ssh-directory, basic-authbuildah/kaniko: dockerconfigDOCKER_CONFIG
    ↑ see it in the visualization