<?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: Kazu</title>
    <description>The latest articles on DEV Community by Kazu (@shinagawa-web).</description>
    <link>https://dev.to/shinagawa-web</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%2F3422958%2F561728f2-3289-4079-9cba-cc1c855c8b68.png</url>
      <title>DEV Community: Kazu</title>
      <link>https://dev.to/shinagawa-web</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/shinagawa-web"/>
    <language>en</language>
    <item>
      <title>Kubernetes: iptables is empty after migrating to Cilium — what to look at next</title>
      <dc:creator>Kazu</dc:creator>
      <pubDate>Tue, 15 Sep 2026 13:03:00 +0000</pubDate>
      <link>https://dev.to/shinagawa-web/kubernetes-iptables-is-empty-after-migrating-to-cilium-what-to-look-at-next-2knn</link>
      <guid>https://dev.to/shinagawa-web/kubernetes-iptables-is-empty-after-migrating-to-cilium-what-to-look-at-next-2knn</guid>
      <description>&lt;p&gt;A Kubernetes Service stops responding. The first move is always the same: get on the node and run &lt;code&gt;iptables-save | grep KUBE-SERVICES&lt;/code&gt; to see which Pod a given Service address maps to.&lt;/p&gt;

&lt;p&gt;kube-proxy wrote those rules. It's a standard Kubernetes component that runs on every node and keeps Service forwarding rules registered in iptables. Looking up where a Service routes means reading those rules.&lt;/p&gt;

&lt;p&gt;Cilium is a Kubernetes CNI plugin — the piece that wires up Pod networking so Pods can talk to each other. Flannel and Calico fill the same role; you pick one per cluster.&lt;/p&gt;

&lt;p&gt;Cilium has a mode that goes beyond Pod networking and also handles Service forwarding, the job kube-proxy used to own. That mode is called kube-proxy replacement. With &lt;code&gt;kubeProxyReplacement=true&lt;/code&gt;, Cilium runs on the assumption that kube-proxy is absent. Cilium itself does not remove kube-proxy; on an existing cluster you have to remove it separately. The test environment in this article is built with kind's &lt;code&gt;kubeProxyMode: none&lt;/code&gt;, so kube-proxy was never present.&lt;/p&gt;

&lt;p&gt;On a node in that Cilium environment, run the same &lt;code&gt;iptables-save | grep KUBE-SERVICES&lt;/code&gt; from before. Nothing comes back. The same setup on a kube-proxy cluster returns 13 lines; here it returns 0. KUBE-SERVICES is the chain kube-proxy creates — the entry point for packets destined to a ClusterIP. It doesn't exist.&lt;/p&gt;

&lt;p&gt;Broaden the grep to &lt;code&gt;KUBE-&lt;/code&gt; and you get a few lines back, but they're &lt;code&gt;KUBE-FIREWALL&lt;/code&gt; and &lt;code&gt;KUBE-KUBELET-CANARY&lt;/code&gt;, chains created by kubelet that have nothing to do with Service forwarding. The nat table itself has some rules; it isn't empty. Only the Service forwarding rules are missing.&lt;/p&gt;

&lt;p&gt;You try a different table, a different node. KUBE-SERVICES is nowhere, and yet the application is up and Services are reachable.&lt;/p&gt;

&lt;p&gt;Nothing is broken. The information just isn't in iptables.&lt;/p&gt;

&lt;p&gt;This article covers three things: where Cilium stores the forwarding rules that used to live in iptables; why &lt;code&gt;tcpdump&lt;/code&gt; on a ClusterIP produces nothing and &lt;code&gt;conntrack -L&lt;/code&gt; returns no matches; and what to check, in what order, when connectivity fails.&lt;/p&gt;

&lt;p&gt;The test setup runs two kind clusters side by side — one with kube-proxy, one with Cilium — and runs the same commands against the same Service on both. All output shown is from real machines.&lt;/p&gt;

&lt;p&gt;All tests run on kind v0.27.0, node image &lt;code&gt;kindest/node:v1.32.2&lt;/code&gt;, Cilium 1.17.3, cilium-cli v0.20.0, kernel 6.8. Both clusters are single-node; multi-node-only behaviors are out of scope. ClusterIPs change each time a cluster is recreated, so IPs may differ between sections.&lt;/p&gt;

&lt;p&gt;The reproduction repo is at &lt;a href="https://github.com/shinagawa-web/kubernetes-cilium-kube-proxy-replacement-debug-guide" rel="noopener noreferrer"&gt;https://github.com/shinagawa-web/kubernetes-cilium-kube-proxy-replacement-debug-guide&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Where forwarding rules live, and when they get written
&lt;/h2&gt;

&lt;h3&gt;
  
  
  kube-proxy
&lt;/h3&gt;

&lt;h4&gt;
  
  
  Rules were registered in netfilter
&lt;/h4&gt;

&lt;p&gt;A Service's ClusterIP doesn't exist on any machine. When a Pod sends a packet to it, the destination gets rewritten to a real Pod IP somewhere in the forwarding path. kube-proxy was what registered those rewrite rules in netfilter. netfilter is the Linux kernel's packet-processing framework; &lt;code&gt;iptables&lt;/code&gt; is the command for reading and writing its rules.&lt;/p&gt;

&lt;p&gt;That's why &lt;code&gt;iptables-save&lt;/code&gt; worked. The forwarding rule — which Pod a ClusterIP packet goes to — was stored as a text rule registered in the kernel, so dumping everything was enough.&lt;/p&gt;

&lt;p&gt;Here's what the packet path looks like when a Pod connects to a ClusterIP:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;client Pod
   |  sends packet destined for 10.96.111.7:80 (ClusterIP)
   v
network stack
   |
   v
netfilter nat table
   KUBE-SERVICES → KUBE-SVC-xxxx → KUBE-SEP-xxxx
   |  rewrites destination to 10.244.0.6:80 (Pod IP) here
   v
Pod
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h4&gt;
  
  
  tcpdump could capture the pre-DNAT packet
&lt;/h4&gt;

&lt;p&gt;In this path, there's a stretch before the rewrite where the packet still carries the ClusterIP as its destination. Running &lt;code&gt;tcpdump&lt;/code&gt; on the node with the ClusterIP as the filter caught those packets. Capturing before and after the rewrite showed exactly which Pod IP the destination changed to.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;iptables-save&lt;/code&gt; for the rules, &lt;code&gt;tcpdump&lt;/code&gt; for the live packets. Those two tools covered Service forwarding.&lt;/p&gt;

&lt;h3&gt;
  
  
  Cilium
&lt;/h3&gt;

&lt;h4&gt;
  
  
  Cluster setup
&lt;/h4&gt;

&lt;p&gt;To enable kube-proxy replacement, you disable kube-proxy at cluster creation and install Cilium as the CNI. The test repo does this with kind. If you already have Cilium running, skip ahead to "No kube-proxy means no chains."&lt;/p&gt;

&lt;p&gt;First, the kind cluster config disables both kube-proxy and the default CNI:&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="c1"&gt;# cluster/kind-cilium.yaml&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Cluster&lt;/span&gt;
&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;kind.x-k8s.io/v1alpha4&lt;/span&gt;
&lt;span class="na"&gt;nodes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;control-plane&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;kindest/node:v1.32.2&lt;/span&gt;
&lt;span class="na"&gt;networking&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;disableDefaultCNI&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;kubeProxyMode&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;none&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;kubeProxyMode: none&lt;/code&gt; keeps kube-proxy off the nodes. &lt;code&gt;disableDefaultCNI: true&lt;/code&gt; removes kindnet so Cilium can take the CNI slot.&lt;/p&gt;

&lt;p&gt;After creating the cluster, install Cilium via Helm. &lt;code&gt;kubeProxyReplacement=true&lt;/code&gt; starts Cilium in the mode where it owns Service forwarding as well.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;helm &lt;span class="nb"&gt;install &lt;/span&gt;cilium cilium/cilium &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--version&lt;/span&gt; 1.17.3 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--namespace&lt;/span&gt; kube-system &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--set&lt;/span&gt; &lt;span class="nv"&gt;kubeProxyReplacement&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;true&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--set&lt;/span&gt; &lt;span class="nv"&gt;k8sServiceHost&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;API_SERVER_IP&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--set&lt;/span&gt; &lt;span class="nv"&gt;k8sServicePort&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;API_SERVER_PORT&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;k8sServiceHost&lt;/code&gt; and &lt;code&gt;k8sServicePort&lt;/code&gt; are the address Cilium uses to reach the Kubernetes API server. Without kube-proxy, you have to supply these explicitly. The full setup script is in &lt;code&gt;scripts/setup-cilium.sh&lt;/code&gt;.&lt;/p&gt;

&lt;h4&gt;
  
  
  No kube-proxy means no chains
&lt;/h4&gt;

&lt;p&gt;With this setup, kube-proxy doesn't exist. No process registers rules, so KUBE-SERVICES is never created. That's why the initial grep returned 0 lines.&lt;/p&gt;

&lt;h4&gt;
  
  
  The forwarding table moves to eBPF maps
&lt;/h4&gt;

&lt;p&gt;The destination mapping moves to eBPF. eBPF lets you load programs into the kernel and run them at predefined hook points — on packet receive, on system call entry — without modifying the kernel itself or rebooting. The attached program can read and modify the data passing through that point.&lt;/p&gt;

&lt;p&gt;eBPF programs are stateless across invocations — local variables don't persist between calls. State lives in maps instead: a key-value store the kernel maintains. Programs read and write there. Cilium writes the ClusterIP-to-Pod-IP mapping into these maps; on each connection, the attached program looks up the map to decide where to forward.&lt;/p&gt;

&lt;h4&gt;
  
  
  The rewrite happens at connect()
&lt;/h4&gt;

&lt;p&gt;If only the storage location changed, you'd just learn a new command. But there's one more difference: the rewrite happens earlier.&lt;/p&gt;

&lt;p&gt;The same connection in a Cilium environment no longer goes through netfilter at all, and the rewrite point shifts:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;client Pod
   |  calls connect() with 10.96.89.117:80 (ClusterIP) as the destination
   v
eBPF program attached to connect()
   |  rewrites destination to 10.0.0.183:80 (Pod IP)
   v
Pod
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Cilium attaches an eBPF program to &lt;code&gt;connect()&lt;/code&gt;, the system call where an application tells the kernel what it wants to connect to. At this point, not a single byte of data has been sent. The destination address gets replaced with the Pod IP right here.&lt;/p&gt;

&lt;p&gt;The rewrite is done before any packet is constructed, so the resulting packet carries the Pod IP as its destination — no ClusterIP packet is ever created. That's why tcpdump on a network interface never shows the ClusterIP.&lt;/p&gt;

&lt;p&gt;This applies to connections from within the cluster. Packets arriving from outside the cluster on a NodePort are handled differently, as covered in a later section.&lt;/p&gt;

&lt;h3&gt;
  
  
  Two kinds of "no output"
&lt;/h3&gt;

&lt;p&gt;At this point there are two separate "nothing shows up" situations with different causes. &lt;code&gt;iptables-save&lt;/code&gt; returning 0 lines is because the forwarding rules moved from iptables to eBPF maps. &lt;code&gt;tcpdump&lt;/code&gt; returning 0 packets when filtering on a ClusterIP is because there are no packets with that ClusterIP as a destination in that path. The causes are different, so the replacement commands are different. The next section handles the first one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reading the forwarding table
&lt;/h2&gt;

&lt;p&gt;The test environment has a single ClusterIP Service named &lt;code&gt;demo&lt;/code&gt;, backed by a Deployment with 2 replicas. Here's how to read the forwarding table for that Service in each environment.&lt;/p&gt;

&lt;h3&gt;
  
  
  kube-proxy
&lt;/h3&gt;

&lt;p&gt;Take another look at those 13 lines from &lt;code&gt;iptables-save | grep KUBE-SERVICES&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;:KUBE-SERVICES - &lt;span class="o"&gt;[&lt;/span&gt;0:0]
&lt;span class="nt"&gt;-A&lt;/span&gt; FORWARD &lt;span class="nt"&gt;-m&lt;/span&gt; conntrack &lt;span class="nt"&gt;--ctstate&lt;/span&gt; NEW &lt;span class="nt"&gt;-m&lt;/span&gt; comment &lt;span class="nt"&gt;--comment&lt;/span&gt; &lt;span class="s2"&gt;"kubernetes service portals"&lt;/span&gt; &lt;span class="nt"&gt;-j&lt;/span&gt; KUBE-SERVICES
&lt;span class="nt"&gt;-A&lt;/span&gt; OUTPUT &lt;span class="nt"&gt;-m&lt;/span&gt; conntrack &lt;span class="nt"&gt;--ctstate&lt;/span&gt; NEW &lt;span class="nt"&gt;-m&lt;/span&gt; comment &lt;span class="nt"&gt;--comment&lt;/span&gt; &lt;span class="s2"&gt;"kubernetes service portals"&lt;/span&gt; &lt;span class="nt"&gt;-j&lt;/span&gt; KUBE-SERVICES
:KUBE-SERVICES - &lt;span class="o"&gt;[&lt;/span&gt;0:0]
&lt;span class="nt"&gt;-A&lt;/span&gt; PREROUTING &lt;span class="nt"&gt;-m&lt;/span&gt; comment &lt;span class="nt"&gt;--comment&lt;/span&gt; &lt;span class="s2"&gt;"kubernetes service portals"&lt;/span&gt; &lt;span class="nt"&gt;-j&lt;/span&gt; KUBE-SERVICES
&lt;span class="nt"&gt;-A&lt;/span&gt; OUTPUT &lt;span class="nt"&gt;-m&lt;/span&gt; comment &lt;span class="nt"&gt;--comment&lt;/span&gt; &lt;span class="s2"&gt;"kubernetes service portals"&lt;/span&gt; &lt;span class="nt"&gt;-j&lt;/span&gt; KUBE-SERVICES
&lt;span class="nt"&gt;-A&lt;/span&gt; KUBE-SERVICES &lt;span class="nt"&gt;-d&lt;/span&gt; 10.96.0.10/32 &lt;span class="nt"&gt;-p&lt;/span&gt; tcp &lt;span class="nt"&gt;-m&lt;/span&gt; comment &lt;span class="nt"&gt;--comment&lt;/span&gt; &lt;span class="s2"&gt;"kube-system/kube-dns:metrics cluster IP"&lt;/span&gt; &lt;span class="nt"&gt;-m&lt;/span&gt; tcp &lt;span class="nt"&gt;--dport&lt;/span&gt; 9153 &lt;span class="nt"&gt;-j&lt;/span&gt; KUBE-SVC-JD5MR3NA4I4DYORP
&lt;span class="nt"&gt;-A&lt;/span&gt; KUBE-SERVICES &lt;span class="nt"&gt;-d&lt;/span&gt; 10.96.111.7/32 &lt;span class="nt"&gt;-p&lt;/span&gt; tcp &lt;span class="nt"&gt;-m&lt;/span&gt; comment &lt;span class="nt"&gt;--comment&lt;/span&gt; &lt;span class="s2"&gt;"default/demo cluster IP"&lt;/span&gt; &lt;span class="nt"&gt;-m&lt;/span&gt; tcp &lt;span class="nt"&gt;--dport&lt;/span&gt; 80 &lt;span class="nt"&gt;-j&lt;/span&gt; KUBE-SVC-73JNOU5FOFXIWZLE
&lt;span class="nt"&gt;-A&lt;/span&gt; KUBE-SERVICES &lt;span class="nt"&gt;-d&lt;/span&gt; 10.96.182.1/32 &lt;span class="nt"&gt;-p&lt;/span&gt; tcp &lt;span class="nt"&gt;-m&lt;/span&gt; comment &lt;span class="nt"&gt;--comment&lt;/span&gt; &lt;span class="s2"&gt;"default/demo-nodeport cluster IP"&lt;/span&gt; &lt;span class="nt"&gt;-m&lt;/span&gt; tcp &lt;span class="nt"&gt;--dport&lt;/span&gt; 80 &lt;span class="nt"&gt;-j&lt;/span&gt; KUBE-SVC-IECF2FXKD7A6IGZV
&lt;span class="nt"&gt;-A&lt;/span&gt; KUBE-SERVICES &lt;span class="nt"&gt;-d&lt;/span&gt; 10.96.0.1/32 &lt;span class="nt"&gt;-p&lt;/span&gt; tcp &lt;span class="nt"&gt;-m&lt;/span&gt; comment &lt;span class="nt"&gt;--comment&lt;/span&gt; &lt;span class="s2"&gt;"default/kubernetes:https cluster IP"&lt;/span&gt; &lt;span class="nt"&gt;-m&lt;/span&gt; tcp &lt;span class="nt"&gt;--dport&lt;/span&gt; 443 &lt;span class="nt"&gt;-j&lt;/span&gt; KUBE-SVC-NPX46M4PTMTKRN6Y
&lt;span class="nt"&gt;-A&lt;/span&gt; KUBE-SERVICES &lt;span class="nt"&gt;-d&lt;/span&gt; 10.96.0.10/32 &lt;span class="nt"&gt;-p&lt;/span&gt; udp &lt;span class="nt"&gt;-m&lt;/span&gt; comment &lt;span class="nt"&gt;--comment&lt;/span&gt; &lt;span class="s2"&gt;"kube-system/kube-dns:dns cluster IP"&lt;/span&gt; &lt;span class="nt"&gt;-m&lt;/span&gt; udp &lt;span class="nt"&gt;--dport&lt;/span&gt; 53 &lt;span class="nt"&gt;-j&lt;/span&gt; KUBE-SVC-TCOU7JCQXEZGVUNU
&lt;span class="nt"&gt;-A&lt;/span&gt; KUBE-SERVICES &lt;span class="nt"&gt;-d&lt;/span&gt; 10.96.0.10/32 &lt;span class="nt"&gt;-p&lt;/span&gt; tcp &lt;span class="nt"&gt;-m&lt;/span&gt; comment &lt;span class="nt"&gt;--comment&lt;/span&gt; &lt;span class="s2"&gt;"kube-system/kube-dns:dns-tcp cluster IP"&lt;/span&gt; &lt;span class="nt"&gt;-m&lt;/span&gt; tcp &lt;span class="nt"&gt;--dport&lt;/span&gt; 53 &lt;span class="nt"&gt;-j&lt;/span&gt; KUBE-SVC-ERIFXISQEP7F7OF4
&lt;span class="nt"&gt;-A&lt;/span&gt; KUBE-SERVICES &lt;span class="nt"&gt;-m&lt;/span&gt; comment &lt;span class="nt"&gt;--comment&lt;/span&gt; &lt;span class="s2"&gt;"kubernetes service nodeports; NOTE: this must be the last rule in this chain"&lt;/span&gt; &lt;span class="nt"&gt;-m&lt;/span&gt; addrtype &lt;span class="nt"&gt;--dst-type&lt;/span&gt; LOCAL &lt;span class="nt"&gt;-j&lt;/span&gt; KUBE-NODEPORTS
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first 6 lines are chain definitions and hook points — no forwarding destination information. Lines 7 onward list each Service's entry. &lt;code&gt;-d&lt;/code&gt; is the ClusterIP; &lt;code&gt;-m comment --comment&lt;/code&gt; contains &lt;code&gt;default/&amp;lt;ServiceName&amp;gt; cluster IP&lt;/code&gt;. Find the Service you want by that comment. &lt;code&gt;demo&lt;/code&gt; is line 8 — &lt;code&gt;"default/demo cluster IP"&lt;/code&gt; — and its ClusterIP is &lt;code&gt;10.96.111.7&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;-j KUBE-SVC-73JNOU5FOFXIWZLE&lt;/code&gt; at the end of that line is the jump to the backend-selection chain. Grep for that chain name to get the backend rules:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;iptables-save &lt;span class="nt"&gt;-t&lt;/span&gt; nat | &lt;span class="nb"&gt;grep &lt;/span&gt;KUBE-SVC-73JNOU5FOFXIWZLE
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nt"&gt;-A&lt;/span&gt; KUBE-SVC-73JNOU5FOFXIWZLE &lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="nt"&gt;-s&lt;/span&gt; 10.244.0.0/16 &lt;span class="nt"&gt;-d&lt;/span&gt; 10.96.111.7/32 &lt;span class="nt"&gt;-p&lt;/span&gt; tcp &lt;span class="nt"&gt;-m&lt;/span&gt; comment &lt;span class="nt"&gt;--comment&lt;/span&gt; &lt;span class="s2"&gt;"default/demo cluster IP"&lt;/span&gt; &lt;span class="nt"&gt;-m&lt;/span&gt; tcp &lt;span class="nt"&gt;--dport&lt;/span&gt; 80 &lt;span class="nt"&gt;-j&lt;/span&gt; KUBE-MARK-MASQ
&lt;span class="nt"&gt;-A&lt;/span&gt; KUBE-SVC-73JNOU5FOFXIWZLE &lt;span class="nt"&gt;-m&lt;/span&gt; comment &lt;span class="nt"&gt;--comment&lt;/span&gt; &lt;span class="s2"&gt;"default/demo -&amp;gt; 10.244.0.6:80"&lt;/span&gt; &lt;span class="nt"&gt;-m&lt;/span&gt; statistic &lt;span class="nt"&gt;--mode&lt;/span&gt; random &lt;span class="nt"&gt;--probability&lt;/span&gt; 0.50000000000 &lt;span class="nt"&gt;-j&lt;/span&gt; KUBE-SEP-4BM33MODP4BKIE3G
&lt;span class="nt"&gt;-A&lt;/span&gt; KUBE-SVC-73JNOU5FOFXIWZLE &lt;span class="nt"&gt;-m&lt;/span&gt; comment &lt;span class="nt"&gt;--comment&lt;/span&gt; &lt;span class="s2"&gt;"default/demo -&amp;gt; 10.244.0.7:80"&lt;/span&gt; &lt;span class="nt"&gt;-j&lt;/span&gt; KUBE-SEP-C7KHQYF2QRUY23U4
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The KUBE-SVC chain selects the backend. The first line — &lt;code&gt;! -s 10.244.0.0/16&lt;/code&gt; — handles masquerading for traffic coming from outside the Pod CIDR; it's not part of backend selection. Lines 2 and 3 are the actual selection: the comment shows &lt;code&gt;default/demo -&amp;gt; &amp;lt;Pod IP&amp;gt;:80&lt;/code&gt; and &lt;code&gt;--probability&lt;/code&gt; sets the distribution ratio. The &lt;code&gt;-j KUBE-SEP-xxx&lt;/code&gt; at the end is the jump to the chain that executes the DNAT:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;iptables-save &lt;span class="nt"&gt;-t&lt;/span&gt; nat | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s2"&gt;"KUBE-SEP-4BM33MODP4BKIE3G|KUBE-SEP-C7KHQYF2QRUY23U4"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nt"&gt;-A&lt;/span&gt; KUBE-SEP-4BM33MODP4BKIE3G &lt;span class="nt"&gt;-p&lt;/span&gt; tcp &lt;span class="nt"&gt;-m&lt;/span&gt; comment &lt;span class="nt"&gt;--comment&lt;/span&gt; &lt;span class="s2"&gt;"default/demo"&lt;/span&gt; &lt;span class="nt"&gt;-m&lt;/span&gt; tcp &lt;span class="nt"&gt;-j&lt;/span&gt; DNAT &lt;span class="nt"&gt;--to-destination&lt;/span&gt; 10.244.0.6:80
&lt;span class="nt"&gt;-A&lt;/span&gt; KUBE-SEP-C7KHQYF2QRUY23U4 &lt;span class="nt"&gt;-p&lt;/span&gt; tcp &lt;span class="nt"&gt;-m&lt;/span&gt; comment &lt;span class="nt"&gt;--comment&lt;/span&gt; &lt;span class="s2"&gt;"default/demo"&lt;/span&gt; &lt;span class="nt"&gt;-m&lt;/span&gt; tcp &lt;span class="nt"&gt;-j&lt;/span&gt; DNAT &lt;span class="nt"&gt;--to-destination&lt;/span&gt; 10.244.0.7:80
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;--to-destination&lt;/code&gt; is the backend Pod IP and port.&lt;/p&gt;

&lt;p&gt;Summary of how to read the forwarding table in a kube-proxy environment:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;ClusterIP: the &lt;code&gt;-A KUBE-SERVICES -d&lt;/code&gt; line (confirm the Service name in the comment)&lt;/li&gt;
&lt;li&gt;Backend Pod IP: &lt;code&gt;--to-destination&lt;/code&gt; in the corresponding KUBE-SEP chain&lt;/li&gt;
&lt;li&gt;Weight: &lt;code&gt;--probability&lt;/code&gt; in the KUBE-SVC chain&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Cilium
&lt;/h3&gt;

