<?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: Antony Nyagah</title>
    <description>The latest articles on DEV Community by Antony Nyagah (@nyagah).</description>
    <link>https://dev.to/nyagah</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%2F987887%2F1090cb66-d4da-4d33-bb38-16b038d053d0.jpg</url>
      <title>DEV Community: Antony Nyagah</title>
      <link>https://dev.to/nyagah</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/nyagah"/>
    <language>en</language>
    <item>
      <title>My Honeypot Could Silently Drop Real Quote Requests. Here's the Fix.</title>
      <dc:creator>Antony Nyagah</dc:creator>
      <pubDate>Wed, 14 Oct 2026 09:26:10 +0000</pubDate>
      <link>https://dev.to/nyagah/my-honeypot-could-silently-drop-real-quote-requests-heres-the-fix-2jan</link>
      <guid>https://dev.to/nyagah/my-honeypot-could-silently-drop-real-quote-requests-heres-the-fix-2jan</guid>
      <description>&lt;p&gt;A few days ago I wrote about &lt;a href="https://dev.to/nyagah/i-used-a-honeypot-instead-of-a-captcha-on-our-quote-form-388l"&gt;using a honeypot instead of a CAPTCHA&lt;/a&gt; on a quote form at work. It's one hidden field that people never see and bots fill in anyway. I ended that post saying that if spam got worse, I'd limit how many requests one IP address could send.&lt;/p&gt;

&lt;p&gt;Then several helpful comments pointed out a problem I hadn't thought about, and it wasn't about spam at all. My honeypot could catch real people.&lt;/p&gt;

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

&lt;p&gt;The honeypot was an input called &lt;code&gt;website&lt;/code&gt;. It sat 9999px off screen, was skipped by the keyboard and hidden from screen readers, and had &lt;code&gt;autocomplete="off"&lt;/code&gt; on it.&lt;/p&gt;

&lt;p&gt;Commenters pointed out that Chrome doesn't always respect &lt;code&gt;autocomplete="off"&lt;/code&gt;. If it decides it knows what a field is for, it may fill it anyway. Password managers that store your details do the same.&lt;/p&gt;

&lt;p&gt;My server treats a filled honeypot as a bot and fakes a success page. So if a lab manager's browser filled that hidden field, their quote request would disappear. They'd see "Request sent", I'd never get it, and nobody would know anything went wrong.&lt;/p&gt;

&lt;p&gt;That's worse than spam. Spam I can delete. A lost quote is a customer who thinks we ignored them.&lt;/p&gt;

&lt;h2&gt;
  
  
  My first fix was wrong
&lt;/h2&gt;

&lt;p&gt;I renamed the field from &lt;code&gt;website&lt;/code&gt; to &lt;code&gt;fax&lt;/code&gt;. Nobody autofills a fax number anymore, I thought.&lt;/p&gt;

&lt;p&gt;Except fax sits right next to phone, email and website in the list of contact details, and autofill tools still know the word. I had swapped one name autofill recognises for another.&lt;/p&gt;

&lt;p&gt;A browser mostly decides whether to fill a field by guessing what it's for. So the real fix was to give it nothing to guess from:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;input&lt;/span&gt;
  &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"hp_field"&lt;/span&gt;
  &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"hp_field"&lt;/span&gt;
  &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"text"&lt;/span&gt;
  &lt;span class="na"&gt;tabindex=&lt;/span&gt;&lt;span class="s"&gt;"-1"&lt;/span&gt;
  &lt;span class="na"&gt;autocomplete=&lt;/span&gt;&lt;span class="s"&gt;"off"&lt;/span&gt;
  &lt;span class="na"&gt;data-1p-ignore&lt;/span&gt;
  &lt;span class="na"&gt;data-lpignore=&lt;/span&gt;&lt;span class="s"&gt;"true"&lt;/span&gt;
  &lt;span class="na"&gt;data-bwignore&lt;/span&gt;
  &lt;span class="na"&gt;data-form-type=&lt;/span&gt;&lt;span class="s"&gt;"other"&lt;/span&gt;
&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;hp_field&lt;/code&gt; doesn't mean anything to autofill, and the label still says "Leave this empty". The &lt;code&gt;data-&lt;/code&gt; attributes tell 1Password, LastPass, Bitwarden and Dashlane to skip the field. Bots don't care about any of this. They fill every input they find, whatever it's called.&lt;/p&gt;

&lt;h2&gt;
  
  
  Logging every honeypot hit
&lt;/h2&gt;

&lt;p&gt;I still fake success for bots. If they get an error, whoever runs them learns they were caught. The catch is that a dropped real request looks exactly like a caught bot.&lt;/p&gt;

&lt;p&gt;So now the server logs every honeypot hit:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;warn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;event&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;honeypot&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;ip&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;clientIp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="na"&gt;userAgent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;user-agent&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="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If a real person ever trips it, I'll see a normal browser user agent in the logs and can go and look. The &lt;code&gt;JSON.stringify&lt;/code&gt; is there because whoever sends the request controls the user agent and can stuff quotes into it. As JSON, each hit stays one line I can parse, whatever's in it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checking how fast the form was sent
&lt;/h2&gt;

&lt;p&gt;Every form now carries the time it was rendered:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;input&lt;/span&gt; &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"hidden"&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"form_started"&lt;/span&gt; &lt;span class="na"&gt;value=&lt;/span&gt;&lt;span class="s"&gt;{Date.now()}&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When the form comes back, the server checks how long it took:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;sentTooFast&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;form&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;FormData&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;boolean&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;started&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;form&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;form_started&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nb"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isFinite&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;started&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;started&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;started&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="nx"&gt;_000&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;Nobody fills in a quote form in under two seconds. Bots posting straight to the server either do it instantly or don't send the field at all.&lt;/p&gt;

&lt;p&gt;I didn't treat a fast submission as a bot, though, because that would bring back the exact bug I was fixing. Instead the form comes back with the answers still filled in and a message asking them to check and send again. A person clicks send a second time and it goes through. A bot doesn't read the page, so it never does.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rate limiter I said I'd build
&lt;/h2&gt;

&lt;p&gt;My first version was a map in memory: five submissions per IP in ten minutes, then a &lt;code&gt;429 Too Many Requests&lt;/code&gt;. When I went back over it, I found four problems:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;It counted every submission, including ones with typos. Someone who got the form wrong five times was locked out on the sixth try.&lt;/li&gt;
&lt;li&gt;The 429 was a bare "Too many requests" page. Everything they had typed was gone, with no way to reach us.&lt;/li&gt;
&lt;li&gt;It read the first IP in &lt;code&gt;X-Forwarded-For&lt;/code&gt;. Visitors can set that header themselves, so a bot could pick a new IP for every request.&lt;/li&gt;
&lt;li&gt;Old entries were never cleaned up. Combined with fake IPs, anyone could keep growing the map until the server ran out of memory.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is the version that shipped:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;clientIp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&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;hops&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;x-forwarded-for&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&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="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;,&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="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;hop&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;hop&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Boolean&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;hops&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;at&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;rateLimited&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;boolean&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;ip&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;clientIp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;ip&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&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;now&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&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;recent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;submissions&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="nx"&gt;ip&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="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;time&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;now&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;time&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;600&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;recent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;recent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;now&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;submissions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ip&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;recent&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&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 reads the last entry in &lt;code&gt;X-Forwarded-For&lt;/code&gt;, which is the one our reverse proxy adds, so a visitor can't pick it. If there's no IP at all, it doesn't guess. Without that check, every request with no IP would share one counter and block each other. Once the map tracks more than 5,000 IPs, entries older than ten minutes get cleared out.&lt;/p&gt;

&lt;p&gt;The order in the handler matters too:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;isBot&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;form&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;redirect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/quote/sent&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nx"&gt;values&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;readFields&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;form&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;fields&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;errors&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;validateFields&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;values&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;fields&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;noErrors&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;errors&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="nx"&gt;notice&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;checkPace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;form&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;noErrors&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;errors&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;notice&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;saveQuoteRequest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;values&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;redirect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/quote/sent&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;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;notice&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;rate-limited&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="mi"&gt;429&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;422&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;checkPace&lt;/code&gt; runs the time check, then the rate limit, and only for submissions that are otherwise valid. Typos don't count against anyone. When someone does hit the limit, they get the form back with everything they typed, a message to try again in a few minutes, and our email and phone number. The status code is still 429.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing it
&lt;/h2&gt;

