<?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: Vicente G. Reyes</title>
    <description>The latest articles on DEV Community by Vicente G. Reyes (@highcenburg).</description>
    <link>https://dev.to/highcenburg</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%2F126345%2F09607ce3-52e6-4252-be98-7a84d754bc60.png</url>
      <title>DEV Community: Vicente G. Reyes</title>
      <link>https://dev.to/highcenburg</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/highcenburg"/>
    <language>en</language>
    <item>
      <title>ScopeGuard: an open-weight model that tells freelancers when "quick one" means "paid one"</title>
      <dc:creator>Vicente G. Reyes</dc:creator>
      <pubDate>Thu, 15 Oct 2026 09:11:27 +0000</pubDate>
      <link>https://dev.to/highcenburg/scopeguard-an-open-weight-model-that-tells-freelancers-when-quick-one-means-paid-one-3o7c</link>
      <guid>https://dev.to/highcenburg/scopeguard-an-open-weight-model-that-tells-freelancers-when-quick-one-means-paid-one-3o7c</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/challenges/hacktoberfest-week2-2026-10-12"&gt;Hacktoberfest Open-Source AI Challenge Week 2: Side Hustle&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

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

&lt;p&gt;Every freelancer knows this message:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Hey! Quick one: could you also add a shop page so we can sell merch? Shouldn't take long 🙏"&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Is a shop page in the contract? You'd have to dig out the statement of work (SOW), find the clause, work out how many hours it is, and then write a reply that says no without souring a client you like. Most of us skip that, say "sure", and do the work for free. Do that a few times a month and it adds up to real money. That's &lt;strong&gt;scope creep&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;ScopeGuard&lt;/strong&gt; is a small web app that does the awkward part for you:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;You save a client's SOW and your hourly rate. Paste it in, or import the PDF or Word file the client signed.&lt;/li&gt;
&lt;li&gt;You paste in a client request from email, Slack, a ticket, or your call notes.&lt;/li&gt;
&lt;li&gt;An open-weight model (&lt;strong&gt;gpt-oss-120b&lt;/strong&gt; by default, served through &lt;strong&gt;Groq&lt;/strong&gt;) gives you:

&lt;ul&gt;
&lt;li&gt;a verdict: &lt;strong&gt;in scope&lt;/strong&gt;, &lt;strong&gt;out of scope&lt;/strong&gt;, or &lt;strong&gt;ambiguous&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;the &lt;strong&gt;exact SOW clause&lt;/strong&gt; it based that on&lt;/li&gt;
&lt;li&gt;an &lt;strong&gt;estimate of the extra hours&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;a &lt;strong&gt;draft reply&lt;/strong&gt; ready to send: a short confirmation if the work is covered, a polite change-order offer with a price if it isn't, or clarifying questions if the SOW is unclear&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A dashboard adds up the &lt;strong&gt;scope creep caught&lt;/strong&gt; across all your contracts. In other words, the billable work you would otherwise have done for free.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Who pays for it?&lt;/strong&gt; Solo freelancers and small agencies on fixed-scope projects or retainers: developers, designers, copywriters, video editors. If ScopeGuard catches even one out-of-scope request a year, a $12/month subscription would pay for itself. And because a check on a shared open-weight model costs about a twentieth of a cent (numbers below), a $12 price leaves plenty of margin.&lt;/p&gt;

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

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Live link:&lt;/strong&gt;  &lt;code&gt;https://scopeguard.icvn.tech&lt;/code&gt;&lt;br&gt;
&lt;strong&gt;Video Demo from Vidyard:&lt;/strong&gt; &lt;code&gt;https://share.vidyard.com/watch/7DrBCnxdrSMVdPe4GAkHLV&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The flow in the demo:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;New contract.&lt;/strong&gt; Fill in the client and your rate, then paste the SOW or click &lt;strong&gt;Import from file&lt;/strong&gt; to pull the text out of a PDF or &lt;code&gt;.docx&lt;/code&gt;. You review the text before saving.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Contract page.&lt;/strong&gt; The SOW stays pinned on the left ("What you agreed to"). On the right are the request form and a feed of checked requests that you can filter by verdict.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Each result card&lt;/strong&gt; shows the verdict badge and the model's confidence. It also shows the quoted clause with a &lt;strong&gt;"Quote verified"&lt;/strong&gt; or &lt;strong&gt;"Not found in SOW, double-check"&lt;/strong&gt; badge, the hours and the change-order price, and the draft reply with a copy button. A footer lists the model, the latency, and the token counts.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Screenshots:&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%2Fxpn7wwthh7g0kb0lmnkb.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%2Fxpn7wwthh7g0kb0lmnkb.png" alt="Landing Page" width="800" height="408"&gt;&lt;/a&gt;&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%2F8jgmxe4acvxz0vz8idyq.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%2F8jgmxe4acvxz0vz8idyq.png" alt="Scope Guard Dashboard" width="799" height="405"&gt;&lt;/a&gt;&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%2Fmrbrlcwa9k4f4ugoqxio.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%2Fmrbrlcwa9k4f4ugoqxio.png" alt="Dashboard" width="799" height="405"&gt;&lt;/a&gt;&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%2F4ac6awuuphixj38fzmf7.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%2F4ac6awuuphixj38fzmf7.png" alt="Contract Page" width="799" height="405"&gt;&lt;/a&gt;&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%2Fi84hkqy5rfhwkunowfks.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%2Fi84hkqy5rfhwkunowfks.png" alt="Contract Detail Page" width="799" height="405"&gt;&lt;/a&gt;&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%2Frae5jcbh78ytkaq7o26g.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%2Frae5jcbh78ytkaq7o26g.png" alt="Contract Quote Details" width="799" height="405"&gt;&lt;/a&gt;&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%2Fp6h2agxc81bxxr7tfugy.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%2Fp6h2agxc81bxxr7tfugy.png" alt="Contract Scope" width="799" height="405"&gt;&lt;/a&gt;&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%2Fj822uf2myuotuyj1h7jc.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%2Fj822uf2myuotuyj1h7jc.png" alt="Sentry" width="799" height="388"&gt;&lt;/a&gt;&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/reyesvicente" rel="noopener noreferrer"&gt;
        reyesvicente
      &lt;/a&gt; / &lt;a href="https://github.com/reyesvicente/ScopeGuard" rel="noopener noreferrer"&gt;
        ScopeGuard
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      Scope-creep detector for freelancers, powered by a hosted open-weight model.
    &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;ScopeGuard&lt;/h1&gt;
&lt;/div&gt;
&lt;p&gt;Scope-creep detector for freelancers, powered by a hosted open-weight model.&lt;/p&gt;
&lt;p&gt;&lt;a href="https://github.com/cookiecutter/cookiecutter-django/" rel="noopener noreferrer"&gt;&lt;img src="https://camo.githubusercontent.com/d42c931a8a25783b81ffce5e44612ed96d64c7af511c606d68ba66cffddd9d03/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f6275696c74253230776974682d436f6f6b6965637574746572253230446a616e676f2d6666363962342e7376673f6c6f676f3d636f6f6b6965637574746572" alt="Built with Cookiecutter Django"&gt;&lt;/a&gt;
&lt;a href="https://github.com/astral-sh/ruff" rel="noopener noreferrer"&gt;&lt;img src="https://camo.githubusercontent.com/d6c7524504b7d886a9d34c11f44b9d31b2de1a579325b42e932744c4575a063b/68747470733a2f2f696d672e736869656c64732e696f2f656e64706f696e743f75726c3d68747470733a2f2f7261772e67697468756275736572636f6e74656e742e636f6d2f61737472616c2d73682f727566662f6d61696e2f6173736574732f62616467652f76322e6a736f6e" alt="Ruff"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;License: MIT&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;Save a client's statement of work (SOW) and your hourly rate.&lt;/li&gt;
&lt;li&gt;Paste a client request (email, Slack, ticket, call notes).&lt;/li&gt;
&lt;li&gt;An open-weight model (default: &lt;code&gt;openai/gpt-oss-120b&lt;/code&gt; via &lt;a href="https://console.groq.com/docs/models" rel="nofollow noopener noreferrer"&gt;Groq&lt;/a&gt;) returns a verdict (in scope / out of scope / ambiguous), quotes the SOW clause it relied on, estimates the extra hours, and drafts a reply.&lt;/li&gt;
&lt;li&gt;ScopeGuard checks that the quoted clause really exists in the SOW, and fills in the change-order price itself (the model never does the arithmetic).&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The dashboard totals the out-of-scope hours caught across all your contracts.&lt;/p&gt;
&lt;div class="markdown-heading"&gt;
&lt;h3 class="heading-element"&gt;Guardrails&lt;/h3&gt;
&lt;/div&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Constrained output.&lt;/strong&gt; The request sends a JSON Schema as &lt;code&gt;response_format&lt;/code&gt;, and every field is validated again in &lt;code&gt;scopeguard/scope/services.py&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Code does the math.&lt;/strong&gt; The model writes &lt;code&gt;{{ESTIMATE}}&lt;/code&gt; where a price belongs; Python computes hours × rate.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Verified quotes.&lt;/strong&gt; A cited clause that isn't…&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/reyesvicente/ScopeGuard" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;


&lt;p&gt;MIT-licensed. Django 6 + Django REST Framework on the backend, React 18 + Vite on the frontend, and an open-weight model behind an OpenAI-compatible API for inference.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  The stack
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;Choice&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Model&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;gpt-oss-120b&lt;/strong&gt; (open weights, Apache 2.0) by default; any open model is one setting away&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Inference&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Groq&lt;/strong&gt;, via the OpenAI-compatible &lt;code&gt;/chat/completions&lt;/code&gt; API&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Backend&lt;/td&gt;
&lt;td&gt;Django + DRF, built from cookiecutter-django, PostgreSQL; &lt;code&gt;pypdf&lt;/code&gt; and &lt;code&gt;python-docx&lt;/code&gt; for contract import&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Frontend&lt;/td&gt;
&lt;td&gt;React 18 + Vite + TypeScript, TanStack Query, Zustand, React Hook Form + Zod, my own &lt;code&gt;ice-ds&lt;/code&gt; neubrutalist component library&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Observability&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Sentry&lt;/strong&gt;: errors, request traces, and AI agent spans for every scope check (model, tokens, latency)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hosting&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Render&lt;/strong&gt; for everything: one Docker web service serves the React app and the API on &lt;code&gt;scopeguard.icvn.tech&lt;/code&gt;, plus Postgres and Key Value&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Why it isn't self-hosted (anymore)
&lt;/h3&gt;

&lt;p&gt;The first version ran Gemma through Ollama as a container next to Django. That works well on a laptop. Then I priced hosting it: CPU-only inference for a small model with a context window big enough for a real SOW needed Render's Pro Plus plan, about &lt;strong&gt;$175 a month&lt;/strong&gt;, before a single user signed up. For a $12 side hustle, that's backwards.&lt;/p&gt;

&lt;p&gt;So inference moved to a hosted provider that serves open weights. The model is still open, but now someone else's GPUs are shared across thousands of apps, and I pay per token. Hosting ScopeGuard now costs about what any small Django app costs.&lt;/p&gt;

&lt;p&gt;The client didn't need an SDK either way. Django talks to the API over plain HTTP with only the Python standard library, and the whole provider choice is three env vars:&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;LLM_BASE_URL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;https://api.groq.com/openai/v1
&lt;span class="nv"&gt;LLM_MODEL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;openai/gpt-oss-120b
&lt;span class="nv"&gt;LLM_API_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;gsk_...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I've pointed it at Hugging Face Inference Providers and at Groq. Together, OpenRouter, and vLLM or Ollama on your own hardware speak the same API, so they should take the same three variables, but I haven't tested them.&lt;/p&gt;

