Templating Guide¶
Overview¶
The HAProxy Template Ingress Controller uses Scriggo, a Go template engine, to generate HAProxy configurations from Kubernetes resources. The Helm chart ships with ready-to-use template libraries that cover standard Ingress and Gateway API use cases — you only need to write templates when you want to extend or replace that default behavior. Templates access watched Kubernetes resources, and the controller renders them whenever resources change, validates the output, and deploys it to HAProxy instances.
Templates are rendered automatically when any watched resource changes, during initial synchronization, or periodically for drift detection.
What You Can Template¶
| Template Type | Use When |
|---|---|
haproxyConfig |
Main HAProxy configuration (frontends, backends, global settings) |
maps |
HAProxy lookup tables for host/path routing decisions |
files |
Auxiliary files like custom error pages |
sslCertificates |
TLS certificate files assembled from Kubernetes Secrets |
HAProxy Configuration¶
The main haproxyConfig template generates the complete HAProxy configuration file:
haproxyConfig:
template: |
global
log stdout len 4096 local0 info
daemon
maxconn 4096
defaults
mode http
timeout connect 5s
timeout client 50s
timeout server 50s
frontend http
bind *:80
use_backend %[req.hdr(host),lower,map({{ pathResolver.GetPath("host.map", "map") }})]
{% for _, ingress := range resources.ingresses.List() %}
backend {{ ingress.metadata.name }}
balance roundrobin
{% end %}
Important
All auxiliary file references should use pathResolver.GetPath() to generate correct paths.
Map Files¶
Map files generate HAProxy lookup tables stored in /etc/haproxy/maps/:
maps:
host.map:
template: |
{%- for _, ingress := range resources.ingresses.List() %}
{%- for _, rule := range fallback(ingress.spec.rules, []any{}) %}
{%- if rule.http != nil %}
{{ rule.host }} {{ rule.host }}
{%- end %}
{%- end %}
{%- end %}
General Files¶
Auxiliary files like custom error pages stored in /etc/haproxy/general/:
files:
503.http:
template: |
HTTP/1.0 503 Service Unavailable
Cache-Control: no-cache
Connection: close
Content-Type: text/html
<html><body><h1>503 Service Unavailable</h1></body></html>
SSL Certificates¶
SSL/TLS certificate files from Kubernetes Secrets stored in /etc/haproxy/ssl/:
sslCertificates:
example-com.pem:
template: |
{%- var secret = resources.secrets.GetSingle("default", "example-com-tls") %}
{%- if secret != nil %}
{{ b64decode(secret.data["tls.crt"]) }}
{{ b64decode(secret.data["tls.key"]) }}
{%- end %}
Note
Certificate data in Secrets is base64-encoded. Use the b64decode filter to decode it.
Template Snippets¶
Reusable template fragments included via {{ render "snippet-name" }}:
templateSnippets:
backend-name:
template: >-
ing_{{ ingress.metadata.namespace }}_{{ ingress.metadata.name }}
backend-servers:
template: |
{%- for _, endpoint_slice := range resources.endpoints.Fetch(service_name) %}
{%- for _, endpoint := range fallback(endpoint_slice.endpoints, []any{}) %}
{%- for _, address := range fallback(endpoint.addresses, []any{}) %}
server {{ endpoint.targetRef.name }} {{ address }}:{{ port }} check
{%- end %}
{%- end %}
{%- end %}
Include a snippet in a template:
Include all snippets matching a glob pattern (rendered in alphabetical order):
Pass local variables to rendered snippets with inherit_context:
Post-Processing¶
The haproxyConfig section supports a postProcessing list that transforms the rendered output before deployment. Post-processors run sequentially on the rendered configuration.
Available types:
| Type | Description |
|---|---|
regex_replace |
Line-by-line regex find/replace (pattern and replace params) |
template |
Scriggo template transformation with access to the rendered output via the input variable (source param) |
haproxyConfig:
template: |
global
daemon
# ...
postProcessing:
- type: template
params:
source: |
{%- if strings_contains(input, "__PLACEHOLDER__") -%}
{{ replace(input, "__PLACEHOLDER__", "computed-value") }}
{%- else -%}
{{ input }}
{%- end -%}
- type: regex_replace
params:
pattern: "^[ ]+"
replace: " "
The template post-processor receives the fully rendered output as the input variable and has access to all standard Scriggo builtins (regexp, replace, len, tostring, etc.). Its output becomes the new rendered content.
Template Syntax¶
Templates use Scriggo's template syntax. For complete syntax reference, see the Scriggo documentation.
Control Structures¶
{# Loops #}
{% for _, ingress := range resources.ingresses.List() %}
backend {{ ingress.metadata.name }}
{% end %}
{# Conditionals #}
{% if ingress.spec.tls != nil %}
bind *:443 ssl crt {{ pathResolver.GetPath(ingress.metadata.name + ".pem", "cert") }}
{% end %}
{# Variables #}
{% var service_name = path.backend.service.name %}
{% var port = fallback(path.backend.service.port.number, 80) %}
{# Comments #}
{# This is a comment #}
Common Functions¶
{{ fallback(path.backend.service.port.number, 80) }} {# Default values #}
{{ toLower(rule.host) }} {# String manipulation #}
{{ len(ingress.spec.rules) }} {# Collection length #}
Path Resolution¶
The pathResolver.GetPath() method resolves filenames to paths:
{# Map files #}
use_backend %[req.hdr(host),lower,map({{ pathResolver.GetPath("host.map", "map") }})]
{# Output: maps/host.map #}
{# General files #}
errorfile 504 {{ pathResolver.GetPath("504.http", "file") }}
{# Output: files/504.http #}
{# SSL certificates #}
bind *:443 ssl crt {{ pathResolver.GetPath("example.com.pem", "cert") }}
{# Output: ssl/example.com.pem #}
Arguments: filename (required), type ("map", "file", "cert", or "crt-list")
Custom Filters¶
| Filter | Description | Example |
|---|---|---|
b64decode |
Decode base64 strings | {{ secret.data.password \| b64decode }} |
glob_match |
Filter strings by glob pattern | {{ templateSnippets \| glob_match("backend-*") }} |
group_by |
Group items by JSONPath | {{ ingresses \| group_by("$.metadata.namespace") }} |
indent |
Indent each line by N spaces | {{ render("snippet") \| indent(4) }} |
sanitize_regex |
Escape regex special characters | {{ path \| sanitize_regex }} |
sort_by |
Sort by JSONPath expressions | {{ routes \| sort_by(["$.priority:desc"]) }} |
debug |
Output as JSON comment | {{ routes \| debug("routes") }} |
toJSON |
Convert value to JSON string | {{ myMap \| toJSON() }} |
semver_gte |
Compare HAProxy version (major.minor) | {{ semver_gte(haproxyVersion, "3.3") }} |
sort_by modifiers: :desc (descending), :exists (by field presence), | length (by length)
Example - Route precedence sorting:
{% var sorted = sort_by(routes, []string{
"$.match.method:exists:desc",
"$.match.headers | length:desc",
"$.match.path.value | length:desc",
}) %}
Available Template Data¶
Context Variables¶
All templates have access to the following top-level variables:
| Variable | Type | Description |
|---|---|---|
resources |
map of stores | Kubernetes resources indexed per watchedResources config |
pathResolver |
object | Resolves filenames to HAProxy paths — use GetPath(name, type) |
haproxyVersion |
string | HAProxy version string, e.g. "3.2" — use with semver_gte filter |
currentConfig |
string | The currently deployed HAProxy configuration — used for slot-preserving updates |
shared |
map | Mutable cross-template cache for expensive computations |
templateSnippets |
list | Names of all available template snippets — useful for dynamic render_glob patterns |
Custom variables defined in templatingSettings.extraContext are also available directly by name. See Custom Template Variables.
The resources Variable¶
Templates access watched resources through the resources variable. Each store provides List(), Fetch(), and GetSingle() methods.
Note
The keys available under resources.* are determined by the watchedResources configuration. See Watching Resources to add resource types beyond the defaults.
{# List all resources #}
{% for _, ingress := range resources.ingresses.List() %}
{# Fetch by index keys (parameters match indexBy configuration) #}
{% for _, ingress := range resources.ingresses.Fetch("default", "my-ingress") %}
{# Get single resource or nil #}
{% var secret = resources.secrets.GetSingle("default", "my-secret") %}
Index Configuration¶
The indexBy field determines what parameters Fetch() expects:
watchedResources:
ingresses:
apiVersion: networking.k8s.io/v1
resources: ingresses
indexBy: ["metadata.namespace", "metadata.name"]
# Fetch(namespace, name)
endpoints:
apiVersion: discovery.k8s.io/v1
resources: endpointslices
indexBy: ["metadata.labels.kubernetes\\.io/service-name"]
# Fetch(service_name)
Tip
Escape dots in JSONPath for labels: kubernetes\\.io/service-name
Custom Template Variables¶
Add custom variables via templatingSettings.extraContext:
Access in templates:
{% if debug %}
http-response set-header X-Debug %[be_name]
{% end %}
global
maxconn {{ limits.maxConn }}
Common Patterns¶
Reserved Server Slots (Avoid Reloads)¶
Pre-allocate server slots to enable runtime API updates without reloads:
{%- var initial_slots = 10 %}
{%- var active_endpoints = []map[string]any{} %}
{# Collect endpoints #}
{%- for _, endpoint_slice := range resources.endpoints.Fetch(service_name) %}
{%- for _, endpoint := range fallback(endpoint_slice.endpoints, []any{}) %}
{%- for _, address := range fallback(endpoint.addresses, []any{}) %}
{%- active_endpoints = append(active_endpoints, map[string]any{"address": address, "port": port}) %}
{%- end %}
{%- end %}
{%- end %}
{# Fixed slots - active endpoints fill first, rest are disabled #}
{%- for i := 1; i <= initial_slots; i++ %}
{%- if i-1 < len(active_endpoints) %}
{%- var ep = active_endpoints[i-1] %}
server SRV_{{ i }} {{ ep["address"] }}:{{ ep["port"] }} check
{%- else %}
server SRV_{{ i }} 127.0.0.1:1 disabled
{%- end %}
{%- end %}
Benefit: Endpoint changes update server addresses via runtime API without dropping connections.
Maximize Runtime API Usage
Keep server lines minimal - only address:port plus enabled or disabled. Place all other options (check, proto h2, SSL settings) in the default-server directive:
backend my-backend
default-server check proto h2
server SRV_1 10.0.0.1:8080 enabled
server SRV_2 10.0.0.2:8080 enabled
server SRV_3 127.0.0.1:1 disabled
The Dataplane API can update Address, Port, and enabled/disabled state at runtime without reloading HAProxy. Both enabled and disabled are runtime-supported, enabling the reserved slots pattern. Options like check on individual server lines trigger reloads on any change.
Cross-Resource Lookups¶
Use fields from one resource to query another:
{% for _, ingress := range resources.ingresses.List() %}
{% for _, rule := range fallback(ingress.spec.rules, []any{}) %}
{% for _, path := range fallback(rule.http.paths, []any{}) %}
{% var service_name = path.backend.service.name %}
{% var port = fallback(path.backend.service.port.number, 80) %}
backend ing_{{ ingress.metadata.name }}_{{ service_name }}
{%- for _, endpoint_slice := range resources.endpoints.Fetch(service_name) %}
{%- for _, endpoint := range fallback(endpoint_slice.endpoints, []any{}) %}
{%- for _, address := range fallback(endpoint.addresses, []any{}) %}
server {{ endpoint.targetRef.name }} {{ address }}:{{ port }} check
{%- end %}
{%- end %}
{%- end %}
{% end %}
{% end %}
{% end %}
Required indexing:
watchedResources:
ingresses:
indexBy: ["metadata.namespace", "metadata.name"]
endpoints:
indexBy: ["metadata.labels.kubernetes\\.io/service-name"]
Safe Iteration¶
Use fallback function to handle missing fields:
{% for _, endpoint := range fallback(endpoint_slice.endpoints, []any{}) %}
{% for _, address := range fallback(endpoint.addresses, []any{}) %}
server srv {{ address }}:80
{% end %}
{% end %}
Filtering with Conditionals¶
Filter resources by attribute presence:
{% for _, rule := range fallback(ingress.spec.rules, []any{}) %}
{% if rule.http != nil %}
{# rule.http is guaranteed to exist #}
{% end %}
{% end %}
Mutable Variables¶
Accumulate values across loop iterations using Go-style variable assignment:
{% var active_endpoints = []map[string]any{} %}
{% for _, endpoint_slice := range resources.endpoints.Fetch(service_name) %}
{% for _, endpoint := range fallback(endpoint_slice.endpoints, []any{}) %}
{% active_endpoints = append(active_endpoints, map[string]any{"address": endpoint.addresses[0]}) %}
{% end %}
{% end %}
{% for i, ep := range active_endpoints %}
server srv{{ i + 1 }} {{ ep["address"] }}:80
{% end %}
Whitespace Control¶
{%- for _, item := range items %} {# Strip before #}
{% for _, item := range items -%} {# Strip after #}
{%- for _, item := range items -%} {# Strip both #}
Status Patches¶
Templates can register status patches for Kubernetes resources using the statusPatch() function. The controller applies these patches to the /status subresource via Server-Side Apply (SSA) after each reconciliation phase.
This allows templates to report processing results back to resources (e.g., setting Accepted and Programmed conditions on Gateways, or propagating LoadBalancer addresses to Ingress status) without the controller needing to understand any specific resource's status schema.
statusPatch()¶
Registers a status patch for a Kubernetes resource with outcome-keyed variants:
{% statusPatch(namespace, name, apiVersion, kind, map[string]any{
"deployed": map[string]any{
"status": map[string]any{
"conditions": []any{
condition("Accepted", "True", "Accepted", "Resource accepted", generation, transitionTime(resource, "Accepted", "True")),
},
},
},
"deployFailed": map[string]any{
"status": map[string]any{
"conditions": []any{
condition("Accepted", "True", "Accepted", "Resource accepted", generation, transitionTime(resource, "Accepted", "True")),
condition("Programmed", "False", "AddressNotAssigned", "No address available", generation, transitionTime(resource, "Programmed", "False")),
},
},
},
}) %}
Parameters:
| Parameter | Type | Description |
|---|---|---|
namespace |
string |
Resource namespace |
name |
string |
Resource name |
apiVersion |
string |
Resource API version (e.g., networking.k8s.io/v1) |
kind |
string |
Resource kind (e.g., Ingress, Gateway) |
variants |
map[string]any |
Status payloads keyed by pipeline phase |
Variants:
| Key | Applied When |
|---|---|
rendered |
After successful template rendering (before deployment) |
deployed |
After successful HAProxy deployment |
renderFailed |
When a later rendering phase fails |
deployFailed |
When HAProxy deployment fails |
Templates render all variants upfront. The controller selects the appropriate variant based on the pipeline outcome.
condition()¶
Creates a metav1.Condition-compatible map:
{{ condition("Accepted", "True", "Accepted", "Resource is accepted", observedGeneration, lastTransitionTime) }}
Parameters: type, status, reason, message, observedGeneration, lastTransitionTime
transitionTime()¶
Returns the correct lastTransitionTime for a condition: preserves the existing timestamp if the condition status hasn't changed, or returns the current time if it has changed or doesn't exist yet:
For resources with nested condition arrays (e.g., Gateway API Route parents[]), pass the parent index:
Using Status Patches in Custom Templates¶
Status patch snippets should use the status-patches-* extension point (priority 200). This renders after feature analysis but before complex config generation, ensuring patches are captured even if later rendering fails.
controller:
config:
templateSnippets:
status-patches-200-custom:
template: |
{%- for _, resource := range resources.myresources.List() %}
{%%
var ns = resource | dig("metadata", "namespace") | fallback("") | tostring()
var name = resource | dig("metadata", "name") | fallback("") | tostring()
var gen = resource | dig("metadata", "generation") | fallback(0)
%%}
{%- statusPatch(ns, name, "example.com/v1", "MyResource", map[string]any{
"deployed": map[string]any{
"status": map[string]any{
"conditions": []any{
condition("Ready", "True", "Deployed", "Successfully deployed", gen, transitionTime(resource, "Ready", "True")),
},
},
},
}) %}
{%- end %}
The built-in Ingress and Gateway API libraries already include status patch snippets. You only need custom status patches for resources not covered by the default libraries.
Complete Example¶
Full ingress → service → endpoints chain with reserved slots:
watchedResources:
ingresses:
apiVersion: networking.k8s.io/v1
resources: ingresses
indexBy: ["metadata.namespace", "metadata.name"]
endpoints:
apiVersion: discovery.k8s.io/v1
resources: endpointslices
indexBy: ["metadata.labels.kubernetes\\.io/service-name"]
maps:
host.map:
template: |
{%- for _, ingress := range resources.ingresses.List() %}
{%- for _, rule := range fallback(ingress.spec.rules, []any{}) %}
{{ rule.host }} ing_{{ ingress.metadata.name }}
{%- end %}
{%- end %}
templateSnippets:
backend-servers:
template: |
{%- var initial_slots = 10 %}
{%- var active_endpoints = []map[string]any{} %}
{%- for _, es := range resources.endpoints.Fetch(service_name) %}
{%- for _, ep := range fallback(es.endpoints, []any{}) %}
{%- for _, addr := range fallback(ep.addresses, []any{}) %}
{%- active_endpoints = append(active_endpoints, map[string]any{"addr": addr}) %}
{%- end %}
{%- end %}
{%- end %}
{%- for i := 1; i <= initial_slots; i++ %}
{%- if i-1 < len(active_endpoints) %}
server SRV_{{ i }} {{ active_endpoints[i-1]["addr"] }}:{{ port }} check
{%- else %}
server SRV_{{ i }} 127.0.0.1:1 disabled
{%- end %}
{%- end %}
haproxyConfig:
template: |
global
daemon
maxconn 4096
defaults
mode http
timeout connect 5s
timeout client 50s
timeout server 50s
frontend http
bind *:80
use_backend %[req.hdr(host),lower,map({{ pathResolver.GetPath("host.map", "map") }})]
{% for _, ingress := range resources.ingresses.List() %}
{% for _, rule := range fallback(ingress.spec.rules, []any{}) %}
{% for _, path := range fallback(rule.http.paths, []any{}) %}
{%- var service_name = path.backend.service.name %}
{%- var port = fallback(path.backend.service.port.number, 80) %}
backend ing_{{ ingress.metadata.name }}
balance roundrobin
{{ render "backend-servers" inherit_context }}
{% end %}
{% end %}
{% end %}