&lt;p&gt;I wrote twelve small tests with Bun's built-in test runner, and they run in CI on every merge request. They cover the honeypot, the time check and the rate limiter. One of them fakes the clock to check that the limit lifts after ten minutes.&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%2Fbme3lvvx12asry7ub4d7.png" 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%2Fbme3lvvx12asry7ub4d7.png" alt="![Terminal output from bun test showing 12 passing tests for the honeypot, the time check and the rate limit](https://nyagah.me/images/blog/honeypot_tests.png)&lt;br&gt;
" width="754" height="396"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What it still won't do
&lt;/h2&gt;

&lt;p&gt;All of this is sized for what the site is: a few public forms, no logins, nothing worth breaking into. The worst a bot can do is fill an inbox with junk. If your forms guard accounts, payments or anything people would put real effort into abusing, don't build this yourself. Use tools made for it, like Cloudflare Turnstile or a hosted spam filter for the forms, rate limiting at your CDN or a library backed by Redis, and proper login throttling and account lockouts.&lt;/p&gt;

&lt;p&gt;The rate limiter lives in memory, so it only works while the site runs on one server. Run two containers and each keeps its own count. I'd need something shared like Redis for that, and I don't need it yet.&lt;/p&gt;

&lt;p&gt;There's no ban list either. A rate limit slows a flood down, and I'd rather not keep a list of every bad IP forever. If one address keeps coming back, I'll deal with it then.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I took from this
&lt;/h2&gt;

&lt;p&gt;I thought hard about what bots would do to the form and never thought about what a browser would do to a real person filling it in. My first fix had the same blind spot: I changed the name and assumed the problem was gone.&lt;/p&gt;

&lt;p&gt;The people who commented saved me from losing real requests without ever knowing. That's a big part of why I write about the things I build.&lt;/p&gt;

&lt;p&gt;If you have a honeypot on a form, look at what the hidden field is called. If it's &lt;code&gt;website&lt;/code&gt;, &lt;code&gt;url&lt;/code&gt;, &lt;code&gt;phone&lt;/code&gt; or &lt;code&gt;fax&lt;/code&gt;, someone's browser might be filling it in.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>a11y</category>
      <category>security</category>
      <category>astro</category>
    </item>
    <item>
      <title>Sidequestar: a local quest-giver that gets to know you</title>
      <dc:creator>Antony Nyagah</dc:creator>
      <pubDate>Sun, 11 Oct 2026 17:47:59 +0000</pubDate>
      <link>https://dev.to/nyagah/sidequestar-a-local-quest-giver-that-gets-to-know-you-4khe</link>
      <guid>https://dev.to/nyagah/sidequestar-a-local-quest-giver-that-gets-to-know-you-4khe</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/challenges/hacktoberfest-week1-2026-10-05"&gt;Hacktoberfest Open-Source AI Challenge Week 1: Touch Grass&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Built
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The main quest can wait.&lt;/strong&gt; One small escape at a time.&lt;/p&gt;

&lt;p&gt;You finish work, you know there has to be more to life than another evening on a screen, and you still have no idea what to do with the next two hours. Sidequestar is for that moment: a quest-giver running on your own machine that hands you one small thing to do, and gets to know you a little better every time you do one.&lt;/p&gt;

&lt;p&gt;You tell it your situation ("two hours this evening, want to be outside"), a model running on your own machine writes one specific quest, and you go do it. Finish it and you get XP and levels. Add a photo and you get a bit more. The whole thing on screen takes about twenty seconds, and then you're out the door.&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%2Fxyf5nk9b2ylye0z1usba.png" 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%2Fxyf5nk9b2ylye0z1usba.png" alt="The Sidequestar home page: the summon box, a fresh quest (" width="799" height="662"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Every time you finish a quest, the model rewrites a short summary of what you seem to enjoy and when you're free, and that summary shapes the next quest. I didn't want that to be a black box, so the profile page shows, word for word, what the model is told about you, and you can edit the summary if it gets something wrong. Everyone on the machine has their own account, so housemates get their own quests, XP and summaries.&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%2Fz9gd4z284ur0u02bxp26.png" 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%2Fz9gd4z284ur0u02bxp26.png" alt="The profile page: the exact lines sent to the model with every quest, the summary it wrote, and the XP log" width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Yesterday I went cycling in Tigoni with a group: 33.64 km, 3 hours 26 minutes and 532 m of climbing. That ride wasn't a Sidequestar quest, but it's the kind of thing I want more of, and its Strava card is the completion photo in the demo.&lt;/p&gt;

&lt;h2&gt;
  
  
  Demo
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Try it: &lt;a href="https://sidequestar.nyagah.me" rel="noopener noreferrer"&gt;sidequestar.nyagah.me&lt;/a&gt;.&lt;/strong&gt; Create an account and ask for a quest. It runs the same open model on a small 4-core ARM server, so a quest takes about 10 to 25 seconds. If you'd rather keep everything on your own machine, &lt;code&gt;make dev&lt;/code&gt; runs the whole thing locally.&lt;/p&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/leNfHCF8VTE" width="710" height="399"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;p&gt;In the demo I create an account, ask for a quest on a Saturday morning with my bike and half a day free, finish it with a photo, and then open the profile page to see what the model knows. The waits for the model are sped up.&lt;/p&gt;

&lt;h2&gt;
  
  
  Code
&lt;/h2&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/tony-nyagah" rel="noopener noreferrer"&gt;
        tony-nyagah
      &lt;/a&gt; / &lt;a href="https://github.com/tony-nyagah/sidequestar" rel="noopener noreferrer"&gt;
        sidequestar
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      
    &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;Sidequestar&lt;/h1&gt;
&lt;/div&gt;
&lt;p&gt;&lt;strong&gt;The main quest can wait.&lt;/strong&gt; One small escape at a time.&lt;/p&gt;
&lt;p&gt;Sidequestar gives you small things to do away from work and school, and XP for doing them. You describe your day, a model running on your own machine suggests one concrete quest, and you go do it. Over time it learns what you enjoy and writes that down where you can read it and correct it.&lt;/p&gt;
&lt;p&gt;Built for the &lt;a href="https://dev.to/challenges/hacktoberfest-week1-2026-10-05" rel="nofollow"&gt;Hacktoberfest Open-Source AI Challenge, Week 1: Touch Grass&lt;/a&gt;. Try it at &lt;a href="https://sidequestar.nyagah.me" rel="nofollow noopener noreferrer"&gt;sidequestar.nyagah.me&lt;/a&gt;, or run it on your own machine (below) to keep everything there.&lt;/p&gt;
&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;How it works&lt;/h2&gt;
&lt;/div&gt;
&lt;ol&gt;
&lt;li&gt;Tell the quest-giver your situation, for example "two hours this evening, I want to be outside".&lt;/li&gt;
&lt;li&gt;A local model (Ollama running &lt;code&gt;llama3.2:3b&lt;/code&gt;) writes one specific quest. You can also add your own.&lt;/li&gt;
&lt;li&gt;Go do it, then mark it done. Adding a photo earns bonus XP.&lt;/li&gt;
&lt;li&gt;XP adds up to…&lt;/li&gt;
&lt;/ol&gt;&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/tony-nyagah/sidequestar" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


&lt;h2&gt;
  
  
  How I Built It
&lt;/h2&gt;

&lt;p&gt;The backend is Go (standard library &lt;code&gt;net/http&lt;/code&gt;) with SQLite through &lt;code&gt;sqlc&lt;/code&gt;. The frontend is Astro with Tailwind CSS and daisyUI. Quest generation and the profile summaries both run through Ollama with &lt;code&gt;llama3.2:3b&lt;/code&gt;, an open-weight model, entirely on the user's machine. &lt;code&gt;make dev&lt;/code&gt; starts Ollama, the API and the frontend together.&lt;/p&gt;