&lt;p&gt;First, get the ClusterIP from &lt;code&gt;kubectl get svc&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl get svc demo
&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   TYPE        CLUSTER-IP      EXTERNAL-IP   PORT(S)   AGE
demo   ClusterIP   10.96.24.124    &amp;lt;none&amp;gt;        80/TCP    4s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;ClusterIP is &lt;code&gt;10.96.24.124&lt;/code&gt;. &lt;code&gt;cilium-dbg service list -o json&lt;/code&gt; shows the frontend (ClusterIP), Service name, and backend Pod IPs all at once:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl &lt;span class="nt"&gt;-n&lt;/span&gt; kube-system &lt;span class="nb"&gt;exec &lt;/span&gt;ds/cilium &lt;span class="nt"&gt;--&lt;/span&gt; cilium-dbg service list &lt;span class="nt"&gt;-o&lt;/span&gt; json &lt;span class="se"&gt;\&lt;/span&gt;
| jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.[] | [
    (.spec.id | tostring),
    (.spec["frontend-address"].ip + ":" + (.spec["frontend-address"].port|tostring)),
    .spec.flags.type,
    (.spec.flags.namespace + "/" + .spec.flags.name),
    (.spec["backend-addresses"] // [] | map(.ip + ":" + (.port|tostring)) | join(" "))
  ] | @tsv'&lt;/span&gt; | column &lt;span class="nt"&gt;-t&lt;/span&gt;
&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;1   10.96.0.1:443      ClusterIP  default/kubernetes       172.18.0.2:6443
5   10.96.0.10:53      ClusterIP  kube-system/kube-dns     10.0.0.88:53     10.0.0.192:53
12  10.96.24.124:80    ClusterIP  default/demo             10.0.0.40:80     10.0.0.8:80
2   0.0.0.0:30080      NodePort   default/demo-nodeport    10.0.0.113:80    10.0.0.195:80
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;10.96.24.124&lt;/code&gt; from &lt;code&gt;kubectl get svc&lt;/code&gt; matches ID 12. The last column shows the backends: &lt;code&gt;10.0.0.40:80&lt;/code&gt; and &lt;code&gt;10.0.0.8:80&lt;/code&gt;. The three-hop walk through KUBE-SERVICES → KUBE-SVC → KUBE-SEP in kube-proxy fits in a single line here.&lt;/p&gt;

&lt;p&gt;In a multi-node cluster, &lt;code&gt;kubectl -n kube-system exec ds/cilium&lt;/code&gt; execs into whichever Pod in the DaemonSet gets scheduled. In a multi-node cluster you only see the local state of that agent. The same applies to &lt;code&gt;hubble observe&lt;/code&gt; flows — traffic on other nodes won't appear. If you know which node your target Pod is on, check &lt;code&gt;kubectl get pod -n kube-system -o wide | grep cilium&lt;/code&gt; for that node's cilium Pod name and exec into it explicitly with &lt;code&gt;kubectl -n kube-system exec &amp;lt;PodName&amp;gt;&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Packet tracing
&lt;/h2&gt;

&lt;p&gt;Comparing how far &lt;code&gt;tcpdump&lt;/code&gt; gets you when chasing ClusterIP traffic in each environment.&lt;/p&gt;

&lt;h3&gt;
  
  
  kube-proxy
&lt;/h3&gt;

&lt;p&gt;Running &lt;code&gt;tcpdump&lt;/code&gt; on the node and filtering on the ClusterIP captured packets. DNAT happened in netfilter, so there was a stretch before the rewrite where packets actually carried the ClusterIP as their destination. That "bracket the DNAT with two tcpdump captures" technique only worked because of that structure.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl debug node/&amp;lt;node&amp;gt; &lt;span class="nt"&gt;-it&lt;/span&gt; &lt;span class="nt"&gt;--image&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;nicolaka/netshoot &lt;span class="nt"&gt;--&lt;/span&gt; tcpdump &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; any &lt;span class="s2"&gt;"host 10.96.104.4"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;tcpdump: data &lt;span class="nb"&gt;link type &lt;/span&gt;LINUX_SLL2
tcpdump: verbose output suppressed, use &lt;span class="nt"&gt;-v&lt;/span&gt;&lt;span class="o"&gt;[&lt;/span&gt;v]... &lt;span class="k"&gt;for &lt;/span&gt;full protocol decode
listening on any, link-type LINUX_SLL2 &lt;span class="o"&gt;(&lt;/span&gt;Linux cooked v2&lt;span class="o"&gt;)&lt;/span&gt;, snapshot length 262144 bytes
11:38:19.980617 vethc1bb8f9e In  IP 10.244.0.5.54462 &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; 10.96.104.4.80: Flags &lt;span class="o"&gt;[&lt;/span&gt;S], &lt;span class="nb"&gt;seq &lt;/span&gt;3530419686, win 64240, options &lt;span class="o"&gt;[&lt;/span&gt;mss 1460,sackOK,TS val 4270529724 ecr 0,nop,wscale 10], length 0
11:38:19.980665 vethc1bb8f9e Out IP 10.96.104.4.80 &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; 10.244.0.5.54462: Flags &lt;span class="o"&gt;[&lt;/span&gt;S.], &lt;span class="nb"&gt;seq &lt;/span&gt;2901899611, ack 3530419687, win 65160, options &lt;span class="o"&gt;[&lt;/span&gt;mss 1460,sackOK,TS val 4170938121 ecr 4270529724,nop,wscale 10], length 0
11:38:19.980675 vethc1bb8f9e In  IP 10.244.0.5.54462 &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; 10.96.104.4.80: Flags &lt;span class="o"&gt;[&lt;/span&gt;.], ack 1, win 63, options &lt;span class="o"&gt;[&lt;/span&gt;nop,nop,TS val 4270529724 ecr 4170938121], length 0
11:38:19.980726 vethc1bb8f9e In  IP 10.244.0.5.54462 &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; 10.96.104.4.80: Flags &lt;span class="o"&gt;[&lt;/span&gt;P.], &lt;span class="nb"&gt;seq &lt;/span&gt;1:76, ack 1, win 63, options &lt;span class="o"&gt;[&lt;/span&gt;nop,nop,TS val 4270529724 ecr 4170938121], length 75: HTTP: GET / HTTP/1.1
11:38:19.980741 vethc1bb8f9e Out IP 10.96.104.4.80 &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; 10.244.0.5.54462: Flags &lt;span class="o"&gt;[&lt;/span&gt;.], ack 76, win 64, options &lt;span class="o"&gt;[&lt;/span&gt;nop,nop,TS val 4170938121 ecr 4270529724], length 0
11:38:19.983523 vethc1bb8f9e Out IP 10.96.104.4.80 &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; 10.244.0.5.54462: Flags &lt;span class="o"&gt;[&lt;/span&gt;P.], &lt;span class="nb"&gt;seq &lt;/span&gt;1:1545, ack 76, win 64, options &lt;span class="o"&gt;[&lt;/span&gt;nop,nop,TS val 4170938124 ecr 4270529724], length 1544: HTTP: HTTP/1.1 200 OK
11:38:19.983538 vethc1bb8f9e In  IP 10.244.0.5.54462 &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; 10.96.104.4.80: Flags &lt;span class="o"&gt;[&lt;/span&gt;.], ack 1545, win 67, options &lt;span class="o"&gt;[&lt;/span&gt;nop,nop,TS val 4170938124 ecr 4270529724], length 0
11:38:19.983641 vethc1bb8f9e In  IP 10.244.0.5.54462 &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; 10.96.104.4.80: Flags &lt;span class="o"&gt;[&lt;/span&gt;.], &lt;span class="nb"&gt;seq &lt;/span&gt;76, ack 1545, win 67, options &lt;span class="o"&gt;[&lt;/span&gt;nop,nop,TS val 4270529727 ecr 4170938124], length 0
11:38:19.983883 vethc1bb8f9e Out IP 10.96.104.4.80 &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; 10.244.0.5.54462: Flags &lt;span class="o"&gt;[&lt;/span&gt;F.], &lt;span class="nb"&gt;seq &lt;/span&gt;1545, ack 77, win 64, options &lt;span class="o"&gt;[&lt;/span&gt;nop,nop,TS val 4170938124 ecr 4270529727], length 0
11:38:19.983898 vethc1bb8f9e In  IP 10.244.0.5.54462 &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; 10.96.104.4.80: Flags &lt;span class="o"&gt;[&lt;/span&gt;.], ack 1546, win 67, options &lt;span class="o"&gt;[&lt;/span&gt;nop,nop,TS val 4270529727 ecr 4170938124], length 0

10 packets captured
10 packets received by filter
0 packets dropped by kernel
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Packets to the ClusterIP (&lt;code&gt;10.96.104.4&lt;/code&gt;) are captured. SYN through FIN, all there. DNAT happens in netfilter after this, so the packets with the ClusterIP destination exist in this stretch.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;conntrack -L&lt;/code&gt; also shows the DNAT in a single entry:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl debug node/&amp;lt;node&amp;gt; &lt;span class="nt"&gt;-it&lt;/span&gt; &lt;span class="nt"&gt;--image&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;nicolaka/netshoot &lt;span class="nt"&gt;--&lt;/span&gt; conntrack &lt;span class="nt"&gt;-L&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="s2"&gt;"10.96.104.4"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;tcp  6 118 TIME_WAIT &lt;span class="nv"&gt;src&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;10.244.0.5 &lt;span class="nv"&gt;dst&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;10.96.104.4 &lt;span class="nv"&gt;sport&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;54462 &lt;span class="nv"&gt;dport&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;80 &lt;span class="nv"&gt;src&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;10.244.0.6 &lt;span class="nv"&gt;dst&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;10.244.0.5 &lt;span class="nv"&gt;sport&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;80 &lt;span class="nv"&gt;dport&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;54462 &lt;span class="o"&gt;[&lt;/span&gt;ASSURED] &lt;span class="nv"&gt;mark&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;0 &lt;span class="nv"&gt;use&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;dst=10.96.104.4&lt;/code&gt; is the ClusterIP (outbound direction); &lt;code&gt;src=10.244.0.6&lt;/code&gt; is the backend Pod IP (return direction). Before and after the DNAT, on one line.&lt;/p&gt;

&lt;h3&gt;
  
  
  Cilium
&lt;/h3&gt;

&lt;p&gt;Same experiment on the Cilium cluster. Run &lt;code&gt;tcpdump&lt;/code&gt; while curling ClusterIP &lt;code&gt;10.96.89.117&lt;/code&gt; from the client Pod:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl debug node/&amp;lt;node&amp;gt; &lt;span class="nt"&gt;-it&lt;/span&gt; &lt;span class="nt"&gt;--image&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;nicolaka/netshoot &lt;span class="nt"&gt;--&lt;/span&gt; tcpdump &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; any &lt;span class="s2"&gt;"host 10.96.89.117"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;tcpdump: data &lt;span class="nb"&gt;link type &lt;/span&gt;LINUX_SLL2
tcpdump: verbose output suppressed, use &lt;span class="nt"&gt;-v&lt;/span&gt;&lt;span class="o"&gt;[&lt;/span&gt;v]... &lt;span class="k"&gt;for &lt;/span&gt;full protocol decode
listening on any, link-type LINUX_SLL2 &lt;span class="o"&gt;(&lt;/span&gt;Linux cooked v2&lt;span class="o"&gt;)&lt;/span&gt;, snapshot length 262144 bytes
0 packets captured
0 packets received by filter
0 packets dropped by kernel
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The curl succeeds. Connectivity is there. The empty capture is not a filter mistake — there are simply no packets with that ClusterIP as their destination.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;connect()&lt;/code&gt; rewrites the destination to a Pod IP before any packet is created, so whatever hits the network already has the Pod IP. The ClusterIP never reaches the network layer.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;conntrack -L&lt;/code&gt; filtered on the ClusterIP also returns nothing. The conntrack table contains only Pod-IP entries, but since no packets with the ClusterIP pass through netfilter, there's nothing to match.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl debug node/&amp;lt;node&amp;gt; &lt;span class="nt"&gt;-it&lt;/span&gt; &lt;span class="nt"&gt;--image&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;nicolaka/netshoot &lt;span class="nt"&gt;--&lt;/span&gt; conntrack &lt;span class="nt"&gt;-L&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="s2"&gt;"10.96.89.117"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No output.&lt;/p&gt;

&lt;h4&gt;
  
  
  Seeing the ClusterIP translation with Hubble
&lt;/h4&gt;

&lt;p&gt;tcpdump can't show the ClusterIP. So how do you trace ClusterIP traffic in a Cilium environment? Hubble is Cilium's observability component — it surfaces eBPF events as flow records. The test repo's &lt;code&gt;scripts/setup-cilium.sh&lt;/code&gt; installs with Hubble Relay enabled. Curl the ClusterIP from the client Pod and then run &lt;code&gt;hubble observe&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl &lt;span class="nt"&gt;-n&lt;/span&gt; kube-system &lt;span class="nb"&gt;exec &lt;/span&gt;ds/cilium &lt;span class="nt"&gt;--&lt;/span&gt; hubble observe &lt;span class="nt"&gt;--last&lt;/span&gt; 30 | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s2"&gt;"client|demo"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;--last 30&lt;/code&gt; outputs the 30 most recent flows.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Sep  7 07:23:31.106: default/client (ID:52940) &amp;lt;&amp;gt; 10.96.89.117:80 (world) pre-xlate-fwd TRACED (TCP)
Sep  7 07:23:31.106: default/client (ID:52940) &amp;lt;&amp;gt; default/demo-864f5f87b9-rvr95:80 (ID:10947) post-xlate-fwd TRANSLATED (TCP)
Sep  7 07:23:31.106: default/client:52852 (ID:52940) -&amp;gt; default/demo-864f5f87b9-rvr95:80 (ID:10947) to-endpoint FORWARDED (TCP Flags: SYN)
Sep  7 07:23:31.106: default/client:52852 (ID:52940) &amp;lt;- default/demo-864f5f87b9-rvr95:80 (ID:10947) to-endpoint FORWARDED (TCP Flags: SYN, ACK)
Sep  7 07:23:31.106: default/client:52852 (ID:52940) -&amp;gt; default/demo-864f5f87b9-rvr95:80 (ID:10947) to-endpoint FORWARDED (TCP Flags: ACK)
Sep  7 07:23:31.106: default/client:52852 (ID:52940) -&amp;gt; default/demo-864f5f87b9-rvr95:80 (ID:10947) to-endpoint FORWARDED (TCP Flags: ACK, PSH)
Sep  7 07:23:31.110: default/client:52852 (ID:52940) &amp;lt;- default/demo-864f5f87b9-rvr95:80 (ID:10947) to-endpoint FORWARDED (TCP Flags: ACK, PSH)
Sep  7 07:23:31.110: default/client:52852 (ID:52940) -&amp;gt; default/demo-864f5f87b9-rvr95:80 (ID:10947) to-endpoint FORWARDED (TCP Flags: ACK, FIN)
Sep  7 07:23:31.110: default/client:52852 (ID:52940) &amp;lt;- default/demo-864f5f87b9-rvr95:80 (ID:10947) to-endpoint FORWARDED (TCP Flags: ACK, FIN)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Focus on the first two lines. &lt;code&gt;pre-xlate-fwd TRACED&lt;/code&gt; is the pre-translation flow, destination still recorded as &lt;code&gt;10.96.89.117:80&lt;/code&gt; — the ClusterIP from &lt;code&gt;kubectl get svc demo&lt;/code&gt;. &lt;code&gt;post-xlate-fwd TRANSLATED&lt;/code&gt; is after, destination now the actual Pod &lt;code&gt;demo-864f5f87b9-rvr95:80&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;This confirms why tcpdump couldn't see the ClusterIP: the translation is done before the packet reaches the network. Hubble records both sides of the translation as eBPF events, so you can read "which ClusterIP became which Pod" directly.&lt;/p&gt;

&lt;h4&gt;
  
  
  NodePort is visible to tcpdump
&lt;/h4&gt;

&lt;p&gt;Try NodePort on the same cluster and the result is different. Send a curl from outside the cluster to the NodePort (&lt;code&gt;172.18.0.2:30080&lt;/code&gt;) while running tcpdump for that port on the node:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl debug node/&amp;lt;node&amp;gt; &lt;span class="nt"&gt;-it&lt;/span&gt; &lt;span class="nt"&gt;--image&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;nicolaka/netshoot &lt;span class="nt"&gt;--&lt;/span&gt; tcpdump &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; any &lt;span class="s2"&gt;"port 30080"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;tcpdump: data &lt;span class="nb"&gt;link type &lt;/span&gt;LINUX_SLL2
tcpdump: verbose output suppressed, use &lt;span class="nt"&gt;-v&lt;/span&gt;&lt;span class="o"&gt;[&lt;/span&gt;v]... &lt;span class="k"&gt;for &lt;/span&gt;full protocol decode
listening on any, link-type LINUX_SLL2 &lt;span class="o"&gt;(&lt;/span&gt;Linux cooked v2&lt;span class="o"&gt;)&lt;/span&gt;, snapshot length 262144 bytes
07:23:24.736193 eth0  In  IP 172.18.0.1.52306 &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; 172.18.0.2.30080: Flags &lt;span class="o"&gt;[&lt;/span&gt;S], &lt;span class="nb"&gt;seq &lt;/span&gt;3175489113, win 64240, options &lt;span class="o"&gt;[&lt;/span&gt;mss 1460,sackOK,TS val 194961069 ecr 0,nop,wscale 10], length 0
07:23:24.736306 eth0  Out IP 172.18.0.2.30080 &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; 172.18.0.1.52306: Flags &lt;span class="o"&gt;[&lt;/span&gt;S.], &lt;span class="nb"&gt;seq &lt;/span&gt;2271513508, ack 3175489114, win 64308, options &lt;span class="o"&gt;[&lt;/span&gt;mss 1410,sackOK,TS val 855246694 ecr 194961069,nop,wscale 10], length 0
07:23:24.736333 eth0  In  IP 172.18.0.1.52306 &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; 172.18.0.2.30080: Flags &lt;span class="o"&gt;[&lt;/span&gt;.], ack 1, win 63, options &lt;span class="o"&gt;[&lt;/span&gt;nop,nop,TS val 194961069 ecr 855246694], length 0
07:23:24.736421 eth0  In  IP 172.18.0.1.52306 &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; 172.18.0.2.30080: Flags &lt;span class="o"&gt;[&lt;/span&gt;P.], &lt;span class="nb"&gt;seq &lt;/span&gt;1:80, ack 1, win 63, options &lt;span class="o"&gt;[&lt;/span&gt;nop,nop,TS val 194961069 ecr 855246694], length 79
...
10 packets captured
10 packets received by filter
0 packets dropped by kernel
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;10 packets captured. The difference is the path. NodePort packets arrive from outside the cluster as real packets, so they're not subject to socket LB. Processing happens at the tc layer (TCX), after the NIC, which is why tcpdump sees them.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;cilium-dbg status --verbose&lt;/code&gt; shows which layers are active:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl &lt;span class="nt"&gt;-n&lt;/span&gt; kube-system &lt;span class="nb"&gt;exec &lt;/span&gt;ds/cilium &lt;span class="nt"&gt;-c&lt;/span&gt; cilium-agent &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  cilium-dbg status &lt;span class="nt"&gt;--verbose&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s2"&gt;"Attach Mode|Socket LB|XDP Acceleration"&lt;/span&gt;
&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;Attach Mode:            TCX
  Socket LB:              Enabled
  XDP Acceleration:       Disabled
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;Socket LB: Enabled&lt;/code&gt; means Cilium rewrites destinations at the socket layer (the &lt;code&gt;connect()&lt;/code&gt; hook). ClusterIP traffic gets translated to a Pod IP there, before any packet is created — which is why tcpdump never sees it.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;XDP Acceleration: Disabled&lt;/code&gt; means NIC-driver-level (XDP) processing is off. XDP requires a compatible NIC, so it's disabled in kind by default. Instead, as &lt;code&gt;Attach Mode: TCX&lt;/code&gt; shows, BPF programs are attached at the tc (Traffic Control) layer. NodePort traffic is real packets from outside the cluster, not subject to socket LB, so it gets processed at that tc layer and passes through the NIC — making it visible to tcpdump.&lt;/p&gt;

&lt;h3&gt;
  
  
  Packet tracing summary
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;kube-proxy&lt;/th&gt;
&lt;th&gt;Cilium&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;tcpdump on ClusterIP&lt;/td&gt;
&lt;td&gt;captures (packet exists before DNAT)&lt;/td&gt;
&lt;td&gt;nothing (translated at connect())&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;conntrack on ClusterIP&lt;/td&gt;
&lt;td&gt;captures (DNAT before/after on one line)&lt;/td&gt;
&lt;td&gt;nothing (doesn't go through netfilter)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;tcpdump on NodePort&lt;/td&gt;
&lt;td&gt;captures&lt;/td&gt;
&lt;td&gt;captures (processed at tc layer)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;translation visibility&lt;/td&gt;
&lt;td&gt;tcpdump before/after DNAT&lt;/td&gt;
&lt;td&gt;Hubble pre-xlate-fwd / post-xlate-fwd&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  E2E debugging flow
&lt;/h2&gt;

&lt;p&gt;Starting from "Service is unreachable," walk through the investigation steps for a Cilium environment. By the end of this section, you'll have a clear path from symptom to cause.&lt;/p&gt;

&lt;p&gt;The test repo's &lt;code&gt;manifests/broken/&lt;/code&gt; has two failure patterns: &lt;code&gt;svc-wrong-selector.yaml&lt;/code&gt; (a Service with a selector that matches no Pods) and &lt;code&gt;netpol-deny.yaml&lt;/code&gt; (a NetworkPolicy that blocks the client). The full debug script is in &lt;code&gt;tests/debug-cilium.sh&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Case A — selector mismatch
&lt;/h3&gt;

&lt;p&gt;Apply &lt;code&gt;manifests/broken/svc-wrong-selector.yaml&lt;/code&gt;. The &lt;code&gt;demo-broken&lt;/code&gt; Service has selector &lt;code&gt;app: demo-v2&lt;/code&gt;, but every Pod in the cluster has the label &lt;code&gt;app: demo&lt;/code&gt;. Nothing matches.&lt;/p&gt;

&lt;p&gt;Check the ClusterIP first:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl get svc demo-broken
&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          TYPE        CLUSTER-IP      EXTERNAL-IP   PORT(S)   AGE
demo-broken   ClusterIP   10.96.171.11    &amp;lt;none&amp;gt;        80/TCP    4s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Send a curl from the client Pod:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; default client &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;--max-time&lt;/span&gt; 5 &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s1"&gt;'%{http_code} (%{time_total}s)'&lt;/span&gt; http://10.96.171.11/
&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;000 (0.000063s)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;%{http_code}&lt;/code&gt; returning &lt;code&gt;000&lt;/code&gt; means no HTTP response. &lt;code&gt;%{time_total}&lt;/code&gt; shows it returned immediately. When a Service has zero backends, Cilium's eBPF program rejects the connection right away — same speed as kube-proxy's &lt;code&gt;REJECT --reject-with icmp-port-unreachable&lt;/code&gt;.&lt;/p&gt;

&lt;h4&gt;
  
  
  Step 1 — check Endpoints
&lt;/h4&gt;

&lt;p&gt;Same command as before Cilium:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl get endpoints demo-broken
&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          ENDPOINTS   AGE
demo-broken   &amp;lt;none&amp;gt;      8s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;&amp;lt;none&amp;gt;&lt;/code&gt; means no Pods are registered as backends. Compare against a working Service:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl get endpoints demo
&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   ENDPOINTS                     AGE
demo   10.0.0.117:80,10.0.0.119:80   34s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;kubectl get endpoints&lt;/code&gt; works exactly the same as in a kube-proxy environment (from 1.33, &lt;code&gt;kubectl get endpointslices&lt;/code&gt; is preferred).&lt;/p&gt;

&lt;h4&gt;
  
  
  Step 2 — check Cilium's internal state
&lt;/h4&gt;

&lt;p&gt;The JSON output includes the Service name:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl &lt;span class="nt"&gt;-n&lt;/span&gt; kube-system &lt;span class="nb"&gt;exec &lt;/span&gt;ds/cilium &lt;span class="nt"&gt;--&lt;/span&gt; cilium-dbg service list &lt;span class="nt"&gt;-o&lt;/span&gt; json &lt;span class="se"&gt;\&lt;/span&gt;
| jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.[] | [
    (.spec.id | tostring),
    (.spec["frontend-address"].ip + ":" + (.spec["frontend-address"].port|tostring)),
    .spec.flags.type,
    (.spec.flags.namespace + "/" + .spec.flags.name),
    (.spec["backend-addresses"] | length | tostring)
  ] | @tsv'&lt;/span&gt; | column &lt;span class="nt"&gt;-t&lt;/span&gt;
&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;12  10.96.171.11:80   ClusterIP  default/demo-broken  0
8   10.96.249.121:80  ClusterIP  default/demo         2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The last column is backend count. &lt;code&gt;demo&lt;/code&gt; (ID 8) has 2; &lt;code&gt;demo-broken&lt;/code&gt; (ID 12) has 0. A backend count of 0 indicates endpoint shortage. Cilium registers the Service but has nowhere to forward connections.&lt;/p&gt;

&lt;p&gt;In a kube-proxy environment, the same situation shows up in the filter table with a comment:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl debug node/&amp;lt;node&amp;gt; &lt;span class="nt"&gt;-it&lt;/span&gt; &lt;span class="nt"&gt;--image&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;nicolaka/netshoot &lt;span class="nt"&gt;--&lt;/span&gt; iptables-save &lt;span class="nt"&gt;-t&lt;/span&gt; filter | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="s2"&gt;"10.96.171.11"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nt"&gt;-A&lt;/span&gt; KUBE-SERVICES &lt;span class="nt"&gt;-d&lt;/span&gt; 10.96.171.11/32 &lt;span class="nt"&gt;-p&lt;/span&gt; tcp &lt;span class="nt"&gt;-m&lt;/span&gt; comment &lt;span class="nt"&gt;--comment&lt;/span&gt; &lt;span class="s2"&gt;"default/demo-broken has no endpoints"&lt;/span&gt; &lt;span class="nt"&gt;-m&lt;/span&gt; tcp &lt;span class="nt"&gt;--dport&lt;/span&gt; 80 &lt;span class="nt"&gt;-j&lt;/span&gt; REJECT &lt;span class="nt"&gt;--reject-with&lt;/span&gt; icmp-port-unreachable
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;has no endpoints&lt;/code&gt; is right there in the comment. Run that same command on the Cilium cluster and you get nothing. For endpoint-shortage cases (as opposed to NetworkPolicy), iptables stops explaining itself. Use &lt;code&gt;cilium-dbg service list -o json | jq&lt;/code&gt; backend count instead. 0 means no endpoints.&lt;/p&gt;

&lt;h4&gt;
  
  
  Root cause
&lt;/h4&gt;

&lt;p&gt;The most common reason Endpoints is empty is a selector mismatch between the Service and its Pods. Compare them directly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl get svc demo-broken &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nv"&gt;jsonpath&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'{.spec.selector}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"app"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"demo-v2"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl get pod &lt;span class="nt"&gt;-l&lt;/span&gt; &lt;span class="nv"&gt;app&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;demo &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nv"&gt;jsonpath&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'{range .items[*]}{.metadata.name}  {.metadata.labels}{"\n"}{end}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;demo&lt;/span&gt;&lt;span class="mi"&gt;-864&lt;/span&gt;&lt;span class="err"&gt;f&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="err"&gt;f&lt;/span&gt;&lt;span class="mi"&gt;87&lt;/span&gt;&lt;span class="err"&gt;b&lt;/span&gt;&lt;span class="mi"&gt;9&lt;/span&gt;&lt;span class="err"&gt;-dccbz&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"app"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"demo"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"pod-template-hash"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"864f5f87b9"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;demo&lt;/span&gt;&lt;span class="mi"&gt;-864&lt;/span&gt;&lt;span class="err"&gt;f&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="err"&gt;f&lt;/span&gt;&lt;span class="mi"&gt;87&lt;/span&gt;&lt;span class="err"&gt;b&lt;/span&gt;&lt;span class="mi"&gt;9&lt;/span&gt;&lt;span class="err"&gt;-zzngq&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"app"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"demo"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"pod-template-hash"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"864f5f87b9"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The selector points to &lt;code&gt;app: demo-v2&lt;/code&gt;; the Pods carry &lt;code&gt;app: demo&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Case B — NetworkPolicy deny
&lt;/h3&gt;

&lt;p&gt;Apply &lt;code&gt;manifests/broken/netpol-deny.yaml&lt;/code&gt;. The &lt;code&gt;deny-client-to-demo&lt;/code&gt; NetworkPolicy allows Ingress to Pods with &lt;code&gt;app: demo&lt;/code&gt; only from Pods labeled &lt;code&gt;app: not-client&lt;/code&gt;. The &lt;code&gt;client&lt;/code&gt; Pod doesn't match, so all connections to &lt;code&gt;demo&lt;/code&gt; are denied.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; default client &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;--max-time&lt;/span&gt; 5 &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s1"&gt;'%{http_code} (%{time_total}s)'&lt;/span&gt; http://10.96.249.121/
&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;000 (5.001378s)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same symptoms as Case A — but &lt;code&gt;%{time_total}&lt;/code&gt; is 5 seconds. Case A (no backends) returned in 0.000063 seconds. Here, &lt;code&gt;--max-time&lt;/code&gt; was exhausted. Packets are reaching the destination; nothing is sending a response back. Endpoints tell a different story.&lt;/p&gt;

&lt;h4&gt;
  
  
  Step 1 — check Endpoints
&lt;/h4&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl get endpoints demo
&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   ENDPOINTS                     AGE
demo   10.0.0.117:80,10.0.0.119:80   34s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Endpoints are healthy. That's the branch point from Case A. Empty Endpoints → check selector match and Pod readiness. Endpoints present → keep going.&lt;/p&gt;

&lt;h4&gt;
  
  
  Step 2 — confirm Cilium sees active backends
&lt;/h4&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl &lt;span class="nt"&gt;-n&lt;/span&gt; kube-system &lt;span class="nb"&gt;exec &lt;/span&gt;ds/cilium &lt;span class="nt"&gt;--&lt;/span&gt; cilium-dbg service list &lt;span class="nt"&gt;-o&lt;/span&gt; json &lt;span class="se"&gt;\&lt;/span&gt;
| jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.[] | select(.spec["frontend-address"].ip == "10.96.249.121") | [
    (.spec.id | tostring),
    (.spec["frontend-address"].ip + ":" + (.spec["frontend-address"].port|tostring)),
    .spec.flags.type,
    (.spec.flags.namespace + "/" + .spec.flags.name),
    (.spec["backend-addresses"] | length | tostring)
  ] | @tsv'&lt;/span&gt; | column &lt;span class="nt"&gt;-t&lt;/span&gt;
&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;8  10.96.249.121:80  ClusterIP  default/demo  2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Backend count is 2; Cilium knows about them. Verify the eBPF map itself agrees with what the agent reports:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl &lt;span class="nt"&gt;-n&lt;/span&gt; kube-system &lt;span class="nb"&gt;exec &lt;/span&gt;ds/cilium &lt;span class="nt"&gt;--&lt;/span&gt; cilium-dbg bpf lb list | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="s2"&gt;"10.96.249.121"&lt;/span&gt;
&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;10.96.249.121:80/TCP (0)    0.0.0.0:0 (8) (0) [ClusterIP, non-routable]
10.96.249.121:80/TCP (1)    10.0.0.52:80/TCP (8) (1)
10.96.249.121:80/TCP (2)    10.0.0.167:80/TCP (8) (2)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;service list&lt;/code&gt; is the agent's view of what it configured. &lt;code&gt;bpf lb list&lt;/code&gt; reads the eBPF maps directly. They usually agree; when they diverge, the agent reports healthy backends the datapath never received. Here they match — the forwarding table is correct. Something else is dropping the packets.&lt;/p&gt;

&lt;h4&gt;
  
  
  Step 3 — check Hubble for packet drops
&lt;/h4&gt;

&lt;p&gt;Cilium enforces NetworkPolicy directly in eBPF, so nothing shows up in iptables. Use Hubble:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; default client &lt;span class="nt"&gt;--&lt;/span&gt; curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;--max-time&lt;/span&gt; 5 http://10.96.249.121/ &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /dev/null
kubectl &lt;span class="nt"&gt;-n&lt;/span&gt; kube-system &lt;span class="nb"&gt;exec &lt;/span&gt;ds/cilium &lt;span class="nt"&gt;--&lt;/span&gt; hubble observe &lt;span class="nt"&gt;--verdict&lt;/span&gt; DROPPED &lt;span class="nt"&gt;--last&lt;/span&gt; 20 | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s2"&gt;"client|demo"&lt;/span&gt;
&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;Sep  9 08:07:46.753: default/client:51940 (ID:12800) &amp;lt;&amp;gt; default/demo-864f5f87b9-dccbz:80 (ID:41143) policy-verdict:none INGRESS DENIED (TCP Flags: SYN)
Sep  9 08:07:46.753: default/client:51940 (ID:12800) &amp;lt;&amp;gt; default/demo-864f5f87b9-dccbz:80 (ID:41143) Policy denied DROPPED (TCP Flags: SYN)
Sep  9 08:07:47.777: default/client:51940 (ID:12800) &amp;lt;&amp;gt; default/demo-864f5f87b9-dccbz:80 (ID:41143) policy-verdict:none INGRESS DENIED (TCP Flags: SYN)
Sep  9 08:07:47.777: default/client:51940 (ID:12800) &amp;lt;&amp;gt; default/demo-864f5f87b9-dccbz:80 (ID:41143) Policy denied DROPPED (TCP Flags: SYN)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;Policy denied DROPPED&lt;/code&gt;. A NetworkPolicy is blocking the connection. &lt;code&gt;policy-verdict:none INGRESS DENIED&lt;/code&gt; is the same verdict from a different angle. Either of these in &lt;code&gt;hubble observe --verdict DROPPED&lt;/code&gt; points to NetworkPolicy as the cause.&lt;/p&gt;

&lt;h4&gt;
  
  
  Root cause
&lt;/h4&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl get networkpolicy deny-client-to-demo &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nv"&gt;jsonpath&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'{.spec.ingress}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="nl"&gt;"from"&lt;/span&gt;&lt;span class="p"&gt;:[{&lt;/span&gt;&lt;span class="nl"&gt;"podSelector"&lt;/span&gt;&lt;span class="p"&gt;:{&lt;/span&gt;&lt;span class="nl"&gt;"matchLabels"&lt;/span&gt;&lt;span class="p"&gt;:{&lt;/span&gt;&lt;span class="nl"&gt;"app"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"not-client"&lt;/span&gt;&lt;span class="p"&gt;}}}]}]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Ingress is only allowed from Pods labeled &lt;code&gt;app: not-client&lt;/code&gt;. The &lt;code&gt;client&lt;/code&gt; Pod doesn't qualify.&lt;/p&gt;

&lt;h3&gt;
  
  
  Debug flow summary
&lt;/h3&gt;

&lt;p&gt;The two cases point to this sequence:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Run &lt;code&gt;kubectl get endpoints&lt;/code&gt; (or &lt;code&gt;kubectl get endpointslices&lt;/code&gt; from 1.33). &lt;code&gt;&amp;lt;none&amp;gt;&lt;/code&gt; → check selector match and Pod readiness.&lt;/li&gt;
&lt;li&gt;If Endpoints exist, run &lt;code&gt;cilium-dbg service list -o json | jq&lt;/code&gt; and check the backend count. 0 → Cilium doesn't see the backends.&lt;/li&gt;
&lt;li&gt;If backends exist but there's no connectivity, run &lt;code&gt;hubble observe --verdict DROPPED&lt;/code&gt;. &lt;code&gt;Policy denied&lt;/code&gt; → NetworkPolicy is the cause.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Tool mapping from kube-proxy to Cilium:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Old tool (kube-proxy)&lt;/th&gt;
&lt;th&gt;New tool (Cilium)&lt;/th&gt;
&lt;th&gt;What it shows&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;`iptables-save -t nat \&lt;/td&gt;
&lt;td&gt;grep KUBE-SERVICES`&lt;/td&gt;
&lt;td&gt;`cilium-dbg service list -o json \&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;{% raw %}`iptables-save -t filter \&lt;/td&gt;
&lt;td&gt;grep `&lt;/td&gt;
&lt;td&gt;backend count from `cilium-dbg service list -o json \&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;{% raw %}&lt;code&gt;conntrack -L&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;cilium-dbg bpf ct list global&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Connection tracking table&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;tcpdump&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;hubble observe&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Per-flow packet visibility (see "Packet tracing")&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;(no equivalent)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;hubble observe --verdict DROPPED&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;NetworkPolicy denials&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Wrap-up
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;iptables-save&lt;/code&gt; returning 0 lines was not a broken cluster. kube-proxy doesn't exist, so nothing writes forwarding rules there — they moved to eBPF maps.&lt;/p&gt;

&lt;p&gt;To read the forwarding table, use &lt;code&gt;cilium-dbg service list -o json | jq&lt;/code&gt;. One command gets you the ClusterIP, Service name, and backend Pod IPs. The three-hop KUBE-SERVICES → KUBE-SVC → KUBE-SEP walk collapses to one line.&lt;/p&gt;

&lt;p&gt;Parts of the troubleshooting workflow carry over unchanged. &lt;code&gt;kubectl get endpoints&lt;/code&gt; works fine in a Cilium environment. After that, check &lt;code&gt;cilium-dbg service list -o json | jq&lt;/code&gt; for backend count, and if backends are present but connectivity still fails, check &lt;code&gt;hubble observe --verdict DROPPED&lt;/code&gt; for NetworkPolicy denials. The &lt;code&gt;has no endpoints&lt;/code&gt; comment that kube-proxy wrote in the filter table is gone, but a backend count of 0 means the same thing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Appendix — iptables evaluation cost grows with endpoint count
&lt;/h2&gt;

&lt;p&gt;Measured while scaling &lt;code&gt;demo&lt;/code&gt; replicas from 1 to 16. The kube-proxy column shows the number of KUBE-SVC chain rules; the Cilium column shows LB map backend slots.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;replicas   kube-proxy SVC rules   Cilium backend slots
1          2                      1
2          3                      2
4          5                      4
8          9                      8
16         17                     16
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both grow linearly with endpoint count. The difference is lookup cost. The table shows equal growth in entry count; the asymmetry is in how those entries are evaluated per new connection.&lt;/p&gt;

&lt;p&gt;kube-proxy evaluates KUBE-SVC chain rules top to bottom to pick a backend. Each rule's &lt;code&gt;--probability&lt;/code&gt; check either hits or falls through to the next. When the last endpoint is selected, every rule before it was evaluated first. With 16 replicas, up to 16 evaluations happen. This only applies to the first packet of a new connection — subsequent packets are handled by conntrack and skip the rules entirely. The impact shows up in environments with high new-connection rates and many Services.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nt"&gt;-A&lt;/span&gt; KUBE-SVC-73JNOU5FOFXIWZLE &lt;span class="nt"&gt;-m&lt;/span&gt; comment &lt;span class="nt"&gt;--comment&lt;/span&gt; &lt;span class="s2"&gt;"default/demo -&amp;gt; 10.244.0.10:80"&lt;/span&gt; &lt;span class="nt"&gt;-m&lt;/span&gt; statistic &lt;span class="nt"&gt;--mode&lt;/span&gt; random &lt;span class="nt"&gt;--probability&lt;/span&gt; 0.06250000000 &lt;span class="nt"&gt;-j&lt;/span&gt; KUBE-SEP-...
&lt;span class="nt"&gt;-A&lt;/span&gt; KUBE-SVC-73JNOU5FOFXIWZLE &lt;span class="nt"&gt;-m&lt;/span&gt; comment &lt;span class="nt"&gt;--comment&lt;/span&gt; &lt;span class="s2"&gt;"default/demo -&amp;gt; 10.244.0.11:80"&lt;/span&gt; &lt;span class="nt"&gt;-m&lt;/span&gt; statistic &lt;span class="nt"&gt;--mode&lt;/span&gt; random &lt;span class="nt"&gt;--probability&lt;/span&gt; 0.06666666688 &lt;span class="nt"&gt;-j&lt;/span&gt; KUBE-SEP-...
...
&lt;span class="nt"&gt;-A&lt;/span&gt; KUBE-SVC-73JNOU5FOFXIWZLE &lt;span class="nt"&gt;-m&lt;/span&gt; comment &lt;span class="nt"&gt;--comment&lt;/span&gt; &lt;span class="s2"&gt;"default/demo -&amp;gt; 10.244.0.9:80"&lt;/span&gt;  &lt;span class="nt"&gt;-j&lt;/span&gt; KUBE-SEP-...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Cilium computes &lt;code&gt;random() % N&lt;/code&gt; to get a slot number and does one map lookup to find the backend. Slot 0 is the service master entry; slots 1+ are backends. With 16 Pods, it's still one lookup. This slot format is &lt;code&gt;cilium-dbg bpf lb list&lt;/code&gt; output — it reads the eBPF maps directly, not the agent's in-memory state.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl &lt;span class="nt"&gt;-n&lt;/span&gt; kube-system &lt;span class="nb"&gt;exec &lt;/span&gt;ds/cilium &lt;span class="nt"&gt;--&lt;/span&gt; cilium-dbg bpf lb list | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="s2"&gt;"10.96.210.178"&lt;/span&gt;
&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;10.96.210.178:80/TCP (0)    0.0.0.0:0 (8) (0) [ClusterIP, non-routable]
10.96.210.178:80/TCP (1)    10.0.0.207:80/TCP (8) (1)
10.96.210.178:80/TCP (2)    10.0.0.48:80/TCP (8) (2)
...
10.96.210.178:80/TCP (16)   10.0.0.187:80/TCP (8) (16)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



</description>
      <category>kubernetes</category>
      <category>networking</category>
      <category>ebpf</category>
      <category>cilium</category>
    </item>
    <item>
      <title>Migrating from ingress-nginx to Gateway API: a three-category approach to annotations</title>
      <dc:creator>Kazu</dc:creator>
      <pubDate>Tue, 08 Sep 2026 13:02:00 +0000</pubDate>
      <link>https://dev.to/shinagawa-web/migrating-from-ingress-nginx-to-gateway-api-a-three-category-approach-to-annotations-5fd</link>
      <guid>https://dev.to/shinagawa-web/migrating-from-ingress-nginx-to-gateway-api-a-three-category-approach-to-annotations-5fd</guid>
      <description>&lt;p&gt;&lt;a href="https://www.kubernetes.dev/blog/2025/11/12/ingress-nginx-retirement/" rel="noopener noreferrer"&gt;The Kubernetes project announced the retirement of ingress-nginx&lt;/a&gt;. ingress-nginx retires in March 2026. No more patches, no more updates. No successor was named — just a recommendation to migrate to Gateway API.&lt;/p&gt;

&lt;p&gt;The reason is straightforward: not enough maintainers. For years, effectively one or two people had kept the project running in their spare time. After the retirement announcement, there were plans to develop a successor controller (InGate) in collaboration with the Gateway API community, but no contributors came forward, and InGate was retired as well. No successor was named because none could be named.&lt;/p&gt;

&lt;p&gt;Existing deployments won't break overnight. Helm charts and container images remain usable. But security patches stop. When new vulnerabilities are found, they'll go unfixed. The system keeps running while the risk accumulates slowly.&lt;/p&gt;

&lt;p&gt;ingress-nginx had a kind of self-evidence: "if you need HTTP in Kubernetes, use this." Gateway API dissolves that.&lt;/p&gt;

&lt;p&gt;This article covers how to choose a Gateway API implementation and how to migrate ingress-nginx annotations by sorting them into three categories — with before/after manifests throughout.&lt;/p&gt;

&lt;h2&gt;
  
  
  What "migrate to Gateway API" actually means
&lt;/h2&gt;

&lt;p&gt;Gateway API is a Kubernetes specification. It is not software.&lt;/p&gt;

&lt;p&gt;In the Ingress era, HTTP routing configuration looked like this:&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="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;networking.k8s.io/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Ingress&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;my-ingress&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;rules&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;host&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;app.example.com&lt;/span&gt;
    &lt;span class="na"&gt;http&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;paths&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/&lt;/span&gt;
        &lt;span class="na"&gt;pathType&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Prefix&lt;/span&gt;
        &lt;span class="na"&gt;backend&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;service&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;my-service&lt;/span&gt;
            &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="na"&gt;number&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;8080&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Apply this YAML and it worked. Assuming ingress-nginx was installed in the cluster — but since "using Kubernetes Ingress" and "using ingress-nginx" were nearly synonymous, that distinction rarely came up. Spec and implementation mapped almost one-to-one.&lt;/p&gt;

&lt;p&gt;Gateway API is different. Writing an &lt;code&gt;HTTPRoute&lt;/code&gt; does nothing unless you have separate software installed that reads those resources and actually routes requests.&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="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;gateway.networking.k8s.io/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;HTTPRoute&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;my-route&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;parentRefs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;my-gateway&lt;/span&gt;
  &lt;span class="na"&gt;rules&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;matches&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;PathPrefix&lt;/span&gt;
        &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/&lt;/span&gt;
    &lt;span class="na"&gt;backendRefs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;my-service&lt;/span&gt;
      &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;8080&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Apply this YAML without a Gateway controller installed in the cluster, and nothing happens. Gateway API is a spec defining what resources exist — it's not a process that handles traffic.&lt;/p&gt;

&lt;p&gt;"Migrating to Gateway API" meant picking an implementation and installing it. The question of which software was actually running — obvious in the Ingress world — is now something you have to decide yourself.&lt;/p&gt;

&lt;h2&gt;
  
  
  Too many implementations
&lt;/h2&gt;

&lt;p&gt;"Pick one" sounds simple, but the options are many. In the Envoy-based family alone: Envoy Gateway, kgateway, Istio, Contour, Cilium. Outside Envoy: Traefik, Kong, NGINX Gateway Fabric. Cloud providers each offer their own. And all of them claim "Gateway API conformant."&lt;/p&gt;

&lt;p&gt;It's tempting to think: "if they're all conformant, they'll behave the same within the standard, and I can switch later." That doesn't hold.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three reasons "conformant" isn't uniform
&lt;/h2&gt;

&lt;p&gt;First, conformance levels vary. Gateway API features are divided into three tiers: Core, Extended, and Implementation-specific. Core is the minimum every implementation must support. Extended is "recommended but optional." Implementation-specific is the open space for vendor extensions. Claiming "Gateway API conformant" only requires Core support. Extended and above vary across implementations.&lt;/p&gt;

&lt;p&gt;Second, the features you actually need in production tend to land in vendor extensions. Auth, rate limiting, circuit breaking — these commonly belong to Extended or Implementation-specific. Envoy Gateway uses a &lt;code&gt;SecurityPolicy&lt;/code&gt; CRD; Istio uses &lt;code&gt;AuthorizationPolicy&lt;/code&gt;. The moment you write one of those resources, you're locked into that implementation.&lt;/p&gt;

&lt;p&gt;Third, there's no default. When ingress-nginx was around, there was a clear first choice. Now there isn't. Every team is being forced to make that choice simultaneously, without a settled answer.&lt;/p&gt;

&lt;p&gt;The first pick carries weight. The comparison below shows why.&lt;/p&gt;

&lt;h2&gt;
  
  
  Comparing the major implementations
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Envoy Gateway&lt;/th&gt;
&lt;th&gt;Istio&lt;/th&gt;
&lt;th&gt;Traefik&lt;/th&gt;
&lt;th&gt;NGINX Gateway Fabric&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Data plane&lt;/td&gt;
&lt;td&gt;Envoy&lt;/td&gt;
&lt;td&gt;Envoy&lt;/td&gt;
&lt;td&gt;Traefik&lt;/td&gt;
&lt;td&gt;nginx&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Service mesh support&lt;/td&gt;
&lt;td&gt;Partial&lt;/td&gt;
&lt;td&gt;Full&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Migration ease from ingress-nginx&lt;/td&gt;
&lt;td&gt;Good&lt;/td&gt;
&lt;td&gt;Moderate&lt;/td&gt;
&lt;td&gt;Good&lt;/td&gt;
&lt;td&gt;Excellent&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Extended feature coverage&lt;/td&gt;
&lt;td&gt;Excellent&lt;/td&gt;
&lt;td&gt;Excellent&lt;/td&gt;
&lt;td&gt;Good&lt;/td&gt;
&lt;td&gt;Good&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Development momentum&lt;/td&gt;
&lt;td&gt;Excellent&lt;/td&gt;
&lt;td&gt;Excellent&lt;/td&gt;
&lt;td&gt;Good&lt;/td&gt;
&lt;td&gt;Moderate&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Istio is the only one with full service mesh support — east-west traffic control and sidecar injection included. The other three have no mesh capabilities. NGINX Gateway Fabric scores highest on migration ease because it shares the same nginx data plane. Istio scores lower because installing the entire service mesh stack is a prerequisite. The difference in Extended feature coverage becomes concrete once you read the Category B before/after examples. For development momentum, Envoy Gateway (CNCF incubating) and Istio (CNCF graduated) lead, while NGINX Gateway Fabric has a lower release cadence.&lt;/p&gt;

&lt;p&gt;No recommendation here — the right answer depends on whether you already have a service mesh, how you weigh the annotation rewrite cost, and whether your team has Envoy operational experience. But before picking an implementation, there's work to do: inventory your current ingress-nginx annotations.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sorting annotations into three categories
&lt;/h2&gt;

&lt;p&gt;The before/after manifests and validation scripts used in this article are at &lt;a href="https://github.com/shinagawa-web/ingress-nginx-to-gateway-api" rel="noopener noreferrer"&gt;shinagawa-web/ingress-nginx-to-gateway-api&lt;/a&gt;. &lt;code&gt;before/&lt;/code&gt; holds the ingress-nginx manifests, &lt;code&gt;after/category-a/&lt;/code&gt; the standard Gateway API equivalents, and &lt;code&gt;after/category-b-envoy-gateway/&lt;/code&gt; and &lt;code&gt;after/category-b-istio/&lt;/code&gt; the implementation-specific ones.&lt;/p&gt;

&lt;p&gt;Envoy Gateway and Istio were chosen for the Category B manifests because they have broad coverage of features that can't be expressed in the standard spec — external auth, rate limiting, and so on. Traefik and NGINX Gateway Fabric can handle basic routing just as well, but their vendor extensions are less mature. Putting Envoy-based (Envoy Gateway) next to service-mesh-capable (Istio) also makes the architectural difference visible. Links to the relevant files appear at the end of each category section so you can run them locally as you read.&lt;/p&gt;

&lt;p&gt;The goal is to inventory every annotation you have and sort each into one of three categories. The approach differs fundamentally by category. Note that Category C is evaluated line by line within the annotation, not annotation by annotation. A single &lt;code&gt;configuration-snippet&lt;/code&gt; can contain lines that fall into Category A and lines that require Category B.&lt;/p&gt;

&lt;h3&gt;
  
  
  Category A: migrates directly to standard Gateway API
&lt;/h3&gt;

&lt;h4&gt;
  
  
  rewrite
&lt;/h4&gt;

&lt;p&gt;A rewrite is the process of rewriting the URL path before the request reaches the backend. If a client hits &lt;code&gt;/api/hello&lt;/code&gt; and the app should receive &lt;code&gt;/hello&lt;/code&gt;, that's a rewrite. In ingress-nginx, you'd use regex capture groups.&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="c1"&gt;# before/02-rewrite.yaml&lt;/span&gt;
&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;networking.k8s.io/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Ingress&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;rewrite&lt;/span&gt;
  &lt;span class="na"&gt;annotations&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;nginx.ingress.kubernetes.io/rewrite-target&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/$2&lt;/span&gt;
    &lt;span class="na"&gt;nginx.ingress.kubernetes.io/use-regex&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;true"&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;ingressClassName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;nginx&lt;/span&gt;
  &lt;span class="na"&gt;rules&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;host&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;app.example.com&lt;/span&gt;
    &lt;span class="na"&gt;http&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;paths&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/api(/|$)(.*)&lt;/span&gt;
        &lt;span class="na"&gt;pathType&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ImplementationSpecific&lt;/span&gt;
        &lt;span class="na"&gt;backend&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;service&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;echo-v1&lt;/span&gt;
            &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="na"&gt;number&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;8080&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The pattern &lt;code&gt;/api(/|$)(.*)&lt;/code&gt; captures the second group (&lt;code&gt;$2&lt;/code&gt;) and forwards it as the path. A request to &lt;code&gt;/api/hello&lt;/code&gt; puts &lt;code&gt;hello&lt;/code&gt; into &lt;code&gt;$2&lt;/code&gt;, delivering &lt;code&gt;/hello&lt;/code&gt; to the backend.&lt;/p&gt;

&lt;p&gt;Gateway API expresses the same thing with the &lt;code&gt;URLRewrite&lt;/code&gt; filter in &lt;code&gt;HTTPRoute&lt;/code&gt;. (&lt;code&gt;URLRewrite&lt;/code&gt; is an Extended-tier feature — not Core. Verify your chosen implementation supports it; the four implementations in this article's comparison table all do.)&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="c1"&gt;# after/category-a/02-rewrite.yaml&lt;/span&gt;
&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;gateway.networking.k8s.io/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;HTTPRoute&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;rewrite&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;parentRefs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;eg&lt;/span&gt;
  &lt;span class="na"&gt;rules&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;matches&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;PathPrefix&lt;/span&gt;
        &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/api&lt;/span&gt;
    &lt;span class="na"&gt;filters&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;URLRewrite&lt;/span&gt;
      &lt;span class="na"&gt;urlRewrite&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ReplacePrefixMatch&lt;/span&gt;
          &lt;span class="na"&gt;replacePrefixMatch&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/&lt;/span&gt;
    &lt;span class="na"&gt;backendRefs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;echo-v1&lt;/span&gt;
      &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;8080&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read it as: replace the &lt;code&gt;/api&lt;/code&gt; prefix with &lt;code&gt;/&lt;/code&gt;. No regex — prefix replacement is explicit.&lt;/p&gt;

&lt;h4&gt;
  
  
  canary
&lt;/h4&gt;

&lt;p&gt;Canary deployment means routing a small percentage of traffic to a new version, then gradually increasing it rather than doing an immediate cutover. In ingress-nginx, this required two Ingress resources: a primary route to v1 and a canary route to v2, with the canary's weight set in an annotation.&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="c1"&gt;# before/03-canary.yaml&lt;/span&gt;
&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;networking.k8s.io/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Ingress&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;canary-primary&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;ingressClassName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;nginx&lt;/span&gt;
  &lt;span class="na"&gt;rules&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;host&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;app.example.com&lt;/span&gt;
    &lt;span class="na"&gt;http&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;paths&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/&lt;/span&gt;
        &lt;span class="na"&gt;pathType&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Prefix&lt;/span&gt;
        &lt;span class="na"&gt;backend&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;service&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;echo-v1&lt;/span&gt;
            &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="na"&gt;number&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;8080&lt;/span&gt;
&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;networking.k8s.io/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Ingress&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;canary&lt;/span&gt;
  &lt;span class="na"&gt;annotations&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;nginx.ingress.kubernetes.io/canary&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;true"&lt;/span&gt;
    &lt;span class="na"&gt;nginx.ingress.kubernetes.io/canary-weight&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;20"&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;ingressClassName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;nginx&lt;/span&gt;
  &lt;span class="na"&gt;rules&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;host&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;app.example.com&lt;/span&gt;
    &lt;span class="na"&gt;http&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;paths&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/&lt;/span&gt;
        &lt;span class="na"&gt;pathType&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Prefix&lt;/span&gt;
        &lt;span class="na"&gt;backend&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;service&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;echo-v2&lt;/span&gt;
            &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="na"&gt;number&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;8080&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two Ingress resources define the same &lt;code&gt;host: app.example.com&lt;/code&gt; and path &lt;code&gt;/&lt;/code&gt;. Normally that's a conflict, but ingress-nginx treats any Ingress with &lt;code&gt;canary: "true"&lt;/code&gt; as a weighted companion to the other. &lt;code&gt;canary-weight: "20"&lt;/code&gt; sends 20% of traffic to echo-v2.&lt;/p&gt;

&lt;p&gt;One routing decision split across two resources is the problem. Reading &lt;code&gt;canary-primary&lt;/code&gt; alone gives you no visibility into the actual traffic split.&lt;/p&gt;

&lt;p&gt;Gateway API fits it into a single &lt;code&gt;HTTPRoute&lt;/code&gt;.&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="c1"&gt;# after/category-a/03-canary.yaml&lt;/span&gt;
&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;gateway.networking.k8s.io/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;HTTPRoute&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;canary&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;parentRefs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;eg&lt;/span&gt;
  &lt;span class="na"&gt;rules&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;backendRefs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;echo-v1&lt;/span&gt;
      &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;8080&lt;/span&gt;
      &lt;span class="na"&gt;weight&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;80&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;echo-v2&lt;/span&gt;
      &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;8080&lt;/span&gt;
      &lt;span class="na"&gt;weight&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;20&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;List v1 and v2 in &lt;code&gt;backendRefs&lt;/code&gt; with &lt;code&gt;weight&lt;/code&gt; ratios. 80:20 sends 80% to v1, 20% to v2. One resource, full picture.&lt;/p&gt;

&lt;p&gt;The conversions in this category can be automated with the &lt;a href="https://github.com/kubernetes-sigs/ingress2gateway" rel="noopener noreferrer"&gt;Ingress2Gateway CLI&lt;/a&gt; (1.0 GA). Worth using to reduce manual work.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://github.com/shinagawa-web/ingress-nginx-to-gateway-api/blob/main/before/02-rewrite.yaml" rel="noopener noreferrer"&gt;before/02-rewrite.yaml&lt;/a&gt; → &lt;a href="https://github.com/shinagawa-web/ingress-nginx-to-gateway-api/blob/main/after/category-a/02-rewrite.yaml" rel="noopener noreferrer"&gt;after/category-a/02-rewrite.yaml&lt;/a&gt;, &lt;a href="https://github.com/shinagawa-web/ingress-nginx-to-gateway-api/blob/main/before/03-canary.yaml" rel="noopener noreferrer"&gt;before/03-canary.yaml&lt;/a&gt; → &lt;a href="https://github.com/shinagawa-web/ingress-nginx-to-gateway-api/blob/main/after/category-a/03-canary.yaml" rel="noopener noreferrer"&gt;after/category-a/03-canary.yaml&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Category B: requires implementation-specific extensions
&lt;/h3&gt;

&lt;h4&gt;
  
  
  external auth
&lt;/h4&gt;

&lt;p&gt;External auth delegates authentication decisions to a separate service rather than the application itself. On each incoming request, the proxy asks the auth service whether to allow it. Only when the auth service responds OK does the request proceed to the app. Because the auth logic lives outside the app, the same auth service can cover multiple apps.&lt;/p&gt;

&lt;p&gt;In ingress-nginx, two annotation lines did it.&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="c1"&gt;# before/04-auth.yaml&lt;/span&gt;
&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;networking.k8s.io/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Ingress&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;auth&lt;/span&gt;
  &lt;span class="na"&gt;annotations&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;nginx.ingress.kubernetes.io/auth-url&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;http://auth-service.default.svc.cluster.local/auth&lt;/span&gt;
    &lt;span class="na"&gt;nginx.ingress.kubernetes.io/auth-response-headers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;X-Auth-User&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;ingressClassName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;nginx&lt;/span&gt;
  &lt;span class="na"&gt;rules&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;host&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;app.example.com&lt;/span&gt;
    &lt;span class="na"&gt;http&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;paths&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/protected&lt;/span&gt;
        &lt;span class="na"&gt;pathType&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Prefix&lt;/span&gt;
        &lt;span class="na"&gt;backend&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;service&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;echo-v1&lt;/span&gt;
            &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="na"&gt;number&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;8080&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;auth-url&lt;/code&gt; is the auth service endpoint. &lt;code&gt;auth-response-headers&lt;/code&gt; lists headers from the auth service response to forward to the backend. When the auth service returns 200, the specified headers are added to the upstream request. Two annotation lines cover delegation and response propagation.&lt;/p&gt;

&lt;p&gt;The sample repository includes a mock auth service in &lt;code&gt;app/auth-service.yaml&lt;/code&gt; — a Python server that returns 200 for &lt;code&gt;Authorization: Bearer valid-token&lt;/code&gt; and 401 for everything else.&lt;/p&gt;

&lt;p&gt;With Envoy Gateway, &lt;code&gt;HTTPRoute&lt;/code&gt; alone has no place to write auth configuration. You create a separate &lt;code&gt;SecurityPolicy&lt;/code&gt; CRD — an Envoy Gateway-specific resource — and bind it to the target &lt;code&gt;HTTPRoute&lt;/code&gt;.&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="c1"&gt;# after/category-b-envoy-gateway/04-auth.yaml (excerpt)&lt;/span&gt;
&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;gateway.envoyproxy.io/v1alpha1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;SecurityPolicy&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;auth&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;targetRefs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;group&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;gateway.networking.k8s.io&lt;/span&gt;
    &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;HTTPRoute&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;protected&lt;/span&gt;
  &lt;span class="na"&gt;extAuth&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;http&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;backendRefs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;group&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;
        &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Service&lt;/span&gt;
        &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;auth-service&lt;/span&gt;
        &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;80&lt;/span&gt;
      &lt;span class="na"&gt;headersToBackend&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;x-auth-user&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;targetRefs&lt;/code&gt; specifies which &lt;code&gt;HTTPRoute&lt;/code&gt; the policy applies to — here, the &lt;code&gt;HTTPRoute&lt;/code&gt; named &lt;code&gt;protected&lt;/code&gt;. &lt;code&gt;extAuth.http.backendRefs&lt;/code&gt; points to the auth service.&lt;/p&gt;

&lt;p&gt;The intent is clearer than in ingress-nginx. Annotations were scoped to their Ingress resource, so tracking which route had which auth policy meant reading every Ingress. &lt;code&gt;SecurityPolicy&lt;/code&gt; stands as an independent resource with its target made explicit via &lt;code&gt;targetRefs&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;To forward &lt;code&gt;X-Auth-User&lt;/code&gt; from the auth service response to the backend, &lt;code&gt;headersToBackend&lt;/code&gt; must be configured explicitly. Unlike ingress-nginx's &lt;code&gt;auth-response-headers&lt;/code&gt;, Envoy Gateway does not forward auth response headers automatically — only the headers listed in &lt;code&gt;headersToBackend&lt;/code&gt; are propagated.&lt;/p&gt;

&lt;p&gt;Migrating to Istio changes the architecture. &lt;code&gt;AuthorizationPolicy&lt;/code&gt; evaluates request attributes locally — headers, JWT claims, source principals — rather than calling out to an external service.&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="c1"&gt;# after/category-b-istio/04-auth.yaml (excerpt)&lt;/span&gt;
&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;security.istio.io/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;AuthorizationPolicy&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;auth&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;selector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;matchLabels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;echo&lt;/span&gt;
      &lt;span class="na"&gt;version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;v1&lt;/span&gt;
  &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;DENY&lt;/span&gt;
  &lt;span class="na"&gt;rules&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;when&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;request.headers[authorization]&lt;/span&gt;
      &lt;span class="na"&gt;notValues&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Bearer&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;valid-token"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;action: DENY&lt;/code&gt; means "deny requests that match the condition." Requests where &lt;code&gt;request.headers[authorization]&lt;/code&gt; is not &lt;code&gt;Bearer valid-token&lt;/code&gt; are denied — only valid-token requests get through. Note that &lt;code&gt;selector.matchLabels&lt;/code&gt; scopes the policy to the workload, not to the &lt;code&gt;/protected&lt;/code&gt; path — the DENY rule applies to all traffic reaching the matched pods. To restrict it to a specific path, add &lt;code&gt;to.operation.paths&lt;/code&gt; to the rule. Where Envoy Gateway's &lt;code&gt;extAuth&lt;/code&gt; asks an external service for an OK/NG decision, Istio's &lt;code&gt;AuthorizationPolicy&lt;/code&gt; evaluates the policy inside the cluster. The mechanism is fundamentally different from ingress-nginx's &lt;code&gt;auth-url&lt;/code&gt; callback model, so migrating to Istio means rethinking the auth design.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;AuthorizationPolicy&lt;/code&gt; evaluates request attributes locally. For logic that requires an external decision — like checking permissions against a database — Istio provides &lt;code&gt;AuthorizationPolicy&lt;/code&gt; with &lt;code&gt;action: CUSTOM&lt;/code&gt; plus an &lt;code&gt;extensionProviders&lt;/code&gt; entry in &lt;code&gt;meshConfig&lt;/code&gt;. This is the proper external auth delegation path in Istio, comparable in ergonomics to Envoy Gateway's &lt;code&gt;extAuth&lt;/code&gt;. The &lt;code&gt;EnvoyFilter&lt;/code&gt; route can also be used but writes Envoy internals directly.&lt;/p&gt;

&lt;p&gt;Category A works the same way regardless of which implementation you choose. Category B changes fundamentally depending on which one you picked. This is the "operational features drift into vendor extensions" reality the comparison table pointed to: you can't form a migration plan for this category until you've chosen an implementation.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://github.com/shinagawa-web/ingress-nginx-to-gateway-api/blob/main/before/04-auth.yaml" rel="noopener noreferrer"&gt;before/04-auth.yaml&lt;/a&gt; → &lt;a href="https://github.com/shinagawa-web/ingress-nginx-to-gateway-api/blob/main/after/category-b-envoy-gateway/04-auth.yaml" rel="noopener noreferrer"&gt;after/category-b-envoy-gateway/04-auth.yaml&lt;/a&gt; / &lt;a href="https://github.com/shinagawa-web/ingress-nginx-to-gateway-api/blob/main/after/category-b-istio/04-auth.yaml" rel="noopener noreferrer"&gt;after/category-b-istio/04-auth.yaml&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h4&gt;
  
  
  rate limit
&lt;/h4&gt;

&lt;p&gt;ingress-nginx's rate limit was self-contained in annotations, but there's a trap: queueing behavior changes after migration.&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="c1"&gt;# before/05-rate-limit.yaml (excerpt)&lt;/span&gt;
&lt;span class="na"&gt;annotations&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;nginx.ingress.kubernetes.io/limit-rps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2"&lt;/span&gt;
  &lt;span class="na"&gt;nginx.ingress.kubernetes.io/limit-burst-multiplier&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;limit-rps: "2"&lt;/code&gt; allows at most 2 requests per second per IP. Requests over the limit don't get rejected immediately — they queue up to the limit set by &lt;code&gt;limit-burst-multiplier&lt;/code&gt;. With &lt;code&gt;limit-burst-multiplier: "1"&lt;/code&gt;, the queue cap is &lt;code&gt;limit-rps × 1 = 2&lt;/code&gt;. Once the queue fills, subsequent requests get a 503 immediately.&lt;/p&gt;

&lt;p&gt;Envoy Gateway uses &lt;code&gt;BackendTrafficPolicy&lt;/code&gt;. Like &lt;code&gt;SecurityPolicy&lt;/code&gt; for external auth, it's a separate resource from &lt;code&gt;HTTPRoute&lt;/code&gt;, with &lt;code&gt;targetRefs&lt;/code&gt; pointing at the target.&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="c1"&gt;# after/category-b-envoy-gateway/05-rate-limit.yaml (excerpt)&lt;/span&gt;
&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;gateway.envoyproxy.io/v1alpha1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;BackendTrafficPolicy&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;rate-limit&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;targetRefs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;group&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;gateway.networking.k8s.io&lt;/span&gt;
    &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;HTTPRoute&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;rate-limited&lt;/span&gt;
  &lt;span class="na"&gt;rateLimit&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Local&lt;/span&gt;
    &lt;span class="na"&gt;local&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;rules&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;limit&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;requests&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt;
          &lt;span class="na"&gt;unit&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Second&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;requests: 2, unit: Second&lt;/code&gt; expresses 2 requests per second, but there's no field corresponding to &lt;code&gt;limit-burst-multiplier&lt;/code&gt;. The queueing behavior can't be reproduced.&lt;/p&gt;

&lt;p&gt;Istio configures the token bucket directly via &lt;code&gt;EnvoyFilter&lt;/code&gt;. There's no abstraction layer like &lt;code&gt;BackendTrafficPolicy&lt;/code&gt; — you're writing Envoy internals directly.&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="c1"&gt;# after/category-b-istio/05-rate-limit.yaml (excerpt)&lt;/span&gt;
&lt;span class="na"&gt;token_bucket&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;max_tokens&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt;
  &lt;span class="na"&gt;tokens_per_fill&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt;
  &lt;span class="na"&gt;fill_interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;1s&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;tokens_per_fill: 2, fill_interval: 1s&lt;/code&gt; replenishes 2 tokens per second (= 2 req/s). &lt;code&gt;max_tokens: 2&lt;/code&gt; is the burst cap — the maximum number of tokens the bucket can hold. This corresponds to ingress-nginx's &lt;code&gt;limit-rps: "2", limit-burst-multiplier: "1"&lt;/code&gt;. The algorithm differs from nginx's queuing model, but the behavior — 2 requests per second, burst cap of 2 — is equivalent. Whether reproducing &lt;code&gt;limit-burst-multiplier&lt;/code&gt; is worth trading against implementation choice is a judgment call that varies by team.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://github.com/shinagawa-web/ingress-nginx-to-gateway-api/blob/main/before/05-rate-limit.yaml" rel="noopener noreferrer"&gt;before/05-rate-limit.yaml&lt;/a&gt; → &lt;a href="https://github.com/shinagawa-web/ingress-nginx-to-gateway-api/blob/main/after/category-b-envoy-gateway/05-rate-limit.yaml" rel="noopener noreferrer"&gt;after/category-b-envoy-gateway/05-rate-limit.yaml&lt;/a&gt; / &lt;a href="https://github.com/shinagawa-web/ingress-nginx-to-gateway-api/blob/main/after/category-b-istio/05-rate-limit.yaml" rel="noopener noreferrer"&gt;after/category-b-istio/05-rate-limit.yaml&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Category C: no equivalent
&lt;/h3&gt;

&lt;h4&gt;
  
  
  configuration-snippet
&lt;/h4&gt;

&lt;p&gt;Probably the most useful annotation ingress-nginx had — and the most problematic.&lt;/p&gt;

&lt;p&gt;nginx config has &lt;code&gt;server&lt;/code&gt; blocks and &lt;code&gt;location&lt;/code&gt; blocks. &lt;code&gt;configuration-snippet&lt;/code&gt; lets you inject nginx directives directly into the &lt;code&gt;location&lt;/code&gt; block, overriding or extending what ingress-nginx generates. It was effectively an escape hatch for anything the standard annotations couldn't express.&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="c1"&gt;# before/06-configuration-snippet.yaml (excerpt)&lt;/span&gt;
&lt;span class="na"&gt;annotations&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;nginx.ingress.kubernetes.io/configuration-snippet&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
    &lt;span class="s"&gt;more_set_headers "X-Custom-Header: my-value";&lt;/span&gt;
    &lt;span class="s"&gt;more_set_headers "X-Request-ID: $request_id";&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;more_set_headers&lt;/code&gt; is a directive from the headers-more module bundled with ingress-nginx, used to add or override response headers. &lt;code&gt;"X-Custom-Header: my-value"&lt;/code&gt; adds a static header. &lt;code&gt;"X-Request-ID: $request_id"&lt;/code&gt; attaches nginx's built-in variable &lt;code&gt;$request_id&lt;/code&gt; — a unique ID auto-generated per request — as a response header.&lt;/p&gt;

&lt;p&gt;Gateway API has no direct equivalent. But saying "no equivalent" oversimplifies it. Break down the annotation line by line, and the picture splits.&lt;/p&gt;

&lt;p&gt;Adding a static header like &lt;code&gt;X-Custom-Header&lt;/code&gt; migrates to &lt;code&gt;HTTPRoute&lt;/code&gt;'s &lt;code&gt;ResponseHeaderModifier&lt;/code&gt; filter. That's standard spec — Category A.&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="c1"&gt;# after/category-a/06-response-header.yaml (excerpt)&lt;/span&gt;
&lt;span class="na"&gt;filters&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ResponseHeaderModifier&lt;/span&gt;
  &lt;span class="na"&gt;responseHeaderModifier&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;add&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;X-Custom-Header&lt;/span&gt;
      &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;my-value&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Dynamic values like &lt;code&gt;$request_id&lt;/code&gt; are a different story. Nothing in the Gateway API standard covers referencing nginx built-in variables. With Envoy Gateway, you can reproduce it by injecting a Lua script via the &lt;code&gt;EnvoyPatchPolicy&lt;/code&gt; CRD — Category B.&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="c1"&gt;# after/category-b-envoy-gateway/06-request-id.yaml (excerpt)&lt;/span&gt;
&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;gateway.envoyproxy.io/v1alpha1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;EnvoyPatchPolicy&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;request-id-lua&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;targetRef&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;group&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;gateway.networking.k8s.io&lt;/span&gt;
    &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Gateway&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;eg&lt;/span&gt;
  &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;JSONPatch&lt;/span&gt;
  &lt;span class="na"&gt;jsonPatches&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type.googleapis.com/envoy.config.listener.v3.Listener"&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;default/eg/http"&lt;/span&gt;
    &lt;span class="na"&gt;operation&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;op&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;add&lt;/span&gt;
      &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/default_filter_chain/filters/0/typed_config/http_filters/0"&lt;/span&gt;
      &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;envoy.filters.http.lua&lt;/span&gt;
        &lt;span class="na"&gt;typed_config&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;@type"&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type.googleapis.com/envoy.extensions.filters.http.lua.v3.Lua"&lt;/span&gt;
          &lt;span class="na"&gt;default_source_code&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;inline_string&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
              &lt;span class="s"&gt;function envoy_on_request(request_handle)&lt;/span&gt;
                &lt;span class="s"&gt;local req_id = request_handle:headers():get("x-request-id")&lt;/span&gt;
                &lt;span class="s"&gt;if req_id then&lt;/span&gt;
                  &lt;span class="s"&gt;request_handle:streamInfo():dynamicMetadata():set("lua_ns", "req_id", req_id)&lt;/span&gt;
                &lt;span class="s"&gt;end&lt;/span&gt;
              &lt;span class="s"&gt;end&lt;/span&gt;
              &lt;span class="s"&gt;function envoy_on_response(response_handle)&lt;/span&gt;
                &lt;span class="s"&gt;local meta = response_handle:streamInfo():dynamicMetadata():get("lua_ns")&lt;/span&gt;
                &lt;span class="s"&gt;if meta and meta["req_id"] then&lt;/span&gt;
                  &lt;span class="s"&gt;response_handle:headers():add("x-request-id", meta["req_id"])&lt;/span&gt;
                &lt;span class="s"&gt;end&lt;/span&gt;
              &lt;span class="s"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Envoy generates an &lt;code&gt;x-request-id&lt;/code&gt; header automatically on incoming requests. The Lua script reads it during request processing, stores it in metadata, then attaches it as a response header during response processing. nginx's &lt;code&gt;$request_id&lt;/code&gt; becomes Envoy's request ID, but the intent — a unique per-request ID on the response header — is the same.&lt;/p&gt;

&lt;p&gt;The same Lua logic works in Istio via &lt;code&gt;EnvoyFilter&lt;/code&gt;, but the apply context and metadata namespace differ.&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="c1"&gt;# after/category-b-istio/06-request-id.yaml (excerpt)&lt;/span&gt;
&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;networking.istio.io/v1alpha3&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;EnvoyFilter&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;request-id-response-header&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;configPatches&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;applyTo&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;HTTP_FILTER&lt;/span&gt;
    &lt;span class="na"&gt;match&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;context&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;GATEWAY&lt;/span&gt;
      &lt;span class="na"&gt;listener&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;filterChain&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;filter&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;envoy.filters.network.http_connection_manager&lt;/span&gt;
            &lt;span class="na"&gt;subFilter&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;envoy.filters.http.router&lt;/span&gt;
    &lt;span class="na"&gt;patch&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;operation&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;INSERT_BEFORE&lt;/span&gt;
      &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;envoy.filters.http.lua&lt;/span&gt;
        &lt;span class="na"&gt;typed_config&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;@type"&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="s"&gt;type.googleapis.com/envoy.extensions.filters.http.lua.v3.Lua&lt;/span&gt;
          &lt;span class="s"&gt;default_source_code&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;inline_string&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
              &lt;span class="s"&gt;function envoy_on_request(request_handle)&lt;/span&gt;
                &lt;span class="s"&gt;local req_id = request_handle:headers():get("x-request-id")&lt;/span&gt;
                &lt;span class="s"&gt;if req_id then&lt;/span&gt;
                  &lt;span class="s"&gt;request_handle:streamInfo():dynamicMetadata():set("lua_metadata", "req_id", req_id)&lt;/span&gt;
                &lt;span class="s"&gt;end&lt;/span&gt;
              &lt;span class="s"&gt;end&lt;/span&gt;
              &lt;span class="s"&gt;function envoy_on_response(response_handle)&lt;/span&gt;
                &lt;span class="s"&gt;local meta = response_handle:streamInfo():dynamicMetadata():get("lua_metadata")&lt;/span&gt;
                &lt;span class="s"&gt;if meta and meta["req_id"] then&lt;/span&gt;
                  &lt;span class="s"&gt;response_handle:headers():add("x-request-id", meta["req_id"])&lt;/span&gt;
                &lt;span class="s"&gt;end&lt;/span&gt;
              &lt;span class="s"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Envoy Gateway version uses &lt;code&gt;EnvoyPatchPolicy&lt;/code&gt; with JSONPatch to insert the Lua filter at the Listener level, with state stored under metadata namespace &lt;code&gt;lua_ns&lt;/code&gt;. The Istio version uses &lt;code&gt;EnvoyFilter&lt;/code&gt; with &lt;code&gt;applyTo: HTTP_FILTER&lt;/code&gt; and &lt;code&gt;match.context: GATEWAY&lt;/code&gt;, with state stored under &lt;code&gt;lua_metadata&lt;/code&gt;. The Lua logic is the same, but the configuration layer differs.&lt;/p&gt;

&lt;p&gt;What truly has no equivalent in Category C is nginx-specific functionality that &lt;code&gt;EnvoyPatchPolicy&lt;/code&gt; can't reach — directives that depend deeply on nginx internals, or snippets that assume concepts Envoy doesn't have. In practice, most &lt;code&gt;configuration-snippet&lt;/code&gt; contents, when broken down line by line, land in Category A or Category B. The annotation as a whole isn't Category C — each line has to be read and evaluated individually.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://github.com/shinagawa-web/ingress-nginx-to-gateway-api/blob/main/before/06-configuration-snippet.yaml" rel="noopener noreferrer"&gt;before/06-configuration-snippet.yaml&lt;/a&gt; → &lt;a href="https://github.com/shinagawa-web/ingress-nginx-to-gateway-api/blob/main/after/category-a/06-response-header.yaml" rel="noopener noreferrer"&gt;after/category-a/06-response-header.yaml&lt;/a&gt; (X-Custom-Header) / &lt;a href="https://github.com/shinagawa-web/ingress-nginx-to-gateway-api/blob/main/after/category-b-envoy-gateway/06-request-id.yaml" rel="noopener noreferrer"&gt;after/category-b-envoy-gateway/06-request-id.yaml&lt;/a&gt; (X-Request-ID, Envoy Gateway) / &lt;a href="https://github.com/shinagawa-web/ingress-nginx-to-gateway-api/blob/main/after/category-b-istio/06-request-id.yaml" rel="noopener noreferrer"&gt;after/category-b-istio/06-request-id.yaml&lt;/a&gt; (X-Request-ID, Istio)&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Standardization didn't make migration easier
&lt;/h2&gt;

&lt;p&gt;Gateway API gave us a unified spec, so "the interface is now shared." But more implementations means more decisions. The Core tier is consistent across implementations. But operational features drift into vendor extensions, so locking in to an implementation comes after, not before.&lt;/p&gt;

&lt;p&gt;The frustration with ingress-nginx — "settings are scattered across annotations, hard to see the structure" — was a real problem. Gateway API was designed to address it. But from the migration side, new decisions piled on: which software to pick, which annotations map to the standard and which need vendor extensions and which get dropped.&lt;/p&gt;

&lt;p&gt;"Migrate to Gateway API" wasn't a work order. It compressed two separate jobs into one phrase: choose an implementation, then classify each annotation into one of three categories and handle each accordingly.&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>devops</category>
      <category>networking</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Inspect HTTPS Traffic Without a Proxy — Built with Go and eBPF</title>
      <dc:creator>Kazu</dc:creator>
      <pubDate>Tue, 01 Sep 2026 12:01:00 +0000</pubDate>
      <link>https://dev.to/shinagawa-web/inspect-https-traffic-without-a-proxy-built-with-go-and-ebpf-4e92</link>
      <guid>https://dev.to/shinagawa-web/inspect-https-traffic-without-a-proxy-built-with-go-and-ebpf-4e92</guid>
      <description>&lt;p&gt;I built a CLI in Go that reads HTTPS traffic as plaintext — no CA certificate installation, no proxy, and no changes to the target app.&lt;/p&gt;

&lt;p&gt;The original motivation was simple: spinning up mitmproxy every time I needed to debug something added one too many steps. I found an approach using eBPF, wrapped it into a CLI, and called it &lt;a href="https://github.com/shinagawa-web/tinytap" rel="noopener noreferrer"&gt;tinytap&lt;/a&gt;. This article is about how I built it.&lt;/p&gt;

&lt;p&gt;Here's what it looks like in action.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvngwl5r0x1wmosduij8a.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvngwl5r0x1wmosduij8a.gif" alt="The left terminal runs an app that sends one HTTPS request. The right terminal shows tinytap printing that request as plaintext — request line, headers, Authorization: Bearer token, JSON body." width="800" height="517"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Two terminals side by side. The left sends one HTTPS request; the right shows tinytap printing it in plaintext — request line, headers, Authorization token, JSON body, all of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it captures
&lt;/h2&gt;

&lt;p&gt;tinytap targets apps like this — ordinary code that hits an API, in this case the OpenAI API from Python:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;openai&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;OpenAI&lt;/span&gt;

&lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;OpenAI&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;api_key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sk-...&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;base_url&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://127.0.0.1:8443/v1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;chat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;completions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;gpt-4o-mini&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;role&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;what is a uprobe&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}],&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And here's what tinytap captures and outputs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;POST /v1/chat/completions HTTP/1.1
Host: 127.0.0.1:8443
Authorization: Bearer sk-demo-xxxx
Content-Type: application/json
User-Agent: OpenAI/Python 2.53.0
...
Request body (decoded): {"messages":[{"role":"user","content":"what is a uprobe"}],"model":"gpt-4o-mini"}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Request line, all headers, the &lt;code&gt;Authorization: Bearer&lt;/code&gt; token, and the JSON body. The token in the gif is &lt;code&gt;sk-demo-xxxx&lt;/code&gt; — a dummy — but when you point tinytap at a real app, the real token shows up exactly like that. No reformatting.&lt;/p&gt;

&lt;p&gt;Nothing on the app side was touched. One command: &lt;code&gt;tinytap&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  How eBPF makes this work
&lt;/h2&gt;

&lt;p&gt;A uprobe is a kernel mechanism for dynamically hooking into user-space functions. tinytap attaches a uprobe to &lt;code&gt;SSL_write&lt;/code&gt; in libssl. The moment that function is called, tinytap reads the argument buffer — which is still plaintext at that point. The data travels through a ring buffer to user space and gets displayed in the TUI. No modifications to the app or to libssl.&lt;/p&gt;

&lt;h2&gt;
  
  
  This is not breaking TLS
&lt;/h2&gt;

&lt;p&gt;One thing worth clearing up.&lt;/p&gt;

&lt;p&gt;tinytap only looks at the buffer your own process hands to the TLS library — the moment before it gets encrypted. It is not a vulnerability that lets you intercept traffic on the network. What flows over the wire is properly encrypted. Other processes and other people's traffic are not accessible.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;App
  │  plaintext
  ▼
TLS library  ◀── tinytap looks here
  │  ciphertext
  ▼
Network
  │  ciphertext
  ▼
API server
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It's the same idea as &lt;code&gt;curl&lt;/code&gt; printing its own request to your terminal.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Go
&lt;/h2&gt;

&lt;p&gt;A few reasons I chose Go.&lt;/p&gt;

&lt;p&gt;First, &lt;a href="https://github.com/cilium/ebpf" rel="noopener noreferrer"&gt;cilium/ebpf&lt;/a&gt;. It's a mature library for managing eBPF programs from Go — it absorbs the boilerplate of talking to the kernel, so I could focus on the actual hook logic.&lt;/p&gt;

&lt;p&gt;Second, binary distribution. &lt;code&gt;go build&lt;/code&gt; produces a single binary you can hand to someone and it just runs. No runtime or external dependencies to install. That matters a lot when you're shipping an open-source CLI.&lt;/p&gt;

&lt;p&gt;Third, the TUI. I used &lt;a href="https://github.com/charmbracelet/bubbletea" rel="noopener noreferrer"&gt;Bubble Tea&lt;/a&gt; to build a live event display. Even when a burst of eBPF events comes in from the kernel, Go's goroutines and channels handle concurrent ingestion and rendering without trouble.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;tinytap uses eBPF and is Linux-only. On Mac or Windows, set up a Linux container with Docker.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-fsSL&lt;/span&gt; https://raw.githubusercontent.com/shinagawa-web/tinytap/main/scripts/install.sh | sh
&lt;span class="nb"&gt;sudo &lt;/span&gt;setcap cap_dac_read_search,cap_perfmon,cap_bpf,cap_sys_admin&lt;span class="o"&gt;=&lt;/span&gt;eip &lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;command&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; tinytap&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nb"&gt;sudo chmod&lt;/span&gt; +x &lt;span class="si"&gt;$(&lt;/span&gt;ldconfig &lt;span class="nt"&gt;-p&lt;/span&gt; | &lt;span class="nb"&gt;grep &lt;/span&gt;libssl.so | &lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="s1"&gt;'{print $NF}'&lt;/span&gt; | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-1&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
tinytap
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;setcap&lt;/code&gt; grants the binary the capabilities it needs for eBPF. The &lt;code&gt;chmod +x&lt;/code&gt; step is needed on Debian and Ubuntu because &lt;code&gt;libssl.so.3&lt;/code&gt; ships without the execute bit set there. With both done, tinytap runs without &lt;code&gt;sudo&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;If something isn't working, run &lt;code&gt;tinytap doctor&lt;/code&gt;. It checks your kernel version, BTF availability, capabilities, and the libssl execute bit, and tells you exactly what's missing.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ tinytap doctor
tinytap doctor — v0.6.1

[OK      ] kernel version               6.1.0 (&amp;gt;= 5.8 required)
[OK      ] kernel BTF                   present
[OK      ] cap_dac_read_search          present
[OK      ] cap_perfmon                  present
[OK      ] cap_bpf                      present
[OK      ] cap_sys_admin                present
[OK      ] cap_syslog                   not needed on arm64
[OK      ] perf_event_paranoid          4
[OK      ] unprivileged_bpf_disabled    2
[OK      ] RLIMIT_MEMLOCK               soft=unlimited hard=unlimited
[OK      ] syscall tracepoints          available
[OK      ] BPF dry-run load             ok
[DEGRADED] libssl execute bit           /usr/lib/x86_64-linux-gnu/libssl.so.3 not set
    Affects: TLS capture only. Plaintext HTTP capture is unaffected.
    Fix:     sudo chmod +x /usr/lib/x86_64-linux-gnu/libssl.so.3

12 ok, 1 degraded, 0 blocking, 0 info
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Python, Ruby, PHP, and Node.js all work. Go apps are out of scope — the standard library's &lt;code&gt;crypto/tls&lt;/code&gt; doesn't go through OpenSSL, so there's no &lt;code&gt;SSL_write&lt;/code&gt; to hook. tinytap currently understands HTTP/1.1 only.&lt;/p&gt;

&lt;p&gt;Targeting implementations that bypass OpenSSL — like Go's &lt;code&gt;crypto/tls&lt;/code&gt; or Rust's &lt;code&gt;rustls&lt;/code&gt; — would require attaching the uprobe at a different layer. Where exactly that should be is still an open question.&lt;/p&gt;

&lt;p&gt;Verified compatibility details are in the docs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://shinagawa-web.github.io/tinytap/docs/server-compatibility/" rel="noopener noreferrer"&gt;Server compatibility&lt;/a&gt; — how much of the response body tinytap can surface for nginx, Caddy, Uvicorn, and other servers&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://shinagawa-web.github.io/tinytap/docs/tls-compatibility/" rel="noopener noreferrer"&gt;TLS compatibility&lt;/a&gt; — the list of supported TLS implementations; OpenSSL-based stacks (Python, nginx, curl, Node.js, etc.)&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://shinagawa-web.github.io/tinytap/docs/compat-matrix/" rel="noopener noreferrer"&gt;Platform support&lt;/a&gt; — tested Linux distributions and kernel versions; kernel 5.8 or later is required (Ubuntu 20.04's default kernel 5.4 is too old)&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Wrapping up
&lt;/h2&gt;

&lt;p&gt;I built a CLI in Go that reads HTTPS plaintext using eBPF uprobes — no CA certificate, no proxy needed. By hooking into &lt;code&gt;SSL_write&lt;/code&gt; in libssl, it works with any OpenSSL-based stack: Python, Ruby, Node.js, and others, regardless of language.&lt;/p&gt;

&lt;p&gt;Code is on &lt;a href="https://github.com/shinagawa-web/tinytap" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;. Environment requirements and compatibility details are in the &lt;a href="https://shinagawa-web.github.io/tinytap/" rel="noopener noreferrer"&gt;docs&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>go</category>
      <category>showdev</category>
      <category>opensource</category>
      <category>devops</category>
    </item>
    <item>
      <title>Nginx, Caddy, Traefik, or HAProxy: How to Pick the Right Reverse Proxy for Your Stack (2026)</title>
      <dc:creator>Kazu</dc:creator>
      <pubDate>Tue, 25 Aug 2026 13:02:00 +0000</pubDate>
      <link>https://dev.to/shinagawa-web/nginx-caddy-traefik-or-haproxy-how-to-pick-the-right-reverse-proxy-for-your-stack-2026-2doj</link>
      <guid>https://dev.to/shinagawa-web/nginx-caddy-traefik-or-haproxy-how-to-pick-the-right-reverse-proxy-for-your-stack-2026-2doj</guid>
      <description>&lt;p&gt;Have you ever heard "Caddy is great" and wondered what actually makes it different from Nginx? Or maybe you were evaluating Traefik for a Kubernetes setup, went down a rabbit hole of comparison articles, and gave up halfway through?&lt;/p&gt;

&lt;p&gt;&lt;a href="https://dev.to/shinagawa-web/youve-configured-a-reverse-proxy-but-can-you-explain-why-it-works-part-1-1lpf"&gt;Part 1&lt;/a&gt; ("For People Who've Configured a Reverse Proxy but Can't Explain How It Works") covered how reverse proxies work under the hood. Part 2 — this article — focuses on tool selection: the design philosophies behind Nginx, Caddy, Traefik, and HAProxy, and a mental model for deciding which one fits your use case. If you haven't read Part 1, one baseline is enough: a reverse proxy is software that sits between the browser and the app server, relaying traffic between them.&lt;/p&gt;

&lt;h2&gt;
  
  
  The four tools and what each one is
&lt;/h2&gt;

&lt;p&gt;A quick one-liner for each:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Nginx: the most widely used general-purpose web server and reverse proxy&lt;/li&gt;
&lt;li&gt;Caddy: simple config file, automatic TLS certificate management built in&lt;/li&gt;
&lt;li&gt;Traefik: designed for dynamic routing config in container environments&lt;/li&gt;
&lt;li&gt;HAProxy: a full-featured load balancer that operates at both the TCP (L4) and HTTP (L7) levels&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;All four can do load balancing, reverse proxying, and TLS termination. Nginx can handle load balancing. HAProxy can work as a reverse proxy. Capability overlap is the point — what differs is design emphasis, not capability ceiling. That emphasis is what drives the choice.&lt;/p&gt;

&lt;p&gt;Consider what "emphasis" means in practice. Nginx was built as a general-purpose machine: serve static files, reverse proxy, act as a load balancer — all from one piece of software. Traefik was born from a specific frustration: having to rewrite config files every time a container starts or stops. The starting point differs, so even when both do the same job, one is working in its natural mode and the other is being stretched.&lt;/p&gt;

&lt;p&gt;Read this article through that lens: where does each tool's center of gravity sit?&lt;/p&gt;

&lt;p&gt;One tool you won't see in this comparison is Envoy. Envoy has native support for circuit breaking, distributed rate limiting, and gRPC load balancing, and it's usually encountered in the context of service meshes like Istio. The configuration complexity and operational overhead are in a different league. For someone who's only used Nginx, it's too big a jump. Envoy does appear in the bonus section at the end — not as "here's the next step to consider," but as "here's what these four can't reach."&lt;/p&gt;

&lt;h2&gt;
  
  
  Nginx vs Caddy: what's actually different
&lt;/h2&gt;

&lt;p&gt;Put the minimal HTTPS configuration for each side by side.&lt;/p&gt;

&lt;p&gt;The Nginx version:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;server&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;listen&lt;/span&gt; &lt;span class="mi"&gt;443&lt;/span&gt; &lt;span class="s"&gt;ssl&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;server_name&lt;/span&gt; &lt;span class="s"&gt;example.com&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="kn"&gt;ssl_certificate&lt;/span&gt; &lt;span class="n"&gt;/etc/ssl/certs/example.com.crt&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;ssl_certificate_key&lt;/span&gt; &lt;span class="n"&gt;/etc/ssl/private/example.com.key&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;ssl_protocols&lt;/span&gt; &lt;span class="s"&gt;TLSv1.2&lt;/span&gt; &lt;span class="s"&gt;TLSv1.3&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;ssl_ciphers&lt;/span&gt; &lt;span class="s"&gt;HIGH:!aNULL:!MD5&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="kn"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_pass&lt;/span&gt; &lt;span class="s"&gt;http://localhost:3000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;Host&lt;/span&gt; &lt;span class="nv"&gt;$host&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;X-Forwarded-For&lt;/span&gt; &lt;span class="nv"&gt;$proxy_add_x_forwarded_for&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;X-Forwarded-Proto&lt;/span&gt; &lt;span class="nv"&gt;$scheme&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The equivalent Caddyfile:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;example.com {
    reverse_proxy localhost:3000
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The line count difference might look like Caddy is skipping things — it isn't. With Caddy, TLS certificate retrieval and renewal happen automatically. It fetches a certificate from Let's Encrypt and renews it automatically well before the 90-day expiry, with no manual steps needed. There's no &lt;code&gt;ssl_certificate&lt;/code&gt; line because you don't supply the cert file yourself. There are no &lt;code&gt;proxy_set_header&lt;/code&gt; lines because Caddy forwards those headers by default.&lt;/p&gt;

&lt;p&gt;If you've ever wrestled with Nginx TLS config, Caddy's automatic TLS will be a real relief. Especially if you've had a service go down because a certificate renewal slipped through the cracks.&lt;/p&gt;

&lt;p&gt;That said, Caddy's simplicity cuts both ways. The convenient defaults make it harder to reason about what's happening when you need to override something. Nginx's verbosity is a feature: every directive is explicit, which makes troubleshooting more straightforward.&lt;/p&gt;

&lt;p&gt;The question to ask when choosing: do you want to control TLS yourself, or do you want to hand it off?&lt;/p&gt;

&lt;p&gt;Caddy does have cases where it doesn't fit. Internal services — those not exposed to the internet — won't work with ACME-based automatic TLS. Let's Encrypt challenges require the domain to be publicly reachable, so for a service on an internal DNS, you'd need extra DNS-01 challenge configuration to get Caddy's automatic TLS working. Going in with the assumption that "Caddy means TLS is automatic" will cause problems for internal services.&lt;/p&gt;

&lt;p&gt;There's also a debugging gap. &lt;code&gt;caddy reload&lt;/code&gt; and &lt;code&gt;caddy validate&lt;/code&gt; error messages can be less specific than Nginx's. Nginx's &lt;code&gt;nginx -t&lt;/code&gt; gives you the exact line number of the problem; Caddy sometimes only tells you "configuration is invalid." For a team with deep Nginx experience, troubleshooting an unfamiliar tool takes longer than it should.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Traefik pairs well with Kubernetes
&lt;/h2&gt;

&lt;p&gt;Traefik's starting point was: "I don't want to rewrite config files every time a container is added."&lt;/p&gt;

&lt;p&gt;With Nginx, you write this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;upstream&lt;/span&gt; &lt;span class="s"&gt;backend&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;server&lt;/span&gt; &lt;span class="nf"&gt;app1&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;3000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;server&lt;/span&gt; &lt;span class="nf"&gt;app2&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;3000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Adding &lt;code&gt;app3&lt;/code&gt; means opening the config file, adding &lt;code&gt;server app3:3000;&lt;/code&gt;, and reloading Nginx. Manual every time.&lt;/p&gt;

&lt;p&gt;Traefik works differently. With Docker, you attach labels to the container at startup and it joins the routing automatically.&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="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;my-app&lt;/span&gt;
    &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;traefik.enable=true"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;traefik.http.routers.app.rule=Host(`example.com`)"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;traefik.http.services.app.loadbalancer.server.port=3000"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The moment that container starts, Traefik reads the Docker labels and adds the route. When the container stops, the route disappears. No config reload needed.&lt;/p&gt;

&lt;p&gt;In Kubernetes, the same thing happens through Ingress annotations. Deploy a new service with &lt;code&gt;kubectl apply&lt;/code&gt;, and Traefik detects it and updates routing. The "update config files on every deploy across 20 containers" workflow goes away.&lt;/p&gt;

&lt;p&gt;If static config files are enough, Nginx works fine. When containers come and go frequently, that dynamic config automation is the reason to choose Traefik.&lt;/p&gt;

&lt;p&gt;Going deeper into Kubernetes: Traefik has its own CRD called IngressRoute. It's more expressive than the standard Ingress resource, but it means writing Traefik-specific config into your K8s manifests. Nginx Ingress Controller is built on the standard Ingress resource and uses annotations to control fine-grained behavior.&lt;/p&gt;

&lt;p&gt;Which you choose depends on whether you'd rather write in the proxy's config language or stay close to K8s manifests. If your team already knows Nginx, the Nginx Ingress Controller has a lower learning curve. If your service churn is high and you want routing definitions managed as code inside K8s, Traefik's IngressRoute is where it earns its keep.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why HAProxy gets chosen as a dedicated load balancer
&lt;/h2&gt;

&lt;p&gt;Ask the question "Nginx can load balance too, so why choose HAProxy?" and the answer surfaces.&lt;/p&gt;

&lt;p&gt;A health check in HAProxy config looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;backend&lt;/span&gt; &lt;span class="s"&gt;api_servers&lt;/span&gt;
    &lt;span class="s"&gt;balance&lt;/span&gt; &lt;span class="s"&gt;roundrobin&lt;/span&gt;
    &lt;span class="s"&gt;option&lt;/span&gt; &lt;span class="s"&gt;httpchk&lt;/span&gt; &lt;span class="s"&gt;GET&lt;/span&gt; &lt;span class="n"&gt;/health&lt;/span&gt; &lt;span class="s"&gt;HTTP/1.1&lt;/span&gt;
    &lt;span class="s"&gt;http-check&lt;/span&gt; &lt;span class="s"&gt;expect&lt;/span&gt; &lt;span class="s"&gt;status&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;
    &lt;span class="s"&gt;default-server&lt;/span&gt; &lt;span class="s"&gt;inter&lt;/span&gt; &lt;span class="s"&gt;2s&lt;/span&gt; &lt;span class="s"&gt;fall&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt; &lt;span class="s"&gt;rise&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="s"&gt;maxconn&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;
    &lt;span class="s"&gt;server&lt;/span&gt; &lt;span class="s"&gt;app1&lt;/span&gt; &lt;span class="nf"&gt;10.0.0.1&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;3000&lt;/span&gt; &lt;span class="s"&gt;check&lt;/span&gt;
    &lt;span class="s"&gt;server&lt;/span&gt; &lt;span class="s"&gt;app2&lt;/span&gt; &lt;span class="nf"&gt;10.0.0.2&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;3000&lt;/span&gt; &lt;span class="s"&gt;check&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;inter 2s fall 3 rise 2&lt;/code&gt; means: check every 2 seconds, mark down after 3 consecutive failures, mark up after 2 consecutive successes. Nginx can do something similar, but HAProxy lets you express this level of control directly in the config.&lt;/p&gt;

&lt;p&gt;Where HAProxy really stands out is observability — seeing what's happening in real time. The built-in stats dashboard shows connection counts, error rates, and latency per backend, live. If one backend starts spiking in latency because it's running out of memory, you can dig through Nginx access logs to find the cause eventually. Opening HAProxy's dashboard makes it immediately obvious which backend has the elevated error rate. When you're handling high traffic and need to know instantly which backend is the bottleneck, that data matters.&lt;/p&gt;

&lt;p&gt;HAProxy also handles both TCP (L4) and HTTP (L7) load balancing. Beyond HTTP apps, you can balance database connections and manage WebSocket traffic in config.&lt;/p&gt;

&lt;p&gt;HAProxy makes sense when traffic is heavy and you need fine-grained control over backend behavior. Reaching for it on a simple reverse proxy setup means absorbing configuration complexity without much benefit.&lt;/p&gt;

&lt;h2&gt;
  
  
  Picking one for your situation
&lt;/h2&gt;

&lt;p&gt;Mapping the discussion to four scenarios:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Scenario&lt;/th&gt;
&lt;th&gt;Choice&lt;/th&gt;
&lt;th&gt;Main reason&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Simple VPS + TLS termination&lt;/td&gt;
&lt;td&gt;Caddy&lt;/td&gt;
&lt;td&gt;Automatic TLS removes certificate management overhead&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Small Docker microservices (stable topology)&lt;/td&gt;
&lt;td&gt;Nginx&lt;/td&gt;
&lt;td&gt;Proven track record, large community, explicit config&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Small Docker microservices (high churn)&lt;/td&gt;
&lt;td&gt;Traefik&lt;/td&gt;
&lt;td&gt;Routing updates automatically as containers start and stop&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Kubernetes cluster&lt;/td&gt;
&lt;td&gt;Traefik / Nginx Ingress&lt;/td&gt;
&lt;td&gt;Depends on what the team already knows&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;High-traffic load balancer&lt;/td&gt;
&lt;td&gt;HAProxy&lt;/td&gt;
&lt;td&gt;Health check granularity and built-in observability&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;For a simple VPS with TLS termination, Caddy is the pick. Offloading certificate management is a meaningful gain, and the short config file is easy to maintain. It suits personal projects and small services.&lt;/p&gt;

&lt;p&gt;For small Docker-based microservices, Nginx or Traefik are both candidates. If the container count is stable and doesn't change much, Nginx is plenty. If you're adding and removing services regularly, Traefik's dynamic config earns its place.&lt;/p&gt;

&lt;p&gt;For Kubernetes, Traefik or Nginx Ingress Controller. Traefik has a head start on K8s integration, but Nginx Ingress Controller is mature and fully viable. If your team has deep Nginx knowledge, there's no good reason to switch to Traefik just because it's the trendy choice.&lt;/p&gt;

&lt;p&gt;For a high-traffic load balancer, HAProxy. That's where its design philosophy — fine-grained control and first-class observability — actually comes into play.&lt;/p&gt;

&lt;p&gt;My last personal choice was Caddy on a VPS. The reason was simple: I'd taken a service down once because I forgot to renew a certificate. Since switching to Caddy, I haven't thought about certificates once. That alone was worth it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Config files and benchmark
&lt;/h2&gt;

&lt;p&gt;Working examples for all four proxies are in &lt;a href="https://github.com/shinagawa-web/reverse-proxy-bench" rel="noopener noreferrer"&gt;shinagawa-web/reverse-proxy-bench&lt;/a&gt; — each proxy has its own directory with a config file and a &lt;code&gt;docker-compose.yml&lt;/code&gt;. Run any of them locally with &lt;code&gt;make bench-nginx&lt;/code&gt; (requires Docker and k6).&lt;/p&gt;

&lt;p&gt;The benchmark runs 60 seconds at 20 concurrent virtual users against a minimal Go backend (&lt;code&gt;/ping&lt;/code&gt; → 200 OK), with default configurations:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Proxy&lt;/th&gt;
&lt;th&gt;req/s&lt;/th&gt;
&lt;th&gt;p50 (ms)&lt;/th&gt;
&lt;th&gt;p95 (ms)&lt;/th&gt;
&lt;th&gt;p99 (ms)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;HAProxy&lt;/td&gt;
&lt;td&gt;10,762&lt;/td&gt;
&lt;td&gt;1.53&lt;/td&gt;
&lt;td&gt;3.45&lt;/td&gt;
&lt;td&gt;4.89&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Traefik&lt;/td&gt;
&lt;td&gt;7,946&lt;/td&gt;
&lt;td&gt;2.15&lt;/td&gt;
&lt;td&gt;4.62&lt;/td&gt;
&lt;td&gt;6.48&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Caddy&lt;/td&gt;
&lt;td&gt;7,535&lt;/td&gt;
&lt;td&gt;2.28&lt;/td&gt;
&lt;td&gt;4.93&lt;/td&gt;
&lt;td&gt;6.81&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Nginx&lt;/td&gt;
&lt;td&gt;4,386&lt;/td&gt;
&lt;td&gt;4.37&lt;/td&gt;
&lt;td&gt;5.43&lt;/td&gt;
&lt;td&gt;6.83&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;These results reflect default configurations on a 2-vCPU GitHub Actions runner. Nginx in particular has significant headroom — &lt;code&gt;worker_processes auto&lt;/code&gt; alone would narrow the gap considerably. The intent isn't to declare a winner, but to show how the design emphasis of each tool translates into behavior under the same load.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bonus: what these four can't do
&lt;/h2&gt;

&lt;p&gt;For intermediate and above: the areas where all four have the same blind spot.&lt;/p&gt;

&lt;h3&gt;
  
  
  Distributed rate limiting
&lt;/h3&gt;

&lt;p&gt;Nginx, Caddy, Traefik, and HAProxy all have rate limiting, but all of them apply limits per instance. Scale out to four instances and your limit effectively quadruples. A "100 requests per second" rule becomes "400 requests per second" in practice. If you hit a situation right after a scale-out where rate limiting suddenly seems broken, the connection to instance count isn't always obvious.&lt;/p&gt;

&lt;p&gt;You can share state via an external store like Redis — each proxy instance increments a Redis counter on every request and returns 429 when the threshold is hit. That turns Redis into a single point of failure, so you also need an availability design for Redis. Every request that touches Redis also adds the round-trip latency — typically 0.5–2ms on a local network, but under traffic spikes Redis itself can become the bottleneck, slowing the proxy down across the board.&lt;/p&gt;

&lt;p&gt;The tools that provide this natively are Kong and Envoy. Kong's Rate Limiting Advanced plugin uses a hybrid approach: local counters per instance, synchronized to a shared store on a configurable interval. Requests don't need a Redis write on every hit, which reduces the latency overhead significantly. Envoy is designed to integrate with a dedicated external rate limit service from the start.&lt;/p&gt;

&lt;h3&gt;
  
  
  Circuit breaking
&lt;/h3&gt;

&lt;p&gt;Suppose an upstream service starts responding slowly. The proxy holds connections open until timeout, and new requests keep trying to connect. The connection pool fills with waiting connections, other backends start backing up too, and failures cascade through the whole system. That's a cascading failure.&lt;/p&gt;

&lt;p&gt;A circuit breaker cuts that chain. When it detects consecutive failures to an upstream, it stops sending new requests there and returns errors immediately. Envoy has this natively via &lt;code&gt;outlier_detection&lt;/code&gt; — setting &lt;code&gt;consecutive_5xx: 5&lt;/code&gt; and &lt;code&gt;base_ejection_time: 30s&lt;/code&gt; is enough to get it working. None of these four have an equivalent. Traefik's circuit breaker plugin uses similar configuration, but it operates at the route level rather than the stream level, which makes it less effective for long-lived streams like gRPC where a single stream can hold a backend connection open for minutes.&lt;/p&gt;

&lt;h3&gt;
  
  
  Native JWT verification / OIDC
&lt;/h3&gt;

&lt;p&gt;Traefik and Caddy have plugin support for this, but many of the available plugins are community-maintained. How they handle JWKS endpoint caching and key rotation varies, and some don't document their behavior clearly. If a signing key rotates and the plugin doesn't refresh its cache in time, valid tokens start getting rejected. Nginx requires Lua or the NJS module. HAProxy is in a similar position.&lt;/p&gt;

&lt;p&gt;If you want to handle authentication at the proxy layer reliably, Kong or Envoy are the more practical choices. Kong's JWT plugin caches JWKS with configurable TTL and handles key rotation predictably — which matters when authentication is in the critical path.&lt;/p&gt;

&lt;h3&gt;
  
  
  gRPC load balancing
&lt;/h3&gt;

&lt;p&gt;With HTTP/1.1, a new connection is opened per request (or closed quickly), so the proxy distributes at the request level. gRPC runs over HTTP/2, which multiplexes requests over a single long-lived connection.&lt;/p&gt;

&lt;p&gt;Put Nginx in front of a gRPC backend as a load balancer, and Nginx maintains one persistent connection to each backend. Those connections stay alive for a long time. Stream distribution becomes uneven, and specific backends end up with concentrated load. You can have 10 backends but effectively route to only 1 or 2 of them.&lt;/p&gt;

&lt;p&gt;Nginx can partially work around this with &lt;code&gt;keepalive&lt;/code&gt; on the upstream block combined with HTTP/2 proxying, but stream-level routing — deciding per-request which backend to send to — remains limited. Traefik opens multiple HTTP/2 streams per backend connection and rebalances more aggressively than Nginx's static upstream model, so load spreads more evenly across backends. Envoy is built gRPC-first, with stream-level load balancing as a baseline assumption.&lt;/p&gt;

</description>
      <category>nginx</category>
      <category>devops</category>
      <category>webdev</category>
      <category>opensource</category>
    </item>
    <item>
      <title>You've Configured a Reverse Proxy, But Can You Explain Why It Works? (Part 1)</title>
      <dc:creator>Kazu</dc:creator>
      <pubDate>Thu, 20 Aug 2026 12:01:00 +0000</pubDate>
      <link>https://dev.to/shinagawa-web/youve-configured-a-reverse-proxy-but-can-you-explain-why-it-works-part-1-1lpf</link>
      <guid>https://dev.to/shinagawa-web/youve-configured-a-reverse-proxy-but-can-you-explain-why-it-works-part-1-1lpf</guid>
      <description>&lt;p&gt;You wrote &lt;code&gt;proxy_pass http://localhost:3000;&lt;/code&gt;, checked it in the browser, saw it working, and called it done. But you couldn't actually explain why it works. What's happening between nginx and the app? Where is the request going? You probably couldn't answer that with any confidence.&lt;/p&gt;

&lt;p&gt;Getting something working and understanding how it works are two different things. This article (Part 1) is about the how. Once you have that, the next question becomes which tool to use. In &lt;a href="https://dev.to/shinagawa-web/nginx-caddy-traefik-or-haproxy-how-to-pick-the-right-reverse-proxy-for-your-stack-2026-2doj"&gt;Part 2&lt;/a&gt;, I'll break down the design philosophies behind nginx, Caddy, Traefik, and HAProxy, and give you a framework for choosing the one that fits your stack.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the browser is actually connecting
&lt;/h2&gt;

&lt;p&gt;When a browser sends a GET to &lt;code&gt;https://api.example.com/users&lt;/code&gt;, it resolves &lt;code&gt;api.example.com&lt;/code&gt; via DNS and opens a TCP connection to port 443 at that IP. Who's on the other end of that connection?&lt;/p&gt;

&lt;p&gt;In a setup with a reverse proxy, it's not the app server — it's the proxy.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Browser  ──[TCP connection]──→  nginx (443)  ──[separate TCP connection]──→  App (3000)
         ←───────────────────                ←──────────────────────────────
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the key point. The browser and the app server are not talking directly. The proxy holds two separate connections and shuttles requests between them. That's what "split communication" means: the browser has no idea what the app server's IP address is, what port it's on, or that it even exists.&lt;/p&gt;

&lt;p&gt;This split is also the root cause of several problems I'll get to later. The reason the browser's IP disappears from the app's perspective, and the reason the Host header gets rewritten — both trace back to this structure.&lt;/p&gt;

&lt;h2&gt;
  
  
  How it differs from a forward proxy
&lt;/h2&gt;

&lt;p&gt;"Proxy" is an overloaded term. Forward proxy and reverse proxy are easy to mix up. Both relay traffic, but they stand in for opposite sides, and they hide opposite things.&lt;/p&gt;

&lt;p&gt;A forward proxy acts on behalf of the client. You see it in corporate networks, where internal machines can't go directly to the internet — they route through a proxy server. The destination server sees the proxy, not the individual machines behind it. What's hidden is the client.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Internal PC  ──→  Forward proxy  ──→  External server
                                       ↑
                             Internal PC is not visible
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A reverse proxy acts on behalf of the server. The client (browser) connects to the proxy without knowing how many app servers are behind it or how they're arranged. What's hidden is the server infrastructure.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Browser  ──→  Reverse proxy  ──→  App server
                                   ↑
                         Server is not visible
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Forward proxies hide the client; reverse proxies hide the server. That distinction comes back when we look at what disappears when you add a proxy.&lt;/p&gt;

&lt;h2&gt;
  
  
  What happens before a request reaches the app
&lt;/h2&gt;

&lt;p&gt;Between the browser sending a request and the app server receiving it, the proxy does several things. Let's trace through a concrete nginx config.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;server&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;listen&lt;/span&gt; &lt;span class="mi"&gt;443&lt;/span&gt; &lt;span class="s"&gt;ssl&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;server_name&lt;/span&gt; &lt;span class="s"&gt;api.example.com&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="kn"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/users&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_pass&lt;/span&gt; &lt;span class="s"&gt;http://localhost:3000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;Host&lt;/span&gt; &lt;span class="nv"&gt;$host&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;X-Forwarded-For&lt;/span&gt; &lt;span class="nv"&gt;$proxy_add_x_forwarded_for&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;X-Forwarded-Proto&lt;/span&gt; &lt;span class="nv"&gt;$scheme&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You've probably written something like this. Let's go through each line and why it's there.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;proxy_set_header Host $host;&lt;/code&gt; — The browser's HTTP request includes a header like &lt;code&gt;Host: api.example.com&lt;/code&gt;. When the proxy forwards that request to the app server, the Host header gets rewritten to &lt;code&gt;localhost:3000&lt;/code&gt; by default, because that's the host of the connection the proxy opened. If your app needs to know the original domain — for virtual host routing, or to construct a redirect URL — it needs &lt;code&gt;api.example.com&lt;/code&gt;, not &lt;code&gt;localhost:3000&lt;/code&gt;. This line passes the original value through explicitly.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;&lt;/code&gt; — X-Forwarded-For (XFF) is a header that records the list of IP addresses a request has passed through. When the proxy forwards a request to the app, the connection comes from the proxy, not the browser. From the app's perspective, the remote IP is the proxy's address. To tell the app what the original client IP was, the proxy adds this header. &lt;code&gt;$proxy_add_x_forwarded_for&lt;/code&gt; is an nginx variable that appends to an existing XFF header if one is present, or creates a new one with the connecting IP if not.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;proxy_set_header X-Forwarded-Proto $scheme;&lt;/code&gt; — When the proxy receives HTTPS on port 443 and forwards it to the app as HTTP, the app has no way of knowing the original request came over HTTPS. X-Forwarded-Proto carries that information.&lt;/p&gt;

&lt;p&gt;Each of those settings exists because of the connection split from the first section.&lt;/p&gt;

&lt;h2&gt;
  
  
  What problems a reverse proxy solves
&lt;/h2&gt;

&lt;p&gt;Consider what happens if there's no proxy and the app server has to handle everything itself.&lt;/p&gt;

&lt;p&gt;To serve over HTTPS, you need to manage a server certificate and handle TLS handshakes. A Node.js app doing that on its own looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;https&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;fs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;fs&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;options&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;readFileSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/etc/ssl/private/server.key&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;cert&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;readFileSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/etc/ssl/certs/server.crt&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="nx"&gt;https&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createServer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;options&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;listen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;443&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The app needs to know the certificate file paths. Every time you renew the certificate, you restart the app. If you're using Let's Encrypt for automatic 90-day renewals, that means the app restarts on every renewal cycle. Cipher suite selection and protocol version management live in your app code too — every time TLS 1.0/1.1 gets deprecated somewhere, you touch the app.&lt;/p&gt;

&lt;p&gt;When nginx handles TLS termination (ending the encrypted connection with the client at the proxy), your app code becomes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Only needs to speak HTTP&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;http&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;http&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;http&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createServer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;listen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Certificate management and TLS configuration stay in the nginx config file. The app server speaks plain HTTP and never needs to restart because a cert was renewed.&lt;/p&gt;

&lt;p&gt;When traffic grows and a single app server can't keep up, you add more. But if browsers connect to app servers directly, there's no way to spread requests across the new instances. With a proxy, you get load balancing across multiple upstreams:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;upstream&lt;/span&gt; &lt;span class="s"&gt;api_servers&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;least_conn&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;# send to the server with fewest active connections&lt;/span&gt;
    &lt;span class="kn"&gt;server&lt;/span&gt; &lt;span class="nf"&gt;10.0.0.1&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;3000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;server&lt;/span&gt; &lt;span class="nf"&gt;10.0.0.2&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;3000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;server&lt;/span&gt; &lt;span class="nf"&gt;10.0.0.3&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;3000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;least_conn&lt;/code&gt; sends each request to whichever server currently has the fewest active connections. It distributes load more evenly than the default round-robin behavior when response times vary — slow requests don't pile up on one server.&lt;/p&gt;

&lt;p&gt;Routing &lt;code&gt;/api/&lt;/code&gt; requests to the backend cluster and &lt;code&gt;/static/&lt;/code&gt; requests to a file server is also more naturally expressed at the proxy layer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;server&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;listen&lt;/span&gt; &lt;span class="mi"&gt;443&lt;/span&gt; &lt;span class="s"&gt;ssl&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;server_name&lt;/span&gt; &lt;span class="s"&gt;api.example.com&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="kn"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/api/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_pass&lt;/span&gt; &lt;span class="s"&gt;http://api_servers&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="kn"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/static/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_pass&lt;/span&gt; &lt;span class="s"&gt;http://file_server&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The routing logic stays out of your app code. Where each request goes is visible in the nginx config.&lt;/p&gt;

&lt;p&gt;These are usually described as "reverse proxy features," but what they really are is work that doesn't belong in the app server. The proxy takes on what would otherwise be scattered across every instance, and the app gets to stay focused on what it's actually for.&lt;/p&gt;

&lt;h2&gt;
  
  
  What disappears when you add a proxy
&lt;/h2&gt;

&lt;p&gt;After adding a reverse proxy, you look at your app's access logs and every IP is &lt;code&gt;127.0.0.1&lt;/code&gt;. This trips people up. I was one of them.&lt;/p&gt;

&lt;p&gt;Before the proxy:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Before proxy&lt;/span&gt;
&lt;span class="k"&gt;203.0.113.45&lt;/span&gt; &lt;span class="s"&gt;-&lt;/span&gt; &lt;span class="s"&gt;-&lt;/span&gt; &lt;span class="s"&gt;[12/Aug/2026:10:00:01&lt;/span&gt; &lt;span class="s"&gt;+0000]&lt;/span&gt; &lt;span class="s"&gt;"GET&lt;/span&gt; &lt;span class="n"&gt;/users&lt;/span&gt; &lt;span class="s"&gt;HTTP/1.1"&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt; &lt;span class="mi"&gt;512&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After adding the proxy, without XFF headers configured:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="c1"&gt;# After proxy (no XFF config)&lt;/span&gt;
&lt;span class="k"&gt;127.0.0.1&lt;/span&gt; &lt;span class="s"&gt;-&lt;/span&gt; &lt;span class="s"&gt;-&lt;/span&gt; &lt;span class="s"&gt;[12/Aug/2026:10:00:01&lt;/span&gt; &lt;span class="s"&gt;+0000]&lt;/span&gt; &lt;span class="s"&gt;"GET&lt;/span&gt; &lt;span class="n"&gt;/users&lt;/span&gt; &lt;span class="s"&gt;HTTP/1.1"&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt; &lt;span class="mi"&gt;512&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The reason is straightforward: the app's connection comes from the proxy, and if the proxy and app are on the same server, that's the loopback address. What the app sees is "the IP of whatever connected to me," not "the browser's IP."&lt;/p&gt;

&lt;p&gt;The fix is to read the X-Forwarded-For header that nginx is attaching:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;# Value available when the app reads XFF
X-Forwarded-For: 203.0.113.45
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But XFF is a header the client can freely manipulate, so trusting the wrong source means your IP can be spoofed.&lt;/p&gt;

&lt;p&gt;If you don't notice that the log format changed after setting up the proxy, debugging gets confusing fast. If your app does any IP-based access control and isn't reading XFF, every request looks like it's coming from the same address.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrapping up
&lt;/h2&gt;

&lt;p&gt;Everything in this article comes back to one thing: the communication is split into two separate TCP connections. Because of that split, the Host header has to be passed through explicitly, the client IP disappears, and TLS termination at the proxy becomes a viable pattern. Every line in that config exists because of this split. Which tool to use for the proxy is &lt;a href="https://dev.to/shinagawa-web/nginx-caddy-traefik-or-haproxy-how-to-pick-the-right-reverse-proxy-for-your-stack-2026-2doj"&gt;Part 2&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If what's in this article has stuck, you should be able to answer something like: "The proxy holds two TCP connections and splits the communication. That's why the Host header gets rewritten and the client IP disappears. TLS ends at the proxy." You can now explain what's happening between nginx and your app.&lt;/p&gt;

</description>
      <category>nginx</category>
      <category>webdev</category>
      <category>devops</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>curl works, your app gets 400: read the exact request your SDK sent</title>
      <dc:creator>Kazu</dc:creator>
      <pubDate>Tue, 18 Aug 2026 13:01:00 +0000</pubDate>
      <link>https://dev.to/shinagawa-web/curl-works-app-gets-400-peek-at-the-raw-https-your-app-actually-sent-o9e</link>
      <guid>https://dev.to/shinagawa-web/curl-works-app-gets-400-peek-at-the-raw-https-your-app-actually-sent-o9e</guid>
      <description>&lt;p&gt;curl returns 200. The same request from your app returns 400. You followed the docs. The parameters look right. It still doesn't work.&lt;/p&gt;

&lt;p&gt;When that happens, what you really want to know is what your app actually sent. But that's the one thing you can't see. The SDK assembles the final request below your code, and TLS encrypts it before it hits the wire — capture the traffic and you get ciphertext.&lt;/p&gt;

&lt;p&gt;This post is about seeing that plaintext with one command, no CA certificate install, no proxy, no changes to your app. The tool is &lt;a href="https://github.com/shinagawa-web/tinytap" rel="noopener noreferrer"&gt;tinytap&lt;/a&gt;, something I built for exactly this. Here's what it looks like.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvngwl5r0x1wmosduij8a.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvngwl5r0x1wmosduij8a.gif" alt="The left terminal runs the app and sends one HTTPS request. The right terminal shows tinytap printing that request as plaintext — request line, headers, Authorization: Bearer token, JSON body."&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  curl works, the app gets 400
&lt;/h2&gt;

&lt;p&gt;Here's a concrete example of how this goes wrong. You're hitting a payment API that expects an integer amount in cents. Your code passes &lt;code&gt;1000&lt;/code&gt;. The app returns &lt;code&gt;400 invalid integer: amount&lt;/code&gt;. You hardcode the same value in &lt;code&gt;curl&lt;/code&gt; and it goes through fine.&lt;/p&gt;

&lt;p&gt;The usual suspects come to mind. Double-encoded body? Type coercion somewhere? Wrong Content-Type? Missing token? All plausible, none confirmable. Your app's logs show the &lt;code&gt;1000&lt;/code&gt; you passed into the SDK — not the final form that went over the wire.&lt;/p&gt;

&lt;p&gt;If you could see the request body with &lt;code&gt;"amount": "10.00"&lt;/code&gt; — the value that silently became a string somewhere — you'd have exactly what you need to compare what you meant to send against what you actually sent.&lt;/p&gt;

&lt;h2&gt;
  
  
  The SDK's final form never appears in any log
&lt;/h2&gt;

&lt;p&gt;The obvious move is to check the logs. But whatever you &lt;code&gt;print&lt;/code&gt; or pass to your logger is the value before the SDK gets it. The SDK adds headers, serializes the body, and possibly retries or signs the request — all below the layer your logs can see. You get the intent, not the result.&lt;/p&gt;

&lt;p&gt;Wireshark is another option. But the connection is TLS, so you get ciphertext. Decoding that requires setting up &lt;code&gt;SSLKEYLOGFILE&lt;/code&gt; and a decryption step, and not every client supports it.&lt;/p&gt;

&lt;p&gt;mitmproxy gives you plaintext — but it requires installing a CA certificate in your trust store, configuring your app to route through the proxy, and some SDKs ignore proxy environment variables entirely. Installing a CA cert on your machine is a heavy setup just for debugging.&lt;/p&gt;

&lt;p&gt;So there was a gap: seeing the actual plaintext, without touching the app, without installing a certificate, without a proxy in the middle.&lt;/p&gt;

&lt;h2&gt;
  
  
  Peek at the plaintext before it gets encrypted
&lt;/h2&gt;

&lt;p&gt;That's what tinytap does. The gif above shows it: run your app on the left, and the request's plaintext appears on the right in tinytap.&lt;/p&gt;

&lt;p&gt;The left side is running this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;openai&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;OpenAI&lt;/span&gt;

&lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;OpenAI&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;api_key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sk-...&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;base_url&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://127.0.0.1:8443/v1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;chat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;completions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;gpt-4o-mini&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;role&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;what is a uprobe&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}],&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And on the right:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="nf"&gt;POST&lt;/span&gt; &lt;span class="nn"&gt;/v1/chat/completions&lt;/span&gt; &lt;span class="k"&gt;HTTP&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="m"&gt;1.1&lt;/span&gt;
&lt;span class="na"&gt;Host&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;127.0.0.1:8443&lt;/span&gt;
&lt;span class="na"&gt;Authorization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Bearer sk-demo-xxxx&lt;/span&gt;
&lt;span class="na"&gt;Content-Type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;application/json&lt;/span&gt;
&lt;span class="na"&gt;User-Agent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;OpenAI/Python 2.53.0&lt;/span&gt;
&lt;span class="s"&gt;...&lt;/span&gt;
&lt;span class="s"&gt;Request body (decoded): {"messages":[{"role":"user","content":"what is a uprobe"}],"model":"gpt-4o-mini"}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The request line, every header, the &lt;code&gt;Authorization: Bearer&lt;/code&gt; token, the JSON body — exactly what the SDK built and sent. The &lt;code&gt;User-Agent: OpenAI/Python&lt;/code&gt; line confirms this is real bytes from the real SDK, not something I reconstructed. No proxy, no certificate, no changes to the app. The token in the gif is a dummy (&lt;code&gt;sk-demo-xxxx&lt;/code&gt;), but when you point tinytap at your own app, your real token appears in plaintext just like this.&lt;/p&gt;

&lt;p&gt;Your code on the left, what it actually sent on the right. The moment you see both side by side, the "I thought I sent X" question is answered on the spot.&lt;/p&gt;

&lt;h2&gt;
  
  
  This is not breaking TLS
&lt;/h2&gt;

&lt;p&gt;Worth stating upfront before anyone raises an eyebrow: this is not a vulnerability that lets you intercept traffic.&lt;/p&gt;

&lt;p&gt;tinytap looks at the buffer your process hands to the TLS library, just before encryption — and the buffer that comes back out, just after decryption.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;App
  │  plaintext
  ▼
TLS library  ◀── tinytap looks here
  │  ciphertext
  ▼
Network
  │  ciphertext
  ▼
API server
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It hooks into your process from the inside, at the moment the plaintext is still plaintext. TLS itself is untouched. Other people's traffic is not accessible. This isn't about someone with root on your machine — it's about the inside of your own process. It's the same idea as &lt;code&gt;curl&lt;/code&gt; printing its own request to your terminal. The ciphertext on the wire is untouched. All you're doing is looking at it from your own side, one step before encryption.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it yourself
&lt;/h2&gt;

&lt;p&gt;"Plaintext without a certificate or proxy" sounds like it might be overselling it. So here's how to reproduce it yourself. tinytap uses eBPF, which means Linux only. On Mac or Windows, spin up a Linux container with Docker.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# install&lt;/span&gt;
curl &lt;span class="nt"&gt;-fsSL&lt;/span&gt; https://raw.githubusercontent.com/shinagawa-web/tinytap/main/scripts/install.sh | sh

&lt;span class="c"&gt;# grant the capabilities needed for eBPF and TLS capture&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;setcap cap_dac_read_search,cap_perfmon,cap_bpf,cap_sys_admin&lt;span class="o"&gt;=&lt;/span&gt;eip &lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;command&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; tinytap&lt;span class="si"&gt;)&lt;/span&gt;

&lt;span class="c"&gt;# set the execute bit on libssl (Debian/Ubuntu ships it without one)&lt;/span&gt;
&lt;span class="nb"&gt;sudo chmod&lt;/span&gt; +x &lt;span class="si"&gt;$(&lt;/span&gt;ldconfig &lt;span class="nt"&gt;-p&lt;/span&gt; | &lt;span class="nb"&gt;grep &lt;/span&gt;libssl.so | &lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="s1"&gt;'{print $NF}'&lt;/span&gt; | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-1&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;

tinytap
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;setcap&lt;/code&gt; grants the binary the capabilities it needs for eBPF and TLS capture. &lt;code&gt;chmod +x&lt;/code&gt; sets the execute bit on libssl — Debian and Ubuntu ship &lt;code&gt;libssl.so.3&lt;/code&gt; without it by default, so this line is required there. With both steps done, tinytap runs without &lt;code&gt;sudo&lt;/code&gt;. Run your app normally and the requests show up.&lt;/p&gt;

&lt;p&gt;If something doesn't work, run &lt;code&gt;tinytap doctor&lt;/code&gt;. It checks your kernel version, BTF availability, capabilities, and the libssl execute bit, and tells you exactly what's missing.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;tinytap doctor
&lt;span class="go"&gt;tinytap doctor — v0.6.1

&lt;/span&gt;&lt;span class="gp"&gt;[OK      ] kernel version               6.1.0 (&amp;gt;&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; 5.8 required&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="go"&gt;[OK      ] kernel BTF                   present
[OK      ] cap_dac_read_search          present
[OK      ] cap_perfmon                  present
[OK      ] cap_bpf                      present
[OK      ] cap_sys_admin                present
[OK      ] cap_syslog                   not needed on arm64
[OK      ] perf_event_paranoid          4
[OK      ] unprivileged_bpf_disabled    2
[OK      ] RLIMIT_MEMLOCK               soft=unlimited hard=unlimited
[OK      ] syscall tracepoints          available
[OK      ] BPF dry-run load             ok
[DEGRADED] libssl execute bit           /usr/lib/x86_64-linux-gnu/libssl.so.3 not set
    Affects: TLS capture only. Plaintext HTTP capture is unaffected.
    Fix:     sudo chmod +x /usr/lib/x86_64-linux-gnu/libssl.so.3

12 ok, 1 degraded, 0 blocking, 0 info
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;tinytap only speaks HTTP/1.1 — gRPC and HTTP/2 binary frames aren't readable as-is. Go is also out of scope: its &lt;code&gt;crypto/tls&lt;/code&gt; bypasses OpenSSL entirely. Python, Ruby, PHP, and Node.js all work out of the box. The language is a quick guide: Python / Ruby / PHP / Node.js, you're good; Go, you'll need a different approach.&lt;/p&gt;

&lt;p&gt;Instead of the self-signed local server in the demo, you can point tinytap at whatever API-connected app you normally run. The &lt;code&gt;Authorization&lt;/code&gt; header and body that show up are what you need to debug "curl works, app gets 400."&lt;/p&gt;

&lt;p&gt;Which SDK has bitten you? Silent retries, double-encoded bodies, headers disappearing for no reason — if any of that sounds familiar, it's probably the kind of thing that resolves the moment you can see what was actually sent.&lt;/p&gt;

&lt;h2&gt;
  
  
  So how does it actually see the plaintext?
&lt;/h2&gt;

&lt;p&gt;This post has stayed focused on what tinytap shows, not how it works. The mechanism — eBPF and uprobes, hooking into the TLS library boundary — is a story for another post.&lt;/p&gt;

&lt;p&gt;The server returned 400. It's telling you the request was wrong. You don't know what's wrong because you can't see what your code actually sent. If you could see it, you'd just compare what you meant to send against what went out. &lt;a href="https://github.com/shinagawa-web/tinytap" rel="noopener noreferrer"&gt;tinytap&lt;/a&gt; is built for that.&lt;/p&gt;

</description>
      <category>devops</category>
      <category>opensource</category>
      <category>showdev</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>"nginx doesn't run your config top to bottom: two traps with rewrite, try_files, and if"</title>
      <dc:creator>Kazu</dc:creator>
      <pubDate>Tue, 11 Aug 2026 13:01:00 +0000</pubDate>
      <link>https://dev.to/shinagawa-web/nginx-doesnt-run-your-config-top-to-bottom-two-traps-with-rewrite-tryfiles-and-if-2hlm</link>
      <guid>https://dev.to/shinagawa-web/nginx-doesnt-run-your-config-top-to-bottom-two-traps-with-rewrite-tryfiles-and-if-2hlm</guid>
      <description>&lt;p&gt;You write a &lt;code&gt;rewrite&lt;/code&gt;, then a &lt;code&gt;try_files&lt;/code&gt; right under it, and you read it as "the rewrite on top runs first, the try_files below runs after." You put an &lt;code&gt;if&lt;/code&gt; inside a &lt;code&gt;location&lt;/code&gt; and read it as "when the condition is true, this stuff gets added to the outer config." Both readings trace the config file straight down, line by line, in the order it's written. And both have burned me.&lt;/p&gt;

&lt;p&gt;Let me show you the two symptoms first. In each case the config reads cleanly, yet the behavior doesn't line up. Why that happens — the order in which nginx processes a request — is what this post is about.&lt;/p&gt;

&lt;h2&gt;
  
  
  Symptom 1: rewrite and try_files side by side, and you get a 404
&lt;/h2&gt;

&lt;p&gt;In some location you want to rewrite the request once, then fall back to a static file. The naive version looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/app/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;rewrite&lt;/span&gt; &lt;span class="s"&gt;^/app/(.*)&lt;/span&gt;$ &lt;span class="n"&gt;/public/&lt;/span&gt;&lt;span class="nv"&gt;$1&lt;/span&gt; &lt;span class="s"&gt;last&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;try_files&lt;/span&gt; &lt;span class="nv"&gt;$uri&lt;/span&gt; &lt;span class="nv"&gt;$uri&lt;/span&gt;&lt;span class="n"&gt;/&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;404&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The intent is "rewrite &lt;code&gt;/app/foo&lt;/code&gt; to &lt;code&gt;/public/foo&lt;/code&gt;, serve the file if it exists, 404 otherwise." Since &lt;code&gt;rewrite&lt;/code&gt; is on top and &lt;code&gt;try_files&lt;/code&gt; is below, the rewrite takes effect first and then try_files handles the fallback — that's how it reads.&lt;/p&gt;

&lt;p&gt;But throw &lt;code&gt;/app/logo.png&lt;/code&gt; at it and you get a 404, even when &lt;code&gt;/public/logo.png&lt;/code&gt; really exists.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;ls&lt;/span&gt; /public/logo.png
/public/logo.png            &lt;span class="c"&gt;# the file is definitely there&lt;/span&gt;

&lt;span class="nv"&gt;$ &lt;/span&gt;curl &lt;span class="nt"&gt;-sI&lt;/span&gt; http://localhost/app/logo.png
HTTP/1.1 404 Not Found      &lt;span class="c"&gt;# and yet, 404&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;try_files&lt;/code&gt; in this location is never evaluated once.&lt;/p&gt;

&lt;h2&gt;
  
  
  Symptom 2: config placed inside an &lt;code&gt;if&lt;/code&gt; block doesn't take effect
&lt;/h2&gt;

&lt;p&gt;In another location you want to add one header conditionally. The naive version looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/download/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;add_header&lt;/span&gt; &lt;span class="s"&gt;X-Served-By&lt;/span&gt; &lt;span class="s"&gt;"nginx"&lt;/span&gt; &lt;span class="s"&gt;always&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="kn"&gt;if&lt;/span&gt; &lt;span class="s"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$arg_debug&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"1")&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kn"&gt;add_header&lt;/span&gt; &lt;span class="s"&gt;X-Debug&lt;/span&gt; &lt;span class="s"&gt;"on"&lt;/span&gt; &lt;span class="s"&gt;always&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;# only want this when ?debug=1&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="kn"&gt;root&lt;/span&gt; &lt;span class="n"&gt;/var/www&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;try_files&lt;/span&gt; &lt;span class="nv"&gt;$uri&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;404&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The intent is "always add &lt;code&gt;X-Served-By&lt;/code&gt;, and add &lt;code&gt;X-Debug&lt;/code&gt; on top of it only when &lt;code&gt;?debug=1&lt;/code&gt;." But hit &lt;code&gt;/download/foo?debug=1&lt;/code&gt; and &lt;code&gt;X-Debug&lt;/code&gt; shows up while &lt;code&gt;X-Served-By&lt;/code&gt;, which you set on the outside, has vanished. The moment the condition goes true, part of the outer config drops.&lt;/p&gt;

&lt;p&gt;Both configs read cleanly when you scan the lines top to bottom. And both miss.&lt;/p&gt;

&lt;h2&gt;
  
  
  How it works: nginx's HTTP processing phases
&lt;/h2&gt;

&lt;p&gt;nginx runs each request through a fixed sequence of processing phases. Which phase a given directive runs in is determined per directive type, and has nothing to do with where the line sits in the config file. Both symptoms come out of exactly this one fact.&lt;/p&gt;

&lt;p&gt;Start with the big picture. Lay out the phases an HTTP request passes through, in order, and you get this (source: the official &lt;a href="https://nginx.org/en/docs/dev/development_guide.html#http_phases" rel="noopener noreferrer"&gt;Phases section of the Development Guide&lt;/a&gt;).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;post-read       right after the request is read
    ↓
server-rewrite  rewrite directly under server (before location selection)
    ↓
find-config     pick the location
    ↓
rewrite         rewrite / if inside the location
    ↓
post-rewrite    if the URI changed, go back to find-config
    ↓
preaccess       connection count, rate limiting, etc.
    ↓
access          allow / deny / auth
    ↓
post-access
    ↓
precontent      try_files
    ↓
content         serving files via proxy_pass / root
    ↓
log             access log
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same &lt;code&gt;rewrite&lt;/code&gt; runs before location selection (server-rewrite) when written directly under &lt;code&gt;server&lt;/code&gt;, and after it (rewrite) when written inside a &lt;code&gt;location&lt;/code&gt;. Where you put it changes the phase, which changes the order it runs in. What decides the order is not the line sequence but "which block, and which type of directive" — that's how you read this list. Now let's follow how each of the two symptoms travels along this timeline.&lt;/p&gt;

&lt;h3&gt;
  
  
  Symptom 1, followed by phase
&lt;/h3&gt;

&lt;p&gt;The part that matters for &lt;code&gt;rewrite ... last&lt;/code&gt; and &lt;code&gt;try_files&lt;/code&gt; is this stretch.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  ┌─▶ find-config     pick the location (first pass: /app/. with last, the second pass picks a different one)
  │       ↓
  │   rewrite         rewrite ... last rewrites to /public/foo
  │       ↓
  └── post-rewrite    URI changed → back to find-config
          ↓
      precontent      try_files runs here. we're now in the different location picked on the second pass.
                      the try_files written in the original /app/ is never reached → 404
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;a href="https://nginx.org/en/docs/http/ngx_http_rewrite_module.html#rewrite" rel="noopener noreferrer"&gt;&lt;code&gt;last&lt;/code&gt;&lt;/a&gt; in &lt;code&gt;rewrite ... last&lt;/code&gt; means "once you've rewritten the URI, redo location selection with the new URI." So the instant &lt;code&gt;/app/foo&lt;/code&gt; becomes &lt;code&gt;/public/foo&lt;/code&gt;, the post-rewrite phase calls find-config back and picks a location all over again. The rewritten &lt;code&gt;/public/foo&lt;/code&gt; no longer matches &lt;code&gt;/app/&lt;/code&gt;, so it moves to a different location. Meanwhile &lt;a href="https://nginx.org/en/docs/http/ngx_http_core_module.html#try_files" rel="noopener noreferrer"&gt;&lt;code&gt;try_files&lt;/code&gt;&lt;/a&gt; runs way later, in the precontent phase. It may get evaluated wherever the request landed, but the &lt;code&gt;try_files&lt;/code&gt; written in the original &lt;code&gt;/app/&lt;/code&gt; is never reached again. If the destination has no file and no fallback, that's where the 404 comes from. It's not that your &lt;code&gt;=404&lt;/code&gt; fired — the request dead-ended somewhere else, never reaching the original &lt;code&gt;try_files&lt;/code&gt;. "Written below, so it runs after" is not what happens; the phases differ, so regardless of line position the rewrite fires first and jumps out of the location.&lt;/p&gt;

&lt;h3&gt;
  
  
  Symptom 2, followed by phase
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;if&lt;/code&gt; and &lt;code&gt;add_header&lt;/code&gt; take effect in two phases that are far apart.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;find-config     pick the location (/download/)
    ↓
rewrite         if is true → switch the config context to the inner block
    ↓           (the switch carries over into all later phases)
preaccess       connection count, rate limiting, etc.
    ↓
access          allow / deny / auth
    ↓
post-access
    ↓
precontent      try_files
    ↓
content         apply add_header when the response is being sent
                it looks at the block we switched into (X-Debug only)
                → the outer X-Served-By is gone
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An &lt;code&gt;if&lt;/code&gt; inside a location is an "implicit nested location" — it creates a config block of its own. And that &lt;code&gt;if&lt;/code&gt; is a rewrite-phase directive. When the condition goes true, this phase switches the request's config context into the inner block, and that state carries over into the phases that follow. Much later, when &lt;a href="https://nginx.org/en/docs/http/ngx_http_headers_module.html#add_header" rel="noopener noreferrer"&gt;&lt;code&gt;add_header&lt;/code&gt;&lt;/a&gt; is applied at the point of sending response headers, what it reads is the block we switched into. All that's inside is &lt;code&gt;X-Debug&lt;/code&gt;, and on top of that, &lt;code&gt;add_header&lt;/code&gt; does not inherit from the outer scope once there's even one in the same block (it replaces rather than accumulates), so &lt;code&gt;X-Served-By&lt;/code&gt; disappears entirely. If the condition is false, no switch happens and &lt;code&gt;X-Served-By&lt;/code&gt; shows up as usual. The outer header vanishing is not about line order — it's because the switch that happened in the rewrite phase is still in effect in the later phases. What people call &lt;a href="https://www.nginx.com/resources/wiki/start/topics/depth/ifisevil/" rel="noopener noreferrer"&gt;if is evil&lt;/a&gt; is, at bottom, this "context switch in the rewrite phase."&lt;/p&gt;

&lt;h2&gt;
  
  
  Fixes
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Symptom 1
&lt;/h3&gt;

&lt;p&gt;If you want to keep processing in the same location after the rewrite, change &lt;code&gt;last&lt;/code&gt; to &lt;code&gt;break&lt;/code&gt;. With &lt;code&gt;break&lt;/code&gt;, the post-rewrite return to find-config doesn't happen, so the request can go down to the precontent &lt;code&gt;try_files&lt;/code&gt; while staying in the same location.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/app/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;rewrite&lt;/span&gt; &lt;span class="s"&gt;^/app/(.*)&lt;/span&gt;$ &lt;span class="n"&gt;/public/&lt;/span&gt;&lt;span class="nv"&gt;$1&lt;/span&gt; &lt;span class="s"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;# last → break&lt;/span&gt;
    &lt;span class="kn"&gt;try_files&lt;/span&gt; &lt;span class="nv"&gt;$uri&lt;/span&gt; &lt;span class="nv"&gt;$uri&lt;/span&gt;&lt;span class="n"&gt;/&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;404&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If instead you want to route the request to a different location depending on the URI, leave &lt;code&gt;last&lt;/code&gt; as is. In that case, put the fallback in the destination location.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/app/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;rewrite&lt;/span&gt; &lt;span class="s"&gt;^/app/(.*)&lt;/span&gt;$ &lt;span class="n"&gt;/public/&lt;/span&gt;&lt;span class="nv"&gt;$1&lt;/span&gt; &lt;span class="s"&gt;last&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/public/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;root&lt;/span&gt; &lt;span class="n"&gt;/var/www&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;try_files&lt;/span&gt; &lt;span class="nv"&gt;$uri&lt;/span&gt; &lt;span class="nv"&gt;$uri&lt;/span&gt;&lt;span class="n"&gt;/&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;404&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;# put the fallback in the destination&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Which one is right comes down to a design call: do you want to keep processing in the same location after the rewrite, or hand off to a different one?&lt;/p&gt;

&lt;h3&gt;
  
  
  Symptom 2
&lt;/h3&gt;

&lt;p&gt;Don't put content-handling directives (&lt;code&gt;add_header&lt;/code&gt;, &lt;code&gt;proxy_pass&lt;/code&gt;, &lt;code&gt;try_files&lt;/code&gt;, and so on) inside an &lt;code&gt;if&lt;/code&gt;. Treat &lt;code&gt;return&lt;/code&gt; and &lt;code&gt;rewrite&lt;/code&gt; as about the only things that are safe inside &lt;code&gt;if&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The condition here is a query argument (&lt;code&gt;?debug=1&lt;/code&gt;), and &lt;code&gt;location&lt;/code&gt; can't match on the query string. For branching on a value like this, push it out of &lt;code&gt;if&lt;/code&gt; and into a &lt;code&gt;map&lt;/code&gt; that builds a variable.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;map&lt;/span&gt; &lt;span class="nv"&gt;$arg_debug&lt;/span&gt; &lt;span class="nv"&gt;$x_debug&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;default&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;"1"&lt;/span&gt;     &lt;span class="s"&gt;"on"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;server&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/download/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kn"&gt;add_header&lt;/span&gt; &lt;span class="s"&gt;X-Served-By&lt;/span&gt; &lt;span class="s"&gt;"nginx"&lt;/span&gt; &lt;span class="s"&gt;always&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;add_header&lt;/span&gt; &lt;span class="s"&gt;X-Debug&lt;/span&gt;     &lt;span class="nv"&gt;$x_debug&lt;/span&gt; &lt;span class="s"&gt;always&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;root&lt;/span&gt; &lt;span class="n"&gt;/var/www&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;try_files&lt;/span&gt; &lt;span class="nv"&gt;$uri&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;404&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The two &lt;code&gt;add_header&lt;/code&gt; lines sit at the same level, so no nested block replaces anything and both take effect. When &lt;code&gt;$arg_debug&lt;/code&gt; isn't &lt;code&gt;1&lt;/code&gt;, &lt;code&gt;$x_debug&lt;/code&gt; is empty, and nginx &lt;a href="https://github.com/nginx/nginx.org/issues/50" rel="noopener noreferrer"&gt;won't emit an &lt;code&gt;add_header&lt;/code&gt; with an empty value&lt;/a&gt;, so &lt;code&gt;X-Debug&lt;/code&gt; shows up only during debugging.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checking which location you're in right now
&lt;/h2&gt;

&lt;p&gt;The feel for phases sticks once you get your hands dirty. The quick way is to plant one marker in a suspect location to tell whether the request settled there.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/app/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;add_header&lt;/span&gt; &lt;span class="s"&gt;X-Loc&lt;/span&gt; &lt;span class="s"&gt;"app"&lt;/span&gt; &lt;span class="s"&gt;always&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;# marker that shows up if you stay in this location&lt;/span&gt;
    &lt;span class="kn"&gt;rewrite&lt;/span&gt; &lt;span class="s"&gt;^/app/(.*)&lt;/span&gt;$ &lt;span class="n"&gt;/public/&lt;/span&gt;&lt;span class="nv"&gt;$1&lt;/span&gt; &lt;span class="s"&gt;last&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;try_files&lt;/span&gt; &lt;span class="nv"&gt;$uri&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;404&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Look at the headers with &lt;code&gt;curl -sI http://localhost/app/foo&lt;/code&gt;, and if &lt;code&gt;X-Loc: app&lt;/code&gt; isn't there, the request has already left this location via &lt;code&gt;rewrite ... last&lt;/code&gt; (the &lt;code&gt;add_header&lt;/code&gt; you wrote here has no effect once it's gone). Change &lt;code&gt;last&lt;/code&gt; to &lt;code&gt;break&lt;/code&gt;, hit it again, and if &lt;code&gt;X-Loc: app&lt;/code&gt; appears, the request stayed in the same location and made it down to precontent. One marker header lets you see, across phases, which location the request is in right now.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrapping up
&lt;/h2&gt;

&lt;p&gt;When you read an nginx config, your eyes follow the lines top to bottom. But what nginx actually does is run the request along a timeline of phases. When you're questioning a config, don't trace the line order — trace it in this order instead: "which phase does this directive run in? and at that point, which location is the request in?" That usually makes things line up.&lt;/p&gt;

&lt;p&gt;More places where this phase-based behavior produces silent bugs — &lt;code&gt;add_header&lt;/code&gt; inheritance dropping when &lt;code&gt;if&lt;/code&gt; switches context, &lt;code&gt;alias&lt;/code&gt; path traversal, location selection overrides — are catalogued with reproduction cases in &lt;a href="https://dev.to/shinagawa-web/nginx-t-passed-but-the-behavior-is-wrong-config-patterns-that-break-silently-outside-the-syntax-2nnc"&gt;Silent nginx Config Bugs That Pass nginx -t&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>nginx</category>
      <category>devops</category>
      <category>programming</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>nginx Tools for When Your Config Gets Out of Hand</title>
      <dc:creator>Kazu</dc:creator>
      <pubDate>Tue, 04 Aug 2026 13:02:00 +0000</pubDate>
      <link>https://dev.to/shinagawa-web/nginx-tools-for-when-your-config-gets-out-of-hand-2075</link>
      <guid>https://dev.to/shinagawa-web/nginx-tools-for-when-your-config-gets-out-of-hand-2075</guid>
      <description>&lt;p&gt;nginx config files grow. What starts simple gets layers added on top: another reverse proxy, TLS settings, routing logic that branches and branches again.&lt;/p&gt;

&lt;p&gt;Before long you're looking at 20+ &lt;code&gt;location&lt;/code&gt; blocks and can't say off the top of your head which one handles which request. You add a single &lt;code&gt;add_header&lt;/code&gt; line and &lt;a href="https://dev.to/shinagawa-web/nginx-t-passed-but-the-behavior-is-wrong-config-patterns-that-break-silently-outside-the-syntax-2nnc"&gt;some other header silently disappears&lt;/a&gt;. You fix an &lt;code&gt;alias&lt;/code&gt; path and prod throws 404s while staging is fine. &lt;code&gt;nginx -t&lt;/code&gt; says "syntax is ok." But you don't feel ok about it.&lt;/p&gt;

&lt;p&gt;Every config change comes with a quiet dread: &lt;em&gt;will this break something?&lt;/em&gt; And that dread usually has a basis. nginx config mistakes are silent. Nothing shows up in the error log. The browser gets a normal-looking response. You don't find out something is broken until much later.&lt;/p&gt;

&lt;p&gt;nginx itself is well-documented, but the ecosystem around it is harder to survey. Validation tools, config generators, test frameworks — searching turns up scattered information. This article organizes the common pain points into six categories and maps the tools that address each one.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Problem 1: Prevent config mistakes upfront&lt;/li&gt;
&lt;li&gt;Problem 2: Generate config files&lt;/li&gt;
&lt;li&gt;Problem 3: Automate testing&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Problem 4: Collect metrics&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Problem 5: Run nginx on Kubernetes&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Problem 6: Visualize logs&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Gaps that still aren't filled&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Problem 1: Prevent config mistakes upfront
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;nginx -t&lt;/code&gt; checks syntax. But syntactically valid config can still have security problems. SSRF, path traversal via &lt;code&gt;alias&lt;/code&gt; (e.g., a request to &lt;code&gt;/files/../../etc/passwd&lt;/code&gt; reaches files it shouldn't), HTTP splitting — none of these trigger a syntax error.&lt;/p&gt;

&lt;h3&gt;
  
  
  Gixy: static analysis focused on security
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://github.com/dvershinin/gixy" rel="noopener noreferrer"&gt;https://github.com/dvershinin/gixy&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Originally built at Yandex, now actively maintained as a community fork. It checks for well-known security risks: SSRF, alias traversal, HTTP splitting. Install the &lt;code&gt;gixy-ng&lt;/code&gt; fork from PyPI.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install &lt;/span&gt;gixy-ng
gixy /etc/nginx/nginx.conf
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Output looks like this — it tells you which directive is the problem and why:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;[WARN]&lt;/span&gt; &lt;span class="s"&gt;[http_splitting]&lt;/span&gt; &lt;span class="s"&gt;Possible&lt;/span&gt; &lt;span class="s"&gt;HTTP-Splitting&lt;/span&gt; &lt;span class="s"&gt;via&lt;/span&gt; &lt;span class="s"&gt;HTTP&lt;/span&gt; &lt;span class="s"&gt;header.&lt;/span&gt;
&lt;span class="s"&gt;Directive:&lt;/span&gt; &lt;span class="s"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;X-Forwarded-For&lt;/span&gt; &lt;span class="nv"&gt;$http_x_forwarded_for&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A good fit when you've just inherited an nginx config and want a quick security audit before touching it. Detection is limited to known patterns and there's no custom rule support, but as a first security gate in CI it offers the best return on effort.&lt;/p&gt;

&lt;h3&gt;
  
  
  crossplane: treat config as data
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://github.com/nginxinc/crossplane" rel="noopener noreferrer"&gt;https://github.com/nginxinc/crossplane&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A Python library that parses nginx config files into JSON. Useful not just for validation but for dynamically generating config or loading it in tests.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;crossplane&lt;/span&gt;

&lt;span class="n"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;crossplane&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;/etc/nginx/nginx.conf&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# Check for parse errors
&lt;/span&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;error&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;errors&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Error: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;error&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# Walk the config tree to find specific directives
&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;find_directives&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;block&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;item&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;block&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;directive&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;yield&lt;/span&gt; &lt;span class="n"&gt;item&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;block&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;yield&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="nf"&gt;find_directives&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;block&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;config&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;config&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;parsed&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="c1"&gt;# Example: flag any proxy_pass that uses plain HTTP
&lt;/span&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;find_directives&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;proxy_pass&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;args&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startswith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;http://&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Warning: proxy_pass uses plain HTTP: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The parsed output is a tree of directives and their arguments. With a traversal like &lt;code&gt;find_directives&lt;/code&gt;, you can express project-specific rules in Python: "are all &lt;code&gt;proxy_pass&lt;/code&gt; values HTTPS?", "is &lt;code&gt;server_tokens off&lt;/code&gt; set?" Where Gixy is limited to known patterns, crossplane lets you write your own.&lt;/p&gt;

&lt;p&gt;Scripting against config files and automating structural checks in CI are where this earns its place. If you don't have a concrete need to generate or programmatically inspect config, skip it for now. Adding it "because it looks useful" tends to mean it sits unused.&lt;/p&gt;

&lt;h3&gt;
  
  
  Which to choose
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Goal&lt;/th&gt;
&lt;th&gt;Pick&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Find security issues quickly&lt;/td&gt;
&lt;td&gt;Gixy&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Manipulate or validate config in code&lt;/td&gt;
&lt;td&gt;crossplane&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Gixy alone covers the security CI gate. If you're writing config generation or structural validation logic in Python, you need crossplane. They don't overlap, so using both is fine.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 2: Generate config files
&lt;/h2&gt;

&lt;p&gt;Writing TLS config from scratch means making a lot of small decisions: cipher suites, HTTP/2 support, HSTS headers. Starting from a known-good template beats hand-rolling it and getting something subtly wrong.&lt;/p&gt;

&lt;h3&gt;
  
  
  nginxconfig.io: generate best-practice config in the browser
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://www.digitalocean.com/community/tools/nginx" rel="noopener noreferrer"&gt;https://www.digitalocean.com/community/tools/nginx&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A web app by DigitalOcean that generates a complete nginx config from a series of UI choices. Select your TLS cipher suites, gzip settings, HTTP/2, security headers, and it outputs ready-to-use config files. Free, no account required.&lt;/p&gt;

&lt;p&gt;Use it when bootstrapping a new server or bringing TLS settings up to current best practices. It's an excellent starting point, but don't deploy the output unchanged. Treat it as a template and adjust for your project's requirements.&lt;/p&gt;

&lt;h3&gt;
  
  
  nginx-proxy: auto-generate config in Docker environments
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://github.com/nginx-proxy/nginx-proxy" rel="noopener noreferrer"&gt;https://github.com/nginx-proxy/nginx-proxy&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A container that watches Docker start/stop events and automatically updates nginx config. Set a &lt;code&gt;VIRTUAL_HOST&lt;/code&gt; environment variable on your container and nginx-proxy generates the reverse proxy config for it.&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="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;my-app&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;VIRTUAL_HOST=example.com&lt;/span&gt;
  &lt;span class="na"&gt;nginx-proxy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;nginxproxy/nginx-proxy&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;80:80"&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/var/run/docker.sock:/tmp/docker.sock:ro&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When &lt;code&gt;app&lt;/code&gt; starts, nginx-proxy automatically creates config to route &lt;code&gt;example.com&lt;/code&gt; traffic to it.&lt;/p&gt;

&lt;p&gt;In Docker Compose setups where manually updating nginx on every deploy is friction, this removes that burden. Traefik and Caddy do the same thing. If you're not already committed to nginx (existing config, team familiarity), compare them first. If you're already running nginx, nginx-proxy is the natural fit.&lt;/p&gt;

&lt;h3&gt;
  
  
  Which to choose
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Environment&lt;/th&gt;
&lt;th&gt;Pick&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Regular Linux server&lt;/td&gt;
&lt;td&gt;nginxconfig.io&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Docker / Docker Compose&lt;/td&gt;
&lt;td&gt;nginx-proxy&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;No Docker? nginxconfig.io is enough. In Docker environments, nginx-proxy's automation pays off.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 3: Automate testing
&lt;/h2&gt;

&lt;p&gt;Manually sending test requests after every config change doesn't scale. Automating it means the same checks run every time.&lt;/p&gt;

&lt;h3&gt;
  
  
  Test::Nginx: declarative test framework
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://github.com/openresty/test-nginx" rel="noopener noreferrer"&gt;https://github.com/openresty/test-nginx&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A Perl-based test framework from the OpenResty community. It starts a real nginx instance, sends requests, and checks responses. The format is declarative — you describe what config to use, what request to send, and what response to expect. Tests read clearly as a result.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight perl"&gt;&lt;code&gt;&lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nv"&gt;TEST&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;basic&lt;/span&gt; &lt;span class="nv"&gt;proxy&lt;/span&gt;
&lt;span class="o"&gt;---&lt;/span&gt; &lt;span class="nv"&gt;config&lt;/span&gt;
&lt;span class="nv"&gt;location&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="nv"&gt;api&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;proxy_pass&lt;/span&gt; &lt;span class="nv"&gt;http:&lt;/span&gt;&lt;span class="sr"&gt;//&lt;/span&gt;&lt;span class="mf"&gt;127.0.0.1&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;8080&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;---&lt;/span&gt; &lt;span class="nv"&gt;request&lt;/span&gt;
&lt;span class="nv"&gt;GET&lt;/span&gt; &lt;span class="sr"&gt;/api/us&lt;/span&gt;&lt;span class="nv"&gt;ers&lt;/span&gt;
&lt;span class="o"&gt;---&lt;/span&gt; &lt;span class="nv"&gt;response_body&lt;/span&gt;
&lt;span class="p"&gt;[{"&lt;/span&gt;&lt;span class="s2"&gt;id&lt;/span&gt;&lt;span class="p"&gt;":&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;}]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This fits nginx module development and verifying that config changes don't alter behavior. Perl is a real barrier for most teams today. If you're not writing custom nginx modules, hurl covers most of what you actually need.&lt;/p&gt;

&lt;h3&gt;
  
  
  hurl: a DSL for HTTP testing
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://hurl.dev/" rel="noopener noreferrer"&gt;https://hurl.dev/&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;hurl lets you write HTTP requests and assertions in a plain-text format, then run them from the shell. Easy to slot into CI. Not nginx-specific — it works for HTTP testing in general.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;GET http://localhost/api/users
HTTP 200
[Asserts]
header "Content-Type" contains "application/json"
jsonpath "$.length" &amp;gt; 0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;hurl &lt;span class="nt"&gt;--test&lt;/span&gt; api.hurl
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Reach for it when you want to confirm API behavior hasn't broken after a config change, or when the team has no Perl experience. Because it's not nginx-specific, it doesn't go deep on nginx internals. It works well for endpoint reachability checks, but for testing precise nginx behavior (match precedence, path rewriting), Test::Nginx is the right tool.&lt;/p&gt;

&lt;h3&gt;
  
  
  Which to choose
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Situation&lt;/th&gt;
&lt;th&gt;Pick&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Testing nginx config or modules directly&lt;/td&gt;
&lt;td&gt;Test::Nginx&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Checking that the API behind nginx still works&lt;/td&gt;
&lt;td&gt;hurl&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Team has no Perl experience&lt;/td&gt;
&lt;td&gt;hurl&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Test::Nginx for precise nginx behavior verification. hurl for HTTP interface testing with a lower adoption cost.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 4: Collect metrics
&lt;/h2&gt;

&lt;p&gt;Without visibility into how many requests nginx is handling and where the bottlenecks are, there's no basis for making informed decisions when problems surface.&lt;/p&gt;

&lt;h3&gt;
  
  
  nginx-module-vts: embedded stats module
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://github.com/vozlt/nginx-module-vts" rel="noopener noreferrer"&gt;https://github.com/vozlt/nginx-module-vts&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A module that adds traffic statistics to nginx — request counts, error rates, latency per virtual host and upstream. Built in at compile time. Exposes a &lt;code&gt;/status&lt;/code&gt; endpoint that returns JSON.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;vhost_traffic_status_zone&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;server&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/status&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kn"&gt;vhost_traffic_status_display&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;vhost_traffic_status_display_format&lt;/span&gt; &lt;span class="s"&gt;html&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Detailed traffic stats are the payoff — when you have a build environment and can compile nginx from source. Requiring a source build is the biggest barrier. If you're using a packaged nginx install, this isn't an option. If you already have Prometheus, the overhead of a custom build may not be worth it.&lt;/p&gt;

&lt;h3&gt;
  
  
  nginx-prometheus-exporter: bridge stub_status to Prometheus
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://github.com/nginxinc/nginx-prometheus-exporter" rel="noopener noreferrer"&gt;https://github.com/nginxinc/nginx-prometheus-exporter&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Reads nginx's built-in &lt;code&gt;stub_status&lt;/code&gt; data (connection counts, request totals) and exposes it in Prometheus format. Runs as a separate process alongside nginx — no changes to nginx itself required.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/nginx_status&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;stub_status&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;allow&lt;/span&gt; &lt;span class="mf"&gt;127.0&lt;/span&gt;&lt;span class="s"&gt;.0.1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;deny&lt;/span&gt; &lt;span class="s"&gt;all&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;./nginx-prometheus-exporter &lt;span class="nt"&gt;-nginx&lt;/span&gt;.scrape-uri&lt;span class="o"&gt;=&lt;/span&gt;http://localhost/nginx_status
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In Prometheus + Grafana stacks, this is the easiest way to add nginx metrics without touching nginx itself. &lt;code&gt;stub_status&lt;/code&gt; only gives you connection counts and request totals — no per-upstream breakdown. Confirm that's enough before committing.&lt;/p&gt;

&lt;h3&gt;
  
  
  Which to choose
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Situation&lt;/th&gt;
&lt;th&gt;Pick&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Already running Prometheus&lt;/td&gt;
&lt;td&gt;nginx-prometheus-exporter&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Need per-host, per-upstream, per-status-code stats&lt;/td&gt;
&lt;td&gt;nginx-module-vts&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Using packaged nginx and don't want to rebuild&lt;/td&gt;
&lt;td&gt;nginx-prometheus-exporter&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If you're on Prometheus, nginx-prometheus-exporter is by far the easier path. If you need granular metrics and control your build, look at vts.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 5: Run nginx on Kubernetes
&lt;/h2&gt;

&lt;p&gt;When exposing services externally in Kubernetes, you use an Ingress resource — a rule that maps incoming paths to backend services. nginx-based Ingress controllers are widely used for this. Two controllers with similar names exist, and they're frequently confused.&lt;/p&gt;

&lt;h3&gt;
  
  
  ingress-nginx: the Kubernetes community controller
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://github.com/kubernetes/ingress-nginx" rel="noopener noreferrer"&gt;https://github.com/kubernetes/ingress-nginx&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Maintained by the Kubernetes community (&lt;code&gt;kubernetes/ingress-nginx&lt;/code&gt;). Used as the default by many Kubernetes distributions. Large amount of documentation and community knowledge available.&lt;/p&gt;

&lt;p&gt;In March 2025, a set of serious vulnerabilities called IngressNightmare was disclosed (CVE-2025-1974 and others, CVSS 9.8). Remote code execution was possible through the admission controller component. Fixed in versions 1.12.1 and 1.11.5. If you're running an older version, upgrade now.&lt;/p&gt;

&lt;p&gt;On a general-purpose Kubernetes cluster running standard Ingress, this is the obvious starting point. Switching Ingress controllers later is painful, so the initial choice matters. When in doubt, ingress-nginx has fewer dead ends from a documentation standpoint.&lt;/p&gt;

&lt;h3&gt;
  
  
  kubernetes-ingress: the NGINX Inc. controller
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://github.com/nginxinc/kubernetes-ingress" rel="noopener noreferrer"&gt;https://github.com/nginxinc/kubernetes-ingress&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Maintained directly by NGINX Inc. (&lt;code&gt;nginxinc/kubernetes-ingress&lt;/code&gt;). Supports both open-source nginx and NGINX Plus (the commercial version).&lt;/p&gt;

&lt;p&gt;The case for it is NGINX Plus features (advanced load balancing, active health checks) or a support contract with NGINX Inc. If you don't need NGINX Plus, there's little reason to choose this over ingress-nginx. For OSS nginx, the community controller has better coverage.&lt;/p&gt;

&lt;h3&gt;
  
  
  Which to choose
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Situation&lt;/th&gt;
&lt;th&gt;Pick&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Community resources matter&lt;/td&gt;
&lt;td&gt;ingress-nginx&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Planning to use NGINX Plus&lt;/td&gt;
&lt;td&gt;kubernetes-ingress&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Starting on EKS / GKE or similar managed cluster&lt;/td&gt;
&lt;td&gt;ingress-nginx&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If you go with ingress-nginx, run version 1.12.1 or 1.11.5 or later. Check your current version before anything else.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 6: Visualize logs
&lt;/h2&gt;

&lt;p&gt;nginx access logs pile up as text. Figuring out which paths are getting hammered, or where errors are spiking, is hard to do from raw log files.&lt;/p&gt;

&lt;h3&gt;
  
  
  GoAccess: real-time stats in the terminal
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://goaccess.io/" rel="noopener noreferrer"&gt;https://goaccess.io/&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A TUI tool that aggregates nginx access logs and displays them in real time. Graphs and stats update live in the terminal. No log server or database needed — just install and run. Can also generate HTML reports for browser viewing.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;goaccess /var/log/nginx/access.log &lt;span class="nt"&gt;-c&lt;/span&gt;
&lt;span class="c"&gt;# Starts in interactive mode to select the log format&lt;/span&gt;

&lt;span class="c"&gt;# Stream live logs in COMBINED format&lt;/span&gt;
&lt;span class="nb"&gt;tail&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; /var/log/nginx/access.log | goaccess &lt;span class="nt"&gt;--log-format&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;COMBINED -
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Reach for it when you need answers quickly, especially on a server with no log collection infrastructure in place. It reads local log files directly, so multi-server environments are awkward. Think of it as a per-server quick-look tool.&lt;/p&gt;

&lt;h3&gt;
  
  
  Loki + Grafana Alloy: long-term log storage and search
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://grafana.com/oss/loki/" rel="noopener noreferrer"&gt;https://grafana.com/oss/loki/&lt;/a&gt; / &lt;a href="https://grafana.com/docs/alloy/latest/" rel="noopener noreferrer"&gt;https://grafana.com/docs/alloy/latest/&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Loki stores and queries logs efficiently, with Grafana for dashboards. Grafana Alloy handles log collection. Alloy replaces Promtail, which reached end-of-life in March 2026 — for new setups, use Alloy.&lt;/p&gt;

&lt;p&gt;Example Alloy config (sending nginx logs to Loki):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;loki&lt;/span&gt;&lt;span class="err"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;source&lt;/span&gt;&lt;span class="err"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt; &lt;span class="s2"&gt;"nginx"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;targets&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt;
    &lt;span class="nx"&gt;__path__&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"/var/log/nginx/access.log"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;job&lt;/span&gt;      &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"nginx"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;}]&lt;/span&gt;
  &lt;span class="nx"&gt;forward_to&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;loki&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;write&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;default&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;receiver&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;loki&lt;/span&gt;&lt;span class="err"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;write&lt;/span&gt; &lt;span class="s2"&gt;"default"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;endpoint&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;url&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"http://loki:3100/loki/api/v1/push"&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It earns its place when you need to aggregate logs from multiple servers, search historical logs by time range, or view nginx logs alongside other metrics in Grafana. Running the full stack yourself carries real operational overhead. Start with Grafana Cloud's free tier, or bring this in when someone on the team already knows how to operate Grafana.&lt;/p&gt;

&lt;h3&gt;
  
  
  Which to choose
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Situation&lt;/th&gt;
&lt;th&gt;Pick&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Need to check something right now&lt;/td&gt;
&lt;td&gt;GoAccess&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Already running Grafana&lt;/td&gt;
&lt;td&gt;Loki + Grafana Alloy&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Need long-term log retention and cross-server search&lt;/td&gt;
&lt;td&gt;Loki + Grafana Alloy&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Debugging with direct SSH access to the server&lt;/td&gt;
&lt;td&gt;GoAccess&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;GoAccess gives you immediate answers. Loki + Alloy is for ongoing operations. They're not mutually exclusive — GoAccess for fast situational awareness, Loki for accumulating and analyzing over time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Summary: match tool to problem
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Problem&lt;/th&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Find security risks in config&lt;/td&gt;
&lt;td&gt;Gixy&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Manipulate config programmatically&lt;/td&gt;
&lt;td&gt;crossplane&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Generate correct config (Linux server)&lt;/td&gt;
&lt;td&gt;nginxconfig.io&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Auto-manage Docker reverse proxies&lt;/td&gt;
&lt;td&gt;nginx-proxy&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Test nginx config behavior&lt;/td&gt;
&lt;td&gt;Test::Nginx&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Add HTTP connectivity checks to CI&lt;/td&gt;
&lt;td&gt;hurl&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Collect metrics via Prometheus&lt;/td&gt;
&lt;td&gt;nginx-prometheus-exporter&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Need detailed per-upstream stats (module)&lt;/td&gt;
&lt;td&gt;nginx-module-vts&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Set up Ingress on Kubernetes&lt;/td&gt;
&lt;td&gt;ingress-nginx (1.12.1+)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Need NGINX Plus features&lt;/td&gt;
&lt;td&gt;kubernetes-ingress&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Visualize logs immediately&lt;/td&gt;
&lt;td&gt;GoAccess&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Long-term log storage and search&lt;/td&gt;
&lt;td&gt;Loki + Grafana Alloy&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;nginx itself is stable and mature, but the ecosystem moves fast — Promtail's EOL is a good example. Periodically check the maintenance status of tools you rely on, or you'll find out about a support cutoff at the worst time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gaps that still aren't filled
&lt;/h2&gt;

&lt;p&gt;The tools covered here leave some problems unsolved or underserved. If you know tools that address any of these, drop them in the comments.&lt;/p&gt;

&lt;h3&gt;
  
  
  Dynamic upstream management
&lt;/h3&gt;

&lt;p&gt;Updating upstreams without an nginx reload is a common requirement. With open-source nginx, the only options are OpenResty (Lua scripting for dynamic upstream control) or NGINX Plus (API-driven management). Vanilla nginx requires rewriting config and reloading.&lt;/p&gt;

&lt;h3&gt;
  
  
  Config drift tracking across environments
&lt;/h3&gt;

&lt;p&gt;There's no standard tool for tracking and visualizing how nginx config differs between dev, staging, and production. Git-based management is the practical answer, but cross-environment diff review and detecting prod-only config still means manual work.&lt;/p&gt;

&lt;h3&gt;
  
  
  IDE / LSP support
&lt;/h3&gt;

&lt;p&gt;VSCode extensions for nginx.conf exist, but completion and inline validation are incomplete. No editor today reliably explains &lt;code&gt;location&lt;/code&gt; block match priority or how &lt;code&gt;proxy_pass&lt;/code&gt; transforms paths.&lt;/p&gt;

&lt;h3&gt;
  
  
  WAF
&lt;/h3&gt;

&lt;p&gt;ModSecurity has a solid track record as an nginx WAF, but rule management and false-positive tuning are heavy ongoing work. There's nothing you can drop in and operate at a low maintenance level.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/owasp-modsecurity/ModSecurity-nginx" rel="noopener noreferrer"&gt;https://github.com/owasp-modsecurity/ModSecurity-nginx&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Distributed tracing
&lt;/h3&gt;

&lt;p&gt;The nginx OpenTelemetry module (&lt;code&gt;ngx_otel_module&lt;/code&gt;) shipped in 2023 but is still maturing. Trace collection with Jaeger or Tempo is possible, but documentation and real-world operational experience are thin.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/nginxinc/nginx-otel" rel="noopener noreferrer"&gt;https://github.com/nginxinc/nginx-otel&lt;/a&gt;&lt;/p&gt;

</description>
      <category>nginx</category>
      <category>devops</category>
      <category>opensource</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>$request_time Looks Fast, But Users Say It''s Slow: Breaking nginx Latency into 4 Parts</title>
      <dc:creator>Kazu</dc:creator>
      <pubDate>Tue, 28 Jul 2026 13:03:00 +0000</pubDate>
      <link>https://dev.to/shinagawa-web/requesttime-looks-fast-but-users-say-its-slow-breaking-nginx-latency-into-4-parts-1pn9</link>
      <guid>https://dev.to/shinagawa-web/requesttime-looks-fast-but-users-say-its-slow-breaking-nginx-latency-into-4-parts-1pn9</guid>
      <description>&lt;p&gt;Your nginx &lt;code&gt;$request_time&lt;/code&gt; looks fine in the dashboard — p95 is 120ms, totally reasonable.&lt;/p&gt;

&lt;p&gt;But users keep reporting that things "occasionally freeze." You dig through the logs looking for slow requests and find nothing obvious. Overall p95 is healthy. The upstream app team says "our side is fast." nginx looks fast too. Yet the complaints are real. You don't know where to look.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;$request_time&lt;/code&gt; collapses everything that happened during the request into a single number. Fast phases and slow phases all get averaged together, making it impossible to see where the time actually went.&lt;/p&gt;

&lt;p&gt;nginx does record timestamps at each intermediate checkpoint, though. Subtract them, and latency breaks into 4 distinct parts.&lt;/p&gt;

&lt;p&gt;The log variables covered here and what they measure are the same regardless of nginx version.&lt;/p&gt;

&lt;h2&gt;
  
  
  What one number hides
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;$request_time&lt;/code&gt; measures everything from when nginx reads the first byte from the client to when it finishes sending the last byte of the response. In a reverse proxy setup, that single number includes at least all of:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;time to establish a connection to the upstream backend&lt;/li&gt;
&lt;li&gt;time until the backend starts returning a response (backend processing time)&lt;/li&gt;
&lt;li&gt;time to receive the full response from the backend&lt;/li&gt;
&lt;li&gt;nginx's own processing and the time to send the response back to the client&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;All of this gets summed into one &lt;code&gt;$request_time&lt;/code&gt;. So even if p95 is 120ms, you cannot tell from that number alone whether the breakdown is "20ms backend, 100ms sending" or "100ms connecting, 20ms everything else." Those scenarios call for completely different fixes, but &lt;code&gt;$request_time&lt;/code&gt; erases the distinction.&lt;/p&gt;

&lt;p&gt;Also worth noting: the TLS handshake on the client side happens &lt;em&gt;before&lt;/em&gt; &lt;code&gt;$request_time&lt;/code&gt; starts, since the clock starts at "first byte received." If you suspect TLS overhead and stare at &lt;code&gt;$request_time&lt;/code&gt;, it won't show up there.&lt;/p&gt;

&lt;h2&gt;
  
  
  Before you start: add variables to your log format
&lt;/h2&gt;

&lt;p&gt;The breakdown analysis below requires &lt;code&gt;$upstream_connect_time&lt;/code&gt;, &lt;code&gt;$upstream_header_time&lt;/code&gt;, and &lt;code&gt;$upstream_response_time&lt;/code&gt; to be present in your access log. Open your own logs and there's a good chance none of these are there.&lt;/p&gt;

&lt;p&gt;The reason is simple: nginx's default &lt;code&gt;combined&lt;/code&gt; log format doesn't include any of them. &lt;code&gt;combined&lt;/code&gt; logs eight fields — remote address, user, timestamp, request line, status, response bytes, referer, and user agent. &lt;code&gt;$request_time&lt;/code&gt; isn't even there. Upstream timing variables are excluded by design.&lt;/p&gt;

&lt;p&gt;Until you define a custom &lt;code&gt;log_format&lt;/code&gt; and reload nginx, this breakdown is physically impossible. You can't subtract what doesn't exist. Fix the log definition first.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;log_format&lt;/span&gt; &lt;span class="s"&gt;latency&lt;/span&gt; &lt;span class="s"&gt;'&lt;/span&gt;&lt;span class="nv"&gt;$remote_addr&lt;/span&gt; &lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt; &lt;span class="nv"&gt;$status&lt;/span&gt; &lt;span class="s"&gt;'&lt;/span&gt;
                   &lt;span class="s"&gt;'rt=&lt;/span&gt;&lt;span class="nv"&gt;$request_time&lt;/span&gt; &lt;span class="s"&gt;'&lt;/span&gt;
                   &lt;span class="s"&gt;'uct=&lt;/span&gt;&lt;span class="nv"&gt;$upstream_connect_time&lt;/span&gt; &lt;span class="s"&gt;'&lt;/span&gt;
                   &lt;span class="s"&gt;'uht=&lt;/span&gt;&lt;span class="nv"&gt;$upstream_header_time&lt;/span&gt; &lt;span class="s"&gt;'&lt;/span&gt;
                   &lt;span class="s"&gt;'urt=&lt;/span&gt;&lt;span class="nv"&gt;$upstream_response_time&lt;/span&gt;&lt;span class="s"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;access_log&lt;/span&gt; &lt;span class="n"&gt;/var/log/nginx/access.log&lt;/span&gt; &lt;span class="s"&gt;latency&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After adding this and running &lt;code&gt;nginx -s reload&lt;/code&gt;, the raw data you need starts appearing in logs. Logs written before the reload won't have these fields, so there's no way to retroactively see the breakdown for historical requests.&lt;/p&gt;

&lt;p&gt;There's also an approach that captures timing directly from the kernel without touching the nginx config. &lt;a href="https://github.com/shinagawa-web/ngxray" rel="noopener noreferrer"&gt;ngxray&lt;/a&gt; explores that direction (currently in development).&lt;/p&gt;

&lt;h2&gt;
  
  
  Breaking it into 4 parts
&lt;/h2&gt;

&lt;p&gt;When nginx operates as a reverse proxy, it records three timestamps for the upstream interaction. These are the basis for the breakdown.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;$upstream_connect_time&lt;/code&gt;: time until the connection to the upstream is established (includes TLS handshake if the upstream is HTTPS)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;$upstream_header_time&lt;/code&gt;: time until nginx starts receiving the response headers from the upstream&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;$upstream_response_time&lt;/code&gt;: time until nginx has received the full response from the upstream&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;All three are measured from the same starting point — the moment nginx begins processing the upstream. They're not three independent intervals; they're cumulative values. &lt;code&gt;$upstream_response_time&lt;/code&gt; contains &lt;code&gt;$upstream_header_time&lt;/code&gt;, and &lt;code&gt;$upstream_header_time&lt;/code&gt; contains &lt;code&gt;$upstream_connect_time&lt;/code&gt;. They're nested.&lt;/p&gt;

&lt;p&gt;Once you understand they're cumulative, subtraction gives you the individual intervals.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Part&lt;/th&gt;
&lt;th&gt;Formula&lt;/th&gt;
&lt;th&gt;What it measures&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;① Connect&lt;/td&gt;
&lt;td&gt;&lt;code&gt;$upstream_connect_time&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Time to connect to upstream (TLS included)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;② Backend processing&lt;/td&gt;
&lt;td&gt;&lt;code&gt;$upstream_header_time − $upstream_connect_time&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Time until upstream starts responding (TTFB)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;③ Response transfer&lt;/td&gt;
&lt;td&gt;&lt;code&gt;$upstream_response_time − $upstream_header_time&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Time to receive the full response from upstream&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;④ Client + nginx internal&lt;/td&gt;
&lt;td&gt;&lt;code&gt;$request_time − $upstream_response_time&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Request body read, nginx internals, sending to client&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Don't read ④ as "nginx is slow." It captures request body reading, rewrite/access phases, and the time to finish delivering the response to the client. When users on slow connections are the only ones complaining, ①②③ will look fine while ④ carries all the time. Slow clients only become visible through this breakdown. This assumes &lt;code&gt;proxy_buffering&lt;/code&gt; is on (the default), where nginx buffers the full upstream response before sending to the client. If buffering is off, slow-client impact can bleed back into ③, so suspect both in that case.&lt;/p&gt;

&lt;p&gt;And don't look at averages. Break each of the four parts into p50 / p95 / p99 separately. Whichever part has a spiking p99 is your answer.&lt;/p&gt;

&lt;p&gt;For example, laying out p50 / p95 / p99 per part might reveal a distribution like this (these numbers are for illustration):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Part             p50     p95     p99
① connect         1ms     2ms     3ms
② backend        15ms    40ms    60ms
③ transfer        3ms     8ms    12ms
④ client+nginx    2ms     5ms   380ms   ← here
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Even when &lt;code&gt;$request_time&lt;/code&gt; p95 looks clean at 120ms, the p99 for ④ alone can spike to 380ms. That's what "occasionally freezes" actually is — buried in the overall average and p95, but visible as soon as you look at p99 per part.&lt;/p&gt;

&lt;p&gt;Once you know which part is the problem, the next action follows directly: ① is thick → upstream keepalive probably isn't configured (covered in the next section); ② is thick → backend processing is the bottleneck; ③ is thick → look at transfer size or upstream bandwidth; ④ is thick → suspect client connection speed or response body size.&lt;/p&gt;

&lt;p&gt;Even the &lt;a href="https://blog.nginx.org/blog/using-nginx-logging-for-application-performance-monitoring" rel="noopener noreferrer"&gt;nginx official blog's explanation&lt;/a&gt; writes that &lt;code&gt;$upstream_header_time&lt;/code&gt; measures the time "between establishing a connection and …" — it's ambiguous whether that excludes &lt;code&gt;$upstream_connect_time&lt;/code&gt; or includes it. Rather than memorizing each variable's exact definition, it's more reliable to internalize the structure: all three accumulate from the same origin, and you subtract to get intervals.&lt;/p&gt;

&lt;h3&gt;
  
  
  Computing p50/p95/p99 per part
&lt;/h3&gt;

&lt;p&gt;"Look at p99 per part" is easy to say, but how do you actually get those numbers? Using the &lt;code&gt;latency&lt;/code&gt; format defined above, here's an awk example to compute percentiles.&lt;/p&gt;

&lt;p&gt;To extract percentiles for part ④, client-side (&lt;code&gt;rt − urt&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="s1"&gt;'{
  for (i = 1; i &amp;lt;= NF; i++) {
    if ($i ~ /^rt=/)  { rt  = substr($i, 4) }
    if ($i ~ /^urt=/) { urt = substr($i, 5) }
  }
  if (rt != "" &amp;amp;&amp;amp; urt != "" &amp;amp;&amp;amp; urt + 0 &amp;gt;= 0) a[++n] = rt - urt
}
END {
  asort(a)
  printf "p50=%.3f  p95=%.3f  p99=%.3f\n",
    a[int(n * 0.50)], a[int(n * 0.95)], a[int(n * 0.99)]
}'&lt;/span&gt; /var/log/nginx/access.log
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The structure is the same for every other part. For ② backend processing use &lt;code&gt;uht - uct&lt;/code&gt;; for ③ response transfer use &lt;code&gt;urt - uht&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;In production you can run equivalent aggregations in Loki, CloudWatch Logs Insights, or Datadog log queries. The key is not "overall p95" but "p99 per part, listed separately." No matter how good a single number looks, the problem stays hidden until you split the breakdown and look at each part's p99.&lt;/p&gt;

&lt;h2&gt;
  
  
  When ① is always non-zero
&lt;/h2&gt;

&lt;p&gt;When you break down the parts, you occasionally see a pattern where ②③④ are all small but &lt;code&gt;$upstream_connect_time&lt;/code&gt; registers as non-zero on every single request. The responses themselves are lightweight, but there's a small constant overhead on every request.&lt;/p&gt;

&lt;p&gt;The TCP connection to the upstream isn't being reused. When connections are kept alive and reused, &lt;code&gt;$upstream_connect_time&lt;/code&gt; should be near 0 (or &lt;code&gt;-&lt;/code&gt;) after the first request. If it's consistently non-zero on every request, nginx is creating a new connection for each one — keepalive to the upstream isn't configured. This tends to get overlooked in long-running systems.&lt;/p&gt;

&lt;p&gt;Two directives work together here:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;upstream&lt;/span&gt; &lt;span class="s"&gt;backend&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;server&lt;/span&gt; &lt;span class="nf"&gt;127.0.0.1&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;3000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;keepalive&lt;/span&gt; &lt;span class="mi"&gt;32&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;server&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_pass&lt;/span&gt; &lt;span class="s"&gt;http://backend&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_http_version&lt;/span&gt; &lt;span class="mf"&gt;1.1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;Connection&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;keepalive 32&lt;/code&gt; sets the maximum number of persistent connections the worker keeps in the pool. &lt;code&gt;proxy_http_version 1.1&lt;/code&gt; and &lt;code&gt;proxy_set_header Connection ""&lt;/code&gt; are required because HTTP/1.0 sends &lt;code&gt;Connection: close&lt;/code&gt; by default, which causes the upstream to close the connection immediately. Without those two lines, &lt;code&gt;keepalive&lt;/code&gt; does nothing.&lt;/p&gt;

&lt;p&gt;After adding the config and running &lt;code&gt;nginx -s reload&lt;/code&gt;, check the &lt;code&gt;uct=&lt;/code&gt; values in the access log. The first request will be non-zero since it's establishing the connection. If subsequent requests show &lt;code&gt;0.000&lt;/code&gt; or &lt;code&gt;-&lt;/code&gt;, connection reuse is working.&lt;/p&gt;

&lt;p&gt;Repeatedly opening new connections also leads to TIME_WAIT buildup and ephemeral port exhaustion, but that's a different layer of concern. For this article, the key read is: "① consistently non-zero = connections aren't being reused."&lt;/p&gt;

&lt;h2&gt;
  
  
  When the breakdown breaks: commas and colons
&lt;/h2&gt;

&lt;p&gt;There's another pattern that breaks the subtraction: looking at a &lt;code&gt;$upstream_*&lt;/code&gt; value and finding more than one number.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;uct=0.001, 0.003 uht=0.012, 0.045 urt=0.012, 1.230
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This isn't a broken log. nginx logs multiple values when a single request involves multiple upstream interactions. The delimiter carries meaning.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Comma-separated&lt;/strong&gt; (&lt;code&gt;0.001, 0.003&lt;/code&gt;): the request hit multiple upstream servers in sequence. The typical case is the first server failed and the second was tried as a retry/failover.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Colon-separated&lt;/strong&gt; (&lt;code&gt;0.001 : 0.003&lt;/code&gt;): an internal redirect via &lt;code&gt;X-Accel-Redirect&lt;/code&gt; or similar occurred, spanning multiple upstream groups.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you treat this as a single value and compute &lt;code&gt;$request_time − $upstream_response_time&lt;/code&gt;, the subtraction breaks completely. The string &lt;code&gt;"0.012, 1.230"&lt;/code&gt; isn't a number.&lt;/p&gt;

&lt;p&gt;More importantly, multiple values are a signal of something abnormal. Two comma-separated values mean there was a retry — the first upstream attempt failed. That failure was buried in the average and invisible. When a slow request shows comma-separated upstream times, investigate the retry before worrying about the interval breakdown.&lt;/p&gt;

&lt;h2&gt;
  
  
  Closing: where is your time going?
&lt;/h2&gt;

&lt;p&gt;Rather than wrapping this up as a checklist of solutions, here are questions to bring to your own logs.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Does your access log include &lt;code&gt;$upstream_connect_time&lt;/code&gt;, &lt;code&gt;$upstream_header_time&lt;/code&gt;, and &lt;code&gt;$upstream_response_time&lt;/code&gt;? Are you still running the &lt;code&gt;combined&lt;/code&gt; format?&lt;/li&gt;
&lt;li&gt;Are you looking at p50 / p95 / p99 separately for each of the 4 parts, not just &lt;code&gt;$request_time&lt;/code&gt; as a whole?&lt;/li&gt;
&lt;li&gt;Have you ever computed &lt;code&gt;$request_time − $upstream_response_time&lt;/code&gt; (part ④, client + nginx internal)?&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Is &lt;code&gt;$upstream_connect_time&lt;/code&gt; non-zero on every request?&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Can you interpret what it means when &lt;code&gt;$upstream_*&lt;/code&gt; has multiple values separated by commas or colons?&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Can you explain "numbers look fast but users say slow" using per-part p99 rather than averages?&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If any answer is no, you haven't broken down latency yet. As long as that's the case, "fast numbers, slow experience" stays a permanent contradiction.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;$request_time&lt;/code&gt; doesn't lie. It just compresses too much into one number. Split it four ways and you can always pinpoint where the time went. Start by opening your access log and checking whether &lt;code&gt;$upstream_response_time&lt;/code&gt; is even being recorded.&lt;/p&gt;

&lt;p&gt;For broader observability — metrics exporters, log visualization tools, and the validation and testing tools that sit around nginx's config — there's a &lt;a href="https://dev.to/shinagawa-web/nginx-tools-for-when-your-config-gets-out-of-hand-2075"&gt;roundup of the nginx ecosystem&lt;/a&gt; that maps what fits where.&lt;/p&gt;

&lt;h2&gt;
  
  
  References (official documentation)
&lt;/h2&gt;

&lt;p&gt;Every variable and behavior discussed here is documented in the official nginx docs.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://nginx.org/en/docs/http/ngx_http_upstream_module.html" rel="noopener noreferrer"&gt;ngx_http_upstream_module&lt;/a&gt;: definitions of &lt;code&gt;$upstream_connect_time&lt;/code&gt;, &lt;code&gt;$upstream_header_time&lt;/code&gt;, &lt;code&gt;$upstream_response_time&lt;/code&gt;; meaning of comma/colon separators; TLS handshake handling&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://nginx.org/en/docs/http/ngx_http_log_module.html" rel="noopener noreferrer"&gt;ngx_http_log_module&lt;/a&gt;: &lt;code&gt;log_format&lt;/code&gt; and the default &lt;code&gt;combined&lt;/code&gt; format definition&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://blog.nginx.org/blog/using-nginx-logging-for-application-performance-monitoring" rel="noopener noreferrer"&gt;Using NGINX Logging for Application Performance Monitoring&lt;/a&gt;: upstream timing variable usage&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/nginx/nginx/blob/master/src/http/ngx_http_upstream.c" rel="noopener noreferrer"&gt;nginx source &lt;code&gt;ngx_http_upstream.c&lt;/code&gt;&lt;/a&gt;: confirmation that all three variables share the same origin (&lt;code&gt;u-&amp;gt;start_time&lt;/code&gt;) and the nested structure&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>nginx</category>
      <category>devops</category>
      <category>programming</category>
      <category>performance</category>
    </item>
    <item>
      <title>Silent nginx Config Bugs That Pass nginx -t: if, location, add_header, alias</title>
      <dc:creator>Kazu</dc:creator>
      <pubDate>Tue, 21 Jul 2026 13:02:00 +0000</pubDate>
      <link>https://dev.to/shinagawa-web/nginx-t-passed-but-the-behavior-is-wrong-config-patterns-that-break-silently-outside-the-syntax-2nnc</link>
      <guid>https://dev.to/shinagawa-web/nginx-t-passed-but-the-behavior-is-wrong-config-patterns-that-break-silently-outside-the-syntax-2nnc</guid>
      <description>&lt;p&gt;&lt;code&gt;nginx -t&lt;/code&gt; returns &lt;code&gt;syntax is ok&lt;/code&gt; and &lt;code&gt;test is successful&lt;/code&gt;. &lt;code&gt;nginx -s reload&lt;/code&gt; finishes without a complaint. Nothing shows up in the error log.&lt;/p&gt;

&lt;p&gt;And yet the behavior is wrong.&lt;/p&gt;

&lt;p&gt;I've done this to myself more times than I'd like to admit. The one that hurt most was a CSP header I shipped to production that never reached a single browser for a full day — I only noticed because I got suspicious that no violation reports were coming in. There have been others: a file I put under &lt;code&gt;/static/&lt;/code&gt; getting swallowed by a different location, old behavior lingering after a reload. In every case &lt;code&gt;nginx -t&lt;/code&gt; passed, and nothing was grammatically wrong anywhere.&lt;/p&gt;

&lt;p&gt;The nature of this gap is clear: &lt;code&gt;nginx -t&lt;/code&gt; validates only syntactic correctness, and never looks at semantic correctness.&lt;/p&gt;

&lt;h2&gt;
  
  
  What &lt;code&gt;nginx -t&lt;/code&gt; actually guarantees for you
&lt;/h2&gt;

&lt;p&gt;What &lt;code&gt;nginx -t&lt;/code&gt; does, roughly speaking, is check "is this config file in a shape that nginx can load?" Does the directive name exist? Is the argument count right? Are the &lt;code&gt;{}&lt;/code&gt; blocks closed? Are the value types valid? In compiler terms, it goes about as far as syntax errors and maybe type checking.&lt;/p&gt;

&lt;p&gt;Everything past that point — "will this config process requests the way you intended?" — is outside the scope of &lt;code&gt;nginx -t&lt;/code&gt;. This is where the blind spot is. When the test passes, you quietly reread it as "the config is correct." But all that passed was the syntax test.&lt;/p&gt;

&lt;p&gt;Outside &lt;code&gt;nginx -t&lt;/code&gt;, there are three layers of breakage.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Semantic level&lt;/strong&gt; — the config isn't read the way you intended. The grammar is perfect.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Security level&lt;/strong&gt; — it works exactly as you intended. It just also works exactly as the attacker intended.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Runtime level&lt;/strong&gt; — the config text is irrelevant. It's the behavior of the reload operation itself.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The higher layers are the ones you step on daily, and their cause is easy to look for inside the config file. The lower you go, the more it becomes the kind of incident where reading the config file gives you no answer at all. We'll go top to bottom. Reading this with your own &lt;code&gt;nginx.conf&lt;/code&gt; open beside you should help.&lt;/p&gt;

&lt;p&gt;Note that this article assumes the behavior of the nginx 1.31 (mainline) series. The stable series is 1.30, and the behavior covered here — &lt;code&gt;if&lt;/code&gt;, location selection, &lt;code&gt;add_header&lt;/code&gt; inheritance, &lt;code&gt;alias&lt;/code&gt;, and reload — is the same across both. Where a version makes a difference, I'll call it out explicitly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Semantic level: the grammar is correct, but it's read differently
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The contents of &lt;code&gt;if&lt;/code&gt; aren't evaluated in the order you think
&lt;/h3&gt;

&lt;p&gt;When you want to "branch on a condition" in an nginx config, &lt;code&gt;if&lt;/code&gt; is the first thing you reach for. But an &lt;code&gt;if&lt;/code&gt; inside a location has long been called "if is evil" in the nginx community. It sounds like a religious argument if you only hear the name, but it's a statement about behavior.&lt;/p&gt;

&lt;p&gt;First, why it breaks. &lt;code&gt;if&lt;/code&gt; is a directive of the &lt;code&gt;rewrite&lt;/code&gt; module, evaluated in an early phase of request processing (&lt;a href="https://dev.to/shinagawa-web/nginx-doesnt-run-your-config-top-to-bottom-two-traps-with-rewrite-tryfiles-and-if-2hlm"&gt;the rewrite phase&lt;/a&gt;). And the nastier part: when you write an &lt;code&gt;if&lt;/code&gt; inside a location, that &lt;code&gt;if&lt;/code&gt; block is treated as an &lt;em&gt;implicit nested location&lt;/em&gt;. When the condition is true, nginx switches the request's entire configuration context into that inner location. Not every directive gets dropped (many settings like &lt;code&gt;root&lt;/code&gt; or &lt;code&gt;proxy_set_header&lt;/code&gt; carry over). But content-processing directives like &lt;code&gt;try_files&lt;/code&gt; or &lt;code&gt;proxy_pass&lt;/code&gt; may not carry over the way you expect. That's the trap.&lt;/p&gt;

&lt;p&gt;Let's look at a concrete example. A common, simple setup: serve a static site, but route just the requests under &lt;code&gt;/api/&lt;/code&gt; to a backend. It's completely valid grammatically and passes &lt;code&gt;nginx -t&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;root&lt;/span&gt; &lt;span class="n"&gt;/var/www/html&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;try_files&lt;/span&gt; &lt;span class="nv"&gt;$uri&lt;/span&gt; &lt;span class="nv"&gt;$uri&lt;/span&gt;&lt;span class="n"&gt;/&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;404&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;      &lt;span class="c1"&gt;# serve static files&lt;/span&gt;

    &lt;span class="kn"&gt;if&lt;/span&gt; &lt;span class="s"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$uri&lt;/span&gt; &lt;span class="p"&gt;~&lt;/span&gt; &lt;span class="sr"&gt;^/api/)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_pass&lt;/span&gt; &lt;span class="s"&gt;http://backend&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;# route only /api/ to the backend...&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The author's intent is "normally serve static files, and only proxy to the backend when the path starts with &lt;code&gt;/api/&lt;/code&gt;." But &lt;code&gt;if&lt;/code&gt; creates an implicit nested location, so when the condition is true the config context switches wholesale into it. Inside there's only &lt;code&gt;proxy_pass&lt;/code&gt;; the outer content processing like &lt;code&gt;root&lt;/code&gt; and &lt;code&gt;try_files&lt;/code&gt; isn't carried into the switched-to location. Worse, once there's an &lt;code&gt;if&lt;/code&gt; in the location, &lt;code&gt;try_files&lt;/code&gt; can stop being evaluated as expected even for requests where the condition is false. The grammar is correct and &lt;code&gt;nginx -t&lt;/code&gt; says nothing, so you get "occasional 404s" and "occasional un-proxied requests" without ever noticing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Detection and avoidance:&lt;/strong&gt; The things that are genuinely safe inside an &lt;code&gt;if&lt;/code&gt; in a location are, in practice, only &lt;code&gt;return ...&lt;/code&gt;, &lt;code&gt;rewrite ... last&lt;/code&gt;, &lt;code&gt;set ...&lt;/code&gt; (and &lt;code&gt;break&lt;/code&gt;). For anything else — &lt;code&gt;try_files&lt;/code&gt;, &lt;code&gt;proxy_pass&lt;/code&gt;, header manipulation — the moment you want to do it inside an &lt;code&gt;if&lt;/code&gt;, that's a sign the design is wrong. When you want to "route by path" like here, the answer is not &lt;code&gt;if&lt;/code&gt; but splitting the location. Routing in nginx is location's job; &lt;code&gt;if&lt;/code&gt; breaks it by cutting in.&lt;/p&gt;

&lt;p&gt;Rewriting the earlier example by splitting the location looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;root&lt;/span&gt; &lt;span class="n"&gt;/var/www/html&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;try_files&lt;/span&gt; &lt;span class="nv"&gt;$uri&lt;/span&gt; &lt;span class="nv"&gt;$uri&lt;/span&gt;&lt;span class="n"&gt;/&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;404&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;      &lt;span class="c1"&gt;# static serving is this block's only job&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/api/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;proxy_pass&lt;/span&gt; &lt;span class="s"&gt;http://backend&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;      &lt;span class="c1"&gt;# /api/ is a separate location from the start&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Making &lt;code&gt;/api/&lt;/code&gt; its own location means no implicit nesting and no config-context switch. The static-serving &lt;code&gt;location /&lt;/code&gt; and the proxying &lt;code&gt;location /api/&lt;/code&gt; each hold only their own processing. Replacing conditional branching with "location as routing" rather than "&lt;code&gt;if&lt;/code&gt; as a control statement" is the basic form of avoiding &lt;code&gt;if&lt;/code&gt;. When you want to branch on a value rather than a path, push the condition out into a &lt;code&gt;map&lt;/code&gt; (a lookup table that maps an input value to an output value) with the same mindset, and use the resulting variable.&lt;/p&gt;

&lt;h3&gt;
  
  
  Location priority is not the order you wrote them in
&lt;/h3&gt;

&lt;p&gt;When you write multiple &lt;code&gt;location&lt;/code&gt; blocks, don't you assume "the one written higher up takes priority"? I did. And it bit me.&lt;/p&gt;

&lt;p&gt;nginx's location selection runs on a rule that differs from the order you wrote. The procedure is this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;If an &lt;code&gt;=&lt;/code&gt; (exact match) location matches the URI exactly, it's decided right there and nothing else is examined.&lt;/li&gt;
&lt;li&gt;Otherwise, among the prefix locations (no modifier, or &lt;code&gt;^~&lt;/code&gt;), pick and remember the one with the longest match. Order within the config file is irrelevant here. The longest match wins.&lt;/li&gt;
&lt;li&gt;&lt;p&gt;If the selected prefix has &lt;code&gt;^~&lt;/code&gt;, it's decided there and regex is not checked.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Otherwise, check the regex locations (&lt;code&gt;~&lt;/code&gt;, &lt;code&gt;~*&lt;/code&gt;) in written order, and the first that matches wins. Here, written order matters.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;If no regex matches, use the prefix remembered in step 2.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;"Prefixes are longest-match (order-independent), regexes are first-match by written order, and regexes take priority over prefixes." That last point is what gets people. A reproduction:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/static/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;root&lt;/span&gt; &lt;span class="n"&gt;/var/www&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;location&lt;/span&gt; &lt;span class="p"&gt;~&lt;/span&gt; &lt;span class="sr"&gt;\.(png|jpe?g|gif)$&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;expires&lt;/span&gt; &lt;span class="s"&gt;30d&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;root&lt;/span&gt; &lt;span class="n"&gt;/var/www/cache&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;# &amp;lt;- a different root&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Let's trace a request to &lt;code&gt;/static/logo.png&lt;/code&gt;. As a prefix, &lt;code&gt;/static/&lt;/code&gt; is the longest match and gets "remembered." But &lt;code&gt;/static/&lt;/code&gt; has no &lt;code&gt;^~&lt;/code&gt;. So nginx proceeds to the regex check, &lt;code&gt;\.(png|jpe?g|gif)$&lt;/code&gt; matches, and that one wins. The result: &lt;code&gt;/static/logo.png&lt;/code&gt; goes looking in &lt;code&gt;/var/www/cache&lt;/code&gt;. The &lt;code&gt;/static/&lt;/code&gt; block is skipped past. The grammar is perfect, &lt;code&gt;nginx -t&lt;/code&gt; passes. Only your assumption — "I'm handling all static files under &lt;code&gt;/static/&lt;/code&gt;" — is off.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Detection:&lt;/strong&gt; Don't guess which location you're hitting — confirm it. The easy way is to temporarily add a marker header to the suspect locations:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/static/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="kn"&gt;add_header&lt;/span&gt; &lt;span class="s"&gt;X-Loc&lt;/span&gt; &lt;span class="s"&gt;"static"&lt;/span&gt; &lt;span class="s"&gt;always&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="kn"&gt;...&lt;/span&gt; &lt;span class="err"&gt;}&lt;/span&gt;
&lt;span class="s"&gt;location&lt;/span&gt; &lt;span class="p"&gt;~&lt;/span&gt; &lt;span class="sr"&gt;\.(png|jpe?g|gif)$&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="kn"&gt;add_header&lt;/span&gt; &lt;span class="s"&gt;X-Loc&lt;/span&gt; &lt;span class="s"&gt;"regex"&lt;/span&gt; &lt;span class="s"&gt;always&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="kn"&gt;...&lt;/span&gt; &lt;span class="err"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then hit &lt;code&gt;curl -sI http://localhost/static/logo.png | grep X-Loc&lt;/code&gt; and you'll know instantly which one answered. Nailing down "where am I hitting right now" as a fact comes first.&lt;/p&gt;

&lt;p&gt;Once you have that, if you want to lock it down, put &lt;code&gt;^~&lt;/code&gt; on the prefix to stop the regex check itself:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;location&lt;/span&gt; &lt;span class="s"&gt;^~&lt;/span&gt; &lt;span class="n"&gt;/static/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;   &lt;span class="c1"&gt;# decided the moment this prefix is the longest match; regex is not examined&lt;/span&gt;
    &lt;span class="kn"&gt;root&lt;/span&gt; &lt;span class="n"&gt;/var/www&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  With &lt;code&gt;add_header&lt;/code&gt;, adding one in the child makes all the parents disappear
&lt;/h3&gt;

&lt;p&gt;This is the trap that most symbolizes the "semantic level" of the three layers, and the one I was stuck on the longest.&lt;/p&gt;

&lt;p&gt;As a premise, many people write security headers like HSTS or CSP once in the &lt;code&gt;server&lt;/code&gt; block, intending them to apply to every location.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;server&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;add_header&lt;/span&gt; &lt;span class="s"&gt;Strict-Transport-Security&lt;/span&gt; &lt;span class="s"&gt;"max-age=63072000"&lt;/span&gt; &lt;span class="s"&gt;always&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;add_header&lt;/span&gt; &lt;span class="s"&gt;Content-Security-Policy&lt;/span&gt; &lt;span class="s"&gt;"default-src&lt;/span&gt; &lt;span class="s"&gt;'self'"&lt;/span&gt; &lt;span class="s"&gt;always&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="kn"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/api/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kn"&gt;add_header&lt;/span&gt; &lt;span class="s"&gt;Cache-Control&lt;/span&gt; &lt;span class="s"&gt;"no-store"&lt;/span&gt; &lt;span class="s"&gt;always&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;# &amp;lt;- the moment you add this&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_pass&lt;/span&gt; &lt;span class="s"&gt;http://backend&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You just added one cache-control header to &lt;code&gt;/api/&lt;/code&gt;. But nginx's &lt;code&gt;add_header&lt;/code&gt; is specified so that if there's even one &lt;code&gt;add_header&lt;/code&gt; at the current level, it does not inherit any &lt;code&gt;add_header&lt;/code&gt; from the parent level at all. It's replacement, not merging. The result: HSTS and CSP disappear from the &lt;code&gt;/api/&lt;/code&gt; response, and all that's left is &lt;code&gt;Cache-Control&lt;/code&gt;. Only the security headers go silently missing.&lt;/p&gt;

&lt;p&gt;This "array-style directives don't inherit (don't merge with) the parent once defined in a child" behavior is not unique to &lt;code&gt;add_header&lt;/code&gt;. &lt;code&gt;proxy_set_header&lt;/code&gt; and &lt;code&gt;fastcgi_param&lt;/code&gt; have the same trap. Write this, for example, and &lt;code&gt;Host&lt;/code&gt; and &lt;code&gt;X-Forwarded-For&lt;/code&gt; stop being passed to the backend:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;server&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;Host&lt;/span&gt;            &lt;span class="nv"&gt;$host&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;X-Forwarded-For&lt;/span&gt; &lt;span class="nv"&gt;$proxy_add_x_forwarded_for&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="kn"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/api/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;X-Request-Id&lt;/span&gt; &lt;span class="nv"&gt;$request_id&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;# &amp;lt;- just added one&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_pass&lt;/span&gt; &lt;span class="s"&gt;http://backend&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="c1"&gt;# neither Host nor X-Forwarded-For is inherited here&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Detection:&lt;/strong&gt; Missing headers — the only sure method is to actually look at the response. At each endpoint where you "think" you set a header, compare against the real thing.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-sI&lt;/span&gt; https://example.com/         | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s1"&gt;'strict-transport|content-security'&lt;/span&gt;
curl &lt;span class="nt"&gt;-sI&lt;/span&gt; https://example.com/api/foo  | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s1"&gt;'strict-transport|content-security'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Present at the top but gone under &lt;code&gt;/api/&lt;/code&gt; is the classic signature of this trap.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How to deal with it:&lt;/strong&gt; The most portable fix is to re-list every header you need in the child block. Split the common part into a separate file and &lt;code&gt;include&lt;/code&gt; it, so you gather the duplication into one place while re-declaring it at each level:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="c1"&gt;# security-headers.conf&lt;/span&gt;
&lt;span class="k"&gt;add_header&lt;/span&gt; &lt;span class="s"&gt;Strict-Transport-Security&lt;/span&gt; &lt;span class="s"&gt;"max-age=63072000"&lt;/span&gt; &lt;span class="s"&gt;always&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;add_header&lt;/span&gt; &lt;span class="s"&gt;Content-Security-Policy&lt;/span&gt;   &lt;span class="s"&gt;"default-src&lt;/span&gt; &lt;span class="s"&gt;'self'"&lt;/span&gt; &lt;span class="s"&gt;always&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;server&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;include&lt;/span&gt; &lt;span class="s"&gt;security-headers.conf&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="kn"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/api/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kn"&gt;include&lt;/span&gt; &lt;span class="s"&gt;security-headers.conf&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;      &lt;span class="c1"&gt;# re-read and re-declare in the child too&lt;/span&gt;
        &lt;span class="kn"&gt;add_header&lt;/span&gt; &lt;span class="s"&gt;Cache-Control&lt;/span&gt; &lt;span class="s"&gt;"no-store"&lt;/span&gt; &lt;span class="s"&gt;always&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_pass&lt;/span&gt; &lt;span class="s"&gt;http://backend&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;add_header_inherit merge;&lt;/code&gt; added in nginx 1.29.3 lets you change the behavior to "inherit the parent, then add the child" (it's in both the current stable 1.30 series and mainline 1.31 series). But it's not in the 1.28-and-earlier stable series, so it may not be available on your version — getting the premise "it's replacement" into your bones is more effective first.&lt;/p&gt;

&lt;h3&gt;
  
  
  Forget one trailing slash on &lt;code&gt;alias&lt;/code&gt;, and you can climb outside the directory
&lt;/h3&gt;

&lt;p&gt;Everything so far has been about "behavior that differs from intent." This &lt;code&gt;alias&lt;/code&gt; trap is the same kind of mistake — a single-slash difference — but the quality of the outcome is different. The config looks like it works as intended and does, yet you can read files outside the published directory. It's not just a behavior bug; it's a security hole as-is.&lt;/p&gt;

&lt;p&gt;A reproduction. It's the commonplace setup of "I want to serve static files from a different directory":&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/assets&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;           &lt;span class="c1"&gt;# &amp;lt;- no trailing slash&lt;/span&gt;
    &lt;span class="kn"&gt;alias&lt;/span&gt; &lt;span class="n"&gt;/var/www/static/&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;# &amp;lt;- this one has a trailing slash&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;/assets/logo.png&lt;/code&gt; is served normally. &lt;code&gt;nginx -t&lt;/code&gt; passes, and as far as the browser is concerned there's no problem. It looks like it works as intended.&lt;/p&gt;

&lt;p&gt;Why it's dangerous. &lt;code&gt;alias&lt;/code&gt; builds the real file path by replacing the portion of the URI that matched the location (here, &lt;code&gt;/assets&lt;/code&gt;) with the value of &lt;code&gt;alias&lt;/code&gt;. When the location is &lt;code&gt;/assets&lt;/code&gt; (no slash), the string following &lt;code&gt;/assets&lt;/code&gt; gets stuck on directly at the end. So a request for &lt;code&gt;/assets../&lt;/code&gt; resolves to &lt;code&gt;/var/www/static/&lt;/code&gt; + &lt;code&gt;../&lt;/code&gt; = &lt;code&gt;/var/www/static/../&lt;/code&gt; = &lt;code&gt;/var/www/&lt;/code&gt;. That means with a little trick like &lt;code&gt;/assets../../etc/passwd&lt;/code&gt;, you can climb outside the intended published directory. It's a classic path traversal (an attack that walks back up directories to read non-public files on the server). Normal access never notices it at all.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Detection and fix:&lt;/strong&gt; The way to spot it is simple — line up the trailing slashes on the location and the alias. Either both have one, or neither does. In the example above, add a trailing slash to the location side too, like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/assets/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;          &lt;span class="c1"&gt;# &amp;lt;- add the trailing slash so they match&lt;/span&gt;
    &lt;span class="kn"&gt;alias&lt;/span&gt; &lt;span class="n"&gt;/var/www/static/&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;# &amp;lt;- alias side has a trailing slash too&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now &lt;code&gt;/assets../&lt;/code&gt; no longer matches this location in the first place, and the climb is gone. Normal requests like &lt;code&gt;/assets/logo.png&lt;/code&gt; are still served exactly as before.&lt;/p&gt;

&lt;p&gt;If the location and the directory name match in the first place, the safest move is to drop &lt;code&gt;alias&lt;/code&gt; and lean on &lt;code&gt;root&lt;/code&gt; instead. &lt;code&gt;root&lt;/code&gt; doesn't replace the location portion — it appends the URI as-is under the directory — so this trailing-slash problem simply can't occur:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/assets/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;root&lt;/span&gt; &lt;span class="n"&gt;/var/www/static&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;    &lt;span class="c1"&gt;# /assets/logo.png -&amp;gt; /var/www/static/assets/logo.png&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(Note that &lt;code&gt;root&lt;/code&gt; appends the URI path verbatim, so the real directory has to be laid out as &lt;code&gt;/var/www/static/assets/&lt;/code&gt; too. When you don't want to change an existing physical layout, take the &lt;code&gt;alias&lt;/code&gt; + matched-trailing-slash route instead.)&lt;/p&gt;

&lt;p&gt;To audit an existing config, use &lt;code&gt;nginx -T&lt;/code&gt; (below) to dump all locations, and eyeball just the blocks containing &lt;code&gt;alias&lt;/code&gt; for the trailing-slash correspondence. To reproduce it at hand, poke one level up with &lt;code&gt;curl&lt;/code&gt;: if &lt;code&gt;curl -sI 'http://localhost/assets../'&lt;/code&gt; returns not a 200 or 403 but "something that shouldn't be visible," you've got a hit.&lt;/p&gt;

&lt;h2&gt;
  
  
  Security level: it works exactly as intended, which is why it's dangerous
&lt;/h2&gt;

&lt;p&gt;The semantic-level traps were "behavior that differs from intent." From here, the quality changes. The config works exactly as you intended. It just also works exactly as the attacker intended. So it passes not only &lt;code&gt;nginx -t&lt;/code&gt; but even a visual review, on a "well, it's working" basis.&lt;/p&gt;

&lt;h3&gt;
  
  
  Passing a client-touchable variable straight into &lt;code&gt;proxy_pass&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;Using a variable for the &lt;code&gt;proxy_pass&lt;/code&gt; destination lets you decide the destination dynamically per request. Handy. But it's a different story if the source of that variable is client-derived.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/fetch/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;proxy_pass&lt;/span&gt; &lt;span class="s"&gt;http://&lt;/span&gt;&lt;span class="nv"&gt;$arg_target&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;# ?target=... decides the destination&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With a request like &lt;code&gt;/fetch/?target=internal-admin:8080/&lt;/code&gt;, nginx can relay the request to any host inside your network. This is a textbook entry point for SSRF (server-side request forgery, an attack that uses your server as a stepping stone to send requests to places it otherwise couldn't reach). Including a variable in &lt;code&gt;proxy_pass&lt;/code&gt; also changes how the URI is handled and how name resolution behaves (you need a &lt;code&gt;resolver&lt;/code&gt; if you use a hostname), and while you're distracted by those details, it's easy to overlook the danger that the destination is client-controlled.&lt;/p&gt;

