Breaking Away from Traditional CI/CD: Introducing GitOps with ArgoCD

Info
Architecture Diagram Traditional CI/CD pipelines tightly couple build, test, and deployment into a single workflow. Once a change passes tests, the pipeline pushes directly to the target environment. While simple, this model limits visibility, auditability, and control over deployments.

This approach shifts deployment responsibility away from the CI pipeline into a dedicated GitOps repository, where the desired state of the system lives. Deployment is no longer “pushed” — it is pulled and reconciled by ArgoCD.

Repos:

Table of Contents

Prerequisite

  • Kubernetes Cluster
  • Istio installed
  • Argocd installed
  • Github repo - cicd running on github actions

Architecture Overview

  • App Repository
    • Contains source code + CI pipelines
  • GitOps Repository
    • Contains Kubernetes manifests (Kustomize overlays for dev and prod)
  • ArgoCD
    • Watches GitOps repo and syncs cluster state

Repo

Code/App Repo

Check this previous post.

GitOps Repo

Manifest are deployed in ArgoCD using kustomize.

 1istio-demo
 2└── ab-testing
 3    ├── backend
 4    │   ├── base
 5    │   │   ├── deployment.yaml
 6    │   │   ├── kustomization.yaml
 7    │   │   └── service.yaml
 8    │   ├── common
 9    │   │   ├── kustomization.yaml
10    │   │   ├── serviceaccount.yaml
11    │   │   └── service.yaml
12    │   └── overlays
13    │       ├── v1
14    │       │   ├── deployment-patch.yaml
15    │       │   ├── kustomization.yaml
16    │       │   ├── name-patch.json
17    │       │   └── service-patch.yaml
18    │       └── v2
19    │           ├── deployment-patch.yaml
20    │           ├── kustomization.yaml
21    │           ├── name-patch.json
22    │           └── service-patch.yaml
23    ├── environments
24    │   ├── demo-dev
25    │   │   ├── demo-app-gateway-patch.yaml
26    │   │   ├── frontend-authpolicy-patch.yaml
27    │   │   ├── kustomization.yaml
28    │   │   ├── monitor-authpolicy-patch.yaml
29    │   │   └── namespace.yaml
30    │   └── demo-prod
31    │       ├── demo-app-gateway-patch.yaml
32    │       ├── frontend-authpolicy-patch.yaml
33    │       ├── kustomization.yaml
34    │       ├── monitor-authpolicy-patch.yaml
35    │       └── namespace.yaml
36    ├── frontend
37    │   └── base
38    │       ├── deployment.yaml
39    │       ├── kustomization.yaml
40    │       ├── serviceaccount.yaml
41    │       └── service.yaml
42    ├── istio
43    │   └── base
44    │       ├── destinationrule.yaml
45    │       ├── gateway.yaml
46    │       ├── kustomization.yaml
47    │       ├── mtls.yaml
48    │       ├── rbac-readwrite-frontend-to-backend.yaml
49    │       ├── rbac-readwrite-monitor-to-backend.yaml
50    │       └── virtualservice.yaml
51    ├── monitor
52    │   ├── base
53    │   │   ├── deployment.yaml
54    │   │   ├── kustomization.yaml
55    │   │   └── service.yaml
56    │   ├── common
57    │   │   ├── kustomization.yaml
58    │   │   ├── serviceaccount.yaml
59    │   │   └── service.yaml
60    │   └── overlays
61    │       ├── v1
62    │       │   ├── deployment-patch.yaml
63    │       │   ├── kustomization.yaml
64    │       │   └── service-patch.yaml
65    │       └── v2
66    │           ├── deployment-patch.yaml
67    │           ├── kustomization.yaml
68    │           └── service-patch.yaml
69    ├── redis
70    │   └── base
71    │       ├── deployment.yaml
72    │       ├── kustomization.yaml
73    │       ├── serviceaccount.yaml
74    │       └── service.yaml
75    └── smoke-test
76        └── base
77            ├── configs
78            │   └── smoke-test.sh
79            ├── job.yaml
80            ├── kustomization.yaml
81            ├── rbac-smoketest-to-backend.yaml
82            └── serviceaccount.yaml
83
8426 directories, 56 files

