Kubernetes CI-CD & GitOp with ArgoCD /
Git automation

03/11/2025

Let’s do this step-by-step. We’ll break it into two main phases:

  1. Phase 1: Continuous Integration (CI). We’ll set up a workflow where git push automatically builds and publishes your app’s Docker image.

  2. Phase 2: Continuous Deployment (CD). We’ll install a tool in K3s that watches your Git repo and automatically updates your cluster when you push changes

Phase 1: Continuous Integration (Git to Docker Image)

Our goal here is to make a simple Node.js app, push it to GitHub, and have GitHub Actions automatically build the Docker image and push it to a registry.

Step 1.1: Create a Simple Node.js App

First, let’s create a super-simple « Hello World » Express app on your local machine.

  1. Create a new folder (e.g., k8s-hello-world) and go into it.

  2. Run npm init -y to create a package.json.

  3. Install Express: npm install express.

  4. Create a file named index.js and add this code:


const express = require('express'); const app = express(); const port = process.env.PORT || 3000; // A "VERSION" variable we can change later const VERSION = '1.0.0'; app.get('/', (req, res) => { res.send(`Hello from Kubernetes! Version: ${VERSION}`); }); app.listen(port, () => { console.log(`App listening at http://localhost:${port}`); });

Add a start script to your package.json. It should look something like this:


{ "name": "k8s-hello-world", "version": "1.0.0", "description": "", "main": "index.js", "scripts": { "start": "node app.js" }, "keywords": [], "author": "", "license": "ISC", "dependencies": { "express": "^4.18.2" } }

You can test it locally by running npm start. You should be able to see the message at `http://localhost:3000`.

Step 1.2: Create the Dockerfile

Now, let’s add the « recipe » for Docker to build this app.

Create a file named Dockerfile (no extension) in the same folder:


# Stage 1: Build the app FROM node:18-alpine AS builder WORKDIR /app COPY package*.json ./ RUN npm install COPY . . # Stage 2: Create the final, small image FROM node:18-alpine WORKDIR /app COPY --from=builder /app . # Set the port ENV PORT=3000 EXPOSE 3000 # Run the app CMD [ "npm", "start" ]

Plain English: This is a « multi-stage build. » It uses one container to install all the dev dependencies and build the app, then copies only the necessary files into a fresh, clean container. This makes your final image much smaller and more secure.

You can test your image is working by building your image locally :

docker build -t antoinebr/k8s-hello-world .

And run it to verify :

docker run -d -p 3001:3000 antoinebr/k8s-hello-world

1.3: Set up the GitHub Repo

This is the central hub for our project.

  1. Go to GitHub and create a new public repository (e.g., k8s-hello-world).

  2. On your local machine, initialize Git and push your code

# Create a .gitignore file
echo "node_modules" > .gitignore

# Initialize git
git init
git add .
git commit -m "Initial commit: hello world app + Dockerfile"

# Link it to your new GitHub repo (replace with your URL)
git remote add origin https://github.com/YOUR_USERNAME/k8s-hello-world.git
git branch -M main
git push -u origin main

1.4: Set up GitHub Actions (The CI Workflow)

This is the magic. We’ll tell GitHub to run a job every time we push to the main branch.

  1. Add Secrets: The GitHub Action will need to log in to Docker Hub to push your image.
    • Go to your GitHub repo’s page.
    • Click on Settings > Secrets and variables > Actions.

Click New repository secret for each of these:

Create the Workflow File: On your local machine, create a new folder path: .github/workflows

mkdir -p .github/workflows

Inside that folder, create a file named build-and-push.yaml:

name: Build and Push Docker Image

# This workflow runs on every push to the 'main' branch
# (The docs you sent use `on: release:`, but for our
# CI/CD project, running on *every push* is what we want)
on:
  push:
    branches: [ "main" ]

jobs:
  build:
    runs-on: ubuntu-latest

    steps:
      # 1. Check out your code from the repo
      - name: Check out the repo
        uses: actions/checkout@v4 

      # 2. Log in to Docker Hub
      - name: Log in to Docker Hub
        uses: docker/login-action@v3
        with:
          # Use the secrets we created in Step 1.4
          username: ${{ secrets.DOCKERHUB_USERNAME }}
          password: ${{ secrets.DOCKERHUB_TOKEN }}

      # 3. NEW: Extract metadata (tags and labels)
      # This is the modern helper action from the docs you shared.
      # It automatically creates smart tags based on the Git event.
      - name: Extract metadata for Docker
        id: meta
        uses: docker/metadata-action@v5 # <-- The new, smart helper
        with:
          # This tells the action what to name your image
          images: ${{ secrets.DOCKERHUB_USERNAME }}/k8s-hello-world

      # 4. Build and push the image
      - name: Build and push Docker image
        uses: docker/build-push-action@v5 # <-- Updated to v5
        with:
          context: .
          push: true
          # This line is the magic!
          # It uses the smart tags and labels generated by the 'meta' step above
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}

