<?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: MyTaghazout</title>
    <description>The latest articles on DEV Community by MyTaghazout (@mytaghazout).</description>
    <link>https://dev.to/mytaghazout</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%2F4080604%2F6b7c5dfc-1e90-4b8e-8f2c-74529ea9c1f3.png</url>
      <title>DEV Community: MyTaghazout</title>
      <link>https://dev.to/mytaghazout</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/mytaghazout"/>
    <language>en</language>
    <item>
      <title>The header I trusted was lying by accident</title>
      <dc:creator>MyTaghazout</dc:creator>
      <pubDate>Sat, 29 Aug 2026 11:39:02 +0000</pubDate>
      <link>https://dev.to/mytaghazout/the-header-i-trusted-was-lying-by-accident-4pp</link>
      <guid>https://dev.to/mytaghazout/the-header-i-trusted-was-lying-by-accident-4pp</guid>
      <description>&lt;p&gt;If you have Cloudflare in front of your site, and at some point you wrote a cache rule that matches bots, go and read that rule again. I wrote one to be helpful to AI crawlers. What it actually did was store my error responses at the edge and replay them to every AI crawler for an hour at a time.&lt;/p&gt;

&lt;p&gt;I found out because OpenAI's crawler could not read my site. GPTBot was getting HTTP 429. Amazonbot and Bytespider were getting 403. I run taghazout.io alone, so there was nobody to hand the problem to. I did what most people do first: I looked at the response headers.&lt;/p&gt;

&lt;p&gt;That is where I got it wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  The header I trusted
&lt;/h2&gt;

&lt;p&gt;The 429 responses carried this:&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;x-turbo-charged-by: LiteSpeed
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;LiteSpeed is the web server my host runs. I read that header, decided the origin was producing the 429, and opened a ticket asking about rate limiting on their side. It felt like a closed case. The server had signed its name on the error.&lt;/p&gt;

&lt;p&gt;The flaw took me too long to see. A response served from a CDN cache is a stored copy of an origin response, headers included. If Cloudflare stored a 429 an hour ago, it hands you that copy later, &lt;code&gt;x-turbo-charged-by: LiteSpeed&lt;/code&gt; and all. That header tells you which server generated the body at some point in the past. It tells you nothing about whether &lt;em&gt;this&lt;/em&gt; response, the one in my terminal right then, came from the origin or from a cache in another city.&lt;/p&gt;

&lt;p&gt;I had used a header as evidence for a claim it cannot make. It was not lying to me on purpose — server-identifying headers get copied along with everything else, and they will point you at an innocent party by accident.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the host actually said
&lt;/h2&gt;

&lt;p&gt;Namecheap support came back and said the domain is proxied through Cloudflare, that a "Cache Everything" style rule can cause 429 or 403 responses to be cached at the edge, and that this looked more like a stale or corrupted cached response at the CDN than something LiteSpeed generated.&lt;/p&gt;

&lt;p&gt;They were right and I was wrong. The easy version of this story is "host blames the CDN, developer is vindicated". That is not what happened. I arrived with a bad theory and their support gave me a better one.&lt;/p&gt;

&lt;h2&gt;
  
  
  The header that actually answers the question
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-D&lt;/span&gt; - &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-A&lt;/span&gt; &lt;span class="s1"&gt;'Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko); compatible; GPTBot/1.0; +https://openai.com/gptbot'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  https://taghazout.io/ &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-iE&lt;/span&gt; &lt;span class="s1"&gt;'^(HTTP/|cf-cache-status|cf-ray|age|cache-control|x-turbo-charged-by)'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If &lt;code&gt;cf-cache-status&lt;/code&gt; says &lt;code&gt;HIT&lt;/code&gt;, you are holding a stored copy and the status code may be older than the problem you are debugging. If it says &lt;code&gt;BYPASS&lt;/code&gt; or &lt;code&gt;MISS&lt;/code&gt;, Cloudflare went to the origin and what you see is live. Look the other values up rather than guessing: &lt;code&gt;EXPIRED&lt;/code&gt; does not mean what it sounds like — the object was in cache, had gone stale, and was served from the origin anyway. A non-zero &lt;code&gt;age&lt;/code&gt; is a second hint; &lt;code&gt;cf-cache-status&lt;/code&gt; answers directly.&lt;/p&gt;

