End-to-End CI/CD Pipeline with GitOps

Build a GitOps pipeline that auto-deploys to Kubernetes on every git push.

Introduction

30 Second Summary

Every time a developer pushes code to a shared repository, someone has to build it, test it, package it, and deploy it. At most companies, this entire process is automated so that code goes from commit to production without a single manual step.

In this project, you will build a complete end-to-end CI/CD pipeline where pushing code to GitHub automatically builds a container image, pushes it to a registry, and deploys it to a local Kubernetes cluster using GitOps.

What You'll Build

A fully automated pipeline using GitHub Actions for continuous integration and ArgoCD for continuous delivery to a local Kubernetes cluster running on kind.

By the end of this project, you'll have:

  • A containerized Python Flask REST API with a multi-stage Docker build.
  • A GitHub Actions CI workflow that automatically builds and pushes images to GitHub Container Registry on every push.
  • A GitOps repository with Kubernetes manifests and ArgoCD watching for changes to auto-sync deployments.
  • A demonstrated self-healing loop where ArgoCD automatically restores desired state after drift.
  • Secret Mission: Add a staging environment with a separate ArgoCD Application and namespace.

Are there any prerequisites?

You need a GitHub account and basic familiarity with running commands in a terminal. No prior experience with Docker, Kubernetes, or CI/CD tools is required.

Install Docker Desktop and CLI Tools

Before you can build containers and deploy to a local cluster, you need the right tools on your machine.

This step installs Docker Desktop for building container images, plus three CLI tools: kind (local Kubernetes clusters), kubectl (cluster commands), and argocd (GitOps deployments).

In this step, get ready to:
  • Install and verify Docker Desktop.
  • Install kind, kubectl, and ArgoCD CLI via Homebrew.
  • Verify all four tools are working.
Download and install Docker Desktop

Docker Desktop provides the Docker engine that builds and runs containers on your Mac. It also provides the docker CLI automatically.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Terminal and press Enter to open it.
  • Check if Docker is already installed by running:
docker --version

✔️ I see a version number

Docker is already installed. Make sure Docker Desktop is running (look for the whale icon in your menu bar), then skip ahead to the next substep.

ⓧ Command not found

  • Open your browser and go to the Docker Desktop for Mac download page:
  • https://docs.docker.com/desktop/setup/install/mac-install/
  • Click the download button for Mac with Apple chip.
  • Once the .dmg file downloads, double-click it to open the installer.
  • Drag Docker.app into the Applications folder.
  • Open Docker Desktop from your Applications folder.
  • Accept the license agreement when prompted.
  • Wait for Docker Desktop to finish starting up. You will see the whale icon appear in your menu bar.

What is Docker Desktop?

Docker Desktop is a GUI application that runs the Docker engine on macOS. It lets you build, run, and manage containers without needing a Linux machine.

It is free for personal use and education.

  • Verify the installation by running:
docker --version

You should see a version number printed, confirming Docker is ready.

Docker command not found after installing?

  • Make sure Docker Desktop is running. The whale icon must be visible in your menu bar.
  • Try closing and reopening your terminal. Docker Desktop adds the docker command to your PATH during startup.

Help me troubleshoot Docker Desktop not being recognized in my terminal.

Install CLI tools via Homebrew

Homebrew is a package manager for macOS that makes it easy to install developer tools from the command line.

  • Check if Homebrew is installed by running:
brew --version

✔️ I see a version number

Homebrew is ready. Continue to the install command below.

ⓧ Command not found

  • Install Homebrew by running this command:
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"

What does this command do?

This downloads and runs the official Homebrew installer script from brew.sh. It will explain what it plans to do and ask for your confirmation before making any changes.

  • Follow the prompts in your terminal. The installer may ask for your password.
  • After installation finishes, the installer prints instructions to add Homebrew to your PATH. Follow those instructions (they typically involve running two commands that start with echo and eval).
  • Close and reopen your terminal, then confirm Homebrew works by running:
brew --version

You should now see a version number.

  • Install kind, kubectl, and the ArgoCD CLI by running:
brew install kind kubectl argocd

What are these tools?

  • kind creates local Kubernetes clusters using Docker containers as nodes. No cloud account needed.
  • kubectl is the standard CLI for interacting with any Kubernetes cluster.
  • argocd is the CLI for managing ArgoCD, the GitOps controller that auto-deploys from Git.

Homebrew will download and install all three tools. This may take a minute or two.

Seeing permission errors during install?

  • If you see errors about permissions on /opt/homebrew, try running:
sudo chown -R $(whoami) /opt/homebrew
  • Then retry the install command.
  • If a specific formula fails, try installing them one at a time to identify which one has the issue.

Help me fix Homebrew permission errors on my Mac.

Verify all tools are installed
  • Confirm each tool is working by running these commands one at a time:
kind version
kubectl version --client
argocd version --client

✔️ All three commands print version info

All tools are installed and ready. You are set up for the rest of this project.

ⓧ One or more commands say command not found

  • If a specific tool shows the error, try reinstalling just that tool.
  • For kind, run:
brew install kind
  • For kubectl, run:
brew install kubectl
  • For argocd, run:
brew install argocd
  • If the issue persists, close and reopen your terminal to refresh your PATH.

Help me troubleshoot why a Homebrew-installed CLI tool is not found.

What should I see?

  • kind version prints something like kind v0.32.0.
  • kubectl version --client prints a Client Version line with a version number.
  • argocd version --client prints the ArgoCD CLI version and build info.

Your development environment is ready. Next up, you will build a Flask application and package it into a Docker container.

Create the Flask App and Dockerfile

Your development environment is ready with Docker, kind, kubectl, and ArgoCD all installed. Now it's time to build the application that will flow through your entire pipeline.

You'll create a simple Python Flask REST API. The app itself is intentionally minimal because the focus of this project is the DevOps pipeline, not the application code.

In this step, get ready to:
  • Create the cicd-app project directory and initialize a Git repository.
  • Write a Flask REST API with health and version endpoints.
  • Containerize the app with a production-ready multi-stage Dockerfile.
Create the project directory and Flask app

Every pipeline starts with source code in a Git repository. You'll create a project folder on your Desktop and initialize it as a Git repo so it's ready to push to GitHub later.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder, move into it, and initialize a Git repository by running these commands:
mkdir cicd-app
cd cicd-app
git init

You should see a message saying Initialized empty Git repository followed by the path to your new .git folder.

Seeing command not found?

Make sure you have Git installed. Run git --version to check. If it's missing, install it with brew install git and try again.

help me install git on my Mac

  • Open your code editor by pressing Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type VS Code (or Cursor) and press Enter to open it.
  • Click File in the top menu bar.
  • Click Open Folder.
  • Select the cicd-app folder on your Desktop, then click Open.

Now you'll create the Flask application file. This is a tiny API that returns JSON responses at two endpoints.

  • Create a new file called app.py in the cicd-app folder and paste the following code:
import os
from flask import Flask, jsonify

# Create the Flask application
app = Flask(__name__)

# Read version from environment variable, default to 1.0.0
VERSION = os.environ.get("APP_VERSION", "1.0.0")

@app.route("/health")
def health():
    # Return health status and current version
    return jsonify({"status": "healthy", "version": VERSION})

@app.route("/")
def index():
    # Return a welcome message with version info
    return jsonify({"message": "CI/CD Pipeline Demo", "version": VERSION})

if __name__ == "__main__":
    # Listen on all interfaces so Docker can forward traffic
    app.run(host="0.0.0.0", port=5000)

What does this code do?

  • VERSION reads from the APP_VERSION environment variable. This lets you change the version at deploy time without editing code.
  • /health is a standard health check endpoint. Kubernetes and Docker use it to know if your app is alive.
  • host="0.0.0.0" makes the app listen on all network interfaces, not just localhost. This is required for Docker to forward traffic into the container.

Why Flask instead of Node.js?

Flask produces the smallest, simplest container image with zero build tooling. There is no bundler, no npm, and no compile step.

The application is a means to demonstrate the pipeline, not the goal itself. A simpler app keeps the focus on DevOps.

  • Save app.py.

You won't be able to run this file yet because Flask isn't installed locally. You'll run it inside a Docker container shortly.

✔️ Awesome, I've got everything!

Great. Double check you've saved app.py and move on to the Dockerfile.

ⓧ I'd like to double check the full code

import os
from flask import Flask, jsonify

app = Flask(__name__)

VERSION = os.environ.get("APP_VERSION", "1.0.0")

@app.route("/health")
def health():
    return jsonify({"status": "healthy", "version": VERSION})

@app.route("/")
def index():
    return jsonify({"message": "CI/CD Pipeline Demo", "version": VERSION})

if __name__ == "__main__":
    app.run(host="0.0.0.0", port=5000)
Create the Dockerfile

A Dockerfile is a recipe that tells Docker how to package your app into a container image. You'll use a multi-stage build pattern that keeps the final image small and secure.

  • Create a new file called Dockerfile (no extension) in the cicd-app folder and paste the following code:
# Stage 1: Install dependencies in a temporary builder image
FROM python:3.12-slim AS builder
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

# Stage 2: Create the final production image
FROM python:3.12-slim
WORKDIR /app
# Create a non-root user for security
RUN useradd -r -s /bin/false appuser
# Copy only the installed packages from the builder stage
COPY --from=builder /usr/local/lib/python3.12/site-packages /usr/local/lib/python3.12/site-packages
COPY --from=builder /usr/local/bin /usr/local/bin
COPY app.py .
# Run as non-root user
USER appuser
EXPOSE 5000
# Health check so Docker knows the container is alive
HEALTHCHECK --interval=30s --timeout=3s CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:5000/health')" || exit 1
CMD ["python", "app.py"]

What does this Dockerfile do?

  • Stage 1 (builder) installs Python packages into a temporary image. This stage gets thrown away after the build.
  • Stage 2 starts from a fresh python:3.12-slim image and copies only the installed packages from the builder. This keeps the final image small.
  • USER appuser runs the app as a non-root user. This is a security best practice that prevents a compromised container from having root access.
  • HEALTHCHECK tells Docker to ping /health every 30 seconds. If it fails 3 times, Docker marks the container as unhealthy.
  • Save Dockerfile.