Plain English: This file tells GitHub: « When someone pushes to main, spin up a new Linux server. On that server, check out the code, log in to Docker Hub using the secrets I gave you, and then build the Dockerfile in this repo. Finally, push that new image to my Docker Hub account with two tags: latest and a unique one based on the Git commit. »

Commit and Push the Workflow:

git commit -a -m "Add GitHub Actions CI workflow"

Then push :

git push origin main

Now, go to your GitHub repo and click the « Actions » tab. You should see your workflow running!

If it’s successful (it might take a minute or two), you’ll see a green checkmark.

our code is now automatically being built and published to the world (or at least, to Docker Hub). You have the CI (Continuous Integration) part of CI/CD all set.

Let’s set up the CD (Continuous Deployment).

Phase 2: Continuous Deployment (Git to Live App)

Right now, your workflow is a « push » system. You git push your code, and GitHub pushes a Docker image to Docker Hub.

We’re about to build a « pull » system in the K3s cluster. We will install a « robot » inside your cluster called ArgoCD.

This robot’s only job is to watch your Git repository. The moment it sees a change in your YAML files, it will « pull » those changes into the cluster and make it happen automatically.

You will never have to run kubectl apply -f ... for your app again. Your Git repo becomes the single source of truth. This is GitOps.

Here’s the plan:

  1. Step 2.1: Create the Kubernetes YAML files (Deployment, Service, Ingress) for your new app.

  2. Step 2.2: Push those new YAML files to your GitHub repo.

  3. Step 2.3: Install ArgoCD (the « robot ») into your K3s cluster.

  4. Step 2.4: Configure ArgoCD to watch your repo.

  5. Step 2.5: Test the complete loop by making a change and watching it deploy.

Step 2.1: Create Your App’s Kubernetes Files

Just like what we have done with WordPress, our app needs a Deployment, a Service, and an Ingress.

  1. On our local machine, in your k8s-hello-world project, create a new folder named k8s.

  2. Inside that k8s folder, create a new file named app.yaml.

  3. Paste all of this into that new app.yaml file:

Before you save!

This file bundle contains the « instructions » for Kubernetes, telling it how to run your app.

Step 2.2: Push the YAML to GitHub

Now, let’s commit our new k8s folder to the repo. This puts our app code and our infrastructure code in the same place.


git add k8s/app.yaml git commit -m "Add Kubernetes manifests for hello-world app" git push

Great! Your repo now has everything needed to run the app.


git add --all git commit -a -m "Add Kubernetes manifests for hello-world app" git push origin main

Step 2.3: Install ArgoCD in K3s

Time to install our « robot. » These two commands will install ArgoCD into its own argocd namespace in your cluster.

Create the namespace:

kubectl create namespace argocd

It should return :

namespace/argocd created

Apply the official installation YAML (this is a big one!)

kubectl apply -n argocd -f https://raw.githubusercontent.com/argoproj/argo-cd/stable/manifests/install.yaml

This will create a bunch of new Deployments, Services, and CRDs (Custom Resource Definitions), which are what make ArgoCD work.

You can check on its progress by running: kubectl get pods -n argocd Wait until all the pods show Running.

In K9s we can see our new namespace too

Step 2.4: Log In to the ArgoCD Dashboard

ArgoCD has a great web UI. By default, it’s not exposed by an Ingress. The most secure and simple way to access it is with kubectl port-forward.

First, get the admin password: ArgoCD generates a default password and stores it in a Secret. Run this command to get it

kubectl -n argocd get secret argocd-initial-admin-secret -o jsonpath="{.data.password}" | base64 -d

It will print out a long, weird string. Copy that password!

Second, access the UI: Open a new terminal (you need to leave this one running) and execute:

kubectl port-forward --address 0.0.0.0 -n argocd svc/argocd-server 8080:443

Now, open your browser and go to: `https://localhost:8080`

Your browser will give you a safety warning (it’s a self-signed certificate). Just click « Advanced » and « Proceed. »

