Custom annotations
December 23, 2025 · View on GitHub
Custom annotations are annotations with full validation that user can create for frontend and backend side of HAProxy configuration. They can be limited for certain resources and they unlock all HAProxy options that might not be available with other options. In background they use CRD (Custom resources definition) to validate its content and access.
Why ?
HAProxy has extensive number of powerful options to tweak load balancing: docs.haproxy.org. The best way to expose them in most reliable way is to provide a secure method to deploy them while still allowing full exposure of settings that HAProxy provides.
What is the main difference if we compare them to CRDs ?
They are pretty similar, both have validation and can potentially add almost everything HAProxy can offer. Main difference with CRDs is, that CRDs do not have granularity like we can have with custom defined annotations.
What is the main difference compared to config snippets
In short reliability, validation and security. While snippets allow really high customization, experience over time has shown that a lot of times they also bring a lot of confusion, typos and potential to do misconfiguration.
What is the difference compared to regular annotations ?
Security
The most important difference is security part of it. With custom annotations there is clear separation of two groups of people who configure and consume them:
-
Administrator using pre defined CRD, administrator can define and limit usage of custom annotations. This can be achieved with limiting annotation on certain HAProxy section, service, ingress or namespace if needed. Also, if a specific service or group needs a little more freedom in what to configure, administrator can create a custom annotation that is specific to that team.
-
Developers/Teams Teams gets a list of all available annotations that admin created. If more is needed, request can be sent to admin to create additional annotation.
Validation
Custom annotations have validation. Common Expression language is used to write rules. Rules can be simple or strict. The more strict rule is there is less chance of accidental mistake.
Speed of delivery
While number of annotations have grown over time in this project, no two deployments are the same. Company A needs different customization than Company B. While purpose of this project is to cover all possible ideas and setups our users might need, it is not possible to cover all use cases with (in the end) limited number of resources and time. With custom annotation there is no need to wait until new annotation is accepted, developed and released, we can simply create a new one, deploy it and start using it immediately after we deploy it.
Monitoring
We all read logs, right, right ? While we do have a log message with annotations where we can see if some annotations is not accepted due to xyz, user annotations have additional advantage. Even in case if validation was not successful it will still appear in configuration, but as comment on certain frontend or backend. Additionall, as a comment, error message will appear that will explain what went wrong.
How can I distinguish custom annotations from 'regular' ones ?
As seen in annotations.md, HAProxy annotations can have ingress.kubernetes.io, haproxy.org and haproxy.com prefixes. Custom annotations can have any prefix we define. For example a 'famous' example.com corporation can use example.com prefix. How exactly structure is defined we will see next.
Is there any downside ?
Not really, but we always need to keep in mind that adding/modifying annotation usually triggers a reload. This is potential issue, but most of the time it is a non issue, especially since changing annotations is not expected to happen often. With HAProxy seamless reload no connections are dropped or broken.
Examples
Controller Arguments
Rules are defined in a Custom Resource Definition (CRD).
in order to use them, new controller argument needs to be specified
--custom-validation-rules=<namepace>/<object name>
in the examples will use
--custom-validation-rules=haproxy-controller/example-validationrules
CRD
We need to write a CRD where we will define all custom annotations.
apiVersion: ingress.v3.haproxy.org/v3 # yes, you are right, CRD updates are lovely
kind: ValidationRules
metadata:
name: example-validationrules
namespace: haproxy-controller
spec:
prefix: "example.com" # company prefix for custom annotations
validation_rules: # list of user defined annotations
# Rule for 'timeout-server' accepting duration values
timeout-server:
...
we can see that not only we can limit and check certain type of value, we can also additionally limit certain limits on values.
Simple Timeout
prefix: "example.com"
validation_rules:
timeout-server:
section: all
template: "timeout server {{.}}"
type: duration
rule: "value > duration('42s') && value <= duration('42m')"
when this is applied, a log message will appear
ValidationRules haproxy-controller/example-validationrules accepted and set [example.com]
after that we can start using it
kind: Service
apiVersion: v1
metadata:
name: http-echo
annotations:
backend.example.com/timeout-server: "60s"
backend.example.com/timeout-server: "60s" => backend.<prefix>/<annotation-name>
after we apply it, in configuration file we can see
backend default_svc_http-echo_http
...
###_config-snippet_### BEGIN
### example.com/timeout-server ###
timeout server 60s
###_config-snippet_### END
...
we can see that a config snippet was added, but compared to regular snippet, our annotation has full validation that we defined (and also it is limiting what we can put)
what happens if we try to add value that is not accepted ?
log message:
failed to validate custom annotation 'timeout-server' for backend 'default_svc_http-echo_http': validation failed for rule 'timeout-server' with value '1s'.
config file:
backend default_svc_http-echo_http
...
###_config-snippet_### BEGIN
### example.com/timeout-server ###
# ERROR: validation failed for rule 'timeout-server' with value '1s'.
# Failed part: 'value > duration('10s')'
###_config-snippet_### END
...
Simple Number
maxconn:
type: int
rule: "value >= 10 && value <= 1000000"
if template is not defined, value as is will be copied
usage
backend.example.com/maxconn: "1000"
⚠ duration, int, uint, bool and float values needs to be defined as strings in annotation value. This is limitation of k8s for annotation values. If using type json normal number and boolean values can be used.
invalid
backend.example.com/maxconn: 1000
Boolean value
option-forwardfor:
template: "option forwardfor"
type: bool
rule: "value == true"
usage
backend.example.com/option-forwardfor: "true"
More complex annotations with more than one value
http-request-set-header-X-Request-ID:
# http-request set-header X-Request-ID %[unique-id] if { hdr(Host) -i example.com }
section: backend
template: "http-request set-header X-Request-ID %[unique-id] if { hdr({{.hdr}}) -i {{.domain}} }"
type: json
rule: "'hdr' in value && 'domain' in value && ((value.hdr == 'host' && value.domain.matches('^([a-zA-Z0-9-]+\\\\.)+[a-zA-Z]{2,}$')) || (value.hdr == 'ip' && value.domain.matches('^((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\\\.){3}(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$')))"
type: json - values needs to be added in json format
{{.hdr}} and {{.domain}} - go templating, in json we need to specify those two values
rule: "'hdr' in value && 'domain' in value - both values are mandatory
rule - we can see flexibility and strictness of validation, depending of hdr value, domain has different validation
usage
backend.example.com/http-request-set-header-X-Request-ID: '{"hdr":"host", "domain":"example.com"}'
or
backend.example.com/http-request-set-header-X-Request-ID: |
{
"hdr":"host",
"domain":"example.com"
}
config:
http-request set-header X-Request-ID %[unique-id] if { hdr(host) -i example.com }
multiline annotation
annotation can also create more that just one line, it only depends on templating. Let imagine we want to define multiple timeouts
timeouts:
section: backend
template: |
timeout server {{.server}}
timeout server-fin {{.server_fin}}
timeout tarpit {{.tarpit}}
type: json
rule: |
'server' in value && value.server.matches('^[0-9]+[smh]?$') &&
'server_fin' in value && value.server_fin.matches('^[0-9]+[smh]?$') &&
'tarpit' in value && value.tarpit.matches('^[0-9]+[smh]?$')
usage
backend.example.com/timeouts: |
{
"server": "42s",
"server_fin": "10s",
"tarpit": "5s"
}
config
### example.com/timeouts ###
timeout server 51s
timeout server-fin 20s
timeout tarpit 5s
predefined values
there are several values we can use in template that are predefined
timeouts:
section: backend
template: |
# ==============================================
# custom annotation, owner: {{.owner}} - Reason: {{.reason}} for {{.BACKEND}}
# namespace {{.NAMESPACE}}, ingress {{.INGRESS}}, service {{.SERVICE}}
# POD_NAME {{.POD_NAME}}, POD_NAMESPACE {{.POD_NAMESPACE}}, POD_IP {{.POD_IP}}
# ==============================================
timeout server {{.server}}
timeout server-fin {{.server_fin}}
timeout tarpit {{.tarpit}}
# ==============================================
type: json
rule: |
'owner' in value &&
'reason' in value &&
'server' in value && value.server.matches('^[0-9]+[smh]?$') &&
'server_fin' in value && value.server_fin.matches('^[0-9]+[smh]?$') &&
'tarpit' in value && value.tarpit.matches('^[0-9]+[smh]?$')
usage (as you see while rule and template is complex, usage is simple)
backend.example.com/timeouts: |
{
"owner": "oktalz",
"reason": "custom annotations demo",
"server": "51s",
"server_fin": "20s",
"tarpit": "5s"
}
config
### example.com/timeouts ###
# ==============================================
# custom annotation, owner: oktalz - Reason: custom annotations demo for default_svc_http-echo_http
# namespace default, ingress http-echo, service http-echo
# POD_NAME haproxy-ingress-56ml56gs, POD_NAMESPACE haproxy-controller, POD_IP 10.8.0.2
# ==============================================
timeout server 51s
timeout server-fin 20s
timeout tarpit 5s
All Options
timeout-server: # name of annotation
section: all # can be all, fronted, backend (default)
namespaces: # we can limit namespace usage
- haproxy-controller
- default
resources: # limit usage to Service, Frontend or Backend names (list)
- <name>
ingresses: # limit usage to specific ingresses
- <name>
order_priority: 100 # order of custom annotations in config. higher is more priority
template: "timeout server {{.}}" # template we can use (golang templates)
type: duration # expected data type for conversion (duration;int;uint;bool;string;float;json;)
rule: "value > duration('42s') && value <= duration('42m')" # CEL expression
How do I create frontend annotations ?
in same way as backend ones, except there is no frontend object in k8s. Therefore we will use configmap
apiVersion: v1
kind: ConfigMap
metadata:
name: haproxy-kubernetes-ingress
namespace: haproxy-controller
annotations:
frontend.example.com/timeout-server: "5s"
frontend.example.com/timeout-client: "6s"
data:
...
it is similar as with other configuration values, except we define it as configmap annotations.
Structure
frontend.<prefix>/<custom-annotation-name>
With HAProxy Ingress controller, you have 3 different frontends: http, https and stats, each can be customized with custom annotations.
If you want to create a annotation for specific frontend use resources as shown in example:
apiVersion: ingress.v3.haproxy.org/v3
kind: ValidationRules
metadata:
name: example-validationrules
namespace: haproxy-controller
spec:
prefix: "haproxy.org"
validation_rules:
maxconn:
section: frontend
resources:
- http
- https
type: int
rule: "value >= 10 && value <= 1000000"
Where can custom annotations be defined ?
Frontend Annotations
Frontend Annotations can be defined in Ingress Controller configmap. not as a key-value, but as a annotation of configmap
apiVersion: v1
kind: ConfigMap
metadata:
name: haproxy-kubernetes-ingress
namespace: haproxy-controller
annotations:
frontend.<org>/<custom-annotation-name>: <value>
Backend Annotations
you can define them on:
configmap- this will be applied for each backend- ⚠
ingress- this will be applied on services used in ingress. use with precaution.- setting custom annotations on ingress level is disabled by default!
- use
--enable-custom-annotations-on-ingressto enable it. Setting different annotation values in different ingresses for same service will trigger inconsistencies, so this is not encouraged. useserviceannotations.
service- this will be applied just on service
what happens if you try to use same annotation on multiple places
Service annotation have highest priority, only if service one does not exist, ingress one will be applied, same goes for configmap, it will be used only if ingress and service annotation do not exist.