<?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: Axel</title>
    <description>The latest articles on DEV Community by Axel (@kakarotdev).</description>
    <link>https://dev.to/kakarotdev</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.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3786868%2F8b1f88a1-0cd6-495d-b90a-548d5893797f.jpeg</url>
      <title>DEV Community: Axel</title>
      <link>https://dev.to/kakarotdev</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/kakarotdev"/>
    <language>en</language>
    <item>
      <title>TCP worked, UDP silently died: shipping a DNS proxy to fly.io</title>
      <dc:creator>Axel</dc:creator>
      <pubDate>Fri, 24 Apr 2026 16:48:21 +0000</pubDate>
      <link>https://dev.to/kakarotdev/tcp-worked-udp-silently-died-shipping-a-dns-proxy-to-flyio-3e9n</link>
      <guid>https://dev.to/kakarotdev/tcp-worked-udp-silently-died-shipping-a-dns-proxy-to-flyio-3e9n</guid>
      <description>&lt;p&gt;I shipped v0.2.0 of &lt;a href="https://github.com/kakarot-dev/dnsink" rel="noopener noreferrer"&gt;dnsink&lt;/a&gt; — a Rust DNS proxy with threat-intelligence feeds and DNS tunneling detection — to fly.io this week. TCP worked on the first deploy. UDP silently dropped every reply. This is the debug.&lt;/p&gt;

&lt;h2&gt;
  
  
  What dnsink does
&lt;/h2&gt;

&lt;p&gt;dnsink is a small Rust daemon that sits between a DNS client and its upstream resolver. It checks every query against live threat-intel feeds (URLhaus, OpenPhish, PhishTank) and blocks matches at the DNS layer, returning NXDOMAIN. Clean queries get forwarded, optionally over DoH. The whole lookup path — bloom filter pre-screen + radix trie confirm — takes ~288 ns on a miss.&lt;/p&gt;

&lt;p&gt;For a portfolio deploy I wanted a live public endpoint on fly.io. The image was on &lt;code&gt;ghcr.io&lt;/code&gt;, multi-arch, distroless-nonroot. &lt;code&gt;fly.toml&lt;/code&gt; was straightforward — UDP + TCP DNS on port 53, Prometheus metrics on 9090. &lt;code&gt;flyctl deploy&lt;/code&gt; ran clean. Two machines came up, health checks passed.&lt;/p&gt;

&lt;h2&gt;
  
  
  The symptom
&lt;/h2&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;dig @dnsink.fly.dev example.com +tcp
&lt;span class="gp"&gt;;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&amp;lt;&amp;lt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; DiG 9.18.39 &amp;lt;&amp;lt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; @dnsink.fly.dev example.com +tcp
&lt;span class="gp"&gt;;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; Got answer:
&lt;span class="gp"&gt;;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; ANSWER SECTION:
&lt;span class="go"&gt;example.com. 292 IN A 104.20.23.154

&lt;/span&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;dig @dnsink.fly.dev example.com
&lt;span class="gp"&gt;;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; communications error: timed out
&lt;span class="gp"&gt;;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; no servers could be reached
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;TCP: clean resolution. UDP: timeout.&lt;/p&gt;

&lt;p&gt;Same port, same server, same protocol family at the transport layer. The only difference was the third argument on the socket call.&lt;/p&gt;

&lt;h2&gt;
  
  
  Diagnosis
&lt;/h2&gt;

&lt;p&gt;First instinct: the container isn't listening on UDP. The logs said otherwise:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;INFO dnsink::proxy: listening on 0.0.0.0:5353 (UDP + TCP)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Next: maybe fly isn't routing UDP to the machine. If UDP packets don't arrive, the &lt;code&gt;dnsink_queries_total&lt;/code&gt; counter stays flat. After several &lt;code&gt;dig&lt;/code&gt; attempts, the counter... stayed at 0. But that told me two things at once — either packets weren't arriving, OR they were arriving but the response path was broken. Counter would increment on RECEIVE, not on successful reply.&lt;/p&gt;