&lt;p&gt;Use &lt;code&gt;-D - -o /dev/null&lt;/code&gt;, not &lt;code&gt;curl -I&lt;/code&gt;: &lt;code&gt;-I&lt;/code&gt; sends a HEAD request, and HEAD can be routed and cached differently from the GET a real crawler sends. Measure the request the crawler actually makes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Root cause 1: I had cached my own errors
&lt;/h2&gt;

&lt;p&gt;The rule was mine. In Cloudflare's cache rules sat one I had written, &lt;code&gt;ai-crawlers-edge-cache&lt;/code&gt;. It matched 13 AI crawler user agents and set Edge TTL to "Ignore cache-control header and use this TTL: 1 hour". My origin sends &lt;code&gt;no-store&lt;/code&gt; on HTML. The rule overrode it.&lt;/p&gt;

&lt;p&gt;The trap has a simple shape:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Your origin returns an error to one bot. A rate limit, a security block, one bad minute.&lt;/li&gt;
&lt;li&gt;Your edge rule says "ignore the origin's cache-control, use this TTL instead".&lt;/li&gt;
&lt;li&gt;Cloudflare stores that error the same way it would store a page.&lt;/li&gt;
&lt;li&gt;Until the TTL expires, every request matching the same cache key gets the error back, including from crawlers that never did anything wrong.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Caching a 200 is the point of the rule. Caching a 429 is the bug. Nothing in the dashboard separates the two for you.&lt;/p&gt;

&lt;p&gt;I had built a machine for turning one bad response into an hour of bad responses, and I had built it on purpose, for exactly the bots the rule was written to help.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What changed:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;ai-crawlers-edge-cache&lt;/code&gt; Edge TTL: &lt;em&gt;Ignore cache-control header&lt;/em&gt; → &lt;em&gt;Respect origin headers&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;A full cache purge afterwards, because the poisoned entries survive the rule change&lt;/li&gt;
&lt;li&gt;Verification is now per-crawler, not "AI crawlers are fine"&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;ClaudeBot, OAI-SearchBot, PerplexityBot and Googlebot all went from 429 or 403 to 200. Amazonbot and Bytespider did not move: both still return 403, and on a cache miss, which means those are coming from the origin too. At the time I read that as leftover noise. It was the second cause, already visible, and I did not look at it.&lt;/p&gt;

&lt;p&gt;I wrote it up as solved. It was not solved.&lt;/p&gt;

&lt;h2&gt;
  
  
  GPTBot alone still gets 429
&lt;/h2&gt;

&lt;p&gt;Four crawlers turning green felt like proof. It was proof about four crawlers.&lt;/p&gt;

&lt;p&gt;So I ran an isolation test built to separate three explanations: a cached error, a flat block, and a rate limit. Ten &lt;strong&gt;distinct&lt;/strong&gt; URLs — distinct matters, or you are testing your own cache — six seconds apart.&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;UA_GPTBOT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko); compatible; GPTBot/1.0; +https://openai.com/gptbot'&lt;/span&gt;

&lt;span class="c"&gt;# ten distinct URLs, pulled from the site's own sitemap&lt;/span&gt;
&lt;span class="c"&gt;# (if yours is a sitemap index, pull one child sitemap instead)&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; https://taghazout.io/sitemap.xml &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="s1"&gt;'&amp;lt;loc&amp;gt;[^&amp;lt;]*'&lt;/span&gt; | &lt;span class="nb"&gt;sed&lt;/span&gt; &lt;span class="s1"&gt;'s/&amp;lt;loc&amp;gt;//'&lt;/span&gt; | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-10&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; urls.txt

&lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="nb"&gt;read&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; url&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;&lt;span class="nv"&gt;code&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &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}'&lt;/span&gt; &lt;span class="nt"&gt;-A&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$UA_GPTBOT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$url&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &amp;lt;/dev/null&lt;span class="si"&gt;)&lt;/span&gt;
  &lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'%s  %s  %s\n'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%T&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$code&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$url&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  &lt;span class="nb"&gt;sleep &lt;/span&gt;6
&lt;span class="k"&gt;done&lt;/span&gt; &amp;lt; urls.txt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;First request: 200. The next nine: 429 — roughly one successful request per minute, which is what the timestamps are there to show. The same URLs with a normal Chrome user agent returned 200 every time.&lt;/p&gt;

&lt;p&gt;Then the same URL again, six user agents back to back. Running them concurrently is tighter, and it is how I would do it now:&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;URL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'https://taghazout.io/'&lt;/span&gt;

&lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="nv"&gt;IFS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'|'&lt;/span&gt; &lt;span class="nb"&gt;read&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; name ua&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
  &lt;span class="o"&gt;(&lt;/span&gt; &lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'%-16s %s\n'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$name&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
      &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &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}'&lt;/span&gt; &lt;span class="nt"&gt;-A&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$ua&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$URL&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &amp;lt;/dev/null&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;)&lt;/span&gt; &amp;amp;
&lt;span class="k"&gt;done&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;'
GPTBot|Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko); compatible; GPTBot/1.0; +https://openai.com/gptbot
OAI-SearchBot|Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36; compatible; OAI-SearchBot/1.0; +https://openai.com/searchbot
ClaudeBot|Mozilla/5.0 (compatible; ClaudeBot/1.0; +claudebot@anthropic.com)
PerplexityBot|Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; PerplexityBot/1.0; +https://perplexity.ai/perplexitybot)
Googlebot|Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)
Chrome|Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36
&lt;/span&gt;&lt;span class="no"&gt;EOF
&lt;/span&gt;&lt;span class="nb"&gt;wait&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Vendors bump their user-agent versions, so check the current published strings before copying. And &lt;code&gt;&amp;lt;/dev/null&lt;/code&gt; is not decoration: without it the backgrounded subshells eat the heredoc and you lose rows.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;User agent (same URL, seconds apart)&lt;/th&gt;
&lt;th&gt;Status&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;GPTBot&lt;/td&gt;
&lt;td&gt;429&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ClaudeBot&lt;/td&gt;
&lt;td&gt;200&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;OAI-SearchBot&lt;/td&gt;
&lt;td&gt;200&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PerplexityBot&lt;/td&gt;
&lt;td&gt;200&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Googlebot&lt;/td&gt;
&lt;td&gt;200&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Chrome&lt;/td&gt;
&lt;td&gt;200&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Same URL, same edge, same origin, all within a minute of each other. Only the user agent string differs.&lt;/p&gt;

&lt;p&gt;And the header that started all this, on those 429s: &lt;code&gt;cf-cache-status&lt;/code&gt; came back &lt;code&gt;BYPASS&lt;/code&gt; / &lt;code&gt;MISS&lt;/code&gt;. Cloudflare went to the origin for them. Live origin 429s, not replays. Cloudflare's AI Crawl Control dashboard also showed every crawler, GPTBot included, set to Allowed.&lt;/p&gt;

&lt;p&gt;So: not the edge, and not a flat block. A user-agent-scoped rate limit at the origin, letting through roughly one GPTBot request per minute.&lt;/p&gt;

&lt;p&gt;I took that evidence back to support, and this time I described the behaviour instead of my theory: same URL, same second, GPTBot 429 while five other user agents get 200, on a cache miss. The answer came back quickly — &lt;strong&gt;ModSecurity&lt;/strong&gt;. The web application firewall was matching GPTBot's requests and refusing them, and the fix is to whitelist the rule that fires.&lt;/p&gt;

&lt;p&gt;Which turns out to be a decision rather than a button. Whitelisting narrowly — one rule ID, scoped to that one user agent — is close to free: GPTBot only ever issues GET requests for public pages, so the exemption has almost no surface. Whitelisting the same rule for all traffic is a different trade entirely, and worth pushing back on. Ask which rule ID fired, and ask for it scoped to the user agent. That request is with the host now.&lt;/p&gt;

&lt;p&gt;Amazonbot and Bytespider, still on 403, are almost certainly the same layer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why crawler access is worth this much trouble
&lt;/h2&gt;

&lt;p&gt;taghazout.io is not a weather app, although you would not guess that from the search traffic. It sells surf weeks, surf lessons, board rental and airport transfers around Taghazout, and bookings run over WhatsApp with a quote I review myself and a 20% deposit. Almost everything Google sends me is a swell, webcam or sea-state query — the forecast side of the stack is in my earlier post, &lt;a href="https://dev.to/mytaghazout/i-run-a-surf-forecast-for-20-breaks-in-morocco-on-eur-0month-heres-the-stack-1ppf"&gt;I run a surf forecast for 20 breaks in Morocco on EUR 0/month&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Two numbers turned crawler access from a hygiene item into a revenue question. In a 16-day window, Bing Webmaster Tools recorded 80 citations in Microsoft Copilot and partner AI surfaces, against 21 Bing search clicks — assistants cited the site about four times more often than Bing search sent people to it. For scale, Google Search Console over 28 days: 430 clicks, 25,100 impressions, average position 11. Total external backlinks: 3, from 2 domains.&lt;/p&gt;

