Connecting GitOps with CI/CD pipelines: separating CI and CD responsibilities, building a pipeline from checkout to security scanning, updating manifests in Git as the CD bridge, and practical integration with GitHub Actions, GitLab CI/CD, and Jenkins.

In episode 26 you prepared disaster recovery — the cluster can be rebuilt at any time with a tested procedure and measurable RTO/RPO targets. But there's still one thing that's manual in your cycle: how does newly committed application code finally run in the cluster? If every image update is still done through manual flux reconcile or kubectl set image, there's work that should be automated.
Let's pull back to the core GitOps principle: the only way to change a cluster is through Git. A good pipeline respects this principle — it doesn't access the cluster directly, but updates the Git repository and lets Flux pull those changes. This turns CI/CD from "deploy directly to the cluster" into "keep Git in sync with the latest code".
In this episode we'll build a complete CI/CD pipeline on top of Flux: separating CI and CD responsibilities, a CI pipeline from checkout to security scanning, CD through Git as the bridge to Flux, and practical integration with GitHub Actions, GitLab CI/CD, and Jenkins.
CI (Continuous Integration) is responsible for code quality: taking the source, building, testing, and publishing artifacts. The question CI answers is "is this code worth shipping?" Its scope of work:
CI stops at the registry door. It doesn't touch the cluster and doesn't check the deployment.
CD (Continuous Delivery/Deployment) is responsible for delivery to the environments. The question CD answers is "when and where is this artifact sent?" Its scope of work:
In GitOps, CD doesn't push to the cluster; CD pushes changes to Git, and Flux pulls them. This moves the deployment risk out of the pipeline and into the reconciliation mechanism.
The bridge between CI and CD is the GitOps repository: CI writes the artifact (image tag), CD writes the manifest changes, and Flux reads both. This separation matters because:
The CI pipeline starts with checking out the source and running quality verification. An example of a common sequence of steps:
The unique tag is the key. Using latest or an overwritable tag makes deployment auditing impossible — we won't know exactly which code is running.
Building and pushing the image is the heart of CI. A simple example step:
docker build -t ghcr.io/devvnull/checkout-app:sha-abc123 .
docker push ghcr.io/devvnull/checkout-app:sha-abc123Or use Buildx for production-ready multi-architecture images:
docker buildx build \
--platform linux/amd64,linux/arm64 \
-t ghcr.io/devvnull/checkout-app:sha-abc123 \
--push .With a commit-SHA-based tag, every image is directly linked to a code revision — the foundation for auditable CD.
Shipping code without scanning its security is like opening a door without checking who's coming in. Two common scan layers:
govulncheck, npm audit, or pip-audit.Make the scan a gate: if a high-severity vulnerability is found, the pipeline fails and the image isn't pushed. Also consider scheduled scanning in the registry, because new vulnerabilities can be found after the image is built.
Once the image is in the registry, the CD step is updating the GitOps repository to reference the new image. For Kustomize, update the image tag in the image file:
kustomize edit set image \
ghcr.io/devvnull/checkout-app=ghcr.io/devvnull/checkout-app:sha-abc123For Helm, update the tag value in values.yaml:
yq eval -i '.image.tag = "sha-abc123"' values.yamlThese changes are committed and pushed. This is where CD ends on the pipeline side — the rest is Flux's job.
Because the GitRepository and Kustomization are already configured, Flux detects the new commit on its interval or through a webhook, then reconciles. Monitor the running deployment with Flux commands:
flux get kustomization apps
flux get helmrelease checkout
flux events --for Kustomization/appsflux events shows the latest events for a specific resource. If the deployment fails, Flux marks the Kustomization not Ready and stops further changes.
Important
Don't add a "deploy to cluster" step in the pipeline. If the pipeline needs cluster access, the architecture violates the GitOps principle — cluster credentials should only exist on the Flux side, not in CI/CD.
GitHub Actions is the most popular choice for repositories on GitHub. A build workflow combining checkout, test, and image push can be made with the following steps:
name: build
on:
push:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: "1.22"
- run: go test ./...
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/build-push-action@v6
with:
push: true
tags: ghcr.io/devvnull/checkout-app:sha-${{ github.sha }}Note that GitHub Actions expressions like github.sha only appear inside the YAML block — outside that, the pipeline carries no cluster secrets.
Once the image is published, the CD workflow updates the GitOps repository. For Kustomize, run kustomize edit set image then commit:
git clone https://github.com/devvnull/gitops-production
cd gitops-production
cd apps/checkout/overlays/production
kustomize edit set image \
ghcr.io/devvnull/checkout-app=ghcr.io/devvnull/checkout-app:sha-abc123
git commit -am "chore: update checkout image"
git pushFor Helm, update values.yaml with yq and commit the same way.
For environments that need approval, don't push directly to main. Create a pull request from a new branch and let review be the last gate:
git checkout -b update/checkout-sha-abc123
git commit -am "chore: update checkout image"
git push origin update/checkout-sha-abc123
gh pr create --title "chore: update checkout image" \
--body "Update checkout image to sha-abc123"Once the PR is approved and merged, Flux detects the commit and does the deployment. A more automatic alternative is flux image automation (ImagePolicy and ImageUpdateAutomation) which can even replace this step entirely — covered in an earlier series.
GitLab has the stage concept that aligns with the CI/CD separation. A typical pipeline has build, test, scan, and gitops stages. An example configuration for running the Flux CLI image in the pipeline:
gitops:
stage: gitops
image: ghcr.io/fluxcd/flux-cli:latest
script:
- git clone https://oauth2:$GITLAB_TOKEN@$GITOPS_REPO
- cd $GITOPS_REPO
- ./update-image.sh checkout sha-abc123
- git commit -am "chore: update checkout image"
- git push
only:
- mainNote that the GitLab pipeline doesn't access the cluster at all — it only updates the GitOps repository.
For workflows that need review, GitLab can create a Merge Request (MR) via the API after updating the manifests:
curl -X POST "$CI_SERVER_URL/api/v4/projects/$GITOPS_PROJECT_ID/merge_requests" \
--header "PRIVATE-TOKEN: $GITLAB_TOKEN" \
--data "source_branch=update/checkout-sha-abc123" \
--data "target_branch=main" \
--data "title=chore: update checkout image"The MR keeps manifest changes going through review before the merge, then Flux executes the automatic deployment after the merge.
Tip
Use a dedicated token for Git access that's limited only to the GitOps repository, not a full developer token. GitLab allows creating tokens with limited scope, so the pipeline can't change other repositories.
Jenkins automates build and deploy through a Jenkinsfile. For GitOps-based CD, Jenkins just does the same thing as the other pipelines: update the GitOps manifests and push. An example pipeline:
pipeline {
agent any
stages {
stage('Update manifest') {
steps {
sh '''
git clone https://github.com/devvnull/gitops-production
cd gitops-production
kustomize edit set image \
ghcr.io/devvnull/checkout-app=ghcr.io/devvnull/checkout-app:sha-abc123
git commit -am "chore: update checkout image"
git push
'''
}
}
}
}kustomize edit set image is run from inside the application's overlay directory, and the push is done with Git credentials stored in Jenkins.
Jenkins closes the cycle with notifications — the team should know the pipeline status without opening Jenkins:
post {
success {
slackSend channel: '#ci-cd',
message: 'Checkout deployment succeeded: sha-abc123'
}
failure {
slackSend channel: '#ci-cd',
message: 'Checkout deployment FAILED: ${BUILD_URL}'
}
}Notifications can be extended to email, Teams, or other webhooks. Combine them with the Flux alerting from episode 25 for full coverage: the pipeline tells you the change was sent, Flux tells you whether the reconciliation succeeded or failed.
Warning
Don't store Git credentials in the Jenkinsfile. Store them in the Jenkins credential store (for example credentials('git-ops-token')) and fetch them in the script. A Jenkinsfile that enters the repository must be free of secrets.
In this episode 27 you connected GitOps with CI/CD pipelines: separating the CI responsibilities (build, test, publish) and CD (deploy, promote, monitor) with Git as the bridge, a CI pipeline from checkout to security scanning with unique image tags, CD through Git where Flux detects and deploys automatically, and practical integration with GitHub Actions, GitLab CI/CD, and Jenkins.
The key takeaways:
latest.Changes now flow automatically from commit to cluster. But how confident are you that the manifests being sent are truly safe to apply? In the next episode, episode 28, we'll discuss Testing Strategies — manifest validation with linting and policy, dry-run testing with flux diff, PR-based preview environments, post-deploy integration testing, and chaos engineering to test cluster resilience. Keep up the momentum!