Skip to main content
  1. Posts/

Tekton on K3s

·3769 words·18 mins· loading · loading · ·
Maxwell Lanzkron-Tamarazo
Author
Maxwell Lanzkron-Tamarazo

I’m currently working on a game development project, and I need a way to automatically build and deploy it. As the Tekton expert and pipeline guru on my team at Ford Motor Company, I figured I’d set up a Tekton CI/CD pipeline for the game on my own hardware.

Since it runs on Kubernetes, getting Tekton running on bare metal is a bit of a challenge. I previously set up a K3s cluster and configured ArgoCD to declaratively manage it, so this post will walk through the process of assembling a Tekton pipeline on that stack.

block-beta columns 3 block:group1 columns 1 block:group2 columns 2 EventListener IngressRoute TriggerTemplate TriggerBinding end namespace["Game project namespace"] end operator["Tekton Operator"] argo["ArgoCD"] k3s["K3s cluster"]:3 nixos["NixOS on bare metal"]:3 classDef tobuild fill:#fcf class EventListener tobuild class IngressRoute tobuild class TriggerTemplate tobuild class TriggerBinding tobuild class namespace tobuild class operator tobuild

Installing the Tekton Operator
#

Tekton consists of a number of different components, such as the core Pipeline functionality, Triggers, and the Dashboard. Tekton provides a way to install one component that manages all the others, which is the Tekton Operator. However, documentation on installing the Operator with ArgoCD appears to be non-existent.

The Tekton Operator installation instructions says to kubectl apply a 1000+ line multi-document YAML file from a cloud bucket. Instead, I created an ArgoCD Application, and included a downloaded copy of that file. In the future, I should be able to update Tekton by swapping out the contents of that YAML file with the latest version of https://storage.googleapis.com/tekton-releases/operator/latest/release.yaml. Alternatively, I could have configured ArgoCD to track the latest release’s URL, at the cost of losing control over the timing of upgrades.

Also managed by the ArgoCD Application is a TektonConfig object. This controls which parts of Tekton are installed, and manages cluster-wide configuration options.

apiVersion: operator.tekton.dev/v1alpha1
kind: TektonConfig
metadata:
  name: config
  namespace: tekton-operator
spec:
  targetNamespace: tekton-pipelines
  profile: all
  pipeline:
    git-resolver-config:
      server-url: https://git.maxwell-lt.dev

Here I set the installation profile to install all the Tekton components. I also set the default Git server URL for runtime remote resolution of Pipeline and Task resources.

With that added to ArgoCD, all the Tekton CRDs are installed and ready to use!

Setting up the namespace
#

The following Kubernetes objects are placed in a Git repository tracked by ArgoCD.

To run a Tekton pipeline, you need a Kubernetes namespace to hold all of the infrastructure and maintain isolation. First I created the Namespace itself.

apiVersion: v1
kind: Namespace
metadata:
  name: coffee-defender-tekton

Next, I added a ServiceAccount that the pipeline will run with.

apiVersion: v1
kind: ServiceAccount
metadata:
  name: pipeline
secrets:
  - name: git-robot-basic-auth

This associated Secret was created manually, as I haven’t yet set up ArgoCD with any external secrets management systems. I created a dedicated user account on my Forgejo instance (a self-hosted GitHub alternative), and used its login credentials in this Secret.

apiVersion: v1
kind: Secret
metadata:
  name: git-robot-basic-auth
  annotations:
    tekton.dev/git-0: 'https://git.maxwell-lt.dev'
type: kubernetes.io/basic-auth
stringData:
  username: <redacted>
  password: <redacted>

The ServiceAccount is linked to this Secret, so Tekton will inject these credentials into the git-clone step automatically. The tekton.dev/git-0 annotation is what enables Tekton to automatically use this Secret when cloning code from the specified host, which eliminates the need to explicitly specify those credentials. More information about this can be viewed in the Tekton documentation.

Interlude: Smoke Testing
#

Before writing a pile of YAML to spin up EventListeners, TriggerBindings, TriggerTemplates, and IngressRoutes, this is a good opportunity to run a basic pipeline and validate that the basics are in place.

This minimal PipelineRun will, if everything is working correctly, clone the game repository using the Git HTTP credentials attached to the pipeline ServiceAccount.

apiVersion: tekton.dev/v1beta1
kind: PipelineRun
metadata:
  name: test-pipelinerun