&lt;p&gt;Most of the work went into getting a 3B model to behave. A few things I ran into:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;It made things up, and then kept them. Early summaries invented details like "free until 10am". Worse, each rewrite started from the previous summary, so once the model invented something, it treated it as fact from then on. The fix was to stop feeding it its own output. A rewrite now only sees what you wrote, the quests you actually finished, and your last manual edit.&lt;/li&gt;
&lt;li&gt;It treated starter quests as history. New accounts come with a few seed quests on the list, and the model read them as things the person had done ("She tends to finish short quests like reading or taking a 20-minute stretch", about a test user who had done neither). Labelling the list as "not done yet, not evidence" and dropping the temperature for summaries fixed most of it. It still slips now and then, which is why the summary is editable.&lt;/li&gt;
&lt;li&gt;It invented places. Early quests sent people to "Kiambu National Park" and "the Kiambu Tea Museum", neither of which exists, and this app's whole point is sending you out to find things. I tried &lt;code&gt;gemma2:9b&lt;/code&gt; to see if a bigger model would do better. On this laptop (an i5-8250U, no GPU) it took 88 and 115 seconds per quest, timed out on the third, and still sent a Limuru cyclist to "hidden art installations" at Ol Donyo Sabuk, about 80 km away. So the problem was my prompt: I'd asked for something "concrete and specific", and both models made specifics up. Now it describes the kind of place ("a tea farm near Limuru") unless you named one yourself. None of the five test quests I ran after that change named a made-up place.&lt;/li&gt;
&lt;li&gt;The profile page reads its "what the model knows" lines from the same function that builds the prompt. I could have written a description of what gets sent, but it would have drifted out of date the first time I changed the prompt.&lt;/li&gt;
&lt;li&gt;If Ollama isn't running, or the model isn't pulled yet, the app says so and gives you the command to fix it, instead of "something went wrong". With a local model those are easy mistakes to make, and I made both while building this.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The design is a topographic map sheet: an orange map-cover header, contour lines behind the quest box, your active quests as a footpath between numbered waypoints, and finished ones as photo insets. Light and dark mode follow your system.&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%2Fr8u8q409q8cf7gcqubyi.png" 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%2Fr8u8q409q8cf7gcqubyi.png" alt="Dark mode: a fresh quest, the completed Tigoni ride as a photo inset, and the footpath route" width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Does Open Innovation Matter?
&lt;/h2&gt;

&lt;p&gt;Because of what this app knows about you. Your situation, your history and a running summary of who you are never leave your laptop. With a closed API, "I'm burnt out and have two free hours" goes to a vendor, every quest costs money, and the whole thing stops working wherever there's no signal, which is exactly where you want to be if the quest worked.&lt;/p&gt;

&lt;p&gt;Ollama and an open-weight model also mean it costs nothing to run and isn't tied to a provider. Swap in another model, or replace Ollama with llama.cpp, and the code doesn't change.&lt;/p&gt;

</description>
      <category>hf26challenge</category>
    </item>
    <item>
      <title>I Used a Honeypot Instead of a CAPTCHA on Our Quote Form</title>
      <dc:creator>Antony Nyagah</dc:creator>
      <pubDate>Fri, 09 Oct 2026 08:39:59 +0000</pubDate>
      <link>https://dev.to/nyagah/i-used-a-honeypot-instead-of-a-captcha-on-our-quote-form-388l</link>
      <guid>https://dev.to/nyagah/i-used-a-honeypot-instead-of-a-captcha-on-our-quote-form-388l</guid>
      <description>&lt;p&gt;At work, I'm rebuilding a website for an organization that serves medical laboratories. The most important page on the new site is a form where a lab picks the services it needs and asks for a quote.&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%2F1tz1quyw3n66ri3yot2g.png" 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%2F1tz1quyw3n66ri3yot2g.png" alt="A quote request form with fields for the laboratory, name, email, phone, town and country, and a Send request button" width="800" height="763"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Every public form attracts spam bots. The usual fix is a CAPTCHA, and I didn't want one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why not a CAPTCHA
&lt;/h2&gt;

&lt;p&gt;A CAPTCHA makes every real visitor prove they are human so you can stop a few bots. Image puzzles are hard for people using screen readers and annoying for everyone else. Services like reCAPTCHA also load third-party scripts, which slow the page down and send your visitors' data to someone else.&lt;/p&gt;

&lt;p&gt;The people filling in this form are lab managers who want a price. I wanted it to be as easy to fill in as possible.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honeypot
&lt;/h2&gt;

&lt;p&gt;A honeypot is a form field that people never see, but bots fill in anyway. Mine sits inside the quote form:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"absolute -left-[9999px]"&lt;/span&gt; &lt;span class="na"&gt;aria-hidden=&lt;/span&gt;&lt;span class="s"&gt;"true"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;label&lt;/span&gt; &lt;span class="na"&gt;for=&lt;/span&gt;&lt;span class="s"&gt;"website"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Leave this empty&lt;span class="nt"&gt;&amp;lt;/label&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;input&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"website"&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"website"&lt;/span&gt; &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"text"&lt;/span&gt; &lt;span class="na"&gt;tabindex=&lt;/span&gt;&lt;span class="s"&gt;"-1"&lt;/span&gt; &lt;span class="na"&gt;autocomplete=&lt;/span&gt;&lt;span class="s"&gt;"off"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each part hides it from a different kind of visitor:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The wrapper moves it 9999px off the left of the screen, so sighted visitors never see it.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;tabindex="-1"&lt;/code&gt; makes keyboard users skip over it.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;aria-hidden="true"&lt;/code&gt; hides it from screen readers. The label says "Leave this empty" in case one reads it anyway.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;autocomplete="off"&lt;/code&gt; stops the browser from autofilling it for real people.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Most spam bots never render the page. They read the HTML and fill in every field they find, and an empty field called &lt;code&gt;website&lt;/code&gt; is hard for them to resist.&lt;/p&gt;

&lt;h2&gt;
  
  
  The check on the server
&lt;/h2&gt;

&lt;p&gt;The form posts to our own server (the site is built with &lt;a href="https://astro.build" rel="noopener noreferrer"&gt;Astro&lt;/a&gt;), and the first thing the handler does is look at that field:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Honeypot: people never see this field; bots fill it in. Pretend it worked.&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;form&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;website&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;trim&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="nx"&gt;Astro&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;redirect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/quote/sent&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;303&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 the field has anything in it, a person didn't send the form. The server doesn't save the request and sends the bot to the normal "Request sent" page. I deliberately fake the success: an error page would tell whoever runs the bot that they were caught, and they might change it.&lt;/p&gt;

&lt;p&gt;Because the check runs on the server, it also works for visitors who have JavaScript turned off.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it won't stop
&lt;/h2&gt;

&lt;p&gt;A honeypot catches generic bots, which send most form spam. It won't stop a person paid to fill in forms, or a bot written specifically for this site that skips hidden fields.&lt;/p&gt;

&lt;p&gt;If that kind of spam ever shows up, the next step is limiting how many requests one IP address can send. Until then, the honeypot keeps the bots out and costs real visitors nothing.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>a11y</category>
      <category>security</category>
      <category>astro</category>
    </item>
    <item>
      <title>I Tried to Learn RAG and Got Stuck on API Costs. Turns Out My CPU Could Do the Expensive Bit.</title>
      <dc:creator>Antony Nyagah</dc:creator>
      <pubDate>Mon, 14 Sep 2026 06:00:00 +0000</pubDate>
      <link>https://dev.to/nyagah/i-tried-to-learn-rag-and-got-stuck-on-api-costs-turns-out-my-cpu-could-do-the-expensive-bit-1o6h</link>
      <guid>https://dev.to/nyagah/i-tried-to-learn-rag-and-got-stuck-on-api-costs-turns-out-my-cpu-could-do-the-expensive-bit-1o6h</guid>
      <description>&lt;p&gt;A few months ago, I started building a &lt;a href="https://en.wikipedia.org/wiki/Retrieval-augmented_generation" rel="noopener noreferrer"&gt;Retrieval-Augmented Generation&lt;/a&gt; application because I wanted to understand what was actually happening underneath all the buzzwords. The project I built was appropriately named &lt;a href="https://github.com/tony-nyagah/simple_rag" rel="noopener noreferrer"&gt;simple_rag&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The idea was simple enough: upload a PDF, ask questions about it, and have an LLM answer using information from the document. I got it working. Then I mostly abandoned it.&lt;/p&gt;