You are now in the ArgoCD dashboard!

Step 2.5: Create the « Application »

This is the final step. We need to give our « robot » its instruction sheet. We’ll tell it: « Watch this Git repo, in this folder, and deploy it to this cluster. »

We’ll do this the GitOps way, by creating one more YAML file.

  1. On your local machine, create one last file. You can call it argo-application.yaml (save it anywhere, this is just for you to run).

  2. Paste this code into it:

# MAKE SURE to change your repo URL below!

apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  # This is the name of the "card" in the ArgoCD UI
  name: hello-world 
  # Deploy this Application *into* the argocd namespace
  namespace: argocd 
spec:
  project: default

  # Source: Where is the code?
  source:
    # ==========================================================
    # !! IMPORTANT !!
    # Change this to your GitHub repo's URL
    repoURL: https://github.com/antoinebr/k8s-hello-world.git
    # ==========================================================

    # This is the folder it should look inside
    path: k8s 

    # It will watch the 'main' branch
    targetRevision: main

  # Destination: Where should it deploy?
  destination:
    # This means "the same cluster ArgoCD is running in"
    server: https://kubernetes.default.svc 

    # Deploy the app into the 'default' namespace
    namespace: default

  # This is the magic!
  # It tells ArgoCD to automatically sync when it sees a change.
  syncPolicy:
    automated:
      prune: true    # Deletes things that are no longer in Git
      selfHeal: true # Fixes any manual changes (drift)



Run :

kubectl apply -f argo-application.yaml

Step 2.6: 🤩 Watch the Magic

Go back to your ArgoCD dashboard in your browser.

You will see a new « card » named hello-world. At first, it will say Missing and OutOfSync.

Within a few moments, ArgoCD will see the new Application. It will:

  1. Clone your Git repo.

  2. Read the k8s/app.yaml file.

  3. Compare it to what’s in your cluster (nothing).

  4. It will automatically start applying the Deployment, Service, and Ingress.

The status will change to Progressing and then, finally, to Healthy and Synced.

If it doesn’t Sync and the error message looks like :

ComparisonError

Failed to load target state: failed to generate manifest for source 1 of 1: rpc error: code = Unknown desc = failed to list refs: authentication required: Repository not found.

It’s because your GitHub repository is private. You can create SSH keys for Argo CD, but for this guide, let’s keep our repository public.

It means

Okay, I’m inside your GitHub repository! But you told me to look for a folder named k8s… and there’s no folder here with that name.

Let’s double check the path

path: k8s # <-- THIS LINE!

After any modification, re-run the command:

kubectl apply -f argo-application.yaml

Ok now it should look like this :

You are now deployed!

To see your app, just:

  1. Add your domain (e.g., hello.home) to your /etc/hosts file, pointing to your K3s node’s IP.

  2. Visit `http://hello.home` in your browser.

You should see: Hello from Kubernetes! Version: 1.0.0

Step 2.7: The Final Test (The Full Loop)

This is the whole point. Let’s make a change and watch it deploy automatically.

  1. On your local machine, open app.js.

  2. Change the version: const VERSION = '2.0.0';

  3. Commit and push the change:

Now, watch what happens:

  1. GitHub Actions: Go to your repo’s « Actions » tab. You’ll see your build-and-push workflow kick off. It’s building your v2.0.0 image and pushing it to Docker Hub with the :main tag.

ArgoCD: Wait for the GitHub Action to finish. ArgoCD checks your repo for changes every 3 minutes (by default). Because we used imagePullPolicy: Always in our Deployment, ArgoCD will detect a new version of the :main image and trigger a new sync

Now it’s in sync but maybe you noticed that we are still on Version 1.0.0 while our code is on Version 2.0.0

What happened here?

We published a new Image on the Docker hub :

antoinebr/k8s-hello-world:main

But in K8s/app.yaml the image didn’t change. So for Argo CD there’s noting to do…

image: antoinebr/k8s-hello-world:main

We need to find a way to first change our version of the image.

Run the GitHub workflow to push the image to the Docker registry.

Tell Argo CD to deploy the new image.

To make this happen, we will tag our code version with git tags to create new version tags.


# Make sure your main branch is up to date git checkout main git pull # Create the new version tag git tag v2 # Push all your tags to GitHub git push --tags

The Robot’s Action (GitHub Actions): The workflow file below will see this v2 tag, build your app, and push the new Docker image: antoinebr/k8s-hello-world:v2.

Part 2: The « Deploy » Manager (You + CD)