spec:
  serviceAccountName: pipeline
  pipelineSpec:
    workspaces:
      - name: shared
    tasks:
      - name: git-clone
        taskRef:
          resolver: hub
          params:
            - name: catalog
              value: tekton-catalog-tasks
            - name: type
              value: artifact
            - name: kind
              value: task
            - name: name
              value: git-clone
            - name: version
              value: '0.9'
        params:
          - name: url
            value: 'https://git.maxwell-lt.dev/maxwell/chazzano-coffee-defender'
        workspaces:
          - name: output
            workspace: shared
  podTemplate:
    securityContext:
      fsGroup: 65532
  workspaces:
   - name: shared
     volumeClaimTemplate:
       spec:
         accessModes:
           - ReadWriteOnce
         resources:
           requests:
             storage: 1Gi

To test it out, I used kubectl create to instantiate it in the cluster, and confirmed that it worked.

Tekton dashboard showing successful execution of the test pipeline

If you’re interested in what each line of the PipelineRun definition does, expand the section below:

Detailed explanation of the test PipelineRun definition

There’s a lot going on in this minimal PipelineRun, so I’ll break down what it’s actually doing.

apiVersion: tekton.dev/v1beta1
kind: PipelineRun
metadata:
  name: test-pipelinerun

This specifies that the described object is a Tekton PipelineRun, and sets its name.

spec:
  serviceAccountName: pipeline

Earlier, I created a ServiceAccount named pipeline, and bound it to a Secret annotated for use with my Forgejo instance. To use that value, the pipeline must be run by that ServiceAccount.

spec:
  workspaces:
   - name: shared
     volumeClaimTemplate:
       spec:
         accessModes:
           - ReadWriteOnce
         resources:
           requests:
             storage: 1Gi

If you need to pass files between Tasks in a Pipeline, a Workspace must be created by the PipelineRun, bound to a PersistentVolumeClaim. Here I’m using a volumeClaimTemplate, which creates a fresh PVC on the fly which will be cleaned up once the PipelineRun is deleted. The mode of this PVC is set to ReadWriteOnce, which means that all Tasks in the pipeline that use this Workspace must run sequentially. If I wanted to run certain Tasks concurrently, I’d need to change this to ReadWriteMany, but this often comes with a performance impact, depending on what storage classes are available.

spec:
  podTemplate:
    securityContext:
      fsGroup: 65532

The git-clone Task from the Artifact Hub explicitly sets its user ID to the non-root value 65532 for security reasons. To allow this user ID to write files to the volume, the podTemplate has to be overridden to match.

Finally, we get to the Pipeline itself. Normally, you would want to reference an external pipeline from the PipelineRun, but it can also be defined inline, which makes things much easier when running this kind of test.

spec:
  pipelineSpec:
    workspaces:
      - name: shared

First, a Workspace is defined at the Pipeline level. This binds to the Workspace created by the PipelineRun, and the names must match.

spec:
  pipelineSpec:
    tasks:
      - name: git-clone
        taskRef:
          resolver: hub
          params:
            - name: catalog
              value: tekton-catalog-tasks
            - name: type
              value: artifact
            - name: kind
              value: task
            - name: name
              value: git-clone
            - name: version
              value: '0.9'

Next, I define a single Task. The name field here can be set to anything, and other Tasks in the Pipeline may reference the task by this provided name. Instead of creating a Task within the namespace which needs to be kept up to date, I use the Hub Resolver to pull it on the fly from the Artifact Hub.

spec:
  pipelineSpec:
    tasks:
      - name: git-clone
        params:
          - name: url
            value: 'https://git.maxwell-lt.dev/maxwell/chazzano-coffee-defender'

The git-clone Task has a lot of available parameters, but only one is required: the repository URL. I pass the URL of my Git repository to the Task as a parameter. It’s a good idea to also provide a branch, tag, or ref for the revision parameter, but if omitted this will pull from the Git repository’s default branch.

spec:
  pipelineSpec:
    tasks:
      - name: git-clone
        workspaces:
          - name: output
            workspace: shared

Finally, the git-clone Task also needs a Workspace to write the cloned files to. The Workspace name expected by the git-clone Task is output, so I pass the shared Workspace into the Task under that name.

Handling Webhooks with Tekton Triggers
#