&lt;p&gt;I won't chase this further here — SSRF is a topic that deserves its own article. The one thing I want to nail down in this piece: "&lt;code&gt;nginx -t&lt;/code&gt; passes" and "it's safe" are entirely separate things. Whether a value the client can influence flows into &lt;code&gt;proxy_pass&lt;/code&gt;, a &lt;code&gt;rewrite&lt;/code&gt; destination, or a &lt;code&gt;root&lt;/code&gt;/&lt;code&gt;alias&lt;/code&gt; path — that perspective is completely outside the syntax check.&lt;/p&gt;

&lt;h2&gt;
  
  
  Runtime level: the config text is no longer relevant
&lt;/h2&gt;

&lt;p&gt;The last layer has a different flavor. Here the contents of the config file are correct. What "looks" broken is the behavior of the &lt;code&gt;reload&lt;/code&gt; operation itself. Stare at the config and the answer isn't written there.&lt;/p&gt;

&lt;h3&gt;
  
  
  After a reload, the old behavior lingers for a while
&lt;/h3&gt;

&lt;p&gt;You fix the config and &lt;code&gt;nginx -s reload&lt;/code&gt;. It succeeds. And yet, for a while, the old behavior is observed. It looks like a bug, but this is nginx's normal behavior.&lt;/p&gt;

