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
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.
[ 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.
TipWith 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
mkdir gitops-appcd gitops-app cat > app.py << 'EOF'from flask import Flask, jsonifyimport 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.0gunicorn==21.2.0EOF cat > Dockerfile << 'EOF'FROM python:3.12-slim AS builderWORKDIR /appCOPY requirements.txt .RUN pip install --user --no-cache-dir -r requirements.txt FROM python:3.12-slimWORKDIR /appCOPY --from=builder /root/.local /root/.localCOPY app.py . RUN useradd --create-home appuserUSER appuser ENV PATH=/root/.local/bin:$PATHCMD ["gunicorn", "--bind", "0.0.0.0:8080", "--workers", "2", "app:app"]EOFBuild and test locally:
docker build -t orders-api:local .docker run -p 8080:8080 orders-api:local & curl http://localhost:8080/curl http://localhost:8080/health{"environment": "production", "service": "orders-api", "status": "healthy", "version": "v1.0.0"}{"status": "ok"}Common MistakeRunning the Flask development server (
app.run()) directly in production instead ofgunicorn. 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
mkdir gitops-configcd gitops-configmkdir -p apps/orders-api/base cat > apps/orders-api/base/deployment.yaml << 'EOF'apiVersion: apps/v1kind: Deploymentmetadata: name: orders-api namespace: production labels: app: orders-apispec: 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: v1kind: Servicemetadata: name: orders-api namespace: productionspec: selector: app: orders-api ports: - port: 80 targetPort: 8080 type: ClusterIPEOF cat > apps/orders-api/base/namespace.yaml << 'EOF'apiVersion: v1kind: Namespacemetadata: name: productionEOF cat > apps/orders-api/base/kustomization.yaml << 'EOF'apiVersion: kustomize.config.k8s.io/v1beta1kind: Kustomizationresources: - namespace.yaml - deployment.yaml - service.yamlEOFPush it to GitHub:
cd gitops-configgit initgit add .git commit -m "Initial Kubernetes manifests for orders-api"git remote add origin https://github.com/your-username/gitops-config.gitgit push -u origin mainTipNaming the folder
base/even for a single-environment setup pays off later - when you add a staging environment, you create anoverlays/staging/folder that patchesbase/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
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 argocdNAME READY STATUSargocd-application-controller-0 1/1 Runningargocd-applicationset-controller-7f9b6c8d4 1/1 Runningargocd-dex-server-6b8c5f9b7-xqprs 1/1 Runningargocd-redis-7b9c8d5f6-klmnp 1/1 Runningargocd-repo-server-8c7d6b9f5-qrstu 1/1 Runningargocd-server-9d8c7f6b5-vwxyz 1/1 RunningAccess the UI:
kubectl get secret argocd-initial-admin-secret \ -n argocd \ -o jsonpath='{.data.password}' | base64 -d kubectl port-forward svc/argocd-server -n argocd 8080:443Open https://localhost:8080, accept the self-signed certificate warning, and log in with username admin and the password above.
Install the CLI:
## macOSbrew install argocd ## Linuxcurl -sSL -o argocd-linux-amd64 \ https://github.com/argoproj/argo-cd/releases/latest/download/argocd-linux-amd64chmod +x argocd-linux-amd64sudo mv argocd-linux-amd64 /usr/local/bin/argocd argocd login localhost:8080 \ --username admin \ --password YOUR_PASSWORD_HERE \ --insecureSecurityThe initial admin password is auto-generated and should be rotated immediately in any real cluster. Never leave
--insecureenabled 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
cat > argocd-app.yaml << 'EOF'apiVersion: argoproj.io/v1alpha1kind: Applicationmetadata: name: orders-api namespace: argocdspec: 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=trueEOF kubectl apply -f argocd-app.yamlVerify the sync:
argocd app get orders-apiSync Status: Synced to main (abc1234)Health Status: Healthykubectl get pods -n productionNAME READY STATUSorders-api-7d9f8c6b4-2kmpq 1/1 Runningorders-api-7d9f8c6b4-5xnpl 1/1 RunningCommon MistakeSetting
selfHeal: falseallows configuration drift to persist indefinitely. If someone runskubectl scale deployment orders-api --replicas=1during an incident, the cluster stays at 1 replica until someone manually syncs ArgoCD. WithselfHeal: 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 fightselfHealover the value. Removereplicasfrom the manifest, or add it toignoreDifferences, 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
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 pushEOFAdd tests:
mkdir tests cat > tests/test_app.py << 'EOF'import pytestimport syssys.path.insert(0, '.')from app import app @pytest.fixturedef 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'EOFCreate 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.
cd gitops-appgit add .git commit -m "Add orders-api Flask application with GitOps pipeline"git push origin mainCommon MistakeThe default
GITHUB_TOKENonly has write access to the current repository - it cannot push togitops-config. Skipping theCONFIG_REPO_TOKENsecret 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
watch argocd app get orders-apiSync Status: OutOfSync from main (new-sha)then:
Sync Status: Synced to main (new-sha)Health Status: Progressingthen:
Sync Status: Synced to main (new-sha)Health Status: HealthyVerify the new version is actually running:
kubectl get pods -n productionkubectl describe pod -n production -l app=orders-api | grep ImageImage: ghcr.io/your-username/gitops-app:sha-abc1234TipDon't want to wait 3 minutes while testing? Run
argocd app sync orders-apito 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:
cd gitops-appsed -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 pushThe 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:
git revert HEAD --no-editgit pushThe pipeline triggers again, ArgoCD syncs, and the previously working version is running within minutes - no manual kubectl commands, no SSH, no scrambling.
TipUse
argocd app history orders-apito see every past sync, andargocd app rollback orders-api <ID>to manually jump to a specific one if agit revertisn't fast enough during an active incident.
Validation & Testing
Verification Steps
Run each of these to confirm the full pipeline works end to end.
## 1. Confirm ArgoCD Application is synced and healthyargocd app get orders-api## Expected: Sync Status Synced, Health Status Healthy ## 2. Confirm pods are running in the production namespacekubectl get pods -n production## Expected: all pods READY and Running ## 3. Confirm the deployed image matches the latest git SHAkubectl describe pod -n production -l app=orders-api | grep Image ## 4. Confirm the health endpoint responds through the Servicekubectl port-forward svc/orders-api -n production 8080:80curl 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 MistakeCommitting an image tag update without verifying the
sedreplacement targeted the right line can produce malformed YAML. Add agreporcatstep immediately after anysedcommand 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.
Videos & Guides
ArgoCD Official Documentation
Official ArgoCD documentation covering all features including ApplicationSets, Projects, RBAC, and SSO integration for enterprise deployments.
GitHub Actions Official Documentation
Official GitHub Actions documentation covering workflow syntax, runners, events, contexts, secrets management, and all built-in actions and marketplace integrations.
Kustomize Documentation
Reference for the kustomization.yaml format used to organize the Kubernetes manifests ArgoCD watches.