Kubernetes CI/CD Pipelines with GitHub Actions - The Definitive Guide

Tl:DR: Create a robust CI/CD pipeline for Kubernetes using GitHub Actions, covering everything from code security scanning to deployment. Learn how Devtron simplifies post-deployment tasks and Day 2 operations for a seamless Kubernetes experience.

3 months ago   •   16 min read

By Siddhant Khisty
In this article

Within the world of modern software, engineers often automate a lot of their workflow. The process of running tests, building, and deploying your applications are all automated which enables developers to achieve faster software delivery cycles.

Many tools in the ecosystem enable the creation of automated CI/CD pipelines. Some of the popular tools include Jenkins, GitHub Actions, Devtron, Circle CI, and more. GitHub actions help build robust pipelines thanks to the wide range of integrations, and its ability to fit into your workflow seamlessly. Within this blog, we explore GitHub actions and create an end-to-end CI/CD pipeline. We will explore all the different aspects of a pipeline from code security scanning, testing, building the container image, and deploying it onto Kubernetes.

What are GitHub Actions?

GitHub actions are a GitHub-native CI/CD pipeline that allows you to build, test as well as deploy your applications. You can use GitHub actions to test the code in every PR and make sure that the new code changes are not negatively affecting your existing applications.

GitHub actions have several different integrations that you can incorporate into your pipelines. These integrations make it a lot easier to implement certain elements within your pipeline such as dependency tracking, security scanning, code quality analysis, etc. These integrations are pre-configured so you can either use them out of the box or customize their workflow and rules as you see fit.

Since GitHub actions are native to GitHub, it enables faster pipeline execution and enhances developer velocity. You can configure the actions to run at any stage of the GitHub workflow. For example, the actions can be configured to run on every PR or issue creation, on every push or commit, etc. It provides a lot of flexibility in how and where should the actions run.

How do GitHub actions work?

GitHub actions can be triggered whenever an event happens within the repository. For example, the action can get triggered when a PR is opened. Actions run as jobs with specific tasks assigned to them. These jobs run either in a VM or a container environment called Runners. The jobs can be configured either by using a custom script or using pre-defined actions.

1. GitHub Actions Architecture
[Fig.1] GitHub Actions Architecture

You can have multiple jobs within your workflow, which either trigger sequentially or run parallel to each other, which allows for a lot of flexibility in the speed and efficiency of workflows. Each runner runs one job.

GitHub actions have several different components. The main components within any Action workflow are:

  • Workflow:- Workflows are the complete pipeline that consists of several different jobs. Workflows are defined in YAML files and exist within the .github/workflows directory in any repository
  • Events:- An event is a specific activity that is triggered within your repository. For example, an event could be a PR or issue being created, a commit or push being made, etc.
  • Runner:- A GitHub runner is a VM or container where your workflows will run after they are created. You can either use the GitHub runners or host your custom runners.
  • Job:- A job is a set of steps that runs in the same runner. Each step can be a custom shell script that can be run or a pre-defined action. These steps are dependent on each other and run in the defined order.
  • Action:- An action is a custom application for the GitHub Actions platform. It performs complex but often repetitive tasks such as building an application or building and pushing containers. Using an action reduces the amount of repetitive code you would need to write in your workflows.

Creating the GitHub CI Workflow

Let’s use GitHub actions to create our own CI/CD pipeline, and deploy the application to a Kubernetes cluster. We will be using a simple key-value store which is written using Go. To create the CI/CD workflow, we are going to define some YAML files within the .github/workflows directory. These files will define what actions should take place within the workflows.

We will create a GitHub Actions workflow which will do the following:

  • Building and testing the code
  • Security Scans
  • Create a Docker image and push it to DockerHub
  • Deploy the application on a Kubernetes cluster

Within this pipeline, we will make use of both parallel and sequential pipelines. We will first run the build and testing action, and the security scanning sequentially. Once our test cases and security scans pass, we can safely create the container image and push it to a container registry, and deploy the application to Kubernetes. The below diagram shows what our ideal workflow would look like once created.

2. Custom Workflow
[Fig. 2] Custom Workflow

Let us first do the prerequisites needed for making our workflow. All of our workflow files will be created in the .github/workflows directory. GitHub will use this directory to read and apply the workflow files. From the root directory of the project, run the below command to create the folders.

