Unreleased documentation. Choose your installed release in the version menu. Features described here may be absent from that release.
Migrating to HAPTIC¶
Move Ingresses from ingress-nginx, haproxy-ingress, or haproxytech/kubernetes-ingress to HAPTIC. Check annotation compatibility, install HAPTIC alongside the existing controller, and test routes before transferring production traffic.
With default values, HAPTIC uses the haptic IngressClass and its own HAProxy
Service. It watches Ingresses that select that class. Review class names and
watch filters if you've customized an existing HAPTIC installation.
Keep the old controller available until routing and DNS changes are verified. See troubleshooting for differences that commonly affect 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 includes commands for a new installation and for an existing HAPTIC release.
- You can edit Ingress manifests (to change
ingressClassName) or you accept renaming HAPTIC's class to match — see below.
Step 0: Check what changes¶
Run the migration report before changing routes. It classifies source-controller annotations as supported, different, or dropped, and renders the Ingress through HAPTIC's template engine.
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. From ingress-nginx, also paste the controller
ConfigMap: the report classifies each of its keys against the
controller ConfigMap table.
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¶
Plan the traffic transition
Changing ingressClassName removes the route from the old controller before
DNS changes take effect. This procedure can interrupt traffic. Use a test
Ingress first and schedule production changes for an acceptable interruption
window. For continuous service, keep a separate route on the old controller
until clients have moved to HAPTIC.
-
Install HAPTIC alongside your existing controller. Give HAProxy a real external address. This example uses
LoadBalancer, which requires a load-balancer implementation in your cluster. A NodePort behind your existing load balancer also works; see Service access:helm install haptic oci://registry.gitlab.com/haproxy-haptic/haptic/charts/haptic \ --version 0.5.0 \ --namespace haptic --create-namespace \ --set haproxy.service.type=LoadBalancer \ --set controller.config.templatingSettings.extraContext.statusPatches.enabled=false # Hold route status until verificationIf HAPTIC is already installed, apply the same flags with
helm upgrade:helm upgrade haptic oci://registry.gitlab.com/haproxy-haptic/haptic/charts/haptic \ --version 0.5.0 \ --namespace haptic --reuse-values \ --set haproxy.service.type=LoadBalancer \ --set controller.config.templatingSettings.extraContext.statusPatches.enabled=false # Hold route status until verification -
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 with a local port forward before changing DNS:
In another terminal, enter the hostname from the test Ingress:
read -r -p "Test Ingress hostname: " test_hostname curl -i --header "Host: $test_hostname" http://127.0.0.1:8080/Check the application response and any authentication, redirects, or headers your route requires. Stop the port forward when you finish.
-
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 \ --version 0.5.0 \ --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
Restore the original ingressClassName to return route ownership to the old
controller. If DNS has changed, restore its previous address too and account
for cached records. 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 temporarily if a DNS controller would move traffic before verification; this affects all routes in the release. |
haproxy.service.type |
NodePort |
Set to LoadBalancer for a routable external address. |
From ingress-nginx¶
Match the IngressClass¶
HAPTIC serves Ingresses whose spec.ingressClassName matches its configured
class. For a gradual migration, change each selected Ingress to haptic:
Keep HAPTIC's class distinct while both controllers run. Reusing an incumbent's class name also requires transferring ownership of the existing IngressClass; changing the Helm value alone doesn't perform that transfer.
Keep ingressClass.default: false during migration. A
default IngressClass
is assigned to new Ingresses that omit a class; it doesn't migrate existing
class-less resources.
Enable the annotation library¶
helm upgrade haptic oci://registry.gitlab.com/haproxy-haptic/haptic/charts/haptic \
--version 0.5.0 --namespace haptic --reuse-values \
--set controller.templateLibraries.nginxIngress.enabled=true
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 provides compatible request metric names and labels for many ingress-nginx dashboards. Set the prefix and controller class below, then review the differences before reusing your alerts:
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 use different buckets. Queries that name a particular size bucket may need updating;_sumand_countare unaffected. To use these ingress-nginx bucket lists: -
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.
See differences from ingress-nginx before moving routes.
Controller ConfigMap settings¶
HAPTIC doesn't read the ingress-nginx controller ConfigMap. Set the equivalent Helm values instead. The migration report in Step 0 classifies every key of the controller ConfigMap against this table when you paste the ConfigMap into the Resources panel:
The report recognizes the controller ConfigMap by its name,
ingress-nginx-controller or nginx-configuration. A ConfigMap with another
name counts when it carries both labels app.kubernetes.io/name: ingress-nginx
and app.kubernetes.io/component: controller and at least one key from this
table. Keys missing from the table show as unknown.
In the table, extraContext. is short for
controller.config.templatingSettings.extraContext., and defaults are in
parentheses. Governance default means HAPTIC sets the value per Ingress
rather than fleet-wide: a governance rule with a
default supplies the native annotation to every Ingress that doesn't set it.
| ingress-nginx key | HAPTIC setting | Status | What to check |
|---|---|---|---|
proxy-connect-timeout (5) |
extraContext.timeoutConnect (100 ms) |
different | The short default lets a failed connect retry another pod quickly. Raise it only if healthy backends take longer to connect. See timeouts. |
proxy-read-timeout (60), proxy-send-timeout (60) |
extraContext.timeoutServer (50000 ms) |
different | One inactivity timeout covers both directions. |
client-header-timeout (60) |
extraContext.timeoutHttpRequest (unset: timeoutClient, 50000 ms) |
supported | |
client-body-timeout (60) |
extraContext.requestBuffering.waitTimeout (10s) |
different | The total wait for the body before HAProxy forwards the request, not the gap between reads. See request buffering. |
keep-alive (75) |
extraContext.timeoutHttpKeepAlive (unset: timeoutHttpRequest, then timeoutClient) |
supported | |
worker-shutdown-timeout (240s) |
extraContext.hardStopAfter (60s) |
supported | See graceful reload drain bound. |
proxy-next-upstream, proxy-next-upstream-tries |
extraContext.retryOn (conn-failure empty-response response-timeout); HAProxy retries 3 times |
different | Takes HAProxy retry-on conditions, not nginx ones. |
retry-non-idempotent (false) |
extraContext.retryNonIdempotent (false) |
supported | |
upstream-keepalive-* |
— | dropped | HAProxy reuses idle backend connections without configuration. |
proxy-body-size (1m) |
Governance default for haproxy-haptic.org/max-request-body-size |
different | HAPTIC doesn't limit the body size unless you set a limit. |
client-header-buffer-size, large-client-header-buffers |
extraContext.tune.bufsize (16384) |
different | The request line and all headers must fit in one HAProxy buffer. |
proxy-request-buffering (on) |
extraContext.requestBuffering.enabled (true) |
different | Buffers at most tune.bufsize bytes of the body. |
ssl-protocols (TLSv1.2 TLSv1.3) |
extraContext.tls.minVersion (TLSv1.2) |
different | Sets a minimum version, not a list. See TLS cipher suites and protocol versions. |
ssl-ciphers |
extraContext.tls.ciphers (TLS 1.2), extraContext.tls.ciphersuites (TLS 1.3) |
supported | |
ssl-session-tickets (false) |
extraContext.tls.sessionTickets.enabled (false) |
supported | See TLS session resumption. |
enable-ocsp (false) |
— | different | Always on: every certificate loads with ocsp-update on. |
ssl-redirect (true) |
extraContext.ingressDefaultSSLRedirect (false) |
different | Off by default. When on, it redirects every host HAPTIC serves over HTTPS, including hosts without spec.tls. See Redirect HTTP to HTTPS. |
http-redirect-code (308) |
extraContext.ingressDefaultSSLRedirectCode ("308"); extraContext.nginxHttpRedirectCode ("308") for the ssl-redirect annotation |
supported | |
hsts (true) |
extraContext.tls.hsts.enabled (false) |
different | Off by default. See HSTS. |
hsts-max-age (31536000) |
extraContext.tls.hsts.maxAge ("31536000") |
supported | |
hsts-include-subdomains (true) |
extraContext.tls.hsts.includeSubdomains (false) |
different | Off by default. |
hsts-preload (false) |
extraContext.tls.hsts.preload (false) |
supported | |
use-forwarded-headers, forwarded-for-header, compute-full-forwarded-for, enable-real-ip, proxy-real-ip-cidr |
Per Ingress: haproxy-haptic.org/forwardfor, haproxy-haptic.org/src-ip-header |
different | HAPTIC appends the connecting address to X-Forwarded-For and keeps the values the client sent. It doesn't set X-Forwarded-Proto, X-Forwarded-Host, or X-Forwarded-Port. src-ip-header trusts the header from every client; there's no trusted-CIDR list. |
use-proxy-protocol (false) |
extraContext.proxyProtocol.enabled (false) |
different | Adds separate PROXY-protocol ports (8081, 8444) and leaves the HTTP and HTTPS ports unchanged. See PROXY protocol. |
use-gzip (false), gzip-types |
Governance default for haproxy-haptic.org/compress-enable: set the default of the bundled haptic-compress-enable rule to "true". Types: haproxy-haptic.org/compress-types |
supported | See compression. |
enable-brotli (false) |
— | dropped | The community HAProxy build has no Brotli. |
load-balance (round_robin) |
Governance default for haproxy-haptic.org/load-balance (roundrobin) |
different | No ewma; leastconn is the closest. |
whitelist-source-range, denylist-source-range |
Governance default for haproxy-haptic.org/allowlist-source-range or haproxy-haptic.org/denylist-source-range |
supported | |
custom-http-errors |
Override 400.http … 504.http under controller.config.files |
different | Replaces the error pages HAProxy generates; error responses from backends pass through unchanged unless the Ingress sets the custom-http-errors annotation. See general files. |
server-tokens (false), allow-backend-server-header (false) |
— | different | Every response carries Server: haptic, without a version, replacing any backend Server header. |
allow-snippet-annotations (false) |
Governance rules that reject raw-configuration annotations, shown below | different | HAPTIC accepts raw HAProxy configuration annotations by default. |
main-snippet, http-snippet, server-snippet |
controller.config.templateSnippets at a base-library extension point |
different | The content must be HAProxy configuration; rewrite nginx snippets. |
log-format-upstream, log-format-escape-json |
extraContext.accessLog.fields |
different | The access log is JSON with a fixed core field set. You add fields; you can't redefine the format. See access logging. |
access-log-path, enable-syslog, syslog-host, syslog-port |
extraContext.accessLog.targets |
supported | |
generate-request-id (true) |
— | supported | Every access-log record carries a req_id. Forward it upstream with haproxy-haptic.org/request-id. |
enable-opentelemetry, otlp-collector-host, otlp-collector-port, otel-service-name, otel-sampler-ratio |
extraContext.tracing.enabled, .otlp.endpoint, .otlp.serviceName, .sampleRate (a percentage) |
different | Spans are built from access-log records. |
enable-modsecurity, enable-owasp-modsecurity-crs |
extraContext.waf.dispatch.mode: default-on |
different | Coraza with the Core Rule Set replaces ModSecurity. See WAF policies. |
worker-processes |
haproxy.nbthread |
different | HAProxy runs threads, not processes. |
Keys missing from the table have no HAPTIC setting. Most tune nginx internals, such as hash-table sizes, worker connections, Lua, GeoIP, and Jaeger.
These values restore the ingress-nginx defaults where HAPTIC's default differs.
ingressDefaultHTTPS: false stops HAPTIC from serving hosts without spec.tls
over HTTPS, so only hosts with spec.tls redirect, as in ingress-nginx:
controller:
config:
templatingSettings:
extraContext:
timeoutServer: "60s"
timeoutHttpRequest: "60s"
timeoutHttpKeepAlive: "75s"
ingressDefaultHTTPS: false
ingressDefaultSSLRedirect: true
tls:
hsts:
enabled: true
includeSubdomains: true
governance:
rules:
nginx-proxy-body-size:
enabled: true
resource: ingresses
path: metadata.annotations['haproxy-haptic.org/max-request-body-size']
default: "1m"
To reject raw configuration the way allow-snippet-annotations: "false" does,
add one rule per raw-configuration annotation. A rule with pattern: '^$'
rejects any non-empty value:
controller:
config:
templatingSettings:
extraContext:
governance:
rules:
no-config-backend:
enabled: true
resource: ingresses
path: metadata.annotations['haproxy-haptic.org/config-backend']
pattern: '^$'
message: Raw HAProxy configuration annotations aren't allowed.
no-configuration-snippet:
enabled: true
resource: ingresses
path: metadata.annotations['nginx.ingress.kubernetes.io/configuration-snippet']
pattern: '^$'
message: Raw HAProxy configuration annotations aren't allowed.
Add the same rule for haproxy-haptic.org/config-global,
haproxy-haptic.org/config-defaults, and haproxy-haptic.org/config-frontend.
The admission webhook rejects a new or edited Ingress that violates a rule.
Existing Ingresses keep serving and receive a GovernanceViolation Warning Event.
TCP and UDP services¶
HAPTIC doesn't read the tcp-services or udp-services ConfigMaps.
TCP: create a Gateway with a TCP listener and a TCPRoute for each entry.
The Gateway API library is on by default. TCPRoute requires the Gateway API v1.6
standard channel or the experimental channel. This tcp-services entry:
becomes:
kubectl apply -f - <<EOF
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
name: tcp-services
namespace: default
spec:
gatewayClassName: haptic
listeners:
- name: postgres
protocol: TCP
port: 5432
allowedRoutes:
namespaces:
from: Same
kinds:
- kind: TCPRoute
---
apiVersion: gateway.networking.k8s.io/v1
kind: TCPRoute
metadata:
name: postgres
namespace: default
spec:
parentRefs:
- name: tcp-services
sectionName: postgres
rules:
- backendRefs:
- name: postgres
port: 5432
EOF
HAPTIC adds the listener port to its HAProxy Service. TCPRoute has no equivalent
of the :PROXY suffixes in a tcp-services entry. See
TCPRoute support for listener rules and
limitations.
UDP: HAPTIC doesn't proxy UDP and doesn't support UDPRoute. Expose a UDP
workload through its own LoadBalancer Service, or keep it on ingress-nginx.
From haproxy-ingress¶
Enable the compatibility library on your existing HAPTIC release:
helm upgrade haptic oci://registry.gitlab.com/haproxy-haptic/haptic/charts/haptic \
--version 0.5.0 --namespace haptic --reuse-values \
--set controller.templateLibraries.haproxyIngress.enabled=true
Review the linked annotation differences, match the IngressClass,
and control the DNS cutover. You can then migrate to
native haproxy-haptic.org/* annotations at your own pace.
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.
See differences from haproxy-ingress before moving routes.
From haproxytech/kubernetes-ingress¶
Enable the compatibility library on your existing HAPTIC release:
helm upgrade haptic oci://registry.gitlab.com/haproxy-haptic/haptic/charts/haptic \
--version 0.5.0 --namespace haptic --reuse-values \
--set controller.templateLibraries.haproxytech.enabled=true
Review the linked annotation differences, match the IngressClass, and control the DNS cutover.
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.
See differences from HAProxy Technologies before moving routes.
Troubleshooting¶
Check these settings when a migrated route behaves differently:
-
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.