Build a GitOps CI/CD Pipeline with ArgoCD

Build a GitOps pipeline where GitHub Actions builds images and ArgoCD automatically syncs Kubernetes to match Git.

Domains & Technologies

Domains
GITOPSCICD
Technologies
ARGOCDKUBERNETES

Blueprint Walkthrough

Architecture Overview & Problem Statement

Architecture Overview

Most teams deploy by running scripts manually or SSHing into servers. That works fine for two engineers. It breaks down completely for twenty. GitOps solves this by making Git the single source of truth for everything running in your cluster - if it is not in Git, it is not supposed to be running.

ArgoCD watches a Git repository. When you merge a change - a new image tag, an updated replica count, a new environment variable - ArgoCD detects the difference between what Git says should be running and what is actually running in Kubernetes, then automatically pulls the cluster toward that desired state.

◈ DIAGRAM
[ Developer pushes code to GitHub ]
|
v
[ GitHub Actions Pipeline ]
(Test -> Build image -> Push to registry)
|
v
[ Updates image tag in Config Repo ]
|
v
[ ArgoCD detects manifest change ]
|
v
[ ArgoCD syncs cluster to match Git ]
|
v
[ New pods run, old pods terminate ]

At companies like Zerodha and Razorpay, GitOps is exactly how production changes get shipped - a pull request into a config repo, not an engineer typing kubectl apply from their laptop at 11pm.

Tip

With GitOps, deployments become pull requests and rollbacks become git revert. The entire deployment history lives in your Git log, not scattered across terminal sessions.

Problem Solved

Manual deployment - SSHing in, running a script, applying YAML by hand - has no audit trail, no easy rollback, and no protection against configuration drift. Someone changes a replica count directly in the cluster during an incident, forgets to update it anywhere else, and three weeks later staging and production quietly don't match anymore.

GitOps solves this with two properties: the desired system state is stored in Git, and a software agent (ArgoCD) continuously reconciles the actual cluster state toward that desired state. This project also separates the app repository (source code, built by CI) from the config repository (Kubernetes manifests, watched by ArgoCD). That separation means the CI pipeline controls what images exist, while the config repo controls what images are deployed - and every production deployment is auditable from the config repo's Git history alone.

By the end of this project, a change like "roll out orders-api v2.3 to production" will be a pull request that anyone on the team can review, approve, and revert - exactly like a code change.

Milestone 1: Create the Application

Concept

Before wiring up GitOps, you need something to deploy. We'll build a small Flask API - this stands in for a real service like Swiggy's order-status API or Razorpay's payment-status endpoint. It's intentionally simple so the focus stays on the deployment pipeline, not the app itself.

The Dockerfile below uses a multi-stage build and runs as a non-root user - both are standard production practice, not optional extras.

Steps

Bash
mkdir gitops-app
cd gitops-app
cat > app.py << 'EOF'
from flask import Flask, jsonify
import os
app = Flask(__name__)
@app.route('/')
def home():
return jsonify({
'service': 'orders-api',
'version': os.getenv('APP_VERSION', 'v1.0.0'),
'environment': os.getenv('ENVIRONMENT', 'production'),
'status': 'healthy'
})
@app.route('/health')
def health():
return jsonify({'status': 'ok'}), 200
if __name__ == '__main__':
app.run(host='0.0.0.0', port=8080)
EOF
cat > requirements.txt << 'EOF'
flask==3.0.0
gunicorn==21.2.0
EOF
cat > Dockerfile << 'EOF'
FROM python:3.12-slim AS builder
WORKDIR /app
COPY requirements.txt .
RUN pip install --user --no-cache-dir -r requirements.txt
FROM python:3.12-slim
WORKDIR /app
COPY --from=builder /root/.local /root/.local
COPY app.py .
RUN useradd --create-home appuser
USER appuser
ENV PATH=/root/.local/bin:$PATH
CMD ["gunicorn", "--bind", "0.0.0.0:8080", "--workers", "2", "app:app"]
EOF

Build and test locally:

