Build an End-to-End GitOps Pipeline
Push code and watch it automatically build, deploy to Kubernetes via Argo CD.
Introduction
30 Second Summary
Think about the last time you made a small code change and then had to manually rebuild, re-upload, and redeploy it. That gap between "code is ready" and "code is live" is where mistakes creep in and time disappears.
In this project, you will build a fully automated delivery pipeline where pushing a code change to GitHub triggers an entire chain: your image builds, your manifests update, and your Kubernetes cluster deploys the new version without you ever running kubectl.
What You'll Build
You push a one-line version bump to your Flask app, curl it a few minutes later, and see the new version running on Kubernetes with zero manual steps in between.
By the end of this project, you'll have:
- A running Flask + Redis hit-counter app on Kubernetes that returns a JSON response with a version number and hit count, proving multi-container orchestration works.
- A GitHub Actions CI pipeline that automatically builds Docker images and pushes them to GitHub Container Registry every time you push code.
- An Argo CD GitOps sync that watches your manifests repo and deploys changes to your cluster automatically, with no manual intervention.
- Secret Mission: Layer in Prometheus and Grafana to monitor your deployments in real-time.
Are there any prerequisites?
This project assumes basic familiarity with Docker, Kubernetes, GitHub Actions, and Argo CD. If you have completed individual projects on each of these tools, you are ready to connect them into a single pipeline here.
Before We Start
Before we dive in, let's lock in what you're about to build and why it matters for your DevOps career. Clarity on the end goal keeps every step purposeful.
Set Up Repositories and Argo CD
A GitOps pipeline needs two separate repositories: one for your application code and one for your Kubernetes manifests. This separation is the standard pattern recommended by Argo CD. CI updates the manifests repo, and Argo CD watches only that repo to drive deployments.
In this step, you will create both GitHub repositories, install Argo CD on your local Kubernetes cluster, and log in via the CLI. By the end, your cluster will have a running Argo CD instance ready to watch for manifest changes.
In this step, get ready to:
- Create two GitHub repositories and clone them into WSL.
- Install Argo CD on your Kubernetes cluster.
- Install and log into the Argo CD CLI.
Verify existing tools and create GitHub repositories
Before creating anything new, confirm that Docker Desktop Kubernetes is running on your machine.
- In your WSL terminal, confirm your Kubernetes cluster is reachable by running this command:
kubectl cluster-info
What should I see?
You should see output showing the Kubernetes control plane address (something like https://127.0.0.1:6443). This confirms Docker Desktop's Kubernetes is running and accessible from WSL.
Seeing 'connection refused'?
Open Docker Desktop, go to Settings then Kubernetes, and make sure Enable Kubernetes is checked. Click Apply & restart and wait for the green Kubernetes icon in the bottom-left.
Help me fix my kubectl connection
Now create the two GitHub repositories that will power your pipeline.
- Open your browser and go to github.com/new.
- Enter pipeline-app as the repository name.
- Leave it set to Public.
- Check Add a README file so the repo has an initial commit you can clone.
- Click Create repository.
Why two separate repos?
The app repo holds your source code and CI workflow. The manifests repo holds only Kubernetes YAML. Argo CD watches the manifests repo exclusively, so CI can update image tags there without giving the deployment tool access to your application source.
- Go to github.com/new again.
- Enter pipeline-manifests as the repository name.
- Leave it set to Public.
- Check Add a README file.
- Click Create repository.
Now clone both repos into your WSL environment.
- Move to your Desktop by running this command:
cd ~/Desktop
- Clone your app repository by running:
git clone https://github.com/[[GITHUB_USERNAME="your-github-username"]]/pipeline-app.git
- Clone your manifests repository by running:
git clone https://github.com/[[GITHUB_USERNAME="your-github-username"]]/pipeline-manifests.git
- Confirm both folders exist by running:
ls
You should see both pipeline-app and pipeline-manifests listed in the output.
Clone failed with 'not found'?
Double-check the repository name matches exactly what you created on GitHub. The URL is case-sensitive and must include your actual GitHub username.
Help me troubleshoot my git clone
Install Argo CD on your Kubernetes cluster
Argo CD runs inside your Kubernetes cluster as a set of pods. It needs its own namespace and a set of custom resources to operate.
- Create the Argo CD namespace and install Argo CD v3.4.5 by running these commands:
kubectl create namespace argocd
kubectl apply -n argocd --server-side --force-conflicts -f https://raw.githubusercontent.com/argoproj/argo-cd/v3.4.5/manifests/install.yaml
What do these commands do?
The first command creates a dedicated argocd namespace to isolate Argo CD's resources from the rest of your cluster.
The second command downloads and applies all the Kubernetes resources Argo CD needs (deployments, services, RBAC roles) from the official v3.4.5 release manifests.
- Wait for all Argo CD pods to become ready by running:
kubectl wait --for=condition=ready pod --all -n argocd --timeout=120s
This command blocks until every pod in the argocd namespace reports ready, or times out after 120 seconds. You should see lines like pod/argocd-server-... condition met for each pod.
Seeing a timeout?
If the wait times out, check pod status by running kubectl get pods -n argocd. Look for pods stuck in ImagePullBackOff or CrashLoopBackOff. A slow network connection is the most common cause. Try running the wait command again with a longer timeout.
Help me diagnose my Argo CD pods
Install and configure the Argo CD CLI
The Argo CD CLI lets you manage applications and check deployment status from your terminal. You will use it to create and monitor your pipeline application in later steps.
- Check if you already have the Argo CD CLI installed by running:
argocd version --client
✔️ I see version 3.4.5 or higher
Your Argo CD CLI is already up to date. Skip ahead to the port-forward step below.
ⓧ I see an older version
You have an outdated version of the CLI. The install command below will overwrite it with v3.4.5.
- Upgrade the Argo CD CLI by running this command:
curl -sSL -o argocd-linux-amd64 https://github.com/argoproj/argo-cd/releases/download/v3.4.5/argocd-linux-amd64 && sudo install -m 555 argocd-linux-amd64 /usr/local/bin/argocd && rm argocd-linux-amd64
- Your terminal may prompt for your WSL password for the sudo step. Enter it to continue.
- Confirm the upgrade by running argocd version --client again. You should see v3.4.5.
ⓧ Command not found
The CLI is not installed yet. You will download and install it now.
- Download and install the Argo CD CLI by running this command:
curl -sSL -o argocd-linux-amd64 https://github.com/argoproj/argo-cd/releases/download/v3.4.5/argocd-linux-amd64 && sudo install -m 555 argocd-linux-amd64 /usr/local/bin/argocd && rm argocd-linux-amd64
What does this command do?
It downloads the v3.4.5 Linux binary from the Argo CD GitHub releases, installs it to /usr/local/bin/argocd with executable permissions, then removes the downloaded file.
- Your terminal may prompt for your WSL password for the sudo step. Enter it to continue.
- Confirm the CLI installed by running:
argocd version --client
You should see argocd: v3.4.5 followed by build details. This confirms the binary is in your PATH.
Still seeing 'command not found'?
Make sure the install command did not fail silently. Run ls /usr/local/bin/argocd to check if the file exists. If not, re-run the full curl and install command above.
Help me fix argocd not being found
Now connect the CLI to the Argo CD server running in your cluster.
- Start a port-forward to the Argo CD server by running:
kubectl port-forward svc/argocd-server -n argocd 8080:443 &
Why the & at the end?
The & sends the port-forward process to the background so you can keep using the same terminal. The Argo CD web UI and CLI both reach the server through localhost:8080.
- Retrieve the initial admin password by running:
argocd admin initial-password -n argocd
Copy the password from the output. You will need it for the next command.
- Log in to Argo CD by running this command, replacing your-password-here with the password you just copied:
argocd login localhost:8080 --insecure --username admin --password your-password-here
You should see a confirmation that admin logged in successfully and the context was updated.
Login failed?
If you see a connection error, make sure the port-forward is still running. Run jobs to check. If it is gone, re-run the port-forward command above.
If the password is rejected, re-run argocd admin initial-password -n argocd and copy it again carefully. Trailing whitespace can cause failures.
Help me fix my Argo CD login
Before you run the next command, what do you think argocd version will show now that you are logged in to the server?
- Verify everything is connected by running:
argocd version
You should see both a client version (v3.4.5) and a server version printed. The server version confirms your CLI can reach the Argo CD instance running in your cluster.
Your cluster now has a running Argo CD instance and your two repositories are ready. Next up, you will write the Flask app and deploy it to Kubernetes manually to prove the baseline works.
Deploy Flask and Redis to Kubernetes Manually
Your tools are installed and Argo CD is running on your cluster. Now it's time to build something you can actually see working.
Before any automation, you need a running baseline. This step creates a Flask + Redis hit-counter app, deploys it to Kubernetes manually, and gives you the satisfaction of curling an endpoint and watching the hit count tick up.
In this step, get ready to:
- Write a Flask + Redis hit-counter app and containerize it with Docker.
- Create Kubernetes manifests for both containers and deploy them to your cluster.
- Verify the app returns JSON with a version number and hit count.
Write the Flask app and Dockerfile
The app is a simple Flask web server that connects to Redis and increments a counter on every request. It returns JSON with the app name, a version string, and the current hit count.
- In your WSL terminal, navigate to your pipeline-app repo by running:
cd ~/pipeline-app
- Create app.py with the Flask application code by running:
cat > app.py << 'EOF'
import os
import redis
from flask import Flask, jsonify
app = Flask(__name__)
redis_client = redis.Redis(host=os.environ.get("REDIS_HOST", "redis"), port=6379, decode_responses=True)
VERSION = "1.0.0"
@app.route("/")
def index():
hits = redis_client.incr("hits")
return jsonify({
"app": "pipeline-demo",
"version": VERSION,
"hits": hits
})
@app.route("/health")
def health():
return jsonify({"healthy": True})
if __name__ == "__main__":
app.run(host="0.0.0.0", port=5000)
EOF
What does this code do?
- redis_client connects to a Redis instance using a hostname from the REDIS_HOST environment variable (defaulting to redis).
- The / route increments a counter in Redis and returns a JSON response with the app name, version, and hit count.
- The /health route provides a simple health check endpoint for Kubernetes probes.
- Confirm the file was created by running:
ls app.py
You should see app.py listed in the output.
- Create requirements.txt to pin your Python dependencies by running:
cat > requirements.txt << 'EOF'
flask==3.1.3
redis==5.3.1
EOF
- Create the Dockerfile to containerize the app by running:
cat > Dockerfile << 'EOF'
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app.py .
EXPOSE 5000
CMD ["python", "app.py"]
EOF
What does this Dockerfile do?
- It starts from a lightweight python:3.12-slim base image to keep the container small.
- It copies requirements.txt first and installs dependencies. This ordering lets Docker cache the install layer as long as your dependencies don't change.
- It copies app.py and exposes port 5000 for the Flask server.
- Confirm all three files exist by running:
ls
You should see app.py, Dockerfile, and requirements.txt in the output.
Missing a file?
If ls does not show all three files, re-run the cat > filename << 'EOF' command for the missing file. Make sure you include the closing EOF on its own line.
Help me figure out why my heredoc file creation failed in WSL.
- Build the Docker image by running:
docker build -t pipeline-app:1.0.0 .
You should see Docker download the base image layers and install your dependencies. The final output ends with a line tagging your image as pipeline-app:1.0.0.
Build failing?
Make sure Docker Desktop is running. If you see a permissions error, confirm your WSL user is in the docker group.
If the build fails on pip install, double-check that requirements.txt contains exactly flask==3.1.3 and redis==5.3.1 with no extra characters.
Help me fix my Docker build error.
Write Kubernetes manifests and deploy
Your image is built locally, but Kubernetes needs to find it. For this first manual deploy, you will tag the image with your GHCR path and tell Kubernetes to use the local copy (imagePullPolicy: Never).
- Navigate to your pipeline-manifests repo by running:
cd ~/pipeline-manifests
- Tag the image with your GitHub Container Registry path by running:
docker tag pipeline-app:1.0.0 ghcr.io/[[GITHUB_USERNAME="your-github-username"]]/pipeline-app:1.0.0
Why tag with a GHCR path now?
In a later step, CI will push images to ghcr.io. By tagging the local image the same way now, your Kubernetes manifests already reference the correct path. The imagePullPolicy: Never setting tells Kubernetes to skip pulling from the registry and use the local image instead.
- Create deployment.yaml with the Flask deployment by running:
cat > deployment.yaml << 'EOF'
apiVersion: apps/v1
kind: Deployment
metadata:
name: pipeline-app
spec:
replicas: 2
selector:
matchLabels:
app: pipeline-app
template:
metadata:
labels:
app: pipeline-app
spec:
containers:
- name: flask
image: ghcr.io/your-username-here/pipeline-app:1.0.0
ports:
- containerPort: 5000
env:
- name: REDIS_HOST
value: "redis"
imagePullPolicy: Never
EOF
What does this deployment do?
- It creates 2 replicas of the Flask container for basic redundancy.
- The REDIS_HOST environment variable is set to redis, which is the Kubernetes Service name you will create shortly. Kubernetes DNS resolves this name to the Redis pod.
- imagePullPolicy: Never forces Kubernetes to use the image already in Docker Desktop's cache.
Replace your-username-here in the image field with your actual GitHub username (lowercase) before saving the file.
- Now append the Redis deployment to the same file by running:
cat >> deployment.yaml << 'EOF'
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: redis
spec:
replicas: 1
selector:
matchLabels:
app: redis
template:
metadata:
labels:
app: redis
spec:
containers:
- name: redis
image: redis:7-alpine
ports:
- containerPort: 6379
EOF
What does the Redis deployment do?
This runs a single replica of redis:7-alpine, a lightweight official Redis image. Your Flask app connects to this pod via the redis Service you will create next.
- Create service.yaml to expose both deployments inside the cluster by running:
cat > service.yaml << 'EOF'
apiVersion: v1
kind: Service
metadata:
name: pipeline-app
spec:
selector:
app: pipeline-app
ports:
- port: 5000
targetPort: 5000
type: ClusterIP
---
apiVersion: v1
kind: Service
metadata:
name: redis
spec:
selector:
app: redis
ports:
- port: 6379
targetPort: 6379
type: ClusterIP
EOF
What do these services do?
- The pipeline-app Service routes traffic on port 5000 to your Flask pods. You will port-forward this to your local machine.
- The redis Service gives your Flask pods a stable DNS name (redis) to reach the Redis pod on port 6379.
- Deploy everything to your cluster by running:
kubectl apply -f .
You should see output confirming both deployments and both services were created.
- Wait for the pods to be ready by running:
kubectl wait --for=condition=ready pod --all --timeout=60s
You should see each pod reported as meeting the ready condition.
Pods stuck in ImagePullBackOff?
This means Kubernetes is trying to pull the image from a registry instead of using the local copy. Confirm that deployment.yaml has imagePullPolicy: Never on the Flask container.
Also verify the image name in deployment.yaml matches the tag you created with docker tag (your GitHub username must be lowercase).
Help me debug why my pods are stuck in ImagePullBackOff.
- Port-forward the Flask service to your local machine by running:
kubectl port-forward svc/pipeline-app 5000:5000 &
What does the ampersand do?
The & at the end sends the port-forward process to the background so you can keep using the same terminal. You will see a line like Forwarding from 127.0.0.1:5000 -> 5000 printed, then your prompt returns.
Before you test, what do you expect to see when you curl the app? Think about what JSON fields the code returns.
- Test the app by running:
curl http://localhost:5000/
You should see a JSON response like this:
{"app":"pipeline-demo","version":"1.0.0","hits":1}
- Run the curl command again.
- The hits value increments each time, proving Redis is storing state across requests.
Seeing Connection refused?
If curl returns Connection refused, the port-forward may not be running. Run jobs to check if it's still active. If it says "Done" or nothing appears, re-run the port-forward command.
If you see a response but the hits value is not incrementing, check that the redis Service exists by running kubectl get svc redis.
Help me debug why curl to my port-forwarded Flask app returns Connection refused.
✔️ Awesome, I've got everything!
Great. Double check you have saved all your files and your app is responding to curl requests.
ⓧ I'd like to double check the full code
Your deployment.yaml is over 30 lines, so confirm both sections are present by scrolling through the file. You should see the pipeline-app Deployment (with imagePullPolicy: Never), then a --- separator, then the redis Deployment.
Here is the full service.yaml for comparison:
apiVersion: v1
kind: Service
metadata:
name: pipeline-app
spec:
selector:
app: pipeline-app
ports:
- port: 5000
targetPort: 5000
type: ClusterIP
---
apiVersion: v1
kind: Service
metadata:
name: redis
spec:
selector:
app: redis
ports:
- port: 6379
targetPort: 6379
type: ClusterIP
And the full app.py:
import os
import redis
from flask import Flask, jsonify
app = Flask(__name__)
redis_client = redis.Redis(host=os.environ.get("REDIS_HOST", "redis"), port=6379, decode_responses=True)
VERSION = "1.0.0"
@app.route("/")
def index():
hits = redis_client.incr("hits")
return jsonify({
"app": "pipeline-demo",
"version": VERSION,
"hits": hits
})
@app.route("/health")
def health():
return jsonify({"healthy": True})
if __name__ == "__main__":
app.run(host="0.0.0.0", port=5000)
And Dockerfile:
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app.py .
EXPOSE 5000
CMD ["python", "app.py"]
Your app is live on the cluster. You deployed it by hand with kubectl apply, and you will keep doing that until you feel the pain of repeating it. Next up, you will automate the build-and-push half with GitHub Actions.
Set Up GitHub Actions CI
Your Flask + Redis app is running on Kubernetes and responding to requests. Every time you change your code, you currently have to rebuild the Docker image and push it manually.
GitHub Actions can automate the entire build-and-push cycle. In this step, you will create a CI workflow that builds your image and pushes it to GitHub Container Registry on every push to main.
In this step, get ready to:
- Create a GitHub Actions workflow that builds and pushes Docker images to GHCR.
- Add a manifest update step that automatically updates your deployment YAML.
- Push the workflow and verify CI runs successfully.
Create the GitHub Actions workflow
GitHub Actions workflows live in a special directory inside your repository. The workflow file defines what triggers the automation, what permissions it needs, and what steps it runs.
- In your WSL terminal, make sure you are inside the pipeline-app directory.
- Create the workflow directory structure by running this command:
mkdir -p .github/workflows
- Confirm the directory exists by running:
ls .github/workflows
You should see an empty directory listed with no errors.
- Open pipeline-app in VS Code and create a new file at .github/workflows/build-push.yaml.
- Paste the following workflow header into build-push.yaml:
name: Build and Push
on:
push:
branches: [main]
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository_owner }}/pipeline-app
jobs:
build-and-push:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
What does this code do?
- on: push: branches: [main] triggers the workflow every time you push to the main branch.
- env sets reusable variables for the container registry address and your image name.
- permissions grants the workflow read access to your code and write access to push packages to GHCR.
- Add the workflow steps below the permissions block (still inside the build-and-push job):
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Log in to the Container registry
uses: docker/login-action@v4
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push Docker image
uses: docker/build-push-action@v7
with:
context: .
push: true
tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}
What does this code do?
- actions/checkout@v6 clones your repository so the workflow can access your Dockerfile and source code.
- docker/login-action@v4 authenticates to GHCR using the built-in GITHUB_TOKEN secret.
- docker/build-push-action@v7 builds your image from the Dockerfile and pushes it, tagged with the git commit SHA for traceability.
- Save build-push.yaml.
Seeing YAML indentation errors in VS Code?
YAML is whitespace-sensitive. Make sure steps: is indented 4 spaces under build-and-push:, and each - name: is indented 6 spaces.
If your file has red squiggles, check that you used spaces and not tabs. Help me fix YAML indentation in my GitHub Actions workflow.
Add a manifest update step to the workflow
CI can build and push your image, but the Kubernetes cluster does not know about it yet. You need the workflow to also update your manifests repo with the new image tag so that the cluster can eventually pick it up.
Why does this need a Personal Access Token?
The built-in GITHUB_TOKEN can only access the repository that triggered the workflow. Since the manifests live in a separate repo (pipeline-manifests), you need a Personal Access Token with repo scope that can push to it.
First, create the token on GitHub:
- On GitHub, click your profile picture in the upper-right corner, then click Settings.
- In the left sidebar, click Developer settings.
- Under Personal access tokens, click Tokens (classic).
- Click Generate new token, then click Generate new token (classic).
- In the Note field, enter pipeline-manifests-push.
- Under Scopes, check the repo checkbox to grant full repository access.
- Click Generate token.
- Copy the token value immediately. You will not be able to see it again.
Next, store the token as a secret in your pipeline-app repository:
- Navigate to your pipeline-app repository on GitHub.
- Click Settings under the repository name.
- In the left sidebar under Security, select Secrets and variables, then click Actions.
- Click the Secrets tab.
- Click New repository secret.
- In the Name field, enter PAT_TOKEN.
- In the Secret field, paste the token you copied.
- Click Add secret.
You should see PAT_TOKEN listed in your repository secrets.
- Back in VS Code, open .github/workflows/build-push.yaml and add this step at the end of the steps: list (below the Build and push step):
- name: Update manifests repo
run: |
git clone https://x-access-token:${{ secrets.PAT_TOKEN }}@github.com/${{ github.repository_owner }}/pipeline-manifests.git
cd pipeline-manifests
sed -i "s|image:.*|image: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}|" deployment.yaml
git config user.name "github-actions"
git config user.email "github-actions@github.com"
git add deployment.yaml
git commit -m "Update image to ${{ github.sha }}"
git push
What does this step do?
After the image is built and pushed, this step clones the pipeline-manifests repo using your PAT for authentication.
The sed command replaces the image: line in deployment.yaml with the new image tag (the git SHA), then commits and pushes the change. This keeps your manifests repo as the single source of truth for what version should be deployed.
- Save build-push.yaml.
✔️ Awesome, I've got everything!
Great. Double check you have saved the file.
ⓧ I'd like to double check the full code
Your build-push.yaml should contain these sections in order:
- The workflow header (name, trigger, env vars).
- The build-and-push job with permissions (contents: read, packages: write).
- Four steps: Checkout repository, Log in to the Container registry, Build and push Docker image, Update manifests repo.
Scroll up and confirm each code block is present in your file and the indentation is consistent.
Push and watch CI run
Your workflow is ready. Pushing it to main will trigger the workflow for the first time.
- Commit and push the workflow file by running these commands in your WSL terminal from the pipeline-app directory:
git add .github/workflows/build-push.yaml
git commit -m "Add CI workflow"
git push origin main
Before you check the result, what do you expect to happen? The workflow should trigger, build your Docker image from the Dockerfile, push it to GHCR, then clone and update your manifests repo.
- Open your pipeline-app repository on GitHub in your browser.
- Click the Actions tab.
- You should see a workflow run in progress (or completed with a green checkmark). Click on it to watch the steps execute.
What should I see?
Each step in the workflow shows its logs. The "Build and push Docker image" step will show the Docker build output. The "Update manifests repo" step will show the git clone, sed replacement, and git push.
A green checkmark on the workflow run means all steps passed.
- After the workflow completes with a green checkmark, set the package visibility to public so Kubernetes can pull the image without authentication.
- On GitHub, click your profile picture, then click Your profile.
- Click the Packages tab on your profile page.
- Click the pipeline-app package.
- On the package landing page, click Package settings on the right-hand side.
- Scroll to the Danger Zone and click Change visibility.
- Select Public, type the package name to confirm, and click I understand the consequences, change package visibility.
- Finally, verify that CI updated your manifests repo. Open your pipeline-manifests repository on GitHub.
- Click on deployment.yaml and check the image: line. It should now contain a long git SHA tag instead of 1.0.0.
Workflow failed with a red X?
- Click the failed run, then click the failed job to see logs. Look for the first red error message.
- If the "Update manifests repo" step failed with a 403 or authentication error, confirm your PAT_TOKEN secret is correctly named and that the token has repo scope.
- If the "Build and push" step failed, check that your Dockerfile builds successfully by running docker build . locally.
Help me debug my GitHub Actions workflow failure.
Your CI pipeline is live. Every push to main now builds, pushes, and updates your manifests automatically. Next up, you will discover the gap between CI updating manifests and your cluster actually running the new version.
Experience the GitOps Gap
Your CI pipeline is working. Every push to main builds a fresh image, pushes it to GHCR, and updates your manifests repo automatically.
But does updating manifests in a repo mean your cluster is running the new code? In this step, you'll push a version bump and discover what happens when CI stops at the manifests repo door.
In this step, get ready to:
- Bump your app version and trigger a CI build.
- Discover whether CI alone deploys changes to your cluster.
- Manually bridge the gap to prove the problem.
Bump the version and push
To test whether your pipeline deploys changes end-to-end, you need a visible code change. A version bump is the simplest signal because you can confirm the deployed version with a single curl.
- In VS Code, open app.py in your pipeline-app folder.
- Find this line:
VERSION = "1.0.0"
- Change "1.0.0" to "2.0.0", so the line looks like this:
VERSION = "2.0.0"
- Save app.py.
- Commit and push this change to main by running these commands in your WSL terminal:
cd ~/Desktop/pipeline-app
git add app.py
git commit -m "Bump version to 2.0.0"
git push origin main
What does this trigger?
Pushing to main fires your GitHub Actions workflow. It builds a new Docker image with version 2.0.0, pushes it to GHCR, then clones your manifests repo and updates the image tag in deployment.yaml to the new git SHA.
- Open your pipeline-app repository on GitHub in your browser.
- Click the Actions tab.
- Wait for the workflow run to complete with a green checkmark. This takes about 1-2 minutes.
Workflow failed?
Click on the failed run to see the logs. The most common issue is the PAT_TOKEN secret missing or expired. Confirm the secret exists in your repo's Settings > Secrets and variables > Actions page.
Help me debug my GitHub Actions workflow failure.
Observe the gap
CI completed successfully. Your new image is in GHCR and the manifests repo has the updated tag. Time to check whether your cluster is actually running the new version.
Before you run the next command, take a moment to predict: do you think curl will return version 1.0.0 or 2.0.0? Why?
- Curl your running app by running this command:
curl http://localhost:5000/
You still see version 1.0.0.
{"app":"pipeline-demo","hits":3,"version":"1.0.0"}
CI ran. The image was pushed. The manifests repo was updated. But your cluster is still serving the old version. This is the gap.
- Confirm CI did its job by opening your pipeline-manifests repo on GitHub.
- Click on deployment.yaml.
- Look at the image: line. It now has a new SHA tag from your latest push.
What just happened?
Your CI pipeline did everything it was designed to do. It built the new image, pushed it to the registry, and updated the manifests file with the new tag.
But nobody told your Kubernetes cluster to pull those updated manifests. The cluster has no idea deployment.yaml changed. You would have to run kubectl apply manually every single time. This is the problem that GitOps solves.
Manually bridge the gap
To prove the new version works, you'll do what a team without GitOps does: manually apply the updated manifests yourself.
- First, pull the updated manifests that CI changed by running:
cd ~/Desktop/pipeline-manifests
git pull origin main
- You should see deployment.yaml listed in the updated files.
- Apply the updated manifests to your cluster by running:
kubectl apply -f .
What does this command do?
This tells Kubernetes to reconcile its running state with the YAML files in the current directory. It sees the new image tag in deployment.yaml and starts rolling out new pods with the updated image.
- Wait for the new pods to be ready by running:
kubectl rollout status deployment/pipeline-app
- Stop any existing port-forward with Ctrl+C, then restart it by running:
kubectl port-forward svc/pipeline-app 5000:5000 &
Now, what do you expect to see when you curl the app this time?
- Test the deployed version by running:
curl http://localhost:5000/
You now see version 2.0.0 in the response.
{"app":"pipeline-demo","hits":1,"version":"2.0.0"}
It works. But you had to pull, apply, and port-forward manually. Imagine doing this ten times a day across five services. That manual step is the gap that Argo CD eliminates.
Still seeing version 1.0.0?
Make sure you ran git pull origin main in the pipeline-manifests directory before applying. Without pulling, you applied the old manifests.
Also confirm your port-forward is targeting the correct service. If you have multiple port-forwards running, kill them all with pkill -f port-forward and restart.
Help me debug why my Kubernetes deployment is still showing the old version after kubectl apply.
✔️ Awesome, I've got everything!
Great. Double check you've saved app.py and that your cluster is running version 2.0.0.
ⓧ I'd like to double check the full code
Here is the full app.py with the version bump:
import os
import redis
from flask import Flask, jsonify
app = Flask(__name__)
redis_client = redis.Redis(host=os.environ.get("REDIS_HOST", "redis"), port=6379, decode_responses=True)
VERSION = "2.0.0"
@app.route("/")
def index():
hits = redis_client.incr("hits")
return jsonify({
"app": "pipeline-demo",
"version": VERSION,
"hits": hits
})
@app.route("/health")
def health():
return jsonify({"healthy": True})
if __name__ == "__main__":
app.run(host="0.0.0.0", port=5000)
You've just proven the gap that every team hits when they only have CI without GitOps. The manifests are updated, but the cluster doesn't know. Next up, you'll wire Argo CD to watch your manifests repo and close this loop permanently.
Wire Up Argo CD for Automated Deployment
In the last step, you experienced the GitOps gap firsthand. CI updated your manifests repo with the new image tag, but your cluster stayed stuck on the old version.
Argo CD fills that gap. It watches your manifests repository and automatically applies any changes to your cluster. Once wired up, a code push triggers the full loop: build, push, update manifests, deploy. No manual kubectl apply required.
In this step, get ready to:
- Create an Argo CD Application that watches your manifests repo.
- Switch your deployment to pull images from GHCR instead of the local cache.
- Push a version bump and watch the entire pipeline deploy it automatically.
Create an Argo CD Application pointing at your manifests repo
An Argo CD Application is a resource that tells Argo CD which Git repository to watch and where to deploy its contents.
- Make sure the Argo CD port-forward from earlier is still running on localhost:8080. If the terminal closed, restart it by running:
- If you need to log in again, run the login command from Step 1.
kubectl port-forward svc/argocd-server -n argocd 8080:443 &
- Create the Argo CD Application by running this command:
argocd app create pipeline-app --repo https://github.com/[[GITHUB_USERNAME="your-github-username"]]/pipeline-manifests.git --path . --dest-server https://kubernetes.default.svc --dest-namespace default --sync-policy automated
What does this command do?
- --repo points Argo CD at your pipeline-manifests repository on GitHub.
- --path . tells it to look at the root of the repo for Kubernetes YAML.
- --dest-server and --dest-namespace specify where to deploy: your local cluster, default namespace.
- --sync-policy automated means Argo CD will auto-apply any change it detects in the repo. No manual sync needed.
- Verify the application was created by running:
argocd app get pipeline-app
You should see the application listed with a sync status and health status. It may show as Synced or OutOfSync depending on whether the current cluster state matches the manifests.
Seeing a connection error?
If you get a connection refused error, your Argo CD port-forward may have stopped. Re-run the port-forward command above, then try again.
If you see an authentication error, re-run the login command from Step 1: argocd login localhost:8080 --insecure --username admin --password <your-password>.
help me fix my Argo CD connection
Update the deployment to pull from GHCR
Your deployment currently uses imagePullPolicy: Never, which tells Kubernetes to only use images already in its local cache. For Argo CD to deploy new images that CI pushes to GHCR, Kubernetes needs permission to pull from the remote registry.
- In pipeline-manifests/deployment.yaml, find this line in the Flask container section:
imagePullPolicy: Never
- Change Never to IfNotPresent, so the line now looks like this:
imagePullPolicy: IfNotPresent
- Commit and push this change by running these commands from the pipeline-manifests directory:
git add deployment.yaml
git commit -m "Switch to IfNotPresent image pull policy"
git push
Argo CD will detect this push within a few seconds and auto-sync the change to your cluster.
- Confirm the sync happened by running:
argocd app get pipeline-app
You should see the sync status showing Synced and health showing Healthy. Argo CD applied your updated manifests without you running kubectl apply.
Still showing OutOfSync?
Argo CD polls the repo every few minutes by default. If it has not synced yet, wait 30 seconds and run argocd app get pipeline-app again. You can also trigger an immediate sync with argocd app sync pipeline-app.
help me get Argo CD to sync my manifests
Test the full loop
Everything is wired up. Time to prove the entire pipeline works end-to-end by bumping the version in your app code and watching it flow all the way to a running pod.
- Switch to your pipeline-app directory.
- Open app.py and find this line:
VERSION = "2.0.0"
- Change it to:
VERSION = "3.0.0"
- Save app.py, then commit and push by running:
git add app.py
git commit -m "Bump version to 3.0.0"
git push
- Open the pipeline-app repository on GitHub and click the **Actions** tab.
- Wait for the workflow to complete with a green checkmark. This means CI built the new image, pushed it to GHCR, and updated your manifests repo with the new image tag.
What happens behind the scenes?
Your push triggers the GitHub Actions workflow. It builds a Docker image tagged with the new git SHA, pushes it to GHCR, then clones pipeline-manifests and updates deployment.yaml with the new tag.
Argo CD detects the commit to pipeline-manifests and auto-syncs the new deployment spec to your cluster. Kubernetes pulls the new image from GHCR and rolls out new pods.
- After CI completes (about 1-2 minutes), wait another minute for Argo CD to detect and sync the change.
- Your existing port-forward may have dropped because Kubernetes replaced the pods. Restart it by running:
kubectl port-forward svc/pipeline-app 5000:5000
Before you run the next command, think about what you expect to see. Will the version be 2.0.0 or 3.0.0? Did you run kubectl apply at any point?
- Test the app by running:
curl http://localhost:5000/
You should see a JSON response showing "version": "3.0.0" along with a hit count. You never ran kubectl apply. The pipeline is fully automated.
Still seeing version 2.0.0?
Check if CI completed successfully in the Actions tab on GitHub. If the workflow failed, look at the logs for the "Update manifests repo" step.
Check if Argo CD synced by running argocd app get pipeline-app. If it shows OutOfSync, run argocd app sync pipeline-app to trigger a manual sync.
If the sync status is Synced but you still see the old version, your port-forward might be connected to an old pod. Kill it with Ctrl+C and restart: kubectl port-forward svc/pipeline-app 5000:5000.
help me debug my GitOps pipeline
Your GitOps pipeline is fully operational. A single git push now triggers the entire delivery loop: build, push, update manifests, and deploy. Next up, you can extend this with real-time monitoring in the Secret Mission.
Secret mission
Add Prometheus Monitoring
Your pipeline deploys code automatically, but how do you know your app is healthy after each release? In this secret mission, you will layer Prometheus and Grafana onto your cluster to monitor request rates and pod health in real-time.
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely on your local Kubernetes cluster and uses free GitHub services, so there are no ongoing costs.
Resources you used:
- Argo CD running in the argocd namespace on your local Kubernetes cluster.
- Argo CD CLI installed at /usr/local/bin/argocd.
- Kubernetes deployments and services (pipeline-app, redis, Prometheus, Grafana).
- Docker images stored in GHCR (free for public packages).
- Two GitHub repositories (pipeline-app and pipeline-manifests).
- Personal Access Token (PAT) stored as a repository secret.
- Local cloned repositories in WSL (pipeline-app and pipeline-manifests folders).
Keep everything running
No action needed. Choose this if you are still actively building or want to experiment further with your pipeline.
- Your pipeline stays active. Any push to pipeline-app triggers the full build, push, and deploy loop.
- Argo CD continues watching your manifests repo and auto-syncing changes.
- GitHub Actions will use minutes from your free 2,000 minutes/month allocation on each push.
- The Argo CD CLI remains installed at /usr/local/bin/argocd.
Pause - I'll come back to this later
Stop Docker Desktop to free up memory and CPU. Everything stops but state is preserved. Restart Docker Desktop to resume.
- Stop any running port-forward processes by pressing Ctrl+C in the terminals where they are running.
- Quit Docker Desktop. All Kubernetes pods stop, but the cluster state is preserved on disk.
- When you are ready to resume, reopen Docker Desktop and wait for Kubernetes to show a green status indicator.
- Re-run any port-forward commands you need (Argo CD on 8080, Flask on 5000, Grafana on 3000).
Delete - I don't want to use this again
Remove all project resources and start fresh. Follow each step below to clean up completely.
Remove Kubernetes resources
- Stop any running port-forward processes by pressing Ctrl+C in each terminal.
- Delete the Argo CD namespace and all its resources by running:
kubectl delete namespace argocd
- Delete your application deployments and services by running this from inside your local pipeline-manifests folder:
kubectl delete -f .
- Delete any Prometheus and Grafana resources you deployed (if you completed the Secret Mission). The exact command depends on how you installed them. If you used manifests, delete them with kubectl delete -f pointing at your monitoring manifests.
Remove the Argo CD CLI
- Remove the Argo CD CLI binary by running:
sudo rm /usr/local/bin/argocd
Delete local project folders
- Remove the cloned repositories from your machine by running:
cd ~/Desktop
rm -rf pipeline-app pipeline-manifests
Delete the GHCR package
- Go to your GitHub profile page.
- Click the Packages tab.
- Click pipeline-app to open the package page.
- Click Package settings on the right-hand side.
- Scroll to the Danger Zone section and click Delete this package.
- Type the package name to confirm and click I understand, delete this package.
Delete the GitHub repositories
- For each repository (pipeline-app and pipeline-manifests):
- Open the repository on GitHub and click Settings.
- Scroll to the Danger Zone section and click Delete this repository.
- Click I want to delete this repository, read the warnings, then click I have read and understand these effects.
- Type the repository name to confirm and click Delete this repository.
Revoke your Personal Access Token
- Click your profile picture on GitHub and click Settings.
- In the left sidebar, click Developer settings.
- Under Personal access tokens, click Tokens (classic).
- Find the token you created for this project and click Delete.
That's a Wrap!
That's a Wrap!
Nice work! You just built a fully automated end-to-end delivery pipeline where a single git push flows all the way from source code to a running Kubernetes deployment without any manual intervention.
You've learned how to:
- Built a multi-container Flask + Redis application and deployed it to Kubernetes with a GitHub Actions CI pipeline that builds Docker images and pushes them to GitHub Container Registry.
- Experienced the GitOps gap firsthand. CI updated your manifests repo, but your cluster kept running the old version until you manually applied the change.
- Wired Argo CD to automatically sync manifests to your cluster, closing the full delivery loop so code changes deploy without ever running kubectl apply.
- Secret Mission: Added Prometheus and Grafana monitoring to observe deployments and request metrics in real-time.
Ready to quiz yourself?