GitOps & CI/CD on Kubernetes
Pushing YAML with kubectl apply from a laptop does not scale past one team. GitOps makes git the single source of truth: CI builds and tests artifacts; a cluster reconciler (ArgoCD or Flux) continuously applies declared state and self-heals drift. On OpenShift, certified operators ship ArgoCD and Tekton Pipelines, and BuildConfig integrates image builds with ImageStream triggers—closing the loop from commit to running pod.
GitOps Principles
GitOps is not a product—it is an operational model. The cluster reconciler watches a git repository (or OCI artifact registry) and continuously drives live state toward declared state. Humans change production by merging pull requests, not by running CLI commands.
flowchart LR DEV["Developer\nPR merge"] GIT["Git repo\nsource of truth"] CI["CI pipeline\nbuild + test"] REG["Container registry\nimage tag"] GITOPS["GitOps controller\nArgoCD / Flux"] K8S["Kubernetes API\netcd desired state"] RUN["Running workloads\npods, services, ingress"] DEV --> GIT GIT --> CI CI --> REG CI -->|"update image tag\nin git"| GIT GIT --> GITOPS GITOPS -->|"apply / prune"| K8S K8S --> RUN RUN -.->|"drift detected"| GITOPS GITOPS -.->|"self-heal"| K8S
Core principles
| Principle | What it means | Anti-pattern |
|---|---|---|
| Declarative | Desired state expressed as YAML/Helm/Kustomize in git—not imperative shell scripts | kubectl set image in a deploy script |
| Versioned & immutable | Every change is a git commit with audit trail, review, and rollback via revert | Editing live manifests with no record |
| Automatic | Controller polls or webhooks git; applies changes without manual intervention | Ops ticket to "please deploy v2.3" |
| Self-healing | Drift from git (manual edits, failed partial apply) is corrected on next sync | Cluster diverges silently from documentation |
| Git as source of truth | What is in git is what should run—not what someone last typed in a terminal | Spreadsheet of "current prod versions" |
No kubectl in production
Production changes flow through git merge → reconciler apply. Direct kubectl apply or oc apply from CI or laptops bypasses review, breaks audit trails, and fights the reconciler (self-heal will revert manual edits—or worse, you disable self-heal and drift accumulates).
- Allowed: read-only debugging (kubectl get/describe/logs), emergency break-glass with documented procedure
- CI job writes to git: bump image tag, open PR, merge after checks—never push manifests directly to API
- Break-glass: time-limited RBAC, incident ticket, follow-up PR to restore git truth
Teams adopt ArgoCD but keep kubectl apply -f in Jenkins. The controller and CI fight over ownership; rollbacks become unpredictable. Pick one delivery path: git → reconciler, period.
GitOps controllers are themselves Kubernetes operators. They watch CRDs (Application, Kustomization) and custom resources, render manifests (helm template, kustomize build), then use server-side apply or strategic merge to the API server—same machinery as kubectl, but automated and auditable.
"How is GitOps different from CI/CD?" — CI/CD answers how artifacts are built and tested. GitOps answers how cluster state is delivered and kept in sync. CI ends at the registry (and a git commit); GitOps starts at that commit and reconciles continuously.
ArgoCD
Argo CD is a declarative GitOps continuous delivery tool for Kubernetes. It ships as a set of controllers plus a UI and CLI, watches git repos, and manages the lifecycle of applications defined by the Application CRD.
Architecture components
- API Server — gRPC/REST API, Web UI, RBAC enforcement, webhook receiver
- Repository Server — clones git, generates manifests (plain YAML, Helm, Kustomize, Jsonnet, plugins)
- Application Controller — compares live vs desired state, drives sync, tracks health
- Redis — caching layer for generated manifests and session state
- Dex (optional) — OIDC/LDAP/SAML SSO connector
- ApplicationSet Controller — generates many Application resources from generators
- Notifications Controller — Slack, email, GitHub status via Notification CRs
flowchart TB
subgraph git["Git / Helm repos"]
REPO["Git repository\nmanifests + values"]
end
subgraph argo["Argo CD namespace"]
API["API Server\nUI + webhooks"]
RS["Repo Server\nhelm template / kustomize"]
AC["Application Controller\ncompare + sync"]
AS["ApplicationSet Controller"]
NC["Notifications Controller"]
RD["Redis cache"]
end
subgraph cluster["Target cluster(s)"]
K8S["Kubernetes API"]
WL["Workloads"]
end
REPO --> RS
API --> AC
RS --> AC
AC --> RD
AS -->|"generates Application CRs"| AC
AC -->|"apply / prune / hook"| K8S
K8S --> WL
AC --> NC
Application CR
An Application binds a source (git path, Helm chart, OCI) to a destination (cluster + namespace). ArgoCD continuously reconciles that binding. Store Application CRs in the argocd namespace (or allow them in app namespaces with project RBAC).
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: payments-api
namespace: argocd
finalizers:
- resources-finalizer.argocd.argoproj.io
spec:
project: team-payments # AppProject RBAC scope
source:
repoURL: https://github.com/acme/gitops-config.git
targetRevision: main # branch, tag, or commit SHA
path: apps/payments/overlays/prod
kustomize:
images:
- payments-api=registry.acme.io/payments:v2.4.1
destination:
server: https://kubernetes.default.svc
namespace: payments-prod
syncPolicy:
automated:
prune: true # delete resources removed from git
selfHeal: true # revert manual cluster edits
allowEmpty: false
syncOptions:
- CreateNamespace=true
- ServerSideApply=true
retry:
limit: 5
backoff:
duration: 5s
factor: 2
maxDuration: 3m
revisionHistoryLimit: 10
Sync policy
- Manual sync — operator clicks Sync or runs argocd app sync; good for regulated change windows
- Automated sync — applies on every git change; pair with prune and selfHeal carefully
- Sync options — CreateNamespace, ServerSideApply, ApplyOutOfSyncOnly, PruneLast
- Sync waves & hooks — ordering via argocd.argoproj.io/sync-wave annotation; PreSync/PostSync/Skip hooks for jobs and migrations
Sync & health status
| Sync status | Meaning |
|---|---|
| Synced | Live state matches desired git revision |
| OutOfSync | Drift or pending changes to apply |
| Unknown | Cannot compare (repo access, render error) |
| Health status | Meaning |
|---|---|
| Healthy | All resources pass built-in or custom health checks |
| Progressing | Rollout in flight (Deployment replicas coming up) |
| Degraded | Failed pods, crash loops, missing dependencies |
| Missing | Expected resources absent from cluster |
| Suspended | App scaled to zero or paused |
App of Apps
Bootstrap pattern: a root Application points at a directory of child Application manifests. Deploying the root app creates the entire fleet. Common for platform teams managing dozens of microservices and cluster add-ons.
ApplicationSet
Generates Application CRs from generators—git directory discovery, cluster list, SCM provider (GitHub/GitLab apps), matrix, merge. Ideal for multi-cluster (same app to dev/stage/prod) and monorepo folder-per-service layouts.
- Git generator — one Application per subdirectory under apps/*
- Cluster generator — fan-out to registered cluster secrets
- SCM provider — discover repos in an org automatically
Hooks & health checks
ArgoCD respects Helm-style hooks and sync-wave annotations. Use PreSync for DB migrations, PostSync for smoke tests, Sync for main resources. Custom health checks in argocd-cm ConfigMap extend Lua logic for CRDs (e.g. cert-manager Certificate, Crossplane claims).
Notifications
The notifications controller watches Application events and sends alerts on sync failed, health degraded, deployed. Configure triggers and templates in argocd-notifications-cm; subscribe per-app via annotations (notifications.argoproj.io/subscribe.on-sync-succeeded.slack: channel).
RBAC & Projects
AppProject restricts which repos, clusters, namespaces, and resource kinds an Application may target. ArgoCD RBAC (Casbin policies in argocd-rbac-cm) maps OIDC groups to roles: role:team-payments → get/sync apps in project team-payments only.
OpenShift GitOps operator
Red Hat ships ArgoCD as the OpenShift GitOps operator (OLM). Install via OperatorHub; it creates an ArgoCD instance in openshift-gitops namespace, integrates with OpenShift OAuth, and is supported on OCP subscriptions. Use ArgoCD CR to configure HA, resource limits, and repo credentials.
$ kubectl get applications -n argocd $ kubectl describe application payments-api -n argocd → Sync Status: Synced | Health: Healthy $ argocd app diff payments-api --revision main $ argocd app sync payments-api --prune $ kubectl logs -n argocd deploy/argocd-application-controller -f$ oc get argocd -n openshift-gitops $ oc get applications -n openshift-gitops $ oc route -n openshift-gitops → openshift-gitops-server URL for Web UI (OAuth login) $ oc adm policy add-cluster-role-to-user cluster-admin developer -z openshift-gitops-argocd-application-controller -n openshift-gitops # ↑ only for break-glass cross-namespace debugging — prefer AppProject RBAC
Install OpenShift GitOps from redhat-operators CatalogSource. Default instance lives in openshift-gitops; cluster admins get ArgoCD admin via OAuth. For tenant isolation, create separate ArgoCD instances or strict AppProjects per team namespace.
Store repo credentials in Kubernetes Secrets referenced by repository CRs or argocd-repo-creds label selectors. For private Helm OCI registries, configure enableOCI and credential templates. Pin targetRevision to tags or SHAs in production—not floating HEAD.
ArgoCD's application controller needs broad RBAC on target clusters. Scope blast radius with AppProjects (deny cluster-scoped resources, restrict namespaces), use separate ArgoCD instances per environment, and never embed long-lived cluster-admin kubeconfigs in CI.
Flux
Flux CD (v2) is a CNCF GitOps toolkit built from composable controllers. Instead of one monolithic app server, you install only the controllers you need—source, kustomize, helm, notification, image automation.
Modular controllers
- Source Controller — watches GitRepository, HelmRepository, Bucket, OCIRepository
- Kustomize Controller — applies Kustomization CRs (built-in Kustomize + health checks)
- Helm Controller — manages HelmRelease CRs (install/upgrade/test/rollback)
- Notification Controller — dispatches events to Slack, Teams, Git commit status
- Image Reflector / Automation — scans registries, updates git with new tags
GitRepository
Declares a git source and revision to track. The source controller clones (or pulls) on interval and exposes an artifact in the status (.status.artifact) that downstream CRs reference.
apiVersion: source.toolkit.fluxcd.io/v1
kind: GitRepository
metadata:
name: platform-config
namespace: flux-system
spec:
interval: 1m
url: https://github.com/acme/platform-gitops.git
ref:
branch: main
secretRef:
name: github-deploy-key
---
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata:
name: payments-prod
namespace: flux-system
spec:
interval: 5m
targetNamespace: payments-prod
sourceRef:
kind: GitRepository
name: platform-config
path: ./apps/payments/overlays/prod
prune: true
wait: true # block until resources healthy
timeout: 5m
healthChecks:
- apiVersion: apps/v1
kind: Deployment
name: payments-api
namespace: payments-prod
postBuild:
substitute:
cluster_env: production
dependsOn:
- name: cluster-addons # ordering: addons before apps
HelmRelease
Flux-native Helm operator. Declares chart source (HelmRepository or git), values, upgrade remediation (rollback on failure), and test hooks. Replaces helm upgrade --install in CI.
apiVersion: helm.toolkit.fluxcd.io/v2
kind: HelmRelease
metadata:
name: ingress-nginx
namespace: flux-system
spec:
interval: 10m
targetNamespace: ingress-nginx
chart:
spec:
chart: ingress-nginx
version: "4.10.x"
sourceRef:
kind: HelmRepository
name: ingress-nginx
namespace: flux-system
install:
remediation:
retries: 3
upgrade:
remediation:
remediateLastFailure: true
values:
controller:
replicaCount: 2
Flux vs ArgoCD
| Dimension | Flux | ArgoCD |
|---|---|---|
| Architecture | Modular controllers; Kubernetes-native CRs only | Unified control plane + rich UI/CLI |
| UI | Minimal (Weave GitOps / third-party); CLI + flux get | First-class Web UI, app topology, diff view |
| Multi-tenancy | CRs in tenant namespaces; RBAC on CRDs | AppProject + built-in RBAC policies |
| Image automation | Built-in ImageRepository / ImagePolicy / ImageUpdateAutomation | Image Updater (Argo CD Image Updater project) |
| Helm | HelmRelease CR with native rollback/remediation | Helm source type in Application CR |
| OpenShift | Community / self-managed install | Red Hat OpenShift GitOps operator (supported) |
Image Automation
Flux can close the image tag loop without custom CI scripts:
- ImageRepository — scans container registry on interval
- ImagePolicy — semver range or alphabetical filter (1.0.x)
- ImageUpdateAutomation — commits updated tags to git via GitRepository push credentials
Kustomize images field or Helm values markers (# {"$imagepolicy": ...}) tell the automation which fields to rewrite.
$ flux check $ flux get sources git -A $ flux get kustomizations -A → READY=True means last reconcile succeeded $ flux reconcile kustomization payments-prod --with-source $ kubectl describe kustomization payments-prod -n flux-system$ flux install --namespace=flux-system $ oc get pods -n flux-system $ flux get helmreleases -A
Choose ArgoCD when teams need a visual ops console, multi-cluster app maps, and Red Hat support on OCP. Choose Flux when you want pure CRD-driven GitOps, tighter Helm lifecycle control, built-in image automation, and a smaller operational footprint without a UI dependency.
Use dependsOn on Flux Kustomizations to enforce ordering: install CRDs and operators before application workloads. Equivalent to ArgoCD sync waves but declared between CRs rather than annotations.
CI/CD Pipeline Patterns
CI builds trust in artifacts; GitOps delivers them. The boundary is a git commit—not a kubectl apply step in Jenkins.
GitOps pipeline steps
- Developer merges to app repo — feature branch → main; triggers CI on application source code
- CI builds & tests — unit tests, SAST, container image build, push to registry with immutable tag (git SHA or semver)
- CI updates GitOps repo — bot opens PR (or commits) bumping image tag in overlay/kustomization.yaml or Helm values
- GitOps reconciler deploys — ArgoCD/Flux detects new commit, syncs cluster; health checks gate promotion
flowchart TB
subgraph app["Application repo"]
CODE["Source code\nDockerfile / tests"]
CIP["CI pipeline\nGitHub Actions / Tekton"]
end
subgraph reg["Registry"]
IMG["payments-api:abc123f"]
end
subgraph gitops["GitOps repo"]
MAN["manifests/overlays/prod\nimage tag abc123f"]
PR["Pull request\nplatform review"]
end
subgraph deliver["Delivery"]
REC["ArgoCD / Flux"]
PROD["Production cluster"]
end
CODE --> CIP
CIP --> IMG
CIP -->|"PR: bump tag"| MAN
MAN --> PR
PR -->|"merge"| REC
REC --> PROD
IMG -.->|"pulled by kubelet"| PROD
CI vs GitOps repo separation
Keep application repos (code, Dockerfile, unit tests) separate from GitOps repos (K8s manifests, environment config). Reasons:
- Different access models — developers push code; only platform bots merge to prod gitops
- Different promotion paths — same image tag promoted dev → stage → prod via overlay PRs
- Blast radius — compromised app CI cannot directly mutate cluster RBAC or cluster-scoped resources
- Audit — gitops repo history is the deployment ledger
Monorepo variant: /src and /deploy directories with path-filtered CI workflows—works for smaller teams but weakens access separation.
Never kubectl in CI — anti-pattern
# Jenkins / GitLab CI stage — IMPERATIVE DEPLOY
docker build -t payments:$GIT_SHA .
docker push registry.acme.io/payments:$GIT_SHA
kubectl set image deployment/payments-api \
payments=registry.acme.io/payments:$GIT_SHA -n prod
kubectl rollout status deployment/payments-api -n prod
# Problems: no git audit, fights GitOps self-heal, kubeconfig in CI is cluster-admin bait
# CI ends at registry + git commit to GitOps repo
docker build -t registry.acme.io/payments:${GIT_SHA} .
docker push registry.acme.io/payments:${GIT_SHA}
yq -i '.images[0].newTag = strenv(GIT_SHA)' deploy/overlays/prod/kustomization.yaml
git commit -am "deploy payments ${GIT_SHA}"
git push origin HEAD # or open PR via gh pr create
# ArgoCD/Flux reconciles — CI holds zero cluster credentials
Storing cluster-admin kubeconfigs in CI secrets "because it's faster" centralizes catastrophic risk. If CI is compromised, attackers own every cluster that credential touches. GitOps repos need only git write access—much smaller blast radius.
Mature platforms use environment branches or folders (overlays/dev, overlays/prod) and require manual approval on prod PRs. CI auto-merges to dev; prod needs platform sign-off. ArgoCD Image Updater or Flux Image Automation handles patch bumps; minor/major still go through PR review.
Draw the four-step loop on a whiteboard: merge → CI → git tag bump → reconciler. Emphasize that rollback is git revert, not re-running an old pipeline with different flags.
Tekton (OpenShift Pipelines)
Tekton is a Kubernetes-native CI/CD framework: pipelines are CRDs, steps run in pods, and artifacts flow through workspaces. Red Hat packages it as the OpenShift Pipelines operator.
Core CRDs
| Resource | Role | Analogy |
|---|---|---|
| Task | Single unit of work (sequence of steps in one pod) | Makefile target / Jenkins stage script |
| Pipeline | Ordered DAG of Tasks with params and workspaces | Jenkinsfile / GitHub Actions workflow |
| TaskRun | Runtime instance of a Task | Single stage execution |
| PipelineRun | Runtime instance of a Pipeline | Full pipeline execution |
| ClusterTask | Cluster-scoped Task shared across namespaces | Shared library step |
Workspaces
Workspaces pass data between tasks: source code (PVC, emptyDir, git clone), cache volumes, Docker config secrets, and optional results. PipelineRun binds concrete volumes to Pipeline workspace declarations.
- volumeClaimTemplate — dynamic PVC per PipelineRun (CI scratch space)
- persistentVolumeClaim — shared cache (Maven, npm) across runs
- secret — .dockerconfigjson for registry push
apiVersion: tekton.dev/v1
kind: PipelineRun
metadata:
generateName: payments-build-
namespace: team-payments
spec:
pipelineRef:
name: build-and-push
taskRunTemplate:
podTemplate:
securityContext:
fsGroup: 65532
params:
- name: git-url
value: https://github.com/acme/payments.git
- name: git-revision
value: main
- name: image
value: image-registry.openshift-image-registry.svc:5000/team-payments/payments-api
workspaces:
- name: source
volumeClaimTemplate:
spec:
accessModes: [ReadWriteOnce]
resources:
requests:
storage: 1Gi
- name: docker-credentials
secret:
secretName: push-secret
timeouts:
pipeline: 30m
OpenShift Pipelines operator
Install from OperatorHub (redhat-openshift-pipelines). Ships Tekton Pipelines, Triggers, Chains (SLSA attestations), and Hub integration. Console adds a Pipelines perspective; tkn CLI is the primary UX.
Tekton Hub
Catalog of community Tasks and Pipelines (git-clone, buildah, kaniko, helm-upgrade). Reference via resolver: hub.tekton.dev/… in taskRef or bundle OCI artifacts for air-gapped installs.
Triggers & EventListener
Event-driven CI: TriggerBinding extracts payload fields (git SHA, repo URL), TriggerTemplate renders PipelineRun YAML, EventListener exposes HTTP endpoint (Knative or K8s Service) for GitHub/GitLab webhooks.
$ tkn pipeline list -n team-payments $ tkn pipelinerun logs -f -n team-payments $ kubectl get taskrun -n team-payments $ tkn pipelinerun describe payments-build-x7k2p -n team-payments$ oc get tektonconfig $ oc get pods -n openshift-pipelines $ tkn hub install task git-clone $ oc get eventlistener -n team-payments → expose Route or Ingress for webhook URL
Tekton pipelines on OCP commonly chain: git-clone → buildah (or s2i-build) → push to internal ImageStream → GitOps PR bot bumps tag. Use Pipeline in app namespace; platform provides ClusterTask catalog.
Each Tekton step runs in a container on the TaskRun pod. Unlike Jenkins agents, there's no long-lived worker—every run is ephemeral pods, which maps cleanly to Kubernetes resource quotas and Pod Security Standards.
Set timeouts.pipeline and timeouts.tasks to prevent stuck workspaces from holding PVCs. Use finally tasks for cleanup (slack notify, git status) even when pipeline fails.
Image Build in OpenShift
OpenShift predates widespread Dockerfile CI and ships a first-class BuildConfig API. Builds run as pods, output to ImageStream tags, and can trigger Deployments automatically—tight integration for teams not yet on external registry + GitOps-only flows.
BuildConfig strategies
| Strategy | How it works | When to use |
|---|---|---|
| Source (S2I) | Inject source into builder image; no Dockerfile required | Standard frameworks (OpenJDK, Python, Node) with approved base images |
| Docker | Build from Dockerfile in git or binary upload | Custom OS packages, multi-stage builds |
| Custom | Your builder image receives source + previous image layers | Legacy build tooling wrapped as image |
apiVersion: build.openshift.io/v1
kind: BuildConfig
metadata:
name: payments-api
namespace: team-payments
spec:
source:
type: Git
git:
uri: https://github.com/acme/payments.git
ref: main
contextDir: /services/payments-api
strategy:
type: Docker
dockerStrategy:
dockerfilePath: Dockerfile
output:
to:
kind: ImageStreamTag
name: payments-api:latest
triggers:
- type: ConfigChange
- type: ImageChange
imageChange:
from:
kind: ImageStreamTag
name: ubi9-openjdk-21:latest
- type: GitHub
github:
secretReference:
name: github-webhook-secret
Buildah
OpenShift builds increasingly use Buildah (rootless OCI image build) inside build pods instead of the Docker daemon. Tekton's buildah task is the portable equivalent for pipeline-based builds on OCP and vanilla K8s (with privileged or custom SCC).
oc start-build
Manually queue a build from CLI—useful for debugging, hotfix binary uploads, or triggering from scripts. Does not replace GitOps for deploy; only produces a new image tag.
$ # BuildConfig is OpenShift-specific — use Tekton/Kaniko on vanilla K8s $ kubectl get builds 2>/dev/null || echo "Install OpenShift or use CI pipelines"$ oc start-build payments-api --from-dir=. --follow $ oc logs -f build/payments-api-42 $ oc get imagestream payments-api -o yaml → status.tags lists pushed digests $ oc describe bc payments-api $ oc import-image ubi9/openjdk-21:latest --confirm
ImageStream triggers
When a build pushes a new image to an ImageStreamTag, OpenShift can automatically roll out dependent workloads:
- DeploymentConfig (legacy) — native ImageChange trigger on DeploymentConfig
- Deployment — annotate with image.openshift.io/triggers JSON referencing ImageStreamTag
- Build chaining — base image ImageChange trigger rebuilds downstream apps when platform base updates
apiVersion: apps/v1
kind: Deployment
metadata:
name: payments-api
namespace: team-payments
annotations:
image.openshift.io/triggers: |
[{"from":{"kind":"ImageStreamTag","name":"payments-api:latest"},"fieldPath":"spec.template.spec.containers[0].image"}]
spec:
template:
spec:
containers:
- name: payments-api
image: image-registry.openshift-image-registry.svc:5000/team-payments/payments-api:latest
sequenceDiagram participant GH as GitHub webhook participant BC as BuildConfig participant BP as Build pod\nBuildah/S2I participant IS as ImageStream participant DEP as Deployment participant GIT as GitOps repo participant AR as ArgoCD GH->>BC: Git push trigger BC->>BP: Start build pod BP->>IS: Push new image tag IS->>DEP: ImageChange rollout (optional) Note over GIT,AR: Preferred production path BP->>GIT: CI bumps image tag in git GIT->>AR: Sync deployment manifest AR->>DEP: Reconcile desired state
ImageStream triggers give fast inner-loop deploys for dev namespaces. For production, still route through GitOps so cluster state matches git. Many teams use BuildConfig + ImageStream in dev and external registry + ArgoCD in prod.
Platform teams publish golden S2I builder ImageStreams (java-21, nodejs-20) in a openshift namespace. App BuildConfigs reference them with ImageChange triggers—when Red Hat ships a security patch to the builder, all dependent apps rebuild automatically.
Internal registry URL format: image-registry.openshift-image-registry.svc:5000/<namespace>/<imagestream>:<tag>. Use this in manifests for cluster-local pulls; external routes exist for out-of-cluster CI push.