<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Onur Bolatoğlu</title>
    <description>The latest articles on DEV Community by Onur Bolatoğlu (@onurbolatogluu).</description>
    <link>https://dev.to/onurbolatogluu</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F1107924%2F533c9c6f-6fbc-4573-b24a-095a5d26b822.png</url>
      <title>DEV Community: Onur Bolatoğlu</title>
      <link>https://dev.to/onurbolatogluu</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/onurbolatogluu"/>
    <language>en</language>
    <item>
      <title>What Silently Breaks When You Migrate from Ingress NGINX to HAProxy</title>
      <dc:creator>Onur Bolatoğlu</dc:creator>
      <pubDate>Tue, 08 Sep 2026 12:20:37 +0000</pubDate>
      <link>https://dev.to/onurbolatogluu/what-silently-breaks-when-you-migrate-from-ingress-nginx-to-haproxy-8b4</link>
      <guid>https://dev.to/onurbolatogluu/what-silently-breaks-when-you-migrate-from-ingress-nginx-to-haproxy-8b4</guid>
      <description>&lt;h2&gt;
  
  
  Introduction
&lt;/h2&gt;

&lt;p&gt;ingress-nginx was retired in March 2026. There are no more releases and no more security patches. It routes traffic into roughly half of all Kubernetes clusters, which makes this a problem for a lot of teams. Installing a retired project is still completely silent. I tried it last week and got no warning at any step. Most migration guides treat this as an annotation mapping exercise. But the real problem is not the annotations. It is everything that keeps working afterwards while quietly doing the wrong thing.&lt;/p&gt;




&lt;h2&gt;
  
  
  The environment
&lt;/h2&gt;

&lt;p&gt;Everything below was tested on a single-node cluster:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Ubuntu 26.04 LTS (kernel 7.0.0-31)
k3s v1.36.4+k3s1
Helm v3.21.4
HAProxy 3.2.9 (host, LTS branch)
ingress-nginx chart 4.15.1 / app 1.15.1  (retired March 2026)
HAProxy Kubernetes Ingress Controller (HAProxy 3.2.23)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Traffic path: Cloudflare (proxied) → HAProxy on the host → ingress controller → pod.&lt;/p&gt;

&lt;p&gt;This is the chain I run in production, so it is the one I tested. Many teams run something close to this, with a CDN in front and a proxy on the host.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 1: Installing a retired project produces no warning
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;bash
helm repo add ingress-nginx https://kubernetes.github.io/ingress-nginx
helm repo update

helm install ingress-nginx ingress-nginx/ingress-nginx \
  --namespace ingress-nginx --create-namespace \
  --set controller.service.type=NodePort \
  --set controller.service.nodePorts.http=30080 \
  --set controller.service.nodePorts.https=30443
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;NAME: ingress-nginx
STATUS: deployed
REVISION: 1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The chart repository still answers, the install succeeds, and no step mentions that the project is over. Automation makes this worse: a pipeline that installs this chart keeps working, and nobody reads the output anyway.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 2: Three layers, three wrong IPs
&lt;/h2&gt;

&lt;p&gt;The test application is &lt;code&gt;traefik/whoami&lt;/code&gt;, which prints the IP it sees and every header it receives.&lt;/p&gt;