ArgoCD Setup

Project

Create a dedicated project, don’t deploy on default project. For this demo I created istio-demo project.

Create from the argoCD UI or manifest.

istio-project.yaml

 1apiVersion: argoproj.io/v1alpha1
 2kind: AppProject
 3metadata:
 4  name: istio-demo
 5  namespace: argocd
 6spec:
 7  sourceRepos:
 8    - https://github.com/mcbtaguiad/gitops-demo.git
 9
10  destinations:
11    - namespace: '*'
12      server: https://kubernetes.default.svc
13
14  clusterResourceWhitelist:
15    - group: '*'
16      kind: '*'

Applications

Create dev and prod application in argocd.

istio-demo-dev.yaml

 1apiVersion: argoproj.io/v1alpha1
 2kind: Application
 3metadata:
 4  name: istio-demo-dev
 5spec:
 6  destination:
 7    namespace: demo-dev
 8    server: https://kubernetes.default.svc
 9  source:
10    path: istio-demo/ab-testing/environments/demo-dev
11    repoURL: https://github.com/mcbtaguiad/gitops-demo.git
12    targetRevision: main
13  sources: []
14  project: istio-demo
15  syncPolicy:
16    automated:
17      prune: true
18      selfHeal: true
19      allowEmpty: false
20    retry:
21      limit: 5
22      backoff:
23        duration: 5s
24        maxDuration: 3m0s
25        factor: 2
26    syncOptions:
27      - CreateNamespace=true
28      - ApplyOutOfSyncOnly=true
29      - RespectIgnoreDifferences=true

istio-demo-prod.yaml

 1apiVersion: argoproj.io/v1alpha1
 2kind: Application
 3metadata:
 4  name: istio-demo-prod
 5spec:
 6  destination:
 7    namespace: demo-prod
 8    server: https://kubernetes.default.svc
 9  source:
10    path: istio-demo/ab-testing/environments/demo-prod
11    repoURL: https://github.com/mcbtaguiad/gitops-demo.git
12    targetRevision: main
13  sources: []
14  project: istio-demo
15  syncPolicy:
16    automated:
17      prune: true
18      selfHeal: true
19      allowEmpty: false
20    retry:
21      limit: 5
22      backoff:
23        duration: 5s
24        maxDuration: 3m0s
25        factor: 2
26    syncOptions:
27      - CreateNamespace=true
28      - ApplyOutOfSyncOnly=true
29      - RespectIgnoreDifferences=true

Phase 1: Code Quality CI

Triggered on every push to any branch, this pipeline enforces quality before merge.

  • code quality check
  • vulnerabilty scans
  • lint check

Pipeline