You won't build the image yet. The Dockerfile references requirements.txt which you'll create next.

✔️ Awesome, I've got everything!

Great. Double check you've saved Dockerfile and move on to building your image.

ⓧ I'd like to double check the full code

FROM python:3.12-slim AS builder
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

FROM python:3.12-slim
WORKDIR /app
RUN useradd -r -s /bin/false appuser
COPY --from=builder /usr/local/lib/python3.12/site-packages /usr/local/lib/python3.12/site-packages
COPY --from=builder /usr/local/bin /usr/local/bin
COPY app.py .
USER appuser
EXPOSE 5000
HEALTHCHECK --interval=30s --timeout=3s CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:5000/health')" || exit 1
CMD ["python", "app.py"]
Build and test the Docker image locally

The Dockerfile needs a requirements.txt file to know which Python packages to install. Then you'll build the image and run it to confirm everything works before pushing it through a pipeline.

  • Create a new file called requirements.txt in the cicd-app folder and paste this single line:
Flask==3.1.3

Why pin the version?

Pinning Flask==3.1.3 ensures every build uses the exact same version. Without a pin, a future pip install could pull a newer version that breaks your app.

  • Save requirements.txt.
  • Confirm all three files exist by running this in your terminal:
ls

You should see Dockerfile, app.py, and requirements.txt in the output.

  • Build the Docker image by running this command:
docker build -t cicd-app:local .

What does this command do?

  • docker build reads your Dockerfile and executes each step to create an image.
  • -t cicd-app:local tags the image with the name cicd-app and the tag local so you can reference it easily.
  • . tells Docker to use the current directory as the build context (where it looks for Dockerfile and files to copy).

The build takes 30-60 seconds the first time as Docker downloads the python:3.12-slim base image. Wait until you see a line ending in FINISHED.

Build failing with an error?

  • Make sure Docker Desktop is running. You should see the Docker whale icon in your menu bar.
  • Check that your terminal is inside the cicd-app folder. Run pwd to confirm.
  • Verify requirements.txt contains exactly Flask==3.1.3 with no typos or extra spaces.

help me debug my Docker build error

  • Run the container by executing this command:
docker run -p 5000:5000 cicd-app:local

What does this command do?

-p 5000:5000 maps port 5000 on your Mac to port 5000 inside the container. This lets you access the app at http://localhost:5000 from your browser.

  • Open your browser and go to http://localhost:5000/health.

You should see a JSON response like this:

{"status": "healthy", "version": "1.0.0"}

Seeing connection refused?

  • Wait a few seconds after running the container for the Flask app to start.
  • Check your terminal for errors. If you see ModuleNotFoundError, the build step may not have installed Flask properly. Rebuild with docker build -t cicd-app:local . and try again.
  • Make sure nothing else is using port 5000. On macOS, AirPlay Receiver sometimes uses this port. Disable it in System Settings > AirDrop & Handoff.

help me fix my Docker container connection issue

  • Go back to your terminal and press Ctrl+C to stop the running container.

Your Flask app is containerized and tested locally. Next up, you'll push this code to GitHub and set up a CI pipeline that builds this image automatically on every push.

Set Up GitHub Actions CI Pipeline

Your Docker image builds and runs perfectly on your machine. But what happens when you push new code tomorrow, or next week? You would need to manually rebuild and re-upload the image every single time.

GitHub Actions solves this by automating the entire build-and-push process. Every time you push code to your main branch, a workflow kicks off that builds a fresh Docker image and stores it in GitHub Container Registry. No manual steps required.

In this step, get ready to:
  • Create a public GitHub repository and push your app code.
  • Write a GitHub Actions CI workflow that builds and pushes your Docker image.
  • Verify the automated pipeline runs successfully.
Create the GitHub repository and push your code

Your app code lives locally right now. To trigger CI workflows, you need the code hosted on GitHub where GitHub Actions can detect pushes.

  • Open your browser and go to github.com.
  • In the upper-right corner of any page, click the + menu, then select New repository.
  • Type cicd-app as the repository name.
  • Select Public for the repository visibility.
  • Leave all other options unchecked (no README, no .gitignore, no license).
  • Click Create repository.

Why leave the repo empty?

You already have local files committed in your cicd-app directory. Adding a README or .gitignore during creation would cause a merge conflict when you push. Starting empty keeps the push clean.

GitHub shows you a quick setup page after creating the repository. Now connect your local code to this remote repository.

  • Switch back to your terminal. Make sure you are inside the cicd-app directory.
  • Stage and commit all your files by running these commands:
git add .
git commit -m "Initial commit: Flask app with Dockerfile"
  • Connect the GitHub repository as your remote and push by running these commands:
git branch -M main
git remote add origin https://github.com/[[GITHUB_USERNAME="your-github-username"]]/cicd-app.git
git push -u origin main

