Building Your GitOps Pipeline with GitHub Actions, DockerHub, and Helm Repository

Eduardo Fernandes de Souza
17 min readOct 9, 2023

The challenge that all developers face today is related to the complexity of deployment operations during software implementation, which are often susceptible to human error. In this article, we present a solution that aims to automate these operations through GitOps. This approach offers numerous advantages, including change logging, versioning, audits, simplified rollback, and consistent deployments.

Let’s explore together how to create a complete GitOps architecture using the GitHub Repository, GitHub Actions, DockerHub, and a central Helm repository. I’ve made all the built code available in a public repository so you can examine it and understand the process. In the end, you can perform your own tests and implement improvements.

This is an extensive article that could be divided into several parts, but I chose to keep it as a single document for ease of study. Below, you will find an illustration of the architecture that we will assemble in this article:

Overview of the Architecture

Some time ago, I published an article with step-by-step instructions on how to create a private Helm Charts repository. In it, I explained how to set up a central Helm repository for your applications and set up the entire automation process using GitHub Actions.

However, in this article, we will take the architecture a step further, covering all the processes, from the repository, building the image, creating Helm files, and deploying the application to the central Helm repository.

Preparing the Sample Application

Before we proceed, let’s not forget that we need an example application. We will use Python and the Fastapi framework, a language and framework with which I am very familiar. Here is the folder and file structure we will have:

. 
├── app
│ ├── __init__.py
│ └── main.py
├── docker-compose.yml
├── Dockerfile
├── .gitignore
├── LICENSE
├── poetry.lock
├── pyproject.toml
└── README.md

The application will be very simple. In the app/main.py file, we will have an endpoint with the GET method called /about and another endpoint called /, which will return an OK status.

from fastapi import FastAPI

app = FastAPI()

@app.get("/")
def status():
return {"status": "ok"}

@app.get("/about")
def read_root():
return {"title": "Building a GitOps structure with GitHub, Actions, DockerHub, and Helm Repository"}

Of course, we cannot forget our Dockerfile, which is necessary to deploy application:

FROM python:3.11 as requirements-stage

WORKDIR /tmp

RUN pip install poetry

COPY ./pyproject.toml ./poetry.lock* /tmp/

RUN poetry export -f requirements.txt --output requirements.txt --without-hashes

FROM python:3.11

WORKDIR /code

COPY --from=requirements-stage /tmp/requirements.txt /code/requirements.txt

RUN pip install --no-cache-dir --upgrade -r requirements.txt

COPY ./app /code/app

CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8080"]

EXPOSE 8080

docker-compose.yml

version: '3'
services:
app:
build:
context: .
dockerfile: Dockerfile
ports:
- "8080:8080"

We won’t go into deep details about the implementation of the application. What we need to know now is whether the sample application is working correctly. Below is the GitHub repository; feel free to clone it and test it on your local machine.

  • Link — We use Poetry as the package manager in this example application. If you’re not already familiar with it, in a nutshell, it works similar to “pip install”, but in a more modern way. It has a robust dependency management system and has gained a lot of popularity.

Let’s now run it on our local machine to check that everything is working as expected. To do this, we will build using docker compose up --build

I’m someone who only believes by seeing, so let’s confirm that everything is as expected by going to the browser and typing the route localhost:8080/about

Now that we have our application working, we can move on to what really matters: building our Helm charts and Github actions. Before that, let’s review some important concepts to understand the entire flow and the following construction.

What is GitOps?

In short, GitOps is an approach that centralizes a Git repository as the primary source of truth for configuring an application’s infrastructure.

The core idea of GitOps is having a Git repository that always contains declarative descriptions of the infrastructure currently desired in the production environment and an automated process to make the production environment match the described state in the repository. If you want to deploy a new application or update an existing one, you only need to update the repository — the automated process handles everything else. It’s like having cruise control for managing your applications in production.

Choosing the Right Development Flow: Git Flow and Beyond

