Skip to main content

Build a GitOps CI/CD Pipeline with ArgoCD

Build a pipeline where GitHub Actions tests and pushes an image, then updates a config repo that ArgoCD syncs to Kubernetes.

Blueprint Walkthrough

Architecture Overview & Problem Statement

This project builds a GitOps CI/CD pipeline: GitHub Actions tests your code, builds a container image, pushes it to GitHub Container Registry, and commits the new image tag to a separate config repository, which ArgoCD then syncs to Kubernetes. CI never touches the cluster, and every production change is a reviewable Git commit.

The problem this pipeline solves

In a classic push-based pipeline, the CI system holds cluster credentials and runs kubectl apply. That means a compromised CI job can reach production, and nobody can answer "what exactly is running right now?" without logging in. Picture a team at Swiggy shipping an order-status API many times a day: they need fast deploys, a clear audit trail and a one-step rollback.

This project splits the work between two repositories with two different jobs:

Repository Contains Who writes to it
orders-api (app repo) Source code, tests, Dockerfile, CI workflow Developers
orders-api-config (config repo) Kubernetes manifests and the current image tag The CI pipeline and reviewers

CI controls which images exist. The config repo controls which image is deployed. ArgoCD, running inside the cluster, pulls from the config repo, so no credentials for the cluster ever leave it.

flowchart TD A[Push to
app repo] --> B subgraph CI[GitHub Actions] B[Test] --> C[Build and
push image] end C --> D[Update
config repo] E[ArgoCD] -->|polls| D E -->|syncs| F[Kubernetes] style CI fill:#F1F5F9,stroke:#475569,stroke-width:2px,color:#1E293B classDef purple fill:#EDE9FE,stroke:#8B5CF6,stroke-width:2px,color:#1E293B classDef blue fill:#DBEAFE,stroke:#3B82F6,stroke-width:2px,color:#1E293B classDef green fill:#D1FAE5,stroke:#10B981,stroke-width:2px,color:#1E293B classDef orange fill:#FFEDD5,stroke:#F97316,stroke-width:2px,color:#1E293B classDef red fill:#FEE2E2,stroke:#EF4444,stroke-width:2px,color:#1E293B classDef slate fill:#F1F5F9,stroke:#475569,stroke-width:2px,color:#1E293B class A purple class B,C blue class D orange class E green class F slate linkStyle default stroke:#475569,stroke-width:2px

What you will build

  • A small Flask API called orders-api with tests and a non-root Docker image
  • A GitHub Actions workflow with three jobs: test, build and push, update config
  • A config repo using Kustomize that CI edits automatically
  • An ArgoCD Application that deploys every new image tag on its own
  • A bad release and a rollback, done through Git

What you need

Tool Check it works Install guide
Docker docker version Get Docker
kind kind version kind quick start
kubectl kubectl version --client Install kubectl
Git and GitHub account git --version GitHub signup
Python 3.12 (optional, for local tests) python3 --version python.org

New to ArgoCD? Do Deploy Your First App with ArgoCD and GitOps first. This project assumes you know what an Application and a sync are. Plan for about 3 hours. Replace YOUR_USER with your GitHub username (in lowercase) everywhere it appears.

Tip

Each milestone ends with a Checkpoint. Do not move on until it matches, because later steps build on earlier ones.

Go deeper

Milestone 1: Build the Orders API and Container Image

This milestone creates a small Flask API with tests and a production-style Docker image, and pushes it to a new orders-api GitHub repository. The pipeline needs something realistic to build and deploy.

Why these choices

The app is intentionally tiny so the focus stays on the pipeline. Two details matter for GitOps. The / endpoint returns the Git commit SHA baked into the image, so later you can prove which version is running. The container runs as a non-root user under gunicorn, a production WSGI server, because Flask's built-in server is meant for local development only.

Steps

Create an empty GitHub repo named orders-api, then:

Bash
mkdir orders-api && cd orders-api
mkdir tests
cat > app.py <<'EOF'
import os
from flask import Flask, jsonify
app = Flask(__name__)
@app.route("/")
def home():
return jsonify(
service="orders-api",
version=os.getenv("APP_VERSION", "dev"),
message="orders-api is running",
status="healthy",
)
@app.route("/health")
def health():
return jsonify(status="ok"), 200
EOF
cat > requirements.txt <<'EOF'
flask==3.0.3
gunicorn==22.0.0
EOF
cat > pytest.ini <<'EOF'
[pytest]
pythonpath = .
EOF
cat > tests/test_app.py <<'EOF'
from app import app
def client():
app.config["TESTING"] = True
return app.test_client()
def test_home_returns_service_name():
response = client().get("/")
assert response.status_code == 200
assert response.get_json()["service"] == "orders-api"
def test_health_returns_ok():
response = client().get("/health")
assert response.status_code == 200
assert response.get_json()["status"] == "ok"
EOF
cat > Dockerfile <<'EOF'
FROM python:3.12-slim
LABEL org.opencontainers.image.source="https://github.com/YOUR_USER/orders-api"
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app.py .
RUN useradd --create-home appuser
USER appuser
ARG GIT_SHA=dev
ENV APP_VERSION=${GIT_SHA}
EXPOSE 8080
CMD ["gunicorn", "--bind", "0.0.0.0:8080", "--workers", "2", "app:app"]
EOF
printf '.git\n__pycache__\ntests\n' > .dockerignore

Test locally:

Bash
python3 -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt pytest
pytest -v
docker build -t orders-api:local .
docker run --rm -d --name orders-local -p 8081:8080 orders-api:local
curl -s localhost:8081/ && curl -s localhost:8081/health
docker stop orders-local

Push the code:

Bash
printf '.venv\n__pycache__\n' > .gitignore
git init -b main
git add .
git commit -m "feat: add orders-api"
git remote add origin https://github.com/YOUR_USER/orders-api.git
git push -u origin main

Checkpoint

TEXT
tests/test_app.py::test_home_returns_service_name PASSED
tests/test_app.py::test_health_returns_ok PASSED
JSON
{"message":"orders-api is running","service":"orders-api","status":"healthy","version":"dev"}
{"status":"ok"}
Common Mistake

Copying a multi-stage build that installs packages into /root/.local and then switching to a non-root user. That user cannot read /root, so the container crashes on start. Install packages system-wide as shown, then switch users.

Tip

The LABEL org.opencontainers.image.source line links the image to your repository in GitHub Packages, which helps with package permissions later.

Go deeper

Milestone 2: Create the Config Repository

This milestone creates the repo that ArgoCD watches. It contains Kubernetes manifests and a Kustomize file that CI will update with each new image tag.

Why Kustomize and not `sed`

CI needs to change one thing: the image tag. Kustomize has a purpose-built images field for exactly this, and kustomize edit set image changes it safely. That is far more reliable than a sed regex, which can silently corrupt YAML. Kustomize is also built into kubectl, and ArgoCD understands it natively.

Steps

Create an empty public GitHub repo named orders-api-config, then:

Bash
mkdir orders-api-config && cd orders-api-config
mkdir -p orders-api argocd
cat > orders-api/deployment.yaml <<'EOF'
apiVersion: apps/v1
kind: Deployment
metadata:
name: orders-api
labels:
app: orders-api
spec:
replicas: 2
selector:
matchLabels:
app: orders-api
template:
metadata:
labels:
app: orders-api
spec:
containers:
- name: orders-api
image: ghcr.io/YOUR_USER/orders-api
ports:
- name: http
containerPort: 8080
readinessProbe:
httpGet:
path: /health
port: http
initialDelaySeconds: 5
periodSeconds: 10
livenessProbe:
httpGet:
path: /health
port: http
initialDelaySeconds: 15
periodSeconds: 20
resources:
requests:
cpu: 50m
memory: 64Mi
limits:
cpu: 200m
memory: 128Mi
EOF
cat > orders-api/service.yaml <<'EOF'
apiVersion: v1
kind: Service
metadata:
name: orders-api
spec:
selector:
app: orders-api
ports:
- name: http
port: 80
targetPort: http
EOF
cat > orders-api/kustomization.yaml <<'EOF'
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: production
resources:
- deployment.yaml
- service.yaml
EOF
git init -b main
git add .
git commit -m "feat: add orders-api manifests"
git remote add origin https://github.com/YOUR_USER/orders-api-config.git
git push -u origin main

The image has no tag on purpose. CI adds an images: block to kustomization.yaml the first time it runs.

Checkpoint

