Issue #018 - Flux OCIRepository: the GitOps that stopped using Git
OCIRepository CRD, Bucket Source Controller, OCI hydration, manifests-as-images
Ask ten platform engineers what GitOps means and at least eight will say "the cluster pulls manifests from Git." That's the part everyone remembers, and it's the part Flux is quietly walking away from. The new default isn't Git. It's an OCI registry, the same one your container images already live in.
This issue is about what happens when you stop polling Git and start polling a registry, why Flux added that capability, and what it tells you about what GitOps was always actually about.
🏗️ Architectural Pattern: when the registry becomes the source of truth
The original GitOps loop looked like one arrow. Git on one end, cluster on the other, a controller in the middle that did git clone, ran kustomize build or helm template, then kubectl apply. Whatever was in main was what ran. Reconcile every minute. Done.
A monorepo with two hundred apps is not a thin arrow. The controller does a shallow clone, sure, but it's still pulling commit history, branch metadata, and the entire tree just to read one path. SOPS-encrypted secrets need decryption inside the cluster. Helm rendering needs the chart cache. The "cluster pulls from Git" sentence hides a lot of work happening inside the cluster, on every reconcile, for every tenant.
The other thing that arrow hides is authentication. Git over SSH means an SSH key sitting in a Secret inside the cluster, with whatever blast radius that key has. Git over HTTPS means a token doing the same. Both are long-lived credentials. Neither integrates with the cloud IAM that already governs every other thing the cluster touches. You end up with a parallel auth domain just for Source Controller, which then needs its own rotation policy, its own incident response when it leaks, its own grumpy security review.
The Gitless GitOps move is to take that work out of the cluster. CI does the rendering. CI does the decryption. CI runs conftest, kyverno test, kubeconform, whatever your policy stack is. The output - a frozen tarball of plain manifests - gets pushed to an OCI registry as an artifact, tagged with the commit SHA. The cluster never sees Git. It pulls one tarball, content-addressed by digest, and applies it.
What "immutable" actually buys you
Git is mutable. You can force-push. You can rewrite history. You can delete a branch. Most teams have policies against it, but the storage layer itself doesn't care. A tag in Git is a movable label.
An OCI artifact pinned by digest is the opposite. sha256:abc123... resolves to exactly one byte sequence forever, or it resolves to nothing. The registry can refuse new pushes to an immutable tag, the way ECR and Harbor both can. There's no "force push" verb in the OCI spec.
When your Kustomization references an OCIRepository by digest, you get something Git can't give you cheaply: the exact bytes that ran in staging are the exact bytes that run in prod. Hydration drift, the thing that bites every team with a complex Helm setup, stops being possible because hydration happened once, in CI, and got frozen.
Content-addressed delivery, the same way images work
You don't git clone your application into the kubelet. You build a binary, layer it into an image, push the image to a registry, and the kubelet pulls a content-addressed blob. The image digest is the contract. The registry handles authentication, replication, caching, signing, scanning. None of that lives in Git.
OCI artifacts extend the same pipe to non-image payloads. The spec carved out mediaType for arbitrary content. Helm charts have been shipping as OCI artifacts for years. Flux's OCIRepository source is the same idea for plain manifests. The bytes are different, the wrapper and the delivery mechanism are identical to what you already trust for application images.
Git stops being a delivery channel. It goes back to being a code review system, which is what it was good at.
The registry becomes the configuration plane, sitting next to the image plane, sharing auth and replication and signing.
The cluster does one thing: pull a tarball by digest, unpack, apply. No template engines, no decryption, no policy evaluation. The complicated work is upstream.
Where Bucket fits
OCIRepository covers the case where you've got a real registry. Some teams don't, or don't want to. Bucket is the same idea with S3 or GCS or any S3-compatible store underneath. Source Controller polls the bucket, packs whatever's there into a tarball, exposes it over HTTP to the rest of Flux. The semantics match: pull a frozen blob, apply it.
Bucket shines for things that have no business being in Git. ML model files of two gigabytes each, the kind nobody wants in their commit history. Database dumps for ephemeral environment seeding live well here too. Big static assets, init-container payloads, similar story. Object storage is the right primitive for any of these, and Source Controller treats it as a first-class source.
Three docs cover the rest of what's worth knowing on the architectural side.
Links
📑 RFC/KEP Read: OCIRepository, Bucket, and the hydration pipeline
The Flux pieces that make this work split across two controllers. Source Controller is the one that turns external storage into in-cluster Artifacts. Kustomize Controller (or Helm Controller, depending on what you're rendering) reads those Artifacts and applies them.
The OCIRepository CRD
A minimal OCIRepository looks like this:
apiVersion: source.toolkit.fluxcd.io/v1
kind: OCIRepository
metadata:
name: app-manifests
namespace: flux-system
spec:
interval: 5m
url: oci://ghcr.io/podostack/app-manifests
ref:
tag: v1.4.0
provider: genericThe interesting fields:
url: registry path, no tag. Tag and digest go underref.ref.tag/ref.semver/ref.digest: three ways to pin the version. Tag is the loosest, semver lets you say">=1.4.0 <2.0.0", digest is the strongest contract.provider:generic,aws,azure, orgcp. The non-generic providers wire in cloud IAM, so the controller's ServiceAccount carries IRSA or Workload Identity instead of a static credential.
Pinning to a digest is what gets you the immutable guarantee. Pinning to a tag is convenient but the tag can move - unless the registry enforces immutability, which most do for production paths.
The reconcile loop is straightforward. Source Controller hits the registry every interval, compares the resolved digest against what it has, pulls and extracts on change, and exposes the result at an HTTP endpoint for other Flux controllers.
Verification with cosign
The spec.verify block is where this pattern stops being just "git over a different transport" and starts being something Git can't match cheaply:
spec:
verify:
provider: cosign
matchOIDCIdentity:
- issuer: "^https://token.actions.githubusercontent.com$"
subject: "^https://github.com/podostack/app/.+$"This says: refuse to use this artifact unless cosign verifies it was signed in a GitHub Actions run on a workflow inside the podostack/app repo. Keyless signing through Fulcio means there's no key to rotate, no key to leak, no key sitting in a secret somewhere. The signature ties the artifact back to the CI workflow that produced it through OIDC.
If you've read Issue #016 on Kyverno, this is the supply-chain end of the same picture. Kyverno verifies image signatures at admission. Flux verifies manifest signatures at source. Both lean on Sigstore, both reject unsigned blobs by default once the verify block is in place. The cluster ends up with a clean property: nothing applied to it exists without a chain of custody back to a CI run.
The Bucket CRD
apiVersion: source.toolkit.fluxcd.io/v1
kind: Bucket
metadata:
name: ml-models
namespace: flux-system
spec:
interval: 10m
provider: generic
bucketName: prod-ml-models
endpoint: s3.eu-central-1.amazonaws.com
region: eu-central-1
ignore: |
!*.onnx
!*.pt
logs/
tmp/The trap people fall into is leaving ignore empty. Source Controller will then try to pack the entire bucket into a tarball in memory, and if the bucket is half a terabyte of training data, it does not end well. The ignore field uses .gitignore syntax, and treating it as required is the right move.
The CI side: flux push artifact
flux push artifact oci://ghcr.io/podostack/app-manifests:v1.4.0 \
--path=./dist/manifests \
--source="$(git config --get remote.origin.url)" \
--revision="main@sha1:$(git rev-parse HEAD)"The --source and --revision flags land in artifact annotations and the Flux UI uses them to show the producing commit. Git stops being the delivery channel but stays the audit trail.
What you do before flux push artifact is where the pattern earns its keep. The CI pipeline renders manifests with kustomize build overlays/prod (or helm template . -f values-prod.yaml, depending on which you live in). Encrypted secrets get sops --decrypt'd, sealed back with kubeseal only if the cluster can't decrypt at runtime. Then the policy gate — conftest test or kyverno test against the rendered output — catches RBAC and image-policy violations before anything ships. That gate is the step everyone underestimates the first time they wire this up. After policy passes, kubeconform does a schema check, then cosign sign signs the artifact with the CI workflow's OIDC token. Schema first, signature second: a broken manifest shouldn't get a signature it doesn't deserve.
Every one of those steps used to happen, in some half-form, inside the cluster. Now it's done once in CI, frozen into one tarball, and reconciliation becomes pure apply.
Wiring it to Kustomization
The Kustomization resource that consumes an OCIRepository only changes one field compared to the GitRepository version:
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata:
name: app
namespace: flux-system
spec:
interval: 10m
path: "./deploy/prod"
prune: true
sourceRef:
kind: OCIRepository
name: app-manifestssourceRef.kind: OCIRepository instead of GitRepository. That's the whole migration. When I first did this, I expected drama in the Helm Controller — health checks, decryption providers, all of it. There was none. The rest of Flux doesn't care which Source kind it consumes, and the downstream pipeline kept working unchanged.
What the cluster actually sees
End to end, three things end up on the cluster, and that's the entire surface area:
One
OCIRepositoryresource pointing at a registry.One
Kustomizationresource pointing at that OCIRepository.A 200KB tarball pulled from the registry every five minutes.
What's not there is the more interesting list. There's no .git directory hidden under /var/lib. No long-lived SSH key parked in a Secret. No template engine running on the reconcile path. The cluster does pull-and-apply against a content-addressed blob, the same shape of operation it already does for every container image.
Links
Flux CLI: `flux push artifact`
Sigstore: keyless signing with cosign and Fulcio
🔥 Hot Take: GitOps was never about Git
The name is a marketing accident.
When Weaveworks coined "GitOps" in 2017, the point was the loop, not the storage. Git happened to be the durable thing everyone had. The loop is what mattered, and the loop works just as well with an OCI registry on the other end.
The standard objection runs something like: "Git gives me an audit log. Git gives me PR-based review. Git gives me branch protection. You're throwing all of that away."
Git is still the place where humans write YAML and review each other's changes. Pull requests still gate merges. Branch protection still keeps main clean. CODEOWNERS still routes reviews. Signed commits still tie changes to identities. None of that goes anywhere. What changes is what happens after the merge.
Pre-OCI, the merge to main was the deploy. The controller polled the branch and applied whatever was there. The "audit log of what shipped" was the same as the "audit log of what got reviewed." Convenient, and also kind of fragile: a force-push or a poorly-reviewed merge ships immediately.
Post-OCI, the merge to main triggers CI. CI renders, validates, signs, and pushes an artifact. The digest is what the cluster runs. "What was running on cluster X at 14:00 UTC" becomes a registry query. The signature on the artifact links back to the CI workflow run, which links back to the commit, which links back to the PR. The chain is longer but stronger, because every link is verifiable cryptographically.
The team most likely to benefit already runs a serious image-supply-chain stack: cosign on every image, replicated registry, air-gapped pulls, scanning on push. Adding manifests to that same pipe costs almost nothing. The team least likely to benefit runs a dozen apps with no monorepo, no air gap, no signing requirements - for them GitRepository is the right answer. The pattern earns its complexity when scale or supply-chain demands push back against Git's limits, not before.
There's a cultural shift hiding in this too. The team that owns the registry is usually the security or platform team; the team that owns Git is usually the application team. Moving the deploy boundary from Git to the registry shifts where the supply-chain controls live. Issue #004 covered the same dynamic from the Crossplane and Backstage angle - the platform layer wants its own contract with the cluster, separate from whatever application teams push.
GitOps was a name for a loop. The loop still runs. The plumbing got better.
Links
Weaveworks (Alexis Richardson): GitOps - Operations by Pull Request
What's next
Issue #019 picks up the supply-chain thread with image-pull policy and registry mirroring in air-gapped clusters - the other half of "what does the cluster actually pull." Issue #020 follows with the Image Preload Operator, which finishes the picture by warming the kubelet's image cache so the artifacts you just signed and shipped don't pay cold-start latency on first deploy.
The arrow into the cluster is getting interesting again.