What do these commands do?

  • git branch -M main renames your current branch to main (GitHub's default branch name).
  • git remote add origin links your local repo to the GitHub repository you just created.
  • git push -u origin main uploads all your committed code to GitHub.
  • Refresh your GitHub repository page in the browser. You should see app.py, Dockerfile, and requirements.txt listed.

Push failed with authentication error?

GitHub requires token-based authentication for HTTPS pushes. If prompted for a password, use a Personal Access Token (not your GitHub password). You can create one at Settings > Developer settings > Personal access tokens on GitHub.

Alternatively, switch to SSH by changing your remote URL to the SSH format shown on your repository's quick setup page.

Help me fix my GitHub push authentication error.

Create the CI workflow file

GitHub Actions looks for workflow definitions inside a .github/workflows/ directory in your repository. Each YAML file in that folder defines an automated pipeline.

  • Create the workflow directory structure by running this command:
mkdir -p .github/workflows
  • Create a new file called ci.yaml inside .github/workflows/ in your code editor, then paste the following code:
name: CI Pipeline

on:
  push:
    branches: [main]

permissions:
  contents: read
  packages: write

env:
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}

jobs:
  build-and-push:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Log in to GitHub Container Registry
        uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Build and push Docker image
        uses: docker/build-push-action@v6
        with:
          context: .
          push: true
          tags: |
            ghcr.io/${{ env.IMAGE_NAME }}:${{ github.sha }}
            ghcr.io/${{ env.IMAGE_NAME }}:latest

What does this workflow do?

  • on: push: branches: [main] triggers the workflow every time you push to the main branch.
  • permissions grants the workflow read access to your code and write access to publish packages.
  • docker/login-action@v3 authenticates to ghcr.io using the built-in GITHUB_TOKEN secret. No manual token setup needed.
  • docker/build-push-action@v6 builds your Dockerfile and pushes the image with two tags: the exact git commit SHA (for traceability) and latest (for convenience).

Why ghcr.io over Docker Hub?

GitHub Container Registry integrates natively with GitHub Actions via the built-in GITHUB_TOKEN. You do not need to create or manage separate registry credentials. Everything stays in one platform.

  • Save .github/workflows/ci.yaml.

✔️ Awesome, I've got everything!

Great. Double check you have saved the file before moving on.

ⓧ I'd like to double check the full code

name: CI Pipeline

on:
  push:
    branches: [main]

permissions:
  contents: read
  packages: write

env:
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}

jobs:
  build-and-push:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Log in to GitHub Container Registry
        uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Build and push Docker image
        uses: docker/build-push-action@v6
        with:
          context: .
          push: true
          tags: |
            ghcr.io/${{ env.IMAGE_NAME }}:${{ github.sha }}
            ghcr.io/${{ env.IMAGE_NAME }}:latest
Push the workflow and verify it runs

The workflow triggers on push to main. Committing and pushing the new file is all it takes to kick off the first automated build.

  • Commit and push the workflow file by running these commands:
git add .github/workflows/ci.yaml
git commit -m "Add CI pipeline workflow"
git push
  • In your browser, navigate to your cicd-app repository on GitHub.
  • Click Actions in the repository navigation bar.
  • In the left sidebar, click CI Pipeline.
  • Click the workflow run (named after your commit message). Wait for it to complete with a green checkmark.

What should I see?

The workflow run page shows the build-and-push job with three steps: Checkout code, Log in to GitHub Container Registry, and Build and push Docker image. Each step should have a green checkmark when complete.

  • Confirm the image was published by clicking your profile avatar in the top-right corner, then selecting Your packages. You should see cicd-app listed.

Workflow failed with permission denied?

If you see a permissions error on the "Log in to GitHub Container Registry" step, make sure your repository is public. The GITHUB_TOKEN has write access to packages for public repositories by default.

If the repository is public and you still see an error, check that the permissions block in your YAML includes packages: write.

Help me debug my GitHub Actions workflow permission error.

Your CI pipeline is live. Every push to main now automatically builds and publishes a Docker image. Next up, you will create a separate GitOps repository that holds your Kubernetes deployment manifests.

Create the GitOps Repository

Your CI pipeline automatically builds and pushes container images on every push. Now you need a dedicated place for your deployment configuration to live.

In this step, you'll create a second GitHub repository that holds only Kubernetes manifests. This is the repo that ArgoCD will watch to know exactly what should be running in your cluster.

In this step, get ready to:
  • Create a second GitHub repository for GitOps manifests.
  • Write Kubernetes Deployment and Service manifests.
  • Push the manifests to GitHub.
Create the cicd-gitops repository on GitHub

Your application code lives in cicd-app. Your deployment configuration needs its own home.

  • In your browser, click the + icon in the top-right corner of GitHub.
  • Select New repository.
  • Enter cicd-gitops as the repository name.
  • Select Public for visibility.
  • Leave all other options unchecked (no README, no .gitignore, no license).
  • Click Create repository.

Why a separate repository?

Mixing application code and infrastructure manifests in one repo creates circular CI triggers. Every time CI updates a manifest, it triggers another CI run, which updates the manifest again.

Separate repos cleanly delineate CI (build) from CD (deploy). Your cicd-app repo owns the code and build. Your cicd-gitops repo owns what gets deployed and how.

  • Return to your terminal and move to your Desktop by running this command:
cd ~/Desktop
  • Clone the new empty repository by running this command:
git clone https://github.com/[[YOUR_USERNAME="your-github-username"]]/cicd-gitops.git
  • Move into the new repository folder by running this command:
cd cicd-gitops

Clone failing with 'not found'?

Make sure you created the repository as Public and that the repository name in the clone URL matches exactly what you typed on GitHub.

help me fix a git clone error for my cicd-gitops repository

Write the Kubernetes Deployment manifest

A Deployment tells Kubernetes how many copies of your app to run and which container image to use. This is where you connect your CI-built image to your cluster.

  • Create a file called deployment.yaml in the cicd-gitops folder and paste the following content:
apiVersion: apps/v1
kind: Deployment
metadata:
  name: cicd-app
  labels:
    app: cicd-app
spec:
  replicas: 2
  selector:
    matchLabels:
      app: cicd-app
  template:
    metadata:
      labels:
        app: cicd-app
    spec:
      containers:
        - name: cicd-app
          image: ghcr.io/[[YOUR_USERNAME="your-github-username"]]/cicd-app:latest
          ports:
            - containerPort: 5000
          env:
            - name: APP_VERSION
              value: "1.0.0"
          resources:
            requests:
              memory: "64Mi"
              cpu: "50m"
            limits:
              memory: "128Mi"
              cpu: "100m"

What does this manifest do?

This Deployment creates 2 replicas (copies) of your container. If one crashes, Kubernetes automatically restarts it to maintain the desired count.

The image field points to your GitHub Container Registry image that CI pushes on every commit. The resources section sets memory and CPU limits so one container cannot consume your entire machine.

  • Save deployment.yaml.
  • Confirm the file exists by running:
ls

You should see deployment.yaml in the output.

Don't see the file?

Make sure you saved the file inside the cicd-gitops folder, not in cicd-app or your Desktop. Run pwd to check your current directory.

help me find where my deployment.yaml file was saved

✔️ Awesome, I've got everything!

Great. Make sure you replaced the username placeholder with your actual GitHub username before moving on.

ⓧ I'd like to double check the full code

Here is the complete deployment.yaml for comparison:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: cicd-app
  labels:
    app: cicd-app
spec:
  replicas: 2
  selector:
    matchLabels:
      app: cicd-app
  template:
    metadata:
      labels:
        app: cicd-app
    spec:
      containers:
        - name: cicd-app
          image: ghcr.io/[[YOUR_USERNAME="your-github-username"]]/cicd-app:latest
          ports:
            - containerPort: 5000
          env:
            - name: APP_VERSION
              value: "1.0.0"
          resources:
            requests:
              memory: "64Mi"
              cpu: "50m"
            limits:
              memory: "128Mi"
              cpu: "100m"
Write the Kubernetes Service manifest

A Service gives your pods a stable network address inside the cluster. Without it, other services and port-forward commands cannot reach your app.

  • Create a file called service.yaml in the cicd-gitops folder and paste the following content:
apiVersion: v1
kind: Service
metadata:
  name: cicd-app-service
spec:
  selector:
    app: cicd-app
  ports:
    - protocol: TCP
      port: 5000
      targetPort: 5000
  type: ClusterIP

What does this Service do?

The selector field matches pods with the label app: cicd-app, which is exactly what your Deployment creates. The ClusterIP type means the Service is only reachable from inside the cluster.

Port 5000 on the Service forwards traffic to port 5000 on your containers. You will use kubectl port-forward later to access it from your browser.

  • Save service.yaml.
  • Confirm both files exist by running:
ls

You should see both deployment.yaml and service.yaml listed.

Only see one file?

Check that both files are in the same cicd-gitops directory. If service.yaml is missing, confirm you saved it in the correct folder.

help me check where my service.yaml was saved

✔️ Awesome, I've got everything!

Great. Both manifests are ready to push to GitHub.

ⓧ I'd like to double check the full code

Here is the complete service.yaml for comparison:

apiVersion: v1
kind: Service
metadata:
  name: cicd-app-service
spec:
  selector:
    app: cicd-app
  ports:
    - protocol: TCP
      port: 5000
      targetPort: 5000
  type: ClusterIP
Push the manifests to GitHub
  • Stage, commit, and push your manifests by running these commands:
git add .
git commit -m "Add Kubernetes deployment and service manifests"
git push origin main

What do these commands do?

The git add . stages both YAML files. The commit records them with a descriptive message. The push sends them to your remote cicd-gitops repository on GitHub.

  • Open your cicd-gitops repository on GitHub in your browser.
  • Refresh the page. You should see deployment.yaml and service.yaml listed in the repository file tree.

Push rejected or branch errors?

If Git says the remote is empty and asks you to set an upstream, try running git push -u origin main. If your default branch is called master instead of main, rename it first with git branch -M main.

help me fix a git push error for my cicd-gitops repository

Your GitOps repository is live on GitHub with both manifests ready. Next up, you'll create a local Kubernetes cluster and install ArgoCD to watch this repo and deploy automatically.

Create a kind Cluster and Install ArgoCD

Your CI pipeline builds and pushes images, and your GitOps repo holds the Kubernetes manifests that describe what should be deployed.