I want to trigger a pipeline every time code is pushed to the code repository. The best way to do this is with a webhook and Tekton Triggers.

flowchart TD git("Forgejo repository") -- "webhook" --> Traefik Traefik --> IngressRoute IngressRoute -- "match PathPrefix" --> el-game("Auto-created EventListener Service") el-game --> eventlistener("EventListener") eventlistener -- "JSON payload" --> TriggerBinding TriggerBinding -- "Parsed key-value list from payload" --> eventlistener eventlistener -- "key-value list passed as params" --> TriggerTemplate TriggerTemplate -- "creates with params" --> PipelineRun PipelineRun -- "pulls from Git" --> Pipeline

The components I will need to create are:

  • A webhook in the Forgejo repository pointing at the K3s server.
  • A Traefik IngressRoute object that routes traffic to the EventListener’s automatically generated Service.
  • An EventListener that handles incoming webhooks.
  • A TriggerBinding that takes a JSON payload from the EventListener and pulls out fields into a flat key-value array.
  • A TriggerTemplate that takes an array of parameters from the EventListener, and creates a PipelineRun.
  • And finally, a Pipeline stored in a Git repository which acts as a template for the PipelineRun, pulled in with remote resolution.

Custom Traefik entrypoint
#

There’s one immediate issue I need to solve first, which is how to get the webhook from Forgejo into the K3s server while maintaining scalability. There are two main ways of letting one server handle HTTP traffic meant for multiple services. The first is to assign each service its own port. The problem with that solution is that port numbers only go up to 65535, putting a hard limit on how many different services can listen from a single server. The second and better way is to have a reverse proxy parse the incoming request and route it based on the Host header (and TLS SNI extension) and sometimes the requested path. This is infinitely scalable, as one server could handle requests targetting any number of domain names.

The solution I used is a combination of both of these methods. I created a new Traefik entrypoint with a port dedicated to Tekton Triggers, and I filtered incoming requests by the path prefix with an IngressRoute, so that I can handle incoming webhooks from many EventListeners across different namespaces. In the future, I can create Tekton pipelines and Triggers in separate namespaces, and re-use the same Traefik entrypoint with a different path prefix defined in the IngressRoute.

I am not yet managing core K3s resources with ArgoCD, so to open a new port for Traefik I had to manually edit its config file:

sudoedit /var/lib/rancher/k3s/server/manifests/traefik-config.yaml

apiVersion: helm.cattle.io/v1
kind: HelmChartConfig
metadata:
  name: traefik
  namespace: kube-system
spec:
  valuesContent: |-
    ports:
      web:
        exposedPort: 8080
      websecure
        exposedPort: 8443
      # New port for incoming webhooks
      tekton:
        exposedPort: 29999
        port: 29999
        expose:
          default: true
        protocol: TCP    

As of version 27.0.0 of the Traefik Helm chart, the schema for exposing a port with the LoadBalancer service changed from expose: true to expose: { default: true }. This took me a solid hour to figure out, as all of the discussions I could find on the topic occurred before this breaking change.

IngressRoute
#

Now that I had a dedicated port available for Tekton webhooks, I added a Traefik IngressRoute to the Tekton namespace’s ArgoCD repository.

apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
  name: webhook-ingress
  namespace: coffee-defender-tekton
spec:
  entryPoints:
  - tekton
  routes:
  - match: PathPrefix(`/coffee-defender`)
    kind: Rule
    middlewares:
    - name: stripprefix
      namespace: coffee-defender-tekton
    services:
    - name: el-coffee-defender
      namespace: coffee-defender-tekton
      port: 8080
---
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
  name: stripprefix
  namespace: coffee-defender-tekton
spec:
  stripPrefix:
    prefixes:
    - '/coffee-defender'

Alongside the IngressRoute, I also added a Traefik middleware to strip out the path prefix, as the EventListener will be expecting requests with no path.

EventListener
#

Next, I configured an EventListener which receives traffic from the IngressRoute.

apiVersion: triggers.tekton.dev/v1beta1
kind: EventListener
metadata:
  name: coffee-defender
spec:
  serviceAccountName: pipeline
  triggers:
  - name: git-push
    interceptors:
    - ref:
        name: "github"
        kind: ClusterInterceptor
        apiVersion: triggers.tekton.dev
      params:
      - name: "secretRef"
        value:
          secretName: webhook-secret
          secretKey: token
      - name: "eventTypes"
        value: ["push"]
    bindings:
      - ref: coffee-defender
    template:
      ref: coffee-defender