&lt;p&gt;The thing I couldn't figure out how to handle cleanly was running the embedding model locally.&lt;/p&gt;

&lt;h2&gt;
  
  
  I accidentally made a simple project expensive
&lt;/h2&gt;

&lt;p&gt;My original implementation used Gemini for both embeddings and generation. Later, I switched it to Mistral. Both worked, but the more I played with the project, the more I disliked one part of the architecture: every time I wanted to turn some text into embeddings, I was calling somebody else's API, with its own rate limits and its own costs.&lt;/p&gt;

&lt;p&gt;That meant another API key, another service to depend on, another rate limit to think about, and potentially another bill. For a production application where the trade-off makes sense, fine. For a tiny side project whose entire purpose was basically:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Antony would like to understand RAG.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;...it felt ridiculous.&lt;/p&gt;

&lt;p&gt;Embeddings are fundamental to how this implementation works. When you upload a document, the text gets split into chunks, and each chunk gets converted into an embedding: a numerical representation of its meaning. That embedding gets compared against the question's embedding, and whichever chunks are most semantically similar get sent to the LLM as context. That is basically the retrieval part of Retrieval-Augmented Generation.&lt;/p&gt;

&lt;p&gt;So if creating embeddings depended on an API I didn't want to keep paying for, the whole experiment got a lot less interesting. The repository sat there.&lt;/p&gt;

&lt;h2&gt;
  
  
  Then I discovered I was overcomplicating the problem
&lt;/h2&gt;

&lt;p&gt;Recently I came back to the project and discovered &lt;a href="https://github.com/qdrant/fastembed" rel="noopener noreferrer"&gt;fastembed&lt;/a&gt;, and had one of those slightly embarrassing engineering moments where you realize the problem that made you abandon something months ago never actually needed the solution you'd assumed it needed.&lt;/p&gt;

&lt;p&gt;I could generate the embeddings locally, on my CPU, without touching an embedding API, a GPU, or a heavy PyTorch install, and without a per-embedding bill.&lt;/p&gt;

&lt;p&gt;I'm currently using &lt;code&gt;BAAI/bge-small-en-v1.5&lt;/code&gt; through FastEmbed. It runs on ONNX Runtime and is small enough that my completely ordinary laptop handles it without breaking a sweat.&lt;/p&gt;

&lt;p&gt;That changed the architecture quite a bit. The current flow is basically:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;PDF / EPUB
    ↓
Extract text
    ↓
Split text into chunks
    ↓
Generate embeddings locally on my CPU
    ↓
Store the vectors in memory
    ↓
User asks question
    ↓
Embed question locally
    ↓
Compare it with document embeddings
    ↓
Retrieve the most relevant chunks
    ↓
Send those chunks + question to the LLM
    ↓
Answer
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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%2Foguvsce1too6b7w04gos.png" 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%2Foguvsce1too6b7w04gos.png" alt=" " width="800" height="504"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That's it. And weirdly, stripping things away helped me understand RAG much better than adding more AI tooling ever did.&lt;/p&gt;

&lt;h2&gt;
  
  
  I don't even have a vector database
&lt;/h2&gt;

&lt;p&gt;This was another thing I had mentally associated with RAG.&lt;/p&gt;

&lt;p&gt;Embeddings mean vectors. Vectors mean vector databases. Therefore RAG means I need Pinecone, Qdrant, Chroma, pgvector, or something similar.&lt;/p&gt;

&lt;p&gt;Except... not necessarily. My application currently stores embeddings as plain Python data and uses NumPy to calculate cosine similarity. For every question, it compares the question's vector against the chunk vectors, sorts by similarity, and grabs the best matches.&lt;/p&gt;

&lt;p&gt;Is that how I'd build a system with millions of documents? Absolutely not. Is it enough for a PDF, an EPUB, or a few hundred pages while I'm trying to understand retrieval? Absolutely.&lt;/p&gt;

&lt;p&gt;That distinction has become increasingly important to me when learning new technology. There's a huge difference between asking what do I need to understand this, and what would I need to operate this at serious production scale. The internet has a habit of giving you the second architecture while you're still trying to answer the first question.&lt;/p&gt;

&lt;h2&gt;
  
  
  The LLM is now the only remote part
&lt;/h2&gt;

&lt;p&gt;I still needed a model to generate the final answer, and this is where I found &lt;a href="https://groq.com/" rel="noopener noreferrer"&gt;Groq&lt;/a&gt; particularly interesting.&lt;/p&gt;

&lt;p&gt;For my current side-project level of usage, their free tier is enough to experiment with. I created an API key without even adding a credit card, pointed the OpenAI-compatible client at Groq, and that was basically it. I'm currently using &lt;code&gt;openai/gpt-oss-120b&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;So now the parts have a much cleaner separation.&lt;/p&gt;

&lt;p&gt;My machine handles:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Extracting the document text.&lt;/li&gt;
&lt;li&gt;Chunking it.&lt;/li&gt;
&lt;li&gt;Creating embeddings.&lt;/li&gt;
&lt;li&gt;Storing those embeddings.&lt;/li&gt;
&lt;li&gt;Embedding the question.&lt;/li&gt;
&lt;li&gt;Retrieving the relevant chunks.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The LLM handles:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Taking the question plus retrieved context.&lt;/li&gt;
&lt;li&gt;Producing a useful, human-readable answer.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That separation made another thing click for me: RAG isn't really about making the LLM know your data, since the model doesn't suddenly learn my PDF. I'm building a retrieval system that finds useful information first, then handing that information to the model at generation time.&lt;/p&gt;

&lt;p&gt;The retrieval is doing a lot more of the work than I originally appreciated.&lt;/p&gt;

&lt;h2&gt;
  
  
  I wanted the implementation to stay boring
&lt;/h2&gt;

&lt;p&gt;The core RAG logic is only a few hundred lines of Python. That's deliberate.&lt;/p&gt;

&lt;p&gt;There's a FastAPI API around it and a small browser UI, plus a CLI because apparently I cannot make a Python project without eventually wanting to run it from the terminal. But the actual interesting bit is separate from all that.&lt;/p&gt;

&lt;p&gt;The RAG module handles:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Text extraction.&lt;/li&gt;
&lt;li&gt;Chunking.&lt;/li&gt;
&lt;li&gt;Embedding.&lt;/li&gt;
&lt;li&gt;Similarity search.&lt;/li&gt;
&lt;li&gt;Retrieval.&lt;/li&gt;
&lt;li&gt;Generation.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The interfaces just call it. I also added an evaluation script using a paper I already had lying around, &lt;em&gt;Attention Is All You Need&lt;/em&gt;, with questions of varying difficulty.&lt;/p&gt;

&lt;p&gt;Again, the point was not to build some enterprise RAG platform. I wanted something small enough that I could open &lt;code&gt;rag.py&lt;/code&gt;, read it top to bottom, and understand what every part was doing. I learn better that way.&lt;/p&gt;

&lt;h2&gt;
  
  
  Local AI is becoming much more interesting to me
&lt;/h2&gt;

&lt;p&gt;The embedding discovery also changed how I think about adding AI features to my own applications. My default assumption used to be that "adding AI" meant sending everything to some massive remote model. That assumption is becoming less useful. There are increasingly parts of the pipeline I can run locally or self-host:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Embeddings.&lt;/li&gt;
&lt;li&gt;Classification.&lt;/li&gt;
&lt;li&gt;Reranking.&lt;/li&gt;
&lt;li&gt;Transcription.&lt;/li&gt;
&lt;li&gt;Smaller language models.&lt;/li&gt;
&lt;li&gt;Various forms of semantic search.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That leaves paid inference for the bits that actually benefit from a larger model, which matters for side projects, and matters even more when building software in places where throwing $50 or $100 a month at every interesting API isn't a particularly attractive development strategy.&lt;/p&gt;