&lt;p&gt;When it receives a reload (&lt;code&gt;SIGHUP&lt;/code&gt; to the master process), nginx doesn't suddenly switch everything over. It starts new worker processes with the new config and tells the old workers to shut down gracefully. Gracefully — that's the point. The old workers stop accepting new connections, but they take care of requests already in flight to the end before terminating.&lt;/p&gt;

&lt;p&gt;Normally this finishes in an instant, so you don't notice. The problem is when there are long-lived connections. WebSockets, large file downloads, long polling. An old worker holding one of these stays put until that connection ends. In other words, a window forms where the old and new configs are alive at the same time. That's the true identity of "the old behavior lingers after a reload." The config has been updated. It's just that a worker with the old config is still running.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Detection:&lt;/strong&gt; This one you look at processes, not the config.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ps &lt;span class="nt"&gt;-eo&lt;/span&gt; pid,ppid,command | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="s1"&gt;'[n]ginx'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Old workers linger with the label &lt;code&gt;nginx: worker process is shutting down&lt;/code&gt;. If this "shutting down" keeps sitting alongside normal workers (&lt;code&gt;nginx: worker process&lt;/code&gt;), that's the substance of old-and-new coexisting. If you want to cap how long they linger, set &lt;code&gt;worker_shutdown_timeout&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;worker_shutdown_timeout&lt;/span&gt; &lt;span class="s"&gt;30s&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;# force old workers to terminate after at most 30 seconds&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The important thing here is not to chase this as a bug. The cause isn't written in the config file — it's in the single fact that "reload is that kind of operation."&lt;/p&gt;