For this project, we chose to use git flow, a development flow developed by Vincent Driessen in 2010. Based on my experience as a developer, it proves to be highly effective in most cases, contributing to organization, development and maintenance of the application.

However, it is important to highlight that there are several other flows that may be more suitable for certain applications or development teams. So feel free to adapt the code from this project to other types of flows, as you will see that, the way it was built, it will suit almost any approach.

To conclude, always remember that panaceas don’t exist. Consider your own context. Don’t be hating. Decide for yourself. By Vincent Driessen

What is helm chart?

When consulting the official Helm documentation, we came across the following statement:

Helm is the best way to find, share, and use software built for Kubernetes.

With this in mind, when it comes to Kubernetes, using Helm to deploy your applications becomes almost mandatory. Helm works as a software catalog that describes all the Kubernetes resources needed for your application, including services, deployments, and configuration files, all in a single package. This significantly simplifies the deployment process and makes it easier to manage updates and changes over time.

Building Our Helm Charts

This topic, I believe, deserves a dedicated article in itself. However, what has been shared here will save you some time in your studies. It is crucial to highlight that practically any configuration for your application on the Kubernetes cluster can be expressed in a Helm file, as it is essentially a simplified representation of the manifest used by Kubernetes.

Let’s create a directory called /helm and, within it, we will organize the directories and files as follows:

└── helm 
├── app-readme.md
├── Chart.yaml
├── .helmignore
├── README.md
├── templates
│ ├── deployment.yaml
│ ├── _helpers.tpl
│ ├── NOTES.txt
│ └── service.yaml
└── values.yaml

Some of them have self-explanatory names; for example, the .helmignore file has similar functionality to .gitignore, but is aimed at Helm files. Therefore, I will provide a brief explanation of each of them unless their purpose is obvious.

Chart.yaml -> Provides essential data about the application, such as its name, version and description.

values.yaml -> store configuration variables for the application, such as the number of replicas, the container image, and the service ports.

Inside the /templates folder we will have YAML templates that define how resources within Kubernetes should be configured and deployed.

deployment.yaml -> You can manage pod-related information such as the number of replicas and container image by dynamically configuring them.

service.yaml -> Defines service characteristics, such as service type (ClusterIP, NodePort, LoadBalancer, etc.)

We can use Helm notation to insert dynamic values ​​from a /values.yaml file, making the deployment customizable and reusable.

apiVersion: apps/v1
kind: Deployment
metadata:
name: sample-app-ci-cd
spec:
replicas: {{ .Values.replicaCount }}
template:
spec:
containers:
- name: sample-app-ci-cd
image: {{ .Values.image.repository }}:{{ .Values.image.tag }}

Here we have an example of /deployment.yaml where .Values.replicaCount and .Values.image.repository are replaced with the values ​​defined in the values.yaml file.

Delving deeper into this subject, access Helm’s official documentation, it is very good and will provide you with more complete insights such as.

. Quickstart Guide

. Installing Helm

. Using Helm

Requirements Analysis: Establishing the Scope

Let’s start with a simple scenario: developing a new functionality for our application. As a developer, I create a new branch on my local machine called feature from develop branch.

In the structure above, we have the creation of the “feature” branch from the “develop” branch.

Following the scheme mentioned at the beginning of this article, in the initial blocks, we have the programmer who commits to the repository. This commit triggers an action within the repository itself, where the automated deployment process takes place.

This raises some questions that need to be answered: what exactly will this action do? Will the action be triggered on each commit? In all branches?

Let’s establish the scope of our action, after all, without a clear scope, the software can become infinite. The action will be triggered when making a commit in any of the following branch: master, release, develop or hotfix.

The action will perform the following steps:

  • Build and upload our Docker image to DockerHub.
  • Update Helm files with image information, versions, and tags.
  • Create Helm file package.
  • Update central Helm repository with the new application package.

Let’s Build Our GitHub Action: Getting Started