&lt;p&gt;I already like self-hosting normal infrastructure. Apparently I'm slowly becoming the person who wants to self-host chunks of the AI stack too. This was probably inevitable.&lt;/p&gt;

&lt;h2&gt;
  
  
  More importantly, I want to actually use this stuff
&lt;/h2&gt;

&lt;p&gt;I have zero interest in adding an AI button to every application just because investors have collectively decided that everything now needs AI. What I &lt;em&gt;am&lt;/em&gt; increasingly interested in is finding places where these models genuinely improve an existing workflow.&lt;/p&gt;

&lt;p&gt;RAG is interesting because there are obvious uses for it. Documentation is one: imagine an internal application with years of manuals, procedures, policies and reports. Instead of manually searching through them, you ask a question and the relevant sections get retrieved before the answer is generated. That's useful.&lt;/p&gt;

&lt;p&gt;Another thing I want to experiment with next is databases. I've spent years building systems where somebody eventually asks:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Can you give me a report showing X, grouped by Y, for these dates?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;...and then somebody has to write SQL, add a report screen, export something to Excel, or build yet another dashboard. I want to experiment with a safer natural-language interface where somebody can ask:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;How many registrations did we receive per month this year?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;and the system understands the database schema, generates a read-only query, validates it, executes it, and explains the result.&lt;/p&gt;

&lt;p&gt;That's not necessarily the same problem as RAG. Structured databases need different techniques, and I definitely do not want an LLM firing random &lt;code&gt;DELETE&lt;/code&gt; statements at production. But it's exactly the kind of AI experimentation I'm interested in now: giving the application another interface to capabilities it already has.&lt;/p&gt;

&lt;h2&gt;
  
  
  The useful lesson wasn't actually about RAG
&lt;/h2&gt;

&lt;p&gt;I started this project because I wanted to understand embeddings, retrieval and RAG. I did learn those things, but I think the more useful lesson was about how easily we confuse the infrastructure around a technology with the technology itself.&lt;/p&gt;

&lt;p&gt;You do not need a vector database to understand vector search.&lt;/p&gt;

&lt;p&gt;You do not need an embedding API to create embeddings.&lt;/p&gt;

&lt;p&gt;You do not need an enormous framework to understand RAG.&lt;/p&gt;

&lt;p&gt;And you definitely do not need to start by building the architecture meant to serve ten million users.&lt;/p&gt;

&lt;p&gt;Sometimes you need a Python file, NumPy, a small model running on your CPU, and enough curiosity to keep poking at the thing until it stops looking like magic. That is what &lt;code&gt;simple_rag&lt;/code&gt; has become for me.&lt;/p&gt;

&lt;p&gt;The repository is still called simple RAG. This time, I think it actually deserves the name.&lt;/p&gt;




&lt;p&gt;The project is on GitHub: &lt;a href="https://github.com/tony-nyagah/simple_rag" rel="noopener noreferrer"&gt;tony-nyagah/simple_rag&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;I am going to keep experimenting with practical AI integrations and documenting what I learn, especially the boring, useful stuff between "call an LLM" and "build an autonomous superintelligence."&lt;/p&gt;

</description>
      <category>rag</category>
      <category>ai</category>
      <category>python</category>
      <category>machinelearning</category>
    </item>
    <item>
      <title>Self-Hosting is easier than you think: A guide to open source PaaS</title>
      <dc:creator>Antony Nyagah</dc:creator>
      <pubDate>Wed, 24 Jun 2026 09:30:00 +0000</pubDate>
      <link>https://dev.to/nyagah/self-hosting-is-easier-than-you-think-a-guide-to-open-source-paas-1pl4</link>
      <guid>https://dev.to/nyagah/self-hosting-is-easier-than-you-think-a-guide-to-open-source-paas-1pl4</guid>
      <description>&lt;h2&gt;
  
  
  Table of Contents
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;What I Was Looking For&lt;/li&gt;
&lt;li&gt;What Dokploy Actually Is&lt;/li&gt;
&lt;li&gt;What I Actually Run on It&lt;/li&gt;
&lt;li&gt;The Trade-offs&lt;/li&gt;
&lt;li&gt;Ten Minutes to Your Own Platform&lt;/li&gt;
&lt;li&gt;Should You Switch?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A while back I built an email verifier using Go while learning the language and wanted to deploy it. Sadly, &lt;a href="https://vercel.com" rel="noopener noreferrer"&gt;Vercel&lt;/a&gt; did not support Go at the time. None of the other platform-as-a-service providers supported it either at the time. Most of them supported only JavaScript, TypeScript and maybe Python.&lt;/p&gt;

&lt;p&gt;So I went looking.&lt;/p&gt;

&lt;p&gt;In the process of looking for something similar to the PaaS I was used to, I came across various self-hosted solutions.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I was looking for
&lt;/h2&gt;

&lt;p&gt;I wanted three things:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A nice UI + dashboard. Managing apps and services on the terminal is fun and all but I am a more visual person. I wanted a dashboard that would let me see all my apps and services at a glance.&lt;/li&gt;
&lt;li&gt;Git-push deploys. I got used to that flow from Vercel and I wasn't giving it up.&lt;/li&gt;
&lt;li&gt;No lock-in. If the platform died tomorrow, I want to move on quickly to another solution.&lt;/li&gt;
&lt;li&gt;Ease of use. I wanted something that I could get up and running with little issue.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I tried a few things:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;a href="https://coolify.io" rel="noopener noreferrer"&gt;Coolify&lt;/a&gt; is the most mature. It supports something like 50 different services and is very feature-rich.    But I found myself lost when trying to set it up on my VPS. This might have changed since I last tried it.&lt;br&gt;&lt;br&gt;
The UI was also a bit overwhelming in terms of what I had to configure to get my projects running.    &lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;a href="https://caprover.com" rel="noopener noreferrer"&gt;CapRover&lt;/a&gt; is the opposite. Nice and simple. Does exactly what you want. This has greatly improved since I last tried it a few years ago. Very simple and to the point.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;a href="https://kamal-deploy.org" rel="noopener noreferrer"&gt;Kamal&lt;/a&gt; is interesting. It's 37signals' tool for deploying Basecamp and HEY. All CLI, no web UI. If you live in a terminal and you trust yourself with YAML at 2am, it's solid. I wanted something between "bare metal CLI" and "kitchen sink platform."&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Docker Compose + GitHub/GitLab actions. I still use this on some projects. But I wanted a better way to monitor what's happening with my software.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I finally settled on &lt;a href="https://dokploy.com" rel="noopener noreferrer"&gt;Dokploy&lt;/a&gt;.&lt;br&gt;
&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/Dokploy" rel="noopener noreferrer"&gt;
        Dokploy
      &lt;/a&gt; / &lt;a href="https://github.com/Dokploy/dokploy" rel="noopener noreferrer"&gt;
        dokploy
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      Open Source Alternative to Vercel, Netlify and Heroku.
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;div&gt;
  &lt;a href="https://dokploy.com" rel="nofollow noopener noreferrer"&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%2FDokploy%2Fdokploy%2FHEAD%2F.github%2Fsponsors%2Flogo.png" alt="Dokploy - Open Source Alternative to Vercel, Heroku and Netlify." width="100%"&gt;
  &lt;/a&gt;
  &lt;br&gt;
  &lt;br&gt;
  &lt;p&gt;Join us on Discord for help, feedback, and discussions!&lt;/p&gt;
  &lt;a href="https://discord.gg/2tBnJ3jDJc" rel="nofollow noopener noreferrer"&gt;
    &lt;img src="https://camo.githubusercontent.com/549d5a6607c79fd013118f147306d06b38a8fa642cd288199e3e91f6681f8106/68747470733a2f2f646973636f72646170702e636f6d2f6170692f6775696c64732f313233343037333236323431383536333131322f7769646765742e706e673f7374796c653d62616e6e657232" alt="Discord Shield"&gt;
  &lt;/a&gt;