&lt;p&gt;Also, confirming worker generations with &lt;code&gt;ps&lt;/code&gt; requires being able to log into that server. I'm also working on a small tool that peeks — from the outside, without touching the running nginx at all — at which worker generation is holding connections after a &lt;code&gt;reload&lt;/code&gt;, using eBPF (&lt;a href="https://github.com/shinagawa-web/ngxray" rel="noopener noreferrer"&gt;ngxray&lt;/a&gt;, work in progress).&lt;/p&gt;

&lt;h2&gt;
  
  
  Summary: how to fill in the space outside the syntax check
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;nginx -t&lt;/code&gt; doesn't lie. It just answers a narrow question. "Is it in a loadable shape?" — it answers that. "Is it read as intended?" "Can it be abused by an attacker?" "What happens during a reload?" — it was never answering those to begin with.&lt;/p&gt;

&lt;p&gt;Each of the three layers is filled in differently.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Semantic level&lt;/strong&gt; (&lt;code&gt;if&lt;/code&gt;, location priority, &lt;code&gt;add_header&lt;/code&gt; inheritance drops) can be caught by looking at the actual response. Marker headers and the difference in &lt;code&gt;curl -I&lt;/code&gt;. Get into the habit of comparing behavior with &lt;code&gt;curl -sI&lt;/code&gt; rather than reading the config and convincing yourself. Flattening the "config that's actually in effect" once with &lt;code&gt;nginx -T&lt;/code&gt; (uppercase; it dumps the final config with all &lt;code&gt;include&lt;/code&gt;s expanded) also helps.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Security level&lt;/strong&gt; is about tracing where client-derived values flow. Whether external input reaches &lt;code&gt;proxy_pass&lt;/code&gt;, a destination, or a path.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Runtime level&lt;/strong&gt; is about looking at processes and connections, not the config. Check worker generations with &lt;code&gt;ps&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The syntax check is only the first sheet of the layers. From the second sheet down, for now, I fill it in with human observation and discipline. Having a machine watch this space — reading the config not just by syntax but by "meaning," warning about location overtakes and header inheritance drops — static analysis, and a mechanism that continuously observes the actual responses, is the topic I want to think about next.&lt;/p&gt;