Let’s create a new folder called .github and inside it we will have the workflows folder. Essentially, GitHub recognizes this structure as the default location for action files.

├── .github
│ └── workflows
│ └── sample-app.yaml

Triggered when making a commit in any of the following branch: master, release, develop or hotfix:

name: Build and Deploy Application
on:
push:
branches:
- 'master'
- 'main'
- 'releases/**'
- 'release/**'
- 'develop'
- 'feature/**'
- 'feat/**'
- 'hotfix/**'
paths-ignore:
- 'docs/**'

With each push, our action will be activated looking at changes in the main, release, develop, feature, hotfix... branches.

/** Being a wildcard expression that will match any subdirectory or file within the specified directory.

paths-ignore We will ignore everything that is inside the docs folder after all, eventually if we include documentation for our application we do not want the action to be executed with each change in it.

Build and upload our Docker image to DockerHub:

jobs:
build-and-push:
runs-on: ubuntu-latest
steps:
- name: Checkout Code
uses: actions/checkout@v3
- name: Create docker image name
id: docker-image
run: |
BRANCH_NAME=${{ github.ref_name }}
IMAGE_NAME=${BRANCH_NAME}_${{ github.sha }}
echo "IMAGE_NAME=${IMAGE_NAME}" >> $GITHUB_OUTPUT
- name: Extract repository name
id: extract-repository-name
run: |
REPO_NAME=$(echo "${{ github.repository }}" | cut -d '/' -f 2)
echo "REPO_NAME=${REPO_NAME}" >> $GITHUB_OUTPUT
- name: Login to Docker registry
uses: docker/login-action@v3
with:
registry: docker.io
username: '${{ secrets.DOCKER_USERNAME }}'
password: '${{ secrets.DOCKER_PASSWORD }}'
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
file: ./Dockerfile
push: true
tags: >
docker.io/${{ secrets.DOCKER_USERNAME }}/${{ steps.extract-repository-name.outputs.REPO_NAME }}:${{ steps.docker-image.outputs.IMAGE_NAME }}
  • jobs Indicates the start of one or more jobs that will be executed as part of this action.
  • build-and-push Name for the job and can be anything you choose.
  • runs-on: ubuntu-latest Defines the environment in which the job’s actions will be executed. In this case, the work will be performed in a virtual machine with the Ubuntu operating system in the latest version (“ubuntu-latest”).
  • Checkout Code Clones the Git repository into the GitHub Actions runtime. This ensures that the repository code is available for subsequent steps.
  • Create Docker Image Name Sets a Docker image name based on the branch name and the hash of the current commit. This is done by using environment variables like github.ref_name and github.sha and storing the result in a variable called IMAGE_NAME.
  • Extract Respository Name from the variable $GITHUB_REPOSITORY and stores it in a variable called REPO_NAME.
  • Login to Docker registry using the credentials provided through secrets.
  • Build and push Docker image based on the application’s dockerfile present in the repository and then push it to dockerhub.

Since we’re uploading our image to DockerHub, we’ll require an access token. To obtain one, navigate to DockerHub and click on the “Account Settings” in the top right corner of the screen, then select “Security” and finally, click “New Access Token.”

Configuring a new access token on DockerHub 1 — Account Settings -> 2 — Security -> 3 — New Access Token

On the subsequent screen, provide a description for your access token and select the required options. It is essential that the token has write permissions to enable the successful pull of images.

Token creation screen, click on “Generate” after choosing the access permissions and description of your token.

On the next screen, copy the created token and keep it carefully. I always recommend using a password vault such as 1password, lastpass, Bitwarden or similar.

After clicking on generate token we enter this screen where the access token is provided.

Now that we have our access token in hand, let’s create our secrets ${{ secrets.DOCKER_USERNAME}} ${{ secrets.DOCKER_PASSWORD}}.For this within the application repository click on settings -> Actions -> New repository secret add the name of its variable and values.

Step by step to configure a repository secret to use in our action 1 — Settings -> 2 — Secrets and Variables — Actions -> 3 — New repository Secret.

Let’s test and see if things are going as they should?

If we go to dockerhub we will be able to find our image that was built by github actions.

  1. Dockerhub user and which we store inside the DOCKER_USERNAME secret
  2. Repository where the action was executed.
  3. Name of the branch where the action was executed.
  4. Here we use the hash referring to commit.
  5. This hash is automatically generated by Docker Hub. When pushing images with the same name, we can use this hash to precisely identify the image, eliminating any ambiguity.

Update Helm files with image information, versions, and tags:

I created two new files in our main directory that act as a basic way of storing the version number of application and the version of Helm charts.

├── VERSION
└── VERSION_HELM

It will also be necessary to create a new environment variable called NAME_HELM_FOLDER, responsible for storing the name of the default folder that will contain the helms charts.

Environment variables screen from github repository settings -> Actions -> Variables.

The next step will be to update our helm charts with dynamic values ​​such as application version, helm version and add the path of our image within the dockerhub that we pushed in the previous steps.

- name: Extract Image sha256
id: image-digest
run: |
IMAGE_DIGEST=$(docker inspect --format='{{index .RepoDigests 0}}' "${{ secrets.DOCKER_USERNAME }}/${{ steps.extract-repository-name.outputs.REPO_NAME }}:${{ steps.docker-image.outputs.IMAGE_NAME }}" | cut -d "@" -f 2)
echo "IMAGE_DIGEST=${IMAGE_DIGEST}" >> $GITHUB_OUTPUT
- name: Setup yq portable command-line YAML, JSON, XML, CSV, TOML and properties processor
uses: mikefarah/yq@v4.35.2
- name: Update Yaml Files
run: |
helm_folder="${{ vars.NAME_HELM_FOLDER }}"
version_helm="$(cat VERSION_HELM)"
run_number="${{ github.run_number }}"
image_name="${{ steps.docker-image.outputs.IMAGE_NAME }}"
image_digest="${{ steps.image-digest.outputs.IMAGE_DIGEST }}"
account="${{ secrets.DOCKER_USERNAME }}"
repo_name="${{ steps.extract-repository-name.outputs.REPO_NAME }}"

yq -i ".version = \"${version_helm}.${run_number}\"" "./${helm_folder}/Chart.yaml"
yq -i ".appVersion = \"$(cat VERSION)\"" "./${helm_folder}/Chart.yaml"

yq -i '.image.repository = "'"${account}/${repo_name}"'"' "./${helm_folder}/values.yaml"
yq -i '.image.tag = "'"${image_name}@${image_digest}"'"' "./${helm_folder}/values.yaml"
  • Extract image sha256 Retrieve the Digest code from the image, which corresponds to option 5 in the image displayed in the previous step.
  • Setup yq a widely used command-line tool for efficiently processing, querying, and manipulating YAML documents.
  • Update Yaml Files Updates the .image.tag field, incorporating the name of the Docker image along with its respective digest and modifies the image.repository field, specifying the image repository in Docker Hub. Furthermore, it also takes care of updating the versions in the .version and .appVersion fields within the Chart.yaml file, combining the values ​​from the VERSION and VERSION_HELM files with the GitHub environment variable ${{github.run_number}}, which contains the action execution number.

If you have questions about how to find these environment variables, visit the following link to the official GitHub documentation:

Create Helm file package:

The code is quite simple and does not require much explanation. The only thing to note is to create the vars.NAME_HELM_FOLDER environment variable with the name of the folder we defined to contain our Helm files, which in our case would be “helm”.

Additionally, create the variable vars.URL_HELM_REPOSITORY with the URL of our central catalog repository, which in this case has the value ` “https://github.com/eduardo854/helm-store.git". Later, we will discuss more about this repository.

- name: Set up Helm
uses: azure/setup-helm@v3
with:
version: 'v3.11.1'
- name: Package Helm Chart
run: |
helm package ./${{ vars.NAME_HELM_FOLDER }}
helm repo index ./${{ vars.NAME_HELM_FOLDER }} --url ${{ vars.URL_HELM_REPOSITORY }}

Update central Helm repository with the new application package:

We have reached the final stage of our process.

- name: Clone Helm Chart Repo
uses: actions/checkout@v3
with:
repository: eduardo854/helm-store
ref: pre-deployment
token: '${{ secrets.TOKEN_HELM_STORE }}'
path: ./helm-store
- name: Copy Chart Package
run: |
mkdir -p helm-store/app/${{ steps.extract-repository-name.outputs.REPO_NAME }}
cp *.tgz helm-store/app/${{ steps.extract-repository-name.outputs.REPO_NAME }}/
- name: Commit and Push Changes
run: |
cd helm-store/
git config user.name "$GITHUB_ACTOR"
git config user.email "$GITHUB_ACTOR@users.noreply.github.com"
git pull origin pre-deployment
git add .
git commit -m "${{ github.ref_name }}"
git push origin pre-deployment

Now, we will clone our Helm repository, more precisely the “pre-deployment” branch. Next, we’ll copy the package from our Helm chart to the “app” folder in this repository, commit it with a message that includes the branch that initiated the action, and finally push the changes to the repository.

If you are in doubt about how to create the access token to send information to another secrets.TOKEN_HELM_STORE repository, look in my other article for the part I mention For a quick example of how to create your PAT token with the necessary permissions follow the instructions below:

Building Our Central Helm Repository: Let’s Get Started!

Up to this point, you may have noticed something that is missing. If you followed all the steps outlined in the article and performed the action in the previous topic, you probably encountered a failure in the final step because our central Helm repository has not yet been created. So now it’s time to start coding!

The first step will be to clone our central Helm repository, as demonstrated in a previous article. This approach avoids discrepancies between the code and the article, as well as simplifies the life of those who are learning.

I won’t go into deep detail about the code for this action, which was written a few months ago, as all the details are documented in the other article. However, we will discuss the improvements that have been implemented. After all, if you look at your own old code and find no improvements, something is wrong. Either you are an exceptional programmer or you are not evolving.

The new architecture I conceived is as follows:

Next, we will have a branch called “pre-deployment” in which the “app” folder will receive all our Helm packages. When a new file is created, this will serve as a trigger for our action. Lastly, we’ll analyze the commit message to identify the environment we should target the Helm chart to, which could be “develop,” “stage,” or “production.”

name: Release Helm Charts

concurrency: release-helm

on:
workflow_dispatch:
push:
branches:
- pre-deployment
paths:
- 'app/**'

jobs:
update-helm-chart:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
with:
path: 'src'
ref: ${{ github.sha }}
fetch-depth: 0

- name: Identify destiny branch name
id: dest-branch
shell: bash
working-directory: src
run: |
COMMIT_BRANCH=$(git log -n 1 --pretty=format:"%s" ${{ github.sha }})

if [[ $COMMIT_BRANCH == main || $COMMIT_BRANCH == master ]]; then
DEST_BRANCH=production
elif [[ $COMMIT_BRANCH == hot* || $COMMIT_BRANCH == release* ]]; then
DEST_BRANCH=stage
elif [[ $COMMIT_BRANCH == dev* || $COMMIT_BRANCH == feat* || $COMMIT_BRANCH == fix* ]]; then
DEST_BRANCH=develop
else
DEST_BRANCH=develop
fi

echo "DEST_BRANCH=${DEST_BRANCH}" >> $GITHUB_OUTPUT

- name: Checkout
uses: actions/checkout@v3
with:
path: 'dest'
ref: '${{ steps.dest-branch.outputs.DEST_BRANCH }}'
fetch-depth: 0

- name: Set up Helm
uses: azure/setup-helm@v3
with:
version: 'v3.11.1'

- name: Update New Files and push to dest branch
shell: bash
working-directory: src
run: |
set -e

shopt -s extglob

file_list=$(git diff-tree --no-commit-id --name-only -r ${{ github.sha }} -- ./app)

mkdir -p aux_dir

for file in $file_list; do
echo "file: ${file}"
if [ -f "$file" ]; then
mv "$file" ./aux_dir
else
echo "Error: File '$file' does not exist."
fi
done

url=$(echo "https://raw.githubusercontent.com/eduardo854/helm-store/${{ steps.dest-branch.outputs.DEST_BRANCH }}/helm/")

if [ -n "$(find ./aux_dir -type f -name '*.tgz')" ]; then
if ! helm repo index ./aux_dir --merge ../dest/index.yaml --url $url; then
echo "Error: Failed to generate repository index."
exit 1
fi


mkdir -p ../dest/helm

echo "Copying files..."

if ! cp -pr ./aux_dir/!(index.yaml) ../dest/helm/; then
echo "Error: Failed to copy files to the destination directory."
exit 1
fi

if ! cp -pr ./aux_dir/index.yaml ../dest/; then
echo "Error: Failed to copy the index file to the destination directory."
exit 1
fi
else
echo "Warning: No .tgz files found in ./aux_dir. Skipping index generation."
fi

- name: Commit alterations to dest branch and push files
shell: bash
working-directory: dest
run: |
git config user.name "$GITHUB_ACTOR"
git config user.email "$GITHUB_ACTOR@users.noreply.github.com"

if [[ $(git status --porcelain) ]]; then

if ! git add .; then
echo "Error: Failed to stage the changes for commit."
exit 1
fi

if ! git commit -m "Updated from ref: ${{ github.sha }}"; then
echo "Error: Failed to commit the changes."
exit 1
fi

if ! git push; then
echo "Error: Failed to push the changes to the remote repository."
if ! git pull --rebase && git push; then
echo "Error: Failed to merge and push the changes."
exit 1
fi
fi
else
echo "No changes to commit."
fi

Don’t forget to check this option to commit to your own repository, unless you want problems. And also don’t forget to click “save” because it already happened to me ;D

Workflow permission click on Settings -> Actions — General -> Workflow permissions

To access the codes that evolves helm central repository, access my repository below:

TEST

Now that we’ve finished developing what we needed, are we going to test it? First, let’s make a small change to our sample-app-ci-cd and commit the changes.

It is worth highlighting that the changes were in the main branch and the pipeline execution number was 52

Going to the helm-store we see that a new action has been triggered and the commit message is “main”.

Highlighted the “main” commit message

The moment of truth arrives when we open our production branch. At this point, we need to ensure that our Helm index is up-to-date, and the Helm chart is readily accessible.

Improvements

Remember, our idea is to create a default action that is friendly to other repositories, making maintaining as easy as possible. So our goal is to make it generic. Taking a look at the GitHub documentation, we found a feature we can explore!

In a future article, I promise to refactor this action and explain step by step how to make our actions reusable by other repositories (but while I don’t do that, showing its existence to those who are not familiar with it is a good first step, right? ;D).

And, of course, we can’t forget to mention our inseparable friend, the Argo CD. Now that we have our Helm catalog, who doesn’t dream of updating their applications in real time and automatically? As a developer, I always wanted my deploys to happen like magic. I’m sure many of you share this dream! I promise to bring more articles on these topics.

Finally

This article is not intended to be an absolute guide but rather a perspective from someone passionate about programming.

So if you have constructive criticism or brilliant ideas, bring it all! I love learning and seeing the world through the lenses of different people. I’m here to answer your questions too.

After all, software is constantly evolving, and DevOps is a very complex area, with different ways of doing the same thing.

If in any way this article was helpful to you or inspired you to have your “Eureka!” moment, you owe me a thumbs up in payment. Thank you for making it this far and I wish you a week full of tiny, easy-to-fix bugs! 😉🐞

--

--