&lt;/div&gt;
&lt;br&gt;
&lt;p&gt;Dokploy is a free, self-hostable Platform as a Service (PaaS) that simplifies the deployment and management of applications and databases.&lt;/p&gt;
&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;✨ Features&lt;/h2&gt;
&lt;/div&gt;
&lt;p&gt;Dokploy includes multiple features to make your life easier.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Applications&lt;/strong&gt;: Deploy any type of application (Node.js, PHP, Python, Go, Ruby, etc.).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Databases&lt;/strong&gt;: Create and manage databases with support for MySQL, PostgreSQL, MongoDB, MariaDB, libsql, and Redis.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Backups&lt;/strong&gt;: Automate backups for databases to an external storage destination.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Docker Compose&lt;/strong&gt;: Native support for Docker Compose to manage complex applications.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Multi Node&lt;/strong&gt;: Scale applications to multiple nodes using Docker Swarm to manage the cluster.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Templates&lt;/strong&gt;: Deploy open-source templates (Plausible, Pocketbase, Calcom, etc.) with a single click.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Traefik Integration&lt;/strong&gt;: Automatically integrates with Traefik for routing and load balancing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Real-time Monitoring&lt;/strong&gt;: Monitor CPU, memory, storage, and network usage for every resource.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Docker Management&lt;/strong&gt;: Easily deploy…&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/Dokploy/dokploy" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&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%2Fkpp361mopg41es5td0a7.png" 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%2Fkpp361mopg41es5td0a7.png" alt="A dashboard showing the Dokploy interface with various running apps and real-time logs" width="800" height="490"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What Dokploy actually is
&lt;/h2&gt;

&lt;p&gt;One binary. You SSH into a fresh VPS, run a single curl command, and you get a dashboard on port 3000. Behind the scenes it's Docker and &lt;a href="https://traefik.io" rel="noopener noreferrer"&gt;Traefik&lt;/a&gt; doing the actual work. Dokploy is the UI layer on top.&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;-sSL&lt;/span&gt; dokploy.com/install.sh | bash
&lt;span class="c"&gt;# That's it. Open port 3000.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What you get:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Git integration.&lt;/strong&gt; Connect GitHub or GitLab, push to main, it builds and deploys. Same flow as Vercel.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Docker Compose support.&lt;/strong&gt; I didn't have to learn a new config format. It reads my existing compose files. &lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Auto SSL.&lt;/strong&gt; I have never once manually configured an SSL cert with Dokploy. Let's Encrypt via Traefik, zero touching.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Built-in monitoring.&lt;/strong&gt; Logs, resource usage, healthchecks. I can see if my Telegram bot crashed without opening a terminal.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One of the most interesting parts is the &lt;strong&gt;Templates for common apps&lt;/strong&gt; such as Anubis, AppSmith, Listmonk, ERPNext, Ghost, GitLab, Bugsink and many more. The list is &lt;a href="https://templates.dokploy.com/" rel="noopener noreferrer"&gt;massive.&lt;/a&gt;&lt;br&gt;
The templates give you an editable Docker Compose file so you can customize the app to your needs. So you're not forced to use a specific setup.&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%2F3tkhhvzpk255en7ferri.png" 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%2F3tkhhvzpk255en7ferri.png" alt="Dokploy app templates" width="800" height="435"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Of note is that most of these solutions depend on Docker. Most of them are essentially a UI on top of a standard Docker + Docker Compose + Traefik configuration. This means you can seamlessly move from one self-hosted solution to another. No lock-in whatsoever.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I actually run on it
&lt;/h2&gt;

&lt;p&gt;I have been using Dokploy in production for a few month now to run some open source applications including:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://listmonk.app/" rel="noopener noreferrer"&gt;Listmonk&lt;/a&gt; - email marketing campaigns for an SME&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://umami.is/" rel="noopener noreferrer"&gt;umami&lt;/a&gt; - website analytics to track user behavior on a new project&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://bugsink.com/" rel="noopener noreferrer"&gt;Bugsink&lt;/a&gt; - error tracking for a legacy project we're maintaining&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So far the setup was simple and straightforward. Pick a template, point a domain at it, edit the configurations such as environment variables and secrets, and press the deploy button.&lt;/p&gt;

&lt;p&gt;I also have some personal projects running on it. Mainly projects built while learning new technologies or experimenting with new ideas.&lt;/p&gt;

&lt;h2&gt;
  
  
  The trade-offs
&lt;/h2&gt;

&lt;p&gt;Self-hosting isn't all upside. I'm not going to pretend it is.&lt;/p&gt;

&lt;p&gt;You don't get a global edge network. Your server is in one region. If your users are in Australia and your VPS is in Germany, they'll feel it. Vercel's edge network is genuinely good at this. For most side projects this doesn't matter. But if you're building something global, keep it in mind.&lt;/p&gt;

&lt;p&gt;You manage capacity. If you get posted on Hacker News and your traffic goes 10x in an hour, Dokploy won't auto-scale. You'll need to handle that. For most side projects? Won't happen. But be honest with yourself.&lt;/p&gt;

&lt;p&gt;You're responsible for uptime. If the server goes down at 3am, nobody's fixing it but you. I use &lt;a href="https://uptime.kuma.pet" rel="noopener noreferrer"&gt;Uptime Kuma&lt;/a&gt; for monitoring and I get Telegram alerts. Works for me. Might not work for you.&lt;/p&gt;

&lt;p&gt;Database backups are on you. Dokploy makes it easy to provision handle that though. Your Docker volumes can be backed up to an S3 bucket or equivalent.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;But here's what you get:&lt;/strong&gt; fixed monthly cost. No surprise bills. Run "unlimited" projects. Full control over your infrastructure. Data stays wherever you want it. And the DevOps skills you pick up? They transfer everywhere.&lt;/p&gt;

&lt;h2&gt;
  
  
  Ten minutes to your own platform
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Get a VPS.&lt;/strong&gt; I use &lt;a href="https://www.hetzner.com/cloud" rel="noopener noreferrer"&gt;Hetzner&lt;/a&gt;. &lt;a href="https://www.digitalocean.com" rel="noopener noreferrer"&gt;DigitalOcean&lt;/a&gt; and &lt;a href="https://www.linode.com" rel="noopener noreferrer"&gt;Linode&lt;/a&gt; work too. Latest Ubuntu.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SSH in and run the install command.&lt;/strong&gt; That's it. Dokploy sets up Docker, Traefik, and the dashboard.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Point your domain.&lt;/strong&gt; A record to the VPS IP. SSL happens automatically.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deploy something.&lt;/strong&gt; Connect a GitHub repo, paste a compose file, or pick a template.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Ten minutes. Then you have a platform that costs less than lunch, runs "unlimited" apps, and you actually own.&lt;/p&gt;

&lt;p&gt;And if there's any issues, the &lt;a href="https://docs.dokploy.com/docs/core" rel="noopener noreferrer"&gt;Dokploy documentation&lt;/a&gt; is fantastic.&lt;/p&gt;

&lt;h2&gt;
  
  
  Should you switch?
&lt;/h2&gt;

&lt;p&gt;If you're happy on Vercel or Heroku or something similar and you don't hit their limits, stay. They're good products.&lt;/p&gt;

&lt;p&gt;But if you've ever opened a surprise bill, or hit a timeout limit that broke your app, or wanted to run something that didn't fit the platform's mold — try Dokploy. Spin up a VPS for a month and deploy one or two or ten side projects. Worst case, you're out less than $10. Best case, you realize you don't need the closed source PaaS platforms.&lt;/p&gt;

