Skip to main content

Kargo Puts Terraform and Kubernetes Behind One Freight Promotion — Does a Self-Hosted PaaS Actually Need That?

10 min readDora NodaDora Noda
Share
On this page

Picture a single promotion object moving through a pipeline: it carries a Terraform plan that resizes a database instance and the Kubernetes manifest bump for the app that's about to start using the extra capacity, gated behind one approval, recorded in one audit trail. That's the pitch behind Kargo's 2026 Terraform/OpenTofu support — infra and app changes as one auditable "Freight" promotion instead of two GitOps loops that don't know about each other. It's a genuinely good idea for some platforms.

Whether it's an idea a Cluster API-managed, self-hosted PaaS needs comes down to three concrete questions: does the app config consume a value that doesn't exist until the infra step finishes, is that infra step gated in a way that lets the two drift out of order, and does skipping coordination produce silently wrong behavior instead of a scheduler that just waits and retries. Run any deploy scenario through those three questions and you get a yes/no answer, not a vibe — this post runs four of them through it, including the one that actually applies to bex.

What Kargo Actually Ships in 2026

Kargo is Akuity's GitOps "promotion" layer — built by the team behind Argo CD, and designed to sit downstream of it rather than replace it. Argo CD (or Flux) still does the reconciling; Kargo decides what gets promoted where and when, then commits the result to Git for the reconciler to pick up. Three objects carry that model:

  • Warehouse — watches one or more artifact sources (a container registry, a Git repo, a Helm chart repo) for new revisions.
  • Freight — a bundle of the specific artifact versions a Warehouse discovered at one point in time: an image tag, a chart version, a commit SHA, packaged together as one promotable unit.
  • Stage — a promotion target (dev, staging, prod). Freight moves stage to stage, automatically or behind a manual approval, and a Stage's own promotion template defines exactly what happens when it lands.

That part has been open source and stable for a while. What's new in 2026 is what a Stage's promotion template can do. Kargo v1.9 (January 29, 2026) added four Terraform/OpenTofu promotion steps — hcl-update, tf-plan, tf-apply, tf-output — so a single promotion can edit HCL, plan it, apply it, and pass the resulting output (a connection string, an IP, a resource ID) straight into the Kubernetes manifests promoted in the same run. Then Custom Steps, announced at KubeCon EU 2026 in Amsterdam, generalized that further: any logic you can put in a container — a policy check, a security scan, an internal ticket system call — becomes a native, versioned promotion step via a CustomPromotionStep resource.

The catch worth stating plainly, because it changes who this actually applies to: both features are Enterprise-only, gated behind the Akuity Platform, not shipped in OSS Kargo. Custom Steps also ships marked alpha. The table below is the split as of mid-2026:

CapabilityOSS KargoKargo Enterprise (Akuity Platform)
Warehouse / Freight / Stage core model
Git, Helm, Kustomize, Argo CD promotion steps
Terraform/OpenTofu steps (tf-plan, tf-apply, tf-output, hcl-update)✅ (v1.9+)
Custom Steps (arbitrary containerized logic)✅ (v1.10+, alpha)
Embedded Argo CD UI, ServiceNow integration, threaded Slack notifications

So "Kargo unifies infra and app promotion" is true of a commercial platform sitting on top of an open-source promotion engine — not something you get by installing Kargo itself. That matters for a self-hosted PaaS evaluating whether to build the same pattern in-house, because the thing being evaluated isn't a free primitive; it's a product decision Akuity made about where to put the paywall.

The Worked Example: A Custom Domain That Depends on Two Systems

Here's where the abstract "infra + app in one promotion" pitch turns into something concrete enough to test. Take custom-domain onboarding on a Cluster API-managed PaaS — a tenant points app.example.com at the platform and expects HTTPS to work. Two independent systems have to agree, in order, for that to succeed:

  1. A DNS record (a CNAME or A record) pointing the tenant's domain at the platform's ingress, created wherever DNS is managed — a registrar API, a DNS-as-code Terraform module, whatever the platform uses outside the cluster.
  2. An in-cluster Ingress (or HTTPRoute) plus a cert-manager Certificate resource requesting a TLS cert for that domain via ACME's HTTP-01 or DNS-01 challenge.

If both are applied as decoupled steps with no explicit ordering, here's the race: the Ingress and Certificate land first (nothing stops them — they're valid Kubernetes objects the moment they're submitted), but the DNS record hasn't propagated yet. cert-manager's HTTP-01 challenge fails because the ACME server can't resolve the domain to the platform's IP. cert-manager doesn't fall over — it retries with exponential backoff and surfaces Ready: False on the Certificate object — but the tenant sees a domain that's "onboarded" yet serves no valid cert for anywhere from a few minutes to over an hour, depending on DNS TTL and propagation, with no indication of why beyond a condition a support engineer has to go look up.

Compare that to a GPU node-pool scale-up for a tenant's inference workload — the other kind of infra-then-app dependency a Cluster API fleet hits constantly. A tenant deploys a workload that needs a GPU node type the fleet doesn't currently have warm; the platform's autoscaler (Cluster Autoscaler or Karpenter-on-bare-metal) triggers a MachineDeployment scale-out via Cluster API, provisioning takes several minutes, and the tenant's pod sits Pending until a node joins and satisfies its resource request. No promotion gate exists between "scale the node pool" and "schedule the pod" — and none is needed, because the Kubernetes scheduler already treats Pending as the correct, self-healing state to wait in. The moment a node is Ready, the pod schedules; nothing was silently wrong in the interim, it was just accurately waiting.