Preview what Kustomize renders:

Bash
kubectl kustomize orders-api | grep -E 'namespace|image:'
TEXT
namespace: production
image: ghcr.io/YOUR_USER/orders-api
Common Mistake

Using uppercase letters in the image name. GitHub Container Registry requires lowercase, and the pipeline lowercases the repository name for you. If your username has capitals, write it in lowercase in deployment.yaml too.

Go deeper

Milestone 3: Build the GitHub Actions Pipeline

This milestone adds a three-job workflow to the app repo and runs it once. The jobs run in order: test, build and push the image, then update the config repo.

Why three jobs

Each job runs only if the one before it succeeds (needs:). Bad code fails at the test job, so no image is built and nothing is deployed. The last job is the GitOps step: it does not deploy anything. It only commits a new image tag to the config repo, and ArgoCD does the deploying.

Step 1: Give CI permission to write to the config repo

The default GITHUB_TOKEN can only write to the repo the workflow runs in, so it cannot push to orders-api-config. Create a token that can:

  • In GitHub, open Settings, Developer settings, Personal access tokens, Fine-grained tokens, and generate a new token
  • Set Repository access to Only select repositories and choose orders-api-config
  • Under Repository permissions, set Contents to Read and write
  • In the orders-api repo, open Settings, Secrets and variables, Actions, and add a secret named CONFIG_REPO_TOKEN with that token

Step 2: Add the workflow

Bash
cd orders-api
mkdir -p .github/workflows
cat > .github/workflows/ci-cd.yml <<'EOF'
name: ci-cd
on:
push:
branches: [main]
pull_request:
branches: [main]
env:
REGISTRY: ghcr.io
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: pip
- run: pip install -r requirements.txt pytest
- run: pytest -v
build-and-push:
needs: test
if: github.event_name == 'push'
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
outputs:
image: ${{ steps.vars.outputs.image }}
tag: ${{ steps.vars.outputs.tag }}
steps:
- uses: actions/checkout@v4
- name: Compute image name and tag
id: vars
run: |
echo "image=${REGISTRY}/${GITHUB_REPOSITORY,,}" >> "$GITHUB_OUTPUT"
echo "tag=sha-${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT"
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/build-push-action@v6
with:
context: .
push: true
build-args: GIT_SHA=${{ github.sha }}
tags: ${{ steps.vars.outputs.image }}:${{ steps.vars.outputs.tag }}
update-config:
needs: build-and-push
runs-on: ubuntu-latest
concurrency: config-repo-update
steps:
- uses: actions/checkout@v4
with:
repository: ${{ github.repository_owner }}/orders-api-config
token: ${{ secrets.CONFIG_REPO_TOKEN }}
- name: Point the deployment at the new image
working-directory: orders-api
run: |
IMAGE="${{ needs.build-and-push.outputs.image }}"
TAG="${{ needs.build-and-push.outputs.tag }}"
kustomize edit set image "${IMAGE}=${IMAGE}:${TAG}"
cat kustomization.yaml
- name: Commit and push
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add orders-api/kustomization.yaml
git commit -m "deploy: orders-api ${{ needs.build-and-push.outputs.tag }}"
git push
EOF
git add .
git commit -m "ci: add test, build and gitops update pipeline"
git push

Step 3: Make the image pullable

After the first run, GitHub creates the package as private. Open your profile, then Packages, then orders-api, then Package settings, and under Danger Zone change visibility to Public so the cluster can pull it without credentials.

Checkpoint

  • The Actions tab on orders-api shows test, build-and-push and update-config all green
  • The Packages page lists orders-api with a tag like sha-1a2b3c4
  • In orders-api-config there is a new commit named deploy: orders-api sha-1a2b3c4, and orders-api/kustomization.yaml now has an images: block
Job Runs when What it does
test Every push and pull request Installs dependencies and runs pytest
build-and-push Push to main only Builds the image and pushes it with a sha- tag
update-config After a successful push Sets the new tag in the config repo with Kustomize
Common Mistake

Using the default GITHUB_TOKEN to push to the config repo. It only has access to the current repo, so the last job fails with a 403 error. Create the fine-grained token and the CONFIG_REPO_TOKEN secret as shown.

Security

Scope the token to the single config repo with only the Contents permission. If it leaks, the damage is limited to that repo and it cannot reach your app code or cluster.

