Deploying Platform Services with Flux and OCI Artifacts🔗
Part of a Learning Path
This article is a step in the Put Your Kubernetes App on the Internet pathway on bradpenney.io. It assumes a cluster already running Flux and the delivery model from Day One — what GitOps is and why everything here ships as OCI artifacts.
The edge stack is written. Sitting in a directory you're proud of are the manifests for Traefik and its Gateway, cert-manager, and external-dns — the machinery that puts apps on the internet. One question left: how does any of it reach the cluster? Your fingers already know the tempting answer, and it's three keystrokes into being wrong.
Because the way you ship this directory decides what kind of platform you run. Apply it by hand and the cluster becomes a snowflake: a pile of state that exists only because someone once typed the right commands in the right order. Ship it the way this article does and the cluster becomes a consequence: if it isn't in the artifact, it doesn't exist, and everything that is, exists on every cluster that subscribes, forever, with a version number. That one rule is the whole article; every section below is it applied to a different question.
What You'll Learn
By the end of this article, you'll understand:
- The delivery chain: config repo → CI → versioned OCI artifact → Flux reconciliation, with no hands in the middle
- How to structure the platform config repo, and why the artifact must be self-contained
- The two cluster-side resources: an
OCIRepositorypinned to an exact version, and aKustomizationwithpruneon - What happens to manual changes, and why the reconciler eating your hotfix is a feature
- How promotion works: publishing a version and deploying it are two different, deliberate acts
flowchart TD
R["Config repo<br/>platform/traefik, cert-manager, external-dns<br/>(pull requests, reviews, history)"]
CI["CI pipeline<br/>flux push artifact"]
REG["OCI registry<br/>platform-config:v1.4.2 — immutable, versioned"]
OCIR["OCIRepository<br/>pinned to v1.4.2"]
K["Flux Kustomization<br/>prune: true, health checks"]
C["Cluster: the edge stack, running<br/>exactly what the artifact declares"]
R --> CI --> REG
REG --> OCIR --> K --> C
style R fill:#2d3748,stroke:#cbd5e0,stroke-width:2px,color:#fff
style CI fill:#4a5568,stroke:#cbd5e0,stroke-width:2px,color:#fff
style REG fill:#326CE5,stroke:#cbd5e0,stroke-width:2px,color:#fff
style OCIR fill:#2d3748,stroke:#cbd5e0,stroke-width:2px,color:#fff
style K fill:#2d3748,stroke:#cbd5e0,stroke-width:2px,color:#fff
style C fill:#2f855a,stroke:#cbd5e0,stroke-width:2px,color:#fff
The Rule, and the Chain That Enforces It🔗
Day One established the paradigm: the cluster continuously reconciles itself to a declared desired state, and that state travels as an OCI artifact: an immutable, versioned bundle in a container registry, not a Git branch some controller polls. This article is the platform engineer's side of that story: you're the one who builds the artifact.
The chain has four links, and a human hand touches exactly one of them:
- The config repo: where manifests live, change by pull request, and carry history. (The one place hands belong.)
- CI packages the repo into an OCI artifact: a build step declared in the pipeline, producing
platform-config:v1.4.2in your registry. - An
OCIRepositoryin the cluster points at that artifact, pinned to a version. - A
Kustomizationapplies what's inside, on an interval, forever, and removes what's no longer there.
Nothing in the chain is a command someone runs. Everything is a declaration something reconciles. That's the standard — not because typing is beneath you, but because typed state can't be reviewed, versioned, rolled back, or rebuilt.
The Config Repo: What Goes in the Artifact🔗
Platform services get a directory each, composed by one Kustomize entry point:
platform/
├── kustomization.yaml # composes everything below
├── traefik/
│ ├── kustomization.yaml # the directory from the Gateway API article
│ ├── gateway-api-crds.yaml # vendored — see below
│ ├── rbac.yaml
│ ├── deployment.yaml
│ ├── service.yaml
│ └── gatewayclass.yaml
├── cert-manager/
│ ├── kustomization.yaml
│ ├── cert-manager-crds.yaml
│ ├── cert-manager.yaml # vendored upstream manifests, version-pinned
│ │ # (--enable-gateway-api arg added to the controller Deployment)
│ └── cluster-issuers.yaml
└── external-dns/
├── kustomization.yaml
├── rbac.yaml
└── deployment.yaml # the flags: sources, provider, txt-owner-id, policy
| platform/kustomization.yaml | |
|---|---|
- Each component keeps its own
kustomization.yaml, so it can be reviewed, versioned, and (when you grow into it) reconciled independently.
Two decisions in that layout carry the rule:
- Everything upstream is vendored. The Gateway API CRDs, cert-manager's manifests: copied into the repo at a pinned version, not fetched from the internet at build or deploy time. The artifact must be self-contained: a bundle that deploys identically whether the cluster has internet egress or not, this year or next. Upgrading a dependency is a pull request that changes vendored files — visible, reviewable, revertible.
- No secrets. Ever. The Cloudflare token external-dns needs, the ACME account key: none of it goes in the artifact. Secrets reach the cluster through their own controlled channel (External Secrets Operator syncing from a secret manager, a topic of its own); the artifact references Secret names and stays safe to store, copy, and audit.
Packaging: A Version Number for Your Platform🔗
CI turns the repo into an artifact — one step in the pipeline definition, declared like everything else:
| The packaging step (in your CI pipeline definition) | |
|---|---|
- A semver tag, minted by the pipeline on merge. Artifacts are immutable:
v1.4.2means the same bytes today, next month, and during the incident review. - The artifact carries its provenance — which repo, which commit — as metadata. Every running cluster can answer "where did this config come from?" precisely.
That tag is the unit everything else operates on. Not "the latest state of main": a number you can pin, promote, diff, and roll back to.
The Cluster Side: Two Small Resources🔗
The cluster subscribes with the pair you learned to read in Flux Resources Explained — now you're writing them:
- Production pins an exact version. Semver ranges exist (
semver: ">=1.4.0 <2.0.0") and have a place in dev clusters that always track newest, but a production cluster states exactly what it runs, and changing this line is the deploy: one reviewable diff. - Where in the artifact to build from — here, its root (the
platform/content we pushed). prune: trueis the rule with teeth: remove a manifest from the artifact and the reconciler removes the object from the cluster. The artifact isn't a suggestion; it's an allow-list of what may exist, the same zero-trust shape as the rest of the platform.wait+timeoutmake the Kustomization report health, not just "applied": if Traefik's Deployment never goes Ready, the reconciliation fails loudly, where you'll see it influx get kustomizations. The status-reading skills from Day One apply unchanged.
And these two resources? They're not hand-applied either: they live in the cluster's own bootstrap configuration, which is itself in Git. It's declarations all the way down; the only imperative act in the whole system is git merge.
The Reconciler Ate My Hotfix (Good.)🔗
Here's the moment the rule stops being philosophy. Someone kubectl edits the Traefik Deployment at 11pm, bumps a resource limit, fixes the incident, goes to bed. By 11:10 the change is gone: the reconciler compared the cluster against v1.4.2, found drift, and put it back.
That's not the tooling being obstinate; that's the system keeping its one promise: the cluster runs what the artifact declares. A hotfix that lives only in cluster state is a time bomb: invisible to review, absent from the next rebuild, contradicting every mental model built on the repo. The correct 11pm move is the same as the 11am move: change the manifest, merge, let the pipeline mint v1.4.3, bump the tag. In a genuine break-glass emergency, do what you must — then commit the equivalent change immediately, because the reconciler's memory is longer than yours.
kubectl apply still has a home: experimentation. A kind cluster, a scratch namespace, a "does this even work" afternoon: apply freely, learn fast, throw it away. The line is bright: experiments are applied by hand; platforms are reconciled from artifacts.
One more distinction the versioned artifact makes possible: publishing and deploying are different acts. Merging your change and letting CI push v1.4.3 publishes: nothing anywhere is running it yet. Bumping a cluster's pinned tag deploys: a separate, deliberate, owned decision. In regulated environments those two acts belong to different roles; even solo, keeping them distinct is what makes "what's actually in prod?" a question with an exact answer.
Why This Matters for Platform Work🔗
- Cluster number two is a re-run, not a rebuild. DR site, staging, a replacement after a bad day: install Flux, apply the bootstrap config, and the entire edge stack reconverges from the artifact. Anything that would be lost in that rebuild was, by definition, drift you're better off without.
- Rollback is a one-line diff.
v1.4.3broke routing? Change the pinned tag back tov1.4.2and merge. No archaeology, no remembered commands, no "who knows how this was installed." - The audit trail is complete and boring. What changed = git log. What's deployed where = each cluster's pinned tag. What was running during the incident = an immutable artifact you can pull and inspect byte-for-byte.
Common Pitfalls🔗
Working as designed: the cluster is pinned to v1.4.2, and publishing a new artifact deploys nothing. Promotion is the deliberate act of changing the pinned tag in the cluster's config and merging. If you wanted auto-tracking (a dev cluster), that's what a semver range on the OCIRepository is for — an explicit choice, never the production default.
Then it wasn't in the artifact, which means it existed only as cluster state: hand-applied, or left over from an earlier version. The recovery is never "re-apply it by hand" (prune will eat it again, correctly); it's adding the manifest to the config repo so it becomes real. Treat every prune surprise as the system finding undeclared state for you.
flux get kustomizations shows Ready: False after a version bump. wait: true means "applied" isn't good enough; something (say, the new Traefik image) never went Ready. The Day One diagnosis chain applies: source first (is the artifact fetchable at that tag?), then the Kustomization's events, then the workload it's waiting on. Roll back by reverting the tag while you investigate: that's one diff, not an outage-length debugging session.
Practice Exercises🔗
Exercise 1: The Friday Rollback
The v1.5.0 artifact shipped a Traefik flag typo; routing is degraded in production. Walk the exact rollback: what changes, where, and what do you explicitly not do?
Solution
One diff: in the cluster's config, change the OCIRepository's ref.tag from v1.5.0 back to v1.4.2 (the last known-good version) and merge. Flux fetches the old artifact — immutable, byte-identical to what ran yesterday — and reconciles the cluster back to it, prune and health checks included. What you don't do: kubectl edit the Deployment to fix the flag (drift, reverted in minutes, invisible forever), re-push a "fixed" artifact under the same v1.5.0 tag (tags are immutable promises; the fix is v1.5.1), or debug live under pressure when a one-line revert restores service first.
Exercise 2: The Rebuild Test
Your cluster is unrecoverable — storage gone, control plane toast. You provision a fresh one. List everything required to get the full edge stack back, and name what this test proves about the rule.
Solution
Three things: a cluster running Flux, the bootstrap config (the OCIRepository + Kustomization pair, itself in Git), and network reach to the registry holding platform-config:v1.4.2. That's the complete list: Traefik, its GatewayClass and Gateway, cert-manager and its issuers, external-dns and its ownership stamps all reconverge without a human recalling a single step. The test proves the rule by contrapositive: anything that doesn't come back was never in the artifact, hand-applied state the old cluster was silently depending on. A platform that fears this test has drift; a platform built this way treats it as a routine drill.
Exercise 3: Publish vs. Deploy
You merge a change adding a new --domain-filter to external-dns. CI mints platform-config:v1.5.0. Ten minutes later a teammate asks, "is that live in prod?" Walk the precise answer, and explain why the gap between the two acts is a feature.
Solution
The answer is a lookup, not a guess: check prod's pinned tag. v1.5.0 existing in the registry means the change is published: reviewable, pullable, running nowhere. It's deployed only when someone changes prod's OCIRepository to ref.tag: v1.5.0 and merges that. The gap is where deliberateness lives: staging can run v1.5.0 for a day first, or the prod bump can wait for a change window, a second set of eyes, or a different role entirely (in regulated shops, the person who writes the change and the person who promotes it to prod are deliberately not the same person). Collapsing publish-and-deploy into one act is how "it just went straight to prod" stories start.
Quick Recap🔗
| Concept | What to Know |
|---|---|
| The rule | If it isn't in the artifact, it doesn't exist, and prune enforces it |
| The chain | Config repo → CI → OCI artifact → OCIRepository → Kustomization → cluster |
| Self-contained artifacts | Vendor upstream CRDs/manifests at pinned versions; never fetch at deploy time |
| No secrets in artifacts | Credentials arrive via their own channel (External Secrets Operator); artifacts reference names |
| Exact pins in prod | ref.tag: v1.4.2; changing that line is the deploy; ranges are for dev at most |
prune + wait |
The artifact is an allow-list of what may exist; health, not just "applied" |
| Drift | Reverted on the next reconcile; the feature that keeps cluster and repo truthful |
| Publish ≠ deploy | CI minting a version and a cluster adopting it are separate, deliberate acts |
| Imperative commands | Experimentation only — platforms are reconciled, never typed |
What's Next?🔗
The pathway's edge stack now ships itself: the front door, its certificates, and its DNS records all reconciled from one versioned artifact, with git merge as the only imperative act left in the system. If the Flux resources here felt like review, Day One walks their anatomy; from here, this site goes deeper into running Flux itself as a platform.
Further Reading🔗
Official Documentation🔗
- Flux: OCIRepository - The source resource, including tag pinning and semver ranges
- Flux: Kustomization - Prune, wait, health checks, and dependency ordering
- Flux: OCI artifacts cheatsheet -
flux push artifactand friends
Related Articles🔗
- Your Flux Workflow - Why delivery runs on OCI artifacts, from the developer's seat
- Flux Resources Explained - The anatomy of the resources this article writes
- Reading Flux Status - The diagnosis chain when reconciliation goes sideways
On the Kubernetes Site🔗
- Gateway API with Traefik (k8s.bradpenney.io) - The
platform/traefikdirectory this artifact delivers - Pointing Your Domain at the Cluster (k8s.bradpenney.io) - The external-dns flags declared in
platform/external-dns