This EventListener validates incoming webhook requests, checking if the event was triggered by a push and testing whether its signature is valid for the expected secret value.

Fixing service account permissions for the EventListener
#

I let ArgoCD create the EventListener, but the Pod it managed failed to start. Displayed in the logs was the error message User "system:serviceaccount:coffee-defender-tekton:pipeline" cannot list resource "clustertriggerbindings" in API group "triggers.tekton.dev" at the cluster scope.

According to the Tekton Triggers documentation, some additional roles must be granted to the pipeline ServiceAccount.

apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  name: pipeline-eventlistener-binding
subjects:
  - kind: ServiceAccount
    name: pipeline
roleRef:
  apiGroup: rbac.authorization.k8s.io
  kind: ClusterRole
  name: tekton-triggers-eventlistener-roles
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
  name: pipeline-eventlistener-clusterbinding
subjects:
  - kind: ServiceAccount
    name: pipeline
    namespace: coffee-defender-tekton
roleRef:
  apiGroup: rbac.authorization.k8s.io
  kind: ClusterRole
  name: tekton-triggers-eventlistener-clusterroles
---
kind: Role
apiVersion: rbac.authorization.k8s.io/v1
metadata:
  name: tekton-triggers-createwebhook
rules:
- apiGroups:
  - ""
  resources:
  - secrets
  verbs:
  - get
  - list
  - create
  - update
  - delete
- apiGroups:
  - triggers.tekton.dev
  resources:
  - eventlisteners
  verbs:
  - get
  - list
  - create
  - update
  - delete
- apiGroups:
  - extensions
  - networking.k8s.io
  resources:
  - ingresses
  verbs:
  - create
  - get
  - list
  - delete
  - update
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  name: tekton-triggers-createwebhook
subjects:
  - kind: ServiceAccount
    name: pipeline
    namespace: coffee-defender-tekton
roleRef:
  apiGroup: rbac.authorization.k8s.io
  kind: Role
  name: tekton-triggers-createwebhook

After ArgoCD applied these roles and bindings, the EventListener’s pod successfully started.

Webhook secret
#

In the trigger definition of the EventListener, I specified a Secret for the GitHub Interceptor to validate. Forgejo supports the GitHub standard for securing webhooks, where the HMAC signature of the payload is calculated against a secret value, and that signature is placed in the X-Hub-Signature header of the webhook request. On the receiving side, this signature can be validated, which allows the webhook sender to prove they possess the correct secret value without sending it over the network.

I manually created a Secret with the name webhook-secret, with a randomly generated secret value in the token field. If a webhook request is sent to the EventListener without a valid HMAC signature, it will be rejected.

TriggerBinding
#

Next is the TriggerBinding. This defines mappings from a structured JSON payload to a flat key-value list. That flat list can then be passed to the TriggerTemplate, and used to instantiate a PipelineRun.

apiVersion: triggers.tekton.dev/v1beta1
kind: TriggerBinding
metadata:
  name: coffee-defender
spec:
  params:
  - name: revision
    value: $(body.head_commit.id)
  - name: repository-url
    value: $(body.repository.html_url)

For this pipeline, the only fields that are needed are the latest commit hash and the repository URL. These are used by the git-clone Task to determine which version of the source code to checkout.

TriggerTemplate
#

The TriggerTemplate is the final object in this Tekton Triggers chain, taking in the parameters generated by the TriggerBinding and using it to create a PipelineRun.

apiVersion: triggers.tekton.dev/v1beta1
kind: TriggerTemplate
metadata:
  name: coffee-defender
spec:
  params:
  - name: revision
    description: Git commit hash to build
  - name: repository-url
    description: URL of the Git repository
  resourcetemplates:
  - apiVersion: tekton.dev/v1beta1
    kind: PipelineRun
    metadata:
      generateName: coffee-defender-build-
    spec:
      serviceAccountName: pipeline
      pipelineRef:
        resolver: git
        params:
        - name: repo
          value: tekton-catalog
        - name: org
          value: maxwell
        - name: token
          value: git-robot-api-token
        - name: revision
          value: master
        - name: scmType
          value: gitea
        - name: pathInRepo
          value: pipeline/coffee-defender-build/0.1/coffee-defender-build.yaml
      params:
      - name: revision
        value: $(tt.params.revision)
      - name: repository-url
        value: $(tt.params.repository-url)
      podTemplate:
        securityContext:
          fsGroup: 65532
      workspaces:
      - name: shared
        volumeClaimTemplate:
          spec:
            accessModes:
            - ReadWriteOnce
            resources:
              requests:
                storage: 1Gi