code-quality.yaml

  1name: code-quality-check
  2
  3on:
  4  push:
  5    branches: [ "**" ]
  6
  7jobs:
  8  checkout:
  9    if: github.event_name == 'push' && github.event.head_commit.message != null && !startsWith(github.event.head_commit.message, 'Merge pull request')
 10    runs-on: ubuntu-latest
 11    outputs:
 12      repo-path: ${{ steps.repo-path.outputs.path }}
 13    steps:
 14      - name: Checkout repository
 15        id: repo-path
 16        uses: actions/checkout@v4
 17
 18  # --- Go Tests ---
 19  backend-test:
 20    if: github.event_name == 'push' && github.event.head_commit.message != null && !startsWith(github.event.head_commit.message, 'Merge pull request')
 21    needs: checkout
 22    runs-on: ubuntu-latest
 23    strategy:
 24      matrix:
 25        go-version: [1.26.2]
 26    steps:
 27      - name: Checkout repo
 28        uses: actions/checkout@v4
 29
 30      - name: Setup Go
 31        uses: actions/setup-go@v4
 32        with:
 33          go-version: ${{ matrix.go-version }}
 34
 35      # - name: Install dependencies
 36      #   working-directory: docker/backend
 37      #   run: go mod tidy
 38
 39      - name: Run Go tests
 40        working-directory: docker/backend
 41        run: |
 42          go mod init github.com/mcbtaguiad/istio-demo/backend
 43          go mod tidy
 44          go test ./... -v
 45
 46      - name: Run govulncheck
 47        working-directory: docker/backend
 48        run: |
 49          go mod tidy
 50          go mod download
 51          go install golang.org/x/vuln/cmd/govulncheck@latest
 52          govulncheck ./...
 53
 54
 55  # --- Python Tests ---
 56  monitor-test:
 57    if: github.event_name == 'push' && github.event.head_commit.message != null && !startsWith(github.event.head_commit.message, 'Merge pull request')
 58    needs: checkout
 59    runs-on: ubuntu-latest
 60    strategy:
 61      matrix:
 62        python-version: [3.13]
 63    steps:
 64      - uses: actions/checkout@v4
 65
 66      - name: Setup Python
 67        uses: actions/setup-python@v5
 68        with:
 69          python-version: ${{ matrix.python-version }}
 70
 71      - name: Install dependencies
 72        run: |
 73          pip install flask pytest pylint black flake8 isort pytest-cov pip-audit
 74
 75      - name: Run pytest
 76        working-directory: docker/monitor
 77        run: pytest . --cov=./
 78
 79      - name: Lint with pylint
 80        working-directory: docker/monitor
 81        run: pylint app.py
 82
 83      - name: Format check with black
 84        working-directory: docker/monitor
 85        run: black --check app.py
 86
 87      - name: Import check with isort
 88        working-directory: docker/monitor
 89        run: isort --check-only app.py
 90
 91      - name: Scan Python dependencies with pip-audit
 92        working-directory: docker/monitor
 93        run: pip-audit -r requirements.txt --strict
 94
 95  # --- React Tests ---
 96  frontend-test:
 97    if: github.event_name == 'push' && github.event.head_commit.message != null && !startsWith(github.event.head_commit.message, 'Merge pull request')
 98    needs: checkout
 99    runs-on: ubuntu-latest
100    steps:
101      - name: Checkout repo
102        uses: actions/checkout@v4
103
104      - name: Setup Node
105        uses: actions/setup-node@v5
106        with:
107          node-version: 25
108          cache: 'npm'
109          cache-dependency-path: docker/frontend/package-lock.json
110
111      - name: Install dependencies
112        working-directory: docker/frontend
113        run: npm ci
114
115      - name: Run React tests
116        working-directory: docker/frontend
117        run: npm run test -- --coverage
118
119      - name: Scan Node dependencies with npm audit
120        working-directory: docker/frontend
121        run: npm audit --audit-level=high

Phase 2: Main CI/CD Pipeline

Triggered on merge to main.

Build & Push Images

  • Multi-service matrix build (frontend, backend, monitor)
  • Tagged with:
    • branch/tag name
    • immutable git sha
  • Pushed to GitHub Container Registry

Vulnerability Scanning

  • uses Trivy
  • fails pipeline on:
    • HIGH or CRITICAL vulnerabilities

Deploy to DEV

Instead of kubectl apply, the pipeline:

  • clones the GitOps repo
  • updates image tags in Kustomize overlays
  • Commits & pushes changes
1backend-v1 -> old SHA
2backend-v2 -> new SHA

This enables:

  • A/B testing
  • Progressive rollout

ArgoCD detects the change and syncs automatically.

Smoke Testing

After ArgoCD sync:

  • wait for all pods to be Ready
  • verify rollout status of all deployments
  • run a Kubernetes Job-based smoke test

Fail if:

  • job fails
  • logs indicate issues

This ensures deployment correctness, not just build succes.

Promote to PROD

Production is not auto-deployed.

Instead:

  • create a promotion branch in GitOps repo
  • update prod overlays with new image SHA
  • open a Pull Request.

This would require a manual trigger and review on the PR first.

  • if PR is approved, argocd sync and rebuild PROD environment.

Pipeline