</description>
      <category>docker</category>
      <category>devex</category>
      <category>opensource</category>
    </item>
    <item>
      <title>You Don't Need Docker Desktop on Linux</title>
      <dc:creator>Antony Nyagah</dc:creator>
      <pubDate>Wed, 17 Jun 2026 08:00:00 +0000</pubDate>
      <link>https://dev.to/nyagah/you-dont-need-docker-desktop-on-linux-1n8b</link>
      <guid>https://dev.to/nyagah/you-dont-need-docker-desktop-on-linux-1n8b</guid>
      <description>&lt;p&gt;Docker Desktop was using 4GB of RAM on my machine. Sitting there. Doing nothing. No containers running. Just the daemon.&lt;/p&gt;

&lt;p&gt;I don't know about you, but 4 gigs for a container runtime that isn't running any containers is a hard no from me.&lt;/p&gt;

&lt;p&gt;I'm on Fedora, but this applies to Ubuntu, Debian, whatever. If you're running Linux, you don't need Docker Desktop.&lt;/p&gt;

&lt;p&gt;So I started looking at what I actually needed. Turns out: not much.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Docker Desktop actually is
&lt;/h2&gt;

&lt;p&gt;Docker Desktop is a GUI wrapper around a VM that runs the Docker Engine. On Linux, the VM part is unnecessary. Docker runs natively on Linux. The VM exists because Windows and macOS don't have a native Linux kernel. But on Linux? You're running a VM to run something that already runs natively on your system. That's the bloat.&lt;/p&gt;