Much of the content of this TriggerTemplate should look similar to the PipelineRun showed earlier, such as the creation of a workspace from a volumeClaimTemplate, and overriding the pod template to enable the git-clone Task to write files into the workspace.

At first glance, it may seem like the pipeline parameters are defined twice. A TriggerTemplate itself declares parameters that are then passed from a TriggerBinding, and those can be used within the resourcetemplates section of the TriggerTemplate with the format $(tt.params.param-name). Separately, the Pipeline is passed the same two parameters. There are scenarios where a parameter may be needed only by the TriggerTemplate, such as if that parameter is used to specify a particular pipeline by name or path. In this case, both parameters are only needed by the Pipeline itself, so they are just passed through.

While ArgoCD would allow declaratively managing Pipelines and Tasks in the namespace, a better way to manage those Tekton objects is by pulling them at runtime from a Git repo or Artifact Hub instance. In the test PipelineRun I used earlier in this post, I pulled the git-clone Task at runtime from the Artifact Hub, and in the same way I’m now pulling a custom Pipeline from a Git repository. The specific YAML file holding a Pipeline definition is identified by passing the repository name and organization, as well as the Git revision and file path. Additionally, the scmType parameter is set to gitea to maximize compatibility with Forgejo. The TektonConfig object mentioned earlier sets the default Git server URL to my Forgejo instance, so the serverURL parameter can be omitted here.

Generating a token for Pipeline remote resolution
#

The Git repository holding the Pipeline requires authentication, so the pipelineRef section of the TriggerTemplate specifies the name of a Secret holding an API token.

To obtain an API token, I logged in to Forgejo with the generic user account, and used the Applications page in User Settings to generate one. The token needs at least the permission read:repository scope, and must have read access to the repository that stores the Tekton resources. The generated token was then manually placed into a Kubernetes Secret with the name git-robot-api-token.

Pipeline
#

Minimal pipeline
#

To start, this pipeline will do nothing but pull the source code with Git, matching the functionality of the smoke test PipelineRun.

apiVersion: tekton.dev/v1beta1
kind: Pipeline
metadata:
  name: coffee-defender-build
  labels:
    app.kubernetes.io/version: "0.1"
  annotations:
    tekton.dev/categories: "Continuous Integration"
    tekton.dev/platforms: "linux/amd64"
spec:
  params:
  - name: revision
    description: Git revision
  - name: repository-url
    description: Git repository URL
  workspaces:
  - name: shared
  tasks:
  - name: git-clone
    taskRef:
      resolver: hub
      params:
      - name: catalog
        value: tekton-catalog-tasks
      - name: type
        value: artifact
      - name: kind
        value: task
      - name: name
        value: git-clone
      - name: version
        value: '0.9'
    params:
    - name: url
      value: $(params.repository-url)
    - name: revision
      value: $(params.revision)
    workspaces:
    - name: output
      workspace: shared

This pipeline accepts the revision and repository-url parameters passed into it by the TriggerTemplate, then passes them into the git-clone Task. The labels and annotations provide additional metadata, but are not strictly necessary.

Defining a build Task
#

To build the code, I needed to define a custom Task. Since my source code is built with Nix, I wrote a generic Task for building Nix Flake outputs.

apiVersion: tekton.dev/v1
kind: Task
metadata:
  name: nix-build
  labels:
    app.kubernetes.io/version: "0.1"
  annotations:
    tekton.dev/categories: "Build Tools"
    tekton.dev/platforms: "linux/amd64"