mkdir -p .github/workflows

Creating the build & test job

GitHub provides us with a lot of different actions that make it easy to write workflow files. A lot of the pre-defined actions can be used out of the box, without many changes. However, for our purposes, we will be making the configuration files from scratch.

Go ahead and create a YAML file with the name build-and-test.yaml. We will write the build instructions within this file.

Every GitHub workflow configuration file starts with a name. As we are going to be defining the actions needed for building the source code and running the tests, we will call this workflow as build and test.

name: build and test

Whenever the action triggers, we will see this action running as build and test. Next, we want to define the trigger condition.

As per best practice, the only time when you will have new code that needs to be tested will be when either a push is created, or when a PR is opened. For this specific repository, we want the action to trigger whenever something is pushed to the main branch, or a PR is created to merge into the main branch. We can define it with the following:

on:
  push:
    branches: [ "main" ]
  pull_request:
    branches: [ "main" ]

Whenever the workflow executes, we will run the various jobs that are defined within it. Within a single workflow file, we can define multiple different jobs, which will run sequentially. Each job has multiple steps which will run in the order that is defined.

Within our build and test workflow, we will define only a single job. To create our complete workflow, we will need to create multiple jobs, but we will separate them into different files.

Within the build job, we will do the following steps:-

  1. Checkout action:- We use the checkout action so that the GitHub runner knows which repository to access.
  2. Set up Golang:- Since the application is written in Go, we need to set up Golang within the runner. To make the setup process quick and easy, we have the setup-go action. This GitHub action installs Golang and fetches the dependencies required for the project.
  3. Build: Building the executable file for the go application.
  4. Test: Running the suite of tests that we have for the go application.

In the above steps, you can notice that we have 2 quite repetitive tasks, i.e. Checking out the repository, and installing and setting up Golang. To make it easier to create a pipeline for this, GitHub has created the actions/checkout and actions/setup-go which greatly reduces the amount of code we need for creating the pipeline.

After running the actions, we are running the build and testing stages with custom scripts. Within Golang, it’s possible to write test cases quite easily and execute them using the go test command. Hence our testing command will be quite simple. However, if you were running tests using a tool such as Playwright, you might have to write a more complex script to run it.

Before we can define the steps for the job, we want to define the type of runner that the job will run on. To keep things simple, we will run the jobs on a Linux runner i.e. ubuntu. We can set this using

runs-on: ubuntu-latest

You can also use Windows and macOS runners. For a complete list of runners that you can use, please refer to the GitHub documentation.

Next, we want to define the steps. We already discussed what the 4 steps are going to be doing. After we’ve created the steps for the job, our entire YAML for the file will look like this:

name: build and test

on:
  push:
    branches: [ "main" ]
  pull_request:
    branches: [ "main" ]

jobs:

  build:
    runs-on: ubuntu-latest
    steps:
    - uses: actions/checkout@v4

    - name: Set up Go
      uses: actions/setup-go@v4
      with:
        go-version: '1.21.3'

    - name: Build
      run: go build -v ./

    - name: Test
      run: go test -v ./

Creating the security scanning workflow

CodeQL is a popular code security scanning tool that’s used widely within the industry. We will be using CodeQL to implement security scanning within our workflow. Earlier, we created the build and test workflow from scratch.

GitHub has a lot of pre-defined actions which are extremely helpful for quickly setting up certain actions such as CodeQL. This is especially useful when want to implement a tool that you are unfamiliar with. Most of the time, the actions are created so that they just work, without needing any configuration changes from you. If some changes are required, it’s usually mentioned within the provided template.

Let’s go ahead and create the CodeQL workflow, using the pre-defined actions.

From the Actions tab, click on New Workflow.

3. Create a new Workflow
[Fig. 3] Create a new Workflow

Over here, you’ll be able to see all the different actions that are available for you to use. Many of these actions are created by GitHub. Some actions are even created by third parties such as Snyk, Codacy, OpenSSF, etc.

From the security list, select the CodeQL Analysis action and click Configure

4. Select CodeQL Analysis Action
[Fig. 4] Select CodeQL Analysis Action

For most use cases, the CodeQL Action is designed to work straight out of the box, without needing any configuration. Feel free to go through the configuration file, and change any settings that you need. If you are unsure about the settings, you can leave them as default, and expect CodeQL to still work.