&lt;p&gt;What you actually need on Linux:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Docker Engine&lt;/strong&gt; — the daemon that runs containers (&lt;code&gt;docker-ce&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Docker Compose&lt;/strong&gt; — the plugin, not the old Python binary (&lt;code&gt;docker compose&lt;/code&gt;, not &lt;code&gt;docker-compose&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The CLI&lt;/strong&gt; — &lt;code&gt;docker&lt;/code&gt; commands, which come with the engine&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's it. No GUI. No VM. No 4GB idle footprint.&lt;/p&gt;

&lt;h2&gt;
  
  
  The auto-start nonsense
&lt;/h2&gt;

&lt;p&gt;Here's what really pushed me over the edge. Every time I rebooted my Fedora machine, Docker Desktop wouldn't auto-start. I had to manually launch it. Every single time.&lt;/p&gt;

&lt;p&gt;This is supposed to "just work." It's a desktop application. Starting on boot is table stakes. But nope — I'd boot up, try to run &lt;code&gt;docker ps&lt;/code&gt;, get a "cannot connect to daemon" error, and have to go find Docker Desktop in my app launcher and start it manually. Like it's 2012 and I'm starting a service by hand.&lt;/p&gt;

&lt;p&gt;Compare that to the Docker Engine installed natively:&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;sudo &lt;/span&gt;systemctl &lt;span class="nb"&gt;enable &lt;/span&gt;docker
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl start docker
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Done. Starts on boot. Every time. No manual launching. No GUI to hunt down. It just works. Because it's a system service, not a desktop app cosplaying as one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Podman is right there
&lt;/h2&gt;

&lt;p&gt;While I was figuring all this out, I also realized Podman Desktop exists. And it does basically the same thing Docker Desktop does — a GUI for managing containers, images, volumes — but it uses Podman under the hood. Which is daemonless. Which means even less overhead.&lt;/p&gt;

&lt;p&gt;Podman Desktop on my machine: about 800MB idle. Docker Desktop: 4GB. Same machine. Same workload (nothing). Five times the memory for the same thing.&lt;/p&gt;

&lt;p&gt;Now, I still use Docker for most things. My docker-compose files, my CI pipelines, my multi-arch builds — they're all Docker. I'm not saying everyone should switch to Podman. I'm saying if you're on Linux and you're using Docker Desktop, you're paying a VM tax for no reason. The engine is already there.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I actually run
&lt;/h2&gt;

&lt;p&gt;Here's my setup. Pick your distro:&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;# Fedora / RHEL&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;dnf &lt;span class="nb"&gt;install &lt;/span&gt;docker-ce docker-ce-cli containerd.io docker-compose-plugin

&lt;span class="c"&gt;# Ubuntu / Debian&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;apt &lt;span class="nb"&gt;install &lt;/span&gt;docker-ce docker-ce-cli containerd.io docker-compose-plugin

&lt;span class="c"&gt;# Then for both:&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl &lt;span class="nb"&gt;enable&lt;/span&gt; &lt;span class="nt"&gt;--now&lt;/span&gt; docker
&lt;span class="nb"&gt;sudo &lt;/span&gt;usermod &lt;span class="nt"&gt;-aG&lt;/span&gt; docker &lt;span class="nv"&gt;$USER&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's it. No Docker Desktop. No VM. No 4GB idle. No manual launching. &lt;code&gt;docker&lt;/code&gt; commands work. &lt;code&gt;docker compose&lt;/code&gt; works. Everything I need.&lt;/p&gt;

&lt;p&gt;(If Docker's repos aren't set up yet: &lt;a href="https://docs.docker.com/engine/install/fedora/" rel="noopener noreferrer"&gt;Fedora guide&lt;/a&gt; or &lt;a href="https://docs.docker.com/engine/install/ubuntu/" rel="noopener noreferrer"&gt;Ubuntu guide&lt;/a&gt;. It's three commands.)&lt;/p&gt;

&lt;p&gt;And if I want a GUI for browsing images or checking logs? Podman Desktop. It connects to Docker's socket just fine. Best of both worlds: Docker engine under the hood (because that's what my CI and my compose files expect), Podman Desktop when I want a visual overview, and neither one is eating my RAM for breakfast.&lt;/p&gt;

&lt;h2&gt;
  
  
  Do you actually need Docker Desktop?
&lt;/h2&gt;

&lt;p&gt;If you're on Windows or macOS: probably yes. Docker Engine needs a Linux kernel, and Docker Desktop provides that VM. It's doing real work on those platforms.&lt;/p&gt;

&lt;p&gt;If you're on Linux: probably no. The engine runs natively. You don't need a VM. You don't need a GUI (the CLI is better once you learn 10 commands). And if you occasionally want a GUI, Podman Desktop is lighter and free.&lt;/p&gt;

&lt;p&gt;Docker Desktop on Linux is like installing a virtual machine to run Firefox. Firefox already runs on Linux. Just install Firefox.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 10 commands you actually need
&lt;/h2&gt;

&lt;p&gt;If the CLI is what's holding you back, here's what covers 90% of daily use. It's not that many:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker ps                 &lt;span class="c"&gt;# what's running?&lt;/span&gt;
docker ps &lt;span class="nt"&gt;-a&lt;/span&gt;              &lt;span class="c"&gt;# what exists (including stopped)?&lt;/span&gt;
docker logs &amp;lt;container&amp;gt;   &lt;span class="c"&gt;# what's it saying?&lt;/span&gt;
docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt;      &lt;span class="c"&gt;# start my stack&lt;/span&gt;
docker compose down       &lt;span class="c"&gt;# stop my stack&lt;/span&gt;
docker compose pull       &lt;span class="c"&gt;# update images&lt;/span&gt;
docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;--build&lt;/span&gt;  &lt;span class="c"&gt;# rebuild and restart&lt;/span&gt;
docker &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-it&lt;/span&gt; &amp;lt;container&amp;gt; sh  &lt;span class="c"&gt;# get a shell inside&lt;/span&gt;
docker system prune &lt;span class="nt"&gt;-a&lt;/span&gt;    &lt;span class="c"&gt;# clean up everything unused&lt;/span&gt;
docker stats              &lt;span class="c"&gt;# what's eating my RAM?&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Ten commands. You'll use maybe five of them regularly. The GUI is nice to have but not worth 4GB.&lt;/p&gt;

&lt;h2&gt;
  
  
  Just try it
&lt;/h2&gt;

&lt;p&gt;Remove Docker Desktop. Install &lt;code&gt;docker-ce&lt;/code&gt; and the compose plugin. Systemctl enable it. See if you miss the GUI. If you do, install Podman Desktop. You can always reinstall Docker Desktop — but I don't think you will.&lt;/p&gt;

&lt;p&gt;I haven't opened Docker Desktop in over a year. My containers run fine. My RAM is happier. My machine boots and Docker is just... there. Working. Like it should.&lt;/p&gt;

</description>
      <category>docker</category>
      <category>podman</category>
      <category>devops</category>
      <category>linux</category>
    </item>
    <item>
      <title>How I Built a Real-Time DDoS Detection Engine That Learns What Normal Looks Like</title>
      <dc:creator>Antony Nyagah</dc:creator>
      <pubDate>Wed, 29 Apr 2026 22:56:24 +0000</pubDate>
      <link>https://dev.to/nyagah/how-i-built-a-real-time-ddos-detection-engine-that-learns-what-normal-looks-like-2fei</link>
      <guid>https://dev.to/nyagah/how-i-built-a-real-time-ddos-detection-engine-that-learns-what-normal-looks-like-2fei</guid>
      <description>&lt;p&gt;Imagine your web server suddenly starts getting hammered by thousands of requests from one IP address. Without any protection, your server slows down, legitimate users get blocked out, and you have no idea it's even happening until it's too late. That's the problem I set out to solve by building a real-time anomaly detection engine alongside a Nextcloud server. The tool watches every single HTTP request coming through Nginx, learns what "normal" traffic looks like over time, and automatically blocks attackers using iptables — the Linux firewall built into every server. No third-party tools, no Fail2Ban, just pure detection logic built from scratch in Python.&lt;/p&gt;

&lt;p&gt;The core of the system is two ideas working together: a sliding window and a rolling baseline. The sliding window is a Python &lt;code&gt;deque&lt;/code&gt; that holds the timestamps of every request in the last 60 seconds — one per IP, one globally. When a new request arrives, its timestamp is added to the back; anything older than 60 seconds is evicted from the front. Dividing the count by 60 gives the current requests-per-second rate. The baseline is a 30-minute rolling average of per-second rates, recalculated every 60 seconds, with separate slots for each hour of the day so the system knows that midnight traffic is naturally lighter than noon. When a new request comes in, the detector computes a z-score: &lt;code&gt;(current_rate - mean) / stddev&lt;/code&gt;. If that score exceeds 3.0, or the rate is more than 5 times the baseline mean, the IP is flagged as an attacker. The response is immediate — the system runs &lt;code&gt;iptables -I INPUT -s &amp;lt;attacker_ip&amp;gt; -j DROP&lt;/code&gt;, which tells the Linux kernel to silently drop every packet from that IP at the network level, before it even reaches the application. A Slack alert fires within seconds, and the ban is automatically lifted on a backoff schedule: 10 minutes for the first offense, 30 minutes for the second, 2 hours for the third, and permanent after that.&lt;/p&gt;

</description>
      <category>cybersecurity</category>
      <category>linux</category>
      <category>monitoring</category>
      <category>python</category>
    </item>
    <item>
      <title>How to Leverage Git and AI to View Your Achievements as a Developer</title>
      <dc:creator>Antony Nyagah</dc:creator>
      <pubDate>Wed, 30 Oct 2024 11:00:00 +0000</pubDate>
      <link>https://dev.to/nyagah/how-to-leverage-git-and-ai-to-view-your-achievements-as-a-developer-26f2</link>
      <guid>https://dev.to/nyagah/how-to-leverage-git-and-ai-to-view-your-achievements-as-a-developer-26f2</guid>
      <description>&lt;h2&gt;
  
  
  Introduction
&lt;/h2&gt;

&lt;p&gt;As developers, we often move from one task to the next without much time to reflect. But periodically assessing what you’ve accomplished can be a great motivator and help you communicate your value to teams, managers, or potential employers. &lt;br&gt;
Here’s a simple approach to using your Git commit history to track your achievements over time, with the help of AI to make sense of it all.&lt;/p&gt;
&lt;h2&gt;
  
  
  Step 1: Filter Your Git Logs
&lt;/h2&gt;

&lt;p&gt;The first step is to look back at your commits over the past year (or a specific timeframe). Git makes it easy to search through your history by date and author. This command will give you a list of commits from the past year, authored by you:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git log &lt;span class="nt"&gt;--since&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"1 year ago"&lt;/span&gt; &lt;span class="nt"&gt;--author&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"your_email@example.com"&lt;/span&gt; &lt;span class="nt"&gt;--pretty&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;format:&lt;span class="s2"&gt;"%h - %an, %ar : %s"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here’s a breakdown of this command:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;--since="1 year ago"&lt;/code&gt; limits results to commits from the past year.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;--author="your_email@example.com"&lt;/code&gt; filters commits to only those you made.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;--pretty=format:"%h - %an, %ar : %s"&lt;/code&gt; displays commit hash, author, relative date, and message.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Here's an example of what my logs looked like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;8abe2e3 - Antony Nyagah, 5 days ago : feat(dashboard/*): add link for downloading packaging list
0409dac - Antony Nyagah, 5 days ago : feat(dashboard/*): add a link for downloading delivery notes from the frontend
0c199f9 - Antony Nyagah, 5 days ago : test(api/*): update tests for document_exporter app
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 2: Export Your Commit Log
&lt;/h2&gt;

&lt;p&gt;For easier analysis, you can export your commit history to a text file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git log &lt;span class="nt"&gt;--since&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"1 year ago"&lt;/span&gt; &lt;span class="nt"&gt;--author&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"your_email@example.com"&lt;/span&gt; &lt;span class="nt"&gt;--pretty&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;format:&lt;span class="s2"&gt;"%h - %an, %ar : %s"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; commit_log.txt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 3: Leverage AI for a Detailed Analysis
&lt;/h2&gt;

&lt;p&gt;After exporting the commit log, I pasted the commits into ChatGPT(you can use any number of AI tool that are out there) to get a detailed summary and insights into the types of tasks I’ve accomplished.&lt;/p&gt;

&lt;p&gt;The prompt I used was "Can you look at these commits and help me come up with a list of my achievements throughout this year?". The prompt itself can be improved 😅&lt;/p&gt;

&lt;p&gt;This helped me uncover trends and group my work into categories like:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Infrastructure &amp;amp; CI/CD Enhancements&lt;/li&gt;
&lt;li&gt;Frontend Development &amp;amp; Refactoring&lt;/li&gt;
&lt;li&gt;Backend API Development &amp;amp; Maintenance&lt;/li&gt;
&lt;li&gt;Database and Data Handling Improvements&lt;/li&gt;
&lt;li&gt;Testing and Code Quality&lt;/li&gt;
&lt;li&gt;Documentation &amp;amp; Project Management&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;ChatGPT’s analysis made it easy to see where I added value and what skills I honed over the past year, even bringing out details I might have overlooked. By using AI this way, you can turn your commit log into a meaningful narrative about your contributions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: Keeping Commit Messages Meaningful
&lt;/h2&gt;

&lt;p&gt;To make sure my commit messages are succinct and relevant, I personally use &lt;a href="https://commitizen-tools.github.io/commitizen/" rel="noopener noreferrer"&gt;Commitizen&lt;/a&gt;. This tool helps maintain consistency by following a commit message convention that describes the "why" and "what" of each change.&lt;/p&gt;

&lt;p&gt;Meaningful commit messages make logs easier to review, and there are other specifications and tools like &lt;a href="https://www.conventionalcommits.org/" rel="noopener noreferrer"&gt;Conventional Commits&lt;/a&gt; and &lt;a href="https://typicode.github.io/husky/" rel="noopener noreferrer"&gt;Husky&lt;/a&gt; that can also keep your commit history clean and purposeful.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: Summarize and Reflect
&lt;/h2&gt;

&lt;p&gt;Reviewing your contributions with AI insights can be both motivating and insightful. Write a brief summary for each category. This can serve as a reference for future performance reviews, portfolio updates, or simply as a reminder of your growth as a developer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Final Thoughts
&lt;/h2&gt;

&lt;p&gt;Using Git as a reflection tool is quick but effective. And with the added support of AI, you can see the big picture more clearly, connecting technical achievements with your overall development. Keep this record for yourself or share it in your performance reviews or job applications.&lt;/p&gt;

</description>
      <category>git</category>
      <category>ai</category>
      <category>codequality</category>
      <category>career</category>
    </item>
  </channel>
</rss>
