Breaking Away from Traditional CI/CD: Introducing GitOps with ArgoCD
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 }}