&lt;p&gt;For now, one line is all I want you to take home. When &lt;code&gt;nginx -t&lt;/code&gt; passes, the next thing to hit is &lt;code&gt;curl -sI&lt;/code&gt;. That the syntax is correct and that it works as intended are two things you confirm separately.&lt;/p&gt;

&lt;p&gt;The static analysis tools that automate parts of this gap — Gixy for security checks, crossplane for structural validation, Test::Nginx for behavioral testing — are &lt;a href="https://dev.to/shinagawa-web/nginx-tools-for-when-your-config-gets-out-of-hand-2075"&gt;mapped in a companion article&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  References (official documentation)
&lt;/h2&gt;

&lt;p&gt;The behavior in this article can all be backed up by official documentation. When you suspect your config, this is the first place to check.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://nginx.org/en/docs/http/ngx_http_rewrite_module.html#if" rel="noopener noreferrer"&gt;ngx_http_rewrite_module (&lt;code&gt;if&lt;/code&gt;)&lt;/a&gt; and &lt;a href="https://www.nginx.com/resources/wiki/start/topics/depth/ifisevil/" rel="noopener noreferrer"&gt;If Is Evil&lt;/a&gt; — the story of &lt;code&gt;if&lt;/code&gt; creating an implicit location&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://nginx.org/en/docs/http/ngx_http_core_module.html#location" rel="noopener noreferrer"&gt;The &lt;code&gt;location&lt;/code&gt; directive&lt;/a&gt; — the selection algorithm (longest prefix and regex priority)&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;a href="https://nginx.org/en/docs/http/ngx_http_headers_module.html" rel="noopener noreferrer"&gt;ngx_http_headers_module (&lt;code&gt;add_header&lt;/code&gt;)&lt;/a&gt; — inheritance rules and &lt;code&gt;add_header_inherit&lt;/code&gt;, added in 1.29.3&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;a href="https://nginx.org/en/docs/http/ngx_http_core_module.html#alias" rel="noopener noreferrer"&gt;The &lt;code&gt;alias&lt;/code&gt; directive&lt;/a&gt; — how the real file path is assembled&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;a href="https://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_pass" rel="noopener noreferrer"&gt;&lt;code&gt;proxy_pass&lt;/code&gt;&lt;/a&gt; — behavior when using a variable in the destination, and &lt;code&gt;resolver&lt;/code&gt;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;a href="https://nginx.org/en/docs/control.html" rel="noopener noreferrer"&gt;Controlling nginx&lt;/a&gt; and &lt;a href="https://nginx.org/en/docs/ngx_core_module.html#worker_shutdown_timeout" rel="noopener noreferrer"&gt;&lt;code&gt;worker_shutdown_timeout&lt;/code&gt;&lt;/a&gt; — reload and graceful shutdown&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>nginx</category>
      <category>devops</category>
      <category>programming</category>
      <category>security</category>
    </item>
    <item>
      <title>"How I Designed a Go Linter's Rule Engine So Adding a Rule Is One Line"</title>
      <dc:creator>Kazu</dc:creator>
      <pubDate>Tue, 14 Jul 2026 13:02:00 +0000</pubDate>
      <link>https://dev.to/shinagawa-web/keeping-add-a-rule-a-one-line-change-inside-gomarklints-rule-engine-pcb</link>
      <guid>https://dev.to/shinagawa-web/keeping-add-a-rule-a-one-line-change-inside-gomarklints-rule-engine-pcb</guid>
      <description>&lt;p&gt;"Just add one more check" — and before you know it, a &lt;code&gt;switch&lt;/code&gt; grows another branch, the config grows another case, and you're copy-pasting the same setup you already wrote somewhere else. If you've ever worked on code where rules or checks keep piling up, you probably recognize this kind of rot.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/shinagawa-web/gomarklint" rel="noopener noreferrer"&gt;gomarklint&lt;/a&gt; is a Markdown linter, and it currently ships 24 checks (rules). Yet adding a new one touches just a function, one line in a table, and two spots in the config. The &lt;code&gt;switch&lt;/code&gt; doesn't grow. No wiring to add. The cost of adding a rule stays flat no matter how many there are. This post takes that apart from the inside — how that state is kept.&lt;/p&gt;

