Base Library¶
The base library provides the core HAProxy configuration infrastructure. It defines extension points that other libraries use to inject their functionality, making it the foundation of the template library system.
Overview¶
The base library is enabled by default and provides the entire haproxyConfig template that the rest of the libraries plug into. It provides:
- Core HAProxy configuration structure (global, defaults, frontends, backends)
- The plugin pattern via extension points for other libraries to inject content
- Frontend routing logic with path matching and backend selection
- Utility macros for template development
- Error page templates
- Map file infrastructure for routing decisions
Every render flows through base — here it is underpinning the Ingress preset live:
Try it: emit a header per map entry¶
Every base extension point is just a Scriggo snippet that emits HAProxy directives from data. Here is that idea in miniature: iterate an inline map and emit one directive per entry.
Configuration¶
The base library has the standard enable/disable flag, but disabling it is rarely useful: every other library plugs into the extension points base provides, so setting it to false produces a broken render with no haproxyConfig and no extension points.
controller:
templateLibraries:
base:
enabled: true # Default; leave on unless you supply a complete replacement haproxyConfig
Extension Points¶
The base library defines extension points using the render_glob "prefix-*" operator. Any template snippet with a matching prefix is automatically rendered at the designated location in the HAProxy configuration.
Available Extension Points¶
| Extension Point | Prefix Pattern | Location in Config | Purpose |
|---|---|---|---|
| Global Settings | global-settings-* |
Inside global section |
Global directives (logging, process, paths, SSL tuning) |
| Defaults Settings | defaults-settings-* |
Inside defaults section |
Defaults directives (options, balance, timeouts, errorfiles) |
| Features | features-* |
Early in config generation | Feature initialization and registration |
| Global Top | global-top-* |
After defaults section |
Top-level HAProxy elements (userlists, peers, etc.) |
| Frontend Extra | frontend-extra-* |
After frontend bind, before routing | Early frontend directives (options, captures, ACLs) |
| Frontend Matchers | frontend-matchers-advanced-* |
Within frontend routing logic | Advanced request matching (method, headers, query params) |
| Frontend Filters | frontend-filters-* |
HTTP frontend, after routing | Request/response filters (header modification, redirects) |
| Custom Frontends | frontends-* |
After HTTP frontend | Additional frontend definitions |
| Custom Backends | backends-* |
Before default_backend | Backend definitions from resource libraries |
| Host Map | map-host-* |
host.map file | Host-to-group mapping entries |
| Path Exact Map | map-path-exact-* |
path-exact.map file | Exact path match entries |
| Path Prefix Exact Map | map-pfxexact-* |
path-prefix-exact.map file | Prefix-exact path match entries |
| Path Prefix Map | map-path-prefix-* |
path-prefix.map file | Prefix path match entries |
| Path Regex Map | map-path-regex-* |
path-regex.map file | Regex path match entries |
| Weighted Backend Map | map-weighted-backend-* |
weighted-multi-backend.map file | Weighted routing entries |
| Status Patches | status-patches-* |
After features, before backends | Resource status patch registration (side effects only) |
| Status Extra | status-extra-* |
Inside the status frontend | Extra status-frontend directives (Prometheus exporter, custom endpoints) |
backend-directives-* is not a base-library extension point. It is invoked by ingress.yaml's backends-500-ingress snippet (with inherit_context) so per-backend annotation libraries can extend each backend block; see haproxytech library for the producer side. Templates outside the ingress backend loop won't see it.
How Extension Points Work¶
- Base library uses
render_glob "prefix-*"to render all snippets matching the pattern - Other libraries (or user config) define snippets with matching prefixes
- At render time, all matching snippets are rendered in alphabetical order — numeric prefixes in snippet names (e.g.
backends-500-ingress) control execution order
Injecting Custom Configuration¶
You can inject custom HAProxy configuration by adding template snippets with the appropriate prefix in your values.yaml:
controller:
config:
templateSnippets:
# Override default timeouts (replaces the base library snippet)
defaults-settings-300-timeouts:
template: |
timeout connect 5000
timeout client 30000
timeout server 30000
timeout tunnel 600000
timeout http-request 10000
# Add custom global tuning directives (extends the global section)
global-settings-500-tuning:
template: |
tune.bufsize 262144
no-memory-trimming
# Add custom security rules to the HTTP frontend
frontend-filters-custom-security:
template: |
http-request deny if { path_beg /admin } !{ src 10.0.0.0/8 }
http-request deny if { path_beg /.env }
# Add a custom userlist
global-top-custom-userlist:
template: |
userlist api_users
user apiuser password $2y$05$...
# Add custom backend
backends-custom-maintenance:
template: |
backend maintenance_backend
http-request return status 503 content-type text/html string "<h1>Under Maintenance</h1>"
Snippet Priority¶
Snippets within a render_glob pattern execute in alphabetical order. Encode priority in the snippet name via a numeric prefix (lower numbers run first):
templateSnippets:
# Runs early — sorts before the 500-range
features-050-ssl-initialization:
template: |
{# Initialize SSL infrastructure #}
# Runs later — sorts after the 100-range certificate registration
features-150-ssl-crtlist:
template: |
{# Generate certificate list #}
See Template Libraries → Snippet Priority for the reserved range conventions used by the built-in libraries.
Features¶
Frontend Routing Logic¶
The base library implements the routing system using HAProxy maps and transaction variables. The rendered config keeps the per-frontend directives terse; this section is the reference the generated comments link to.
1. Host matching — txn.host_match is resolved through a fall-through cascade, each step tried only if the previous left it empty (-m len 0):
| Order | Lookup | Purpose |
|---|---|---|
| 1 | host_full (Host header verbatim, incl. :port) |
Port-pinned routes — e.g. Gateway listeners on non-default ports — match before the port-stripped lookups. Only fires when the request actually carried a port, so the common case has zero overhead. |
| 2 | host (port stripped) |
Normal hostname match. |
| 3 | host with leading label removed (regsub(^[^.]*,,)) |
Wildcard hosts (*.example.com stored as .example.com). |
| 4 | host-regex.map |
Regex hostnames. |
| 5 | host:listener_port / :listener_port |
Per-listener-port fallback when no hostname matched (Gateway listeners on dedicated ports). |
txn.host_match is seeded to '' first so every step can use -m len 0 ("not matched yet") consistently — -m found would be true after a lookup that yielded an empty string, blocking the rest of the cascade.
Listener-port translation. txn.listener_port is the user-facing port the request arrived on. For chart-static binds it equals dst_port. Resource libraries that map a pod-port to a different listener port (e.g. Gateway API per-Gateway HTTPS binds listening on an allocated pod port like 18002 while the map keys use the original 8443) plug a translation into the frontend-routing-listener-port-* extension point. With no such library, dst_port passes through unchanged.
2. Path matching — evaluated in order Exact > Regex > Prefix-exact > Prefix:
http-request set-var(txn.path_match) var(txn.host_match),concat(,txn.path,),map(maps/path-exact.map)
http-request set-var(txn.path_match) var(txn.host_match),concat(,txn.path,),map_reg(maps/path-regex.map) if !{ var(txn.path_match) -m found }
http-request set-var(txn.path_match) var(txn.host_match),concat(,txn.path,),map(maps/path-prefix-exact.map) if !{ var(txn.path_match) -m found }
http-request set-var(txn.path_match) var(txn.host_match),concat(,txn.path,),map_beg(maps/path-prefix.map) if !{ var(txn.path_match) -m found }
Overriding Path Match Order
Setting controller.config.routing.regexMatchOrder=last swaps in the alternate frontend-routing-logic-regex-last variant of this snippet at Helm load time, producing performance-first ordering (Exact > Prefix-exact > Prefix > Regex). Faster matchers run first and regex matching is only evaluated as a fallback. The variant snippet is unset before the merged config is rendered, so it never appears in the operator-visible output.
3. Qualifier system — the first :-separated field of path_match selects the routing mode: BACKEND:<name> routes directly, MULTIBACKEND:<weight>:<key> selects a weighted backend via random draw. Advanced matchers (method / header / query, contributed by the gateway library through frontend-matchers-advanced-*) may rewrite path_match, so the qualifier is re-parsed after they run.
Owner-resource identity. txn.resource_id (<namespace>/<name>) is derived from the qualifier value the routing chain already produced — no extra map lookup — and keys the per-resource feature maps (auth, Coraza WAF, body-size, header rewrites). Backend names use _ as the separator (<ns>_<name>_svc_<svc>_<port> for Ingress, <ns>_<name>_<ruleIdx> for Gateway weighted routing); Kubernetes names disallow _, so splitting on it is collision-free.
Connection Reliability and Timeouts¶
The defaults section is tuned for a Kubernetes ingress workload, where backends are pod IPs from EndpointSlices reached directly over the cluster's CNI fabric.
timeout connect defaults to 100ms (most controllers ship HAProxy's 5s). Same-node connects are sub-millisecond, cross-node VXLAN overlay (flannel/calico/cilium) is typically under 30ms, and even AWS cross-AZ stays under ~10ms p99 — so 100ms is already several times the normal case. The case it deliberately fails fast on is a TCP SYN to a pod IP whose pod just terminated: during the brief window between an EndpointSlice update and HAPTIC's runtime update landing, a server slot can still point at a dying pod. A request already dispatched into HAProxy is committed to that slot and must wait out timeout connect before option redispatch retries it elsewhere. At 5s that surfaces as a client-visible 504; at 100ms the full failover (original attempt + retry) completes within ~200ms.
Override it for genuinely slow networks (multi-region, satellite, constrained CPU):
controller:
config:
templatingSettings:
extraContext:
timeout_connect: "5000" # ms; also timeout_client / timeout_server / timeout_http_request / timeout_http_keep_alive
option redispatch lets a failed TCP connect be retried against a different server in the backend rather than the same dead one. Combined with HAProxy's default retries 3, this is what closes the pod-termination race during rolling updates — the retry lands on a healthy slot instead of hanging on the dead IP until the client times out. It matches nginx-ingress's default proxy-next-upstream: "error timeout" and envoy's default retry policy. See HAProxy's retries documentation.
h2c Cleartext Detection¶
The plaintext HTTP entry point is an outer mode tcp frontend that inspects the first wire bytes and routes to one of two unix-socket-bound inner mode http frontends, preserving the original client IP via PROXY-protocol v2 across the hop. Both inner frontends share the same routing logic, so any HTTP-level snippet lands in both protocol paths.
HAProxy cannot auto-detect HTTP/2 cleartext (h2c) on a plaintext bind, and it cannot parse an Upgrade: h2c handshake in mode tcp. The only available signal is the HTTP/2 prior-knowledge connection preface — the 24 bytes PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n — which the outer frontend matches byte-exactly with an acl ... req.payload(0,24) -m bin <hex>. gRPC-Go's insecure dial uses prior-knowledge by default, and the Gateway API conformance suite dials every GRPCRoute test with insecure.NewCredentials(), so this path is exercised by all gRPC conformance tests. The connection is classified as soon as 24 bytes arrive (with a WAIT_END fallback for shorter HTTP/1.1 sends); the ~10µs unix-socket round-trip is invisible against backend latency.
gRPC Request Handling¶
The default_backend returns a gRPC-aware fallback for unmatched requests. For application/grpc requests it returns a trailers-only response (grpc-status: 12, Unimplemented) instead of a plain 404 — without a valid content-type, grpc-go reports "malformed header: missing HTTP content-type" and tears down the whole HTTP/2 connection, cancelling every multiplexed stream on it. Because HAProxy's http-request return strips content-type from its header arguments, the base library re-adds it with http-after-response set-header, which runs on responses produced by the return action. Non-gRPC requests get a plain 404.
Built-in Operators and Functions¶
render_glob (operator)¶
Renders all snippets matching a glob pattern. This is a built-in Scriggo operator, not a macro — no import is needed:
See the Scriggo template guide for details.
sanitize_regex (function)¶
Built-in function that escapes regex metacharacters so a user-supplied literal (a host or path containing ., +, …) can be safely embedded in a map_reg() lookup. Edit the value and watch each metacharacter pick up a backslash:
Utility macros¶
The base library provides reusable macros across several util-* snippets, imported by other libraries:
| Macro | Purpose |
|---|---|
CalculateShardCount(resourceCount, itemsPerShard) |
Computes clamp(count / itemsPerShard, 1, 2*GOMAXPROCS) |
HostMatchCondition(hosts) |
Builds a host-match ACL condition (in util-ingress-helpers) |
BuildServerOptions(serverOpts) |
Renders server-line option flags (in util-backend-servers-helpers) |
BackendServers(serviceName, maxSlots, port, opts, portName, backendName, namespace) |
Generates the full server pool with reserved slots (in util-backend-servers) |
Usage:
{%- import "util-backend-servers" for BackendServers %}
{{ BackendServers(serviceName, 10, port, serverOpts, nil, backendKey, namespace) }}
Backend Server Pool¶
The util-backend-servers snippet generates server lines with:
- Pre-allocated server slots for dynamic scaling
- Health check configuration
- Support for per-server options (maxconn, SSL, etc.)
{%- var service_name = "my-service" %}
{%- var port = 8080 %}
{{ render "util-backend-servers" inherit_context }}
Error Pages¶
Pre-configured error response templates for common HTTP errors:
| File | HTTP Status |
|---|---|
| 400.http | Bad Request |
| 403.http | Forbidden |
| 408.http | Request Timeout |
| 500.http | Internal Server Error |
| 502.http | Bad Gateway |
| 503.http | Service Unavailable |
| 504.http | Gateway Timeout |
Debug Headers¶
When debug mode is enabled, the frontend adds response headers for routing introspection:
Debug headers include:
X-HAProxy-Backend: Selected backend nameX-HAProxy-Host-Match: Matched host groupX-HAProxy-Path-Match: Full path match resultX-HAProxy-Path-Match-Qualifier: BACKEND or MULTIBACKEND
Shared Memory Stats (HAProxy 3.3+)¶
When haproxy.shmStats.enabled is true and HAProxy version is 3.3 or later, the base library adds shm-stats-file and shm-stats-file-max-objects to the global section. This persists stats counters (frontend/backend/server metrics) across HAProxy reloads via shared memory, eliminating counter resets during configuration changes.
The shm-stats-file-max-objects value is a configurable fixed value (default 50000) set via haproxy.shmStats.maxObjects. Since the shm-stats file is fixed-size and cannot be resized on reload, a large fixed value prevents reload failures when new ingresses are added. HAProxy allocates object slots lazily, so memory overhead is proportional to actual objects, not the configured maximum.
When shmStats is enabled, the chart automatically adds a /dev/shm emptyDir volume with medium: Memory to the HAProxy pod. The volume's sizeLimit is auto-calculated from maxObjects (~4KB per object with 10% margin), or can be overridden via haproxy.shmStats.shmSizeLimit. This volume counts against the pod's memory limit.
Address Discovery¶
The base library watches controller LoadBalancer Services and discovers external addresses for status reporting. Addresses are aggregated from all matching services and deduplicated, then stored in gf["addresses"]. This supports multi-service setups where HAProxy is exposed via both internal and public LoadBalancers.
Controller Services are discovered via label selector (app.kubernetes.io/name=<name>,app.kubernetes.io/component=loadbalancer). If no Service has LoadBalancer addresses assigned yet, gf["addresses"] remains nil and status patches that depend on addresses are skipped.
Address discovery can be disabled via controller.statusPatches.enabled: false. When disabled, gf["addresses"] is never set, which prevents all status-patches-* snippets from writing to Ingress or Gateway status. This is useful during migration from another ingress controller to avoid premature DNS cutover when tools like external-dns watch status fields.
Status Patch Extension Point¶
The status-patches-* extension point renders at priority 200 — after feature analysis (features-* at 050-150) but before backends and frontends (500+). This ensures status patches are captured even when later config generation fails, allowing the renderFailed variant to be applied.
Status patch snippets produce no HAProxy configuration output. They call statusPatch() as a side effect to register patches for later application by the controller.
Declarative Kubernetes Resources (k8sResources.haproxy-service)¶
The base library declares the user-facing HAProxy LoadBalancer Service under the controller CRD's top-level spec.k8sResources map (sibling of templateSnippets, maps, files, sslCertificates). The controller's renderer parses the rendered YAML and applies the resulting Service via Server-Side Apply with field manager haptic; an OwnerReference to the HAProxyTemplateConfig CR is injected automatically (controller=true, blockOwnerDeletion=true) so cascade-delete (e.g. helm uninstall) garbage-collects the Service.
This replaces the chart-static templates/haproxy-service.yaml main-Service block, which was emitted by Helm at install time with a fixed port set. Two operational consequences operators should be aware of:
- First-render delay: when the chart is installed for the first time, the Service does not exist until the controller renders once (typically a few seconds). External tools (cert-manager, external-dns) that look up the Service immediately after
helm installshould expect a brief absence. The internal<release>-haproxy-dataplaneService remains chart-static and is created at install time as before. helm uninstallcleans up via cascade-GC: theOwnerReferenceties the Service's lifecycle to the CR. Whenhelm uninstallremoves the CR, the Service goes with it; no extra cleanup hook is required.
The Service port set is a single list assembled by the chart at install / upgrade time. There is no static / dynamic split inside the Scriggo template — templates/haproxytemplateconfig.yaml builds the list and hands it to the renderer via extraContext.haproxyService.ports. Two stages contribute:
-
Chart-time defaults + extras (Helm). The chart reads
haproxy.service.{http,https,stats}.{port,nodePort}and assembles three default entries (http,https,stats), then appends every entry inhaproxy.service.extraPorts. The result is a flat list ofcorev1.ServicePort-shaped objects.haproxy: service: # Defaults — change the port number / nodePort here, or set # port: 0 to drop the entry from the rendered Service: http: port: 80 nodePort: 30080 https: port: 443 nodePort: 30443 stats: port: 8404 nodePort: 30404 # Add your own ports — same shape as corev1.ServicePort: extraPorts: - name: postgres port: 5432 targetPort: 5432 protocol: TCP nodePort: 30432 # only honored when service.type is NodePort/LoadBalancernameandportare required;targetPortdefaults toport;protocoldefaults toTCP;appProtocolis plumbed through verbatim when set. Names must be RFC 1123 labels and unique across all Service ports (including the Gateway-derivedgw-*entries below). To drop one of the defaults entirely, set the matchinghaproxy.service.<name>.portto0— the chart filters zero-port entries out at render time. -
Render-time Gateway-derived ports (Scriggo). For each non-default Gateway / admitted ListenerSet listener port, the
k8sResources.haproxy-servicetemplate appends agw-<port>-<proto-letter>entry (e.g.gw-9090-hfor an HTTPS listener on port 9090). Listener ports that already exist in the chart-time list (because their port number matcheshttp/https/statsor one of the operator'sextraPortsentries) are skipped — first writer wins. Skipped entirely for Gateways that setspec.addresses(those get a dedicated per-Gateway Service viak8sResources.gateway-static-addresses).
Chart values consumed by the merged spec: haproxy.service.type, haproxy.service.annotations, haproxy.service.loadBalancerIP, haproxy.service.loadBalancerClass, haproxy.service.loadBalancerSourceRanges, haproxy.service.externalTrafficPolicy, haproxy.service.internalTrafficPolicy, haproxy.service.healthCheckNodePort, haproxy.service.publishNotReadyAddresses. All are plumbed into extraContext.haproxyService by templates/haproxytemplateconfig.yaml.
Map Files¶
The base library generates these map files for routing:
| Map File | Purpose | Matcher |
|---|---|---|
| host.map | Host header to group mapping | Exact match |
| path-exact.map | Exact path matching | map() |
| path-prefix-exact.map | Prefix paths that should match exactly | map() |
| path-prefix.map | Prefix path matching | map_beg() |
| path-regex.map | Regex path matching | map_reg() |
| weighted-multi-backend.map | Weighted backend selection | map() |
HAProxy Configuration Structure¶
The base library generates this configuration structure. The global and defaults sections are composed from individually overridable snippets:
global
# global-settings-100-logging
log stdout len 4096 local0 info
# global-settings-200-process
daemon
nbthread 1 # auto-calculated: ceil(HAProxy CPU request), 250m by default
# global-settings-250-shm-stats (when haproxy.shmStats.enabled=true and HAProxy >= 3.3)
shm-stats-file /dev/shm/haproxy-stats
shm-stats-file-max-objects 50000 # configurable via haproxy.shmStats.maxObjects
# global-settings-300-paths — emits the BaseDir from pathResolver (chart default /etc/haproxy)
default-path origin /etc/haproxy
crt-base ssl/ # relative to default-path origin
# global-settings-400-ssl
tune.ssl.default-dh-param 2048
defaults
# defaults-settings-100-options
mode http
log global
option httplog
option dontlognull
option log-health-checks
option forwardfor
# defaults-settings-200-balance
balance roundrobin
# defaults-settings-300-timeouts
timeout connect 100
timeout client 50000
timeout server 50000
# defaults-settings-400-errorfiles — relative paths resolved via default-path origin
errorfile 400 general/400.http
# ... other error files
# global-top-* snippets here (userlists, etc.)
frontend status
bind *:8404
# Health check endpoints
frontend http_frontend
bind *:80
# frontend-extra-* snippets (options, captures, ACLs)
# Routing logic
# frontend-matchers-advanced-* snippets
# frontend-filters-* snippets
use_backend %[var(txn.backend_name)] if { var(txn.backend_name) -m found }
default_backend default_backend
# frontends-* snippets (HTTPS, TCP, etc.)
# backends-* snippets (resource-specific backends)
backend default_backend
http-request return status 404
Each global-settings-* and defaults-settings-* snippet can be individually overridden or extended via controller.config.templateSnippets in your values.yaml. For example, to customize timeouts, override defaults-settings-300-timeouts with your own values. To add new global directives, create a global-settings-500-tuning snippet (or any name matching the pattern).
See Also¶
- Template Libraries Overview - How template libraries work
- SSL Library - TLS certificate management and HTTPS frontend
- Configuration Reference - Switching path-matching order