Kubernetes configuration is easy to write and surprisingly hard to trust. A YAML file can be valid, pass review and still be wrong for one environment. Templating can hide what will actually be deployed; copying manifests between environments makes it easy for them to drift.
I’d used Helm before and had trouble with it. Another team at work used Kustomize, and I have friends at CUE Labs, so CUE was on my radar too. I wanted to explore all three options before making a recommendation. This post looks at where CUE might fit with Argo CD, while Helm remains useful for third-party charts.
Three ways to describe the same change
Suppose an API needs one replica in dev and five in prod. The rendered values in this example are:
| Environment | API replicas | Worker replicas |
|---|---|---|
| dev | 1 | 1 |
| staging | 2 (the default) | 2 (the default) |
| prod | 5 | 3 |
Helm uses YAML templates and chart values. It also manages releases: install, upgrade, history and rollback. With templates, though, the output is still text. nindent can keep generated YAML aligned, but indentation becomes part of the template’s correctness. A small mistake can change or break the rendered structure.
Kustomize patches plain YAML without a template language, which keeps rendered manifests close to the source. It works well for one app with a few environments. As services and shared rules grow, reusable abstractions and policies across many resources are harder to express.
CUE describes values and constraints together. A value can have a default, a type and limits; configuration from another file must unify with those constraints. An environment can narrow a value, such as setting replicas to 5, but it cannot silently contradict a value already set elsewhere.
replicas: *2 | int & >=1 & <=20Here, replicas defaults to 2, while allowing integers from 1 to 20. A platform team could provide that default and range in a shared CUE module. Service teams supply their environment values, and CUE checks them against the shared bounds instead of relying on each team to copy the rule correctly. The distinction matters: CUE is checking whether all the pieces fit together, rather than applying an ordered set of overrides.
The practical differences between the three are easier to compare side by side:
| Tool | What it starts with | Where it tends to fit |
|---|---|---|
| Helm | YAML templates and values | Reusable charts, especially third-party software |
| Kustomize | Plain YAML and patches | A small number of environments with modest variation |
| CUE | Values, types and constraints | Shared configuration with rules that should fail early |
Why I’d choose CUE with Argo CD
Helm’s release lifecycle is useful when Helm is doing the deploying. With Argo CD, Helm renders manifests with helm template, while Argo CD handles syncing and keeps application history. In this setup, rolling back means reverting a source change and letting Argo CD sync the newly rendered output. Helm’s release-management advantage mostly disappears from that workflow.
That leaves a rendering contest. Helm is still a good choice when I need to install third-party software that already ships a chart. For services we define ourselves, I’m interested in whether CUE makes the configuration easier to trust.
I don’t like writing YAML. NoYAML can make the case better than I could. Helm adds Go templates on top of YAML, which I find especially hard to read and easy to get wrong in large files. The template logic gets mixed through hundreds of lines of YAML, so I have to keep both the template flow and the indentation in my head at once. CUE still renders ordinary YAML for Argo CD to sync; I’d just rather keep YAML at that boundary than use it as my source language.
Another benefit is sharing a setting across deployment and application configuration. I can define a value once in CUE, then generate both its Kubernetes representation and the config the application consumes, instead of maintaining separate copies. This example only emits Kubernetes resources and Helm Applications, so it doesn’t demonstrate that extension.
There are costs. CUE takes time to learn, and some errors take practice to read. CUE has enough articles, examples and tooling to get started, and its developers are generous with support when you get stuck. The appeal is a checked definition for each service, with rendered output committed for review. Helm has a larger ecosystem and more ready-made Kubernetes packages.
A pipeline from CUE to Argo CD
Let’s sketch an illustrative pipeline using two repositories, rather than a published example you can clone. The source repository holds CUE and the rendering script. The output repository holds plain YAML and the Argo CD ApplicationSet:
source-repo/
deploy/
schema.cue
envs.cue
services.cue
policy.cue
helmapp.cue
certmanager.cue
manifests.cue
argocd/v1alpha1/
charts/certmanager/
scripts/render.sh
scripts/update-chart-schema.sh
.github/workflows/render-check.yaml
output-repo/
envs/<env>/manifests.yaml
.source-sha
argocd/applicationset.yaml
.github/workflows/render.yamlThe source repo defines Deployments and Services for code we own, plus Argo CD Applications for third-party Helm charts. Both go into the output repo’s per-environment YAML.
This example keeps the output layout flat: each environment gets one manifest stream containing every configured system. For a larger platform repo with service teams, I’d group the output by system and environment, so each team has a separate path to review and sync:
platform-output/
systems/
<system>/
envs/
<environment>/
manifests.yamlThis is one possible layout for a central platform repo; some organisations may instead keep a separate output repo for each environment. The example below uses one source repo and one central output repo. It is not a single-application setup: each environment’s manifest stream contains the API and worker Deployments and Services, plus a cert-manager Argo CD Application. The ApplicationSet creates one Argo CD Application per environment. Supporting the grouped layout would mean rendering each system/environment pair and changing the ApplicationSet to discover those paths; splitting by environment would also need matching workflow configuration. The read-only pull boundary stays the same.
Render in CI, then commit the YAML
I prefer rendering in CI and committing the result as plain YAML. However CUE is installed, I’d use the same version for local runs, pull request checks and publishing, so they produce the same output. Reviewers can inspect the generated manifest diff in Git, and Argo CD syncs the plain YAML directly.
The output repo is effectively the production control plane: Argo CD deploys what lands on its main branch. Its workflow pulls source using a GitHub App token with Contents: Read-only permission, while the source repo holds no credential that can write to the output repo. That separates the credentials, not the authority to change what gets deployed. A change merged to source main will be rendered and published by the output workflow. The source pull request is the deployment gate here: I’d require review and a passing render check before merge, with reviewers inspecting the rendered diff where it’s available. The output repo adds no separate approval.
I could trigger the output repo with repository_dispatch, but sending that event would need a token with contents: write permission on the output repo. I don’t want that credential in the source repo, so this example polls source on a schedule instead. GitHub’s minimum schedule interval is five minutes, and runs can lag, so updates aren’t immediate. That’s fine for now. There are other ways to trigger or approve publishing; polling is a choice for this setup, not a requirement of CUE or Argo CD.
Keep the CUE package small and explicit
The files separate schema, environment values, policy, service composition, Helm chart Applications and output.
Here, CUE v0.17.1 uses Kubernetes schemas from the curated module cue.dev/x/k8s.io v0.11.0; the registry and replica rules are local.
schema.cue defines #Service: teams provide a few service values, and CUE derives a typed Deployment and Service from them.
package deploy
import (
appsv1 "cue.dev/x/k8s.io/api/apps/v1"
corev1 "cue.dev/x/k8s.io/api/core/v1"
)
// #Service is our own abstraction over "a stateless HTTP service". Teams
// fill in a handful of fields; the Kubernetes objects are derived. Keeping
// this small is deliberate: every field here is API surface you have to
// support, exactly like a Helm chart's values.yaml, except this one is typed.
#Service: {
name: =~"^[a-z][a-z0-9-]{1,40}$"
namespace: string
image: string
imageRef: string
// A moving tag is fine for dev. Staging and prod use a digest because
// even a version-shaped tag can be repointed to different image contents.
if env == "dev" {
tag: =~"^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$"
imageRef: "\(image):\(tag)"
}
if env != "dev" {
digest: =~"^sha256:[0-9a-f]{64}$"
imageRef: "\(image)@\(digest)"
}
port: *8080 | int & >0 & <65536
replicas: *2 | int & >=1 & <=20
resources: {
cpu: *"100m" | string
memory: *"128Mi" | string
}
// let bindings sidestep CUE's scoping rule: a reference resolves to the
// nearest enclosing field of that name, so inside metadata a bare `name`
// means metadata.name itself, and the same trap exists for replicas,
// image and resources further down. Binding every input here, and only
// referring to the bindings below, removes the whole class of bug.
let _name = name
let _ns = namespace
let _image = imageRef
let _port = port
let _replicas = replicas
let _res = resources
let _labels = {"app.kubernetes.io/name": _name}
// Keyed by kind rather than a list so other files can unify extra
// objects in (e.g. a PodDisruptionBudget) without index juggling.
objects: {
// Unifying with the upstream definitions means a misspelt or
// wrongly typed field fails at eval time instead of being silently
// dropped by the API server.
deployment: appsv1.#Deployment & {
apiVersion: "apps/v1"
kind: "Deployment"
metadata: {name: _name, namespace: _ns, labels: _labels}
spec: {
replicas: _replicas
selector: matchLabels: _labels
template: {
metadata: labels: _labels
spec: containers: [{
name: _name
image: _image
ports: [{containerPort: _port, name: "http"}]
resources: requests: {cpu: _res.cpu, memory: _res.memory}
}]
}
}
}
service: corev1.#Service & {
apiVersion: "v1"
kind: "Service"
metadata: {name: _name, namespace: _ns, labels: _labels}
spec: {
selector: _labels
ports: [{name: "http", port: 80, targetPort: "http"}]
}
}
}
}Environment selection has no default; each render must supply -t env=<name>. envs.cue holds the tagged field and the per-environment values:
package deploy
// Injected per render with `-t env=<name>`. No default on purpose: a render
// without an explicit environment should fail, not quietly produce dev.
env: "dev" | "staging" | "prod" @tag(env)
// Dev uses a moving tag; staging and prod pin image contents. The digests
// here are illustrative placeholders, not digests of real example-org images.
environments: {
dev: {
namespace: "app-dev"
services: {
api: {tag: "latest", replicas: 1}
worker: {tag: "latest", replicas: 1}
}
}
staging: {
namespace: "app-staging"
services: {
api: digest: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
worker: digest: "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
}
}
prod: {
namespace: "app-prod"
services: {
api: {digest: "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", replicas: 5}
worker: {digest: "sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", replicas: 3}
}
}
}-t env=prod supplies a value for the tagged env field. CUE uses it to resolve environments[env] as environments.prod; this is unification, not textual replacement. Without a tag, env remains unresolved and the render fails.
Staging omits replicas and uses the default of 2; prod sets them explicitly to 5 and 3. In a real deployment, replace the placeholder digests with those from your registry. Promoting a release means copying a tested digest from staging to prod in a PR. Dev can use latest, but Argo CD won't notice when that tag moves unless something triggers a new rollout.
services.cue combines shared image definitions with the selected environment’s values. Unification lets the environment set an image reference or replica count without replacing the shared service definition:
package deploy
// Every entry is a #Service, named after its key, living in the selected
// environment's namespace.
services: [Name=string]: #Service & {
name: Name
namespace: environments[env].namespace
}
// Base definitions shared by all environments.
services: {
api: image: "ghcr.io/example-org/api"
worker: image: "ghcr.io/example-org/worker"
}
// Pull in the selected environment's overrides. Because this is unification,
// an environment can narrow a value (set replicas) but can't contradict the
// base: a second, different image for api would be a conflict error.
//
// A misspelt service key here (say `apii`) creates a new service with no
// image, which fails the render as incomplete rather than being ignored.
services: environments[env].servicespolicy.cue adds constraints across all services, then a production-only replica rule:
package deploy
import "strings"
// Only images from our own registry, in every environment.
services: [_]: image: strings.HasPrefix("ghcr.io/example-org/")
// Prod needs headroom for rolling updates and node loss.
if env == "prod" {
services: [_]: replicas: >=3
}This is a small example of platform policy. A larger setup could use CUE to combine each service’s configuration with shared requirements. For instance, a platform team could constrain TLS settings on an Istio Gateway and generate a VirtualService and AuthorizationPolicy for each exposed service alongside its Deployment and Service. CUE would check those resources before rendering them as plain YAML into the output repo, rather than relying on each team to copy security manifests. This pipeline doesn’t implement that; Istio would still enforce the policies in the cluster. For third-party Helm charts, it only checks the chart values, not the resources the chart renders later.
Keep Helm for third-party charts
I wouldn’t replace a well-maintained upstream Helm chart just to make the renderer consistent. CUE can generate an Argo CD Application and check the chart’s values before that Application reaches the output repo, while leaving Argo CD to render the chart.
The shared #HelmApp definition describes the chart inputs and builds an Argo CD Application. Its values are checked against a chart-specific schema and passed to Argo CD through source.helm.valuesObject:
package deploy
import argocd "github.com/example-org/source-repo/deploy/argocd/v1alpha1"
// #HelmApp is a third-party chart that Argo CD renders and deploys. CUE's
// job here is only to produce the Application manifest, with the chart's
// values checked against that chart's own schema before Argo ever sees them.
#HelmApp: {
name: =~"^[a-z][a-z0-9-]{1,40}$"
namespace: string
chart: {
repoURL: string
name: string
// Exact versions only. A range would let the chart move underneath
// a values schema generated from a specific version.
version: =~"^v?[0-9]+\\.[0-9]+\\.[0-9]+$"
}
// Left open here; each chart-specific definition narrows it to the
// schema generated from that chart.
values: {...}
// Same scoping trap as #Service: inside metadata or source, bare `name`
// or `chart` would resolve to the nearest field of that name.
let _name = name
let _ns = namespace
let _chart = chart
let _values = values
application: argocd.#Application & {
apiVersion: "argoproj.io/v1alpha1"
kind: "Application"
metadata: {
name: "\(_name)-\(env)"
namespace: "argocd"
}
spec: {
project: "default"
source: {
repoURL: _chart.repoURL
chart: _chart.name
targetRevision: _chart.version
// Inline values rather than a values file: the whole
// configuration is in one reviewable object, and the
// rendered-output diff shows every value change.
helm: valuesObject: _values
}
destination: {
server: "https://kubernetes.default.svc"
namespace: _ns
}
syncPolicy: {
automated: {prune: true, selfHeal: true}
syncOptions: ["CreateNamespace=true", "ServerSideApply=true"]
}
}
}
}For cert-manager, #CertManager pins the chart version and narrows values to a CUE schema generated from the chart’s published JSON schema. The helmApps map adds an instance of that definition to the environment’s output:
#CertManager: #HelmApp & {
name: "cert-manager"
namespace: "cert-manager"
chart: {
repoURL: "https://charts.jetstack.io"
name: "cert-manager"
version: "v1.21.2"
}
values: certmanager.#Values
}
helmApps: "cert-manager": #CertManager & {
values: {
crds: enabled: true
prometheus: enabled: true
}
}#Values is generated from the JSON schema for the pinned chart version. An excerpt from the generated definition shows how it types replicaCount:
#: "helm-values.replicaCount": numberThat constraint is why replicaCount: "2" fails type-checking before the Application reaches Argo CD. A helper script pulls the exact chart version and imports its schema:
cp "$schema" "$dest/values.schema.json"
cd "$dest"
$CUE import -f -p "$pkg" -l '#Values:' jsonschema: values.schema.jsonBumping the chart means regenerating the schema in the same PR. If a chart doesn’t provide a JSON schema, the script stops. You can then hand-write a small #Values definition for the keys you use, or leave values open and accept that CUE won’t check them.
manifests.cue gathers the ordinary Kubernetes objects and the application from each helmApps entry into the YAML stream that Argo CD reads:
package deploy
import "encoding/yaml"
objects: [
for _, s in services for _, o in s.objects {o},
for _, a in helmApps {a.application},
]
manifests: yaml.MarshalStream(objects)The environment Application manages the generated cert-manager Application, following the app-of-apps pattern. Argo CD then renders the chart. The PR diff shows the Application and its values, not the manifests produced by the chart. CUE can catch a misspelt key or wrong value type when those conflict with the chart’s published schema, but this pipeline doesn’t show what the chart will render.
Use one render command everywhere
scripts/render.sh is the rendering contract for local runs, pull request checks and the output repository. It vets every environment before writing output, so a failing prod policy can’t leave a fresh dev render next to stale prod YAML. It then clears the output directory and exports one manifest stream per environment:
# Vet every environment before writing anything.
for env in "${envs[@]}"; do
$CUE vet -c . -t "env=$env"
done
# Wipe first so deleted resources disappear from the output.
find "$out" -mindepth 1 -delete
for env in "${envs[@]}"; do
mkdir -p "$out/$env"
$CUE export . -e manifests --out text -t "env=$env" >"$out/$env/manifests.yaml"
doneFor local runs without a Go module, invoke the pinned version directly:
CUE="go run cuelang.org/go/cmd/cue@v0.17.1" bash scripts/render.sh /tmp/renderedIf the repo already has a go.mod, you can add CUE as a Go tool instead (Go 1.24 or later). Commit the resulting go.mod and go.sum changes so other developers use the same version:
go get -tool cuelang.org/go/cmd/cue@v0.17.1
CUE="go tool cue" bash scripts/render.sh /tmp/renderedYou can also install the CUE CLI and run bash scripts/render.sh /tmp/rendered; the script uses cue from PATH by default. Check cue version first so you know you're using v0.17.1. In GitHub Actions, the official cue-lang/setup-cue action can install that version too.
Make the rendered diff part of review
For a pull request, head is the proposed change and base is the branch it will merge into. The workflow renders both into temporary directories, using the base commit’s own render.sh for the baseline. If the PR changes the renderer, this compares its output with what the target branch currently produces. The workflow posts the manifest diff as a comment so reviewers can see the resulting YAML before merge, without waiting for the scheduled output render.
No stored secrets or output-repo access are needed. GitHub provides a token to create or update comments on same-repo pull requests. Fork pull requests still run the render check but skip the comment because their token is read-only. A render or policy failure stops the check before the diff is posted.
Pull source and write output from the output repo
The output workflow checks out the source with a GitHub App token that can only read that repo. It supplies the output repo’s write token only to the final push step, after the source has been rendered.
The workflow uses .source-sha as a checkpoint: the last source commit touching render inputs that it has processed. It finds the newest commit touching deploy or scripts/render.sh and compares that SHA with the checkpoint:
sha=$(git -C source log -1 --format=%H -- deploy scripts/render.sh)
last=$(cat output/.source-sha 2>/dev/null || true)
echo "sha=$sha" >>"$GITHUB_OUTPUT"
if [[ "$sha" == "$last" ]]; then
echo "changed=false" >>"$GITHUB_OUTPUT"
echo "Render inputs unchanged at ${sha:0:12}, nothing to do."
else
echo "changed=true" >>"$GITHUB_OUTPUT"
fiIf the SHAs match, the workflow skips rendering. If they differ, it renders and commits the new SHA to .source-sha, even when the manifests themselves don’t change. Otherwise a comment-only edit under deploy would leave the checkpoint stale and trigger the same render every five minutes. When manifests do change, the source SHA and commit URL in the output commit message make the rendered result traceable to its input. CUE’s release doesn’t ship a checksums file, so both workflows pin the release tarball’s SHA-256 and fail if it doesn’t match.
Let an ApplicationSet discover environments
The ApplicationSet watches envs/* in the output repo and creates one Argo CD Application for each directory. This is the flat, per-environment version shown in the example. In the larger layout above, the generator would need to discover each system/environment pair and create an Application for each one. The generated Applications sync with prune and self-heal enabled. Removing a resource from CUE therefore removes it from the rendered output, and Argo CD can prune it from the cluster.
There’s a separate safety setting for removing an entire environment directory:
syncPolicy:
preserveResourcesOnDeletion: trueWithout it, deleting the generated Application can cascade and delete everything it manages. With it, deleting an environment directory does not automatically delete those cluster resources. That’s a useful guardrail, but it also means environment removal needs a deliberate cleanup plan.
Guardrails in action
With CUE v0.17.1, these invalid configurations produce the following cue vet -c errors:
| Change | Error |
|---|---|
Prod worker with replicas: 2 | invalid value 2 (out of bound >=3) |
A second file sets prod api replicas to 3 | conflicting values 5 and 3 |
spec: replica: 3 on a Deployment | field not allowed |
| An image from another registry | conflicting values |
Render without -t env=... | unresolved disjunction |
cert-manager value replicaCont: 2 | field not allowed |
cert-manager replicaCount: "2" | mismatched types string and number |
| Override the pinned chart version elsewhere | conflicting values "v1.21.2" and "v1.22.0" |
These are useful because they fail while rendering, before Argo CD sees the output. A misspelt Kubernetes field or chart value is rejected against its imported schema; a string where the chart expects a number fails the type check. A value that conflicts with another file is reported as a conflict, rather than winning or losing based on file order. And forgetting to select an environment doesn’t quietly render dev by default.
The registry and replica checks are ordinary constraints in the same CUE package as the service definitions. There’s no separate policy command that could be forgotten in one path through CI.
A few gotchas
CUE’s scoping rule can bite inside Kubernetes objects. A bare field reference resolves to the nearest enclosing field of that name. Inside a Deployment, replicas can resolve to spec.replicas itself; name, image and resources can cause the same trap.
The service schema binds inputs with let and uses those bindings when building Kubernetes objects. I hit this three times while putting the example together, so bind inputs consistently rather than fixing each collision as it appears.
Rollbacks start in the source repo. Revert the source change; that creates a new render-input commit, so the output workflow regenerates the YAML and Argo CD syncs it. Don’t rely on reverting YAML in the output repo: if .source-sha still matches, the scheduled workflow skips rendering, so that edit can persist until a source render input changes. A manual workflow trigger has the same checkpoint check.
Prod replicas are explicit by design. #Service defaults to 2, but the prod policy requires at least 3. Every prod service must set replicas explicitly or rendering fails as incomplete.
The schedule isn’t a fast delivery mechanism. GitHub schedules run no more often than every five minutes, and can lag. The output workflow has a manual trigger for urgent changes; Argo CD still has its own polling unless you add a webhook on the output repo.
Pinning CUE takes a little housekeeping. CUE releases don’t ship a checksums file, so both workflows pin the tarball’s SHA-256. Update the version and hash together when upgrading.
Where I’d draw the line
For a small set of environments and straightforward patches, Kustomize is probably the right amount of tool. I’d favour CUE when a team can maintain the abstractions and wants service definitions, environment values and policy checked together. For me, the extra work is worthwhile if it catches a bad production value before the output repo is updated.
Helm can validate chart values against values.schema.json, but enforcing shared policy across environments takes extra tooling with Helm or Kustomize. With Helm and Argo CD, I’ve often discovered bad values only after a sync fails, then had to dig through the Argo CD UI. I’d still keep Helm for third-party software, with CUE checking its values and Argo CD rendering the chart. Reviewers see those values, not the chart’s final manifests. Argo CD can sync the plain YAML for our own services from the output repo, without the source repo having a credential to write there.