&lt;p&gt;I re-ran after adding a log line on UDP receive. Packets WERE arriving. The machine saw the queries. The replies just never made it back to me.&lt;/p&gt;

&lt;h2&gt;
  
  
  The actual bug
&lt;/h2&gt;

&lt;p&gt;fly.io's &lt;a href="https://fly.io/docs/networking/udp-and-tcp/" rel="noopener noreferrer"&gt;UDP docs&lt;/a&gt; have one critical line that didn't match the symptom I was searching for:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;To receive UDP packets, your app needs to bind to the special &lt;code&gt;fly-global-services&lt;/code&gt; address. Standard addresses like &lt;code&gt;0.0.0.0&lt;/code&gt;, &lt;code&gt;*&lt;/code&gt;, or &lt;code&gt;INADDR_ANY&lt;/code&gt; won't work properly because Linux will use the wrong source address in replies if you use those.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The word "receive" in the first sentence is misleading. Packets DO receive on &lt;code&gt;0.0.0.0&lt;/code&gt;. The problem is the REPLY — when Linux responds to a UDP datagram, it picks a source IP based on the local interface the packet goes out on. With a wildcard-bound socket on fly, the kernel picks the machine's private fly-internal IP as the source. fly-proxy accepts the reply, looks at the source IP, doesn't recognize it as belonging to the publicly-routed IPv4, and drops it.&lt;/p&gt;

&lt;p&gt;Binding to &lt;code&gt;fly-global-services&lt;/code&gt; tells the kernel: "use this specific address as the source on replies." fly's infra then sees the right source IP and forwards correctly.&lt;/p&gt;

&lt;p&gt;The reason this doesn't bite TCP is because TCP is connection-oriented and fly-proxy maintains the connection state at the edge — it knows where to route the response regardless of source-IP inconsistencies. UDP has no such state. Each reply packet has to route itself based on its own header.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix (first attempt, broken)
&lt;/h2&gt;

&lt;p&gt;I changed the bind address in my baked config:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight toml"&gt;&lt;code&gt;&lt;span class="nn"&gt;[listen]&lt;/span&gt;
&lt;span class="py"&gt;address&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"fly-global-services"&lt;/span&gt;
&lt;span class="py"&gt;port&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;5353&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Redeployed. &lt;code&gt;dig @dnsink.fly.dev example.com&lt;/code&gt; now worked over UDP.&lt;/p&gt;

&lt;p&gt;And broke TCP.&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;dig @dnsink.fly.dev example.com +tcp
&lt;span class="gp"&gt;;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; communications error: end of file
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;TCP on fly goes through fly-proxy, which connects to the container via the container's published ports. If the listener binds to a specific local IP (&lt;code&gt;fly-global-services&lt;/code&gt; resolves to the fly-internal IPv4), fly-proxy's connection from the public ingress interface doesn't land on that listener — it's bound to a different interface than the one fly-proxy is dialing in on.&lt;/p&gt;

&lt;p&gt;UDP needs &lt;code&gt;fly-global-services&lt;/code&gt;. TCP needs a wildcard bind. They conflict.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix (real)
&lt;/h2&gt;

&lt;p&gt;The clean solution is asymmetric binds — UDP to &lt;code&gt;fly-global-services&lt;/code&gt;, TCP to wildcard. I added an optional &lt;code&gt;tcp_address&lt;/code&gt; override to dnsink's listen config:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="n"&gt;ListenConfig&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="n"&gt;address&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="n"&gt;port&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;u16&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="c1"&gt;// Optional TCP-specific bind. When None, TCP uses `address`.&lt;/span&gt;
    &lt;span class="nd"&gt;#[serde(default)]&lt;/span&gt;
    &lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="n"&gt;tcp_address&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Option&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="o"&gt;&amp;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 in the proxy:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;port&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="py"&gt;.config.listen.port&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;udp_addr&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nd"&gt;format!&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="k"&gt;self&lt;/span&gt;&lt;span class="py"&gt;.config.listen.address&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;port&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;tcp_addr&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;match&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="py"&gt;.config.listen.tcp_address&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;Some&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="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nd"&gt;format!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"{a}:{port}"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="nb"&gt;None&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;udp_addr&lt;/span&gt;&lt;span class="nf"&gt;.clone&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;udp_socket&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;UdpSocket&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;bind&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;udp_addr&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="k"&gt;.await&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;tcp_listener&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;TcpListener&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;bind&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;tcp_addr&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="k"&gt;.await&lt;/span&gt;&lt;span class="o"&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;config.docker.toml&lt;/code&gt; shipped with the image now specifies:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight toml"&gt;&lt;code&gt;&lt;span class="nn"&gt;[listen]&lt;/span&gt;
