Templating Guide¶
Overview¶
The HAProxy Template Ingress Controller uses Scriggo, a Go template engine, to generate HAProxy configurations from Kubernetes resources. You define templates that access watched Kubernetes resources, and the controller renders these templates 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¶
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 must use absolute paths from /etc/haproxy/. 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 %}
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 absolute paths:
{# Map files #}
use_backend %[req.hdr(host),lower,map({{ pathResolver.GetPath("host.map", "map") }})]
{# Output: /etc/haproxy/maps/host.map #}
{# General files #}
errorfile 504 {{ pathResolver.GetPath("504.http", "file") }}
{# Output: /etc/haproxy/general/504.http #}
{# SSL certificates #}
bind *:443 ssl crt {{ pathResolver.GetPath("example.com.pem", "cert") }}
{# Output: /etc/haproxy/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 | {{ snippets \| glob_match("backend-*") }} |
regex_escape |
Escape regex special characters | {{ path \| regex_escape }} |
sort_by |
Sort by JSONPath expressions | {{ routes \| sort_by(["$.priority:desc"]) }} |
extract |
Extract values via JSONPath | {{ routes \| extract("$.spec.rules[*].host") }} |
group_by |
Group items by JSONPath | {{ ingresses \| group_by("$.metadata.namespace") }} |
transform |
Regex substitution on arrays | {{ paths \| transform("^/api/v\\d+", "") }} |
debug |
Output as JSON comment | {{ routes \| debug("routes") }} |
eval |
Show JSONPath evaluation | {{ route \| eval("$.priority") }} |
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¶
The resources Variable¶
Templates access watched resources through the resources variable. Each store provides List(), Fetch(), and GetSingle() methods.
{# 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 }}
Authentication Annotations¶
Enable HTTP basic authentication via Ingress annotations:
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: protected-app
annotations:
haproxy.org/auth-type: "basic-auth"
haproxy.org/auth-secret: "my-auth-secret"
haproxy.org/auth-realm: "Protected"
spec:
rules:
- host: app.example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: my-service
port:
number: 80
Create authentication secrets with crypt(3) SHA-512 password hashes:
HASH=$(openssl passwd -6 mypassword)
kubectl create secret generic my-auth-secret \
--from-literal=admin=$(echo -n "$HASH" | base64 -w0)
Configure secrets store with store: on-demand to fetch secrets on-demand rather than watching all cluster secrets.
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.
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 #}
{# Use show for indented output #}
{% show render("server-list") %}
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
{% show render("backend-servers") %}
{% end %}
{% end %}
{% end %}