The missing piece is an actual cluster to deploy to and a tool to watch your GitOps repo for changes. In this step, you will create a local Kubernetes cluster with kind and install ArgoCD so it can automatically sync your manifests into the cluster.

In this step, get ready to:
  • Create a local Kubernetes cluster using kind.
  • Install ArgoCD into the cluster.
  • Access the ArgoCD dashboard in your browser.
Create a local Kubernetes cluster

Kind (Kubernetes in Docker) spins up a fully functional Kubernetes cluster using Docker containers as "nodes" on your machine. It gives you the same Kubernetes experience as a cloud cluster, without any cloud costs.

  • Create a cluster named devops-pipeline by running this command:
kind create cluster --name devops-pipeline

What does this command do?

The --name devops-pipeline flag gives your cluster a custom name instead of the default name kind. Behind the scenes, kind pulls a Docker image that runs as a Kubernetes node container.

This also automatically sets your kubectl context to point at the new cluster, so all future kubectl commands target it.

  • Verify the cluster is running by checking its connection info:
kubectl cluster-info --context kind-devops-pipeline
  • You should see output showing the Kubernetes control plane address and CoreDNS address, confirming the cluster is alive.

Seeing a connection error?

  • Make sure Docker Desktop is running. Kind needs Docker to create cluster nodes.
  • If you see a timeout, wait 30 seconds and try the kubectl cluster-info command again. The cluster may still be starting up.
  • If you already have a cluster named devops-pipeline from a previous attempt, delete it first with kind delete cluster --name devops-pipeline and then re-create it.

help me troubleshoot my kind cluster creation

Install ArgoCD into the cluster

ArgoCD is a GitOps controller that continuously watches a Git repository and automatically syncs your Kubernetes manifests to the cluster. You will install it into its own dedicated namespace.

  • Create the argocd namespace and install ArgoCD by running these commands:
kubectl create namespace argocd
kubectl apply -n argocd --server-side --force-conflicts -f https://raw.githubusercontent.com/argoproj/argo-cd/stable/manifests/install.yaml

What do these commands do?

The first command creates a namespace called argocd to isolate all ArgoCD components from your application workloads.

The second command downloads the official ArgoCD manifest and installs all its components (API server, repo server, controller, etc.) into that namespace. The --server-side --force-conflicts flags are required because some ArgoCD custom resource definitions exceed the size limit for standard client-side apply.

  • Wait for all ArgoCD pods to become ready. Check their status by running:
kubectl get pods -n argocd
  • You should see several pods (including argocd-server, argocd-repo-server, and argocd-application-controller) all showing Running status with all containers ready.
  • If some pods show ContainerCreating or Pending, wait 60 seconds and run the command again. ArgoCD takes a moment to pull its images.

Pods stuck in Pending or CrashLoopBackOff?

  • Check that Docker Desktop has at least 4 GB of RAM allocated. ArgoCD needs adequate resources to run all its components.
  • If a pod is crash-looping, run kubectl logs <pod-name> -n argocd to inspect the error output.

help me debug ArgoCD pods that won't start

Access the ArgoCD UI

ArgoCD ships with a web dashboard where you can see your applications, their sync status, and the health of your deployments. By default, the ArgoCD server is not exposed outside the cluster, so you need to forward a local port to reach it.

  • Start a port-forward to the ArgoCD server by running this command:
kubectl port-forward svc/argocd-server -n argocd 8080:443

What does this command do?

This forwards your local port 8080 to the ArgoCD server's port 443 inside the cluster. As long as this command is running, you can access the ArgoCD UI in your browser.

This command runs in the foreground and occupies the terminal. You will need to open a new terminal tab or window for the next commands.

  • Open a new terminal tab or window. You will use this for the remaining commands.
  • Retrieve the initial admin password by running:
argocd admin initial-password -n argocd
  • Copy the password that appears in the output. You will use it to log in.
  • Open your browser and navigate to https://localhost:8080.

Seeing a security warning in your browser?

ArgoCD uses a self-signed TLS certificate by default. Your browser will show a warning about the connection not being private. This is expected for local development.

Click Advanced and then proceed to localhost. The exact wording differs by browser, but the goal is to accept the self-signed certificate and continue.

  • On the ArgoCD login page, enter admin as the username.
  • Paste the password you copied earlier into the password field.
  • Click SIGN IN.

You should see the ArgoCD dashboard with an empty applications list. This is where your deployed applications will appear once ArgoCD starts syncing.

Can't reach localhost:8080?

  • Switch back to the terminal running the port-forward command. If it exited or shows an error, re-run kubectl port-forward svc/argocd-server -n argocd 8080:443.
  • If port 8080 is already in use by another application, stop that application first or choose a different local port (e.g., 9080:443).
  • Verify the ArgoCD server pod is running with kubectl get pods -n argocd. The argocd-server pod must show Running status.

help me access the ArgoCD UI

Your local Kubernetes cluster is running and ArgoCD is ready to manage deployments. Next up, you will connect ArgoCD to your GitOps repo and watch it automatically deploy your Flask application.

Connect ArgoCD to GitOps Repo and Deploy

Your cluster is running and ArgoCD is installed. Now it is time to connect ArgoCD to your GitOps repository so it can automatically deploy your application.