ci-argocd.yaml

  1name: istio-demo-ci-argocd
  2
  3on:
  4  pull_request:
  5    branches: [ "main" ]
  6    types: [ closed ]
  7
  8env:
  9  REGISTRY: ghcr.io
 10  IMAGE_REPO: ${{ github.repository }}
 11  GITOPS_REPO: mcbtaguiad/gitops-demo
 12
 13jobs:
 14  checkout:
 15    if: github.event.pull_request.merged == true
 16    runs-on: ubuntu-latest
 17    steps:
 18      - name: Checkout repository
 19        id: repo-path
 20        uses: actions/checkout@v4
 21
 22  get-sha:
 23    runs-on: ubuntu-latest
 24    outputs:
 25      sha_new: ${{ steps.sha.outputs.sha_new }}
 26      sha_old: ${{ steps.sha.outputs.sha_old }}
 27    steps:
 28      - uses: actions/checkout@v4
 29        with:
 30          fetch-depth: 2
 31
 32      - id: sha
 33        run: |
 34          echo "sha_new=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT
 35          echo "sha_old=$(git rev-parse HEAD~1)" >> $GITHUB_OUTPUT
 36
 37  # --- Docker Build & Push ---
 38  build:
 39    if: github.event.pull_request.merged == true
 40    needs: [checkout, get-sha]
 41    runs-on: ubuntu-latest
 42    permissions:
 43      contents: read
 44      packages: write
 45    strategy:
 46      matrix:
 47        service: [frontend, backend, monitor]
 48
 49    steps:
 50      - name: Checkout repository
 51        uses: actions/checkout@v4
 52
 53      - name: Set up Docker Buildx
 54        uses: docker/setup-buildx-action@v3
 55
 56      - name: Log in to GitHub Container Registry
 57        uses: docker/login-action@v2
 58        with:
 59          registry: ${{ env.REGISTRY }}
 60          username: ${{ github.actor }}
 61          password: ${{ secrets.GH_TOKEN }}
 62
 63      - name: Set service-specific variables
 64        run: |
 65          case "${{ matrix.service }}" in
 66            frontend)
 67              echo "IMAGE_NAME=frontend" >> $GITHUB_ENV
 68              echo "CONTEXT=./docker/frontend" >> $GITHUB_ENV
 69              echo "PORT=8080" >> $GITHUB_ENV
 70              ;;
 71            backend)
 72              echo "IMAGE_NAME=backend" >> $GITHUB_ENV
 73              echo "CONTEXT=./docker/backend" >> $GITHUB_ENV
 74              echo "PORT=3000" >> $GITHUB_ENV
 75              ;;
 76            monitor)
 77              echo "IMAGE_NAME=monitor" >> $GITHUB_ENV
 78              echo "CONTEXT=./docker/monitor" >> $GITHUB_ENV
 79              echo "PORT=8000" >> $GITHUB_ENV
 80              ;;
 81          esac
 82
 83      - name: Determine image tag
 84        id: tag
 85        run: |
 86          if [[ "${GITHUB_REF}" == refs/tags/* ]]; then
 87            echo "IMAGE_TAG=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV
 88          else
 89            echo "IMAGE_TAG=${GITHUB_REF_NAME}" >> $GITHUB_ENV
 90          fi
 91
 92      - name: Build and push Docker image
 93        uses: docker/build-push-action@v4
 94        with:
 95          context: ${{ env.CONTEXT }}
 96          file: ${{ env.CONTEXT }}/Dockerfile
 97          push: true
 98          tags: |
 99            ${{ env.REGISTRY }}/${{ env.IMAGE_REPO }}/${{ env.IMAGE_NAME }}:${{ env.IMAGE_TAG }}
100            ${{ env.REGISTRY }}/${{ env.IMAGE_REPO }}/${{ env.IMAGE_NAME }}:${{ github.sha }}
101          labels: |
102            org.opencontainers.image.created=${{ github.run_started_at }}
103            org.opencontainers.image.revision=${{ github.sha }}
104            org.opencontainers.image.source=https://github.com/${{ github.repository }}
105            org.opencontainers.image.title=istio-demo
106            org.opencontainers.image.version=${{ env.IMAGE_TAG }}
107          build-args: |
108            ENVIRONMENT=${{ github.ref_name == 'main' && 'prod' || 'dev' }}
109
110          cache-from: type=gha
111          cache-to: type=gha,mode=max
112
113  vulnerability-scan:
114    if: github.event.pull_request.merged == true
115    needs: [build, get-sha]
116    runs-on: ubuntu-latest
117    strategy:
118      matrix:
119        service: [frontend, backend, monitor]
120    steps:
121      - name: Log in to GitHub Container Registry
122        uses: docker/login-action@v2
123        with:
124          registry: ${{ env.REGISTRY }}
125          username: ${{ github.actor }}
126          password: ${{ secrets.GH_TOKEN }}
127      - name: Scan Docker image with Trivy
128        uses: aquasecurity/trivy-action@v0.35.0
129        with:
130          scan-type: image
131          # image-ref: ${{ env.REGISTRY }}/${{ env.IMAGE_REPO }}/${{ matrix.service }}:${{ github.ref_name }}
132          image-ref: ${{ env.REGISTRY }}/${{ env.IMAGE_REPO }}/${{ matrix.service }}:${{ github.sha }}
133          exit-code: '1'
134          severity: HIGH,CRITICAL
135          format: table
136          ignore-unfixed: true
137
138  deploy-dev:
139    if: github.event.pull_request.merged == true
140    needs: [vulnerability-scan, get-sha]
141    runs-on: ubuntu-latest
142
143    env:
144      SHA_NEW: ${{ needs.get-sha.outputs.sha_new }}
145      SHA_OLD: ${{ needs.get-sha.outputs.sha_old }}
146
147    steps:
148      - name: Clone GitOps repo
149        run: |
150          git clone -b main https://x-access-token:${{ secrets.GH_TOKEN }}@github.com/${{ env.GITOPS_REPO }}.git
151          cd gitops-demo
152
153          git config user.name "github-actions"
154          git config user.email "actions@github.com"
155
156      - name: Update DEV images (A/B)
157        run: |
158          cd gitops-demo/istio-demo/ab-testing/environments/demo-dev 
159
160          yq -i '
161          (.patches[] | select(.target.name == "backend-v1") | .patch) |=
162            sub("backend:.*"; "backend:" + env(SHA_OLD)) |
163
164          (.patches[] | select(.target.name == "backend-v2") | .patch) |=
165            sub("backend:.*"; "backend:" + env(SHA_NEW)) |
166
167          (.patches[] | select(.target.name == "monitor-v1") | .patch) |=
168            sub("monitor:.*"; "monitor:" + env(SHA_OLD)) |
169
170          (.patches[] | select(.target.name == "monitor-v2") | .patch) |=
171            sub("monitor:.*"; "monitor:" + env(SHA_NEW))
172          ' kustomization.yaml
173
174      - name: Commit & push DEV
175        run: |
176          cd gitops-demo
177          git add .
178          git commit -m "dev: deploy ${{ env.SHA_NEW }}"
179          git push
180
181  smoke-test:
182    if: github.event.pull_request.merged == true
183    needs: deploy-dev
184    runs-on: ubuntu-latest
185
186    env:
187      # APP_HOST: ${{ secrets.DEV_APP_HOST }}
188      NAMESPACE: demo-dev
189      JOB_NAME: smoke-test
190
191    steps:
192      - name: Checkout repo
193        uses: actions/checkout@v4
194
195      # Setup kubectl (assumes kubeconfig stored as secret)
196      - name: Set up kubeconfig
197        run: |
198          mkdir -p $HOME/.kube
199          echo "${{ secrets.KUBECONFIG }}" > $HOME/.kube/config
200
201      # Wait for pods to be Ready
202      - name: Wait for Kubernetes pods
203        run: |
204          echo "Waiting for pods in namespace $NAMESPACE..."
205
206          kubectl wait --for=condition=ready pod \
207            --all \
208            -n $NAMESPACE \
209            --timeout=120s \
210            --insecure-skip-tls-verify=true
211
212      - name: Verify rollout
213        run: |
214          kubectl rollout status deployment/frontend --insecure-skip-tls-verify=true -n $NAMESPACE
215          kubectl rollout status deployment/backend-v1 --insecure-skip-tls-verify=true -n $NAMESPACE
216          kubectl rollout status deployment/backend-v2 --insecure-skip-tls-verify=true -n $NAMESPACE
217          kubectl rollout status deployment/monitor-v1 --insecure-skip-tls-verify=true -n $NAMESPACE
218          kubectl rollout status deployment/monitor-v2 --insecure-skip-tls-verify=true -n $NAMESPACE
219          kubectl rollout status deployment/redis --insecure-skip-tls-verify=true -n $NAMESPACE
220
221      - name: Deploy smoke test job
222        run: |
223          kubectl create -k test/smoke-test/environments/demo-dev --insecure-skip-tls-verify=true
224
225      - name: Wait for job completion
226        run: |
227          echo "Waiting for job to complete..."
228
229          kubectl wait \
230            --for=condition=complete \
231            job/$JOB_NAME \
232            -n $NAMESPACE \
233            --timeout=180s \
234            --insecure-skip-tls-verify=true
235
236      - name: Check job status
237        run: |
238          FAILED=$(kubectl get job $JOB_NAME -n $NAMESPACE -o jsonpath='{.status.failed}' --insecure-skip-tls-verify=true)
239          SUCCEEDED=$(kubectl get job $JOB_NAME -n $NAMESPACE -o jsonpath='{.status.succeeded}' --insecure-skip-tls-verify=true)
240
241          echo "Succeeded: $SUCCEEDED"
242          echo "Failed: $FAILED"
243
244          if [ "$FAILED" != "" ] && [ "$FAILED" != "0" ]; then
245            echo "Smoke test FAILED"
246            kubectl logs job/$JOB_NAME -n $NAMESPACE --insecure-skip-tls-verify=true
247            exit 1
248          fi
249
250          if [ "$SUCCEEDED" == "1" ]; then
251            echo "Smoke test PASSED"
252          else
253            echo "Smoke test did not complete successfully"
254            kubectl logs job/$JOB_NAME -n $NAMESPACE --insecure-skip-tls-verify=true
255            exit 1
256          fi
257
258      - name: Delete smoke test job
259        run: |
260          kubectl delete -k test/smoke-test/environments/demo-dev --insecure-skip-tls-verify=true
261
262  deploy-prod:
263    if: github.event.pull_request.merged == true
264    needs: [smoke-test, get-sha]
265    runs-on: ubuntu-latest
266
267    env:
268      SHA_NEW: ${{ needs.get-sha.outputs.sha_new }}
269      SHA_OLD: ${{ needs.get-sha.outputs.sha_old }}
270
271    steps:
272      - name: Clone GitOps repo
273        run: |
274          git clone -b main https://x-access-token:${{ secrets.GH_TOKEN }}@github.com/${{ env.GITOPS_REPO }}.git
275          cd gitops-demo
276
277          git config user.name "github-actions"
278          git config user.email "actions@github.com"
279
280      - name: Create promotion branch
281        run: |
282          cd gitops-demo
283          BRANCH=promote-${{ env.SHA_NEW }}
284
285          git checkout -b $BRANCH
286
287      - name: Update PROD images (A/B)
288        run: |
289          cd gitops-demo/istio-demo/ab-testing/environments/demo-prod
290
291          yq -i '
292          (.patches[] | select(.target.name == "backend-v1") | .patch) |=
293            sub("backend:.*"; "backend:" + env(SHA_OLD)) |
294
295          (.patches[] | select(.target.name == "backend-v2") | .patch) |=
296            sub("backend:.*"; "backend:" + env(SHA_NEW)) |
297
298          (.patches[] | select(.target.name == "monitor-v1") | .patch) |=
299            sub("monitor:.*"; "monitor:" + env(SHA_OLD)) |
300
301          (.patches[] | select(.target.name == "monitor-v2") | .patch) |=
302            sub("monitor:.*"; "monitor:" + env(SHA_NEW))
303          ' kustomization.yaml
304
305      - name: Commit & push DEV
306        run: |
307          cd gitops-demo
308          BRANCH=promote-${{ env.SHA_NEW }}
309
310          git add .
311          git commit -m "prod: deploy ${{ env.SHA_NEW }}"
312          git push origin $BRANCH
313
314      - name: Create PR
315        run: |
316          BRANCH=promote-${{ env.SHA_NEW }}
317
318          gh pr create \
319            --repo ${{ env.GITOPS_REPO }} \
320            --base main \
321            --head $BRANCH \
322            --title "Promote to prod: ${{ env.SHA_NEW }}" \
323            --body "Automated promotion from dev to prod"
324        env:
325          GH_TOKEN: ${{ secrets.GH_TOKEN }}