Migrating to HAPTIC¶
How to move an existing cluster from ingress-nginx, haproxy-ingress, or haproxytech/kubernetes-ingress to HAPTIC with zero downtime — run both controllers side by side, cut over one Ingress at a time, and flip DNS only when you're ready.
HAPTIC is built to coexist with your current controller: it ships a distinct
IngressClass (haptic, not cluster-default) and its own HAProxy Service, so
it adopts only the Ingresses you explicitly point at it. Nothing you have today
moves until you move it, and every step is a kubectl patch away from rollback.
Work through the cutover below in order. If something doesn't route as expected, the Troubleshooting section at the end covers the handful of defaults that most often trip up a migration.
Before you start¶
- Your incumbent controller (ingress-nginx / haproxy-ingress / haproxytech kubernetes-ingress) is still running and serving traffic. Leave it running until cutover is complete.
- You have Helm and cluster access. Step 1 below installs HAPTIC with the migration-specific flags. If HAPTIC is already installed, that's fine — it adopts nothing until you point Ingresses at its class; just apply the same flags with
helm upgradeinstead. - You can edit Ingress manifests (to change
ingressClassName) or you accept renaming HAPTIC's class to match — see below.
Step 0: Check what changes¶
See how your current setup fares under HAPTIC before you change anything. The migration report classifies every source-controller annotation you use as supported, different, or dropped, and renders your Ingress through HAPTIC's real template pipeline — so you find the surprises now, not mid-cutover.
It runs in your browser, on your own manifests. Paste the Ingresses you want to
audit into the Resources panel — kubectl get ingress -A -o yaml output
works as-is — and read the migration tab. Nothing leaves your machine and
nothing touches your cluster.
The same report runs live below on a preset ingress-nginx setup:
Read the verdicts the same way whatever you paste in:
- supported — works the same after migrating.
- different — carries over, but behaves differently; read the note.
- dropped — has no HAProxy equivalent and doesn't carry over.
- A render failure means HAPTIC can't build a configuration for that Ingress as-is. Fix those before cutover.
For the full editor — more resources, every template, the rendered HAProxy config — open the playground.
The cutover, step by step¶
-
Install HAPTIC alongside your existing controller. Give HAProxy a real external address — the default
haproxy.service.typeisNodePort; set it toLoadBalancerso status/DNS get a routable address:helm install haptic oci://registry.gitlab.com/haproxy-haptic/haptic/charts/haptic \ --namespace haptic --create-namespace \ --set haproxy.service.type=LoadBalancer \ --set controller.config.templatingSettings.extraContext.statusPatches.enabled=false # no DNS writes yetIf HAPTIC is already installed, apply the same flags with
helm upgrade: -
Enable the right annotation library for your source controller — see From ingress-nginx, From haproxy-ingress, or From haproxytech/kubernetes-ingress.
-
Move one test Ingress to HAPTIC by changing only its class:
The incumbent controller drops it; HAPTIC picks it up. Verify routing directly against HAPTIC's HAProxy Service address (curl with a
Host:header) before touching DNS. -
Bulk cut over once you're confident: change
ingressClassNameon the remaining Ingresses (in batches you can roll back). -
Enable status writes, then flip DNS. Turn status patches back on (step 1 installed with them off) so
external-dnsand dashboards see HAPTIC's address:helm upgrade haptic oci://registry.gitlab.com/haproxy-haptic/haptic/charts/haptic \ --namespace haptic --reuse-values \ --set controller.config.templatingSettings.extraContext.statusPatches.enabled=trueThen repoint DNS to HAPTIC's load balancer if you manage it manually. Watch traffic.
-
Decommission the old controller once all Ingresses are served by HAPTIC and traffic is stable.
Rolling back
Until DNS is flipped, rollback is just kubectl patch the
ingressClassName back. Keep the old controller installed until step 6.
Key settings that affect migration¶
| Setting | Default | Why it matters |
|---|---|---|
ingressClass.name |
haptic |
Only Ingresses with this exact ingressClassName are served. |
ingressClass.default |
false |
Class-less Ingresses aren't adopted; leave false during migration. |
controller.templateLibraries.hapticAnnotations.enabled |
true |
Native haproxy-haptic.org/* annotations — on by default and the recommended target vocabulary once you've migrated. |
controller.templateLibraries.nginxIngress.enabled |
false |
Opt-in: turn on to keep nginx.ingress.kubernetes.io/* annotations working. |
controller.templateLibraries.haproxyIngress.enabled |
false |
Opt-in: turn on to keep haproxy-ingress.github.io/* annotations working. |
controller.templateLibraries.haproxytech.enabled |
false |
Opt-in: turn on to keep haproxy.org/* annotations working. |
controller.config.templatingSettings.extraContext.statusPatches.enabled |
true |
Writes Ingress/Gateway status; disable during migration. |
haproxy.service.type |
NodePort |
Set to LoadBalancer for a routable external address. |
From ingress-nginx¶
Match the IngressClass¶
HAPTIC scopes Ingresses with a field selector on
spec.ingressClassName=<ingressClass.name> (a client-side filter — the
controller fetches the watch and drops non-matching Ingresses before they reach
its store). Your Ingresses carry ingressClassName: nginx, so pick one:
Change ingressClassName: nginx → ingressClassName: haptic per Ingress.
This is what enables the one-at-a-time, reversible cutover above.
Note
Marking HAPTIC's IngressClass cluster-default (ingressClass.default: true)
doesn't help: the match is on the literal spec.ingressClassName value,
so default-class resolution and class-less Ingresses are never picked up.
Enable the annotation library¶
The flag is camelCase
It's nginxIngress, not nginx-ingress. --set …nginx-ingress.enabled=true
silently has no effect.
Enabling it also auto-enables the Coraza Web Application Firewall (WAF) and external-auth plugins in the
Stream Processing Offload Agent (SPOA) hub sidecar. The gateway library (on by
default) already auto-enables the mirror plugin, so a default install may already be running the sidecar.
Basic host/path routing works without this library; only the nginx.ingress.kubernetes.io/* annotations need it.
Control the DNS cutover¶
Keep controller.config.templatingSettings.extraContext.statusPatches.enabled=false until you've verified routing.
With it on, the moment HAPTIC's HAProxy Service has an address it stamps
.status.loadBalancer onto every adopted Ingress, and external-dns
switches DNS to it — a premature, unverified cutover.
Metrics¶
HAPTIC derives the same per-request metric set from its access log, so your
existing dashboards, recording rules and alerts keep working. They're named
haptic_ingress_controller_* by default; set the prefix to make them a literal
drop-in:
That reproduces nginx_ingress_controller_requests and the six histograms, with
the label set ingress-nginx uses — status, method, path, namespace,
ingress, service, host, controller_class, controller_namespace,
controller_pod. See Request metrics
for what each family measures.
You gain one label: term, HAProxy's termination state, which distinguishes a
backend that refused the connection (SC--) from one that timed out without
sending headers (sH--) from a client that gave up (cD--). Turn it off with
vector.requestMetrics.terminationStateLabel: false if the extra cardinality
isn't worth it.
Four differences to expect:
- Two scrape ports, not one. Byte sizes and latencies can't share one set of
histogram buckets, so the size families are exported on
9599and everything else on9598. The bundledPodMonitordeclares both — sethaproxy.monitoring.podMonitor.enabled: true. - No
canarylabel. HAPTIC has no per-request canary marker. PromQL{canary=""}, which the stock dashboards use, matches a series without the label, so those queries are unaffected. -
Different bucket boundaries. Durations are a strict superset of
--time-buckets, so a query hardcodingle="0.5"still resolves. Sizes deliberately differ:ingress-nginxmeasures them against 10–100 bytes, so every real payload lands in+Infand those_bucketseries carry no information._sumand_countare unaffected. To restore both lists exactly: -
request_sizereads lower. It counts request body bytes; nginx's$request_lengthalso counts the request line and headers. HAProxy exposes no headers-inclusive counter.
These are counted from the access log, not in the data path
They report fewer requests than were served whenever HAProxy drops log records under back-pressure. Keep
the HAProxyAccessLogRecordsDropped alert on. If a request count has to stay
exact through a drop, set
extraContext.prometheusExporter.excludeMetrics.httpRequestCounters.enabled: false
to keep HAProxy's own counters alongside these.
Annotation support¶
The library covers the common nginx.ingress.kubernetes.io/* annotations —
backend timeouts, load-balance, proxy-body-size, rewrite-target,
ssl-redirect/force-ssl-redirect, HTTP Strict Transport Security (HSTS), Cross-Origin Resource Sharing (CORS), whitelist/denylist-source-range,
custom headers, basic + external auth, ssl-passthrough, canary, client mTLS,
request mirroring (mirror-target), and ModSecurity. Full per-annotation reference:
nginx-ingress library docs.
The table below lists every annotation that doesn't carry over unchanged — generated from the library's declared migration coverage, so it can't drift from the template code. Anything not listed is fully supported.
The library classifies 102 nginx.ingress.kubernetes.io/* annotations: 55 supported, 31 with behaviour differences, 16 not carried over, 0 failing.
| Annotation | Status | What to check |
|---|---|---|
nginx.ingress.kubernetes.io/auth-method |
Behaviour differs | Overrides the auth subrequest method; POST/PUT/PATCH are sent with an empty body. |
nginx.ingress.kubernetes.io/auth-secret |
Behaviour differs | One divergence to note. Hashes are verified by HAProxy's crypt(3), which supports $2y$/$6$/$5$/$1$ but not Apache apr1 ($apr1$) or {SHA} — an htpasswd Secret using those verifies under ingress-nginx but rejects every login here; regenerate with a crypt(3) algorithm. |
nginx.ingress.kubernetes.io/auth-signin |
Behaviour differs | nginx variables ($escaped_request_uri, …) aren't expanded — the URL is used verbatim. |
nginx.ingress.kubernetes.io/auth-snippet |
Not carried over | Freeform nginx configuration can't be translated to HAProxy; the haproxy-ingress library's auth-headers-request annotation covers the common use case. |
nginx.ingress.kubernetes.io/auth-tls-error-page |
Behaviour differs | 302 redirect on client-certificate verification failure, applied reload-free via a map — but it only fires when auth-tls-verify-client is optional/optional_no_ca. Under the default "on" (HAProxy verify required) an invalid/missing client cert aborts the TLS handshake, so the request never reaches the redirect and the client sees a TLS error instead of the page. |
nginx.ingress.kubernetes.io/auth-tls-pass-certificate-to-upstream |
Behaviour differs | Forwards ssl-client-cert (base64 DER — ingress-nginx sends URL-encoded PEM) and ssl-client-subject-dn; ssl-client-verify and ssl-client-issuer-dn aren't set. |
nginx.ingress.kubernetes.io/auth-tls-secret |
Behaviour differs | Client-CA verification is keyed by SNI — every rule needs an explicit host or the render fails; a missing Secret (or missing ca.crt) skips mTLS for the Ingress with a rendered warning. |
nginx.ingress.kubernetes.io/auth-tls-verify-client |
Behaviour differs | "on"→required; "optional" and "optional_no_ca"→optional (HAProxy has no verify-but-accept-invalid mode); other values fail the render. |
nginx.ingress.kubernetes.io/auth-tls-verify-depth |
Not carried over | HAProxy has no per-server/per-crt-list chain-depth option; a warning comment is rendered and the CA bundle scope bounds the accepted chain instead. |
nginx.ingress.kubernetes.io/auth-type |
Behaviour differs | Only "basic" is supported; "digest" fails the render. |
nginx.ingress.kubernetes.io/backend-protocol |
Behaviour differs | HTTP, HTTPS, GRPC and GRPCS map to HAProxy server options; AJP and FCGI have no HAProxy equivalent and fail the render with an error. |
nginx.ingress.kubernetes.io/canary-weight-total |
Not carried over | The weight base is fixed at 100. |
nginx.ingress.kubernetes.io/configuration-snippet |
Behaviour differs | Injected verbatim into the backend section — the value must contain HAProxy directives, not nginx configuration; existing nginx snippets need rewriting. |
nginx.ingress.kubernetes.io/cors-allow-credentials |
Behaviour differs | The header is only sent when explicitly "true" — ingress-nginx defaults it to true. |
nginx.ingress.kubernetes.io/denylist-source-range |
Behaviour differs | Host-scoped — the denylist only gates rules with an explicit host, so an Ingress without rule hosts gets no filtering; invalid CIDRs fail the render. |
nginx.ingress.kubernetes.io/enable-modsecurity |
Behaviour differs | "false" opts the route out of the WAF; "true" is accepted as a no-op (dispatch is default-on when the coraza plugin is enabled); other values fail the render. |
nginx.ingress.kubernetes.io/enable-opentelemetry |
Not carried over | Requires the nginx OpenTelemetry module; no HAProxy-side tracing is wired. |
nginx.ingress.kubernetes.io/enable-opentracing |
Not carried over | Requires the nginx OpenTracing module; no HAProxy-side tracing is wired. |
nginx.ingress.kubernetes.io/hsts |
Behaviour differs | By default the header is emitted only when the Ingress sets hsts to "true", whereas ingress-nginx enables HSTS globally by default. Set extraContext.hstsEnabled=true to send HSTS on all TLS hosts (matching ingress-nginx); a per-Ingress hsts annotation still overrides the value for its hosts. |
nginx.ingress.kubernetes.io/hsts-include-subdomains |
Behaviour differs | includeSubDomains is added only when explicitly "true" — ingress-nginx defaults it to true. |
nginx.ingress.kubernetes.io/limit-connections |
Behaviour differs | Rejects with 429, and ignored when limit-rps or limit-rpm is set (one stick-table per backend). |
nginx.ingress.kubernetes.io/limit-rate |
Behaviour differs | Download throttle via an outbound bandwidth-limit filter, but applied per stream — an HTTP/2 client gets the limit once per stream, not once per connection. |
nginx.ingress.kubernetes.io/limit-rate-after |
Behaviour differs | Mapped to the filter's min-size, the smallest chunk forwarded at a time — not the start-throttling-after-N-bytes offset nginx applies, which HAProxy can't express. A large value adds latency rather than delaying the throttle. |
nginx.ingress.kubernetes.io/limit-rpm |
Behaviour differs | Same hard-cap/429 semantics as limit-rps, and ignored when limit-rps is also set (HAProxy stores one request-rate counter per backend). |
nginx.ingress.kubernetes.io/limit-rps |
Behaviour differs | Hard per-source-IP cap rejecting with 429 — ingress-nginx allows a 5x burst and rejects with 503. |
nginx.ingress.kubernetes.io/mirror-host |
Not carried over | The mirror plugin forces the mirrored Host header to the target authority. |
nginx.ingress.kubernetes.io/mirror-request-body |
Not carried over | The buffered request body is always forwarded to the mirror target. |
nginx.ingress.kubernetes.io/mirror-target |
Behaviour differs | Mirrors via the SPOA hub mirror plugin — requires spoaHub.plugins.mirror and a rule host (the render fails otherwise); only the URL's authority is used, the live request path/query is re-attached. |
nginx.ingress.kubernetes.io/opentelemetry-operation-name |
Not carried over | Requires the nginx OpenTelemetry module; no HAProxy-side tracing is wired. |
nginx.ingress.kubernetes.io/opentelemetry-trust-incoming-span |
Not carried over | Requires the nginx OpenTelemetry module; no HAProxy-side tracing is wired. |
nginx.ingress.kubernetes.io/opentracing-trust-incoming-span |
Not carried over | Requires the nginx OpenTracing module; no HAProxy-side tracing is wired. |
nginx.ingress.kubernetes.io/proxy-cookie-domain |
Behaviour differs | Only the " |
nginx.ingress.kubernetes.io/proxy-cookie-path |
Behaviour differs | Only the " |
nginx.ingress.kubernetes.io/proxy-max-temp-file-size |
Not carried over | HAProxy buffers in memory; there is no temp-file spooling. |
nginx.ingress.kubernetes.io/proxy-next-upstream |
Behaviour differs | Maps to HAProxy retry-on (error→conn-failure, timeout→response-timeout, invalid_header→junk-response, http_NNN→NNN); non_idempotent is ignored per route — non-idempotent methods are excluded from L7 retries globally (matching nginx's own default), liftable via extraContext.retryNonIdempotent; "off" emits retries 0. |
nginx.ingress.kubernetes.io/proxy-read-timeout |
Behaviour differs | Collapses with proxy-send-timeout into HAProxy's single timeout server — the larger value wins, asymmetric read/send timeouts are lost. |
nginx.ingress.kubernetes.io/proxy-redirect-from |
Behaviour differs | "default" isn't supported (warning comment, no rewrite); requires proxy-redirect-to; values must be space-free. |
nginx.ingress.kubernetes.io/proxy-send-timeout |
Behaviour differs | Collapses with proxy-read-timeout into HAProxy's single timeout server — the larger value wins, asymmetric read/send timeouts are lost. |
nginx.ingress.kubernetes.io/proxy-ssl-server-name |
Not carried over | Not read; SNI toward the upstream is controlled via proxy-ssl-name instead. |
nginx.ingress.kubernetes.io/proxy-ssl-verify-depth |
Not carried over | HAProxy has no per-server chain-depth option; a warning comment is rendered. |
nginx.ingress.kubernetes.io/satisfy |
Behaviour differs | "any" OR-combines whitelist-source-range with basic auth only; unlike ingress-nginx it doesn't extend to external auth (auth-url). |
nginx.ingress.kubernetes.io/server-snippet |
Not carried over | nginx server-level directives have no HAProxy equivalent. |
nginx.ingress.kubernetes.io/session-cookie-expires |
Behaviour differs | Emitted as a Max-Age attribute — HAProxy can't compute an absolute Expires date; browsers treat both equivalently. |
nginx.ingress.kubernetes.io/session-cookie-hash |
Not carried over | HAProxy's dynamic-cookie hashing isn't selectable; the value is ignored and a warning comment is rendered. |
nginx.ingress.kubernetes.io/ssl-redirect |
Behaviour differs | Redirects only when explicitly "true" — ingress-nginx redirects TLS-enabled Ingresses by default; the code comes from extraContext.nginxHttpRedirectCode (default 308, matching ingress-nginx's http-redirect-code). |
nginx.ingress.kubernetes.io/stream-snippet |
Not carried over | nginx stream directives have no HAProxy equivalent. |
nginx.ingress.kubernetes.io/whitelist-source-range |
Behaviour differs | Host-scoped — the allowlist only gates rules with an explicit host, so an Ingress without rule hosts gets no filtering; invalid CIDRs fail the render. |
From haproxy-ingress¶
The haproxy-ingress.github.io/* library is opt-in — enable it with
--set controller.templateLibraries.haproxyIngress.enabled=true and your
jcmoraisjr/haproxy-ingress annotations work unchanged. You still need to
match the IngressClass (your Ingresses likely
use ingressClassName: haproxy — either edit them or --set ingressClass.name=haproxy)
and control the DNS cutover the same way. Once
you're on HAPTIC you can migrate to the native haproxy-haptic.org/*
annotations at your own pace, or keep both.
Most routing, SSL, session-affinity, redirect, HSTS, CORS, access-control, basic/external auth, client-mTLS, and WAF annotations are supported. Full reference: haproxy-ingress library docs.
The table below lists every annotation that doesn't carry over unchanged — generated from the library's declared migration coverage. Anything not listed is fully supported.
The library classifies 92 haproxy-ingress.github.io/* annotations: 62 supported, 28 with behaviour differences, 2 not carried over, 0 failing.
| Annotation | Status | What to check |
|---|---|---|
haproxy-ingress.github.io/agent-check-addr |
Behaviour differs | Has no effect without agent-check-port — and setting it (or -interval/-send) without agent-check-port fails the render. |
haproxy-ingress.github.io/agent-check-interval |
Behaviour differs | Has no effect without agent-check-port — and setting it without agent-check-port fails the render. |
haproxy-ingress.github.io/agent-check-send |
Behaviour differs | Has no effect without agent-check-port — and setting it without agent-check-port fails the render. |
haproxy-ingress.github.io/allowlist-source-range |
Behaviour differs | Host-scoped — only gates rules with an explicit host; invalid CIDRs fail the render. |
haproxy-ingress.github.io/auth-method |
Behaviour differs | Overrides the auth subrequest method; POST/PUT/PATCH are sent with an empty body. |
haproxy-ingress.github.io/auth-tls-cert-header |
Behaviour differs | Forwards X-SSL-Client-CN, X-SSL-Client-DN and X-SSL-Client-Cert when "true"; jcmoraisjr/haproxy-ingress additionally forwards the SHA-1 and serial headers, which aren't set. |
haproxy-ingress.github.io/auth-tls-secret |
Behaviour differs | Verification is keyed by SNI — every rule needs an explicit host or the render fails. A missing Secret (or missing ca.crt) currently skips mTLS for the Ingress (fail-open — requests with no/any client cert pass), where upstream with auth-tls-strict defaulting to true installs a fake CA and rejects all clients (fail-closed). |
haproxy-ingress.github.io/auth-tls-strict |
Not carried over | Accepted for compatibility but has no separate effect. Upstream defaults it to true (fail-closed on a missing/invalid CA); here a missing CA fails open (see auth-tls-secret), and this annotation can't restore fail-closed. For soft verification use auth-tls-verify-client optional instead. |
haproxy-ingress.github.io/auth-tls-verify-client |
Behaviour differs | "on"→required; "optional"/"optional_no_ca"→optional (HAProxy has no verify-but-accept-invalid mode); other values fail the render. |
haproxy-ingress.github.io/auth-url |
Behaviour differs | External auth via the SPOA hub external-auth plugin, which (unlike the nginx-ingress library) isn't auto-enabled — enable spoaHub.plugins.external-auth, otherwise the auth is silently not enforced. |
haproxy-ingress.github.io/backend-protocol |
Behaviour differs | h1, h2, h1-ssl and h2-ssl are accepted (h1-ssl/h2-ssl enable TLS); other values fail the render — note this is a different value set from ingress-nginx's HTTP/HTTPS/GRPC/GRPCS. |
haproxy-ingress.github.io/config-frontend |
Behaviour differs | Injected into HAPTIC's shared HTTP frontend (before routing), not a per-Ingress frontend — directives apply process-wide; deduplication is the operator's responsibility. |
haproxy-ingress.github.io/default-backend-redirect-code |
Behaviour differs | Default 302; an invalid code fails the render. |
haproxy-ingress.github.io/denylist-source-range |
Behaviour differs | Host-scoped — only gates rules with an explicit host; invalid CIDRs fail the render. |
haproxy-ingress.github.io/docs |
Not carried over | A pointer to jcmoraisjr/haproxy-ingress documentation, not a configuration key; not read. |
haproxy-ingress.github.io/hsts |
Behaviour differs | By default the header is emitted only when the Ingress sets hsts to "true", whereas jcmoraisjr/haproxy-ingress enables HSTS globally by default. Set extraContext.hstsEnabled=true to send HSTS on all TLS hosts (matching that default); a per-Ingress hsts annotation still overrides the value for its hosts. |
haproxy-ingress.github.io/limit-connections |
Behaviour differs | Maps to backend fullconn (a soft full-queue threshold) rather than a hard per-server connection cap; must be a positive integer. |
haproxy-ingress.github.io/limit-rpm |
Behaviour differs | Same hard-cap/429 semantics, and ignored when limit-rps is also set (one stick-table per backend). |
haproxy-ingress.github.io/limit-rps |
Behaviour differs | Hard per-source-IP cap rejecting with 429 — jcmoraisjr/haproxy-ingress applies a burst allowance. |
haproxy-ingress.github.io/oauth |
Behaviour differs | Only "oauth2_proxy"/"oauth2-proxy" is supported and requires an Ingress path (oauth-uri-prefix, default /oauth2) routing to the oauth2-proxy Service — otherwise the render fails; auth-url takes precedence; needs the external-auth plugin (not auto-enabled) with plaintext allowed. |
haproxy-ingress.github.io/path-type |
Behaviour differs | regex, exact, prefix, and begin are honoured, but only for paths with pathType ImplementationSpecific — the annotation is ignored on Prefix/Exact-typed paths. |
haproxy-ingress.github.io/redirect-to-code |
Behaviour differs | Default 302; an out-of-range code silently falls back to 302 rather than failing. |
haproxy-ingress.github.io/secure-crt-secret |
Behaviour differs | Presents a client certificate to the upstream from the Secret; a missing Secret or missing tls.crt/tls.key renders a warning comment and skips the client cert instead of failing. |
haproxy-ingress.github.io/secure-verify-ca-secret |
Behaviour differs | Verifies the upstream certificate against the Secret's ca.crt; a missing Secret or missing ca.crt renders a warning comment and silently downgrades to no verification instead of failing. |
haproxy-ingress.github.io/ssl-cipher-suites-backend |
Behaviour differs | TLS 1.3 cipher suites for upstream TLS — only applied when backend TLS is enabled; otherwise silently ignored. |
haproxy-ingress.github.io/ssl-ciphers-backend |
Behaviour differs | Cipher list for upstream TLS — only applied when backend TLS is enabled (secure-backends or an -ssl backend-protocol); otherwise silently ignored. |
haproxy-ingress.github.io/ssl-redirect-code |
Behaviour differs | Default 302 (jcmoraisjr/haproxy-ingress redirects with 302); an out-of-range code silently falls back to 302 rather than failing. |
haproxy-ingress.github.io/waf |
Behaviour differs | Only "modsecurity" is supported (enforced by the bundled Coraza WAF via the SPOA hub); requires the coraza plugin (auto-enabled with this library) or the render fails. |
haproxy-ingress.github.io/waf-mode |
Behaviour differs | "deny" (default) enforces, "detect" runs in shadow mode; other values, or waf-mode without waf, fail the render. |
haproxy-ingress.github.io/whitelist-source-range |
Behaviour differs | Deprecated alias of allowlist-source-range, honoured only when allowlist-source-range is absent; host-scoped. |
From haproxytech/kubernetes-ingress¶
The haproxy.org/* library (the official haproxytech/kubernetes-ingress
annotation set) is opt-in — enable it with
--set controller.templateLibraries.haproxytech.enabled=true and those
annotations work unchanged. You still need to match the IngressClass
(your Ingresses likely use ingressClassName: haproxy) and
control the DNS cutover the same way. Once you're
on HAPTIC you can migrate to the native haproxy-haptic.org/* annotations at
your own pace, or keep both.
HAPTIC reads these annotations on Ingress resources only. haproxytech's controller also reads many of them on Service and ConfigMap resources; that Service/ConfigMap-level configuration doesn't carry over. Full reference: haproxytech library docs.
The table below lists every annotation that doesn't carry over unchanged — generated from the library's declared migration coverage. Anything not listed is fully supported.
The library classifies 56 haproxy.org/* annotations: 37 supported, 14 with behaviour differences, 5 not carried over, 0 failing.
| Annotation | Status | What to check |
|---|---|---|
haproxy.org/allow-list |
Behaviour differs | Host-scoped source-IP allowlist — only gates rules with an explicit host; invalid CIDRs fail the render. |
haproxy.org/auth-realm |
Behaviour differs | Default "Protected-Content" (matching the upstream controller); spaces in the realm are replaced with dashes, as upstream does. |
haproxy.org/auth-secret |
Behaviour differs | Secret format is one key per username with a base64(hash) value — different from ingress-nginx's htpasswd. A missing Secret makes the route serve 503 until it appears, and writing such an Ingress is rejected by the admission webhook. |
haproxy.org/auth-type |
Behaviour differs | Only "basic-auth" is supported; other values fail the render (note the value differs from ingress-nginx's "basic"). |
haproxy.org/blacklist |
Behaviour differs | Deprecated alias of deny-list, honoured only when deny-list is absent; host-scoped. |
haproxy.org/cookie-persistence-no-dynamic |
Behaviour differs | Static (non-dynamic) cookie stickiness; setting it together with cookie-persistence fails the render. |
haproxy.org/deny-list |
Behaviour differs | Host-scoped source-IP denylist — only gates rules with an explicit host; invalid CIDRs fail the render. |
haproxy.org/pod-maxconn |
Behaviour differs | Divided across the number of ready HAProxy pods (quantized to a power of two) rather than applied per-server verbatim; must be a positive integer. |
haproxy.org/request-redirect-code |
Behaviour differs | Default 302; an invalid code fails the render. |
haproxy.org/scale-server-slots |
Not carried over | Inert; servers are named after their pods (ADR-0011), so there is no slot pool to size. Setting it emits an UnsupportedAnnotation Warning Event. |
haproxy.org/send-proxy-protocol |
Behaviour differs | proxy, proxy-v1, proxy-v2, proxy-v2-ssl and proxy-v2-ssl-cn map to the matching send-proxy flags; any other value is silently ignored. |
haproxy.org/server-ca |
Behaviour differs | Verifies the upstream certificate against the Secret's ca.crt; a missing Secret or missing ca.crt renders a warning comment and silently skips verification instead of failing. |
haproxy.org/server-crt |
Behaviour differs | Presents a client certificate to the upstream from the Secret; a missing Secret or missing tls.crt/tls.key renders a warning comment and skips the client cert instead of failing. |
haproxy.org/src-ip-header |
Behaviour differs | Rewrites the source IP from the named header (set-src), but only for rules with an explicit host. |
haproxy.org/standalone-backend |
Not carried over | Not implemented; HAPTIC always shares the backend model. |
haproxy.org/timeout-client |
Not carried over | A frontend-level timeout owned by HAPTIC's shared frontend, not settable per Ingress. |
haproxy.org/timeout-http-keep-alive |
Not carried over | A frontend-level timeout owned by HAPTIC's shared frontend, not settable per Ingress. |
haproxy.org/timeout-http-request |
Not carried over | A frontend-level timeout owned by HAPTIC's shared frontend, not settable per Ingress. |
haproxy.org/whitelist |
Behaviour differs | Deprecated alias of allow-list, honoured only when allow-list is absent; host-scoped. |
Troubleshooting¶
Three defaults cause most "it's installed but doesn't behave like before" reports. Each fails quietly, so check them first.
-
Existing Ingresses aren't being routed. HAPTIC only serves Ingresses whose
spec.ingressClassNameequalsingressClass.name(defaulthaptic) — aningressClassName: nginxIngress is filtered out before it reaches the controller's store. Fix: match the IngressClass. -
Annotations seem to be ignored. All three vendor compatibility libraries (
nginx.ingress.kubernetes.io/*,haproxy-ingress.github.io/*,haproxy.org/*) are disabled by default — only the nativehaproxy-haptic.org/*library is on. A vendor annotation (timeouts, auth, CORS, rate-limits, redirects) is a silent no-op until you enable its matching library. Fix: enable the annotation library for the controller you're migrating from. -
DNS cut over before you were ready. Ingress status writes are on by default — install with them off and enable them only after you've verified routing. Fix: control the DNS cutover.
For symptoms beyond these three, see the general troubleshooting guide.
See also¶
- Getting Started — install HAPTIC and route your first Ingress.
- Playground — render an annotated Ingress live and see per-annotation migration warnings.
- Watching Resources — how
ingressClassNamescoping and field selectors work.