In this step, you will create an ArgoCD Application that watches your cicd-gitops repo and deploys any changes to your cluster automatically. You will also prove the GitOps self-healing loop by deleting a pod and watching ArgoCD restore it.

In this step, get ready to:
  • Log in to ArgoCD CLI and create an Application pointing to your GitOps repo.
  • Verify the deployment is running and accessible.
  • Demonstrate self-healing and automatic sync.
Log in to ArgoCD CLI and create the application

The ArgoCD CLI lets you manage applications from the terminal. Before creating anything, you need to authenticate the CLI against your local ArgoCD server.

  • Make sure your port-forward to the ArgoCD server is still running in a separate terminal tab. If it stopped, restart it by running:
kubectl port-forward svc/argocd-server -n argocd 8080:443
  • In a new terminal tab, log in to ArgoCD via the CLI by running this command:
argocd login localhost:8080 --insecure
  • When prompted, enter admin as the username and paste the initial admin password you retrieved earlier.

What does this command do?

The --insecure flag skips TLS certificate verification. This is safe for local development because the ArgoCD server uses a self-signed certificate. In production, you would use a proper TLS certificate instead.

  • You should see a message confirming you are logged in successfully.
  • Now create an ArgoCD Application that points to your cicd-gitops repository by running this command:
argocd app create cicd-app --repo https://github.com/[[GITHUB_USERNAME="YOUR_USERNAME"]]/cicd-gitops.git --path . --dest-server https://kubernetes.default.svc --dest-namespace default --sync-policy automated

What does this command do?

This command creates an ArgoCD Application with five key settings:

  • --repo tells ArgoCD which Git repository to watch for Kubernetes manifests.
  • --path . means the manifests live at the root of the repository.
  • --dest-server points to the in-cluster Kubernetes API (your kind cluster).
  • --dest-namespace default deploys resources into the default namespace.
  • --sync-policy automated tells ArgoCD to automatically deploy whenever the Git repo changes.
  • You should see output confirming the application was created.

Seeing a permission error?

If the argocd app create command fails with an authentication error, your login session may have expired. Run argocd login localhost:8080 --insecure again and re-enter your credentials.

If ArgoCD cannot reach your GitHub repo, make sure the cicd-gitops repository is public. Private repos require additional credentials configuration.

Help me troubleshoot my ArgoCD app create command. I'm getting an error when trying to create an application pointing to my GitHub repo.

Verify the app syncs and the deployment is running

ArgoCD should have already started syncing your manifests to the cluster. Let's confirm that everything deployed correctly.

  • Check the ArgoCD application status by running:
argocd app get cicd-app
  • You should see the app status as Synced and health as Healthy. This means ArgoCD successfully applied your manifests to the cluster.
  • Confirm the pods are running by running:
kubectl get pods
  • You should see 2 pods with names starting with cicd-app- in the Running state. ArgoCD created these from your deployment.yaml manifest.
  • Test the application by port-forwarding to the service:
kubectl port-forward svc/cicd-app-service 5001:5000
  • Open a new terminal tab and test the health endpoint by running:
curl http://localhost:5001/health
  • You should see a JSON response like {"status":"healthy","version":"1.0.0"}. This confirms your Flask API is running inside the cluster, deployed entirely by ArgoCD from your GitOps repo.

What just happened?

You never ran kubectl apply yourself. ArgoCD read your deployment.yaml and service.yaml from GitHub, applied them to the cluster, and verified they reached a healthy state. This is GitOps in action.

Pods not showing as Running?

If the pods show ImagePullBackOff or ErrImagePull, your cluster cannot pull the container image from ghcr.io. Confirm that your cicd-app package on GitHub is set to public visibility.

If the app status shows OutOfSync instead of Synced, wait 30 seconds and re-run argocd app get cicd-app. Automated sync can take a moment to trigger on first creation.

Help me debug why my pods are not running after ArgoCD synced my GitOps manifests to a kind cluster.

Demonstrate GitOps self-healing

The real power of GitOps shows when something goes wrong. If someone manually deletes a resource, ArgoCD detects the drift and restores it automatically.

  • Stop the port-forward to the service by pressing Ctrl+C in the terminal tab where it is running.
  • Get the name of one of your running pods by running:
kubectl get pods
  • Copy one of the pod names (it will look something like cicd-app-6d7f8b9c4-abc12).
  • Delete that pod by running:
kubectl delete pod [[POD_NAME="your-pod-name-from-the-list-above"]]
  • Immediately check the pods again by running:
kubectl get pods
  • You should see that Kubernetes already created a replacement pod. The Deployment controller maintains the desired replica count of 2. ArgoCD did not even need to intervene here because the Deployment spec is still in sync with Git.

Why did the pod come back?

The Kubernetes Deployment controller constantly reconciles the number of running pods with the replicas: 2 field in your manifest. When you deleted a pod, the controller noticed only 1 replica was running and immediately scheduled a new one.

ArgoCD adds another layer on top of this. If someone ran kubectl edit and changed the Deployment spec directly (bypassing Git), ArgoCD would detect the drift and revert it to match what is declared in your GitOps repo.