Same shape of problem — infra step, app step, a delay between them — two very different outcomes. That difference is exactly what the decision framework below is built to catch.

The Three-Question Test

Run a scenario through these three questions, in order:

  1. Does the app step consume a value that doesn't exist until the infra step completes — a connection string, an IP, a DNS-resolvable name — rather than just needing the infra step to have happened at some point?
  2. Is the infra step itself gated (a Terraform apply behind manual approval, a DNS change routed through a registrar with its own propagation delay) in a way that lets it drift out of sync with the app step that depends on it?
  3. Does skipping coordination produce silently wrong behavior — a stale value, a failed challenge with no clear signal — rather than a Kubernetes-native Pending/retry state that resolves itself once the dependency clears?

A "yes" on all three means the platform needs an explicit gate — a Kargo-style Freight promotion, or the equivalent hand-rolled check — before the app step runs. A "no" on any of them means the two systems' own reconciliation already handles it, and bolting on a cross-domain promotion layer adds an operational surface for no correctness gain.

ScenarioQ1: consumes late value?Q2: infra step gated?Q3: fails silently?Needs explicit gate?
App image tag bump (no infra change)NoN/AN/ANo — pure app-layer reconciliation
GPU node-pool scale-up for a pending workloadNo (pod just needs a node, not a specific value)No (autoscaler-triggered, no approval step)No (Pending → scheduled once ready)No — Kubernetes scheduler already self-heals
Custom domain: DNS record + cert-manager ACME challengeYes (challenge needs the DNS record resolvable)Yes (DNS propagation + potentially a registrar approval flow)YesReady: False with no obvious cause until someone checks conditionsYes — needs DNS-verified-before-cert-request ordering
Terraform-provisioned output (e.g., a managed resource's connection string) consumed directly in app env varsYesYes (Terraform apply is explicitly gated)Yes — app boots against a stale or empty valueYes — exactly what Kargo's tf-output step is built for

Two of four scenarios need the gate; two don't. That split is the actual finding — not "coordination is always necessary" or "Kubernetes reconciliation always saves you," but a test that tells you which one you're looking at before you build for the wrong one.

Where This Actually Lands for bex

bex's infrastructure layer is Cluster API, not Terraform — machines are Machine and MachineDeployment objects reconciled by the same Kubernetes control plane that reconciles the tenant workloads running on them. That's a structurally different starting point than the problem Kargo's Terraform steps solve, and it's worth being precise about why: Terraform is imperative-apply, run-once-and-done, with no controller watching the applied state afterward — which is exactly why an external promotion layer needs to explicitly capture its output and hand it downstream, because nothing else will.

Cluster API doesn't have that gap. A MachineDeployment is watched continuously; if a node disappears, CAPI's own controller notices and replaces it, the same level-triggered guarantee a Kubernetes Deployment gives an app. Two of Kargo's core justifications for a cross-domain promotion layer — "infra changes don't reconcile themselves" and "the app has to explicitly wait for infra to catch up" — don't apply to a fleet where infra is a Kubernetes control loop already.

Running the four-scenario table again against bex's actual architecture confirms it: image bumps and node-pool scale-outs are already two of the "no" rows, and neither Terraform-consumed-output nor most Terraform-gated-approval scenarios show up at all, because bex deliberately keeps managed infrastructure like tenant databases off Terraform's plate — CloudNativePG runs those as Kubernetes-native operators, not as a Terraform-provisioned external resource whose outputs need smuggling into a Pod's environment. The one row that does apply — DNS-plus-cert-manager custom domain onboarding — is also the one case study this post worked through in detail, and it's telling that the fix there isn't a Kargo-style promotion gate either: it's making the platform's own domain-onboarding flow explicitly wait on DNS-resolvability (a dig check, or watching for the Certificate's challenge to actually start succeeding) before telling a tenant their domain is live, rather than declaring victory the instant the Kubernetes objects are created.

The transferable idea from Kargo isn't "build a cross-system promotion layer" — for a CAPI-native platform, that's solving a coupling problem it mostly doesn't have. It's the narrower discipline Kargo's Freight model enforces by construction: any step that reaches outside Kubernetes' own reconciliation loop — a DNS registrar, an ACME certificate authority, a third-party API — needs an explicit, checkable "did this actually finish" gate before the next step trusts its result, precisely because nothing in that external system is going to keep retrying and self-correcting the way a MachineDeployment or a Deployment controller does. Where a platform's whole stack is Kubernetes-native control loops talking to each other, that discipline is already free. Where it isn't — DNS being the one place it usually isn't — it's the one thing worth borrowing.

Bex.co is the open-source, AI-native Render alternative — push a git repo, get a running HTTPS service on machines you own, reconciled onto a Cluster API-managed fleet you control end-to-end. Star the repo on GitHub or deploy your first app today.

Related articles

Run this on infrastructure you own

bex is the open-source, AI-native Render alternative — push a git repo and get a running HTTPS service on your own machines.

Get started with bex