&lt;h2&gt;
  
  
  First, let's run gomarklint
&lt;/h2&gt;

&lt;p&gt;Before we go inside, let's see what the tool actually does, once. gomarklint is simple: it reads Markdown files, finds structural and link problems, and points at them with a &lt;code&gt;file:line&lt;/code&gt;. Say you have a README with a link that goes nowhere.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gh"&gt;# My Project&lt;/span&gt;

See the &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;docs&lt;/span&gt;&lt;span class="p"&gt;](&lt;/span&gt;&lt;span class="sx"&gt;)&lt;/span&gt; for details.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Run gomarklint on it and you get:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;gomarklint README.md
&lt;span class="go"&gt;
Errors in README.md:
  README.md:3: [error] no-empty-links: link has empty destination: [docs]()

✖ 1 issues found
✓ Checked 1 file(s), 3 line(s) in 1ms
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Because an &lt;code&gt;[error]&lt;/code&gt; was printed, the exit code is non-zero. In CI, this is where the build fails.&lt;/p&gt;

&lt;p&gt;When this post says "rule," it means one of those checks that produces a single line of output like that. "Find empty links." "Check that heading levels don't skip." "Check for duplicate headings." There are 24 such checks today, and each one returns "which line, and what's wrong." gomarklint's own job is to collect those findings (the &lt;code&gt;LintError&lt;/code&gt; you'll see in code shortly), sort them, and print them.&lt;/p&gt;

&lt;p&gt;Here's the real question. How do you keep those 24 checks at "adding one is a one-line change"? We'll open it up in order — the diagnostic type, how rules are laid out, the shared preprocessing that skips code and HTML, and how severity wires into the exit code — and finish by adding a real rule through an actual PR (&lt;a href="https://github.com/shinagawa-web/gomarklint/issues/106" rel="noopener noreferrer"&gt;#106&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;Even if you're not writing a linter — a validator, a CI check, a rule-based plugin system — if you write "a mechanism where items keep growing" in Go, this should be a template you can copy directly.&lt;/p&gt;
&lt;h2&gt;
  
  
  Rules pile up, and code usually rots
&lt;/h2&gt;

&lt;p&gt;Take a check like that "find empty links" one, and now imagine 24 of them. Built naively, the dispatch and configuration rot a little more with every rule you add. The usual four traps:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Every new rule makes some &lt;code&gt;switch&lt;/code&gt; longer. You can't tell where a new rule should be registered, so you get missed registrations and double registrations.&lt;/li&gt;
&lt;li&gt;Small setup logic like "don't flag &lt;code&gt;[x]()&lt;/code&gt; inside a code block" gets reimplemented in each rule. Fix it in one, and another stays stale.&lt;/li&gt;
&lt;li&gt;The items you can write in the config file and the default values in code become two sources of truth, and one gets updated while the other drifts.&lt;/li&gt;
&lt;li&gt;The "error or warning?" decision — the same one that just set our exit code — plus its counting and its effect on the exit code get scattered around, and CI fails or passes not quite as intended.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;gomarklint would have stepped on every one of these if left alone. This post walks through how each is avoided, in order.&lt;/p&gt;
&lt;h2&gt;
  
  
  First, express diagnostics, config, and severity as minimal types
&lt;/h2&gt;

&lt;p&gt;At the center of the engine sit three small types. Everything wobbles if these wobble, so they're kept deliberately plain.&lt;/p&gt;

&lt;p&gt;The first is the violation itself.&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;LintError&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;File&lt;/span&gt;     &lt;span class="kt"&gt;string&lt;/span&gt;
    &lt;span class="n"&gt;Line&lt;/span&gt;     &lt;span class="kt"&gt;int&lt;/span&gt;
    &lt;span class="n"&gt;Rule&lt;/span&gt;     &lt;span class="kt"&gt;string&lt;/span&gt;
    &lt;span class="n"&gt;Message&lt;/span&gt;  &lt;span class="kt"&gt;string&lt;/span&gt;
    &lt;span class="n"&gt;Severity&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;The point is that &lt;code&gt;Rule&lt;/code&gt; and &lt;code&gt;Severity&lt;/code&gt; are in there but are filled in by the engine, not the rule — as we'll see later. A rule function only has to return "which line, and what happened."&lt;/p&gt;

&lt;p&gt;The second is per-rule configuration.&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;RuleConfig&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;Enabled&lt;/span&gt;  &lt;span class="kt"&gt;bool&lt;/span&gt;
    &lt;span class="n"&gt;Severity&lt;/span&gt; &lt;span class="n"&gt;RuleSeverity&lt;/span&gt;
    &lt;span class="n"&gt;Options&lt;/span&gt;  &lt;span class="k"&gt;map&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="k"&gt;interface&lt;/span&gt;&lt;span class="p"&gt;{}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;The third is severity, which has only three values.&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;SeverityError&lt;/span&gt;   &lt;span class="n"&gt;RuleSeverity&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"error"&lt;/span&gt;   &lt;span class="c"&gt;// presence of this makes the exit non-zero&lt;/span&gt;
    &lt;span class="n"&gt;SeverityWarning&lt;/span&gt; &lt;span class="n"&gt;RuleSeverity&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"warning"&lt;/span&gt; &lt;span class="c"&gt;// reported, but doesn't fail the run&lt;/span&gt;
    &lt;span class="n"&gt;SeverityOff&lt;/span&gt;     &lt;span class="n"&gt;RuleSeverity&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"off"&lt;/span&gt;     &lt;span class="c"&gt;// disabled&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;If there's an &lt;code&gt;error&lt;/code&gt;, CI fails. Hold onto just that one fact, and the rest of the branching comes back to it.&lt;/p&gt;
&lt;h2&gt;
  
  
  Rules are functions, not an interface
&lt;/h2&gt;

&lt;p&gt;This is what changed the most in the engine.&lt;/p&gt;

&lt;p&gt;The obvious design is to define a &lt;code&gt;Rule&lt;/code&gt; interface and make each rule implement &lt;code&gt;Check()&lt;/code&gt;. gomarklint doesn't do that. A rule is just a function, and the functions are laid out in a table.&lt;/p&gt;

&lt;p&gt;They even split into two families by signature. One takes just the raw slice of lines — the simple rules.&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;simpleRules&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;
    &lt;span class="n"&gt;fn&lt;/span&gt;   &lt;span class="k"&gt;func&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="n"&gt;rule&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;LintError&lt;/span&gt;
&lt;span class="p"&gt;}{&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="s"&gt;"final-blank-line"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rule&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CheckFinalBlankLine&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;The other takes a shared preprocessing result, &lt;code&gt;*preprocess.Context&lt;/code&gt;, which we'll get to. Most rules live on this side now.&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;contextRules&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;
    &lt;span class="n"&gt;fn&lt;/span&gt;   &lt;span class="k"&gt;func&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;preprocess&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="n"&gt;rule&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;LintError&lt;/span&gt;
&lt;span class="p"&gt;}{&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="s"&gt;"no-bare-urls"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rule&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CheckNoBareURLs&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="s"&gt;"single-h1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rule&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CheckSingleH1&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="s"&gt;"duplicate-heading"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rule&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CheckDuplicateHeadings&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="s"&gt;"unclosed-code-block"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rule&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CheckUnclosedCodeBlocks&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="s"&gt;"empty-alt-text"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rule&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CheckEmptyAltText&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="s"&gt;"no-empty-links"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rule&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CheckNoEmptyLinks&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="c"&gt;// …and more&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Running the rules is then just iterating that table.&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="k"&gt;range&lt;/span&gt; &lt;span class="n"&gt;contextRules&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;l&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IsEnabled&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;errs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;errs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;l&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;withSeverity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;fn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;offset&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;...&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Only the rules that take options — like &lt;code&gt;heading-level&lt;/code&gt;'s &lt;code&gt;minLevel&lt;/code&gt; or the &lt;code&gt;style&lt;/code&gt; of &lt;code&gt;consistent-*&lt;/code&gt;, and &lt;code&gt;max-line-length&lt;/code&gt; — are kept out of the table and listed below with explicit &lt;code&gt;if&lt;/code&gt;s, because they take extra arguments. The trade is: "the option-free majority goes in a table, the option-taking minority is spelled out."&lt;/p&gt;

&lt;p&gt;There's a cost to not using an interface. You can't do dynamic extension like dropping in a rule as a plugin at runtime. But the payoff was bigger.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Rules become stateless pure functions. Give input, get output — so tests fall straight out as table-driven.&lt;/li&gt;
&lt;li&gt;Which rules run is fully visible by reading one slice. Nothing hides behind an abstraction.&lt;/li&gt;
&lt;li&gt;Registering a new rule is, literally, one line in a slice.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  Write "skip inside code blocks" once
&lt;/h2&gt;

&lt;p&gt;Of those traps, the one that hurts most quietly is "the logic to skip inside code blocks scattered across every rule."&lt;/p&gt;

&lt;p&gt;The heading rule, the link rule, the emphasis rule — all of them want to say "but not inside a fenced code block." Done naively, each rule starts counting "am I inside a fence right now?" on its own. And then one rule misses indented code blocks, another misses HTML comments — inconsistencies creep in. This actually happened in gomarklint, and we took inventory of it in &lt;a href="https://github.com/shinagawa-web/gomarklint/issues/337" rel="noopener noreferrer"&gt;issue #337&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The answer is &lt;code&gt;preprocess.Scan&lt;/code&gt;. It walks the file's lines exactly once, classifies each line into "fenced code / indented code / HTML block / HTML comment," and hands it to every rule as a &lt;code&gt;*preprocess.Context&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;ctx&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;preprocess&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Scan&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lines&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;allErrors&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;l&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;collectLineErrors&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;lines&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;offset&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;The rule side no longer counts anything itself. It just asks the shared verdict.&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;inBlockContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;continue&lt;/span&gt; &lt;span class="c"&gt;// inside code/HTML, so this rule ignores it&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;The clever bit here is that &lt;code&gt;Context&lt;/code&gt; deliberately does not offer a single "is this line skippable?" convenience method. It exposes the four contexts individually. There's a reason: &lt;code&gt;max-line-length&lt;/code&gt; and &lt;code&gt;no-hard-tabs&lt;/code&gt;, for instance, do want to look inside fenced code (line length and tabs should be flagged even there). So "what to skip" is the rule's choice. The &lt;code&gt;inBlockContext&lt;/code&gt; helper for the majority is just a thin OR over those four.&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;inBlockContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;preprocess&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;InFencedCode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;InIndentedCode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;
        &lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;InHTMLBlock&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;InHTMLComment&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;&lt;code&gt;Context&lt;/code&gt; is trimmed hard for speed. It borrows the input line slice rather than copying it. The context flags are packed one byte per line, and the "sanitized line" with inline code blanked out is stored in a map only for the lines that differ. Since &lt;code&gt;Scan&lt;/code&gt; runs over every file, that one byte matters. But that's a story about designing for speed, so I'll leave the deep dive to the benchmarking post. Here the design win — "write the skip logic once and share it with everyone" — is the real point.&lt;/p&gt;
&lt;h2&gt;
  
  
  Severity and exit code are wired on the engine side
&lt;/h2&gt;

&lt;p&gt;I said the rule functions fill in neither &lt;code&gt;Rule&lt;/code&gt; nor &lt;code&gt;Severity&lt;/code&gt;. What fills them is this &lt;code&gt;withSeverity&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;l&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;Linter&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;withSeverity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;errs&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="n"&gt;rule&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;LintError&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ruleName&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="n"&gt;rule&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;LintError&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;sev&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;l&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RuleSeverity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ruleName&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="k"&gt;range&lt;/span&gt; &lt;span class="n"&gt;errs&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;errs&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Rule&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ruleName&lt;/span&gt;
        &lt;span class="n"&gt;errs&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Severity&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;sev&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;errs&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;The rule body returns only the "facts," and whether it's an error or a warning is decided by the config. The same rule can be an error for one team and a warning for another. Finally, the number of errors is counted, and if there's even one, the exit is non-zero. The basis for the CI gate is consolidated into this one place.&lt;/p&gt;
&lt;h2&gt;
  
  
  Prevent config/code drift with a contract
&lt;/h2&gt;

&lt;p&gt;The flexibility of &lt;code&gt;.gomarklint.json&lt;/code&gt; comes from &lt;code&gt;RuleConfig.UnmarshalJSON&lt;/code&gt; accepting three shorthands.&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"rules"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"no-empty-links"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;                        &lt;/span&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;enabled&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;/&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;error&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"no-trailing-punctuation"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"warning"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;          &lt;/span&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;enabled&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;/&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;warning&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"max-line-length"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"enabled"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"lineLength"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;120&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;full&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;spec&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;&lt;code&gt;true&lt;/code&gt; means "enabled / error," &lt;code&gt;"warning"&lt;/code&gt; means "enabled / warning," and an object is a full spec. The writer's convenience (the human) is absorbed by one method on the parser side.&lt;/p&gt;

&lt;p&gt;Here's the two-sources-of-truth trap. There's a default definition in the Go code too, &lt;code&gt;config.Default()&lt;/code&gt;, and the JSON string that &lt;code&gt;gomarklint init&lt;/code&gt; emits (&lt;code&gt;DefaultConfigJSON&lt;/code&gt;) carries the same list. These two are kept in sync by a manual contract — and the code says so in a comment. It is not auto-generated. When you add a rule, you update both; that's the promise.&lt;/p&gt;
&lt;h2&gt;
  
  
  Actually adding one: no-empty-links (MD042)
&lt;/h2&gt;

&lt;p&gt;With the tooling in place, let's trace it through a real PR. It's &lt;code&gt;no-empty-links&lt;/code&gt; (MD042 in markdownlint terms, empty-link detection), added in &lt;a href="https://github.com/shinagawa-web/gomarklint/issues/106" rel="noopener noreferrer"&gt;issue #106&lt;/a&gt;. It catches links whose destination is empty, like &lt;code&gt;[text]()&lt;/code&gt;.&lt;/p&gt;
&lt;h3&gt;
  
  
  1. Write one rule function
&lt;/h3&gt;

&lt;p&gt;In &lt;code&gt;internal/rule/no_empty_links.go&lt;/code&gt;, write one function with the &lt;code&gt;contextRules&lt;/code&gt; signature. The parts we've built so far just work.&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;CheckNoEmptyLinks&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;filename&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ctx&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;preprocess&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;offset&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="n"&gt;LintError&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;errs&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="n"&gt;LintError&lt;/span&gt;

    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Len&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;inBlockContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt; &lt;span class="c"&gt;// ignore links inside code/HTML&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="c"&gt;// Line with inline code blanked out, so a link-looking&lt;/span&gt;
        &lt;span class="c"&gt;// `[x]()` inside a code span isn't a false positive.&lt;/span&gt;
        &lt;span class="n"&gt;line&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Sanitized&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;strings&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Contains&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"]("&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;match&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="k"&gt;range&lt;/span&gt; &lt;span class="n"&gt;findEmptyLinks&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;errs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;errs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;LintError&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="n"&gt;File&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;    &lt;span class="n"&gt;filename&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;Line&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;    &lt;span class="n"&gt;offset&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;Message&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Sprintf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"no-empty-links: link has empty destination: %s"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;match&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="p"&gt;})&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;errs&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;All that's written here is the judgment of "what counts as an empty link" (&lt;code&gt;findEmptyLinks&lt;/code&gt; catches &lt;code&gt;[]()&lt;/code&gt;, &lt;code&gt;[](#)&lt;/code&gt;, &lt;code&gt;[](&amp;lt;&amp;gt;)&lt;/code&gt;, and the image variants). "Skip code blocks," "blank out inline code," "adjust the line number by offset" — those all just call the shared parts that already exist. &lt;code&gt;Rule&lt;/code&gt; and &lt;code&gt;Severity&lt;/code&gt; aren't filled in — the engine adds them later.&lt;/p&gt;