Now let's prove ArgoCD auto-syncs when you change the GitOps repo.

  • Open your cicd-gitops repository on GitHub in your browser.
  • Click on deployment.yaml.
  • Click the pencil icon to edit the file.
  • Find the line that says replicas: 2 and change it to replicas: 3.
  • Click Commit changes to save directly to the main branch.
  • Wait about 30 seconds for ArgoCD to poll the repository.
  • Check the pods in your terminal by running:
kubectl get pods
  • You should now see 3 pods running. ArgoCD detected the change in Git and automatically scaled your deployment without any manual intervention.

What is automated sync?

With --sync-policy automated, ArgoCD polls your Git repository every 3 minutes by default. When it detects a difference between what Git declares and what the cluster has, it applies the change automatically.

This means CI/CD pipelines never need direct access to the cluster. They just commit to Git, and ArgoCD handles the deployment.

Still seeing only 2 pods?

ArgoCD polls Git on a schedule. If 30 seconds was not enough, wait up to 3 minutes or force a manual sync by running:

argocd app sync cicd-app

Then check kubectl get pods again. If you still see 2 pods, verify that you committed the replicas: 3 change to the main branch (not a separate branch or pull request).

Help me understand why ArgoCD is not picking up my Git changes and syncing them to the cluster.

You have just proven the complete GitOps loop. Changes in Git flow automatically to your cluster, and the cluster self-heals when reality drifts from the declared state. Next up, you will tackle the Secret Mission and add a staging environment.

Secret mission

Add a Staging Environment

In real companies, you never deploy straight to production. Add a staging environment to your GitOps setup so you can test changes before they reach users. This mirrors how teams at companies like Netflix and Spotify manage multi-environment deployments.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely on your local machine using Docker Desktop and a kind cluster. There are no ongoing cloud costs, but the kind cluster and Docker containers do consume memory and CPU while running.

Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • kind cluster devops-pipeline (local Kubernetes cluster running in Docker).
  • ArgoCD installation (running in the argocd namespace).
  • ArgoCD Applications (cicd-app and cicd-app-staging).
  • GitHub repository cicd-app (application code).
  • GitHub repository cicd-gitops (Kubernetes manifests).
  • Container images on ghcr.io.
  • Local project files (cicd-app directory on your Desktop).

Keep everything running

No action needed. Choose this if you are still actively building or experimenting with the pipeline.

  • Your kind cluster continues running inside Docker. It uses memory and CPU while active.
  • ArgoCD will keep watching your GitOps repo and auto-syncing any changes you push.
  • Docker Desktop stays installed and running in the background.
  • Your GitHub repos and container images remain available for future pushes.

Pause - I'll come back to this later

Shut down running processes to free up memory and CPU, but keep your files and repos intact so you can resume later.

  • Delete the kind cluster to free Docker resources by running:
  • You can recreate it later with the same kind create cluster --name devops-pipeline command.
kind delete cluster --name devops-pipeline
  • Quit Docker Desktop by clicking the Docker icon in your menu bar and selecting Quit Docker Desktop.
  • Your local cicd-app folder, GitHub repos, and container images all remain untouched.
  • When you return, start Docker Desktop, recreate the cluster, reinstall ArgoCD, and reconnect your GitOps repo.

Delete - I don't want to use this again

Remove all project resources completely and start fresh.

Delete the kind cluster
  • Delete the cluster by running:
kind delete cluster --name devops-pipeline
Delete local project files
  • Remove the project folder from your Desktop by running:
rm -rf ~/Desktop/cicd-app
Delete your GitHub repositories
  • Go to your cicd-app repository on GitHub.
  • Click Settings.
  • Scroll down to the Danger Zone section and click Delete this repository.
  • Confirm by typing the repository name and clicking Delete this repository.
  • Repeat these steps for the cicd-gitops repository.
Delete container images from ghcr.io
  • Go to your GitHub profile and click Packages.
  • Find the cicd-app package.
  • Click Package settings.
  • Scroll down to the Danger Zone and click Delete this package.
Uninstall Homebrew packages (optional)
  • Remove the CLI tools you installed for this project by running:
brew uninstall kind
brew uninstall argocd
brew uninstall kubernetes-cli
Uninstall Docker Desktop (optional)
  • Open Docker Desktop.
  • Click the Troubleshoot icon in the top-right corner of the Docker Desktop Dashboard.
  • Select Uninstall.
  • When prompted, confirm by selecting Uninstall again.
  • Move Docker to the Trash.

Nice Work!

Nice Work!

You just built a complete end-to-end CI/CD pipeline that mirrors how companies ship code in production. Every push to main now triggers an automated build, and ArgoCD ensures your cluster always matches what is declared in Git.

You've learned how to:

  • Build a GitHub Actions CI pipeline that automatically builds Docker images and pushes them to GitHub Container Registry on every push to main.
  • Install and configure ArgoCD on a local Kubernetes cluster to automatically sync deployments from a GitOps repository.
  • Demonstrate self-healing deployments where ArgoCD detects drift and automatically restores desired state.
  • Add a multi-environment setup with separate staging and production ArgoCD Applications deploying independently to different namespaces.

Ready to quiz yourself?