&lt;span class="py"&gt;address&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"fly-global-services"&lt;/span&gt;
&lt;span class="py"&gt;tcp_address&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"[::]"&lt;/span&gt;
&lt;span class="py"&gt;port&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;5353&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Local Docker runs (without fly-global-services resolvable) override the config via bind-mount to use &lt;code&gt;0.0.0.0&lt;/code&gt; or &lt;code&gt;[::]&lt;/code&gt; for both. The default for direct &lt;code&gt;cargo run&lt;/code&gt; stays at &lt;code&gt;127.0.0.1&lt;/code&gt;, unaware of any of this.&lt;/p&gt;

&lt;p&gt;Deployed. TCP works. UDP works. Metrics work.&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;dig @dnsink.fly.dev example.com +tcp
&lt;span class="gp"&gt;;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; ANSWER SECTION:
&lt;span class="go"&gt;example.com. 300 IN A 104.20.23.154

&lt;/span&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;dig @dnsink.fly.dev &lt;span class="nt"&gt;-p&lt;/span&gt; 5353 example.com
&lt;span class="gp"&gt;;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; ANSWER SECTION:
&lt;span class="go"&gt;example.com. 300 IN A 104.20.23.154

&lt;/span&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;curl https://dnsink.fly.dev/metrics
&lt;span class="go"&gt;dnsink_queries_total 42
dnsink_queries_allowed_total 42
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Platform quirks are honest.&lt;/strong&gt; The &lt;code&gt;listen.tcp_address&lt;/code&gt; override isn't elegant — a clean config shouldn't expose host-platform specifics. But the alternative is hard-coding fly.io detection into dnsink, which is worse. An optional config field that users only set when deploying to fly is a minimal tax.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Docs are often correct but don't match your symptom.&lt;/strong&gt; fly's docs say what you need to do — they don't describe what FAILURE looks like when you don't. "Linux uses the wrong source address" is easy to read past when you're debugging a timeout. Finding this required building a mental model of how UDP replies route, not just reading the bind guidance.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reply-path bugs look like receive-path bugs.&lt;/strong&gt; I spent time validating my UDP listener was actually listening before realizing the issue was outbound, not inbound. Adding a log line on packet receive would have shortened the debug by 20 minutes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TCP and UDP aren't interchangeable on serverless platforms.&lt;/strong&gt; Same port, same listener, different routing path. fly's proxy handles TCP differently from UDP. AWS ELB, GCP Load Balancer, Kubernetes kube-proxy — all have similar asymmetries. If you ship something UDP-heavy, deploy early, break things, and factor the quirks into your config surface.&lt;/p&gt;

&lt;p&gt;v0.2.0 is live at &lt;a href="https://github.com/kakarot-dev/dnsink" rel="noopener noreferrer"&gt;github.com/kakarot-dev/dnsink&lt;/a&gt;. Docker image at &lt;code&gt;ghcr.io/kakarot-dev/dnsink:v0.2.0&lt;/code&gt; (multi-arch, amd64 + arm64, distroless/cc-debian12:nonroot). The &lt;code&gt;fly.toml&lt;/code&gt; and &lt;code&gt;config.docker.toml&lt;/code&gt; in the repo are the reference for deploying yourself.&lt;/p&gt;

</description>
      <category>networking</category>
      <category>rust</category>
      <category>security</category>
      <category>showdev</category>
    </item>
  </channel>
</rss>