&lt;p&gt;The switch between providers happened sooner than I planned. ScopeGuard started on Hugging Face, and partway through testing a check came back with this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Model API returned 402: {"error":"You have no remaining credits. Purchase pre-paid credits to continue using Inference Providers. ..."}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The free monthly credit was gone. With a closed model, that's where the afternoon ends: pay up or rewrite the integration. Here, the same &lt;code&gt;gpt-oss-120b&lt;/code&gt; weights are also served by Groq, so I changed the base URL and the key. It wasn't &lt;em&gt;quite&lt;/em&gt; zero code: Groq rejected the first requests for two reasons (see the first gotcha below), and each was a one-line fix. Then the demo contract's requests came back right:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Client request&lt;/th&gt;
&lt;th&gt;Verdict&lt;/th&gt;
&lt;th&gt;Confidence&lt;/th&gt;
&lt;th&gt;Cited clause&lt;/th&gt;
&lt;th&gt;Time&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;"Could you also add a shop page so we can sell bags of our beans and some merch online?"&lt;/td&gt;
&lt;td&gt;out of scope&lt;/td&gt;
&lt;td&gt;0.98&lt;/td&gt;
&lt;td&gt;5.1, quote verified&lt;/td&gt;
&lt;td&gt;1.6 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;"Can we make sure cafés can book a tasting slot straight from the site? We already use Calendly for that."&lt;/td&gt;
&lt;td&gt;in scope&lt;/td&gt;
&lt;td&gt;0.98&lt;/td&gt;
&lt;td&gt;2.3, quote verified&lt;/td&gt;
&lt;td&gt;2.2 s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Each check used about 1,050–1,070 prompt tokens and 430–490 completion tokens. At Groq's listed price for this model (around $0.15 per million input tokens and $0.60–0.75 per million output), that's roughly $0.0005 a check.&lt;/p&gt;

&lt;h3&gt;
  
  
  Importing the contract instead of pasting it
&lt;/h3&gt;

&lt;p&gt;Nobody keeps their SOW as plain text. It's the PDF the client signed or a Word doc with the scope in a table. So the contract form has an &lt;strong&gt;Import from file&lt;/strong&gt; button that accepts PDF, &lt;code&gt;.docx&lt;/code&gt;, TXT, and Markdown up to 10 MB.&lt;/p&gt;

&lt;p&gt;The API reads the file, returns the text, and &lt;strong&gt;throws the file away&lt;/strong&gt;. Only the text you review and save is kept. For documents that are often under NDA, not keeping a copy is the simplest privacy feature there is.&lt;/p&gt;

&lt;p&gt;Two details mattered more than I expected:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Word tables.&lt;/strong&gt; SOWs love tables ("Deliverable | Included? | Notes"). Reading only paragraphs silently drops them, so the extractor walks paragraphs &lt;em&gt;and&lt;/em&gt; tables in document order.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scanned PDFs.&lt;/strong&gt; A scanned contract is just images, so there's no text to extract. Instead of saving an empty SOW, the import says so: &lt;em&gt;"No text found in this file. Scanned PDFs aren't supported yet, so paste the text instead."&lt;/em&gt; Password-protected and damaged files get their own messages too.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Making a model reliable enough to send to a client
&lt;/h3&gt;