Bash
docker build -t orders-api:local .
docker run -p 8080:8080 orders-api:local &
curl http://localhost:8080/
curl http://localhost:8080/health
JSON
{"environment": "production", "service": "orders-api",
"status": "healthy", "version": "v1.0.0"}
{"status": "ok"}
Common Mistake

Running the Flask development server (app.run()) directly in production instead of gunicorn. The dev server is single-threaded and not designed to handle concurrent traffic - always use a WSGI server like gunicorn for anything beyond local testing.

Milestone 2: Create the Config Repository

Concept

This is the repository ArgoCD will actually watch. It contains only Kubernetes manifests - no application source code at all. Keeping it separate from gitops-app is what makes the CI pipeline and the deployment pipeline independently controllable.

We use kustomize here (built into kubectl) to bundle the manifests together cleanly, which is the standard pattern ArgoCD expects.

Steps

Bash
mkdir gitops-config
cd gitops-config
mkdir -p apps/orders-api/base
cat > apps/orders-api/base/deployment.yaml << 'EOF'
apiVersion: apps/v1
kind: Deployment
metadata:
name: orders-api
namespace: production
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-github-username/orders-api:latest
ports:
- containerPort: 8080
env:
- name: ENVIRONMENT
value: "production"
- name: APP_VERSION
value: "v1.0.0"
readinessProbe:
httpGet:
path: /health
port: 8080
initialDelaySeconds: 5
periodSeconds: 10
livenessProbe:
httpGet:
path: /health
port: 8080
initialDelaySeconds: 15
periodSeconds: 20
resources:
requests:
memory: "64Mi"
cpu: "50m"
limits:
memory: "128Mi"
cpu: "200m"
EOF
cat > apps/orders-api/base/service.yaml << 'EOF'
apiVersion: v1
kind: Service
metadata:
name: orders-api
namespace: production
spec:
selector:
app: orders-api
ports:
- port: 80
targetPort: 8080
type: ClusterIP
EOF
cat > apps/orders-api/base/namespace.yaml << 'EOF'
apiVersion: v1
kind: Namespace
metadata:
name: production
EOF
cat > apps/orders-api/base/kustomization.yaml << 'EOF'
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- namespace.yaml
- deployment.yaml
- service.yaml
EOF

Push it to GitHub:

Bash
cd gitops-config
git init
git add .
git commit -m "Initial Kubernetes manifests for orders-api"
git remote add origin https://github.com/your-username/gitops-config.git
git push -u origin main
Tip

Naming the folder base/ even for a single-environment setup pays off later - when you add a staging environment, you create an overlays/staging/ folder that patches base/ with kustomize, instead of duplicating every manifest.

Milestone 3: Install ArgoCD and Access the UI

Concept

ArgoCD itself runs as a set of pods inside your cluster - a controller that continuously polls Git, a repo-server that clones and renders manifests, and a UI/API server. Installing it is a one-time setup; after this, ArgoCD manages every future deployment for you.

Steps

Bash
kubectl create namespace argocd
kubectl apply -n argocd \
-f https://raw.githubusercontent.com/argoproj/argo-cd/stable/manifests/install.yaml
kubectl wait --for=condition=ready pod \
--all -n argocd \
--timeout=300s
kubectl get pods -n argocd
TEXT
NAME READY STATUS
argocd-application-controller-0 1/1 Running
argocd-applicationset-controller-7f9b6c8d4 1/1 Running
argocd-dex-server-6b8c5f9b7-xqprs 1/1 Running
argocd-redis-7b9c8d5f6-klmnp 1/1 Running
argocd-repo-server-8c7d6b9f5-qrstu 1/1 Running
argocd-server-9d8c7f6b5-vwxyz 1/1 Running

Access the UI:

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

Open https://localhost:8080, accept the self-signed certificate warning, and log in with username admin and the password above.

Install the CLI:

Bash
## macOS
brew install argocd
## Linux
curl -sSL -o argocd-linux-amd64 \
https://github.com/argoproj/argo-cd/releases/latest/download/argocd-linux-amd64
chmod +x argocd-linux-amd64
sudo mv argocd-linux-amd64 /usr/local/bin/argocd
argocd login localhost:8080 \
--username admin \
--password YOUR_PASSWORD_HERE \
--insecure
Security