Tip

The concurrency line makes two quick pushes update the config repo one after the other instead of racing and failing with a rejected git push. Image tags use the short commit SHA, which is immutable, never latest.

Go deeper

Milestone 4: Prepare the Cluster and Install ArgoCD

This milestone creates a local cluster and installs ArgoCD. If you completed Deploy Your First App with ArgoCD and GitOps and its cluster still exists, run only the verification commands and move on.

Why it is brief here

The beginner project explains what each ArgoCD component does. Here you need a working ArgoCD and nothing more.

Steps

Bash
kind create cluster --name gitops-lab
kubectl create namespace argocd
kubectl apply -n argocd --server-side --force-conflicts \
-f https://raw.githubusercontent.com/argoproj/argo-cd/stable/manifests/install.yaml
kubectl wait --for=condition=ready pod --all -n argocd --timeout=300s

In a second terminal, open the UI and get the password:

Bash
kubectl get secret argocd-initial-admin-secret -n argocd \
-o jsonpath='{.data.password}' | base64 -d; echo
kubectl port-forward svc/argocd-server -n argocd 8080:443

Install the CLI (brew install argocd on macOS, or download from the releases page) and log in:

Bash
argocd login localhost:8080 --username admin --password 'PASTE_PASSWORD_HERE' --insecure

Checkpoint

Bash
kubectl get pods -n argocd
argocd account get-user-info

All ArgoCD pods are Running, and argocd account get-user-info shows Logged In: true.

Common Mistake

Leaving out --server-side --force-conflicts. The install fails because the ApplicationSet CRD is too large for client-side apply.

Go deeper

Milestone 5: Create the ArgoCD Application

This milestone tells ArgoCD to watch the config repo and keep the production namespace in sync with it. The Application is the link between Git and the cluster.

Why `selfHeal` and `prune`

automated makes ArgoCD sync without a human clicking. selfHeal reverts manual changes made in the cluster, and prune deletes resources you remove from Git. Together they make Git the only way to change the app.

Steps

Create argocd/orders-api-app.yaml in the config repo:

YAML
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: orders-api
namespace: argocd
spec:
project: default
source:
repoURL: https://github.com/YOUR_USER/orders-api-config.git
targetRevision: main
path: orders-api
destination:
server: https://kubernetes.default.svc
namespace: production
syncPolicy:
automated:
prune: true
selfHeal: true
syncOptions:
- CreateNamespace=true
Bash
cd orders-api-config
git pull
git add argocd/orders-api-app.yaml
git commit -m "feat: add argocd application"
git push
kubectl apply -f argocd/orders-api-app.yaml
argocd app get orders-api
kubectl get pods -n production

Test the running service, using port 8081 because 8080 is used by the ArgoCD UI:

Bash
kubectl port-forward svc/orders-api -n production 8081:80
curl -s localhost:8081/

Checkpoint

TEXT
Sync Status: Synced to main (abc1234)
Health Status: Healthy
JSON
{"message":"orders-api is running","service":"orders-api","status":"healthy","version":"1a2b3c4d..."}

The version is the full commit SHA of your app repo, which proves the image CI built is the one running.

If your config repo is private, give ArgoCD read access first:

Bash
argocd repo add https://github.com/YOUR_USER/orders-api-config.git \
--username YOUR_USER --password YOUR_READ_ONLY_TOKEN
Common Mistake

Pods stuck in ImagePullBackOff. The image package is still private. Set it to public (Milestone 3, Step 3) or create an image pull secret for the namespace.

Security

Never commit plaintext Secrets to the config repo. For real apps use Sealed Secrets or the External Secrets Operator so only encrypted data lives in Git.

Go deeper

Milestone 6: Ship a Change End to End

This milestone changes the app code, pushes it, and watches the change travel through the whole pipeline to the cluster without you running any deploy command.

Why this is the payoff

You touch only the app repo. CI builds a new image, writes its tag into the config repo, and ArgoCD notices and rolls it out. Every hop is a Git commit you can inspect.

Steps

Change the message in app.py and update nothing else:

Python
message="orders-api v2 is running",
Bash
cd orders-api
git add app.py
git commit -m "feat: update status message"
git push

Watch each stage:

Bash
# 1. CI runs (check the Actions tab), then the config repo gets a new commit
# 2. Ask ArgoCD to refresh instead of waiting up to 3 minutes
argocd app get orders-api --refresh
kubectl get pods -n production -w

Checkpoint

Bash
kubectl port-forward svc/orders-api -n production 8081:80
curl -s localhost:8081/

The response now shows "message":"orders-api v2 is running" and a new version SHA. In the config repo, git log --oneline shows a new deploy: orders-api sha-... commit.

Tip

The default polling interval is about 3 minutes. A GitHub webhook to ArgoCD makes syncs start in seconds. See the webhook guide below.

Common Mistake

Editing the image tag in kustomization.yaml by hand and pushing a tag that was never built. ArgoCD syncs it happily and the pods fail to pull. Let CI own that line.

Go deeper

Milestone 7: Roll Back a Bad Release

This milestone ships a release that passes tests but crashes at runtime, then rolls it back by reverting a commit in the config repo. It also shows what tests can and cannot catch.

Why revert the config repo first

Tests cannot catch everything, such as a broken start command. The fastest rollback reverts the config repo commit, which points ArgoCD back at the previous image that already exists, so there is no rebuild. Fix the app code afterwards, without time pressure.

Steps

Break the start command in a way the tests will not notice:

Bash
cd orders-api
sed -i.bak 's|"app:app"|"app:wrong_name"|' Dockerfile
rm Dockerfile.bak
git add Dockerfile
git commit -m "chore: bad start command (on purpose)"
git push

CI passes because the unit tests never run the container. After the pipeline finishes and ArgoCD syncs:

Bash
argocd app get orders-api --refresh
kubectl get pods -n production

You will see new pods in CrashLoopBackOff while the old pods keep serving traffic. Roll back in the config repo:

Bash
cd orders-api-config
git pull
git revert HEAD --no-edit
git push
argocd app get orders-api --refresh
kubectl get pods -n production

Finally, fix the cause in the app repo so the bad commit does not ship again:

Bash
cd orders-api
git revert HEAD --no-edit
git push

Checkpoint

TEXT
Sync Status: Synced
Health Status: Healthy

All pods are Running, and curl -s localhost:8081/ returns your last good version.

Common Mistake

Reverting only the app repo. That triggers a brand new pipeline run and a rebuild, which is slower than reverting the config repo, and the broken image stays deployed until it finishes.

Tip

Kubernetes rolling updates kept the old pods alive here because the new pods never became ready. Readiness probes are what turn a bad release into a stalled rollout instead of an outage.

Go deeper

Validation & Testing

This final step proves the pipeline works from commit to running pod, and gives you a checklist to show your work.

Verification commands

Bash
# 1. ArgoCD reports the app as synced and healthy
argocd app get orders-api
# 2. Pods are running in production
kubectl get pods -n production
# 3. The running image tag matches the latest deploy commit
kubectl get deployment orders-api -n production \
-o jsonpath='{.spec.template.spec.containers[0].image}'; echo
# 4. The service answers
kubectl port-forward svc/orders-api -n production 8081:80 &
curl -s localhost:8081/health

Expected: Synced and Healthy, 2 pods Running, an image ending in :sha-xxxxxxx that matches the newest deploy: commit in the config repo, and {"status":"ok"}.

Troubleshooting

Symptom Likely cause Fix
update-config fails with 403 Token missing or too narrow Recreate the fine-grained token with Contents read and write on the config repo
update-config fails with kustomize: command not found Runner image changed Add a step that installs Kustomize before the update step
ImagePullBackOff Package is private or tag missing Make the package public and confirm the tag exists in Packages
ArgoCD never syncs the new tag Waiting for the poll Run argocd app get orders-api --refresh or add a webhook
git push rejected in update-config Two runs raced Keep the concurrency line on that job
Pods CrashLoopBackOff Bad start command or missing env Run kubectl logs on the pod, then revert in the config repo

Clean up

Bash
kind delete cluster --name gitops-lab

What to try next

  • Build a GitOps Delivery Platform with ArgoCD - add staging and production, ApplicationSets and canary releases
  • Add a GitHub webhook so ArgoCD syncs within seconds of a config commit
  • Add a vulnerability scan to the build-and-push job before the image is pushed

Go deeper

Found a mistake, an outdated step or a broken link?