Since we want the CodeQL action to run parallel to the build and test workflow, we will set the trigger conditions the same as we have for the build and test workflow.

5. CodeQL Action Template
[Fig. 5] CodeQL Action Template

Creating the build & push workflow

Now that we have the build, test, and security scanning workflows created, we can create a workflow for building a container image and pushing it to DockerHub.

Before we start creating the YAML for this workflow, we need to create some secret keys. These secret keys will be used for authenticating to the correct DockerHub account so that the image can be pushed.

From the repository settings, head to the Secrets and Variables, and click on actions. From here, create 2 new secrets.

  • DOCKER_USERNAME: Store the username of your DockerHub account in this variable
  • DOCKER_PASSWORD: Store the API token for your DockerHub account in this variable

If you are unaware of how to create an API token for DockerHub, check out the DockerHub docs.

6. Create Environment Variables
[Fig. 6] Create Environment Variables

When we are creating the container image, we expect that the application is working properly, and the code is secure as well. That means, we only want to create the container image after the security scans are done. To ensure that the build and push workflow works only after the testing and security scans are done, we will put the following trigger conditions:

on:
  workflow_run:
    workflows: [build and test, CodeQL]
    types:
      - completed

Similar to the build and test action, we will first use the checkout action so that the runner knows which repository is to be used.

For different container-specific use cases, we have several actions created by docker. We will be using two of these actions.

The first action that we use is the docker/login-action which will be used for authenticating to the DockerHub account. We will be using the secret variables that we created earlier for providing the correct authentication keys, without the risk of them getting leaked.

I will be pushing this image to the DockerHub repository siddhantkhisty/gin-kv.

   - name: Log in to Docker Hub
     uses: docker/login-action@v2
     with:
       username: ${{ secrets.DOCKER_USERNAME }}
       password: ${{ secrets.DOCKER_PASSWORD }}
       repository: siddhantkhisty/gin-kv

Once we are authenticated to the DockerHub account, the next step is to build the image and push it to DockerHub. If you checked out the repository, you would have noticed that we already have a DockerFile. We will be using this DockerFile to build the image.

In order to save a lot of time and energy, docker has already created and provided the Docker build and push action which will automate the entire process of container creation and pushing it to the Container registry.

   - name: Build and push Docker image
     uses: docker/build-push-action@v2
     with:
       context: .
       tags: siddhantkhisty/gin-kv
       push: true      

With this final step, our entire CI pipeline is ready to use. After you’ve created the docker workflow, the final yaml file will look something like this:

name: build and push docker image
on:
  workflow_run:
    workflows: [build and test, CodeQL]
    types:
      - completed
jobs:
  release-docker:
    name: Release docker image
    if: "!contains(github.event.head_commit.message, '[skip ci]')"
    runs-on: ubuntu-latest
    steps:
      - name: Check out the repo
        uses: actions/checkout@v3
      - name: Log in to Docker Hub
        uses: docker/login-action@v2
        with:
          username: ${{ secrets.DOCKER_USERNAME }}
          password: ${{ secrets.DOCKER_PASSWORD }}
          repository: siddhantkhisty/gin-kv
      
      - name: Build and push Docker image
        uses: docker/build-push-action@v2
        with:
          context: .
          tags: siddhantkhisty/gin-kv
          push: true

Deploying the application

We have successfully created the CI pipeline for the application. Now it’s time to make the CD pipeline for the workflow. The CD pipeline will simply apply the manifest files to a Kubernetes cluster. We have the manifests needed in the ./Kubernetes directory for this particular application. For creating the CD pipeline, we have to make a workflow that simply runs the kubectl apply command.

There are a couple of different ways in which we can do this. Depending on the cluster environment, there are a few different options that we have.

  1. Kubectl GitHub action
  2. EKS actions - If you are running on an EKS cluster
  3. AKS actions - If you are running on an AKS cluster
  4. GKE Actions - If you are running on a GKE cluster
  5. Add a custom GitHub runner to GKE
  6. Using a custom script

For our purposes, we are going to be deploying a custom runner on a Kubernetes cluster running in a Google Cloud VM. You can provision a VM from Google Cloud, and run a cluster using Kind, minikube, kubeadm, or k3s.