&lt;p&gt;Request from an external machine, through Cloudflare:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;RemoteAddr: 10.42.0.6:36848
Cf-Connecting-Ip: 203.0.113.5
X-Forwarded-For: 10.42.0.1
X-Real-Ip: 10.42.0.1
X-Original-Forwarded-For: 203.0.113.5
X-Forwarded-Proto: http
Cf-Visitor: {"scheme":"https"}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;HAProxy log for the same request:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;162.158.210.222:14219 [08/Sep/2026:08:54:27] fe_main~ be_ingress/ingress 200
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;ingress-nginx access log for the same request:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;10.42.0.1 - - [08/Sep/2026:08:54:27 +0000] "GET / HTTP/1.1" 200
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;IP it recorded&lt;/th&gt;
&lt;th&gt;Correct?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;HAProxy (host)&lt;/td&gt;
&lt;td&gt;Cloudflare edge address&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ingress-nginx&lt;/td&gt;
&lt;td&gt;CNI gateway&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pod (&lt;code&gt;RemoteAddr&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;ingress pod&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;CF-Connecting-IP&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;real client&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Every hop rewrites the source address, so by the time the request reaches the pod, the original client is three layers away. Notice that the real address is not gone. ingress-nginx moved it to X-Original-Forwarded-For and wrote its own address into the header your application actually reads. The same request also arrives with X-Forwarded-Proto set to http, even though the client connected over HTTPS. This breaks redirect loops, secure cookies, and any absolute URL the application builds.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 3: Fixing the chain
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Cloudflare IP list
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;bash
{ curl -s https://www.cloudflare.com/ips-v4; echo; \
  curl -s https://www.cloudflare.com/ips-v6; echo; } \
  | sudo tee /etc/haproxy/cloudflare-ips.lst &amp;gt; /dev/null
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you run these two curl commands one after another, the last IPv4 range and the first IPv6 range end up on the same line. HAProxy then refuses to load the config, and the error message shows the two ranges stuck together. Adding an echo between the two commands fixes it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;'131.0.72.0/222400:cb00::/32' is not a valid IPv4 or IPv6 address
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  HAProxy configuration
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;frontend fe_main
    bind *:80
    bind *:443 ssl crt /etc/haproxy/certs/lab.pem

    acl from_cloudflare src -f /etc/haproxy/cloudflare-ips.lst
    http-request deny unless from_cloudflare

    http-request set-header X-Forwarded-For %[req.hdr(CF-Connecting-IP)] if from_cloudflare
    http-request set-header X-Forwarded-Proto https if { ssl_fc }
    http-request set-header X-Forwarded-Proto http  if !{ ssl_fc }

    default_backend be_ingress
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I did not use option forwardfor here, because it appends the address of the connecting peer, which in this setup is a Cloudflare edge server. Instead I set X-Forwarded-For from the CF-Connecting-IP header, which Cloudflare adds and which holds a single address rather than a chain. The deny rule rejects any request that does not come from a Cloudflare address, because a header is only evidence when it comes from a party you trust.&lt;/p&gt;

&lt;h3&gt;
  
  
  Controller configuration
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;bash
helm upgrade ingress-nginx ingress-nginx/ingress-nginx \
  --namespace ingress-nginx --reuse-values \
  --set controller.config.use-forwarded-headers="true" \
  --set controller.config.compute-full-forwarded-for="true" \
  --set controller.config.proxy-real-ip-cidr="10.42.0.0/16"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Result:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;X-Forwarded-For: 203.0.113.5, 10.42.0.1
X-Real-Ip: 203.0.113.5
X-Forwarded-Proto: https
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Step 4: A correct output does not mean a correct configuration
&lt;/h2&gt;

&lt;p&gt;I want to describe a mistake I made here, because the output gave me no reason to look for it. haproxy -c had rejected the file because of the IP list bug from the previous section, so the reload failed and the old config stayed live. One of the two changes I made was doing all the work, and it was not the one I was looking at. On its own, that setting tells the controller to trust the incoming X-Forwarded-For without checking where it came from. A correct-looking output is not a verification. It only tells you that something produced the right value, not what.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 5: Proving the gap
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;bash
curl -s -H "Host: lab.example.com" \
     -H "X-Forwarded-For: 198.51.100.99" \
     http://192.0.2.10/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;X-Forwarded-For: 198.51.100.99, 10.42.0.1
X-Real-Ip: 198.51.100.99
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The forged address in the ingress-nginx access log:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;198.51.100.99 - - [08/Sep/2026:09:02:34 +0000] "GET / HTTP/1.1" 200
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I sent a request straight to the origin IP, skipping Cloudflare, with an X-Forwarded-For header I made up. Both the header and the access log recorded 198.51.100.99, an address that belongs to nobody and had nothing to do with the machine sending the request. A wrong header is a bug. A wrong log line is a security control pointed at the wrong target. The fix is not a better header. It is restricting who is allowed to write one, which is what the deny rule from the previous section does.&lt;/p&gt;

&lt;p&gt;With the deny rule in place, the same request:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;HTTP/1.1 403 Forbidden
Request forbidden by administrative rules.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Step 6: The migration
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;bash
helm repo add haproxytech https://haproxytech.github.io/helm-charts
helm repo update

helm install haproxy-ingress haproxytech/kubernetes-ingress \
  --namespace haproxy-controller --create-namespace \
  --set controller.service.type=NodePort \
  --set controller.service.nodePorts.http=31080 \
  --set controller.service.nodePorts.https=31443
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;NAME      CONTROLLER
haproxy   haproxy.org/ingress-controller/haproxy
nginx     k8s.io/ingress-nginx
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I installed the HAProxy controller next to the existing one instead of replacing it, on a different node port. This is what makes the rest of this article possible: every difference below comes from sending one identical request to two ports.&lt;/p&gt;

&lt;h3&gt;
  
  
  A note on bulk conversion
&lt;/h3&gt;

&lt;p&gt;I converted the existing Ingress with a sed command that renamed the resource and switched the ingress class. The name pattern was not specific enough. It rewrote the resource name and the backend service name in the same pass, and only one of those was supposed to change. A 404 after a migration looks like a routing rule you got wrong, so that is where you start looking, and that is not where it is.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;lab.example.com
  /   whoami-haproxy:80 (&amp;lt;error: services "whoami-haproxy" not found&amp;gt;)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Step 7: What disappeared
&lt;/h2&gt;

&lt;p&gt;Same request, same forged header, sent directly to each controller.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;ingress-nginx (port 30080)&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;X-Forwarded-For: 198.51.100.99, 10.42.0.1
X-Forwarded-Host: lab.example.com
X-Forwarded-Port: 80
X-Forwarded-Proto: http
X-Forwarded-Scheme: http
X-Original-Forwarded-For: 198.51.100.99
X-Real-Ip: 198.51.100.99
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;HAProxy controller (port 31080)&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;X-Forwarded-For: 198.51.100.99
X-Forwarded-For: 10.42.0.1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;These three ConfigMap settings existed before the migration and were carried over by nothing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="s"&gt;yaml&lt;/span&gt;
&lt;span class="na"&gt;controller&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;config&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;use-forwarded-headers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
    &lt;span class="na"&gt;compute-full-forwarded-for&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
    &lt;span class="na"&gt;proxy-real-ip-cidr&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;10.42.0.0/16&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;X-Real-Ip, X-Forwarded-Host and X-Forwarded-Port are simply not there anymore. The X-Forwarded-For header did not disappear, which is worse. It arrives twice now, as two separate lines instead of one comma-separated value. Which value your application ends up using now depends on its HTTP library, and different frameworks make different choices here. Those three settings were the entire reason the client IP was correct before. The HAProxy controller has no equivalent for any of them, and the migration did not mention that. None of this produced an error. The site loads, the controller is Running, every check in a normal migration runbook passes, and the client IP is wrong.&lt;/p&gt;

&lt;h3&gt;
  
  
  The header that came back for the wrong reason
&lt;/h3&gt;

&lt;p&gt;Through the full chain:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;X-Forwarded-Proto: https
X-Forwarded-For: 203.0.113.5
X-Forwarded-For: 10.42.0.1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Through the full chain X-Forwarded-Proto is present again, but the controller is not the one setting it. Most setups do not have that layer, because a cloud load balancer connects straight to the controller, and there the header is gone entirely. So where you run the test changes what you see, and testing through the full chain can hide a regression that a simpler setup would expose.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 8: Rolling back
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;bash
sudo sed -i 's/127.0.0.1:31080/127.0.0.1:30080/' /etc/haproxy/haproxy.cfg
sudo systemctl reload haproxy
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because both controllers are still running, going back is a port number and a reload, not a reinstall. This is why I kept the old controller instead of deleting it. The cost is one unused node port; the benefit is that every step is reversible while you are still learning what changed.&lt;/p&gt;




&lt;h2&gt;
  
  
  When this does not apply
&lt;/h2&gt;

&lt;p&gt;No CDN at all means one less hop and one less place to get this wrong. With a cloud load balancer there is no HAProxy on the host, so the trust boundary moves to the load balancer and its own IP ranges. If you use PROXY protocol instead of headers, none of this applies. The client address arrives at the TCP layer and there is no header to forge. This was a single-node cluster, so the CNI addresses you see here will be different on a multi-node setup.&lt;/p&gt;

&lt;p&gt;The migration itself is easy. Finding out what changed afterwards is the actual work.&lt;/p&gt;

</description>
      <category>devops</category>
      <category>infrastructure</category>
      <category>kubernetes</category>
      <category>networking</category>
    </item>
  </channel>
</rss>