This part is your manual « Go » button. You do this after the build in Part 1 is finished.

Your Action:

  1. You know the v2 image is now on Docker Hub.

  2. You open your k8s/app.yaml file.

  3. You manually change the image: line to tell your cluster which version to use:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: hello-world
spec:
  template:
    spec:
      containers:
      - name: app
        # You manually update this line
        image: antoinebr/k8s-hello-world:v2
...

You commit and push this one file change:

git add k8s/app.yaml
git commit -m "Deploy: Promote v2 to production"
git push

Your Final Workflow File

This is the only file you need. This code is « smart »—it creates a :main tag when you push to main (for testing) and a :v2 tag when you push a v2 tag.

File: .github/workflows/build-and-push.yaml


name: Build and Push Docker Image on: push: # Run on pushes to the 'main' branch branches: [ "main" ] # ALSO run on pushes of tags that start with 'v' (e.g., v1, v2.0, v1.2.3) tags: [ 'v*' ] jobs: build: runs-on: ubuntu-latest steps: - name: Check out the repo uses: actions/checkout@v4 - name: Log in to Docker Hub uses: docker/login-action@v3 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} # This step is smart! - name: Extract metadata for Docker id: meta uses: docker/metadata-action@v5 with: images: ${{ secrets.DOCKERHUB_USERNAME }}/k8s-hello-world tags: | # This creates the ':main' tag on a push to main type=ref,event=branch # This creates the ':v2' tag on a push of a tag 'v2' type=ref,event=tag - name: Build and push uses: docker/build-push-action@v5 with: context: . push: true # This 'meta' step will output the correct tag automatically tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }}

Step 1 : Push Your New Workflow File

First, make sure the new build-and-push.yaml file (the one that builds based on tags) is in your main branch.

### Push Your New Workflow File
git add .github/workflows/build-and-push.yaml git commit -m "Update CI to build from Git tags" git push

Step 2 : Create Your First « Release » (Build the v1 Image)

Now, let’s play the role of the developer releasing « Version 1 » of your app.

On your local machine, create a Git tag named v1:

git tag v1

Push your tags to GitHub. This is the trigger for your CI build.

Bash

git push --tags

Watch the « Build » Robot (GitHub Actions):

Step 3: « Deploy » Your v1 Release

You are now the « Release Manager. » The build is done. It’s time to tell ArgoCD to deploy it.

  1. On your local machine, manually edit k8s/app.yaml.

  2. Change the image: line to point to the new, permanent v1 tag:


spec: containers: - name: app # Before: image: antoinebr/k8s-hello-world:main (or something else) # After: image: antoinebr/k8s-hello-world:v1

Commit and push this change. This is your « Go » button.

git add k8s/app.yaml
git commit -m "Deploy: Promote v1 to production"
git push origin main

Step 4: Watch the « Deploy » Robot (ArgoCD)

  1. Open your ArgoCD dashboard.

  2. You will see your hello-world application change to OutOfSync (because the Git image: is now different from the cluster’s image:).

  3. ArgoCD will automatically start syncing. It will see the new v1 tag.

  4. It will perform a rolling update in your K3s cluster. The status will go to Progressing and then Healthy and Synced again.

Step 5: Verify Your v1 App is Live!

  1. Add your domain (e.g., hello.home) to your /etc/hosts file (if you haven’t already).

  2. Go to `http://hello.home` in your browser.

CheatSheet : How to Publish & Deploy a New Version

Step 0: Code a new version

Code you version then commit


git commit -a -m "My new feature for v3" git push

Step 1: Build a New Release (Your Job -> GitHub)

This tells your CI « robot » (GitHub Actions) to build and publish a new, permanent Docker image.

  1. Tag your code: (Replace v2 with your new version, e.g., v3)
git tag v3

Push the tag:

git push --tags

Verify: Go to your GitHub Actions tab and watch the build finish. Check Docker Hub to see your new v2 image.

Step 2: Deploy the New Release (Your Job -> ArgoCD)

This tells your CD « robot » (ArgoCD) to pull the new image and update your cluster.

  1. Edit k8s/app.yaml: Manually change the image: line to the new tag you just built.

... spec: containers: - name: app # Change this line image: antoinebr/k8s-hello-world:v2 ...

Commit and push the change:

git add K8s/app.yaml
git commit -m "Deploy: Promote v2"
git push

Verify: Go to your ArgoCD dashboard. Watch your app sync up. Refresh your website to see the change!