Creating a custom GitHub runner

To create a custom runner in Kubernetes, there are a few things that we need to do. Firstly, we need to install a cert-manager. Using cert-manager, we will handle the authentication process to the GitHub workflow. It is also a dependency for the GitHub runner.

We can install cert-manager very easily using its’ helm chart. Simply run the below command. Please ensure that you have Helm installed in your target environment.

helm repo add jetstack https://charts.jetstack.io

helm repo update

helm install cert-manager jetstack/cert-manager \
--namespace cert-manager \
--create-namespace \
--version v1.15.1 \
--set crds.enabled=true

Before we can deploy the GitHub runner onto the cluster, we need to create a GitHub PAT. This PAT will be used to authenticate the runner to the target repository. When provisioning a token, ensure that you provide all the repo level permissions.

You can generate a token from Settings > Developer Settings > Personal Access Token > Tokens (classic)

7. Create a PAT for GitHub
[Fig. 7] Create a PAT for GitHub

Next, we will be deploying the actual GitHub runner to Kubernetes. To deploy the custom GitHub runners on Kubernetes, we will be using the Actions Runner Controller. While deploying this controller, we will require the PAT that we just generated. Run the below helm command to install the Actions Controller on your cluster. Please update the below command with the PAT that you just generated.

helm repo add actions-runner-controller https://actions-runner-controller.github.io/actions-runner-controller

helm repo update

helm upgrade --install --namespace actions-runner-system --create-namespace\
--set=authSecret.create=true\
--set=authSecret.github_token="REPLACE_YOUR_TOKEN_HERE"\
--wait actions-runner-controller actions-runner-controller/actions-runner-controller

With this, we have successfully deployed the Actions Controller on our Kubernetes cluster. Now we need to create a runner. Along with the actions controller, we also have a few Custom Resources deployed on our cluster. One of these custom resources is RunnerDeployment. This is like a normal Kubernetes deployment, but it’s specific for GitHub runners. Let’s create this resource using the below yaml.

Please update the repo to your target repo.
apiVersion: actions.summerwind.dev/v1alpha1
kind: RunnerDeployment
metadata:
  name: kubernetes-runner
spec:
  replicas: 1
  template:
    spec:
      serviceAccountName: runner-sa
      repository: siddhant-khisty/key-store-gin
      labels: 
        - "kubernetes-runner"

Before we can use this runner, we need to ensure that this runner has the proper authentication policies in place, so that it can create Kubernetes resources. If you try to use this runner, the jobs will run, but the Kubernetes resources will not be updated as the runner does not have the correct permissions.

We will go ahead and create a Service Account, and assign it the correct permissions so that the runner can create Kubernetes objects.

If you inspect the above runner yaml file, we already have mentioned a Service Account in it. Let’s create this service account, and assign it the proper permissions using ClusterRoles and ClusterRoleBindings.

Run the below script to create the appropriate resources.

kubectl create sa runner-sa 

kubectl create clusterrole runner --verb=get, list, watch, create, delete, patch --resource=*

kubectl create clusterrolebinding runnerbinding --clusterrole=runner --serviceaccount=default:runner-sa

Creating the deployment workflow

We are now ready to create the deployment action and have it run on our custom runner.

Similar to the build and push action, we want to ensure that the deployment workflow runs only after every other workflow is complete. Since the last workflow was the build and push workflow, we will run the deployment workflow after it’s completed. We can define this with

on:
  workflow_run:
    workflows: [build and push docker image]
    types:
      - completed

Within this workflow, we will define multiple steps for the job. The first step will be to check out the repository. For this, we will use the checkout action that we used earlier.

The Ubuntu instance that we are running the instance in, does not have a kubectl utility. So the second and third steps will be to download the kubectl binary and install it. Finally, we can run the kubectl commands to deploy the application to the cluster.

Since the runner is already running in our target Kubernetes cluster, we do not need to provide a kube-config or any kind of destination or authentication information. Since we created the service account, the runner has the correct permissions needed to create the resources. The final YAML workflow file will look like this:

name: Kubernetes-deployment

on:
  workflow_run:
    workflows: [build and push docker image]
    types:
      - completed
