Generate a JSON Schema for a Helm chart's values.yaml by analyzing the chart's templates:
π Documentation β introduction, quick start, guide, and full CLI reference.
- Discovers a chart (and its vendored dependencies under
charts/, including.tgz/.tar.gzarchives). - Reads the Helm templates and finds every
.Values.*the chart actually uses. - Follows the control flow around each value (
if,with,range, plus patterns such asdefault,eq,not, andor), so the schema mirrors the template's real logic. - Tracks which Kubernetes resource and field a value flows into, so it can type that value from the upstream API.
- Types values using upstream Kubernetes JSON schemas and CRD schemas.
- Merges these signals into a single Draft-07 JSON schema (commonly saved as
values.schema.json).
Many Helm schema generation approaches primarily rely on:
- The chart's
values.yamlas the source-of-truth for types, and/or - Manual annotations/comments embedded in
values.yaml.
Those approaches can be useful, but they don't see how values are actually used in templates.
helm-schema goes a step further:
- Template-aware: it inspects the templates and extracts
.Values.*paths from actual render logic. - Resource-aware: it tries to understand which Kubernetes resource a value is used in and where in that resource.
- Schema-backed: it uses upstream Kubernetes JSON schemas (and CRD schemas) to infer realistic object/field types.
In other words: instead of documenting values in isolation, it tries to infer the contract from:
- How values are consumed by templates.
- What Kubernetes expects at the target field.
The result is often a more informative schema than one derived from values.yaml alone.
# via brew
brew install --cask romnn/tap/helm-schema
# or from source
cargo install --locked helm-schema-cliGenerate schema for a chart directory:
helm-schema ./path/to/chart > values.schema.jsonWrite to a file:
helm-schema ./path/to/chart --output values.schema.jsonCompact output:
helm-schema ./path/to/chart --output values.schema.json --compactFor exceptionally conditional charts that exceed Helm's 5 MiB chart-file
limit or compile slowly during helm lint, emit the lean validation profile:
helm-schema ./path/to/chart \
--profile lean \
--output values.schema.json \
--compactLean keeps base path, type, and provider constraints, but omits document-level conditional refinements. It can therefore accept some values that full validation rejects. Full validation remains the default.
Generate a schema fully offline (no network access) using a pre-populated schema cache:
helm-schema ./path/to/chart \
--offline \
--k8s-schema-cache-dir ./k8s-schema-cache \
--output values.schema.jsonGenerate without Kubernetes schemas (template-only extraction + defaults):
helm-schema ./path/to/chart --no-k8s-schemas --output values.schema.json--k8s-version <VERSION>(repeatable)- Kubernetes minor version directory(s) to consult, in user-supplied priority order.
The first value is the primary; any further values are explicit fallbacks.
Default:
v1.35.0.
- Kubernetes minor version directory(s) to consult, in user-supplied priority order.
The first value is the primary; any further values are explicit fallbacks.
Default:
--k8s-version-fallback=auto|<N>- Auto-extend the (single explicit)
--k8s-versionwithNolder minors.autouses the default window (15, sized to cover the realistic K8s deprecation horizon β charts in the wild still shippolicy/v1beta1andnetworking.k8s.io/v1beta1, both removed in v1.25). Useful for charts that target a deprecated API (e.g.PodSecurityPolicy (policy/v1beta1)) β the lookup falls back throughv1.34.0 β v1.33.0 β β¦until it finds the schema. Mutually exclusive with--strict-k8s-version; rejected when combined with multiple explicit--k8s-versionvalues (the right knob in that case is the explicit list). - Auto-fallback versions are escape valves for the schema-lookup layer only;
they do NOT participate in apiVersion inference cache scans (so a chart
that's missing an
apiVersionfor aPodDisruptionBudgetwon't pick up the fallback'spolicy/v1beta1and become ambiguous againstpolicy/v1).
- Auto-extend the (single explicit)
--k8s-schema-mirror <URL>(repeatable)- Additional upstream K8s schema mirror URL. Per-source cache namespacing keeps mirror entries from masking the default catalog. Available in both strict and loose modes β mirrors are alternate exact-version sources, not heuristics.
--k8s-schema-cache-dir <DIR>- Managed cache root for K8s schemas. Subject to the cache compatibility contract below.
--strict-k8s-version- Disable Feature B's auto-fallback chain. Conflicts only with
--k8s-version-fallback; orthogonal to--k8s-schema-mirror.
- Disable Feature B's auto-fallback chain. Conflicts only with
--offline- Disable all network access; use only local caches.
--no-k8s-schemas- Disable Kubernetes JSON schema lookup entirely.
--crd-version-lookup=strict|loose(default:strict)strict: only the exact(group, kind, version)the chart pinned is consulted.loose: same as strict for the actual schema resolution (CRD version is never substituted), but additionally enables a local-cache cross-scan that lets the tool emit aCrdVersionAvailableAtOtherVersionshint when the requested version is missing but other versions of the same(group, kind)are present in the cache.
--strict-crd-version- Short alias for
--crd-version-lookup=strict. Kept for symmetry with the other strict flags and to keep CI opt-out flags short.
- Short alias for
--crd-catalog-mirror <URL>(repeatable)- Additional upstream CRD catalog mirror URL. Per-source cache namespacing keeps mirror entries from masking the default catalog. Available in both strict and loose modes.
--crd-catalog-cache-dir <DIR>- Managed cache root for CRD schemas. Subject to the cache compatibility contract below.
--crd-override-dir <DIR>- Hand-maintained local schema override layer. Never wiped, never subject
to the cache invalidation contract. Despite the historical
crd-prefix in the flag name (kept for compatibility), this layer accepts schemas for any grouped resource β CRDs you've patched locally, built-in K8s resources whose upstream schema you want to override, anything keyed by(group, version, kind). It sits at the top of the lookup chain ahead of both the CRD catalog and the K8s OpenAPI provider, so anything placed here is authoritative. - Authoritative shadowing of built-in schemas is deliberate (power-user
feature for adding custom constraints, locking a chart to a historical
schema, or working around an upstream bug). The safety implication: don't
point this at a directory you don't control; whatever JSON is at
<group>/<kind>_<version>.jsonwill silently replace the upstream answer. - If the override file is unreadable, the chain emits
LocalOverrideUnreadableand does not fall through to the catalog or upstream β silently substituting a different schema for one the user pinned would be wrong.
- Hand-maintained local schema override layer. Never wiped, never subject
to the cache invalidation contract. Despite the historical
--crd-cache-record-source- Write a
<schema>.json.metasidecar alongside every CRD cache entry recording the fetch URL and timestamp. Useful when debugging which mirror answered.
- Write a
Note: the previous --crd-catalog-dir flag is removed. Use
--crd-override-dir (hand-maintained schemas) and/or --crd-catalog-cache-dir
(managed cache root) instead. Passing the old flag fails CLI validation with a
hint pointing to the replacements.
When the IR walker can't statically pin a manifest's apiVersion (because it's
templated or absent), the lookup normally fails with apiVersion unknown. Two
flags control the optional inference:
--api-version-guess- Enable a three-tier inference path: (1) a hardcoded canonical
kind β apiVersionshortlist for unambiguous K8s + Prometheus operator kinds, (2) a local cache scan across every configured K8s + CRD cache namespace, (3) a kind-scoped HTTP probe against the CRD catalog mirrors (only for kinds in the extended shortlist β no blind group sweeps). When exactly one apiVersion is implied, the chain emitsInferredApiVersion. When multiple plausible candidates exist (e.g.Ingressin bothnetworking.k8s.io/v1andextensions/v1beta1), the chain abstains and emitsAmbiguousApiVersion.
- Enable a three-tier inference path: (1) a hardcoded canonical
--strict-api-versions- Disable the inference path entirely. Mutually exclusive with
--api-version-guess.
- Disable the inference path entirely. Mutually exclusive with
--diag-format=text|json(default:text)- In
textmode, diagnostics print to stderr prefixed withwarning:orinfo:. - In
jsonmode, each diagnostic is emitted as a single JSON object per line on stderr. After successful CLI parse, every stderr line is aDiagnosticJSON object (a discriminated union tagged on the"type"field). CLI parse errors are not subject to this contract β clap writes its plain-text usage error to stderr before our runtime starts; JSON consumers detect "parse vs runtime" by exit code: non-zero exit before any JSON line means parse-error.
- In
Diagnostic variants the tool emits:
| Variant | When |
|---|---|
MissingSchema |
No provider in the chain owns the resource. Carries the K8s versions tried, filenames tried, and (when available) other cache versions that do hold the resource. |
ResolvedFromFallbackVersion |
A non-primary K8s version answered (Feature B). |
InferredApiVersion |
The apiVersion was inferred for a kind with no static apiVersion in the template (Feature D). |
AmbiguousApiVersion |
Multiple plausible apiVersions; the chain abstains. |
CrdVersionNotFound |
The chart's exact CRD version was not found in any probed location. |
CrdVersionAvailableAtOtherVersions |
Same (group, kind) exists at other versions in local cache; informational only β the chain never substitutes. |
LocalOverrideUnreadable |
The hand-maintained override claimed a resource but its file is unreadable. Hard error: the chain does not fall through. |
CacheLayoutInvalidated |
A managed cache root's layout was older than the compiled binary; the root was wiped and will be repopulated. |
CacheLayoutForwardIncompatible |
A managed cache root carries a marker newer than the binary; the binary refuses to mutate the root. |
helm-schema cache layout is not a stable compatibility surface during alpha.
Each managed cache root (--k8s-schema-cache-dir, --crd-catalog-cache-dir)
carries an on-disk CACHE_LAYOUT_VERSION marker. At startup:
- Marker matches the binary's compiled-in constant β root is used as-is.
- Marker missing and root is populated (legacy layout) β managed subtree is
wiped and repopulated. One
CacheLayoutInvalidateddiagnostic is emitted. - Marker missing and root is empty β first-populate, marker is written. No diagnostic.
- Marker older than the binary's constant β wipe and repopulate, same as above.
- Marker newer than the binary's constant β the binary refuses to mutate the
root (forward-incompat). One
CacheLayoutForwardIncompatiblediagnostic is emitted; the root is left untouched. The user is expected to upgrade or point the flag at a different path.
K8s and CRD roots are versioned and invalidated independently. A forward- incompat K8s cache does not block CRD resolution and vice versa.
--crd-override-dir is a different concept β it is hand-maintained content,
never wiped, no marker, not subject to this contract. Mixing the two roles in
a single directory is prevented at CLI parse time.
Both managed cache roots use a per-source layout so a --k8s-schema-mirror /
--crd-catalog-mirror URL never silently masks the default catalog's content:
<k8s-cache-root>/
βββ CACHE_LAYOUT_VERSION
βββ default/ # built-in yannh catalog
β βββ v1.35.0/service-v1.json
β βββ β¦
βββ <hash-of-mirror-url>/ # per-mirror namespace
βββ v1.35.0/service-v1.json
<crd-cache-root>/
βββ CACHE_LAYOUT_VERSION
βββ default/ # built-in datreeio catalog
β βββ monitoring.coreos.com/servicemonitor_v1.json
β βββ β¦
βββ <hash-of-mirror-url>/ # per-mirror namespace
βββ monitoring.coreos.com/servicemonitor_v1.json
Precedence at lookup time: default catalog wins over mirrors. The mirror's cache entry stays in its own namespace for inspection / debugging but is not returned.
--exclude-tests- Do not scan
templates/tests/**.
- Do not scan
--no-subchart-values- Do not include vendored subchart values under
charts/in the composed values.
- Do not include vendored subchart values under
--infer-required- Mark paths that the chart checks unconditionally at the top of a
template (
{{- if .Values.X }}/{{- eq .Values.X "..." }}with no enclosing guard) asrequiredon their parent object. Paths reachable via anydefault <expr> .Values.Xfallback (literal or non-literal, e.g.default .Chart.Name .Values.nameOverride) are excluded because the chart explicitly handles them being unset. Off by default β the generated schema stays as permissive as the template logic allows.
- Mark paths that the chart checks unconditionally at the top of a
template (
When a template uses default <literal> .Values.X (or the pipeline form
.Values.X | default <literal>), helm-schema infers X's type from the
literal β string, integer, number, or boolean. Combined with a null (or
absent) value in values.yaml, the inferred schema becomes a nullable
union: anyOf: [{type: null}, {type: <literal-type>}]. This catches the
common Helm pattern of "ship a null placeholder, fall back to a literal
at render time" without surfacing it as a schema validation error.
Non-literal fallbacks (default .Chart.Name .Values.X, default (printf "%s" .Y) .Values.X) don't get a type hint β we can't statically infer the
type β but they still suppress --infer-required for X.
Library charts have no value scope of their own β their helpers run in
the caller's scope, with .Values.X resolving against the chart that
includes the helper. helm-schema builds a per-helper call graph
across every chart's templates: nodes are individual {{ define "name" }} blocks plus per-chart "chart-direct" pseudo-nodes for text
outside any define, edges are {{ include "callee" ... }} /
{{ template "callee" ... }} references.
When a non-library chart's schema is generated, the helpers reachable
from that chart's chart-direct includes β followed transitively through
the graph β are the helpers whose signals (type hints and
default-fallback paths used by --infer-required) apply at that
chart's value prefix. The resolution is helper-granular, not library-
name-granular: a library that defines both a helper the app includes
and another helper the app never references will only contribute the
included helper's signals. Transitive chains (app β libA.X β libB.Y)
carry signals from the deepest helper back to the app.
You can post-process the generated schema with one or more override schemas:
helm-schema ./path/to/chart \
--override-schema ../schemas/injected-keys.override.json \
--override-schema ./schema-override.json \
--output values.schema.json--override-schema is repeatable. Files are applied in the order given, each merged on top of the previous result. The intended split is a shared cross-chart override (e.g. top-level keys an outer pipeline injects at render time and helm-schema can't see in values.yaml) followed by a chart-specific override.
Overrides are applied as a recursive merge (with special handling to union required lists), which is useful for tightening types and filling inference gaps. One exception: an override subtree that contains $ref replaces the corresponding base subtree entirely rather than merging β JSON Schema draft-07 ignores siblings of $ref, and merging would otherwise leave inferred constraints from the base alongside the refed schema's constraints, producing shapes no input can satisfy.
For every value, helm-schema combines three signals, and the schema is recovered from what the chart does β not from what its defaults happen to be:
- How your templates use it β which resource field the value renders into, and the control flow around it. A value used only behind
{{ if .Values.x.enabled }}is only constrained when that guard is on; a value with no default still appears if the chart reads it. - Your composed defaults β the root
values.yamlmerged with each subchart's defaults andglobal, the same way Helm composes them. - What Kubernetes expects β once a value is traced to a resource field, its type (and description) comes from the upstream Kubernetes or CRD schema for that field. A port that happens to be
80is an integer because the Service field is one, not because80looks numeric.
When a value is genuinely ambiguous it stays a union rather than a wrong guess, and anything that can't be resolved is reported as a diagnostic instead of being invented. See the documentation for the details.
This project is useful and works for many charts, but it is not yet fully production-ready or 100% reliable.
Helm templating and YAML composition have many edge cases (whitespace trimming, dynamic keys, helper indirection, runtime-only behavior, etc.). Expect some charts to require manual overrides and expect occasional incorrect or missing inference.