&lt;p&gt;A model's reply ends up in an email to a paying client, so most of the engineering went into &lt;strong&gt;not trusting it more than necessary&lt;/strong&gt;. Four decisions did most of the work.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Constrained JSON, not "please respond in JSON".&lt;/strong&gt; The request sends a JSON Schema in &lt;code&gt;response_format&lt;/code&gt;, and providers that support structured output constrain decoding to match it. The model &lt;em&gt;cannot&lt;/em&gt; return a verdict outside the enum:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;VERDICT_SCHEMA&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;object&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;properties&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;verdict&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;string&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;enum&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;in_scope&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;out_of_scope&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ambiguous&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]},&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;confidence&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;number&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;reasoning&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;string&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cited_clause&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;string&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;estimated_hours&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;number&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;draft_reply&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;string&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;required&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;verdict&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;confidence&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;reasoning&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                 &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cited_clause&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;estimated_hours&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;draft_reply&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;additionalProperties&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&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;The service still validates every field afterwards. Confidence is clamped to 0–1, hours are clamped and rounded, and any unknown verdict marks the check &lt;code&gt;failed&lt;/code&gt; rather than storing garbage.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Never let the model do the math.&lt;/strong&gt; Language models are bad at arithmetic, and a wrong number on a change order damages your credibility with the client. So the prompt tells the model to write the literal token &lt;code&gt;{{ESTIMATE}}&lt;/code&gt; wherever the price belongs, and Python fills it in:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;fill_estimate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;reply&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&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="n"&gt;hours&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Decimal&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;contract&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Contract&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Prices are computed here, never by the model, so the math is always right.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;ESTIMATE_PLACEHOLDER&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;reply&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;reply&lt;/span&gt;
    &lt;span class="n"&gt;cost&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;estimate_cost&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hours&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;hours&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;contract&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;contract&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;hours&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;cost&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;cost&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;a quote to follow&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;rate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;contract&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;hourly_rate&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;,.&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/hr&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;contract&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;currency&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;cost&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;,.&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; (&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;hours&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;normalize&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; hours at &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;rate&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;reply&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ESTIMATE_PLACEHOLDER&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The model does the judgment ("about 12 hours of work"). Code does the arithmetic ("USD 960.00").&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Check the model's quotes.&lt;/strong&gt; The quoted clause is what makes a reply convincing ("per section 3, e-commerce isn't included"). It's also the part a model is most likely to make up. So every &lt;code&gt;cited_clause&lt;/code&gt; is checked against the real SOW, ignoring whitespace and case:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;clause_in_sow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;clause&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sow_text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Guard against hallucinated citations: the quote must exist in the SOW.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;clause&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;_normalize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;clause&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;“”&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;_normalize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sow_text&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 quote isn't found, the UI says so with a warning badge instead of quietly showing an invented contract term. Ignoring whitespace also matters for imported contracts, because PDF text comes out with line breaks wherever the page wrapped.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Fail loudly, not with a 500.&lt;/strong&gt; If the API is down, rejects the key, runs out of credits, times out, or returns broken output, the check is saved with &lt;code&gt;verdict = failed&lt;/code&gt; and a readable error, and the card offers a &lt;strong&gt;Re-run&lt;/strong&gt; button. That's exactly how the 402 above showed up: as a red card with the provider's message, not a crash.&lt;/p&gt;

&lt;h3&gt;
  
  
  Gotcha 1: "OpenAI-compatible" isn't one standard
&lt;/h3&gt;

&lt;p&gt;Every provider says "OpenAI-compatible". My first two requests to Groq still failed, and neither failure was about the model.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;First, a 403 with nothing but &lt;code&gt;error code: 1010&lt;/code&gt;.&lt;/strong&gt; That isn't Groq; it's Cloudflare in front of Groq, and 1010 means "your client's signature is blocked". My stdlib client sent urllib's default &lt;code&gt;User-Agent: Python-urllib/3.14&lt;/code&gt;, which Cloudflare treats as a bot. The fix is one header:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;USER_AGENT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ScopeGuard/1.0 (+https://github.com/reyesvicente/ScopeGuard)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Second, a 400 about the schema:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;invalid JSON schema for response_format: 'scope_verdict':
`additionalProperties:false` must be set on every object
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Groq's strict mode requires every object to forbid extra keys, which is also what OpenAI's strict structured outputs require. One more line in the schema, shown above.&lt;/p&gt;

&lt;p&gt;Neither of these showed up in the mocked tests, because the mock never sees a User-Agent or validates a schema. If you build on "OpenAI-compatible" APIs so you can switch providers, run one real request against each provider before you trust it. The client also strips a markdown fence around the JSON and rejects anything that isn't a JSON object, as a guard in case another provider is looser than these two.&lt;/p&gt;

&lt;h3&gt;
  
  
  Gotcha 2: a "secure" default that broke every save
&lt;/h3&gt;

&lt;p&gt;The first time I saved a contract through the React app, Django answered &lt;code&gt;403 Forbidden&lt;/code&gt;, and the form showed nothing at all.&lt;/p&gt;

&lt;p&gt;Two bugs were stacked. The settings had &lt;code&gt;CSRF_COOKIE_HTTPONLY = True&lt;/code&gt;, which sounds like hardening. But a single-page app has to &lt;em&gt;read&lt;/em&gt; the CSRF cookie in JavaScript to send it back as the &lt;code&gt;X-CSRFToken&lt;/code&gt; header, so every write went out with an empty token. Django's own docs say HttpOnly on the CSRF cookie gives no practical benefit, so I turned it off.&lt;/p&gt;

&lt;p&gt;The second bug was why the failure was invisible. The 403 body was &lt;code&gt;{"detail": "CSRF Failed: ..."}&lt;/code&gt;, and the frontend treated every key in the error object as a form field. There's no field called &lt;code&gt;detail&lt;/code&gt;, so the message was dropped, and because "field errors" existed, the general error banner stayed hidden too. Now &lt;code&gt;detail&lt;/code&gt; and &lt;code&gt;non_field_errors&lt;/code&gt; always surface as a banner, and a CSRF failure fetches a fresh token and retries once.&lt;/p&gt;

&lt;p&gt;The tests hadn't caught it because DRF's &lt;code&gt;force_authenticate&lt;/code&gt; skips CSRF entirely. There's now a test that does what the browser does: load &lt;code&gt;/api/users/me/&lt;/code&gt;, read the cookie, and send it back with the write.&lt;/p&gt;

&lt;h3&gt;
  
  
  One domain, one service
&lt;/h3&gt;

&lt;p&gt;The first deploy split the app: the React build on Vercel at &lt;code&gt;scopeguard.icvn.tech&lt;/code&gt;, and the API on Render at &lt;code&gt;api.scopeguard.icvn.tech&lt;/code&gt;. To keep the browser off two origins (CORS with credentials, shared cookie domains, more CSRF configuration), &lt;code&gt;vercel.json&lt;/code&gt; rewrote &lt;code&gt;/api/*&lt;/code&gt;, &lt;code&gt;/accounts/*&lt;/code&gt;, and &lt;code&gt;/static/*&lt;/code&gt; to the API host. It worked, but it was two platforms, two DNS records, and a proxy hop for every request. Django also thought it was on the &lt;code&gt;api.&lt;/code&gt; host, so the account adapter had to rewrite links in verification and password-reset emails to the public domain.&lt;/p&gt;

&lt;p&gt;The production Docker image already had a Node stage that builds the frontend into Django's static files, and a view that serves &lt;code&gt;index.html&lt;/code&gt; for the app's routes. So I dropped Vercel and pointed &lt;code&gt;scopeguard.icvn.tech&lt;/code&gt; straight at the Render service. Now there's one deploy, one origin, and no proxy.&lt;/p&gt;

&lt;p&gt;One gotcha: Render's own domains already sit behind Cloudflare, so my Cloudflare CNAME has to be &lt;strong&gt;DNS only&lt;/strong&gt;. With the orange-cloud proxy on, Cloudflare refuses to proxy to another Cloudflare IP and shows &lt;em&gt;Error 1000: DNS points to prohibited IP&lt;/em&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Tracing the agent with Sentry
&lt;/h3&gt;

&lt;p&gt;A scope check is one agent step: build a prompt from the SOW, call the model, validate the structured verdict, check the quote, and draft the reply. When a check is slow or wrong, I want to know which part went wrong, so each check is traced in Sentry as two nested spans that follow Sentry's AI agent conventions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;gen_ai.invoke_agent&lt;/code&gt; wraps the whole check and records the verdict, whether the cited clause was found in the SOW, and where the request came from. A failed check marks the span as an error.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;gen_ai.chat&lt;/code&gt; wraps the model call and records the model, input and output tokens, and latency. Sentry's AI Agents view turns those into per-model latency and token charts.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;sentry_sdk&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;start_span&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;op&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;gen_ai.chat&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;chat &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;settings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;LLM_MODEL&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;span&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;span&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set_data&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;gen_ai.request.model&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;settings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;LLM_MODEL&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_chat_json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;system&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;system&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;schema&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;span&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set_data&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;gen_ai.usage.input_tokens&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;prompt_tokens&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;span&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set_data&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;gen_ai.usage.output_tokens&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;completion_tokens&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The spans deliberately leave out prompts and replies. SOWs are often under NDA, so the trace says &lt;em&gt;how&lt;/em&gt; a check went without storing &lt;em&gt;what&lt;/em&gt; the contract says. Sentry is optional: with no &lt;code&gt;SENTRY_DSN&lt;/code&gt;, the spans do nothing.&lt;/p&gt;

&lt;h3&gt;
  
  
  Tests
&lt;/h3&gt;

&lt;p&gt;The scope app has 37 tests:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the model client: request shape, auth and User-Agent headers, fenced JSON, non-JSON output, missing key, HTTP errors&lt;/li&gt;
&lt;li&gt;contract import: PDF, Word tables, text encodings, scanned and damaged files, unsupported types, size limits&lt;/li&gt;
&lt;li&gt;services: unique slugs, verdict storage and pricing, hallucinated quotes, unknown verdicts, the model server being down&lt;/li&gt;
&lt;li&gt;the API: auth, the response envelope, per-user privacy, pagination, filtering, re-runs&lt;/li&gt;
&lt;li&gt;the browser's real CSRF flow&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Five more cover running behind a host that isn't the public one: the email link rewriting, and a write arriving with one host but another origin. The single-domain deploy doesn't need them today, but they keep the split setup an option. The network is mocked, so the tests run in seconds in CI with no API key. Ruff and mypy are clean. The mocks are also why the two Groq bugs got through: only a real request found them.&lt;/p&gt;

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

&lt;p&gt;Moving off self-hosting changed &lt;em&gt;where&lt;/em&gt; the model runs. It didn't change why it's an open model.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. No lock-in on the most important part.&lt;/strong&gt; The model and the provider are three env vars. If a provider raises prices, rate-limits me, runs out of free credits, or shuts down, I move to another one that serves the same weights, and the verdicts don't change underneath my users. That isn't hypothetical: it happened during this build. It took two env vars and two one-line fixes, and the fixes now work for every provider. With a closed model, there is one vendor, and when it deprecates a model version, your product's behavior changes on their schedule.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Privacy has an exit.&lt;/strong&gt; A freelancer's SOWs and client messages are often under NDA. Uploaded contract files are never stored. Today the default model host is a shared provider, which is the right trade for a $12 product. But because the weights are open, a freelancer or agency that can't send contracts to a third party can run the same model on their own hardware with vLLM or Ollama, point &lt;code&gt;LLM_BASE_URL&lt;/code&gt; at it, and get the same app. A closed API can only promise not to train on your data.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Competition keeps the pricing honest.&lt;/strong&gt; Every check sends the &lt;em&gt;entire SOW&lt;/em&gt; as context, so the heaviest users (long contracts, chatty clients) cost the most to serve. Open weights mean many providers serve the same model and compete on price per token. That keeps a check around a twentieth of a cent today, and the &lt;code&gt;30/hour&lt;/code&gt; per-user throttle caps the worst case.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Room to specialize.&lt;/strong&gt; "Is this billable?" is a niche judgment that general-purpose models handle with generic caution. The next feature on my list is letting users correct a verdict, and every correction would be a labeled example. Open weights mean those examples could fine-tune a model that's &lt;em&gt;better at this one task&lt;/em&gt; than a much bigger general model. You can't do that with a closed model.&lt;/p&gt;

&lt;p&gt;The honest trade-off: hosted inference means contracts leave your server by default. ScopeGuard is upfront about that, and the open model is what makes "run it yourself" a real option instead of a promise.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prize Categories
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Best Use of a .tech Domain&lt;/strong&gt;: It's live on &lt;code&gt;scopeguard.icvn.tech&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Best Use of Sentry Agent Tracing&lt;/strong&gt;: Every scope check is traced as a &lt;code&gt;gen_ai.invoke_agent&lt;/code&gt; span with a nested &lt;code&gt;gen_ai.chat&lt;/code&gt; span. The trace records the model, tokens, latency, and the verdict, but not the contract text.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Best Use of Render&lt;/strong&gt;: The whole product runs on Render: the React front end and Django API in one Docker web service, plus Postgres and Key Value. The service is the agent's runtime: it builds the prompt from the SOW, calls the open-weight model, validates the structured verdict, and drafts the change order.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>devchallenge</category>
      <category>hf26challenge</category>
      <category>opensource</category>
      <category>ai</category>
    </item>
    <item>
      <title>Is SaaS dead? Are we just building software for ourselves?</title>
      <dc:creator>Vicente G. Reyes</dc:creator>
      <pubDate>Mon, 12 Oct 2026 10:58:49 +0000</pubDate>
      <link>https://dev.to/highcenburg/is-saas-dead-are-we-just-building-software-for-ourselves-1k67</link>
      <guid>https://dev.to/highcenburg/is-saas-dead-are-we-just-building-software-for-ourselves-1k67</guid>
      <description>&lt;p&gt;I saw a post on X a few days ago that says SaaS is dead and we just build software for ourselves. What's your take on this? Kinda realized it as real since I just built &lt;a href="https://gigglegigs.com/?ref=devto" rel="noopener noreferrer"&gt;GiggleGigs&lt;/a&gt; initially for others but then realized I should use it since I have free time.&lt;/p&gt;

</description>
      <category>discuss</category>
      <category>software</category>
      <category>coding</category>
    </item>
    <item>
      <title>Tala: Free Audio-to-Text for My Designer Friend (No Signup, No Credit Card)</title>
      <dc:creator>Vicente G. Reyes</dc:creator>
      <pubDate>Sat, 03 Oct 2026 08:10:14 +0000</pubDate>
      <link>https://dev.to/highcenburg/tala-free-audio-to-text-for-my-designer-friend-no-signup-no-credit-card-2o26</link>
      <guid>https://dev.to/highcenburg/tala-free-audio-to-text-for-my-designer-friend-no-signup-no-credit-card-2o26</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/challenges/hacktoberfest-weekend-2026-10-01"&gt;Hacktoberfest Weekend Challenge: Build for a Friend&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

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

&lt;p&gt;Paolo, a designer I work with, asked me a simple question:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Do you know an app that turns audio into text that doesn't make me sign up or put in a credit card?"&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I couldn't name one. Every tool I knew wanted an account, a "free trial" that asked for a card up front, or had a monthly minutes cap. Paolo just wanted to drop in a recording of a client call or a voice memo and get the words back.&lt;/p&gt;

&lt;p&gt;So I built &lt;strong&gt;Tala&lt;/strong&gt; (Tagalog for &lt;em&gt;note&lt;/em&gt; or &lt;em&gt;record&lt;/em&gt;). It does one thing:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Audio in. Text out. No signup, no card, no catch.&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Upload or record:&lt;/strong&gt; drag in a voice memo, meeting, or interview (mp3, m4a, aac, wav, ogg, webm, even mp4 video), or record in the browser and play it back before sending.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;~100 languages:&lt;/strong&gt; auto-detected, including Tagalog/Filipino, which matters for us. There's also a one-click &lt;strong&gt;translate to English&lt;/strong&gt; option.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Exports:&lt;/strong&gt; copy the text, or download plain text, &lt;strong&gt;SRT&lt;/strong&gt;, or &lt;strong&gt;WebVTT&lt;/strong&gt;. Paolo can drop the subtitle files straight into video edits.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Timestamps toggle:&lt;/strong&gt; see when each line was said.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A private link instead of a signup:&lt;/strong&gt; every transcript gets a hard-to-guess URL, and your browser remembers the ones you made.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Optional accounts:&lt;/strong&gt; if you want your transcripts on every device, sign up with an email and password to get a searchable &lt;strong&gt;My transcripts&lt;/strong&gt; history. It's never required to transcribe. Forgot-password emails a one-time reset link.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Self-destructing data:&lt;/strong&gt; the audio is deleted the moment it's transcribed. The text deletes itself after 72 hours, or right away with &lt;strong&gt;Delete now&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;🌐 &lt;strong&gt;Try it live: &lt;a href="https://tala.vicentereyes.org" rel="noopener noreferrer"&gt;tala.vicentereyes.org&lt;/a&gt;&lt;/strong&gt;. No signup, just drop in a file.&lt;/p&gt;

&lt;p&gt;▶️ &lt;strong&gt;&lt;a href="https://share.vidyard.com/watch/qtnjGHRDCkx56fe2cLSBjx" rel="noopener noreferrer"&gt;Watch the demo video&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The flow is three screens:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Home:&lt;/strong&gt; drop a file or hit &lt;em&gt;Record now&lt;/em&gt;, pick a language (or leave it on auto-detect), and click &lt;strong&gt;Turn it into text&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Progress:&lt;/strong&gt; you land on your private link right away. It shows &lt;em&gt;Preparing audio&lt;/em&gt;, then a live progress bar with an estimate of the time left. You can close the tab and come back.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Transcript:&lt;/strong&gt; copy, download TXT/SRT/VTT, toggle timestamps, or delete it.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Code
&lt;/h2&gt;

&lt;p&gt;The full source is on GitHub: &lt;strong&gt;&lt;a href="https://github.com/reyesvicente/tala" rel="noopener noreferrer"&gt;github.com/reyesvicente/tala&lt;/a&gt;&lt;/strong&gt;. Run it locally with Docker and npm by following the README.&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/reyesvicente" rel="noopener noreferrer"&gt;
        reyesvicente
      &lt;/a&gt; / &lt;a href="https://github.com/reyesvicente/tala" rel="noopener noreferrer"&gt;
        tala
      &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;Tala — audio to text, no signup&lt;/h1&gt;
&lt;/div&gt;
&lt;p&gt;Free audio/video → text transcription. No signup needed, no credit card. Built for Paolo for the
DEV Hacktoberfest Weekend Challenge ("Build for a Friend").&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Open-weight model:&lt;/strong&gt; OpenAI Whisper via &lt;a href="https://github.com/SYSTRAN/faster-whisper" rel="noopener noreferrer"&gt;faster-whisper&lt;/a&gt;
(CTranslate2, int8 on CPU). No third-party AI API is called.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Privacy:&lt;/strong&gt; audio is deleted the moment it's transcribed; transcripts self-destruct after
&lt;code&gt;RETENTION_HOURS&lt;/code&gt; (default 72). Access is by unguessable link only.&lt;/li&gt;
&lt;li&gt;Upload a file or record in the browser · auto-detect ~100 languages · translate to English ·
export TXT / SRT / WebVTT.&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Using Tala&lt;/h2&gt;
&lt;/div&gt;
&lt;div class="markdown-heading"&gt;
&lt;h3 class="heading-element"&gt;1. Add your audio&lt;/h3&gt;
&lt;/div&gt;
&lt;p&gt;On the home page, pick one:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Upload a file:&lt;/strong&gt; drag it onto the box, or click to browse. Most audio and video formats work
&lt;code&gt;mp3&lt;/code&gt;, &lt;code&gt;m4a&lt;/code&gt;, &lt;code&gt;aac&lt;/code&gt;, &lt;code&gt;wav&lt;/code&gt;, &lt;code&gt;flac&lt;/code&gt;, &lt;code&gt;ogg&lt;/code&gt;, &lt;code&gt;webm&lt;/code&gt;, &lt;code&gt;mp4&lt;/code&gt;, &lt;code&gt;mov&lt;/code&gt;, and more. Max 100 MB.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Record now:&lt;/strong&gt; click &lt;strong&gt;Start recording&lt;/strong&gt;, allow…&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/reyesvicente/tala" 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;h3&gt;
  
  
  The open-source AI at the core: Whisper
&lt;/h3&gt;

&lt;p&gt;The model is OpenAI's &lt;strong&gt;Whisper&lt;/strong&gt;, an open-weight speech recognition model, run through &lt;strong&gt;&lt;a href="https://github.com/SYSTRAN/faster-whisper" rel="noopener noreferrer"&gt;faster-whisper&lt;/a&gt;&lt;/strong&gt;. faster-whisper reimplements Whisper on CTranslate2, so it runs on a plain CPU with &lt;strong&gt;int8&lt;/strong&gt; quantization. No GPU and no external API.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;React (Vite) ──/api──▶ FastAPI ──▶ PostgreSQL
                         │
                         └─▶ worker thread ──▶ faster-whisper (local CPU)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Backend: FastAPI + SQLAlchemy 2 + Alembic + PostgreSQL&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;POST /api/transcriptions&lt;/code&gt; streams the upload to disk in 1 MB chunks, enforcing the size limit without holding the file in memory. It saves a &lt;code&gt;pending&lt;/code&gt; row and immediately returns &lt;strong&gt;202&lt;/strong&gt; with the slug.&lt;/li&gt;
&lt;li&gt;One &lt;strong&gt;in-process worker thread&lt;/strong&gt; runs Whisper on one file at a time, so a CPU-heavy model doesn't overload the machine. On startup, unfinished jobs go back into the queue, so a restart doesn't lose anyone's work.&lt;/li&gt;
&lt;li&gt;The audio file is deleted in the same step that saves the transcript, and also when a job fails. A periodic purge removes expired transcripts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Optional accounts&lt;/strong&gt; use server-side sessions: the browser holds an &lt;code&gt;httpOnly&lt;/code&gt; cookie, the database stores only a SHA-256 of it, and passwords are hashed with Argon2. Logging out ends the session on the server, and resetting a password logs out every device. Forgot-password answers the same way whether or not the email exists, and reset links arrive through &lt;a href="https://resend.com" rel="noopener noreferrer"&gt;Resend&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rate limits&lt;/strong&gt; on login, sign-up and password resets: 5 failed logins lock that email for 15 minutes, and blocked requests get a &lt;code&gt;429&lt;/code&gt; with &lt;code&gt;Retry-After&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The tests swap Whisper for a fake transcriber, so all 35 tests, from uploads to the full password-reset flow, run in seconds.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Frontend: React 18 + Vite + TypeScript&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;TanStack Query&lt;/strong&gt; polls the transcript every 1.5 seconds &lt;em&gt;only&lt;/em&gt; while it's &lt;code&gt;pending&lt;/code&gt; or &lt;code&gt;processing&lt;/code&gt;, then stops.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Zustand&lt;/strong&gt; (persisted) stores the "made in this browser" list, so people who never sign up still keep track of their transcripts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;MediaRecorder&lt;/strong&gt; handles in-browser recording, picking whatever codec the browser supports. The backend decodes all of them through PyAV.&lt;/li&gt;
&lt;li&gt;The UI is built with &lt;a href="https://www.npmjs.com/package/ice-ds" rel="noopener noreferrer"&gt;ice-ds&lt;/a&gt;, my neubrutalist component library.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Deployment: &lt;a href="https://render.com" rel="noopener noreferrer"&gt;Render&lt;/a&gt;.&lt;/strong&gt; One Docker image builds the React app, installs the API, and bakes the Whisper model in at build time. FastAPI serves the frontend and the API from one origin, and the container never contacts the model hub at runtime. A &lt;code&gt;render.yaml&lt;/code&gt; Blueprint defines the whole stack: a Docker web service running Whisper on CPU (1 CPU, 2 GB) plus Render Postgres, both in Singapore, close to Paolo. Every push to &lt;code&gt;main&lt;/code&gt; redeploys automatically, and the live site is at &lt;a href="https://tala.vicentereyes.org" rel="noopener noreferrer"&gt;tala.vicentereyes.org&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  What testing on a real recording taught me
&lt;/h3&gt;

&lt;p&gt;My first test was an 11-second clip, and it worked well. Then I tried a real &lt;strong&gt;70-minute Tagalog meeting recording&lt;/strong&gt;, and two things broke.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. It was far too slow.&lt;/strong&gt; The progress bar barely moved. I benchmarked a 3-minute slice of the recording on my 4-core laptop:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Settings&lt;/th&gt;
&lt;th&gt;Speed&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Sequential decoding, beam 5 (my original settings)&lt;/td&gt;
&lt;td&gt;0.6× real time&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sequential decoding, beam 1&lt;/td&gt;
&lt;td&gt;0.7× real time&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Batched decoding (&lt;code&gt;BatchedInferencePipeline&lt;/code&gt;), batch 8, beam 1&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;3.8× real time&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Batched decoding first uses voice-activity detection to cut the audio into speech chunks, then decodes several chunks at once. Switching to it took the full 70-minute file from &lt;strong&gt;about 2 hours to 13 minutes&lt;/strong&gt;. I also added a &lt;em&gt;Preparing audio&lt;/em&gt; stage and a time-left estimate, so a long job no longer looks frozen.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Whisper hallucinated.&lt;/strong&gt; On hard audio, small Whisper models get stuck in loops, and the transcript filled up with lines like &lt;code&gt;ngayon ngayon ngayon …&lt;/code&gt; and &lt;code&gt;kakakakaka…&lt;/code&gt;. One unbroken loop was so long it pushed the page wider than the screen. Batched mode decodes at a single temperature, so Whisper's usual retry-on-repetition fallback never runs. I added:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a decoder &lt;code&gt;repetition_penalty&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;a small cleanup step that collapses repeated phrases and in-word loops (&lt;code&gt;sa-ma-ma-ma-ma&lt;/code&gt; → &lt;code&gt;sa-ma&lt;/code&gt;), drops segments that are just &lt;code&gt;...&lt;/code&gt;, and merges duplicate lines&lt;/li&gt;
&lt;li&gt;CSS so long strings wrap instead of overflowing&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;On that meeting, the cleanup cut the transcript from 5,458 words to 1,425. That tells you how much of it was noise.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The honest result:&lt;/strong&gt; the cleanup removes junk, but the default &lt;code&gt;base&lt;/code&gt; model still struggles with Taglish conversation. Because the model is open, fixing that is a config change rather than a vendor negotiation: &lt;code&gt;WHISPER_MODEL=small&lt;/code&gt; or &lt;code&gt;large-v3-turbo&lt;/code&gt; trades speed for accuracy.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One more bug:&lt;/strong&gt; the first real transcription failed with &lt;code&gt;open() got an unexpected keyword argument 'metadata_errors'&lt;/code&gt;. faster-whisper 1.2.1 passes an argument that PyAV 19 removed. Pinning &lt;code&gt;av==15.1.0&lt;/code&gt; fixed it, and the failure path worked as designed: the job was marked &lt;code&gt;failed&lt;/code&gt;, the user saw a clear message, and the audio was still deleted.&lt;/p&gt;

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

&lt;p&gt;The whole idea of Tala, &lt;em&gt;no signup and no credit card&lt;/em&gt;, only works because the model is open.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It costs nothing per minute.&lt;/strong&gt; Hosted speech-to-text APIs bill per minute of audio. A free product built on one needs an account to rate-limit you, or a card to bill you, which is exactly the friction Paolo wanted to avoid. With Whisper running on my own server, the cost is the server, so a free tool with no strings attached actually works.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The audio never leaves a machine I control.&lt;/strong&gt; Paolo's recordings are client calls and design reviews. With a closed API, every file goes to a third party under their retention policy. With Tala, the audio is decoded on the server it was uploaded to and deleted minutes later. I can promise that because I can read every line of code it passes through.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;I could open it up and fix it.&lt;/strong&gt; When Tala was too slow on long files, I didn't wait for a provider to ship a faster tier. I benchmarked decoding strategies on my own laptop and switched to batched inference: 6× faster in an afternoon. When it hallucinated on Tagalog, I could see exactly why (no temperature fallback in batched mode) and add guards. A closed API is a black box: you get what it returns.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;I can swap models freely.&lt;/strong&gt; &lt;code&gt;tiny&lt;/code&gt; for a cheap host, &lt;code&gt;large-v3-turbo&lt;/code&gt; for accuracy. It's one environment variable, with no lock-in and no deprecation emails. If Tagalog accuracy is still not good enough, the weights are open, so fine-tuning is an option.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It runs on a laptop.&lt;/strong&gt; The local dev setup is the full product. No API keys, and no internet after the model downloads once. Anyone can clone the repo and run their own private transcriber.&lt;/p&gt;

&lt;p&gt;Where closed is better: hosted APIs run on GPUs, so long files finish faster, and they offer speaker labels. On a modest CPU, an hour of audio takes a while. For Paolo's usual 5–15 minute memos and calls, that trade-off is fine.&lt;/p&gt;

&lt;h2&gt;
  
  
  My Agent Session
&lt;/h2&gt;

&lt;p&gt;I built Tala pair-programming with Claude Code: scaffolding, the benchmark that found the batched-decoding speedup, and debugging the hallucination loops all happened in that session.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prize Categories
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Best Use of Render:&lt;/strong&gt; Render is Tala's AI runtime. The open-weight Whisper model runs inside a Render web service, with Render Postgres for jobs and transcripts, all defined in a one-file Blueprint.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Thanks for reading, and if you need audio turned into text, no signup required. 🎧&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>weekendchallenge</category>
      <category>hf26challenge</category>
      <category>ai</category>
    </item>
    <item>
      <title>[FOR HIRE] Full-stack developer, 2 build slots open for November</title>
      <dc:creator>Vicente G. Reyes</dc:creator>
      <pubDate>Sun, 27 Sep 2026 16:55:47 +0000</pubDate>
      <link>https://dev.to/highcenburg/for-hire-full-stack-developer-2-build-slots-open-for-november-h12</link>
      <guid>https://dev.to/highcenburg/for-hire-full-stack-developer-2-build-slots-open-for-november-h12</guid>
      <description>&lt;p&gt;If you want something live before the year ends, November is the last good month to start. December disappears into the holidays.&lt;/p&gt;

&lt;p&gt;I'm Vicente, a freelance developer since 2018. I've shipped 25 client projects for businesses in the UK, US, and Australia, including 11 production Shopify stores.&lt;/p&gt;

&lt;p&gt;What I can take on in November:&lt;br&gt;
→ Shopify stores: new builds, rebuilds, and pre-sale fixes ahead of 11.11 and 12.12&lt;br&gt;
→ WordPress sites: business sites, WooCommerce, and cleanup of slow or broken installs&lt;br&gt;
→ Web apps: Django + React for SaaS, dashboards, internal tools, and custom platforms&lt;/p&gt;

&lt;p&gt;Portfolio: vicentereyes.org&lt;/p&gt;

&lt;p&gt;Only 2 slots, so both projects get my full attention. Book a call at &lt;a href="https://calendly.com/vicentereyes" rel="noopener noreferrer"&gt;https://calendly.com/vicentereyes&lt;/a&gt;, or DM me your site link or idea and I'll reply within 24 hours with how I'd approach it.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>freelance</category>
    </item>
    <item>
      <title>GiggleGigs: A Job Board That Actually Uses AI Where It Helps</title>
      <dc:creator>Vicente G. Reyes</dc:creator>
      <pubDate>Tue, 22 Sep 2026 16:57:43 +0000</pubDate>
      <link>https://dev.to/highcenburg/gigglegigs-a-job-board-that-actually-uses-ai-where-it-helps-1cgd</link>
      <guid>https://dev.to/highcenburg/gigglegigs-a-job-board-that-actually-uses-ai-where-it-helps-1cgd</guid>
      <description>&lt;p&gt;Most job boards are glorified spreadsheets with a search bar. GiggleGigs isn't. It's a two-sided marketplace — &lt;strong&gt;"Find Work. Hire Talent."&lt;/strong&gt; — built to close the gap between "I posted a job" and "I hired someone good," using AI at exactly the three points where it saves real time: writing the posting, writing the application, and screening the results.&lt;/p&gt;

&lt;p&gt;Here's what's actually under the hood, and why it's worth a look.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem It Solves
&lt;/h2&gt;

&lt;p&gt;Job boards fail in a predictable way: employers post vague listings because writing a good one is tedious, candidates send generic applications because tailoring one is tedious, and then the employer drowns in a stack of applications that all look the same. Nobody's time gets saved — the tedium just moves downstream.&lt;/p&gt;

&lt;p&gt;GiggleGigs attacks all three steps at once:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Companies&lt;/strong&gt; write a two-sentence prompt and GiggleGigs' AI job-description writer expands it into a full posting with requirements, budget framing, and structure.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Seekers&lt;/strong&gt; get an AI-drafted, job-specific cover letter instead of copy-pasting the same one everywhere — and can even upload a resume during onboarding and have Gemini auto-fill their entire profile (name, bio, skills, headline, links, experience) in seconds.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Companies&lt;/strong&gt; then use an AI application screener that match-scores and summarizes every applicant against the job's actual requirements, so reviewing 40 applications doesn't mean reading 40 applications.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's the pitch: less busywork on both sides of the transaction, and a marketplace that gets more useful the more people use it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's Actually Built
&lt;/h2&gt;

&lt;p&gt;This isn't a prototype — it's a full production-shaped Django + React application with a genuinely deep feature set:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;For job seekers&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Skill/location/budget/timeline-filtered job search&lt;/li&gt;
&lt;li&gt;One-click applications with optional resume upload (PDF/DOC/DOCX, validated server-side)&lt;/li&gt;
&lt;li&gt;AI cover letter generation and AI resume-parsing onboarding&lt;/li&gt;
&lt;li&gt;Real-time application tracking across a five-stage pipeline (Pending → Reviewing → Interviewed → Hired/Rejected/Withdrawn)&lt;/li&gt;
&lt;li&gt;Application withdrawal, favorited jobs, and a daily digest email of new jobs matching your skills&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;For companies&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Full job posting management (hourly or fixed price, detailed requirements)&lt;/li&gt;
&lt;li&gt;A filterable, sortable applicant dashboard with live hiring-pipeline stats&lt;/li&gt;
&lt;li&gt;AI-powered candidate search and AI match-scoring per applicant&lt;/li&gt;
&lt;li&gt;Star-rating feedback to candidates, resume downloads, and team invitations for multi-seat company accounts&lt;/li&gt;
&lt;li&gt;Credit-based billing via PayPal to unlock posting and candidate contact&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Platform-wide&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Real-time notifications over WebSockets (Django Channels + Daphne ASGI) — no polling, no refresh&lt;/li&gt;
&lt;li&gt;JWT auth plus django-allauth for email/social login&lt;/li&gt;
&lt;li&gt;Celery + Redis background processing with Celery Beat and Flower for monitoring&lt;/li&gt;
&lt;li&gt;Content moderation tooling for jobs, companies, and users&lt;/li&gt;
&lt;li&gt;A full analytics dashboard for staff on jobs and user activity&lt;/li&gt;
&lt;li&gt;An API-first backend (Django REST Framework, OpenAPI schema via drf-spectacular) behind a React 18 + Vite SPA&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every one of these shipped incrementally and is live in the codebase today — this is a v1.7 product with a real changelog, not a feature wishlist.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Business Model Is Already Wired In
&lt;/h2&gt;

&lt;p&gt;GiggleGigs isn't just feature-complete — it's monetization-complete. Companies post their first two jobs a month for free (liquidity first, revenue second), and then convert through one of:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;$29 single-post unlock&lt;/strong&gt; — the low-commitment first purchase, for a company that just got applicants and wants to actually contact them&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Starter ($49/mo)&lt;/strong&gt; — for companies hiring regularly&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Growth ($99/mo)&lt;/strong&gt; — adds full candidate search&lt;/li&gt;
&lt;li&gt;Annual plans at two months free&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That progression — free listing → paid contact unlock → subscription — is a deliberate sequencing choice: liquidity (seekers applying) has to exist before employers will ever pay, so the free tier isn't a loss leader, it's the engine that makes the paid tier valuable at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the Engineering Choices Matter
&lt;/h2&gt;

&lt;p&gt;A few decisions stand out as the kind that pay off later rather than look impressive now:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Services layer, not fat views&lt;/strong&gt; — business logic lives outside the DRF views, which keeps the API surface thin and testable.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Proxy user model&lt;/strong&gt; (Company / Seeker / Employee subtypes) instead of bolting role flags onto a single User table — cleaner permissions, cleaner queries.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Consistent &lt;code&gt;{ data, message, errors }&lt;/code&gt; API envelope&lt;/strong&gt; across every endpoint, which makes the frontend's error handling and loading states uniform instead of ad hoc per-page.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Docker-only local workflow&lt;/strong&gt; — Postgres, Redis, Django, Celery, Channels, and MailHog all come up together with one command, so "works on my machine" isn't a category of bug here.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Real-time built on Channels from day one&lt;/strong&gt;, not retrofitted — notifications, status changes, and messaging all ride the same WebSocket infrastructure.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of this shows up in a demo GIF, but it's the difference between a project that can take on its next ten features and one that needs a rewrite to get there.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try It
&lt;/h2&gt;

&lt;p&gt;GiggleGigs is a working, self-hostable job board and marketplace — clone it, &lt;code&gt;docker compose -f local.yml up&lt;/code&gt;, and you have Postgres, Redis, Celery, Channels, and MailHog running together in one shot. If you're evaluating job-board platforms, building a niche talent marketplace, or just want to see AI applied to hiring in a way that removes tedium instead of adding a chatbot nobody asked for, it's worth a look.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Find work. Hire talent. Skip the busywork.&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>django</category>
      <category>react</category>
      <category>showdev</category>
    </item>
    <item>
      <title>I'm the dev who messes shit up on Figma. I'm sorry. I do not do it on purpose.</title>
      <dc:creator>Vicente G. Reyes</dc:creator>
      <pubDate>Mon, 21 Sep 2026 14:40:16 +0000</pubDate>
      <link>https://dev.to/highcenburg/im-the-dev-who-messes-shit-up-on-figma-im-sorry-i-do-not-do-it-on-purpose-nod</link>
      <guid>https://dev.to/highcenburg/im-the-dev-who-messes-shit-up-on-figma-im-sorry-i-do-not-do-it-on-purpose-nod</guid>
      <description></description>
      <category>design</category>
      <category>frontend</category>
      <category>software</category>
      <category>ux</category>
    </item>
    <item>
      <title>12 sections, 3.5 hours. Thank God for Claude MCP, Shopify MCP and Figma MCP. 👆</title>
      <dc:creator>Vicente G. Reyes</dc:creator>
      <pubDate>Mon, 21 Sep 2026 11:49:38 +0000</pubDate>
      <link>https://dev.to/highcenburg/12-sections-35-hours-thank-god-for-claude-mcp-shopify-mcp-and-figma-mcp-46mi</link>
      <guid>https://dev.to/highcenburg/12-sections-35-hours-thank-god-for-claude-mcp-shopify-mcp-and-figma-mcp-46mi</guid>
      <description></description>
      <category>ai</category>
      <category>claude</category>
      <category>mcp</category>
      <category>productivity</category>
    </item>
    <item>
      <title>The CI/CD Pipeline That Was Lying to Us: A Deploy Debugging Story</title>
      <dc:creator>Vicente G. Reyes</dc:creator>
      <pubDate>Tue, 01 Sep 2026 13:10:03 +0000</pubDate>
      <link>https://dev.to/highcenburg/-the-cicd-pipeline-that-was-lying-to-us-a-deploy-debugging-story-9fi</link>
      <guid>https://dev.to/highcenburg/-the-cicd-pipeline-that-was-lying-to-us-a-deploy-debugging-story-9fi</guid>
      <description>&lt;p&gt;I had a GitHub Actions workflow called &lt;code&gt;Deploy to DigitalOcean&lt;/code&gt;. It had been sitting in the repo for weeks, fully wired up — SSH action, secrets references, the works. And yet every single time I shipped a backend change, I still had to SSH into the droplet and run &lt;code&gt;git pull&lt;/code&gt; by hand.&lt;/p&gt;

&lt;p&gt;I hadn't stopped to ask &lt;em&gt;why&lt;/em&gt; until I actually went looking. It turned out the pipeline wasn't broken in any one obvious way — it was broken in six small, unrelated ways, stacked on top of each other, each one hiding the next. This is the story of digging through all of them, in the order I actually found them.&lt;/p&gt;




&lt;h2&gt;
  
  
  Act 1: The Deploy Job That Never Ran
&lt;/h2&gt;

&lt;p&gt;The setup looked correct on paper. &lt;code&gt;deploy.yml&lt;/code&gt; was gated like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;workflow_run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;workflows&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;CI'&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;branches&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;main'&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;types&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;completed&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;deploy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ github.event.workflow_run.conclusion == 'success' }}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Reasonable: only deploy if CI passed. So the first thing worth checking wasn't the deploy job at all — it was whether CI had &lt;em&gt;ever&lt;/em&gt; passed on &lt;code&gt;main&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gh run list &lt;span class="nt"&gt;--workflow&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;ci.yml &lt;span class="nt"&gt;--limit&lt;/span&gt; 5
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every single row: &lt;code&gt;failure&lt;/code&gt;. Every deploy run: &lt;code&gt;skipped&lt;/code&gt;. The deploy pipeline wasn't broken — it was doing exactly what it was told, gating correctly on a CI workflow that had never once gone green on &lt;code&gt;main&lt;/code&gt;. I hadn't connected those dots because the failures and the missing deploys lived in two different tabs of the Actions UI.&lt;/p&gt;

&lt;h2&gt;
  
  
  Act 2: Two Bugs Wearing One Trenchcoat
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;gh run view &amp;lt;run-id&amp;gt;&lt;/code&gt; showed two jobs failing for completely unrelated reasons.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The &lt;code&gt;linter&lt;/code&gt; job&lt;/strong&gt; died immediately:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;The specified python version file at: .python-version doesn't exist.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;.python-version&lt;/code&gt; was sitting right there in the repo root. Except it wasn't — it was in &lt;code&gt;.gitignore&lt;/code&gt;, left over from a &lt;code&gt;pyenv&lt;/code&gt; boilerplate line nobody had questioned. &lt;code&gt;actions/setup-python&lt;/code&gt;'s &lt;code&gt;python-version-file&lt;/code&gt; input needs the file to actually exist in the checkout, and a gitignored file never reaches CI no matter how real it looks on disk locally.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The &lt;code&gt;frontend&lt;/code&gt; job&lt;/strong&gt; died differently:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Error: [vitest-pool]: Failed to start forks worker for test files ...
Caused by: TypeError: webidl.util.markAsUncloneable is not a function
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That error has nothing to do with my test code. &lt;code&gt;ci.yml&lt;/code&gt; pinned &lt;code&gt;node-version: '20'&lt;/code&gt;, but &lt;code&gt;jsdom@30&lt;/code&gt; (a Vitest dependency) requires:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"engines"&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;"node"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"^22.22.2 || ^24.15.0 || &amp;gt;=26.0.0"&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;Node 20 doesn't have &lt;code&gt;markAsUncloneable&lt;/code&gt; — jsdom needs it. Every frontend test run was crashing before a single test file even executed. The actual backend test suite (&lt;code&gt;pytest&lt;/code&gt;) had been passing this entire time; it was just outvoted by two infrastructure bugs with nothing to do with the code being tested.&lt;/p&gt;

&lt;p&gt;Fix: un-ignore &lt;code&gt;.python-version&lt;/code&gt;, commit it. Bump &lt;code&gt;node-version&lt;/code&gt; to &lt;code&gt;'22'&lt;/code&gt;. Two-line diff, two root causes eliminated.&lt;/p&gt;

&lt;h2&gt;
  
  
  Act 3: The Debt That Was Always There
&lt;/h2&gt;

&lt;p&gt;Pushed the fix, watched CI run again. &lt;code&gt;pytest&lt;/code&gt; and &lt;code&gt;frontend&lt;/code&gt; went green. &lt;code&gt;linter&lt;/code&gt; failed &lt;em&gt;again&lt;/em&gt; — different reason this time:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;black....................................................................Failed
- hook id: black
reformatted job_board/jobs/services.py

isort....................................................................Failed
- hook id: isort
Fixing job_board/jobs/services.py

djLint formatting for Django.............................................Failed
- hook id: djlint-reformat-django
1 file was updated.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Formatting drift had been quietly accumulating in three files — a multi-line import that black wanted split, a &lt;code&gt;.envs&lt;/code&gt; file missing a trailing newline, an &lt;code&gt;index.html&lt;/code&gt; &lt;code&gt;&amp;lt;meta&amp;gt;&lt;/code&gt; tag that djLint wanted wrapped. None of it mattered locally because nobody had &lt;code&gt;pre-commit&lt;/code&gt; wired into their git hooks. It only mattered in CI, which is exactly where it had been failing, unnoticed, this whole time.&lt;/p&gt;

&lt;p&gt;The fix here was almost mechanical: CI's own hook output &lt;em&gt;is&lt;/em&gt; the diff. Applied it verbatim rather than re-deriving it — no ambiguity about what "correctly formatted" means when the formatter already told you.&lt;/p&gt;

&lt;h2&gt;
  
  
  Act 4: The Flaky Cache and the Duplicate Runs
&lt;/h2&gt;

&lt;p&gt;Two more small papercuts surfaced once real CI runs were flowing:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A cache backend race.&lt;/strong&gt; PR-triggered builds started intermittently failing with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Error: cannot parse bake definitions: ERROR: failed to solve:
failed to load cache key: repository does not contain ref refs/pull/166/merge
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is a known &lt;code&gt;docker/bake-action&lt;/code&gt; + GitHub Actions cache quirk: a PR's merge ref gets recomputed (or invalidated) if the PR updates or merges while the cache backend is still resolving it. It never affected the push-triggered run on &lt;code&gt;main&lt;/code&gt; — the one that actually gates deploy — but it left a false red X on every promotion PR. Buildx's GHA cache backend has a purpose-built escape hatch for exactly this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="s"&gt;django.cache-from=type=gha,scope=django-cached-tests,ignore-error=true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;ignore-error=true&lt;/code&gt; degrades a cache-restore failure to a cache miss instead of failing the whole job.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Duplicate CI runs.&lt;/strong&gt; With both &lt;code&gt;pull_request: branches: [main]&lt;/code&gt; and &lt;code&gt;push: branches: [main]&lt;/code&gt; as triggers, every promotion ran the exact same commit through CI twice — once when the PR opened, again the instant it merged. Since deploy only ever cares about the post-merge state of &lt;code&gt;main&lt;/code&gt;, the fix was to drop the &lt;code&gt;pull_request&lt;/code&gt; trigger entirely and let &lt;code&gt;push&lt;/code&gt; do the only job that mattered.&lt;/p&gt;

&lt;p&gt;While in there, I also pulled the &lt;code&gt;frontend&lt;/code&gt; lint/test job out of backend CI altogether — the frontend deploys through Vercel, which already runs its own build/lint pipeline as a separate PR check. There was no reason a frontend typo should ever gate a DigitalOcean backend deploy, so &lt;code&gt;frontend/**&lt;/code&gt; also got added to &lt;code&gt;paths-ignore&lt;/code&gt; — a frontend-only commit no longer triggers a backend rebuild at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  Act 5: CI Was Finally Green. Deploy Still Failed.
&lt;/h2&gt;

&lt;p&gt;For the first time, &lt;code&gt;CI&lt;/code&gt; showed &lt;code&gt;success&lt;/code&gt; on a push to &lt;code&gt;main&lt;/code&gt;. And for the first time, &lt;code&gt;Deploy to DigitalOcean&lt;/code&gt; actually &lt;em&gt;ran&lt;/em&gt; instead of showing &lt;code&gt;skipped&lt;/code&gt;. It failed in 12 seconds:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ssh"&gt;&lt;code&gt;&lt;span class="k"&gt;ssh&lt;/span&gt;.ParsePrivateKey: ssh: &lt;span class="no"&gt;no&lt;/span&gt; key found
&lt;span class="k"&gt;ssh&lt;/span&gt;: handshake failed: ssh: unable to authenticate, attempted methods [none], &lt;span class="no"&gt;no&lt;/span&gt; supported methods remain
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gh secret list &lt;span class="nt"&gt;--repo&lt;/span&gt; &amp;lt;org&amp;gt;/&amp;lt;repo&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Zero rows. &lt;code&gt;DO_HOST&lt;/code&gt;, &lt;code&gt;DO_USERNAME&lt;/code&gt;, &lt;code&gt;DO_SSH_KEY&lt;/code&gt;, &lt;code&gt;DO_PORT&lt;/code&gt; — none of them existed. The workflow had been referencing four secrets that had simply never been created. CI passing had done its job; it just exposed the next layer down.&lt;/p&gt;

&lt;h2&gt;
  
  
  Act 6: Two SSH Gotchas
&lt;/h2&gt;

&lt;p&gt;Setting these up surfaced two mistakes worth writing down because they're both easy to make and easy to misdiagnose.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Gotcha 1 — &lt;code&gt;ssh-copy-id&lt;/code&gt; from inside the box.&lt;/strong&gt; Trying to install a public key onto the droplet while &lt;em&gt;already SSH'd into that droplet&lt;/em&gt; doesn't work: &lt;code&gt;ssh-copy-id&lt;/code&gt; opens a &lt;em&gt;new&lt;/em&gt; outbound connection back to the same host to install the key, but that connection has no valid key to authenticate with yet. Classic chicken-and-egg. Once you're already sitting in a shell on the target machine, skip &lt;code&gt;ssh-copy-id&lt;/code&gt; and just append directly:&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;cat&lt;/span&gt; ~/.ssh/id_ed25519.pub &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; ~/.ssh/authorized_keys
&lt;span class="nb"&gt;chmod &lt;/span&gt;700 ~/.ssh
&lt;span class="nb"&gt;chmod &lt;/span&gt;600 ~/.ssh/authorized_keys
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Gotcha 2 — mangled secret content.&lt;/strong&gt; Even after the public key was trusted server-side, the deploy still failed with the exact same &lt;code&gt;ssh: no key found&lt;/code&gt; error. The private key had been set as a GitHub secret via a string interpolation (&lt;code&gt;--body "..."&lt;/code&gt;) rather than a direct file redirect, which had silently collapsed the multi-line PEM block. The fix is to never let anything touch the formatting:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gh secret &lt;span class="nb"&gt;set &lt;/span&gt;DO_SSH_KEY &lt;span class="nt"&gt;--repo&lt;/span&gt; &amp;lt;org&amp;gt;/&amp;lt;repo&amp;gt; &amp;lt; ~/.ssh/id_ed25519
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Redirecting the raw file guarantees byte-for-byte fidelity, including the &lt;code&gt;-----BEGIN/END-----&lt;/code&gt; lines and every internal newline that a shell string would otherwise eat.&lt;/p&gt;

&lt;h2&gt;
  
  
  Act 7: Right Key, Wrong Directory
&lt;/h2&gt;

&lt;p&gt;With auth finally working, the deploy script actually reached the droplet and ran — into a new error:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;err: bash: line 1: cd: /home/***/apps/gigglegigs: No such file or directory
err: fatal: not a git repository (or any of the parent directories): .git
err: open /home/***/production.yml: no such file or directory
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;deploy.yml&lt;/code&gt; assumed the checkout lived at &lt;code&gt;~/apps/gigglegigs&lt;/code&gt;. It didn't — a quick &lt;code&gt;ls ~&lt;/code&gt; on the droplet showed the actual clone sitting at &lt;code&gt;~/GiggleGigs&lt;/code&gt;. That path had apparently been copy-pasted from a different project's deploy config and never actually validated, because the workflow had never gotten far enough to hit it before. One-line fix, and the deploy script finally ran start to finish: build, collectstatic, migrate, &lt;code&gt;up -d&lt;/code&gt;, all green.&lt;/p&gt;

&lt;h2&gt;
  
  
  Act 8: The First Real Deploy Broke the Site
&lt;/h2&gt;

&lt;p&gt;Two minutes after that first fully green deploy, the admin's user list started returning &lt;code&gt;502 Bad Gateway&lt;/code&gt;. The instinct is to panic here — first automated deploy, and the site's down? But the logs told a much less dramatic story:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;django-1  | PostgreSQL is available
django-1  | 186 static files copied to '/app/staticfiles', 538 post-processed.
django-1  | [2026-09-01 12:48:17 +0000] [1] [INFO] Starting gunicorn 21.2.0
django-1  | [2026-09-01 12:48:17 +0000] [1] [INFO] Listening at: http://0.0.0.0:5000
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Gunicorn didn't finish booting — Postgres wait, &lt;code&gt;collectstatic&lt;/code&gt;, worker fork — until &lt;code&gt;12:48:17&lt;/code&gt;. The 502 was timestamped &lt;code&gt;12:48:16&lt;/code&gt;. One second earlier. Nothing crashed; I'd just refreshed the admin during the exact window where &lt;code&gt;docker compose up -d&lt;/code&gt; had already killed the old &lt;code&gt;django&lt;/code&gt; container and the new one wasn't listening yet.&lt;/p&gt;

&lt;p&gt;That's not really a bug in the app — it's a gap in the deploy setup. Traefik here uses the static &lt;code&gt;file&lt;/code&gt; provider, routing unconditionally to &lt;code&gt;http://django:5000&lt;/code&gt; with zero awareness of container readiness:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;django&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;loadBalancer&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;servers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;http://django:5000&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every deploy, without exception, would keep hitting this same window. The right fix is an active health check so Traefik stops sending traffic to a container until it's actually answering:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;django&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;loadBalancer&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;servers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;http://django:5000&lt;/span&gt;
      &lt;span class="na"&gt;healthCheck&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/healthz/&lt;/span&gt;
        &lt;span class="na"&gt;hostname&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;api.example.com&lt;/span&gt;
        &lt;span class="na"&gt;interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;5s'&lt;/span&gt;
        &lt;span class="na"&gt;timeout&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;3s'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things worth calling out about that config. First, &lt;code&gt;/healthz/&lt;/code&gt; didn't exist — it needed to be a genuinely trivial, unauthenticated view (no DB touch, no auth, just &lt;code&gt;200 OK&lt;/code&gt;), because coupling infra health to a real business endpoint means a permissions change or a slow query silently takes your health check down with it. Second, &lt;code&gt;hostname&lt;/code&gt; isn't optional: without it, Traefik's health check request sends &lt;code&gt;Host: django&lt;/code&gt; (the internal service name), and &lt;code&gt;ALLOWED_HOSTS&lt;/code&gt; — quite correctly — rejects that as a &lt;code&gt;DisallowedHost&lt;/code&gt;, returning &lt;code&gt;400&lt;/code&gt; and making Traefik think the container is permanently unhealthy. Setting &lt;code&gt;hostname&lt;/code&gt; to the real public domain makes the health check's request indistinguishable from real traffic, from Django's point of view.&lt;/p&gt;

&lt;p&gt;While already in &lt;code&gt;production.yml&lt;/code&gt;, I added &lt;code&gt;restart: unless-stopped&lt;/code&gt; to every long-running service (everything except the one-off backup container). Without it, a droplet reboot — a DigitalOcean maintenance window, a crash, anything — leaves every container down until someone notices and SSHes in to bring them back up manually. &lt;code&gt;unless-stopped&lt;/code&gt; means Docker brings them back on its own the moment the daemon starts.&lt;/p&gt;




&lt;h2&gt;
  
  
  What Actually Was Wrong, All Together
&lt;/h2&gt;

&lt;p&gt;Laid out in one place, the full list is almost funny:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A gitignored file broke Python setup in CI&lt;/li&gt;
&lt;li&gt;A pinned Node version was two majors behind what a dependency required&lt;/li&gt;
&lt;li&gt;Formatting drift had been silently failing lint for who knows how long&lt;/li&gt;
&lt;li&gt;A GHA cache backend race gave false negatives on PR checks&lt;/li&gt;
&lt;li&gt;CI ran twice per promotion for no reason&lt;/li&gt;
&lt;li&gt;Zero deploy secrets existed&lt;/li&gt;
&lt;li&gt;A private key got mangled by shell interpolation&lt;/li&gt;
&lt;li&gt;The deploy script's target directory was simply wrong&lt;/li&gt;
&lt;li&gt;Traefik had no way to know when a container wasn't ready yet&lt;/li&gt;
&lt;li&gt;Nothing would restart itself after a reboot&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;No single one of these was hard to fix once found. What made this a slog was that &lt;strong&gt;each bug hid the next one&lt;/strong&gt; — CI had to go green before deploy secrets could even matter; deploy secrets had to work before the wrong directory path could surface; the deploy had to actually &lt;em&gt;succeed&lt;/em&gt; before the Traefik timing gap became visible at all. A pipeline with ten small breakages in series doesn't look like ten breakages. It looks like one big wall, and the only way through it is to fix the outermost failure, rerun, and see what the next layer reveals.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaway
&lt;/h2&gt;

&lt;p&gt;If your "automated" deploy still requires someone to manually pull and rebuild, don't assume the deploy job itself is broken — check whether the thing gating it has &lt;em&gt;ever&lt;/em&gt; actually succeeded. A pipeline that's silently and permanently gated behind a red CI run looks, from a distance, exactly like a pipeline that doesn't exist. The fix isn't usually one big rewrite. It's peeling one failing layer at a time until the thing underneath finally gets a chance to run — and then trusting the next error message, however unrelated it looks, to point at the next real problem.&lt;/p&gt;

</description>
      <category>githubactions</category>
      <category>docker</category>
      <category>django</category>
      <category>devops</category>
    </item>
    <item>
      <title>Side project deployed https://www.gigglegigs.com/ tomorrow is devops and SEO day!</title>
      <dc:creator>Vicente G. Reyes</dc:creator>
      <pubDate>Mon, 31 Aug 2026 13:26:16 +0000</pubDate>
      <link>https://dev.to/highcenburg/side-project-deployed-httpswwwgigglegigscom-tomorrow-is-devops-and-seo-day-boe</link>
      <guid>https://dev.to/highcenburg/side-project-deployed-httpswwwgigglegigscom-tomorrow-is-devops-and-seo-day-boe</guid>
      <description>&lt;div class="crayons-card c-embed text-styles text-styles--secondary"&gt;
    &lt;div class="c-embed__content"&gt;
      &lt;div class="c-embed__body flex items-center justify-between"&gt;
        &lt;a href="https://www.gigglegigs.com/" rel="noopener noreferrer" class="c-link fw-bold flex items-center"&gt;
          &lt;span class="mr-2"&gt;gigglegigs.com&lt;/span&gt;
          

        &lt;/a&gt;
      &lt;/div&gt;
    &lt;/div&gt;
&lt;/div&gt;


</description>
      <category>devops</category>
      <category>seo</category>
      <category>sideprojects</category>
      <category>webdev</category>
    </item>
    <item>
      <title>What do you think is better, a free period or a freemium?</title>
      <dc:creator>Vicente G. Reyes</dc:creator>
      <pubDate>Sun, 30 Aug 2026 05:47:21 +0000</pubDate>
      <link>https://dev.to/highcenburg/what-do-you-think-is-better-a-free-period-or-a-freemium-5026</link>
      <guid>https://dev.to/highcenburg/what-do-you-think-is-better-a-free-period-or-a-freemium-5026</guid>
      <description></description>
      <category>product</category>
      <category>saas</category>
      <category>startup</category>
    </item>
    <item>
      <title>Your App Center List Is Not a Security Audit</title>
      <dc:creator>Vicente G. Reyes</dc:creator>
      <pubDate>Fri, 28 Aug 2026 05:10:02 +0000</pubDate>
      <link>https://dev.to/highcenburg/your-app-center-list-is-not-a-security-audit-196g</link>
      <guid>https://dev.to/highcenburg/your-app-center-list-is-not-a-security-audit-196g</guid>
      <description>&lt;p&gt;I opened Ubuntu's App Center, scrolled through ~90 installed packages, and asked the obvious question: is any of this malware?&lt;/p&gt;

&lt;p&gt;The list looked suspicious in all the wrong ways. A font package last updated nine years ago. A dozen unlabeled GStreamer plugins with generic gray icons. Something called BRLTTY I had no memory of installing. Plenty of surface area for paranoia.&lt;/p&gt;

&lt;p&gt;None of it turned out to matter, and the reason is worth writing down: &lt;strong&gt;App Center is a package browser, not an inventory.&lt;/strong&gt; It shows you the two sources it manages — the Ubuntu archive and the Snap Store — and nothing else. Every path an attacker would actually use to get code onto a developer workstation is invisible to it.&lt;/p&gt;

&lt;p&gt;This is the audit I ran instead, and what each command is actually good for.&lt;/p&gt;




&lt;h2&gt;
  
  
  Reading the GUI correctly first
&lt;/h2&gt;

&lt;p&gt;Two things I had wrong at a glance.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"Updated 9 years ago" is not neglect.&lt;/strong&gt; That's the package version date. &lt;code&gt;(URW)++ Core Font Set 20200910-8&lt;/code&gt;, TeXInfo, BRLTTY — these are frozen-by-design dependencies pulled in by other packages. A font metric file doesn't need a 2026 release to be safe.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Gray placeholder icons are not unsigned packages.&lt;/strong&gt; They mean no AppStream metadata or &lt;code&gt;.desktop&lt;/code&gt; file. Most are libraries that were never meant to appear in a launcher. A few, as I found later, are a real (and benign) finding.&lt;/p&gt;

&lt;p&gt;Neither observation tells you anything about integrity. For that you need the actual tooling.&lt;/p&gt;




&lt;h2&gt;
  
  
  1. Snap publishers
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;snap list

&lt;span class="k"&gt;for &lt;/span&gt;s &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;snap list | &lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="s1"&gt;'NR&amp;gt;1{print $1}'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;snap info &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$s&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s1"&gt;'^(name|publisher)'&lt;/span&gt;
&lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The publisher column is the entire point. Snap uses two markers:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Marker&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;✓&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Verified publisher — identity confirmed by Canonical&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;✪&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Starred — a trusted community maintainer&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;On my machine: 21 snaps, all marked. Canonical for the base and GNOME runtimes, &lt;code&gt;mozilla✓&lt;/code&gt; for Firefox, &lt;code&gt;bitwarden✓&lt;/code&gt; (8bit Solutions LLC), &lt;code&gt;spotify✓&lt;/code&gt;. The three starred ones were Discord and &lt;code&gt;ffmpeg-2404&lt;/code&gt; from &lt;strong&gt;Snapcrafters&lt;/strong&gt;, and Pinta from &lt;strong&gt;James Carroll&lt;/strong&gt; — the legitimate upstream maintainers in both cases.&lt;/p&gt;

&lt;p&gt;An unmarked publisher on a snap claiming to be a well-known app is the thing to look for. There weren't any.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Third-party APT repositories
&lt;/h2&gt;

&lt;p&gt;This is the real risk vector, and it's the one App Center will never show you.&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;grep&lt;/span&gt; &lt;span class="nt"&gt;-rhE&lt;/span&gt; &lt;span class="s1"&gt;'^deb '&lt;/span&gt; /etc/apt/sources.list /etc/apt/sources.list.d/ 2&amp;gt;/dev/null
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Seven results, all using &lt;code&gt;signed-by=&lt;/code&gt; with a dedicated keyring — which is the modern, correct form. The deprecated &lt;code&gt;apt-key&lt;/code&gt; approach trusts a key for &lt;em&gt;every&lt;/em&gt; repo on the system; &lt;code&gt;signed-by&lt;/code&gt; scopes it to one. If you see repos without it, that's worth fixing regardless of whether anything is compromised.&lt;/p&gt;

&lt;p&gt;Six were immediately recognizable: Docker, GitHub CLI, Heroku, Cloudflare WARP, mise, and the Ulauncher PPA. The seventh was truncated in my terminal and read, in part, &lt;code&gt;antigravity-repo-key.gpg ... us-central1-apt.pkg.dev/projects/antigra...&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Unfamiliar name, Google Cloud infrastructure hostname. Exactly the shape of thing that deserves thirty seconds of verification rather than a shrug.&lt;/p&gt;

&lt;p&gt;It's Google's Antigravity IDE. Google's official Linux download page publishes that repo verbatim — same keyring path, same Artifact Registry host, same project. Confirmed, but confirming the URL is only half the check.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Verifying the signing key
&lt;/h2&gt;

&lt;p&gt;A repo URL you recognize means nothing if the key trusting it was swapped.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gpg &lt;span class="nt"&gt;--show-keys&lt;/span&gt; &lt;span class="nt"&gt;--fingerprint&lt;/span&gt; /etc/apt/keyrings/antigravity-repo-key.gpg
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;pub   rsa2048 2021-05-04 [SC]
      35BA A0B3 3E9E B396 F59C  A838 C0BA 5CE6 DC63 15A3
uid   Artifact Registry Repository Signer &amp;lt;artifact-registry-repository-signer@google.com&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Full-length match against the published fingerprint, and a single &lt;code&gt;pub&lt;/code&gt; key with no extra subkeys smuggled into the file. Compare all 40 characters — matching the last eight is a known-weak check that collision attacks have historically defeated.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One nuance that matters.&lt;/strong&gt; This is Google's &lt;em&gt;shared&lt;/em&gt; Artifact Registry signing key. It signs every public AR apt repo, not just Antigravity's. So the fingerprint proves "Google Artifact Registry published this" — it does not prove "the Antigravity team published this." The trust boundary is the repository path. Verify both halves or you've verified neither.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Binary integrity
&lt;/h2&gt;

&lt;p&gt;This is the command that actually answers the malware question.&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;apt &lt;span class="nb"&gt;install &lt;/span&gt;debsums
&lt;span class="nb"&gt;sudo &lt;/span&gt;debsums &lt;span class="nt"&gt;-s&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;debsums&lt;/code&gt; recomputes the checksum of every file installed from a &lt;code&gt;.deb&lt;/code&gt; and compares it against what the package shipped. &lt;code&gt;-s&lt;/code&gt; prints only problems. If something had patched a system binary, this is where it surfaces.&lt;/p&gt;

&lt;p&gt;My output — three lines, all of the same kind:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;debsums: missing file /usr/share/applications/btop.desktop (from btop package)
debsums: missing file /usr/share/applications/org.flameshot.Flameshot.desktop
debsums: missing file /usr/share/applications/org.gnome.SystemMonitor.desktop
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Missing is not modified, and the distinction is the whole finding.&lt;/strong&gt; Zero checksum mismatches means not one binary or library on the system differs from what Ubuntu published. What's missing is three launcher entries — which is also why those three showed gray placeholder icons back in the GUI. Almost certainly an app-grid cleanup I did and forgot. Malware adds &lt;code&gt;.desktop&lt;/code&gt; files for persistence; it has no reason to delete them from unrelated packages.&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;apt &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;--reinstall&lt;/span&gt; btop flameshot gnome-system-monitor
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One caveat: not every package ships checksums, so &lt;code&gt;debsums&lt;/code&gt; coverage is good but not total. Add &lt;code&gt;sudo debsums -ca&lt;/code&gt; to list modified config files, which is a separate and noisier category.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Running services and persistence
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;systemctl list-units &lt;span class="nt"&gt;--type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;service &lt;span class="nt"&gt;--state&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;running
&lt;span class="nb"&gt;ls&lt;/span&gt; &lt;span class="nt"&gt;-la&lt;/span&gt; ~/.config/autostart/ /etc/systemd/system/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Thirty-four running services, every one accounted for: Docker and containerd, &lt;code&gt;warp-svc&lt;/code&gt; matching the Cloudflare repo, ClamAV daemon and freshclam, and the usual GNOME and systemd stack. Autostart held two entries, both mine.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;/etc/systemd/system/&lt;/code&gt; listing had a detail I liked seeing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;avahi-daemon.service -&amp;gt; /dev/null
cups.service -&amp;gt; /dev/null
libvirtd.service -&amp;gt; /dev/null
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Symlinking a unit to &lt;code&gt;/dev/null&lt;/code&gt; is masking — a stronger form of disable that can't be overridden by another unit's dependencies. Past-me hardening the box and leaving a legible trace of it.&lt;/p&gt;




&lt;h2&gt;
  
  
  What this audit does not cover
&lt;/h2&gt;

&lt;p&gt;Every check above is scoped to the package layer. That layer came back clean, and it's also not where compromise on a developer machine tends to originate.&lt;/p&gt;

&lt;p&gt;The paths that stay invisible to all of it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Language package managers&lt;/strong&gt; — &lt;code&gt;npm -g&lt;/code&gt;, &lt;code&gt;pip&lt;/code&gt;, &lt;code&gt;cargo install&lt;/code&gt;, &lt;code&gt;go install&lt;/code&gt;. Typosquats and compromised maintainer accounts are a live and recurring problem here, and postinstall scripts run with your privileges.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;curl | bash&lt;/code&gt; installers&lt;/strong&gt; — no signature, no manifest, no uninstall path, nothing for &lt;code&gt;debsums&lt;/code&gt; to compare against.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Editor and IDE extensions&lt;/strong&gt; — VS Code marketplace extensions run with full user permissions and are minimally reviewed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Agentic coding tools&lt;/strong&gt; — anything with filesystem write access and an autonomous execution loop. This is the newest item on the list and the one with the least established review culture around it.
That is the sensible next audit. The system package layer is the part with actual cryptographic guarantees behind it — signed repos, verified publishers, reproducible checksums. Everything above it is running on trust.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  The runbook
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Snap publishers — every entry should be ✓ or ✪&lt;/span&gt;
snap list
&lt;span class="k"&gt;for &lt;/span&gt;s &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;snap list | &lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="s1"&gt;'NR&amp;gt;1{print $1}'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;snap info &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$s&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s1"&gt;'^(name|publisher)'&lt;/span&gt;
&lt;span class="k"&gt;done&lt;/span&gt;

&lt;span class="c"&gt;# Third-party APT repos — all should use signed-by=&lt;/span&gt;
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rhE&lt;/span&gt; &lt;span class="s1"&gt;'^deb '&lt;/span&gt; /etc/apt/sources.list /etc/apt/sources.list.d/ 2&amp;gt;/dev/null

&lt;span class="c"&gt;# Verify any unfamiliar repo key against its published fingerprint (all 40 chars)&lt;/span&gt;
gpg &lt;span class="nt"&gt;--show-keys&lt;/span&gt; &lt;span class="nt"&gt;--fingerprint&lt;/span&gt; /etc/apt/keyrings/&amp;lt;name&amp;gt;.gpg

&lt;span class="c"&gt;# Binary integrity — the check that matters&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;apt &lt;span class="nb"&gt;install &lt;/span&gt;debsums &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;sudo &lt;/span&gt;debsums &lt;span class="nt"&gt;-s&lt;/span&gt;

&lt;span class="c"&gt;# Persistence and running services&lt;/span&gt;
systemctl list-units &lt;span class="nt"&gt;--type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;service &lt;span class="nt"&gt;--state&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;running
&lt;span class="nb"&gt;ls&lt;/span&gt; &lt;span class="nt"&gt;-la&lt;/span&gt; ~/.config/autostart/ /etc/systemd/system/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Five minutes end to end. Worth running on any machine you build client work on, if only to replace a vague sense of unease with an actual answer.&lt;/p&gt;

</description>
      <category>linux</category>
      <category>security</category>
      <category>ubuntu</category>
      <category>devops</category>
    </item>
    <item>
      <title>Why I Couldn't Access My Own Site (But My Phone Could): A Cloudways Auto-Ban Story</title>
      <dc:creator>Vicente G. Reyes</dc:creator>
      <pubDate>Mon, 24 Aug 2026 13:12:24 +0000</pubDate>
      <link>https://dev.to/highcenburg/why-i-couldnt-access-my-own-site-but-my-phone-could-a-cloudways-auto-ban-story-1f2l</link>
      <guid>https://dev.to/highcenburg/why-i-couldnt-access-my-own-site-but-my-phone-could-a-cloudways-auto-ban-story-1f2l</guid>
      <description>&lt;p&gt;I sat down to work on Rev6 and couldn't load the site. Not a slow load, not an SSL warning — nothing. The browser just spun until it gave up. My first assumption was the usual suspect: the server was down.&lt;/p&gt;

&lt;p&gt;Then I checked my phone, on the same Wi-Fi. Rev6 loaded instantly.&lt;/p&gt;

&lt;p&gt;That one detail sent me down a much longer rabbit hole than I expected — and the actual cause had nothing to do with my laptop, my router, or even my ISP.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Setup
&lt;/h2&gt;

&lt;p&gt;Rev6 is hosted on Cloudways, which provisions on top of DigitalOcean. DNS resolved fine — the domain pointed to the correct droplet IP. So this wasn't a broken domain or expired DNS record. The problem was specifically about &lt;em&gt;reaching&lt;/em&gt; that IP.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Failed Attempts
&lt;/h2&gt;

&lt;p&gt;I went through the usual network debugging checklist, and every single one came back clean:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Ping and curl to the server&lt;/strong&gt; — both hung and eventually timed out on ports 80, 443, and even 22 (SSH). Not "connection refused," just total silence, as if the packets vanished.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;General internet sanity check&lt;/strong&gt; — Google, GitHub, Cloudflare all loaded instantly. So this wasn't a broken connection, just this one destination.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Local firewall (&lt;code&gt;ufw&lt;/code&gt;/&lt;code&gt;iptables&lt;/code&gt;/&lt;code&gt;nftables&lt;/code&gt;)&lt;/strong&gt; — checked the full ruleset, &lt;code&gt;OUTPUT&lt;/code&gt; policy was &lt;code&gt;ACCEPT&lt;/code&gt;, no rule matched the server's IP or any DigitalOcean range.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;VPN or proxy&lt;/strong&gt; — none active, no &lt;code&gt;http_proxy&lt;/code&gt;/&lt;code&gt;https_proxy&lt;/code&gt; env vars, no tunnel interfaces.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;MAC-based router filtering&lt;/strong&gt; — randomized my Wi-Fi adapter's MAC address entirely. Still blocked.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hostname-based filtering&lt;/strong&gt; — some routers key parental-control rules off the DHCP hostname instead of the MAC. Disabled hostname broadcasting, renewed the lease. Still blocked.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The router itself&lt;/strong&gt; — logged into the ONT admin panel and checked every filtering section: IP Filter, MAC Filter, WLAN MAC Filter, Parental Control, Device Access Control. All disabled, all empty.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;At this point every layer between my laptop and my ISP was confirmed clean. Whatever was blocking the connection wasn't local.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Clue That Cracked It
&lt;/h2&gt;

&lt;p&gt;The "works on my phone" comparison turned out to be misleading. When I checked my phone's actual public IP (via &lt;code&gt;whatismyip.com&lt;/code&gt;), it came back as a completely different address in a different range than my laptop's real ISP-assigned IP — a sign that Brave's VPN feature was tunneling all my phone's traffic elsewhere. My phone was never a fair comparison; it simply wasn't taking the same path to the internet.&lt;/p&gt;

&lt;p&gt;The real breakthrough came from testing the connection from a network entirely outside my ISP. From there, the request to the server didn't time out — it came back as an &lt;strong&gt;active refusal&lt;/strong&gt;. That's a meaningfully different signal:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;From my home connection:&lt;/strong&gt; total silence — no response at all&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;From an outside network:&lt;/strong&gt; an explicit "connection refused"&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Silence usually means something in the path is &lt;em&gt;dropping&lt;/em&gt; packets. An active refusal means the packets &lt;em&gt;arrived&lt;/em&gt;, and something on the other end responded. That distinction pointed the investigation away from my network entirely and toward the server side.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Real Issue: Cloudways Had Auto-Banned My IP
&lt;/h2&gt;

&lt;p&gt;Cloudways includes a &lt;strong&gt;Bot Protection&lt;/strong&gt; feature — AI-driven heuristics and CAPTCHA challenges meant to keep malicious bots off the server. It's a genuinely useful feature. It's also fully automated, which means it can ban a legitimate visitor without any human ever reviewing the decision.&lt;/p&gt;

&lt;p&gt;Under &lt;strong&gt;Server → Security → Firewall&lt;/strong&gt;, there's a blacklist table showing banned IPs, the country they're associated with, and the reason. Searching my own IP directly returned nothing — but filtering by country (Philippines) surfaced it immediately:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;IP&lt;/th&gt;
&lt;th&gt;Purpose&lt;/th&gt;
&lt;th&gt;TTL&lt;/th&gt;
&lt;th&gt;Country&lt;/th&gt;
&lt;th&gt;Event&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;em&gt;(my IP)&lt;/em&gt;&lt;/td&gt;
&lt;td&gt;black&lt;/td&gt;
&lt;td&gt;1 week from now&lt;/td&gt;
&lt;td&gt;Philippines&lt;/td&gt;
&lt;td&gt;Blacklisted for CAPTCHA failure&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;My own home IP had been silently banned for a full week after failing a CAPTCHA challenge — almost certainly an automated bot check I never even saw, triggered while browsing my own site.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Fix
&lt;/h2&gt;

&lt;p&gt;From the same Firewall screen:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Search for the banned IP (or filter by country if the exact IP search comes up empty)&lt;/li&gt;
&lt;li&gt;Open the &lt;strong&gt;⋮&lt;/strong&gt; actions menu on that row&lt;/li&gt;
&lt;li&gt;Remove/delete the blacklist entry&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Access was restored immediately — no server restart, no DNS changes, nothing else required.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaway
&lt;/h2&gt;

&lt;p&gt;When a site is unreachable but DNS resolves and the rest of the internet works fine, don't assume it's your network just because another device on the same Wi-Fi can get through — verify that device is actually taking the same path out to the internet first. A background VPN can make an "it works on my phone" comparison completely meaningless.&lt;/p&gt;

&lt;p&gt;And when the destination is a managed host like Cloudways, remember that automated bot/security protections can ban &lt;em&gt;you&lt;/em&gt; just as easily as an actual attacker. If everything on your end checks out clean, check the host's own security dashboard before assuming there's a deeper network problem — the block might already be sitting there in plain sight, just filed under "security."&lt;/p&gt;

</description>
      <category>devops</category>
      <category>networking</category>
      <category>security</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