jobs:
 deploy:
   name: Create k8s deployment
   runs-on: kubernetes-runner
   steps:
      - name: Check out the repository to the runner
        uses: actions/checkout@v4  
     
      - name: Download kubectl binaries
        run: curl -LO "https://dl.k8s.io/release/$(curl -L -s https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl"
     
      - name: Install Kubectl
        run: sudo install -o root -g root -m 0755 kubectl /usr/local/bin/kubectl
      
      - name: Deploy the application
        run: |
          kubectl apply -f https://raw.githubusercontent.com/siddhant-khisty/key-store-gin/main/Kubernetes/svc.yaml
          kubectl apply -f https://raw.githubusercontent.com/siddhant-khisty/key-store-gin/main/Kubernetes/deply.yaml
          kubectl apply -f https://raw.githubusercontent.com/siddhant-khisty/key-store-gin/main/Kubernetes/ingress.yaml

Based on all our configurations, the workflow will trigger when we make a commit. Once the actions start running, we can see all the actions and their status from the Actions tab.

8. GitHub Actions Run History
[Fig. 8] GitHub Actions Run History

As you can see in the above image, our jobs have been completed and the application is deployed on the Kubernetes cluster.

GitHub Actions are great for Continous Integration (CI)

Now that the pipeline is complete, and the application is successfully deployed onto Kubernetes, we need to start thinking about the Day 2 operations. Day 2 operations include taking into consideration how you will be handling maintenance tasks such as cluster maintenance, version upgrades, rolling out a new version of the application, handling configuration drifts, security policies, etc.

While GitHub actions are great for creating continuous integration (CI) pipelines, they fall short when it comes to providing a continuous deployment (CD) ecosystem. Here are some of the challenges you might face with GitHub Actions, if used for continuous deployment (CD):

  • Advanced Rollback and Recovery: GitHub Actions does not natively offer robust rollback strategies or disaster recovery features making it harder to manage rollbacks in case of deployment failure.
  • Advanced Deployment Strategies: Github Actions does not natively provide support for advanced deployment strategies like a canary, blue-green, advanced canary with istio and flagger, etc, out of the box compared to other CD tools in the Kubernetes ecosystem.
  • Limited RBAC: GitHub Actions lacks granular role-based access controls (RBAC) compared to other CD platforms like Devtron, which may be a concern in large teams needing fine-grained permissions.
  • Limited Visualization: GitHub Actions does not provide the visual deployment pipelines or user-friendly dashboards, that you can find in other tools like Devtron, Spinnaker, or ArgoCD.
  • DORA Metrics: GitHub Actions doesn't provide any out-of-the-box solutions for checking the DORA metrics for your applications which can be one of the important factors to consider and analyze your deployments.
  • Configuration Drifts: It would be difficult to analyze the configuration drifts and check the deployment history of releases in GitHub Actions, which can make it difficult to troubleshoot applications in case of failure.

While GitHub actions are great for creating robust CI pipelines, they fall short when it comes to deploying applications on Kubernetes and dealing with everyday operations tasks. Devtron is an open-source software delivery workflow for Kubernetes that not only lets you make a robust CD pipeline, with all the post-deployment tasks but it also helps simplify all Day 2 operations including tool integrations, debugging, and much more. Check out this blog to understand how GitHub Actions and Devtron work together to build robust CI/CD pipelines for Kubernetes.

Conclusion

Creating CI/CD pipelines is an essential part of the software development lifecycle which also enables you to accelerate software release cycles. While there are many tools out there that enable you to create your own CI/CD pipelines, GitHub actions make it very easy to create your pipelines quickly, and securely. Thanks to the plethora of pre-built actions, you can simply plug them into your workflow, and they will work without needing a lot of custom configurations.

You can create your pipelines with a combination of pre-built GitHub Actions, and write your custom scripts, which provides you the flexibility to customize your pipelines, while also reducing the amount of code you have to write as actions exist for a lot of the common tasks such as security scanning, code scanning, building applications of a certain stack, etc.

Moreover, you are not locked into using only GitHub actions for creating your pipelines. For example, in this blog, we created the entire CI and CD pipeline purely in GitHub actions. We could also use GitHub actions for only the CI part, and for the CD part, we could use a GitOps tool such as ArgoCD, FluxCD or Devtron.

Related articles

Spread the word

Keep reading