Lately I've been using GitOps for managing the continous deployment of my applications.
My applications runs in a Kuberentes cluster so they are managed by a couple of different yml files (manifests).

This blog post will demonstrate a complete ci/cd pipeline using GitHub Actions. Whenever I commit new source code, the following will happen:

  1. A GitHub action runs a build pipeline and create a new docker image
  2. It then updates another git repository (JOS.Infra) which contains the Kubernetes manifests.
  3. Argo CD will see that the JOS.Infra repository has been updated and create a new deployment.
  4. The new code will be deployed and run in the cluster.

Pretty neat!

I have created a sample application, JOS.Echo, that I will use in this post as an example.

Setup

I have two git repositories:

  • JOS.Echo - The source code of the application
  • JOS.Infra - The kubernetes manifests. This repository will be updated via a GitHub action in the JOS.Echo repository.

I also have Argo CD running in my Kubernetes cluster.

Manifests

My manifests are really simple, they look like this:
deployment.yml

apiVersion: apps/v1
kind: Deployment
metadata:
  name: jos-echo-deployment
spec:
  replicas: 3
  selector:
    matchLabels:
      app: jos-echo
  template:
    metadata:
      labels:
        app: jos-echo
    spec:
      imagePullSecrets:
        - name: dockerconfig-ghcr.io
      containers:
        - name: jos-echo-container
          image: ghcr.io/joseftw/jos-echo:1.0.21-ci.ga3b2bc3949
          resources:
            limits:
              cpu: "1"
              memory: 2048Mi
            requests:
              cpu: 0m
              memory: 128Mi
          volumeMounts:
            - name: certs-volume
              mountPath: /certs
              readOnly: true
          ports:
            - containerPort: 5555
              name: http-web-svc
      volumes:
        - name: certs-volume
          secret:
            secretName: local-josef-guru-tls

ingress.yml

apiVersion: traefik.containo.us/v1alpha1
kind: IngressRoute
metadata:
  name: jos-echo-ingress-route
  annotations:
    kubernetes.io/ingress.class: traefik-internal
spec:
  entryPoints:
    - websecure
  routes:
    - match: Host(`echo.local.josef.guru`)
      kind: Rule
      services:
        - name: jos-echo-service
          port: 5555

service.yml

apiVersion: v1
kind: Service
metadata:
  name: jos-echo-service
spec:
  ports:
    - name: http
      port: 5555
      targetPort: http-web-svc
  selector:
    app: jos-echo

GitHub Action

My GitHub Action looks like this:

name: JOS.Echo

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

jobs:
  build:
    name: CI
    runs-on: ubuntu-latest
    timeout-minutes: 20
    outputs:
      semver2: ${{ steps.nbgv.outputs.SemVer2 }}
      version: ${{ steps.nbgv.outputs.Version }}
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - uses: dotnet/nbgv@master
        id: nbgv
      - name: Setup .NET
        uses: actions/setup-dotnet@v3
        with:
          dotnet-version: 8.0.x
          dotnet-quality: 'preview'
      - name: Build
        run: |
          dotnet test src/JOS.Echo.sln  -c Release /p:AssemblyVersion=${{ steps.nbgv.outputs.version }} /p:InformationalVersion=${{steps.nbgv.outputs.semver2}} --verbosity minimal
      - name: Docker - Login
        uses: docker/login-action@v2
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
      - name: Docker - Build and Push
        uses: docker/build-push-action@v3
        with:
          context: .
          file: ./src/JOS.Echo/Dockerfile
          platforms: linux/amd64
          push: true
          tags: |
            ghcr.io/${{ github.repository_owner }}/jos-echo:latest
            ghcr.io/${{ github.repository_owner }}/jos-echo:${{ steps.nbgv.outputs.semver2 }}
      - uses: actions/checkout@v4
        name: GitOps - Checkout
        with:
          repository: joseftw/jos.infra
          ref: 'develop'
          token: ${{ secrets.JOS_YODA_PAT }}
          fetch-depth: 0
          path: jos.infra
      - name: GitOps - Patch and push
        run: |
          git -C jos.infra config user.name "jos-yoda"
          git -C jos.infra config user.email "some-secret-email@users.noreply.github.com"
          sed -i'.bak' -e 's|/jos-echo:*.*.*-*.*$|/jos-echo:${{steps.nbgv.outputs.semver2}}|g' jos.infra/apps/internal/jos.echo/deployment.yml
          git -C jos.infra commit -am "jos-echo ${{steps.nbgv.outputs.semver2}}"
          git -C jos.infra push origin develop

Let's break it down:

  • Checkout the source code
  • Build the project
  • Login to GitHub Container Registry
  • Build the docker image
  • Push it to GitHub Container Registry
  • Checkout the JOS.Infra repository
  • Patch the deployment.yml file with the new image tag
  • Commit and push the changes to JOS.Infra.

Let's talk a bit about the last three steps, the "GitOps portion".

Connecting to the infra repo - Bot user

I've created a "bot" (just a regular GitHub account) that will do the commits for me since I don't want to use my personal account. That's a more "production-like" setup when working in a team for example. The bot account is added as a collaborator to the JOS.Infra repository so it's allowed to clone, commit and push code.

To avoid having to use the bot account password, I've created a PAT (Personal Access Token) and stored it as a secret in the JOS.Echo repository. You can see it being used here to connect to the JOS.Infra repository:

- uses: actions/checkout@v4
  name: GitOps - Checkout
  with:
    repository: joseftw/jos.infra
    ref: 'develop'
    token: ${{ secrets.JOS_YODA_PAT }}
    fetch-depth: 0
    path: jos.infra

Updating the deployment manifest

The relevant portion of the deployment.yml file looks like this:

containers:
  - name: jos-echo-container
    image: ghcr.io/joseftw/jos-echo:1.0.21-ci.ga3b2bc3949

We want to update the image version with our newly built one.
I'm using sed for that.
By no means am I an expert on sed, but the following code works...๐Ÿ˜ƒ

sed -i'.bak' -e 's|/jos-echo:*.*.*-*.*$|/jos-echo:${{steps.nbgv.outputs.semver2}}|g' jos.infra/apps/internal/jos.echo/deployment.yml

When the file has been updated, the bot does a new commit and pushes it to the infra repository.

git -C jos.infra commit -am "jos-echo ${{steps.nbgv.outputs.semver2}}"
git -C jos.infra push origin develop

Image of jos-yoda bot account latest commit

Argo CD

It's really easy to configure Argo CD. The only thing you need to do is to point it to your repository that contains the manifests, in my case that's JOS.Infra.
I'm using the cli for this but you can also use the Argo CD UI.

argocd repo add https://github.com/joseftw/jos.infra --name jos.infra --username my-service-user --password github_pat_MyPat

You will then need to create the application in Argo CD...

argocd app create jos.echo --repo https://github.com/joseftw/jos.infra --path apps/internal/jos.echo --dest-namespace jos-apps --dest-server https://kubernetes.default.svc --directory-recurse

...and that's it!

I've configured Argo CD to auto sync my manifests, so whenever it detects a change in my manifests, it will apply them.
Argo CD - status dashboard
If I now add a new feature to my application...Argo CD will eventually see it and it will be reflected on the dashboard.
Screenshot-2023-09-11-at-16.04.44

The fully (automated) process from commiting the code to have it deployed takes roughly 3 minutes, and the best part is that I don't need to do anything (besides setting everything up ๐Ÿ˜…), it just works.