&lt;p&gt;Three backlinks from two domains is an ordinary link profile, and that is the point: if an assistant can read your pages, it can recommend you without anyone having linked to you first. I am not going to out-link anybody. Being readable by assistants is the channel I actually have, and someone asking an assistant where to book a surf week in Morocco is much closer to a booking than someone checking whether tomorrow is offshore.&lt;/p&gt;

&lt;p&gt;So a crawler getting a 429 is not a log-noise problem. It is a distribution problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would tell myself before opening that ticket
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Read &lt;code&gt;cf-cache-status&lt;/code&gt; first. It answers origin-versus-replay directly and costs thirty seconds. &lt;code&gt;x-turbo-charged-by&lt;/code&gt;, &lt;code&gt;server&lt;/code&gt; and friends are stored copies, and they will point you at an innocent party by accident.&lt;/li&gt;
&lt;li&gt;An edge rule that ignores origin &lt;code&gt;cache-control&lt;/code&gt; caches your errors as cheerfully as your successes. Caching 200s is the intent; caching 429s is the bug, same rule doing both.&lt;/li&gt;
&lt;li&gt;"The obvious cause was real" and "the obvious cause was the only cause" are different claims. Verify per-crawler: a fix that moves four crawlers from red to green will hide the fifth that did not move.&lt;/li&gt;
&lt;li&gt;In the support ticket, describe the behaviour, not your theory. Mine was confident and wrong, and the host's read of it was correct — and when I went back the second time with a reproducible test instead of a hunch, the real cause came back in one reply.&lt;/li&gt;
&lt;li&gt;If you care about being cited by AI assistants, crawler access is a revenue path, not a nice-to-have — and it can be broken by a rule you wrote yourself to help them.&lt;/li&gt;
&lt;/ol&gt;

</description>
      <category>cloudflare</category>
      <category>webdev</category>
      <category>seo</category>
      <category>ai</category>
    </item>
    <item>
      <title>I run a surf forecast for 20 breaks in Morocco on EUR 0/month. Here's the stack.</title>
      <dc:creator>MyTaghazout</dc:creator>
      <pubDate>Mon, 17 Aug 2026 00:04:53 +0000</pubDate>
      <link>https://dev.to/mytaghazout/i-run-a-surf-forecast-for-20-breaks-in-morocco-on-eur-0month-heres-the-stack-1ppf</link>
      <guid>https://dev.to/mytaghazout/i-run-a-surf-forecast-for-20-breaks-in-morocco-on-eur-0month-heres-the-stack-1ppf</guid>
      <description>&lt;p&gt;I live on the Taghazout coast in Morocco - a strip of Atlantic between Agadir and Imsouane that's basically one long right-hand point break after another. Two years ago the only way to know if tomorrow was worth it was to check three different global forecast sites, none of which knew the difference between Anchor Point and the beach break 400m south of it.&lt;/p&gt;

&lt;p&gt;So I built &lt;a href="https://taghazout.io" rel="noopener noreferrer"&gt;taghazout.io&lt;/a&gt;. It now covers 20 named breaks, runs in 10 languages, and costs me nothing per month. Here's how it's actually put together - including the parts I'd do differently.&lt;/p&gt;

&lt;h2&gt;
  
  
  The stack is deliberately boring
&lt;/h2&gt;

&lt;p&gt;Hand-rolled PHP. No framework, no build step, no node_modules. About 4,800 files, server-rendered, no hydration.&lt;/p&gt;

&lt;p&gt;That sounds like a confession, but it was the right call for one reason: &lt;strong&gt;my readers are on phones, on cafe Wi-Fi, often on 3G.&lt;/strong&gt; A server-rendered page that ships HTML and a little CSS beats anything I could have built with a client-side framework in that environment. Time-to-content is the only metric that matters when someone is standing on the beach deciding whether to paddle out.&lt;/p&gt;

&lt;p&gt;The hosting is a cheap shared plan. The forecast data is free and open. The whole thing runs at &lt;strong&gt;EUR 0/month recurring&lt;/strong&gt;, which was a hard constraint from day one.&lt;/p&gt;

&lt;h2&gt;
  
  
  The interesting part: two ocean models that disagree
&lt;/h2&gt;