The initial admin password is auto-generated and should be rotated immediately in any real cluster. Never leave --insecure enabled outside of local practice - it skips TLS certificate verification entirely. Also never commit Secrets in plaintext to the config repo - use Sealed Secrets or the External Secrets Operator so only ciphertext lives in Git.

Milestone 4: Create the ArgoCD Application

Concept

An ArgoCD Application is the object that ties everything together: it tells ArgoCD which Git repo to watch, which path inside it to render, and which cluster and namespace to deploy into. The syncPolicy block is what makes it GitOps rather than just a fancy kubectl apply - with automated sync and selfHeal enabled, ArgoCD will detect and fix drift on its own.

Steps

Bash
cat > argocd-app.yaml << 'EOF'
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: orders-api
namespace: argocd
spec:
project: default
source:
repoURL: https://github.com/your-username/gitops-config.git
targetRevision: main
path: apps/orders-api/base
destination:
server: https://kubernetes.default.svc
namespace: production
syncPolicy:
automated:
prune: true
selfHeal: true
syncOptions:
- CreateNamespace=true
EOF
kubectl apply -f argocd-app.yaml

Verify the sync:

Bash
argocd app get orders-api
TEXT
Sync Status: Synced to main (abc1234)
Health Status: Healthy
Bash
kubectl get pods -n production
TEXT
NAME READY STATUS
orders-api-7d9f8c6b4-2kmpq 1/1 Running
orders-api-7d9f8c6b4-5xnpl 1/1 Running
Common Mistake

Setting selfHeal: false allows configuration drift to persist indefinitely. If someone runs kubectl scale deployment orders-api --replicas=1 during an incident, the cluster stays at 1 replica until someone manually syncs ArgoCD. With selfHeal: true, ArgoCD restores the replica count specified in Git within minutes, automatically. A related trap: if a Horizontal Pod Autoscaler also manages replicas, it will fight selfHeal over the value. Remove replicas from the manifest, or add it to ignoreDifferences, when an HPA is present.

Milestone 5: Build the GitHub Actions CI Pipeline

Concept

This pipeline has three jobs, and the order matters: test, then build-and-push, then update-manifests. Each job only runs if the one before it succeeds (needs:). The last job is the critical GitOps step - it does not deploy anything directly. It only edits a YAML file in the config repo and pushes the change. ArgoCD does the actual deploying, on its own schedule.

Steps

Bash
mkdir -p .github/workflows
cat > .github/workflows/ci-cd.yml << 'EOF'
name: Build and Deploy
on:
push:
branches: [main]
pull_request:
branches: [main]
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
test:
name: Run Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- run: pip install flask pytest requests
- run: pytest tests/ -v
build-and-push:
name: Build and Push Image
runs-on: ubuntu-latest
needs: test
if: github.ref == 'refs/heads/main'
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- name: Login to GHCR
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=sha,prefix=sha-,format=short
type=raw,value=latest,enable={{is_default_branch}}
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
cache-from: type=gha
cache-to: type=gha,mode=max
update-manifests:
name: Update Kubernetes Manifests
runs-on: ubuntu-latest
needs: build-and-push
if: github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v4
with:
repository: your-username/gitops-config
token: ${{ secrets.CONFIG_REPO_TOKEN }}
- name: Update image tag
run: |
NEW_TAG="ghcr.io/${{ github.repository }}:sha-${{ github.sha }}"
sed -i "s|image: ghcr.io/.*|image: ${NEW_TAG}|g" \
apps/orders-api/base/deployment.yaml
grep "image:" apps/orders-api/base/deployment.yaml
- name: Commit and push
run: |
git config user.email "github-actions@github.com"
git config user.name "GitHub Actions"
git add apps/orders-api/base/deployment.yaml
git commit -m "Deploy orders-api:sha-${{ github.sha }}"
git push
EOF

Add tests:

Bash
mkdir tests
cat > tests/test_app.py << 'EOF'
import pytest
import sys
sys.path.insert(0, '.')
from app import app
@pytest.fixture
def client():
app.config['TESTING'] = True
with app.test_client() as client:
yield client
def test_home_returns_200(client):
response = client.get('/')
assert response.status_code == 200
def test_home_returns_json(client):
response = client.get('/')
data = response.get_json()
assert data['service'] == 'orders-api'
assert data['status'] == 'healthy'
def test_health_returns_ok(client):
response = client.get('/health')
assert response.status_code == 200
data = response.get_json()
assert data['status'] == 'ok'
EOF

Create a Fine-grained Personal Access Token scoped to write access on gitops-config only, and add it to the app repo's secrets as CONFIG_REPO_TOKEN.

Bash
cd gitops-app
git add .
git commit -m "Add orders-api Flask application with GitOps pipeline"
git push origin main
Common Mistake

The default GITHUB_TOKEN only has write access to the current repository - it cannot push to gitops-config. Skipping the CONFIG_REPO_TOKEN secret makes the manifest-update step fail with a 403 error every time.

Milestone 6: Watch ArgoCD Sync Automatically

Concept

This is the payoff step - you don't run any deployment command yourself. After the pipeline updates the image tag in gitops-config, ArgoCD detects the change within its default 3-minute polling interval and syncs on its own.

Steps

Bash
watch argocd app get orders-api
TEXT
Sync Status: OutOfSync from main (new-sha)

then:

TEXT
Sync Status: Synced to main (new-sha)
Health Status: Progressing

then:

TEXT
Sync Status: Synced to main (new-sha)
Health Status: Healthy

Verify the new version is actually running:

Bash
kubectl get pods -n production
kubectl describe pod -n production -l app=orders-api | grep Image
TEXT
Image: ghcr.io/your-username/gitops-app:sha-abc1234
Tip

Don't want to wait 3 minutes while testing? Run argocd app sync orders-api to force an immediate sync instead of waiting for the polling interval.

Milestone 7: Practice a Rollback

Concept

The real power of GitOps shows up here: rollback is just git revert. There's no special rollback tooling to learn - the same mechanism that ships a deploy also undoes one.

Steps

Simulate a bad deployment:

Bash
cd gitops-app
sed -i "s/return jsonify({'status': 'ok'}), 200/return jsonify({'status': 'error'}), 500/" app.py
git add .
git commit -m "Bug: health endpoint returning 500"
git push

The pipeline builds and deploys the broken version. Kubernetes marks the pods Unhealthy, and ArgoCD shows the app as Degraded - exactly the signal an on-call engineer at a company like Hotstar would see during a bad rollout.

Roll back:

Bash
git revert HEAD --no-edit
git push

The pipeline triggers again, ArgoCD syncs, and the previously working version is running within minutes - no manual kubectl commands, no SSH, no scrambling.

Tip

Use argocd app history orders-api to see every past sync, and argocd app rollback orders-api <ID> to manually jump to a specific one if a git revert isn't fast enough during an active incident.

Validation & Testing

Verification Steps

Run each of these to confirm the full pipeline works end to end.

Bash
## 1. Confirm ArgoCD Application is synced and healthy
argocd app get orders-api
## Expected: Sync Status Synced, Health Status Healthy
## 2. Confirm pods are running in the production namespace
kubectl get pods -n production
## Expected: all pods READY and Running
## 3. Confirm the deployed image matches the latest git SHA
kubectl describe pod -n production -l app=orders-api | grep Image
## 4. Confirm the health endpoint responds through the Service
kubectl port-forward svc/orders-api -n production 8080:80
curl http://localhost:8080/health
## Expected: {"status": "ok"}

Common Mistakes Recap

Mistake Fix
App code and manifests in one repo Keep app repo and config repo separate
Using :latest as the image tag Always use an immutable tag like the git SHA
selfHeal: false Set selfHeal: true in the sync policy
Missing CONFIG_REPO_TOKEN Add a fine-grained PAT scoped to the config repo
Common Mistake

Committing an image tag update without verifying the sed replacement targeted the right line can produce malformed YAML. Add a grep or cat step immediately after any sed command to confirm the change looks correct before pushing - a single corrupted line will make ArgoCD fail to parse the manifest and halt all deployments for that app.