&lt;h3&gt;
  
  
  2. Add one line to the table
&lt;/h3&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;contextRules&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="c"&gt;/* … */&lt;/span&gt; &lt;span class="p"&gt;}{&lt;/span&gt;
    &lt;span class="c"&gt;// …&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="s"&gt;"no-empty-links"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rule&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CheckNoEmptyLinks&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="c"&gt;// ← just this&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Dispatch is done. No &lt;code&gt;switch&lt;/code&gt;, no wiring to add.&lt;/p&gt;
&lt;h3&gt;
  
  
  3. Register in config (two spots)
&lt;/h3&gt;

&lt;p&gt;Add &lt;code&gt;no-empty-links&lt;/code&gt; to both &lt;code&gt;config.Default()&lt;/code&gt; and &lt;code&gt;DefaultConfigJSON&lt;/code&gt;. This is the "keep in sync by hand" contract in practice.&lt;/p&gt;
&lt;h3&gt;
  
  
  4. Add tests
&lt;/h3&gt;

&lt;p&gt;Write &lt;code&gt;internal/rule/no_empty_links_test.go&lt;/code&gt; table-driven, like the other rules. Because the rule is a pure function, you just line up input lines and expected violations.&lt;/p&gt;

&lt;p&gt;Of the four steps, the only one where you really "think" is step 1. The rest is one line, two spots, and a boilerplate test. Even at 24 rules, this shape hasn't broken.&lt;/p&gt;
&lt;h2&gt;
  
  
  Extensibility isn't adding layers
&lt;/h2&gt;

&lt;p&gt;Many linters get slower and harder to extend as they grow, because every rule leans on a shared, complex framework. gomarklint went the other way. Each rule is an independent, stateless function, and only the thing everyone needs — the code/HTML context check — was pulled out into a single preprocessing pass. Simplicity scales better than abstraction. Even now, with the engine carrying 24 rules, that still holds.&lt;/p&gt;

&lt;p&gt;The result is:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Adding a rule is one function + one line in a table + two config spots,&lt;/li&gt;
&lt;li&gt;Which rules run is visible by reading a slice,&lt;/li&gt;
&lt;li&gt;It scales linearly with repository size.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Extensibility is often assumed to mean stacking layers — but sometimes it's about not stacking them. If you ever want to enforce your own documentation style guide, you can copy this shape directly. Write one function, add one line to the table. That's it.&lt;/p&gt;

&lt;p&gt;For the background on parsing and the overall architecture, my earlier post &lt;em&gt;Inside gomarklint: Architecture, Rule Engine, and How to Extend It&lt;/em&gt; is also worth a look (the engine has since evolved into the shape described here).&lt;/p&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/shinagawa-web" rel="noopener noreferrer"&gt;
        shinagawa-web
      &lt;/a&gt; / &lt;a href="https://github.com/shinagawa-web/gomarklint" rel="noopener noreferrer"&gt;
        gomarklint
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      Catch broken links before your readers do.
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;gomarklint&lt;/h1&gt;
&lt;/div&gt;

&lt;p&gt;&lt;a rel="noopener noreferrer" href="https://github.com/shinagawa-web/gomarklint/actions/workflows/test.yml/badge.svg"&gt;&lt;img src="https://github.com/shinagawa-web/gomarklint/actions/workflows/test.yml/badge.svg" alt="Test"&gt;&lt;/a&gt;
&lt;a href="https://codecov.io/gh/shinagawa-web/gomarklint" rel="nofollow noopener noreferrer"&gt;&lt;img src="https://camo.githubusercontent.com/3a62ea1c605d28ca8b76a989447c82d628955be97440820aa775fdaf031acd42/68747470733a2f2f636f6465636f762e696f2f67682f7368696e61676177612d7765622f676f6d61726b6c696e742f67726170682f62616467652e7376673f746f6b656e3d354d4743595a5a593753" alt="codecov"&gt;&lt;/a&gt;
&lt;a href="https://pkg.go.dev/github.com/shinagawa-web/gomarklint" rel="nofollow noopener noreferrer"&gt;&lt;img src="https://camo.githubusercontent.com/6dfb8608e07cc6f5d15a43cf57ad1bcca3ab07f55909cc6b48380c79b1b4d277/68747470733a2f2f706b672e676f2e6465762f62616467652f6769746875622e636f6d2f7368696e61676177612d7765622f676f6d61726b6c696e742e737667" alt="Go Reference"&gt;&lt;/a&gt;
&lt;a href="https://github.com/shinagawa-web/gomarklint/LICENSE" rel="noopener noreferrer"&gt;&lt;img src="https://camo.githubusercontent.com/fdf2982b9f5d7489dcf44570e714e3a15fce6253e0cc6b5aa61a075aac2ff71b/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f4c6963656e73652d4d49542d79656c6c6f772e737667" alt="License: MIT"&gt;&lt;/a&gt;
&lt;a href="https://securityscorecards.dev/viewer/?uri=github.com/shinagawa-web/gomarklint" rel="nofollow noopener noreferrer"&gt;&lt;img src="https://camo.githubusercontent.com/aa4a07c35236d812f2e7f06eded94a41cfedf3d96fce7c9a3264c1a861e4b7d4/68747470733a2f2f6170692e736563757269747973636f726563617264732e6465762f70726f6a656374732f6769746875622e636f6d2f7368696e61676177612d7765622f676f6d61726b6c696e742f6261646765" alt="OpenSSF Scorecard"&gt;&lt;/a&gt;
&lt;a href="https://www.bestpractices.dev/projects/12970" rel="nofollow noopener noreferrer"&gt;&lt;img src="https://camo.githubusercontent.com/82af1e6eafb8ced8bfed64b0b39bdcb64d047fb6982d66aabd364828fe9ea371/68747470733a2f2f7777772e626573747072616374696365732e6465762f70726f6a656374732f31323937302f6261646765" alt="OpenSSF Best Practices"&gt;&lt;/a&gt;
&lt;a href="https://www.npmjs.com/package/@shinagawa-web/gomarklint" rel="nofollow noopener noreferrer"&gt;&lt;img src="https://camo.githubusercontent.com/10a64e2908c3838b4807ecad04edb3b8da11ff42205368324d3a775ec21a5972/68747470733a2f2f696d672e736869656c64732e696f2f6e706d2f762f407368696e61676177612d7765622f676f6d61726b6c696e742e737667" alt="npm version"&gt;&lt;/a&gt;
&lt;a href="https://www.npmjs.com/package/@shinagawa-web/gomarklint" rel="nofollow noopener noreferrer"&gt;&lt;img src="https://camo.githubusercontent.com/bb3fda742cdbb106cfe477862812f68fafcac76d434a7a2ec7efddfd3159ed67/68747470733a2f2f696d672e736869656c64732e696f2f6e706d2f64772f407368696e61676177612d7765622f676f6d61726b6c696e742e737667" alt="npm downloads"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;English | &lt;a href="https://github.com/shinagawa-web/gomarklint/README.ja.md" rel="noopener noreferrer"&gt;日本語&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;a rel="noopener noreferrer" href="https://github.com/shinagawa-web/gomarklint/docs/static/demo.gif"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fraw.githubusercontent.com%2Fshinagawa-web%2Fgomarklint%2FHEAD%2Fdocs%2Fstatic%2Fdemo.gif" width="800" alt="gomarklint catching a broken link and structure issues"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Catch broken links before your readers do — and keep your Markdown clean while you're at it. &lt;strong&gt;100,000+ lines in ~170ms&lt;/strong&gt;, single binary, no Node.js required.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;&lt;strong&gt;Quick install&lt;/strong&gt; (macOS / Linux):&lt;/p&gt;
&lt;div class="highlight highlight-source-shell notranslate position-relative overflow-auto js-code-highlight"&gt;
&lt;pre&gt;curl -fsSL https://raw.githubusercontent.com/shinagawa-web/gomarklint/main/install.sh &lt;span class="pl-k"&gt;|&lt;/span&gt; sh&lt;/pre&gt;

&lt;/div&gt;
&lt;p&gt;&lt;strong&gt;Download binary&lt;/strong&gt; (no Go required):&lt;/p&gt;
&lt;p&gt;Download the latest binary for your platform from &lt;a href="https://github.com/shinagawa-web/gomarklint/releases/latest" rel="noopener noreferrer"&gt;GitHub Releases&lt;/a&gt;.&lt;/p&gt;
&lt;div class="highlight highlight-source-shell notranslate position-relative overflow-auto js-code-highlight"&gt;
&lt;pre&gt;&lt;span class="pl-c"&gt;&lt;span class="pl-c"&gt;#&lt;/span&gt; macOS / Linux&lt;/span&gt;
tar -xzf gomarklint_Darwin_x86_64.tar.gz
sudo mv gomarklint /usr/local/bin/
&lt;span class="pl-c"&gt;&lt;span class="pl-c"&gt;#&lt;/span&gt; or install to user-local directory (no sudo required)&lt;/span&gt;
mkdir -p &lt;span class="pl-k"&gt;~&lt;/span&gt;/.local/bin &lt;span class="pl-k"&gt;&amp;amp;&amp;amp;&lt;/span&gt; mv gomarklint &lt;span class="pl-k"&gt;~&lt;/span&gt;/.local/bin/&lt;/pre&gt;

&lt;/div&gt;
&lt;div class="highlight highlight-source-powershell notranslate position-relative overflow-auto js-code-highlight"&gt;
&lt;pre&gt;&lt;span class="pl-c"&gt;&lt;span class="pl-c"&gt;#&lt;/span&gt; Windows (PowerShell)&lt;/span&gt;
&lt;span class="pl-c1"&gt;Expand-Archive&lt;/span&gt; &lt;span class="pl-k"&gt;-&lt;/span&gt;Path gomarklint_Windows_x86_64.zip &lt;span class="pl-k"&gt;-&lt;/span&gt;DestinationPath &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;span class="pl-smi"&gt;$&lt;span class="pl-c1"&gt;env:&lt;/span&gt;LOCALAPPDATA&lt;/span&gt;\Programs\gomarklint&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;
&lt;span class="pl-c"&gt;&lt;span class="pl-c"&gt;#&lt;/span&gt; Add to PATH (run once)&lt;/span&gt;
[&lt;span class="pl-k"&gt;Environment&lt;/span&gt;]::SetEnvironmentVariable(&lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;PATH&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;&lt;span class="pl-k"&gt;,&lt;/span&gt; &lt;span class="pl-smi"&gt;$&lt;span class="pl-c1"&gt;env:&lt;/span&gt;PATH&lt;/span&gt; &lt;span class="pl-k"&gt;+&lt;/span&gt; &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;;&lt;span class="pl-smi"&gt;$&lt;span class="pl-c1"&gt;env:&lt;/span&gt;LOCALAPPDATA&lt;/span&gt;\Programs\gomarklint&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;&lt;span class="pl-k"&gt;,&lt;/span&gt; &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;User&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;)&lt;/pre&gt;

&lt;/div&gt;
&lt;p&gt;&lt;strong&gt;Via Homebrew:&lt;/strong&gt;&lt;/p&gt;
&lt;div class="highlight highlight-source-shell notranslate position-relative overflow-auto js-code-highlight"&gt;
&lt;pre&gt;brew install shinagawa-web/tap/gomarklint&lt;/pre&gt;

&lt;/div&gt;
&lt;p&gt;&lt;strong&gt;Via npm:&lt;/strong&gt;&lt;/p&gt;
&lt;div class="highlight highlight-source-shell notranslate position-relative overflow-auto js-code-highlight"&gt;
&lt;pre&gt;npm install -g &lt;a class="mentioned-user" href="https://dev.to/shinagawa-web"&gt;@shinagawa-web&lt;/a&gt;/gomarklint&lt;/pre&gt;

&lt;/div&gt;
&lt;p&gt;&lt;strong&gt;Via &lt;code&gt;go install&lt;/code&gt;:&lt;/strong&gt;&lt;/p&gt;
&lt;div class="highlight highlight-source-shell notranslate position-relative overflow-auto js-code-highlight"&gt;
&lt;pre&gt;go install github.com/shinagawa-web/gomarklint/v3@latest&lt;/pre&gt;

&lt;/div&gt;
&lt;ul&gt;
&lt;li&gt;Catch broken…&lt;/li&gt;
&lt;/ul&gt;&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/shinagawa-web/gomarklint" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


</description>
      <category>go</category>
      <category>markdown</category>
      <category>opensource</category>
      <category>programming</category>
    </item>
    <item>
      <title>Tired of re-recording my README demo GIF by hand, I turned the whole demo environment into code</title>
      <dc:creator>Kazu</dc:creator>
      <pubDate>Tue, 07 Jul 2026 13:01:00 +0000</pubDate>
      <link>https://dev.to/shinagawa-web/tired-of-re-recording-my-readme-demo-gif-by-hand-i-turned-the-whole-demo-environment-into-code-29lc</link>
      <guid>https://dev.to/shinagawa-web/tired-of-re-recording-my-readme-demo-gif-by-hand-i-turned-the-whole-demo-environment-into-code-29lc</guid>
      <description>&lt;p&gt;The demo GIF at the top of an OSS README. That little clip that shows you what a tool does in five seconds, before a single line of prose does. That one.&lt;/p&gt;

&lt;p&gt;I used to re-record mine by hand. Every single time.&lt;/p&gt;

&lt;p&gt;Fire up the screen recorder, tidy up the terminal, type the command, stop at a good moment, export to GIF. Three minutes if it goes well. It usually doesn't. I fat-finger one character in the command. I hit that final &lt;code&gt;q&lt;/code&gt; a beat too early and the recording ends before the screen I wanted to show even appears. Again. This time I notice, after exporting, that the terminal font size is different from last time. Again.&lt;/p&gt;

&lt;p&gt;That's how "just one GIF" would casually swallow 30 minutes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Hand-recording produces a slightly different picture every time
&lt;/h2&gt;

&lt;p&gt;The biggest problem with recording by hand is that the picture changes every time you redo it.&lt;/p&gt;

&lt;p&gt;Font, size, color theme, shell prompt, window width. All of it depends on the state of the machine you recorded on, at that moment. My prompt is set up to show the current git branch, and once, after a re-record, the README GIF alone had some unrelated repo name baked into it.&lt;/p&gt;

&lt;p&gt;The quieter problem is staleness. You tweak the UI a little. You add a line to the header. You change a keybinding. Every time, the GIF gets a bit more out of date. But re-recording is a pain, so it stays stale. The very top of the README goes on proudly showing a screen that no longer exists in the current version. The first thing a visitor sees is the thing that gets updated least.&lt;/p&gt;

&lt;p&gt;Anyone who's shipped a CLI tool as OSS has probably felt this one.&lt;/p&gt;

&lt;h2&gt;
  
  
  A TUI demo is a whole other level of annoying
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/shinagawa-web/pgincident" rel="noopener noreferrer"&gt;pgincident&lt;/a&gt;, a tool I maintain, is a TUI for the first response to a Postgres incident. It's the full-screen-in-the-terminal kind.&lt;/p&gt;

&lt;p&gt;A TUI demo is hard in a way a single CLI command isn't. If there's no "state" on the screen, the demo means nothing.&lt;/p&gt;

&lt;p&gt;For an &lt;code&gt;ls&lt;/code&gt; GIF, you just run it in a directory with a few files and it looks fine. But what pgincident wants to show you is a screen of "what's happening in this Postgres right now." A long query is running. A lock wait queue is forming. There's a session that opened a transaction and walked away. Only when all of that is on the screen at once does the viewer go "ah, so that's what this tool is."&lt;/p&gt;

&lt;p&gt;Connect to a clean, quiet Postgres where nothing is happening, and all you record is a screen of empty rows that says nothing. That's not a demo.&lt;/p&gt;

&lt;p&gt;But manufacturing a real long query, a real lock, and a real idle-in-transaction all at once, conveniently, just for the demo — doing that by hand every time isn't realistic. To create a lock artificially you open two sessions, deliberately make one wait, keep that state alive while you start recording in a third terminal, and... just the choreography is a headache. It's a pain to do once, and the thought of reproducing it every time I change the UI means the GIF stays stale even longer.&lt;/p&gt;

&lt;h2&gt;
  
  
  The &lt;code&gt;.tape&lt;/code&gt; file — writing terminal operations down
&lt;/h2&gt;

&lt;p&gt;This is where &lt;a href="https://github.com/charmbracelet/vhs" rel="noopener noreferrer"&gt;vhs&lt;/a&gt; comes in. It's a Charm tool, and in one line: you write a script of terminal operations, and it runs them exactly as written and exports a GIF or MP4.&lt;/p&gt;

&lt;p&gt;The script goes in a text file with a &lt;code&gt;.tape&lt;/code&gt; extension. &lt;code&gt;Type&lt;/code&gt; types characters, &lt;code&gt;Enter&lt;/code&gt; presses enter, &lt;code&gt;Sleep&lt;/code&gt; waits. &lt;code&gt;Set&lt;/code&gt; pins down the appearance. This isn't a full reference article, so it's faster to just look at the real thing. Here's pgincident's &lt;code&gt;demo.tape&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# pgincident demo&lt;/span&gt;
&lt;span class="c"&gt;# Run via: ./scripts/record-demo.sh&lt;/span&gt;

Output docs/demo.gif

Set FontSize 14
Set Width 1200
Set Height 700
Set Theme &lt;span class="s2"&gt;"Dracula"&lt;/span&gt;
Set Shell &lt;span class="s2"&gt;"bash"&lt;/span&gt;

&lt;span class="c"&gt;# Start pgincident with the dev config&lt;/span&gt;
Type &lt;span class="s2"&gt;"./pgincident --config dev/pgincident-dev.toml"&lt;/span&gt;
Sleep 500ms
Enter

&lt;span class="c"&gt;# Overview screen — let it render and refresh once&lt;/span&gt;
Sleep 4s

&lt;span class="c"&gt;# Navigate to Dashboard screen&lt;/span&gt;
Type &lt;span class="s2"&gt;"o"&lt;/span&gt;
Sleep 4s

&lt;span class="c"&gt;# Move cursor down to a Long-running query row and open SQL detail&lt;/span&gt;
Type &lt;span class="s2"&gt;"j"&lt;/span&gt;
Sleep 300ms
Enter
Sleep 3s

&lt;span class="c"&gt;# Close detail and quit&lt;/span&gt;
Type &lt;span class="s2"&gt;"q"&lt;/span&gt;
Sleep 500ms
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You can pretty much read what it does. Font size, window width, height, color theme — all pinned explicitly by the &lt;code&gt;Set&lt;/code&gt; lines up top. None of my machine's settings leak in. &lt;code&gt;Type "o"&lt;/code&gt; moves to the Dashboard screen, &lt;code&gt;Type "j"&lt;/code&gt; moves the cursor down a row, &lt;code&gt;Enter&lt;/code&gt; opens the SQL detail — the gaze of a README reader, turned straight into a script.&lt;/p&gt;

&lt;p&gt;Even this alone almost entirely kills the "slightly different picture every time" problem. Same script, same picture. No re-shoots for typos. If I mistype something, I fix one character in the &lt;code&gt;.tape&lt;/code&gt; and run it again.&lt;/p&gt;

&lt;h2&gt;
  
  
  But &lt;code&gt;.tape&lt;/code&gt; alone is only half of it
&lt;/h2&gt;

&lt;p&gt;So far the &lt;em&gt;operations&lt;/em&gt; have become code. But remember the TUI difficulty from earlier. The "state" that shows up on screen still isn't set up anywhere.&lt;/p&gt;

&lt;p&gt;A script that launches &lt;code&gt;./pgincident&lt;/code&gt; is useless if the Postgres it connects to is empty — all you record is an empty screen. The script only knows &lt;em&gt;how to operate&lt;/em&gt;. It doesn't know &lt;em&gt;what should be on the screen&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;So I wrapped the &lt;code&gt;.tape&lt;/code&gt; in one more script from the outside. That's &lt;code&gt;scripts/record-demo.sh&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/usr/bin/env bash&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-euo&lt;/span&gt; pipefail

&lt;span class="nb"&gt;cd&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;dirname&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$0&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;/.."&lt;/span&gt;

&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"==&amp;gt; Building pgincident..."&lt;/span&gt;
go build &lt;span class="nt"&gt;-o&lt;/span&gt; pgincident ./cmd/pgincident

&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"==&amp;gt; Starting Postgres..."&lt;/span&gt;
docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt; postgres

&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"==&amp;gt; Waiting for Postgres to be ready..."&lt;/span&gt;
&lt;span class="k"&gt;until &lt;/span&gt;docker compose &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-T&lt;/span&gt; postgres pg_isready &lt;span class="nt"&gt;-U&lt;/span&gt; postgres &lt;span class="nt"&gt;-q&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;&lt;span class="nb"&gt;sleep &lt;/span&gt;1
&lt;span class="k"&gt;done

&lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"==&amp;gt; Starting load generator..."&lt;/span&gt;
docker compose &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-T&lt;/span&gt; postgres psql &lt;span class="nt"&gt;-U&lt;/span&gt; postgres &amp;lt; dev/loadgen_setup.sql
go run ./cmd/pgincident-loadgen &amp;amp;
&lt;span class="nv"&gt;LOADGEN_PID&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$!&lt;/span&gt;
&lt;span class="nb"&gt;trap&lt;/span&gt; &lt;span class="s1"&gt;'kill $LOADGEN_PID 2&amp;gt;/dev/null || true'&lt;/span&gt; EXIT

&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"==&amp;gt; Waiting 5s for load to build up..."&lt;/span&gt;
&lt;span class="nb"&gt;sleep &lt;/span&gt;5

&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"==&amp;gt; Recording demo..."&lt;/span&gt;
vhs demo.tape

&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"==&amp;gt; Done! -&amp;gt; docs/demo.gif"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Reading top to bottom, here's what it does. First it &lt;code&gt;go build&lt;/code&gt;s pgincident. It starts Postgres with Docker. It waits until &lt;code&gt;pg_isready&lt;/code&gt; says the connection is up. It pipes in the load-generation SQL and runs the load generator in the background. It waits 5 seconds for the load to build up. Only then does it call &lt;code&gt;vhs demo.tape&lt;/code&gt;. When the recording finishes, the &lt;code&gt;trap&lt;/code&gt; kills the load generator and cleans up.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;.tape&lt;/code&gt; records the &lt;em&gt;operations&lt;/em&gt;, and this script sets up the &lt;em&gt;state that shows on screen&lt;/em&gt;. Build, Postgres startup, load generation, recording, cleanup — all folded into a single script, so the whole demo environment is reproducible. This is the part I most wanted to say. Unless you widen "record the demo" into "assemble the demo environment and record it," you can't fully turn a stateful TUI demo into code.&lt;/p&gt;

&lt;p&gt;You type &lt;code&gt;./scripts/record-demo.sh&lt;/code&gt;. After that, with a real long query, a real lock, and a real idle-in-transaction lined up on screen, one GIF gets generated. The manual choreography that used to give me a headache became a single command.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually creates that "state"?
&lt;/h2&gt;

&lt;p&gt;Inside &lt;code&gt;record-demo.sh&lt;/code&gt;, the line that looks the most unremarkable and does the most work is &lt;code&gt;go run ./cmd/pgincident-loadgen&lt;/code&gt;. That's the part that artificially stokes the "real incident" you see on screen.&lt;/p&gt;

&lt;p&gt;The prep is &lt;code&gt;dev/loadgen_setup.sql&lt;/code&gt;. It's an unremarkable script that just creates two small tables for the load to hit.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;IF&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="n"&gt;loadgen_accounts&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;id&lt;/span&gt;         &lt;span class="nb"&gt;bigint&lt;/span&gt;      &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;balance&lt;/span&gt;    &lt;span class="nb"&gt;numeric&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;touched_at&lt;/span&gt; &lt;span class="n"&gt;timestamptz&lt;/span&gt;   &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;-- 10k rows gives cache hit ratio room to move&lt;/span&gt;
&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;loadgen_accounts&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;balance&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;10000&lt;/span&gt;&lt;span class="p"&gt;)::&lt;/span&gt;&lt;span class="nb"&gt;numeric&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt;   &lt;span class="n"&gt;generate_series&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;10000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="k"&gt;g&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;CONFLICT&lt;/span&gt; &lt;span class="k"&gt;DO&lt;/span&gt; &lt;span class="k"&gt;NOTHING&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- pgincident_dev only has pg_monitor privileges. Grant DML so the simulator can run&lt;/span&gt;
&lt;span class="k"&gt;GRANT&lt;/span&gt; &lt;span class="k"&gt;SELECT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;loadgen_accounts&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="n"&gt;pgincident_dev&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here's where it gets interesting. The load generator itself (Go) deliberately produces, one by one, the three categories that pgincident's Dashboard shows.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Long-running queries&lt;/strong&gt; come from analytical queries with a &lt;code&gt;pg_sleep&lt;/code&gt; baked in. It runs short ones that finish in a few seconds and long ones that squat for 5–8 minutes, with staggered start times.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;WITH&lt;/span&gt; &lt;span class="n"&gt;paused&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;pg_sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;balance&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;balance&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt;   &lt;span class="n"&gt;paused&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;loadgen_accounts&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;
&lt;span class="k"&gt;JOIN&lt;/span&gt;   &lt;span class="n"&gt;loadgen_accounts&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt;  &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Lock waits&lt;/strong&gt; come from the classic wait queue: one session grabs a row with &lt;code&gt;FOR UPDATE&lt;/code&gt; and holds it, while another goes for the same row and gets stuck waiting.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Idle in transaction&lt;/strong&gt; comes from a &lt;code&gt;BEGIN&lt;/code&gt;, one query, and then just sitting there without committing or rolling back. pgincident's threshold is 30 seconds, so it sleeps long enough to cross it.&lt;/li&gt;
&lt;li&gt;On top of that, a light OLTP load (a mix of SELECT / UPDATE) runs in the background so that TPS and cache hit ratio look "alive" on screen.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And here's the biggest trick for the sake of the recording: each worker is written to start on a staggered schedule. The idle ones so that "at least one is always visibly past the 30-second threshold," the long queries so that "two cycles half-overlap." The point is that the load itself is designed so that whenever &lt;code&gt;vhs demo.tape&lt;/code&gt; runs, the Dashboard's three categories are all filled in at once, just right.&lt;/p&gt;

&lt;p&gt;"Turning the whole demo environment into code," taken to its conclusion, includes this. Writing operations in a &lt;code&gt;.tape&lt;/code&gt; will never produce this "broken just right" state.&lt;/p&gt;

&lt;p&gt;The README just embeds this GIF as the top image.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="p"&gt;![&lt;/span&gt;&lt;span class="nv"&gt;pgincident demo&lt;/span&gt;&lt;span class="p"&gt;](&lt;/span&gt;&lt;span class="sx"&gt;docs/demo.gif&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What changed after turning it into code
&lt;/h2&gt;

&lt;p&gt;Honestly, I didn't invent anything dramatic. Neither vhs nor a shell script is a novel technology. Even so, the day-to-day feel clearly changed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Anyone who runs it gets the same look and the same composition.&lt;/strong&gt; Font, size, theme, the way load is applied — it's all in the script. It won't match down to &lt;em&gt;which&lt;/em&gt; query lands in the top row on any given run, but the look and composition of the picture don't depend on my machine's mood. When a future contributor wants to update the GIF, they can read the &lt;code&gt;.tape&lt;/code&gt; and know what's on screen.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It reviews in a diff and lives in git.&lt;/strong&gt; A GIF is binary, so you can't read the diff of its contents, but &lt;code&gt;.tape&lt;/code&gt; and &lt;code&gt;record-demo.sh&lt;/code&gt; are just text. "Bumped a Sleep from 3s to 4s," "reordered the operations" — that shows up in the PR diff. Demo changes ride on the same rails as code changes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Change the UI, regenerate.&lt;/strong&gt; Added a line to the header. Changed a keybinding. Before, it was "re-recording is a pain, so leave it." Now it's just typing &lt;code&gt;./scripts/record-demo.sh&lt;/code&gt; again. I won't claim staleness is gone — forget to run the regen and it stays stale. But the weight of the "it's a pain" excuse dropped from 30 minutes of manual work to a single command.&lt;/p&gt;

&lt;p&gt;Let me be honest about one thing too. Right now &lt;code&gt;record-demo.sh&lt;/code&gt; is a local script I run by hand. I'm not auto-regenerating the GIF in CI. When I change the UI, I still have to notice "ah, gotta regenerate the GIF" myself and type it myself. Ideally there's room to get to where any UI-touching change makes CI re-bake the GIF and commit it. That's the next move. What I've got so far is that regeneration is one command.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gotchas and tips
&lt;/h2&gt;

&lt;p&gt;A few spots that actually took some doing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tuning &lt;code&gt;Sleep&lt;/code&gt; takes the most work.&lt;/strong&gt; A TUI refreshes the screen periodically. Right after launch, the data isn't drawn yet. If &lt;code&gt;Sleep&lt;/code&gt; is too short, you record an empty or half-drawn screen. The reason &lt;code&gt;demo.tape&lt;/code&gt; waits a generous &lt;code&gt;Sleep 4s&lt;/code&gt; after launch is to let the Overview screen refresh once and fill in. Too long, though, and the GIF drags. I went back and forth a few times here — "shorten it, record, didn't make it, lengthen it a bit." It's an adjustment a single CLI command never needs; it's a TUI-specific chore.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How well the load is crafted decides how convincing the demo is.&lt;/strong&gt; If the load the generator throws is too weak, not a single long query crosses the threshold and the screen stays sparse. Too strong and the screen fills up so much you can't tell what to look at. Creating the "broken just right" state is unglamorous but the single biggest thing for demo-ness.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Don't mix in secrets.&lt;/strong&gt; The script is text and goes into git. And of course the GIF bakes in exactly the characters you typed and the screen you showed. Be careful not to write a production DSN or password into a &lt;code&gt;Type&lt;/code&gt;, or to record a screen connected to production. That pgincident's demo uses a dev config (&lt;code&gt;dev/pgincident-dev.toml&lt;/code&gt;) and connects to a local Docker Postgres is partly for this reason.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;GIF size.&lt;/strong&gt; Crank up &lt;code&gt;Set Width&lt;/code&gt; / &lt;code&gt;Set Height&lt;/code&gt; and the file naturally gets heavier. This image sits at the top of the README, so slow loading defeats the purpose. Width 1200 × height 700 was the sweet spot where the text isn't crushed and the file isn't too heavy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Demos go from something you "record" to something you "write and generate"
&lt;/h2&gt;

&lt;p&gt;Looking back, what changed is the kind of work. From the manual labor of "recording" a demo — moving your hands, failing, re-shooting — to "writing and generating" one: turning the script and the environment into code, and baking it with a command.&lt;/p&gt;

&lt;p&gt;And for a stateful TUI demo, writing the operations in a &lt;code&gt;.tape&lt;/code&gt; isn't enough. Only when you go all the way to setting up the "state" on screen — a real long query, a real lock — and turn the whole demo environment into code does it become reproducible. That, I think, is the key thing when you turn a TUI demo into code.&lt;/p&gt;

&lt;p&gt;If you're re-recording your own OSS README demo GIF by hand every time, start with a single &lt;code&gt;.tape&lt;/code&gt;. And if the screen you want to show needs "state," try folding the setup of that state into the script too.&lt;/p&gt;

&lt;p&gt;By the way, what pgincident actually is as a tool, I wrote up in &lt;a href="https://dev.to/_402ccbd6e5cb02871506/the-first-30-seconds-of-a-postgres-incident-why-they-take-30-minutes-5bnb"&gt;another article&lt;/a&gt;. It's the story of that first 30 seconds when Postgres slows down at 2 a.m.&lt;/p&gt;

</description>
      <category>opensource</category>
      <category>cli</category>
      <category>go</category>
      <category>bash</category>
    </item>
  </channel>
</rss>
