Templating Helm Releases with Jinja2
Crossplane Python Functions | Part 7
Building a production multi-cloud platform with Python
Your clusters need applications: cert-manager, ingress controllers, monitoring stacks. Crossplane's Helm provider deploys these, but Helm values often need dynamic content—cluster names, endpoints, secrets from the composition context.
This post shows how to integrate Jinja2 templating into your Python functions for dynamic Helm value generation.
The Problem: Static Helm Values
A naive approach uses static values:
def add_ingress_nginx(c: Composition):
myplatform.add_helm_release(
composition=c,
release=HelmRelease(
name="ingress-nginx",
repo="https://kubernetes.github.io/ingress-nginx",
version="4.9.0",
values={
"controller": {
"replicaCount": 2,
"service": {
"annotations": {
"cloud.google.com/load-balancer-type": "Internal"
}
}
}
}
),
)
Problems:
- Cloud-specific annotations hard-coded - GCP vs AWS vs Azure annotations differ
- No access to composition data - Can't reference cluster name, domain, etc.
- Environment configs unused - Can't change replica count per environment
- Duplicate values across charts - Common settings repeated everywhere
Jinja2 for Dynamic Values
Jinja2 is Python's most popular templating engine. It lets you write templates that reference variables at render time.

Basic Jinja2 Example
from jinja2 import Template
template = Template("""
controller:
replicaCount: {{ replica_count }}
service:
annotations:
{{ annotation_key }}: {{ annotation_value }}
""")
values_yaml = template.render(
replica_count=3,
annotation_key="cloud.google.com/load-balancer-type",
annotation_value="Internal",
)
print(values_yaml)
# controller:
# replicaCount: 3
# service:
# annotations:
# cloud.google.com/load-balancer-type: Internal
Integrating with Compositions
The HelmRelease Dataclass
First, define a structured representation:
from dataclasses import dataclass
@dataclass
class HelmRelease:
"""A Helm Release configuration."""
name: str # Release name
repo: str # Chart repository URL
version: str # Chart version
chart: str = "" # Chart name (defaults to release name)
namespace: str = "" # Target namespace (defaults to release name)
values: dict | str | None = None # Values as dict or Jinja2 template
def __post_init__(self):
self.chart = self.chart or self.name
self.namespace = self.namespace or self.name
The MyPlatform Helper Class
To call myplatform functions from templates:
import importlib
class MyPlatform:
"""Exposes myplatform functions to Jinja2 templates."""
def __init__(self, composition):
self.composition = composition
self.module = importlib.import_module("myplatform")
def __getattr__(self, name):
"""Return a lambda that injects the composition."""
return lambda *args, **kwargs: getattr(self.module, name)(
*args, **kwargs, composition=self.composition
)
This lets templates call myplatform.add_bucket(...) directly.
The add_helm_release Function
import yaml
from jinja2 import Template, Undefined, make_logging_undefined
def add_helm_release(
release: HelmRelease,
providerconfig_resource: str,
**r: Unpack[ResourceDict],
):
"""Add a Helm release with Jinja2 templated values."""
if "external_name" not in r:
r["external_name"] = release.name
resource = Resource(**r)
values = release.values
if values:
# Accept both dicts and strings
if isinstance(values, dict):
values = yaml.dump(values)
# Create undefined handler that logs missing variables
logging_undefined = make_logging_undefined(
logger=resource.composition.log,
base=Undefined,
)
# Render the template
try:
values = Template(
values,
undefined=logging_undefined,
finalize=lambda x: "" if x is None else x,
).render(
# Spread environment configs
**resource.composition.env,
# Add composition context
composition=resource.composition,
cloud=resource.cloud,
logger=resource.composition.log,
ns=release.namespace,
# Add myplatform functions
myplatform=MyPlatform(resource.composition),
)
except Exception as e:
resource.composition.log.error(
f"Failed to render template for {release.name}: {e}"
)
raise
# Parse back to dict for Crossplane
values = yaml.safe_load(values)
# Build the Release resource
definition = {
"kind": "Release",
"apiVersion": "helm.crossplane.io/v1beta1",
"spec": {
"forProvider": {
"chart": {
"name": release.chart,
"repository": release.repo,
"version": release.version,
},
"values": values or {},
"namespace": release.namespace,
},
"providerConfigRef": {
"name": get_resource_name(
resource.composition.observed,
providerconfig_resource,
),
},
},
}
add_definition(definition, release.name, **r)
Writing Templated Values
Basic Variable Substitution
def add_cert_manager(c: Composition):
myplatform.add_helm_release(
composition=c,
release=HelmRelease(
name="cert-manager",
repo="https://charts.jetstack.io",
version="v1.14.0",
values="""
installCRDs: true
prometheus:
enabled: {{ monitoring.enabled | default(true) }}
replicaCount: {{ cert_manager_replicas | default(1) }}
""",
),
providerconfig_resource="helm_providerconfig",
uses=["cluster", "k8s-provider"],
)
Cloud-Specific Configuration
def add_ingress_nginx(c: Composition):
myplatform.add_helm_release(
composition=c,
release=HelmRelease(
name="ingress-nginx",
repo="https://kubernetes.github.io/ingress-nginx",
version="4.9.0",
values="""
controller:
replicaCount: {{ ingress_replicas | default(2) }}
service:
annotations:
{% if cloud == "gcp" %}
cloud.google.com/load-balancer-type: "Internal"
networking.gke.io/load-balancer-type: "Internal"
{% elif cloud == "aws" %}
service.beta.kubernetes.io/aws-load-balancer-type: "nlb"
service.beta.kubernetes.io/aws-load-balancer-scheme: "internal"
{% elif cloud == "azure" %}
service.beta.kubernetes.io/azure-load-balancer-internal: "true"
{% endif %}
""",
),
providerconfig_resource="helm_providerconfig",
uses=["cluster"],
)
Accessing Composition Context
def add_external_dns(c: Composition):
myplatform.add_helm_release(
composition=c,
release=HelmRelease(
name="external-dns",
repo="https://kubernetes-sigs.github.io/external-dns",
version="1.14.0",
values="""
provider: {{ cloud }}
domainFilters:
- {{ composition.params.domain }}
{% if cloud == "gcp" %}
google:
project: {{ composition.plane }}
serviceAccountSecretRef:
name: external-dns-gcp
key: credentials.json
{% elif cloud == "aws" %}
aws:
region: {{ composition.location }}
zoneType: public
{% endif %}
txtOwnerId: {{ composition.metadata.name }}
""",
),
providerconfig_resource="helm_providerconfig",
uses=["cluster", "dns_zone"],
)
Using Environment Config Values
def add_prometheus(c: Composition):
myplatform.add_helm_release(
composition=c,
release=HelmRelease(
name="kube-prometheus-stack",
repo="https://prometheus-community.github.io/helm-charts",
version="56.0.0",
values="""
prometheus:
prometheusSpec:
retention: {{ monitoring.retention | default("30d") }}
storageSpec:
volumeClaimTemplate:
spec:
storageClassName: {{ storage_class | default("standard") }}
resources:
requests:
storage: {{ monitoring.storage_size | default("50Gi") }}
grafana:
enabled: {{ monitoring.grafana.enabled | default(true) }}
adminPassword: {{ monitoring.grafana.admin_password | default("admin") }}
alertmanager:
enabled: {{ monitoring.alertmanager.enabled | default(true) }}
config:
global:
resolve_timeout: 5m
receivers:
- name: 'null'
{% if monitoring.slack.webhook %}
- name: 'slack'
slack_configs:
- api_url: {{ monitoring.slack.webhook }}
channel: {{ monitoring.slack.channel | default("#alerts") }}
{% endif %}
""",
),
providerconfig_resource="helm_providerconfig",
uses=["cluster"],
)
Advanced Templating Patterns
Loops in Templates
values = """
config:
sources:
{% for source in data_sources %}
- name: {{ source.name }}
type: {{ source.type }}
url: {{ source.url }}
{% endfor %}
"""
Conditional Blocks
values = """
{% if features.ha_mode %}
replicaCount: 3
affinity:
podAntiAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
- labelSelector:
matchLabels:
app.kubernetes.io/name: {{ release_name }}
topologyKey: kubernetes.io/hostname
{% else %}
replicaCount: 1
{% endif %}
"""
Default Values
values = """
# Simple default
replicas: {{ replicas | default(1) }}
# Nested default
timeout: {{ config.timeout | default(30) }}
# Default to expression
name: {{ custom_name | default(composition.metadata.name) }}
"""
Safe Access with Filters
values = """
# Avoid errors if key doesn't exist
{% if monitoring is defined and monitoring.enabled %}
metrics:
enabled: true
{% endif %}
# Or use default filter
metrics:
enabled: {{ (monitoring.enabled if monitoring is defined else true) }}
"""
Handling Undefined Variables
Jinja2's default behavior raises errors on undefined variables. Use make_logging_undefined to log instead:
from jinja2 import make_logging_undefined, Undefined
logging_undefined = make_logging_undefined(
logger=composition.log,
base=Undefined,
)
template = Template(
values_template,
undefined=logging_undefined,
)
Now missing variables log warnings instead of crashing:
WARNING: Template variable 'monitoring.slack.webhook' is undefined
Custom Undefined Handling
from jinja2 import Undefined
class SilentUndefined(Undefined):
"""Undefined variables return empty string."""
def _fail_with_undefined_error(self, *args, **kwargs):
return ""
def __str__(self):
return ""
def __iter__(self):
return iter([])
template = Template(values, undefined=SilentUndefined)
Calling MyPlatform from Templates
The MyPlatform helper lets templates create additional resources:
values = """
# This calls myplatform.add_bucket() from within the template
{% set _ = myplatform.add_bucket(
name="loki-chunks",
external_name=composition.metadata.name + "-loki",
template={"spec": {"forProvider": {"location": composition.location}}}
) %}
loki:
storage:
type: gcs
bucketNames:
chunks: {{ composition.metadata.name }}-loki
ruler: {{ composition.metadata.name }}-loki
"""
This is powerful but use with caution—it can make templates harder to understand.
Error Handling
Validation Before Rendering
def validate_values_template(template_str: str, available_vars: dict):
"""Check that all template variables are available."""
from jinja2 import Environment, meta
env = Environment()
ast = env.parse(template_str)
required_vars = meta.find_undeclared_variables(ast)
missing = required_vars - set(available_vars.keys())
if missing:
raise ValueError(f"Template requires missing variables: {missing}")
Catching Render Errors
try:
rendered = template.render(**context)
except Exception as e:
composition.log.error(
f"Template render failed for {release.name}",
error=str(e),
template=values_template[:200], # First 200 chars for debugging
)
raise
YAML Parse Validation
try:
values = yaml.safe_load(rendered_template)
except yaml.YAMLError as e:
composition.log.error(
f"Invalid YAML after template render for {release.name}",
error=str(e),
)
raise
Testing Templates
Unit Testing
import pytest
from jinja2 import Template
def test_ingress_nginx_gcp_values():
template = Template("""
controller:
service:
annotations:
{% if cloud == "gcp" %}
cloud.google.com/load-balancer-type: "Internal"
{% endif %}
""")
result = template.render(cloud="gcp")
assert "cloud.google.com/load-balancer-type" in result
assert "Internal" in result
def test_ingress_nginx_aws_values():
template = Template("""
controller:
service:
annotations:
{% if cloud == "aws" %}
service.beta.kubernetes.io/aws-load-balancer-type: "nlb"
{% endif %}
""")
result = template.render(cloud="aws")
assert "aws-load-balancer-type" in result
Integration Testing with Render
# Test full render with templates
crossplane beta render \
examples/claim.yaml \
apis/cluster/composition.yaml \
examples/functions.yaml \
-e examples/envconfigs/ \
-r | grep -A50 "kind: Release"
Best Practices
1. Keep Templates Readable
# Good: Clear structure
controller:
replicaCount: {{ replicas | default(2) }}
resources:
requests:
cpu: {{ cpu_request | default("100m") }}
memory: {{ memory_request | default("128Mi") }}
# Avoid: Complex inline expressions
controller:
replicaCount: {{ ((env.production and 5) or (env.staging and 3) or 2) }}
2. Use Environment Configs for Defaults
# In EnvironmentConfig (not in template)
monitoring:
prometheus:
retention: 30d
storage: 50Gi
# Template just references
retention: {{ monitoring.prometheus.retention }}
storage: {{ monitoring.prometheus.storage }}
3. Document Template Variables
def add_custom_app(c: Composition):
"""Add custom application Helm release.
Template variables from environment config:
- custom_app.replicas: Number of replicas (default: 1)
- custom_app.image_tag: Docker image tag (required)
- custom_app.resources.cpu: CPU request (default: 100m)
"""
myplatform.add_helm_release(...)
4. Validate Required Variables
def add_app(c: Composition):
# Check required config exists
if not get(c.env, "custom_app.image_tag"):
c.log.error("Missing required config: custom_app.image_tag")
response.fatal(c.rsp, "custom_app.image_tag is required")
return
myplatform.add_helm_release(...)
Complete Example
EnvironmentConfig
apiVersion: apiextensions.crossplane.io/v1alpha1
kind: EnvironmentConfig
metadata:
name: defaults.charts.ingress-nginx
labels:
baseline: default
data:
charts:
ingress-nginx:
enabled: true
version: "4.9.0"
replicas: 2
internal: true
Helm Release Function
def add_ingress_nginx(c: Composition):
config = get(c.env, "charts.ingress-nginx", {})
if config.get("disabled", False):
c.log.info("ingress-nginx disabled by config")
return
myplatform.add_helm_release(
composition=c,
release=HelmRelease(
name="ingress-nginx",
repo="https://kubernetes.github.io/ingress-nginx",
version=config.get("version", "4.9.0"),
values="""
controller:
replicaCount: {{ charts['ingress-nginx'].replicas | default(2) }}
service:
{% if charts['ingress-nginx'].internal | default(true) %}
annotations:
{% if cloud == "gcp" %}
cloud.google.com/load-balancer-type: "Internal"
{% elif cloud == "aws" %}
service.beta.kubernetes.io/aws-load-balancer-scheme: "internal"
{% elif cloud == "azure" %}
service.beta.kubernetes.io/azure-load-balancer-internal: "true"
{% endif %}
{% endif %}
metrics:
enabled: {{ monitoring.enabled | default(true) }}
""",
),
providerconfig_resource="helm_providerconfig",
uses=["cluster"],
)
Key Takeaways
- Jinja2 templates enable dynamic Helm values based on composition context
- Pass environment config as template variables with
**composition.env - Cloud conditionals handle provider-specific annotations and settings
- Use
make_logging_undefinedto log missing variables instead of crashing - The
MyPlatformhelper exposes functions to templates (use sparingly) - Validate templates before rendering to catch missing variables early
Next Up
In Part 8, we'll build CI/CD pipelines for your composition functions—GitHub Actions workflows that test, build, and deploy your functions automatically.
Written by Marouan Chakran, Senior SRE and Platform Engineer, building multi-cloud platforms with Crossplane and Python.
Part 7 of 10 | Previous: Configuration with EnvironmentConfigs | Next: CI/CD Pipelines
Companion repository: github.com/Marouan-chak/crossplane-python-blog-series
Tags: crossplane, platform-engineering, kubernetes, python, devops