&lt;p&gt;The forecast blends two sources:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Open-Meteo&lt;/strong&gt; (CC BY 4.0) - the primary, with a marine endpoint that covers our coastal cells.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;NOAA WaveWatch III&lt;/strong&gt; via PacIOOS - the second opinion.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Here's the thing nobody tells you: &lt;strong&gt;they disagree, a lot.&lt;/strong&gt; On the same hour at the same break I've seen WaveWatch read ~55% higher than Open-Meteo (1.36m vs 0.88m). Offshore models resolve coastal bathymetry badly, and our points are exactly the kind of close-in, shallow-reef setups where that bias shows up.&lt;/p&gt;

&lt;p&gt;The wrong fix is to pick one and pretend. What I did instead:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Run both, cache both.&lt;/li&gt;
&lt;li&gt;Compute agreement over a 72-hour window - a Pearson correlation on the swell rhythm plus a circular difference on direction (you can't average 350 and 10 degrees naively).&lt;/li&gt;
&lt;li&gt;Show the user a confidence flag: &lt;strong&gt;agree / mixed / diverge&lt;/strong&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A recent live read for Anchor Point: r=0.88, 8 degrees direction difference over 73 hours, so "agree". When it says &lt;em&gt;diverge&lt;/em&gt;, that's genuinely useful information - it means treat the numbers with suspicion, not that the site is broken.&lt;/p&gt;

&lt;p&gt;Some gotchas from the WaveWatch integration, since these cost me a full evening:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The ERDDAP query brackets &lt;strong&gt;must&lt;/strong&gt; be percent-encoded as &lt;code&gt;%5B&lt;/code&gt; / &lt;code&gt;%5D&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The time stop must be the literal &lt;code&gt;(last)&lt;/code&gt;, not a future timestamp you computed.&lt;/li&gt;
&lt;li&gt;The coastal grid cell you'd expect (lon 350.5) is &lt;strong&gt;dry land&lt;/strong&gt;. You have to fall back half a degree offshore to get swell at all.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Honest data beats impressive data
&lt;/h2&gt;

&lt;p&gt;The tide readout says, in the payload itself:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"modelled sea level, relative to this window's low"&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Because that's what it is. It isn't a harbour gauge, and pretending otherwise would get someone hurt on a reef. Every derived number on the site carries its caveat, and the public feed carries the same note in a &lt;code&gt;source_note&lt;/code&gt; field. I'd rather be trusted than look authoritative.&lt;/p&gt;

&lt;h2&gt;
  
  
  I opened up the feed
&lt;/h2&gt;

&lt;p&gt;The same data the site's own &lt;a href="https://taghazout.io/weather/" rel="noopener noreferrer"&gt;forecast pages&lt;/a&gt; run on is public, keyless JSON:&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="s2"&gt;"https://taghazout.io/weather-data/_feed.php?loc=anchor-point"&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="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"ok"&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;"spot"&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;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Anchor Point"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"area"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"North Taghazout"&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;"swell_m"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;0.66&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"period_s"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;8.4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"wind_kmh"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;11.2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"water_temp_c"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;21.6&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"surf_potential_percent"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;82&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"tide"&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;"label"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Rising tide"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"trend"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"rising"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"height_m"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;2.53&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;There's a small MIT-licensed client for it in Python and JS: &lt;a href="https://github.com/MyTaghazout/taghazout-surf" rel="noopener noreferrer"&gt;github.com/MyTaghazout/taghazout-surf&lt;/a&gt;, a &lt;a href="https://taghazout.gitbook.io/taghazout-docs/introduction" rel="noopener noreferrer"&gt;full API reference&lt;/a&gt;, and the spot registry is published as a &lt;a href="https://huggingface.co/datasets/MyTaghazout/taghazout-surf-spots" rel="noopener noreferrer"&gt;dataset on Hugging Face&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If you build something with it, carry the attribution: weather by Open-Meteo (CC BY 4.0), wave model NOAA WaveWatch III via PacIOOS.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd do differently
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Ten languages was too many, too early.&lt;/strong&gt; Nine of them started as machine output and I have spent months undoing chimera sentences - English word order with individual words swapped. If I started again: two languages done properly, and add the rest only when each one has real readers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;I measured nothing for six months.&lt;/strong&gt; The analytics tag was gated behind cookie consent in a way that made most visitors invisible, so I was optimising blind. Fix your measurement before you optimise anything.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Happy to answer anything about the model-blending or the caching layer in the comments.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Weather data by &lt;a href="https://open-meteo.com/" rel="noopener noreferrer"&gt;Open-Meteo.com&lt;/a&gt; (CC BY 4.0). Wave model NOAA WaveWatch III via PacIOOS.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>php</category>
      <category>showdev</category>
      <category>api</category>
    </item>
  </channel>
</rss>