spec:
  params:
  - name: package
    type: string
    description: Name of Nix flake output to build
  workspaces:
  - name: source
    description: Source code directory
  steps:
  - name: build
    image: nixos/nix
    computeResources:
      requests:
        memory: 4Gi
        cpu: 8
      limits:
        memory: 8Gi
        cpu: 16
    workingDir: $(workspaces.source.path)
    env:
    - name: PACKAGE
      value: $(params.package)
    - name: HOME
      value: /tekton/home
    script: |
      #! /usr/bin/env bash
      set -euf -o pipefail

      chown -R root:root .
      mkdir -p $HOME/.config/nix
      echo "experimental-features = nix-command flakes" > $HOME/.config/nix/nix.conf

      nix build ".#${PACKAGE}"      

This Task accepts one parameter with the name of the Flake output to build, and passes that as an environment variable into the script. While possible, it’s not recommended to interpolate variables within the script directly, as an interpolated parameter could execute arbitrary code.

The workingDir field is set to the mounted path of the Workspace, so the script can immediately build without having to cd into the right directory.

Because the git-clone Task uses a non-root user ID, this Task first takes ownership of the files in the workspace, as otherwise the Nix build would fail. There may be nicer ways to fix the file permissions, but this works for now. The resource limits for this task are relatively large, but running a Rust build through Nix with nothing cached takes a lot of time.

The Task runs on the nixos/nix image from Docker Hub, and uses the nix build command to build a specific output. I placed this Task at the path task/nix-build/0.1/nix-build.yaml in my tekton-catalog repository, which will be referenced by the Pipeline in the next step.

Adding the build Task to the Pipeline
#

apiVersion: tekton.dev/v1beta1
kind: Pipeline
metadata:
  name: coffee-defender-build
  labels:
    app.kubernetes.io/version: "0.1"
  annotations:
    tekton.dev/categories: "Continuous Integration"
    tekton.dev/platforms: "linux/amd64"
spec:
  params:
  - name: revision
    description: Git revision
  - name: repository-url
    description: Git repository URL
  workspaces:
  - name: shared
  tasks:
  - name: git-clone
    taskRef:
      resolver: hub
      params:
      - name: catalog
        value: tekton-catalog-tasks
      - name: type
        value: artifact
      - name: kind
        value: task
      - name: name
        value: git-clone
      - name: version
        value: '0.9'
    params:
    - name: url
      value: $(params.repository-url)
    - name: revision
      value: $(params.revision)
    workspaces:
    - name: output
      workspace: shared
  - name: build
    runAfter:
    - git-clone
    taskRef:
      resolver: git
      params:
      - name: repo
        value: tekton-catalog
      - name: org
        value: maxwell
      - name: token
        value: git-robot-api-token
      - name: revision
        value: master
      - name: scmType
        value: gitea
      - name: pathInRepo
        value: task/nix-build/0.1/nix-build.yaml
    params:
    - name: package
      value: 'wasm'
    workspaces:
    - name: source
      workspace: shared

In a Pipeline, the order in which Tasks are defined has no effect. By default, Tekton will attempt to execute all Tasks concurrently (limited by resource quotas), which is rarely desired. To arrange the Tasks into a directed graph, the runAfter field of a Task should be set to an array of Tasks that should run immediately before. Here the build Task arranges itself after the git-clone Task. A deployment Task would similarly specify that it runs after the build Task. Remote resolution is again used to pull the build Task at runtime from my tekton-catalog repository. The name of the Flake output I want this pipeline to build is wasm, so that is passed to the nix-build Task in the package parameter.

Setting up the webhook in Forgejo
#

In the Forgejo repository settings, I added a new webhook with the default Forgejo type. The target URL is http://localhost:29999/coffee-defender, which is the matching path that will be sent to the EventListener by the IngressRoute. The HTTP method is set to POST, the content type set to application/json, and the secret set to the same value as I in the webhook-secret Secret. I also set the webhook to only trigger on push events, and only for the master branch. Alternatively, this filtering can be done in the EventListener.

Running the pipeline
#

I used the test delivery button for the webhook, triggering the pipeline. After a while of waiting for the build, it finished successfully!

Tekton dashboard showing a successful run

Conclusion
#

ArgoCD dashboard showing all of the Kubernetes objects created in this article

In this article, I showed how a Tekton pipeline can be run on a self-hosted K3s cluster. We took a trip across the entire stack, from exposing a custom Traefik entrypoint, through Tekton Triggers, and finally executing a custom Tekton pipeline.

At this point, I have a pipeline which can checkout source code and compile it, or in other words, continuous integration without continuous deployment. In a future article, I will enhance this pipeline to deploy the compiled game to Firebase Hosting.