<?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>My first freelance client paid me $5. Here's what I'd tell myself at that stage.</title>
      <dc:creator>Vicente G. Reyes</dc:creator>
      <pubDate>Thu, 29 Oct 2026 14:15:54 +0000</pubDate>
      <link>https://dev.to/highcenburg/my-first-freelance-client-paid-me-5-heres-what-id-tell-myself-at-that-stage-16e1</link>
      <guid>https://dev.to/highcenburg/my-first-freelance-client-paid-me-5-heres-what-id-tell-myself-at-that-stage-16e1</guid>
      <description>&lt;p&gt;My first paid freelance job was a $5 mixing gig on Fiverr.&lt;/p&gt;

&lt;p&gt;I'd been recording myself and my band for practice. At some point I figured someone might pay for that, so I listed a gig, landed one, and delivered in 5 days. After that I ghost-produced a hardstyle track for a client in Europe.&lt;/p&gt;

&lt;p&gt;Then the audio work dried up. I could mix, but buyers were scarce.&lt;/p&gt;

&lt;p&gt;So I stepped sideways. I paired what I'd studied in college (computer science) with a platform businesses were actually paying for: Shopify. I started doing Shopify development in 2019, landed an agency gig in 2020 building stores from the ground up, and since 2018 I've shipped 37 client projects, including 11 production Shopify stores.&lt;/p&gt;

&lt;p&gt;Looking back, the skill was never the hard part. Everything &lt;em&gt;around&lt;/em&gt; the skill was. Here are the five things I'd tell myself at the $5 stage.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. You probably already have something sellable
&lt;/h2&gt;

&lt;p&gt;Most people waiting to "learn enough" to freelance are already sitting on a skill: editing videos for their friends, designing posters for their org, fixing the family computer, mixing their band's demos.&lt;/p&gt;

&lt;p&gt;The question isn't "am I good enough?" It's "who already pays for this, and what do they pay for?" List what you do for free, then look for where businesses pay for the same outcome.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Sell an outcome, not a skill
&lt;/h2&gt;

&lt;p&gt;"I do video editing" is a skill. "I turn your raw Zoom recordings into three 60-second clips for Reels, delivered in 48 hours" is an offer.&lt;/p&gt;

&lt;p&gt;A client can say yes to an offer. A skill makes them do the work of figuring out what they're buying.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Say what you &lt;em&gt;can't&lt;/em&gt; do yet
&lt;/h2&gt;

&lt;p&gt;This one felt backwards to me at first. When you're new, the instinct is to say yes to everything.&lt;/p&gt;

&lt;p&gt;What actually builds trust is being specific about your gaps, then pointing to adjacent evidence: "I haven't built a subscription store yet, but here's a store I built with a custom checkout flow." Clients have been burned by people who overpromised. Honesty stands out.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Scope before you start, every time
&lt;/h2&gt;

&lt;p&gt;Most freelancer horror stories come down to one thing: nobody wrote down what "done" means.&lt;/p&gt;

&lt;p&gt;Before any work starts, write a short Scope of Work:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;what's included&lt;/li&gt;
&lt;li&gt;what's &lt;em&gt;not&lt;/em&gt; included&lt;/li&gt;
&lt;li&gt;how many revisions&lt;/li&gt;
&lt;li&gt;the timeline&lt;/li&gt;
&lt;li&gt;what happens if the client wants more
It doesn't need to be a 10-page contract. One page beats a handshake every time.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  5. Don't price like a hobbyist forever
&lt;/h2&gt;

&lt;p&gt;$5 was fine for a first gig. It got me a review and proof that someone would pay. But plenty of freelancers get stuck at their first price because raising it feels scary.&lt;/p&gt;

&lt;p&gt;Treat your first rate as temporary. Every few projects, raise it for &lt;em&gt;new&lt;/em&gt; clients. The ones who leave over a small increase usually weren't the ones keeping you afloat anyway.&lt;/p&gt;

&lt;h2&gt;
  
  
  The playbook
&lt;/h2&gt;

&lt;p&gt;I turned all of this into a full guide: &lt;strong&gt;From Hobby to Hired: The First-Client Playbook&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;It's an 18-page PDF with 8 chapters covering the skill audit, packaging a service, building proof, finding clients, pricing, scoping, getting paid (written PH-first, but useful anywhere), and growing to steady income.&lt;/p&gt;

&lt;p&gt;It also includes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;6 copy-paste AI prompts, including one that interviews you and drafts a Scope of Work from your answers&lt;/li&gt;
&lt;li&gt;A fill-in Scope of Work template&lt;/li&gt;
&lt;li&gt;A Skill Audit worksheet
It's for beginners who want their first paying client, not for freelancers who are already booked out.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;👉 &lt;a href="https://highcenburg.gumroad.com/l/hobby-to-hired" rel="noopener noreferrer"&gt;Get it on Gumroad&lt;/a&gt; · &lt;a href="https://vicentereyes.org/playbook" rel="noopener noreferrer"&gt;More about the playbook&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;What was your first paid gig, and what did you charge? Drop it in the comments. I'd love to hear where everyone started.&lt;/p&gt;

</description>
      <category>freelance</category>
      <category>career</category>
      <category>productivity</category>
    </item>
    <item>
      <title>From Side Projects to Your First Paying Client: A Practical Guide for Developers</title>
      <dc:creator>Vicente G. Reyes</dc:creator>
      <pubDate>Tue, 27 Oct 2026 10:00:15 +0000</pubDate>
      <link>https://dev.to/highcenburg/from-side-projects-to-your-first-paying-client-a-practical-guide-for-developers-19mb</link>
      <guid>https://dev.to/highcenburg/from-side-projects-to-your-first-paying-client-a-practical-guide-for-developers-19mb</guid>
      <description>&lt;p&gt;Most developers stuck before their first client don't have a skills problem.&lt;/p&gt;

&lt;p&gt;You've built the side projects. You know your framework. You could probably ship what a small business needs in a few weeks. What's missing is everything &lt;em&gt;around&lt;/em&gt; the code: knowing what to offer, where clients come from, what to charge, and how to keep a project from swallowing your evenings.&lt;/p&gt;

&lt;p&gt;My first client was a mixing engineering client on Fiverr for $5 which lasted for 5 days. Small budget but it is what it made me work from home/freelance.&lt;/p&gt;

&lt;p&gt;Here's the path broken into six steps.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Pick one offer a stranger understands in ten seconds
&lt;/h2&gt;

&lt;p&gt;"I'm a full-stack developer" describes you. It doesn't tell anyone what they can buy.&lt;/p&gt;

&lt;p&gt;Clients buy outcomes, so turn your skills into one specific offer:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;❌ "I do React, Django, Tailwind and some DevOps"&lt;/li&gt;
&lt;li&gt;✅ "I build fast booking websites for small clinics"&lt;/li&gt;
&lt;li&gt;✅ "I turn your spreadsheet workflow into a simple web app"&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Narrow feels risky, but it's what makes you easy to recommend. You can widen the offer once people are paying you.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Try this:&lt;/strong&gt; finish the sentence &lt;em&gt;"I help [who] get [result] by [what you build]."&lt;/em&gt; If a non-developer friend can't repeat it back, simplify it.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Make your portfolio sell, not just exist
&lt;/h2&gt;

&lt;p&gt;A portfolio of tutorial clones says "I can follow along". A portfolio aimed at clients says "I can solve your problem".&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Show fewer, better projects.&lt;/strong&gt; Three relevant projects beat twelve unrelated ones.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hide what doesn't match your offer.&lt;/strong&gt; The to-do app and the weather app can go.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Write each project up as a short case study:&lt;/strong&gt; the problem, what you built, and the result. Even a side project can say "built to solve X, handles Y".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Put a clear next step on every page:&lt;/strong&gt; "Need something like this? Let's talk."&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  3. Find your first lead (it probably isn't a job board)
&lt;/h2&gt;

&lt;p&gt;Job boards and freelance marketplaces are crowded and push prices down. First clients more often come from people who already know or trust you:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Your existing network:&lt;/strong&gt; former coworkers, classmates, family businesses, local shops you already buy from.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Communities you're already in:&lt;/strong&gt; Discord servers, local dev groups, industry forums for the niche you picked in step 1.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Showing your work in public:&lt;/strong&gt; posting what you build, like this article, puts you in front of people before you ever pitch them.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;When you reach out, keep it short and specific:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Hi [name], I noticed [specific thing, e.g. your booking form is a Google Form]. I build [your offer] and could [specific improvement] in about [timeframe]. Want me to send a quick example of how that would look?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Specific beats clever. Point at a real problem you noticed.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Price without panic
&lt;/h2&gt;

&lt;p&gt;Your first price will feel wrong whatever you pick. Some guardrails:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Price the project, not your hours.&lt;/strong&gt; A fixed price for a defined scope is easier for the client to say yes to, and it rewards you for working efficiently.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Work out your floor.&lt;/strong&gt; Estimate the hours, multiply by the lowest hourly rate you'd accept, and add a buffer for revisions and communication.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't compete on cheap.&lt;/strong&gt; The lowest price attracts the clients who are hardest to work with.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Raise it on the second client.&lt;/strong&gt; The first project is partly paying for your learning. Don't lock that price in forever.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  5. Write a one-page proposal and protect yourself
&lt;/h2&gt;

&lt;p&gt;You don't need a 20-page document. One page with these sections is enough:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The problem&lt;/strong&gt;, in the client's own words&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What you'll deliver&lt;/strong&gt;, as a specific list&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What's not included&lt;/strong&gt;, which is just as important&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Timeline and milestones&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Price and payment terms&lt;/strong&gt;, for example 50% upfront and 50% on delivery&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Then make sure your agreement covers the clauses that prevent most scope creep:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;How many revision rounds are included&lt;/li&gt;
&lt;li&gt;How new requests are handled ("quoted separately as a change request")&lt;/li&gt;
&lt;li&gt;When ownership of the code transfers (usually on final payment)&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  6. Deliver so one client turns into three
&lt;/h2&gt;

&lt;p&gt;The first project isn't just income. It's your first testimonial and your first referral source.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Communicate more than feels necessary:&lt;/strong&gt; short weekly updates, even when nothing is blocked.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hand over properly:&lt;/strong&gt; a short guide, credentials, and a walkthrough call.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ask for a testimonial and a referral&lt;/strong&gt; while they're happy, right after launch: "Do you know anyone else who'd find this useful?"&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A happy first client who introduces you to two people is worth more than any marketplace profile.&lt;/p&gt;




&lt;h2&gt;
  
  
  Want the full playbook?
&lt;/h2&gt;

&lt;p&gt;I turned all of this into &lt;strong&gt;&lt;a href="https://www.vicentereyes.org/playbook" rel="noopener noreferrer"&gt;From Hobby to Hired: The First-Client Playbook&lt;/a&gt;&lt;/strong&gt;, a practical PDF with more detail on each step, the outreach scripts, and the proposal structure.&lt;/p&gt;

&lt;p&gt;It's ₱499 / $12 during launch (regular ₱799 / $19), with an instant download after payment.&lt;/p&gt;

&lt;p&gt;👉 &lt;strong&gt;&lt;a href="https://www.vicentereyes.org/playbook" rel="noopener noreferrer"&gt;Get the playbook&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If you've already landed your first client, I'd love to hear how it happened in the comments. And if you're still working toward it, which step feels hardest right now?&lt;/p&gt;

</description>
      <category>freelance</category>
      <category>career</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Zero-Downtime Django Deploys on One Small Droplet</title>
      <dc:creator>Vicente G. Reyes</dc:creator>
      <pubDate>Tue, 27 Oct 2026 02:36:39 +0000</pubDate>
      <link>https://dev.to/highcenburg/zero-downtime-django-deploys-on-one-small-droplet-58mo</link>
      <guid>https://dev.to/highcenburg/zero-downtime-django-deploys-on-one-small-droplet-58mo</guid>
      <description>&lt;p&gt;GiggleGigs is a job board I run: Django + DRF on the backend, React + Vite on the front (on Vercel), Celery for background work, all in Docker Compose on a single &lt;strong&gt;2 GB DigitalOcean droplet&lt;/strong&gt;, with Traefik in front and Cloudflare in front of that.&lt;/p&gt;

&lt;p&gt;In the &lt;a href="https://www.vicentereyes.org/blog/stop-building-docker-images-on-your-2-gb-droplet-1bf41c6a" rel="noopener noreferrer"&gt;last post&lt;/a&gt; I moved image builds off the droplet and into GitHub Actions. That stopped deploys from freezing the server. It didn't stop them from taking the site down.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why 99.99% means deploys can't cause any downtime
&lt;/h2&gt;

&lt;p&gt;99.99% uptime allows about &lt;strong&gt;4.3 minutes of downtime a month&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;I measured a normal deploy with a loop that requested the API every 5 seconds: &lt;strong&gt;about two minutes&lt;/strong&gt; of &lt;code&gt;no available server&lt;/code&gt; from Traefik. I deploy several times a day. At that rate, a single morning of deploys would use up the month's budget.&lt;/p&gt;

&lt;p&gt;So "faster deploys" wasn't the goal. The goal was &lt;strong&gt;deploys with no downtime at all&lt;/strong&gt;, which left the 4.3 minutes for things I can't control.&lt;/p&gt;

&lt;h2&gt;
  
  
  Measure first
&lt;/h2&gt;

&lt;p&gt;Everything below is based on one simple measurement: a loop on my laptop that requested the API's health endpoint every 5 seconds while a deploy ran, plus a request to a site that's always up (google.com) on each tick as a &lt;strong&gt;control&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="nb"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; +%T&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="nt"&gt;-m&lt;/span&gt; 5 &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s1"&gt;'%{http_code}'&lt;/span&gt; https://api.example.com/healthz/&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="nt"&gt;-m&lt;/span&gt; 5 &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s1"&gt;'%{http_code}'&lt;/span&gt; https://www.google.com/&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  &lt;span class="nb"&gt;sleep &lt;/span&gt;5
&lt;span class="k"&gt;done&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; uptime.log
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The control column matters. My own connection dropped twice during the day, and without the control those drops would have looked like site outages.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a deploy went down
&lt;/h2&gt;

&lt;p&gt;The old deploy ran &lt;code&gt;docker compose up -d&lt;/code&gt;, which:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;stops the only Django container,&lt;/li&gt;
&lt;li&gt;starts a new one,&lt;/li&gt;
&lt;li&gt;whose start script runs &lt;code&gt;collectstatic&lt;/code&gt; before gunicorn starts listening.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Traefik had nothing to send requests to until step 3 finished. That's two minutes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: Collect static files when the image is built
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;collectstatic&lt;/code&gt; doesn't need a database or real secrets. It only needs the settings module to import. So it now runs in the Dockerfile, which runs in CI, with placeholder env vars that exist only for that &lt;code&gt;RUN&lt;/code&gt; step:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="c"&gt;# Collect static files at build time so a new container starts&lt;/span&gt;
&lt;span class="c"&gt;# serving in seconds. Production settings need these env vars to import,&lt;/span&gt;
&lt;span class="c"&gt;# but collectstatic never uses them, so placeholders are enough.&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;&lt;span class="nv"&gt;DJANGO_SETTINGS_MODULE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"config.settings.production"&lt;/span&gt; &lt;span class="se"&gt;\
&lt;/span&gt;  &lt;span class="nv"&gt;DJANGO_SECRET_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"build-only"&lt;/span&gt; &lt;span class="se"&gt;\
&lt;/span&gt;  &lt;span class="nv"&gt;DATABASE_URL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"postgres://build:build@localhost:5432/build"&lt;/span&gt; &lt;span class="se"&gt;\
&lt;/span&gt;  &lt;span class="c"&gt;# ...the rest of the required settings...&lt;/span&gt;
  python manage.py collectstatic --noinput
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The start script went from about 25 lines to one:&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;exec&lt;/span&gt; /usr/local/bin/gunicorn config.wsgi &lt;span class="nt"&gt;--bind&lt;/span&gt; 0.0.0.0:5000 &lt;span class="nt"&gt;--chdir&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/app &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--timeout&lt;/span&gt; 90 &lt;span class="nt"&gt;--graceful-timeout&lt;/span&gt; 90 &lt;span class="nt"&gt;--no-control-socket&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A new container now passes its healthcheck in &lt;strong&gt;13–20 seconds&lt;/strong&gt; on my laptop and &lt;strong&gt;about 43 seconds&lt;/strong&gt; on the droplet, instead of about two minutes.&lt;/p&gt;

&lt;p&gt;One surprise: I also tried running &lt;code&gt;django-compressor&lt;/code&gt;'s &lt;code&gt;compress&lt;/code&gt; at build time, and it failed with &lt;code&gt;No 'compress' template tags found&lt;/code&gt;. None of my templates use it. It must have been switched off in production, or the old start script would have crashed. Moving work into the build is a good way to find steps that never did anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: A healthcheck, and a second container
&lt;/h2&gt;

&lt;p&gt;Then a rolling deploy: start a second Django container on the new image, wait until it's healthy, and only then stop the old one. The new container gets a Docker healthcheck on the existing &lt;code&gt;/healthz/&lt;/code&gt; endpoint:&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;django&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ghcr.io/you/app-django:${IMAGE_TAG:-latest}&lt;/span&gt;
  &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/start&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;test&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;CMD&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;python&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;-c&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;import&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;urllib.request;&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;urllib.request.urlopen(urllib.request.Request('http://localhost:5000/healthz/',&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;headers={'Host':&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;'api.example.com'}),&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;timeout=3)"&lt;/span&gt;
    &lt;span class="na"&gt;interval&lt;/span&gt;&lt;span class="pi"&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="s"&gt;5s&lt;/span&gt;
    &lt;span class="na"&gt;retries&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;3&lt;/span&gt;
    &lt;span class="na"&gt;start_period&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;60s&lt;/span&gt;
  &lt;span class="na"&gt;stop_grace_period&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;90s&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The image has no &lt;code&gt;curl&lt;/code&gt;, so the check uses Python. The &lt;code&gt;Host&lt;/code&gt; header has to be one of &lt;code&gt;ALLOWED_HOSTS&lt;/code&gt;, and &lt;code&gt;/healthz/&lt;/code&gt; has to be in &lt;code&gt;SECURE_REDIRECT_EXEMPT&lt;/code&gt;, or the check gets a 301 and fails.&lt;/p&gt;

&lt;p&gt;The deploy script uses Compose's &lt;code&gt;--scale&lt;/code&gt; and &lt;code&gt;--no-recreate&lt;/code&gt; flags, which most people never touch:&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;old_container&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;docker compose ps &lt;span class="nt"&gt;--quiet&lt;/span&gt; django&lt;span class="si"&gt;)&lt;/span&gt;
docker compose run &lt;span class="nt"&gt;--rm&lt;/span&gt; django python manage.py migrate
docker compose up &lt;span class="nt"&gt;--detach&lt;/span&gt; &lt;span class="nt"&gt;--no-deps&lt;/span&gt; &lt;span class="nt"&gt;--no-recreate&lt;/span&gt; &lt;span class="nt"&gt;--scale&lt;/span&gt; &lt;span class="nv"&gt;django&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;2 django
&lt;span class="nv"&gt;new_container&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;docker compose ps &lt;span class="nt"&gt;--quiet&lt;/span&gt; django | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;--invert-match&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;old_container&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="c"&gt;# ...poll `docker inspect --format '{{.State.Health.Status}}'` until healthy...&lt;/span&gt;
docker stop &lt;span class="nt"&gt;--timeout&lt;/span&gt; 90 &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;old_container&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
docker &lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;old_container&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
docker compose up &lt;span class="nt"&gt;--detach&lt;/span&gt; &lt;span class="nt"&gt;--no-deps&lt;/span&gt; &lt;span class="nt"&gt;--no-recreate&lt;/span&gt; &lt;span class="nt"&gt;--scale&lt;/span&gt; &lt;span class="nv"&gt;django&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1 django
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the new container never becomes healthy, the script removes it and exits non-zero. The old container keeps serving, and the deploy shows as failed in GitHub Actions.&lt;/p&gt;

&lt;p&gt;I rehearsed it locally: the production image behind Traefik, 4 parallel request loops, two deploys in a row. &lt;strong&gt;5,624 requests, all 200.&lt;/strong&gt; A deliberately broken image was rejected while the old container kept serving. I shipped it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The first live deploy still dropped requests
&lt;/h2&gt;

&lt;p&gt;Production disagreed:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Time (UTC)&lt;/th&gt;
&lt;th&gt;API&lt;/th&gt;
&lt;th&gt;Control&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;08:16:33 → 08:17:12&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;503&lt;/code&gt; ×3, &lt;code&gt;000&lt;/code&gt; ×1&lt;/td&gt;
&lt;td&gt;200&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;08:17:30 → 08:17:46&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;521&lt;/code&gt; ×4&lt;/td&gt;
&lt;td&gt;200&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The 521s were expected. That's Cloudflare reporting that Traefik was restarting for its own config change, once. The 503s were not expected. They happened while the new Django container was starting, which was exactly the part the rolling deploy was supposed to handle.&lt;/p&gt;

&lt;h3&gt;
  
  
  Reproducing it locally
&lt;/h3&gt;

&lt;p&gt;The local rehearsal had passed because my laptop is fast, and a new container there is ready almost as soon as it starts. The droplet is slow. Compose can limit a container's CPU, so I ran the rehearsal again with Django capped to 0.3 CPU:&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;django&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;cpus&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0.3"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That reproduced it right away:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Time&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;16:37:25–36&lt;/td&gt;
&lt;td&gt;4 requests hung for 10 seconds and timed out&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;16:37:47&lt;/td&gt;
&lt;td&gt;35 instant &lt;code&gt;503&lt;/code&gt;s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Two bugs, one cause
&lt;/h3&gt;

&lt;p&gt;My Traefik config routed to Django through a single URL:&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;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;&lt;code&gt;django&lt;/code&gt; is a Docker DNS name. With two containers running, &lt;strong&gt;it resolves to both, including the one that's still booting&lt;/strong&gt;. Gunicorn accepts connections before its worker is ready, so:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Requests that reached the new container waited in its queue and hung.&lt;/li&gt;
&lt;li&gt;Traefik's health probe also reached the new container sometimes, timed out, and marked &lt;strong&gt;the whole &lt;code&gt;django&lt;/code&gt; server&lt;/strong&gt; down, old container included. That's &lt;code&gt;no available server&lt;/code&gt;, the 503s.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The Docker healthcheck I'd added only told the &lt;em&gt;deploy script&lt;/em&gt; when the new container was ready. Traefik never saw it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: Route only to healthy containers
&lt;/h2&gt;

&lt;p&gt;Traefik's &lt;strong&gt;Docker provider&lt;/strong&gt; solves this directly: it skips containers whose Docker healthcheck is still &lt;code&gt;starting&lt;/code&gt; or &lt;code&gt;unhealthy&lt;/code&gt;. So Django is now discovered through labels instead of a hardcoded URL:&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;django&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;traefik.enable=true&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;traefik.http.services.django.loadbalancer.server.port=5000&lt;/span&gt;

&lt;span class="na"&gt;traefik&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/var/run/docker.sock:/var/run/docker.sock:ro&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# traefik.yml&lt;/span&gt;
&lt;span class="na"&gt;http&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;routers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;web-secure-router&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;rule&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Host(`api.example.com`)'&lt;/span&gt;
      &lt;span class="na"&gt;service&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;django@docker&lt;/span&gt;   &lt;span class="c1"&gt;# from the labels above&lt;/span&gt;
&lt;span class="na"&gt;providers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;file&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;filename&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/etc/traefik/traefik.yml&lt;/span&gt;
  &lt;span class="na"&gt;docker&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;exposedByDefault&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The router, TLS and middlewares stay in the file provider. Only the service moved.&lt;/p&gt;

&lt;p&gt;A word on that socket mount: &lt;code&gt;:ro&lt;/code&gt; makes the socket file read-only, but it doesn't limit the Docker API, so Traefik can still do anything Docker can. If Traefik were ever compromised, so is the host. On a single box I own, I accepted that. A Docker socket proxy that only allows container listings is the stricter option.&lt;/p&gt;

&lt;p&gt;With the Docker provider, the 503s and the startup hangs were gone. In the slow-CPU rehearsal, &lt;strong&gt;6 failures were left, all at the moment the old container stopped.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: Drain before you stop
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;docker stop&lt;/code&gt; sends SIGTERM, and gunicorn stops accepting connections. But Traefik still sees the old container as healthy until it exits, so for a few seconds it keeps sending requests to a server that's refusing them.&lt;/p&gt;

&lt;p&gt;The fix is to take the container out of rotation &lt;em&gt;before&lt;/em&gt; sending SIGTERM. Traefik already follows the healthcheck, so the healthcheck becomes the off switch:&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;healthcheck&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;test&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;CMD&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;python&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;-c&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;import&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;os,&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;sys,&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;urllib.request;&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;os.path.exists('/tmp/draining')&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;and&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;sys.exit(1);&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;urllib.request.urlopen(...)"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And in the deploy script:&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;sleep &lt;/span&gt;5                                         &lt;span class="c"&gt;# let Traefik pick up the new container&lt;/span&gt;
docker &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;old_container&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nb"&gt;touch&lt;/span&gt; /tmp/draining
&lt;span class="c"&gt;# ...poll until Docker reports it unhealthy...&lt;/span&gt;
&lt;span class="nb"&gt;sleep &lt;/span&gt;5                                         &lt;span class="c"&gt;# let Traefik drop it&lt;/span&gt;
docker stop &lt;span class="nt"&gt;--timeout&lt;/span&gt; 90 &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;old_container&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;     &lt;span class="c"&gt;# gunicorn finishes in-flight requests&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;By the time gunicorn gets SIGTERM, nothing new is being sent to it, and the 90-second grace period lets requests already in flight finish. My gunicorn &lt;code&gt;--timeout&lt;/code&gt; is 90 seconds because some requests wait on an LLM.&lt;/p&gt;

&lt;h3&gt;
  
  
  The rehearsal, with the CPU capped
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Setup&lt;/th&gt;
&lt;th&gt;Requests&lt;/th&gt;
&lt;th&gt;Failed&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;DNS routing + Traefik health probe&lt;/td&gt;
&lt;td&gt;1,639&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;39&lt;/strong&gt; (35 × 503, 4 × 10 s hangs)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Docker provider&lt;/td&gt;
&lt;td&gt;2,847&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;6&lt;/strong&gt; (all when the old container stopped)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Docker provider + drain, 5 rollouts in a row&lt;/td&gt;
&lt;td&gt;12,957&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  A gunicorn 26 surprise
&lt;/h3&gt;

&lt;p&gt;While reading container logs I found this on every start:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[ERROR] Control server error: [Errno 13] Permission denied: '/nonexistent'
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Gunicorn 26 added a control socket, which defaults to a path under &lt;code&gt;$HOME&lt;/code&gt;. cookiecutter-django's &lt;code&gt;django&lt;/code&gt; system user doesn't have a home directory. Nothing broke, but &lt;code&gt;--no-control-socket&lt;/code&gt; turns it off.&lt;/p&gt;

&lt;h2&gt;
  
  
  The transitional deploy
&lt;/h2&gt;

&lt;p&gt;The first deploy of this change was still messy. It &lt;em&gt;had&lt;/em&gt; to be: the Traefik that was running was the old one, with DNS routing, and the old Django container had no drain logic.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Time (UTC)&lt;/th&gt;
&lt;th&gt;What happened&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;09:44:57–09:45:10&lt;/td&gt;
&lt;td&gt;503s during startup (old Traefik, old routing)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;09:45:45 → 09:47:23&lt;/td&gt;
&lt;td&gt;Drain step waited out its limit; the old container's healthcheck had no drain check&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;09:47:26–09:47:38&lt;/td&gt;
&lt;td&gt;521/525 while Traefik restarted on its new config&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If you change how traffic is routed, the deploy that ships the change runs on the old routing. Plan for one bad deploy, and don't measure that one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Result
&lt;/h2&gt;

&lt;p&gt;The next deploy was the first to run entirely on the final setup. It was an ordinary backend change: a query fix for the Browse Jobs page.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Time (UTC)&lt;/th&gt;
&lt;th&gt;Step&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;09:55:41&lt;/td&gt;
&lt;td&gt;Deploy job starts on the droplet (pull, small image builds, migrate)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;09:56:36&lt;/td&gt;
&lt;td&gt;New Django container starts next to the old one&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;09:57:24&lt;/td&gt;
&lt;td&gt;New container healthy; old one starts draining&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;~09:58:14&lt;/td&gt;
&lt;td&gt;Old container stopped; Celery and Flower restart&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;09:58:34&lt;/td&gt;
&lt;td&gt;Deploy job done&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;And the uptime loop:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Window&lt;/th&gt;
&lt;th&gt;API probes&lt;/th&gt;
&lt;th&gt;Failed&lt;/th&gt;
&lt;th&gt;Control&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Deploy job, 09:55:41 → 09:58:34&lt;/td&gt;
&lt;td&gt;44&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;all 200&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Before, during and after, 09:54 → 10:00&lt;/td&gt;
&lt;td&gt;64&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;all 200&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Zero failed probes, and no Traefik restart, because nothing in its config changed.&lt;/p&gt;

&lt;p&gt;For comparison, the morning's deploy on the original setup had about &lt;strong&gt;two minutes&lt;/strong&gt; of &lt;code&gt;no available server&lt;/code&gt;, and the transitional deploy had 8 failed probes across three bursts.&lt;/p&gt;

&lt;p&gt;To be clear about what that measures: one probe every 5 seconds can't see a blip shorter than 5 seconds. The rehearsal fills that gap. There, two request loops ran continuously against Django capped to 0.3 CPU, and 12,957 requests across five rollouts had zero failures. Together, the two give me confidence the rollout itself no longer drops requests.&lt;/p&gt;

&lt;p&gt;The deploy job went from 1m 47s to 2m 53s. The extra minute is mostly waiting for the new container to become healthy and for the old one to drain. That's an easy trade for no downtime.&lt;/p&gt;

&lt;h2&gt;
  
  
  What 99.99% still needs
&lt;/h2&gt;

&lt;p&gt;Zero-downtime deploys take the biggest planned cause of downtime out of the budget. Several things are still left:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;An independent monitor.&lt;/strong&gt; My curl loop was fine for watching a deploy, but to &lt;em&gt;claim&lt;/em&gt; 99.99%, you need something that checks every minute, all month, from outside your own network.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Backward-compatible migrations.&lt;/strong&gt; The old container keeps serving while the new migrations run. Adding a column is fine. Renaming or dropping one needs two deploys: first ship code that stops using it, then remove it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One host is still one host.&lt;/strong&gt; A hypervisor reboot, a kernel update or running out of memory will take the site down, and rolling deploys can't help with that. On a single droplet you can avoid causing downtime yourself, but you can't guarantee against it.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Measure with a control.&lt;/strong&gt; Otherwise your own Wi-Fi shows up as site downtime.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rehearse on hardware as slow as production.&lt;/strong&gt; A container that starts quickly will hide every timing bug. &lt;code&gt;cpus: "0.3"&lt;/code&gt; found mine in one run.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Routing to a DNS name isn't the same as routing to healthy containers.&lt;/strong&gt; Docker DNS includes containers that are still starting.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A Docker healthcheck only helps if your proxy reads it.&lt;/strong&gt; Traefik's Docker provider does; a hardcoded URL doesn't.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Drain before SIGTERM.&lt;/strong&gt; Take the old container out of rotation first, then stop it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The deploy that changes routing runs on the old routing.&lt;/strong&gt; Expect one rough deploy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Move work to build time.&lt;/strong&gt; It makes containers start faster and shows you which steps never did anything.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;GiggleGigs is a job board for gig and contract work: &lt;a href="https://gigglegigs.com" rel="noopener noreferrer"&gt;gigglegigs.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>django</category>
      <category>devops</category>
      <category>docker</category>
    </item>
    <item>
      <title>Stop Building Docker Images on Your 2 GB Droplet</title>
      <dc:creator>Vicente G. Reyes</dc:creator>
      <pubDate>Mon, 26 Oct 2026 10:03:52 +0000</pubDate>
      <link>https://dev.to/highcenburg/stop-building-docker-images-on-your-2-gb-droplet-4e57</link>
      <guid>https://dev.to/highcenburg/stop-building-docker-images-on-your-2-gb-droplet-4e57</guid>
      <description>&lt;p&gt;GiggleGigs is a job board I run: Django + DRF on the backend, React + Vite on the front, Celery for background work, all in Docker, on a single &lt;strong&gt;2 GB DigitalOcean droplet&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Deploys were the cookiecutter-django default: GitHub Actions SSHes into the droplet, runs &lt;code&gt;git pull&lt;/code&gt;, then &lt;code&gt;docker compose build&lt;/code&gt;, then restarts everything. It's simple, and it worked fine right up to the first time it didn't.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem: the droplet was also the build server
&lt;/h2&gt;

&lt;p&gt;Most deploys only change application code, so Docker reuses the cached pip layer and the build finishes quickly. But any change to &lt;code&gt;requirements/*.txt&lt;/code&gt; invalidates that layer, and the droplet then has to rebuild &lt;strong&gt;every wheel&lt;/strong&gt; from scratch.&lt;/p&gt;

&lt;p&gt;On 2 GB of RAM, that's dangerous. One build compiled &lt;code&gt;psycopg-c&lt;/code&gt; from source, ran the box out of memory, and froze it so completely that SSH stopped responding. I had to power-cycle it from the DigitalOcean dashboard.&lt;/p&gt;

&lt;p&gt;My quick fixes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Added 2 GB of swap.&lt;/li&gt;
&lt;li&gt;Switched to &lt;code&gt;psycopg[binary]&lt;/code&gt; so nothing compiled from source.&lt;/li&gt;
&lt;li&gt;Raised the SSH action's &lt;code&gt;command_timeout&lt;/code&gt; to 30 minutes, because full rebuilds could outlast the default 10.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That helped, but it treated the symptom. Then I moved the project from Python 3.11 to 3.14 and updated 16 dependencies in one go. That changed the base image &lt;strong&gt;and&lt;/strong&gt; every pin, so the droplet had to download a new Python image and reinstall the whole dependency tree while also serving production traffic. I forgot to stop the containers first, so all I could do was watch and hope.&lt;/p&gt;

&lt;p&gt;It survived. But "it survived" isn't a deploy strategy. A server's job is to run the app, and building images is a separate job that belongs on a separate machine.&lt;/p&gt;

&lt;h2&gt;
  
  
  The goal
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Build the Django image on GitHub's runners, which have plenty of RAM and cost nothing for this.&lt;/li&gt;
&lt;li&gt;Push it to &lt;strong&gt;GitHub Container Registry (GHCR)&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Have the droplet &lt;strong&gt;pull&lt;/strong&gt; the image and restart. No compiling there.&lt;/li&gt;
&lt;li&gt;Make rollbacks one command.&lt;/li&gt;
&lt;li&gt;Keep changes to the compose file small. It's the file I least want to get wrong.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Step 1: One image instead of four
&lt;/h2&gt;

&lt;p&gt;Like most cookiecutter-django projects, my &lt;code&gt;production.yml&lt;/code&gt; had four services built from the same Dockerfile, each with its own image name:&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;django&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;context&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;.&lt;/span&gt;
    &lt;span class="na"&gt;dockerfile&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;./compose/production/django/Dockerfile&lt;/span&gt;
  &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;job_board_production_django&lt;/span&gt;
  &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/start&lt;/span&gt;

&lt;span class="na"&gt;celeryworker&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;context&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;.&lt;/span&gt;
    &lt;span class="na"&gt;dockerfile&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;./compose/production/django/Dockerfile&lt;/span&gt;
  &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;job_board_production_celeryworker&lt;/span&gt;
  &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/start-celeryworker&lt;/span&gt;

&lt;span class="c1"&gt;# ...same again for celerybeat and flower&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;They're the same image. Only the &lt;code&gt;command&lt;/code&gt; is different. So now all four point at a single registry image:&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;django&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="c1"&gt;# Built and pushed by the Deploy workflow; the droplet only pulls it.&lt;/span&gt;
  &lt;span class="c1"&gt;# Roll back with: IMAGE_TAG=&amp;lt;commit sha&amp;gt; docker compose -f production.yml up -d&lt;/span&gt;
  &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ghcr.io/reyesvicente/gigglegigs-django:${IMAGE_TAG:-latest}&lt;/span&gt;
  &lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;context&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;.&lt;/span&gt;
    &lt;span class="na"&gt;dockerfile&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;./compose/production/django/Dockerfile&lt;/span&gt;
  &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/start&lt;/span&gt;

&lt;span class="na"&gt;celeryworker&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ghcr.io/reyesvicente/gigglegigs-django:${IMAGE_TAG:-latest}&lt;/span&gt;
  &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/start-celeryworker&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I kept &lt;code&gt;build:&lt;/code&gt; on &lt;code&gt;django&lt;/code&gt; only. If GHCR is ever down, I can still run &lt;code&gt;docker compose build django&lt;/code&gt; on the droplet as a last resort. The other three just reuse the image.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;${IMAGE_TAG:-latest}&lt;/code&gt; is the rollback hook. Normally it resolves to &lt;code&gt;latest&lt;/code&gt;, and setting it to an older commit SHA pins every service to that build.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Build and push in GitHub Actions
&lt;/h2&gt;

&lt;p&gt;The deploy workflow already ran after CI passed on &lt;code&gt;main&lt;/code&gt;. It's now two jobs: &lt;code&gt;build&lt;/code&gt; runs on a GitHub runner, and &lt;code&gt;deploy&lt;/code&gt; SSHes into the droplet.&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;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Deploy to DigitalOcean&lt;/span&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;concurrency&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;group&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;deploy-production&lt;/span&gt;
  &lt;span class="na"&gt;cancel-in-progress&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;

&lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;IMAGE&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ghcr.io/reyesvicente/gigglegigs-django&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;build&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&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;span class="na"&gt;permissions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;contents&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;read&lt;/span&gt;
      &lt;span class="na"&gt;packages&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;write&lt;/span&gt;

    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Checkout the commit CI tested&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v7&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;ref&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ github.event.workflow_run.head_sha }}&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;docker/setup-buildx-action@v4&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;docker/login-action@v4&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;registry&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ghcr.io&lt;/span&gt;
          &lt;span class="na"&gt;username&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ github.actor }}&lt;/span&gt;
          &lt;span class="na"&gt;password&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.GITHUB_TOKEN }}&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Build and push the Django image&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;docker/build-push-action@v7&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;context&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;.&lt;/span&gt;
          &lt;span class="na"&gt;file&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;./compose/production/django/Dockerfile&lt;/span&gt;
          &lt;span class="na"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
          &lt;span class="na"&gt;tags&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
            &lt;span class="s"&gt;${{ env.IMAGE }}:latest&lt;/span&gt;
            &lt;span class="s"&gt;${{ env.IMAGE }}:${{ github.event.workflow_run.head_sha }}&lt;/span&gt;
          &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;org.opencontainers.image.source=https://github.com/${{ github.repository }}&lt;/span&gt;
          &lt;span class="na"&gt;cache-from&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;type=gha,scope=django-production&lt;/span&gt;
          &lt;span class="na"&gt;cache-to&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;type=gha,scope=django-production,mode=max&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Some of these lines matter more than they look.&lt;/p&gt;

&lt;h3&gt;
  
  
  Gotcha #1: &lt;code&gt;workflow_run&lt;/code&gt; doesn't check out what CI tested
&lt;/h3&gt;

&lt;p&gt;A workflow triggered by &lt;code&gt;workflow_run&lt;/code&gt; runs in the context of the default branch. A plain &lt;code&gt;actions/checkout&lt;/code&gt; gives you the &lt;strong&gt;current&lt;/strong&gt; tip of &lt;code&gt;main&lt;/code&gt;, which isn't necessarily the commit CI just approved. If two merges land close together, you could build and ship code CI never saw.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;ref: ${{ github.event.workflow_run.head_sha }}&lt;/code&gt; pins the build to the exact commit that passed. I use the same SHA as the second image tag, so every image traces back to a tested commit.&lt;/p&gt;

&lt;h3&gt;
  
  
  Gotcha #2: Private repo, private package
&lt;/h3&gt;

&lt;p&gt;GiggleGigs is a private repo, so the GHCR package is private too. The &lt;code&gt;org.opencontainers.image.source&lt;/code&gt; label links the package to the repo, so the repo's workflows get access to it.&lt;/p&gt;

&lt;p&gt;If the first pull fails with &lt;code&gt;denied&lt;/code&gt;, open the package's settings on GitHub, go to &lt;strong&gt;Manage Actions access&lt;/strong&gt;, and give the repo Read access. You only do that once.&lt;/p&gt;

&lt;h3&gt;
  
  
  The cache
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;cache-from&lt;/code&gt; and &lt;code&gt;cache-to&lt;/code&gt; with &lt;code&gt;type=gha&lt;/code&gt; store BuildKit layers in the GitHub Actions cache. A push that only changes app code reuses the pip layer, the same way the droplet's local cache used to. The difference is that a requirements change now gets rebuilt on a machine that can handle it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: The droplet only pulls
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;  &lt;span class="na"&gt;deploy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;needs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;build&lt;/span&gt;
    &lt;span class="na"&gt;permissions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;contents&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;read&lt;/span&gt;
      &lt;span class="na"&gt;packages&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;read&lt;/span&gt;

    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Deploy via SSH&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;appleboy/ssh-action@v1.2.5&lt;/span&gt;
        &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;GHCR_USER&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ github.actor }}&lt;/span&gt;
          &lt;span class="c1"&gt;# Short-lived: GITHUB_TOKEN expires when this job ends.&lt;/span&gt;
          &lt;span class="na"&gt;GHCR_TOKEN&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.GITHUB_TOKEN }}&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;host&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.DO_HOST }}&lt;/span&gt;
          &lt;span class="na"&gt;username&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.DO_USERNAME }}&lt;/span&gt;
          &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.DO_SSH_KEY }}&lt;/span&gt;
          &lt;span class="na"&gt;envs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;GHCR_USER,GHCR_TOKEN&lt;/span&gt;
          &lt;span class="na"&gt;command_timeout&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;15m&lt;/span&gt;
          &lt;span class="na"&gt;script&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
            &lt;span class="s"&gt;set -e&lt;/span&gt;
            &lt;span class="s"&gt;cd ~/GiggleGigs&lt;/span&gt;
            &lt;span class="s"&gt;git pull origin main&lt;/span&gt;
            &lt;span class="s"&gt;echo "$GHCR_TOKEN" | docker login ghcr.io -u "$GHCR_USER" --password-stdin&lt;/span&gt;
            &lt;span class="s"&gt;docker compose -f production.yml pull django celeryworker celerybeat flower&lt;/span&gt;
            &lt;span class="s"&gt;docker logout ghcr.io&lt;/span&gt;
            &lt;span class="s"&gt;docker compose -f production.yml build postgres traefik awscli&lt;/span&gt;
            &lt;span class="s"&gt;docker compose -f production.yml run --rm django python manage.py migrate&lt;/span&gt;
            &lt;span class="s"&gt;docker compose -f production.yml up -d&lt;/span&gt;
            &lt;span class="s"&gt;docker image prune -f&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  No long-lived registry token on the server
&lt;/h3&gt;

&lt;p&gt;The droplet logs in to GHCR with the workflow's own &lt;code&gt;GITHUB_TOKEN&lt;/code&gt;, which &lt;code&gt;appleboy/ssh-action&lt;/code&gt; passes through via &lt;code&gt;envs&lt;/code&gt;. That token expires when the job ends. The script also runs &lt;code&gt;docker logout&lt;/code&gt; straight after the pull, so no credential is left in &lt;code&gt;~/.docker/config.json&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;I didn't need to create a personal access token or store a new secret.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why &lt;code&gt;git pull&lt;/code&gt; still runs
&lt;/h3&gt;

&lt;p&gt;The image contains the code, but the droplet still needs the current &lt;code&gt;production.yml&lt;/code&gt; and the Traefik config, and those come from the repo.&lt;/p&gt;

&lt;h3&gt;
  
  
  Gotcha #3: Don't push your Postgres image
&lt;/h3&gt;

&lt;p&gt;My first idea was to push &lt;strong&gt;every&lt;/strong&gt; image, including postgres, traefik and awscli. These are cookiecutter's small wrapper images: &lt;code&gt;FROM postgres:17&lt;/code&gt; plus a few maintenance scripts copied in.&lt;/p&gt;

&lt;p&gt;The problem is that a rebuilt image can get a new digest even when nothing in it changed. When &lt;code&gt;docker compose up -d&lt;/code&gt; sees a different image, it &lt;strong&gt;recreates the container&lt;/strong&gt;. That would restart the database on every deploy, for nothing.&lt;/p&gt;

&lt;p&gt;So those three still build on the droplet. They don't install anything, they're cached, and they finish in seconds without using much memory. When their Dockerfiles haven't changed, Docker reuses the same image and the containers keep running.&lt;/p&gt;

&lt;h3&gt;
  
  
  Gotcha #4: Overlapping deploys
&lt;/h3&gt;

&lt;p&gt;Earlier, two merges landed close together and two deploys ran &lt;code&gt;up -d&lt;/code&gt; at the same time. Compose left containers with hash-prefixed names like &lt;code&gt;3f2a…_gigglegigs-django-1&lt;/code&gt;, and the next deploy failed with "container name already in use".&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;concurrency&lt;/code&gt; group at the top of the workflow queues deploys instead:&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;concurrency&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;group&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;deploy-production&lt;/span&gt;
  &lt;span class="na"&gt;cancel-in-progress&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;cancel-in-progress: false&lt;/code&gt; matters. Cancelling a deploy halfway through a migration is worse than waiting for it to finish.&lt;/p&gt;

&lt;h3&gt;
  
  
  A step I deleted
&lt;/h3&gt;

&lt;p&gt;The old script had this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose &lt;span class="nt"&gt;-f&lt;/span&gt; production.yml run &lt;span class="nt"&gt;--rm&lt;/span&gt; django python manage.py collectstatic &lt;span class="nt"&gt;--noinput&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It runs in a throwaway container. &lt;code&gt;staticfiles/&lt;/code&gt; isn't a volume, so the collected files disappear when that container exits. Meanwhile the container's &lt;code&gt;/start&lt;/code&gt; script runs &lt;code&gt;collectstatic&lt;/code&gt; again anyway, before gunicorn starts. The step cost time on every deploy and did nothing.&lt;/p&gt;

&lt;h3&gt;
  
  
  Cleaning up
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;docker image prune -f&lt;/code&gt; removes the dangling images each deploy leaves behind. On a small droplet, old image layers fill the disk quietly until a deploy fails for lack of space.&lt;/p&gt;

&lt;p&gt;After the first deploy with the new setup, the old locally built images aren't tagged as dangling, so remove them by hand once:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker rmi job_board_production_django job_board_production_celeryworker job_board_production_celerybeat job_board_production_flower
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Rollbacks
&lt;/h2&gt;

&lt;p&gt;Every image is tagged with its commit SHA, so rolling back is one command on the droplet:&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;cd&lt;/span&gt; ~/GiggleGigs &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nv"&gt;IMAGE_TAG&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&amp;lt;commit sha&amp;gt; docker compose &lt;span class="nt"&gt;-f&lt;/span&gt; production.yml up &lt;span class="nt"&gt;-d&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Before, rolling back meant checking out an old commit on the server and rebuilding. That's the same memory-hungry build I'm trying to avoid, at the worst possible time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checking it before merging
&lt;/h2&gt;

&lt;p&gt;Before this reached &lt;code&gt;main&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;docker compose -f production.yml config --images&lt;/code&gt; showed the four app services resolving to the GHCR image, and &lt;code&gt;IMAGE_TAG=abc123&lt;/code&gt; switched all four to &lt;code&gt;:abc123&lt;/code&gt;. Postgres, Traefik, Redis and awscli were unchanged.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/rhysd/actionlint" rel="noopener noreferrer"&gt;actionlint&lt;/a&gt; passed on both workflow files.&lt;/li&gt;
&lt;li&gt;The production image had already been built and checked locally with production settings during the Python 3.14 upgrade, so the Dockerfile itself was known to work.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The first deploy
&lt;/h2&gt;

&lt;p&gt;I merged to &lt;code&gt;main&lt;/code&gt; and timed it. A loop on my laptop also hit the API every 5 seconds, so I could see exactly when the site went down and when it came back.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Step&lt;/th&gt;
&lt;th&gt;Where&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;Build the image and push it to GHCR&lt;/td&gt;
&lt;td&gt;GitHub runner&lt;/td&gt;
&lt;td&gt;2m 49s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pull, migrate, &lt;code&gt;up -d&lt;/code&gt;, prune&lt;/td&gt;
&lt;td&gt;Droplet&lt;/td&gt;
&lt;td&gt;1m 47s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The GHCR pull worked the first time.&lt;/strong&gt; The source label linked the package to the repo, so I didn't need to change any access settings.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Postgres, Traefik and Redis kept running.&lt;/strong&gt; Their small images rebuilt from cache to the same image, so Compose left those containers alone. Only the four app containers were recreated.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The droplet never built a wheel.&lt;/strong&gt; Its whole job came down to pulling the image and restarting containers. On a requirements change, the expensive part now runs on GitHub's runner instead of next to production traffic.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;docker image prune -f&lt;/code&gt; reclaimed 0 B.&lt;/strong&gt; That's expected on the first run, since the old images are still tagged under their old names. That's why the manual &lt;code&gt;docker rmi&lt;/code&gt; above is needed once.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What this doesn't fix
&lt;/h2&gt;

&lt;p&gt;This removes the &lt;strong&gt;risk&lt;/strong&gt; of the droplet freezing during a build. It doesn't remove the &lt;strong&gt;downtime&lt;/strong&gt; during the restart.&lt;/p&gt;

&lt;p&gt;My uptime loop got its last good response at 07:34:28 UTC. The API answered again at 07:36:24, so the site was down for &lt;strong&gt;about two minutes&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;When Compose replaces the Django container, Traefik has nothing to route to until the new one is ready. My &lt;code&gt;/start&lt;/code&gt; script runs &lt;code&gt;collectstatic&lt;/code&gt; and &lt;code&gt;django-compressor&lt;/code&gt;'s &lt;code&gt;compress&lt;/code&gt; before gunicorn starts listening, and during that time Traefik answers with a plain &lt;code&gt;no available server&lt;/code&gt;. Moving the build to CI doesn't touch any of that, because it all happens after the new container starts.&lt;/p&gt;

&lt;p&gt;Now that the image is built in CI, the fix is straightforward: run &lt;code&gt;collectstatic&lt;/code&gt; and &lt;code&gt;compress&lt;/code&gt; at build time, so the image ships with its static files ready and &lt;code&gt;/start&lt;/code&gt; only has to start gunicorn. After that comes a real zero-downtime switch with a healthcheck, starting the new container before stopping the old one. That's for another post.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Your production server shouldn't also be your build server.&lt;/strong&gt; On a small box, one requirements change can turn a deploy into an outage.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pin &lt;code&gt;workflow_run&lt;/code&gt; builds to &lt;code&gt;head_sha&lt;/code&gt;.&lt;/strong&gt; Otherwise you can ship a commit CI never tested.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One Dockerfile should mean one image.&lt;/strong&gt; Point every service that shares it at the same tag.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tag by commit SHA.&lt;/strong&gt; Rollbacks become a single &lt;code&gt;up -d&lt;/code&gt; with no rebuild.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use the workflow's own token&lt;/strong&gt; to log in on the server, and log out after pulling.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't push images whose digest will change for no reason.&lt;/strong&gt; Recreating Postgres on every deploy is a bad trade.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Remove steps that don't do anything.&lt;/strong&gt; A throwaway-container &lt;code&gt;collectstatic&lt;/code&gt; only looks useful.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;GiggleGigs is a job board for gig and contract work: &lt;a href="https://gigglegigs.com" rel="noopener noreferrer"&gt;gigglegigs.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>django</category>
      <category>docker</category>
      <category>devops</category>
    </item>
    <item>
      <title>Moving a Django App from Mailgun to Amazon SES</title>
      <dc:creator>Vicente G. Reyes</dc:creator>
      <pubDate>Mon, 26 Oct 2026 02:55:14 +0000</pubDate>
      <link>https://dev.to/highcenburg/moving-a-django-app-from-mailgun-to-amazon-ses-4beh</link>
      <guid>https://dev.to/highcenburg/moving-a-django-app-from-mailgun-to-amazon-ses-4beh</guid>
      <description>&lt;p&gt;GiggleGigs is a job board I run: Django + DRF on the backend, React + Vite on the front, Celery for background work, all in Docker. It sends the usual transactional email: verification, password resets, application updates, messages, and a daily job alert digest for seekers.&lt;/p&gt;

&lt;p&gt;All of that went through Mailgun's free tier, which caps you at &lt;strong&gt;100 emails a day&lt;/strong&gt;. That was fine until it wasn't. As the seeker count grew, the daily digest alone could eat the whole quota, and every new signup needs a verification email on top of that.&lt;/p&gt;

&lt;p&gt;So I moved to &lt;strong&gt;Amazon SES&lt;/strong&gt;. This post covers what that took, including a few gotchas that cost me time.&lt;/p&gt;

&lt;h2&gt;
  
  
  The goal
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Swap the email transport from Mailgun to SES (SESv2 API).&lt;/li&gt;
&lt;li&gt;Send from my own domain, with a custom MAIL FROM subdomain.&lt;/li&gt;
&lt;li&gt;Handle bounces and complaints properly. SES watches your bounce and complaint rates closely, and you get put on review if they climb.&lt;/li&gt;
&lt;li&gt;Keep Mailgun one env var away, so a rollback needs no code change.&lt;/li&gt;
&lt;li&gt;No change to templates or email content. Transport swap plus suppression handling, nothing else.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Step 1: The AWS side
&lt;/h2&gt;

&lt;p&gt;In the SES console, in the region you'll send from:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Verify your sending domain&lt;/strong&gt; as an identity with Easy DKIM, then add the three CNAME records at your DNS provider.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Set a custom MAIL FROM domain&lt;/strong&gt; on a subdomain. That's one MX record and one SPF TXT record. Your Return-Path then aligns with your domain instead of &lt;code&gt;amazonses.com&lt;/code&gt;, which helps DMARC.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Request production access.&lt;/strong&gt; New SES accounts start in the sandbox, where you can only send to verified addresses. Do this early, because approval isn't instant.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;You get two nice emails when DNS checks out: "DKIM setup SUCCESS" and "Custom MAIL FROM Domain Setup SUCCESS". Then it's time to write code.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Choosing the provider with an env var
&lt;/h2&gt;

&lt;p&gt;The project already used &lt;a href="https://anymail.dev/" rel="noopener noreferrer"&gt;django-anymail&lt;/a&gt;, which made this mostly a configuration change. To make the provider testable without loading all of &lt;code&gt;production.py&lt;/code&gt; (Sentry, Redis, S3, and so on), I put the choice in a small helper:&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="c1"&gt;# config/email_providers.py
&lt;/span&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;django.core.exceptions&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;ImproperlyConfigured&lt;/span&gt;

&lt;span class="n"&gt;SES_BACKEND&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;anymail.backends.amazon_ses.EmailBackend&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;MAILGUN_BACKEND&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;anymail.backends.mailgun.EmailBackend&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;email_provider_settings&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;env&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;provider&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;env&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;EMAIL_PROVIDER&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;default&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ses&lt;/span&gt;&lt;span class="sh"&gt;"&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="nf"&gt;lower&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;provider&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ses&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;SES_BACKEND&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;AMAZON_SES_CLIENT_PARAMS&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;region_name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;env&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SES_REGION&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;aws_access_key_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;env&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SES_ACCESS_KEY_ID&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;aws_secret_access_key&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;env&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SES_SECRET_ACCESS_KEY&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;AMAZON_SES_CONFIGURATION_SET_NAME&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;env&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SES_CONFIGURATION_SET&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;default&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;WEBHOOK_SECRET&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;env&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ANYMAIL_WEBHOOK_SECRET&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="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;provider&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;mailgun&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;MAILGUN_BACKEND&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;MAILGUN_API_KEY&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;env&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;MAILGUN_API_KEY&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;MAILGUN_SENDER_DOMAIN&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;env&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;MAILGUN_DOMAIN&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="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;ImproperlyConfigured&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;EMAIL_PROVIDER must be &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ses&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt; or &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;mailgun&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;, got &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;provider&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few deliberate choices:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Dedicated SES credentials.&lt;/strong&gt; The app already has AWS keys for S3 media storage. Separate variable names mean a least-privilege, SES-only IAM user, and no chance of one set quietly picking up the other.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;WEBHOOK_SECRET&lt;/code&gt; has no default.&lt;/strong&gt; If it's missing in production, the app fails at startup instead of serving an unauthenticated webhook.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rollback is &lt;code&gt;EMAIL_PROVIDER=mailgun&lt;/code&gt;&lt;/strong&gt; and a restart.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In &lt;code&gt;production.py&lt;/code&gt; it's a single line:&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;EMAIL_DELIVERY_BACKEND&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ANYMAIL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;email_provider_settings&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;env&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;EMAIL_BACKEND&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;notifications.email_backends.SuppressionFilterBackend&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;EMAIL_BACKEND&lt;/code&gt; isn't SES directly. More on that in a moment.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gotcha #1: The &lt;code&gt;[amazon-ses]&lt;/code&gt; extra wouldn't install
&lt;/h2&gt;

&lt;p&gt;The obvious change was &lt;code&gt;django-anymail[mailgun]&lt;/code&gt; → &lt;code&gt;django-anymail[amazon-ses]&lt;/code&gt;. The Docker build failed with a pip &lt;code&gt;ResolutionImpossible&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;In anymail 15.x, the &lt;code&gt;amazon-ses&lt;/code&gt; extra pulls in &lt;code&gt;boto3&lt;/code&gt; &lt;strong&gt;and&lt;/strong&gt; &lt;code&gt;amazon-s3-encryption-client-python&lt;/code&gt;. That second package requires &lt;code&gt;cryptography&amp;gt;=48&lt;/code&gt;, which clashed with the rest of my dependency tree. It's only used for &lt;em&gt;encrypted inbound mail stored in S3&lt;/em&gt;, which I don't receive. Anymail imports it lazily, so I skipped the extra and installed plain &lt;code&gt;django-anymail&lt;/code&gt; plus a pinned &lt;code&gt;boto3&lt;/code&gt; instead.&lt;/p&gt;

&lt;p&gt;I also moved anymail from &lt;code&gt;production.txt&lt;/code&gt; to &lt;code&gt;base.txt&lt;/code&gt;. Tests use Anymail's test backend, and the webhook URLs need to import in every environment.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: Bounces, complaints, and a suppression list
&lt;/h2&gt;

&lt;p&gt;SES reports bounces and complaints through SNS. Anymail has a tracking webhook that receives those SNS notifications, turns them into normalized events, and fires a &lt;code&gt;tracking&lt;/code&gt; signal.&lt;/p&gt;

&lt;p&gt;First, mount the URLs:&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="c1"&gt;# config/urls.py
&lt;/span&gt;&lt;span class="nf"&gt;path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;anymail/&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;include&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;anymail.urls&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then a small model to remember who not to email:&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;class&lt;/span&gt; &lt;span class="nc"&gt;EmailSuppression&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Model&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Reasons&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TextChoices&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;BOUNCED&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;bounced&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&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;Bounced&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;COMPLAINED&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;complained&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&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;Complained&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;email&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;EmailField&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&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;Email&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;unique&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;reason&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;CharField&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&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;Reason&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;max_length&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;choices&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;Reasons&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;choices&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;event_payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;JSONField&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&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;Event Payload&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;default&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;blank&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;DateTimeField&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&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;Created At&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;auto_now_add&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And a receiver:&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="nd"&gt;@receiver&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tracking&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;suppress_bounced_and_complained&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sender&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;esp_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;kwargs&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="nf"&gt;handle_tracking_event&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Gotcha #2: Anymail calls every SES bounce "bounced"
&lt;/h3&gt;

&lt;p&gt;I only wanted to suppress &lt;strong&gt;hard&lt;/strong&gt; bounces (address doesn't exist). A full mailbox or a temporary DNS failure is a &lt;em&gt;transient&lt;/em&gt; bounce and shouldn't lock someone out for good.&lt;/p&gt;

&lt;p&gt;Anymail normalizes both kinds to &lt;code&gt;event_type="bounced"&lt;/code&gt; with &lt;code&gt;reject_reason="bounced"&lt;/code&gt;. The difference only shows up in the raw SES payload, so you have to check it there:&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;is_hard_bounce&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;esp_event&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;esp_event&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;isinstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;esp_event&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;else&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;esp_event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;bounce&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="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;bounceType&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Permanent&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;handle_tracking_event&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&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;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;recipient&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;None&lt;/span&gt;
    &lt;span class="n"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;esp_event&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;isinstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;esp_event&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;else&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;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;event_type&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;complained&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;record_suppression&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;recipient&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;EmailSuppression&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Reasons&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;COMPLAINED&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;payload&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;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;event_type&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;bounced&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;is_hard_bounce&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;record_suppression&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;recipient&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;EmailSuppression&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Reasons&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;BOUNCED&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;info&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Ignoring non-permanent bounce for %s: %s&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;recipient&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;description&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;None&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Complaints always suppress. Someone hitting "Report spam" is the clearest signal you'll get.&lt;/p&gt;

&lt;h3&gt;
  
  
  Gotcha #3: The webhook host
&lt;/h3&gt;

&lt;p&gt;My first draft pointed the SNS subscription at the main domain. But the main domain serves the React SPA; Django lives on a separate API subdomain behind the reverse proxy. The subscription URL has to target whichever host actually reaches Django, at Anymail's &lt;code&gt;/anymail/amazon_ses/tracking/&lt;/code&gt; path, with the basic-auth credentials embedded in the URL.&lt;/p&gt;

&lt;p&gt;Check your reverse proxy before you paste a URL into SNS. Also leave &lt;strong&gt;raw message delivery off&lt;/strong&gt;, because Anymail expects the SNS envelope.&lt;/p&gt;

&lt;h3&gt;
  
  
  Gotcha #4: 401, not 400
&lt;/h3&gt;

&lt;p&gt;I wrote a test asserting that a request with no basic auth gets a 400. It got a &lt;strong&gt;401&lt;/strong&gt;. That's on purpose: SNS doesn't send credentials up front. It only retries with them after a &lt;code&gt;401&lt;/code&gt; challenge. A webhook that answered 400 there would never receive a single notification. Wrong credentials still get a 400. Anymail also auto-confirms the SNS subscription once the auth checks out.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: Enforcing suppression in one place
&lt;/h2&gt;

&lt;p&gt;Emails go out from all over the codebase: allauth (verification, password reset), a notification helper, job alerts, team invitations, billing receipts. Adding &lt;code&gt;if is_suppressed(email)&lt;/code&gt; to each one would be fragile. Someone adds a new email next month, and suppression silently stops applying to it.&lt;/p&gt;

&lt;p&gt;Instead I wrapped the real backend:&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;class&lt;/span&gt; &lt;span class="nc"&gt;SuppressionFilterBackend&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BaseEmailBackend&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;__init__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fail_silently&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;kwargs&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="nf"&gt;super&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;__init__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fail_silently&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;fail_silently&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;delivery&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;get_connection&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;EMAIL_DELIVERY_BACKEND&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fail_silently&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;fail_silently&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;kwargs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&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;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;delivery&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&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;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;delivery&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;send_messages&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;email_messages&lt;/span&gt;&lt;span class="p"&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;email_messages&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="nf"&gt;suppression_bypassed&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
            &lt;span class="n"&gt;email_messages&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;filter_suppressed_messages&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;email_messages&lt;/span&gt;&lt;span class="p"&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;email_messages&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;delivery&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send_messages&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;email_messages&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;filter_suppressed_messages&lt;/code&gt; runs one query for all recipients across a batch, removes suppressed addresses from &lt;code&gt;to&lt;/code&gt;/&lt;code&gt;cc&lt;/code&gt;/&lt;code&gt;bcc&lt;/code&gt;, and drops any message left with no recipients. Every &lt;code&gt;send_mail()&lt;/code&gt; call in the project now honors the list without knowing it exists.&lt;/p&gt;

&lt;h3&gt;
  
  
  The exception: requests the user explicitly made
&lt;/h3&gt;

&lt;p&gt;Here's a case I didn't want to get wrong. Say someone's mailbox was full last month, or they clicked "spam" by accident. Now they're suppressed and trying to reset their password. Silently dropping that email locks them out of their own account.&lt;/p&gt;

&lt;p&gt;So emails the user &lt;strong&gt;explicitly requests&lt;/strong&gt; (a password reset, or clicking "resend verification email") still go out. The automatic verification email sent at signup does not. I used a &lt;code&gt;ContextVar&lt;/code&gt;, so the decision is made where the request is handled, not inside the mail layer:&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;_bypass_suppression&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;ContextVar&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;email_suppression_bypass&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;default&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;


&lt;span class="nd"&gt;@contextmanager&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;allow_suppressed_recipients&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;_bypass_suppression&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;yield&lt;/span&gt;
    &lt;span class="k"&gt;finally&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;_bypass_suppression&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# PasswordResetRequestAPIView
&lt;/span&gt;&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nf"&gt;allow_suppressed_recipients&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;form&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;allauth sends these emails synchronously inside the request, so the context variable is still set when the backend runs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: Staying under the SES send rate
&lt;/h2&gt;

&lt;p&gt;SES gives every account a maximum send rate (emails per second). The job alert digest used to be one Celery task looping over every seeker and sending inline. A &lt;code&gt;rate_limit&lt;/code&gt; on that task would throttle the &lt;em&gt;task&lt;/em&gt;, not the emails it sends.&lt;/p&gt;

&lt;p&gt;So the digest task now creates each seeker's in-app notification and queues &lt;strong&gt;one email task per seeker&lt;/strong&gt;:&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;BULK_EMAIL_TASK_OPTIONS&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;rate_limit&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;EMAIL_BULK_SEND_RATE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;# e.g. "10/s", read from an env var
&lt;/span&gt;    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;autoretry_for&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="nb"&gt;Exception&lt;/span&gt;&lt;span class="p"&gt;,),&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;retry_backoff&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;retry_backoff_max&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;max_retries&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;


&lt;span class="nd"&gt;@shared_task&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;BULK_EMAIL_TASK_OPTIONS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;send_job_alert_email&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;notification_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;email_subject&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;email_context&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;notification&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Notification&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;objects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;select_related&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;recipient&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pk&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;notification_id&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;first&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;notification&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&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;send_notification_email&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;notification&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;email_subject&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;email_template&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;notifications/email/job_alert.html&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;email_context&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;email_context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;fail_silently&lt;/span&gt;&lt;span class="o"&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;It's still one email per seeker covering all their matches, never one email per job, and a test enforces that. Celery applies &lt;code&gt;rate_limit&lt;/code&gt; per worker, not globally. With one worker that's effectively a global limit; with several, divide the rate between them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gotcha #5: Django's test runner replaces your email backend
&lt;/h2&gt;

&lt;p&gt;My suppression tests passed for the webhook and failed for the backend. Emails to suppressed addresses were landing in &lt;code&gt;mail.outbox&lt;/code&gt; anyway.&lt;/p&gt;

&lt;p&gt;Django's &lt;code&gt;setup_test_environment()&lt;/code&gt; (which pytest-django calls) &lt;strong&gt;forces &lt;code&gt;EMAIL_BACKEND&lt;/code&gt; to locmem&lt;/strong&gt;, whatever your test settings say. My wrapper never ran. The fix is an autouse fixture:&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="c1"&gt;# conftest.py
&lt;/span&gt;&lt;span class="nd"&gt;@pytest.fixture&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;autouse&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;email_backend&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="c1"&gt;# Django's test setup forces EMAIL_BACKEND to locmem, which would bypass
&lt;/span&gt;    &lt;span class="c1"&gt;# the suppression filter. Restore it; it delivers to Anymail's test
&lt;/span&gt;    &lt;span class="c1"&gt;# backend (EMAIL_DELIVERY_BACKEND), which still fills mail.outbox.
&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;EMAIL_BACKEND&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;notifications.email_backends.SuppressionFilterBackend&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;with this in the test settings:&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;EMAIL_DELIVERY_BACKEND&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;anymail.backends.test.EmailBackend&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The test settings also define a throwaway &lt;code&gt;WEBHOOK_SECRET&lt;/code&gt;, so the webhook tests can send valid and invalid basic auth.&lt;/p&gt;

&lt;p&gt;Anymail's test backend still appends to &lt;code&gt;mail.outbox&lt;/code&gt;, so all 344 existing tests passed unchanged.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the tests cover
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;The provider toggle picks the SES or Mailgun backend, and rejects unknown values or a missing webhook secret.&lt;/li&gt;
&lt;li&gt;Real SNS-shaped payloads posted to the webhook: a permanent bounce suppresses, a complaint suppresses, a transient bounce doesn't.&lt;/li&gt;
&lt;li&gt;The webhook rejects missing auth (401) and wrong auth (400).&lt;/li&gt;
&lt;li&gt;The backend removes suppressed recipients, drops messages with none left, and respects the explicit-request bypass.&lt;/li&gt;
&lt;li&gt;A password reset still reaches a suppressed address.&lt;/li&gt;
&lt;li&gt;Job alerts skip suppressed seekers (the in-app notification still arrives) and send one digest per seeker.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;360 tests pass, and coverage stayed at 85%.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checking it before deploy
&lt;/h2&gt;

&lt;p&gt;Before deploying, I built the production image and loaded the production settings with dummy SES credentials, to confirm the chain resolves to the suppression wrapper, then Anymail's SES backend, then a &lt;code&gt;sesv2&lt;/code&gt; boto3 client, without sending anything.&lt;/p&gt;

&lt;p&gt;After deploy, the real check is Django's built-in &lt;code&gt;sendtestemail&lt;/code&gt; management command, run inside the production container with your own inbox as the recipient.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Treat the swap as config if you can.&lt;/strong&gt; django-anymail made changing providers mostly a settings change. Putting the choice behind a testable function made it a safe one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Handle bounces and complaints from day one.&lt;/strong&gt; SES will hold you to it, and a suppression list is about 100 lines of code.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Enforce rules at the transport layer, not at every call site.&lt;/strong&gt; A backend wrapper covers email paths you haven't written yet.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Read your library's normalization.&lt;/strong&gt; "bounced" didn't mean "hard bounce".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep a rollback.&lt;/strong&gt; Mailgun stays configured for a week of clean SES sending, then it goes.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;GiggleGigs is a job board for gig and contract work: &lt;a href="https://gigglegigs.com" rel="noopener noreferrer"&gt;gigglegigs.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>django</category>
      <category>aws</category>
      <category>python</category>
    </item>
    <item>
      <title>Case Study: Making My Portfolio Loud (Without Making It Slow)</title>
      <dc:creator>Vicente G. Reyes</dc:creator>
      <pubDate>Sat, 24 Oct 2026 02:11:23 +0000</pubDate>
      <link>https://dev.to/highcenburg/case-study-making-my-portfolio-loud-without-making-it-slow-1jdd</link>
      <guid>https://dev.to/highcenburg/case-study-making-my-portfolio-loud-without-making-it-slow-1jdd</guid>
      <description>&lt;p&gt;My portfolio worked fine. That was the problem.&lt;/p&gt;

&lt;p&gt;It was a tidy neubrutalist site: yellow, cyan and green cards, thick black borders, hard drop shadows, a dark-mode toggle. And it did its job: it helped me land &lt;a href="https://oatopia.co.uk" rel="noopener noreferrer"&gt;Oatopia&lt;/a&gt;, &lt;a href="https://rev6.fit/" rel="noopener noreferrer"&gt;Rev6&lt;/a&gt; and Digital Trek (Rodentia Pest Products) as clients.&lt;/p&gt;

&lt;p&gt;But it looked like a lot of other neubrutalist portfolios, and a UX audit I'd been working through kept pointing out the same thing. It was clean and it was forgettable.&lt;/p&gt;

&lt;p&gt;So I tried something I'd normally talk myself out of. I went maximalist. Kinetic type, colours that clash, stickers you can throw around, marquees that react to your scroll. Internally I called it &lt;strong&gt;the loud redesign&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;This post covers what I built, the rules that kept it usable, and the bugs that caught me out.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Live site:&lt;/strong&gt; &lt;a href="https://www.vicentereyes.org" rel="noopener noreferrer"&gt;vicentereyes.org&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The brief I gave myself
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Loud, but still a portfolio.&lt;/strong&gt; Every animation had to sit on top of real content: projects, services, testimonials, blog posts. Nothing decorative could block reading or contacting me.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No new backend.&lt;/strong&gt; Everything still comes from my existing Django REST API. The redesign was frontend only.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Respect the user.&lt;/strong&gt; &lt;code&gt;prefers-reduced-motion&lt;/code&gt; and touch devices get a proper experience, not a broken one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't tax every page for the homepage's party tricks.&lt;/strong&gt; Animation code ships in its own chunk.&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  Step 1: Prototype on a hidden route
&lt;/h2&gt;

&lt;p&gt;I didn't touch the live homepage at first. I built the whole thing on a hidden &lt;code&gt;/loud&lt;/code&gt; route so I could compare it with the real site side by side and throw it away if it didn't work.&lt;/p&gt;

&lt;p&gt;That first commit added a set of reusable motion primitives under &lt;code&gt;components/motion/&lt;/code&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Primitive&lt;/th&gt;
&lt;th&gt;What it does&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;KineticText&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Splits a headline into letters with a staggered spring entrance; letters get pushed away from the cursor&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Marquee&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Infinite ticker whose speed and skew follow scroll velocity&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;StickerWall&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Draggable, throwable skill stickers kept inside their section&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;HorizontalScroll&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;A pinned section where vertical scrolling moves you sideways&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;MagneticButton&lt;/code&gt;, &lt;code&gt;LiftCard&lt;/code&gt;, &lt;code&gt;Parallax&lt;/code&gt;, &lt;code&gt;Counter&lt;/code&gt;, &lt;code&gt;CustomCursor&lt;/code&gt;, &lt;code&gt;ScrollProgress&lt;/code&gt;, &lt;code&gt;IntroLoader&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;The supporting cast&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;All of them are built on &lt;a href="https://motion.dev" rel="noopener noreferrer"&gt;Motion&lt;/a&gt; (the library formerly known as Framer Motion).&lt;/p&gt;

&lt;p&gt;Once &lt;code&gt;/loud&lt;/code&gt; felt right, I moved it onto the real homepage, wired it to the live API hooks I already had, and redirected &lt;code&gt;/loud&lt;/code&gt; to &lt;code&gt;/&lt;/code&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 2: A design system made of clashes
&lt;/h2&gt;

&lt;p&gt;The new palette is deliberately louder than the old one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="k"&gt;@theme&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="py"&gt;--color-loud-lime&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;#a3e636&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="py"&gt;--color-loud-ink&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;#111903&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="py"&gt;--color-loud-cool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;#e0e7f1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="py"&gt;--color-loud-paper&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;#eeefe9&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="py"&gt;--color-loud-blue&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;#2d5bff&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="py"&gt;--color-loud-orange&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;#ff7a00&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="py"&gt;--color-loud-purple&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;#8b5cf6&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="py"&gt;--shadow-loud&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;8px&lt;/span&gt; &lt;span class="m"&gt;8px&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="nx"&gt;#111903&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="py"&gt;--shadow-loud-sm&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;4px&lt;/span&gt; &lt;span class="m"&gt;4px&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="nx"&gt;#111903&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="py"&gt;--font-anton&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Anton&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Impact&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;sans-serif&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="py"&gt;--font-jet&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;'JetBrains Mono'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ui-monospace&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;monospace&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;Anton for the giant headlines, JetBrains Mono for the sticker labels, Space Grotesk for body copy, all on a grid-paper background.&lt;/p&gt;

&lt;p&gt;The hard part was making API data look hand-placed. Projects, services and FAQs come from the backend in whatever order and quantity they come in, so I couldn't art-direct each card. Instead, each card gets a colour and a tilt based on its position in the list:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;FACES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;bg-loud-lime&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;bg-loud-blue text-loud-paper&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;bg-loud-orange&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;bg-loud-purple text-loud-paper&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;bg-loud-cool&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;bg-loud-paper&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;];&lt;/span&gt;

&lt;span class="c1"&gt;// Small fixed tilts so lists look hand-placed but stay stable between renders.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;TILTS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;2.5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mf"&gt;1.5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mf"&gt;2.5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;1.5&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;tilt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;TILTS&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="nx"&gt;TILTS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;face&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;FACES&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="nx"&gt;FACES&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It looks random, but it's deterministic. Nothing reshuffles when the page re-renders, so you don't get layout jank or hydration surprises. The featured-work grid works the same way, with a repeating pattern of bento column and row spans.&lt;/p&gt;

&lt;p&gt;Huge headlines that fill the width need some maths. Anton capitals are about 0.47em wide, so each line gets a font size that keeps it under roughly 92vw:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;fitLine&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;max&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;17&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;fontSize&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`min(&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;max&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;vw, &lt;/span&gt;&lt;span class="p"&gt;${(&lt;/span&gt;&lt;span class="mi"&gt;92&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.47&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;toFixed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;vw)`&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;h2&gt;
  
  
  Step 3: Motion that doesn't fight the browser
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Kinetic text without layout thrashing
&lt;/h3&gt;

&lt;p&gt;The hero headline reacts to your cursor letter by letter. The obvious way to build that is to call &lt;code&gt;getBoundingClientRect()&lt;/code&gt; on every letter on every &lt;code&gt;pointermove&lt;/code&gt;, which is a great way to drop frames.&lt;/p&gt;

&lt;p&gt;What I did instead:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;One shared pointer listener&lt;/strong&gt; feeds every component, through Motion values:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;pointer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;x&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;MotionValue&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;y&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;MotionValue&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;usePointer&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;pointer&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;pointer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;x&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;motionValue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;9999&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="na"&gt;y&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;motionValue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;9999&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
      &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;pointermove&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;pointer&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;clientX&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nx"&gt;pointer&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;clientY&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;passive&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;pointer&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;ul&gt;
&lt;li&gt;
&lt;strong&gt;Each letter measures its centre once&lt;/strong&gt; (and again on resize and when fonts finish loading) by walking the &lt;code&gt;offsetParent&lt;/code&gt; chain. That skips transforms, so the letter's own animation doesn't change the measurement.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The per-frame work is pure maths&lt;/strong&gt; on cached numbers: the distance to the pointer becomes a force, and the force drives springs for x, y, rotation and scale. No DOM reads happen while the pointer moves.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;On touch devices there's no hover, so tapping a letter makes it jump instead.&lt;/p&gt;

&lt;h3&gt;
  
  
  Marquees that speed up with your scroll
&lt;/h3&gt;

&lt;p&gt;My first marquee set its speed as a percentage of the track. That meant a marquee with more items scrolled faster in pixels, and every band on the page moved at a different speed. The fix was to define speed in &lt;strong&gt;pixels per second&lt;/strong&gt; and convert it to a percentage using a track width that only gets read on resize:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;useAnimationFrame&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;delta&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;reduced&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;inView&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;trackWidth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="c1"&gt;// px/s → percent of the track, so speed doesn't depend on content length.&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;moveBy&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;direction&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;baseVelocity&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;delta&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;trackWidth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;factor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;velocityFactor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;factor&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;direction&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;factor&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;direction&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;moveBy&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;direction&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;moveBy&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;factor&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;baseX&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;baseX&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;moveBy&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;Scroll velocity also pushes a small &lt;code&gt;skewX&lt;/code&gt;, so the bands lean as you scroll fast. Marquees that are off screen (&lt;code&gt;!inView&lt;/code&gt;) skip their frame updates entirely.&lt;/p&gt;

&lt;h3&gt;
  
  
  Case studies that grow out of their card
&lt;/h3&gt;

&lt;p&gt;Clicking a featured project opens a full-screen case study that &lt;strong&gt;expands from the card itself&lt;/strong&gt;, using Motion's shared &lt;code&gt;layoutId&lt;/code&gt;. The overlay is lazy-loaded, so &lt;code&gt;react-markdown&lt;/code&gt; stays out of the homepage's first load. It handles focus, closes on Escape and locks page scrolling while it's open.&lt;/p&gt;

&lt;h3&gt;
  
  
  The intro plays once
&lt;/h3&gt;

&lt;p&gt;The homepage has an intro loader. Seeing it every time you come back to the homepage would get old quickly, so it runs once per session behind a &lt;code&gt;sessionStorage&lt;/code&gt; flag wrapped in &lt;code&gt;try/catch&lt;/code&gt; (private windows can throw).&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 4: Accessibility rules I didn't break
&lt;/h2&gt;

&lt;p&gt;A maximalist site is where accessibility tends to get dropped. These were non-negotiable:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Reduced motion is a real mode.&lt;/strong&gt; &lt;code&gt;Marquee&lt;/code&gt; renders as static text with no skew. &lt;code&gt;HorizontalScroll&lt;/code&gt; becomes a normal vertical stack. Letter entrances become a simple fade. Parallax layers stay still.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cursor effects need a fine pointer and motion allowed:&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;  &lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;usePointerEffects&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;isTouch&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useIsTouch&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// (hover: none), (pointer: coarse)&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;reduced&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useReducedMotion&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;isTouch&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;reduced&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;ul&gt;
&lt;li&gt;
&lt;strong&gt;Split text is still readable.&lt;/strong&gt; &lt;code&gt;KineticText&lt;/code&gt; puts &lt;code&gt;aria-label&lt;/code&gt; with the full headline on the wrapper and hides the individual letter spans from screen readers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Duplicated marquee copies&lt;/strong&gt; (it renders four for a seamless loop) are &lt;code&gt;aria-hidden&lt;/code&gt; after the first.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The case study overlay&lt;/strong&gt; is a proper &lt;code&gt;role="dialog"&lt;/code&gt; with &lt;code&gt;aria-modal&lt;/code&gt; and &lt;code&gt;aria-labelledby&lt;/code&gt;, and focus moves to the close button.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Step 5: Rebuild every page
&lt;/h2&gt;

&lt;p&gt;A loud homepage next to a quiet inner site would have felt like two different websites, so I rebuilt every page in the new style:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;About&lt;/strong&gt; and its sub-pages (Now, Uses, Music, Certifications)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Blog&lt;/strong&gt; list, posts, series and tag pages: grid-paper backgrounds, sticker metadata, article prose restyled to the new palette&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Services&lt;/strong&gt; list and detail, plus the &lt;strong&gt;Works&lt;/strong&gt; hub, Professional, Personal, Audio, Tech and project pages&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Contact&lt;/strong&gt;, &lt;strong&gt;Guestbook&lt;/strong&gt; (entries are now sticky notes), &lt;strong&gt;Privacy&lt;/strong&gt;, &lt;strong&gt;Terms&lt;/strong&gt;, &lt;strong&gt;404&lt;/strong&gt; and &lt;strong&gt;Unsubscribe&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;The cookie banner, resume modal and loading/error fallbacks&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Doing this many pages forced me to extract shared pieces: &lt;code&gt;LoudLayout&lt;/code&gt; (the page shell), &lt;code&gt;LoudPageHeader&lt;/code&gt; (kinetic title plus sub-page tabs), &lt;code&gt;LoudPostGrid&lt;/code&gt;, &lt;code&gt;LoudProjectBrowser&lt;/code&gt; (search, category filters, animated grid), &lt;code&gt;LoudFaqList&lt;/code&gt;, and &lt;code&gt;LoudBriefSection&lt;/code&gt;. That last one replaced &lt;strong&gt;three separate copies&lt;/strong&gt; of the inline contact form.&lt;/p&gt;

&lt;p&gt;The form logic moved into &lt;code&gt;useContactForm&lt;/code&gt; and &lt;code&gt;useNewsletterForm&lt;/code&gt; hooks, so the old and new UIs submitted the same way during the transition. That made the switch-over low-risk.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 6: Delete everything
&lt;/h2&gt;

&lt;p&gt;This is the part I'm proudest of. After the rebuild, I removed:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Components:&lt;/strong&gt; the old Navbar, Footer, Hero, Projects, Services, Stats, Testimonials, FAQs, Button, Card, DevIcon, the old project and service modals, and an AI assistant widget that wasn't earning its place&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Packages:&lt;/strong&gt; &lt;code&gt;framer-motion&lt;/code&gt; (as a direct dependency), &lt;code&gt;lucide-react&lt;/code&gt;, &lt;code&gt;react-countup&lt;/code&gt;, &lt;code&gt;react-intersection-observer&lt;/code&gt; and &lt;code&gt;@google/genai&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dark mode.&lt;/strong&gt; The loud design is light-only on purpose: the whole look depends on ink on paper. &lt;code&gt;LoudLayout&lt;/code&gt; removes the &lt;code&gt;dark&lt;/code&gt; class while it's mounted.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Across the redesign: &lt;strong&gt;86 files changed, 5,423 lines added, 8,454 lines removed.&lt;/strong&gt; The site does more than it did before, with about 3,000 fewer lines of code.&lt;/p&gt;

&lt;h3&gt;
  
  
  Bundle split
&lt;/h3&gt;

&lt;p&gt;Motion gets its own chunk, so pages that don't animate much don't pay for it up front. Because &lt;code&gt;motion&lt;/code&gt; depends on &lt;code&gt;framer-motion&lt;/code&gt; internally, they have to go in the same chunk, or you end up with duplicated runtime code:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;manualChunks&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;react&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;react-dom&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;react-router-dom&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;react-helmet-async&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pkg&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`/node_modules/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;pkg&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/`&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;chunk-react&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="c1"&gt;// motion depends on framer-motion internally; keep the whole animation stack in one chunk.&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;motion&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;motion-dom&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;motion-utils&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;framer-motion&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pkg&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`/node_modules/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;pkg&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/`&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;chunk-motion&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/node_modules/@sentry/&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;chunk-sentry&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Current production build, gzipped:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Chunk&lt;/th&gt;
&lt;th&gt;Size (gzip)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;App entry&lt;/td&gt;
&lt;td&gt;~26 KB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;React + router&lt;/td&gt;
&lt;td&gt;~71 KB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Motion&lt;/td&gt;
&lt;td&gt;~56 KB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sentry&lt;/td&gt;
&lt;td&gt;~54 KB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Each page&lt;/td&gt;
&lt;td&gt;0.5–7.5 KB raw&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Every page is lazy-loaded, so a visitor only downloads the page they're looking at.&lt;/p&gt;




&lt;h2&gt;
  
  
  Gotchas I hit
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. &lt;code&gt;overflow-x: hidden&lt;/code&gt; broke &lt;code&gt;position: sticky&lt;/code&gt;.&lt;/strong&gt;&lt;br&gt;
The pinned horizontal-scroll section wouldn't stick. The cause was a global &lt;code&gt;overflow-x: hidden&lt;/code&gt; on &lt;code&gt;html&lt;/code&gt; and &lt;code&gt;body&lt;/code&gt;, which turns &lt;code&gt;body&lt;/code&gt; into a scroll container and breaks sticky positioning for everything inside it. The fix is &lt;code&gt;overflow-x: clip&lt;/code&gt;, which hides overflow without creating a scroll container:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// The global overflow-x: hidden on html/body makes body a scroll container,&lt;/span&gt;
&lt;span class="c1"&gt;// which breaks position: sticky (HorizontalScroll). clip hides overflow&lt;/span&gt;
&lt;span class="c1"&gt;// without that side effect.&lt;/span&gt;
&lt;span class="nx"&gt;els&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;forEach&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;overflowX&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;clip&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;2. A CommonJS-only package crashed the homepage under Vite 8.&lt;/strong&gt;&lt;br&gt;
During a dependency upgrade, &lt;code&gt;react-countup&lt;/code&gt; (which only ships CommonJS) had its default import resolved to the entire exports object. React tried to render that object, threw error #130, and the homepage crashed as soon as the stats section scrolled into view. I unwrapped &lt;code&gt;.default&lt;/code&gt; as a quick fix, then removed the package in the cleanup.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. &lt;code&gt;@vercel/og&lt;/code&gt; 0.11 and later wouldn't build on Edge.&lt;/strong&gt;&lt;br&gt;
The newer edge build imports Node's &lt;code&gt;module&lt;/code&gt; (&lt;code&gt;createRequire&lt;/code&gt;), which Vercel Edge Functions reject at build time. I pinned it to &lt;code&gt;0.11.1&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Social previews were quietly broken.&lt;/strong&gt;&lt;br&gt;
The fallback &lt;code&gt;og:image&lt;/code&gt; pointed at a Twitter profile-photo URL that had started returning 404, so links to the homepage were shared with no image. It's now a self-hosted 1200×630 card with &lt;code&gt;og:image:width&lt;/code&gt; and &lt;code&gt;og:image:height&lt;/code&gt; declared, so Facebook renders it on the first share instead of processing it in the background. Detail pages (case studies, blog tags, series, tech) also get their own generated OG images from an edge function.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. Phones needed their own layout.&lt;/strong&gt;&lt;br&gt;
The first version of the hero looked great on desktop and fell apart on phones. Phones now get one word per headline line, the stickers sit above the headline instead of scattered around it, the hero height is capped on tall tablets, and the bento grid only uses four columns at &lt;code&gt;lg&lt;/code&gt; and up.&lt;/p&gt;




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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Plan for reduced motion from day one.&lt;/strong&gt; I did this, and it's the only reason the reduced-motion version doesn't feel like an afterthought. Every primitive decides its fallback up front.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prototype on a hidden route again.&lt;/strong&gt; Being able to compare &lt;code&gt;/loud&lt;/code&gt; with the live site was the most useful decision in the whole project.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Extract shared components sooner.&lt;/strong&gt; I wrote the contact form three times before turning it into &lt;code&gt;LoudBriefSection&lt;/code&gt;. I should have stopped at two.&lt;/li&gt;
&lt;/ul&gt;




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

&lt;p&gt;The redesign took four days, from the first &lt;code&gt;/loud&lt;/code&gt; prototype to the final cleanup. The site is louder and lighter, and every page shares one set of components.&lt;/p&gt;

&lt;p&gt;Go throw some stickers around at &lt;strong&gt;&lt;a href="https://www.vicentereyes.org" rel="noopener noreferrer"&gt;vicentereyes.org&lt;/a&gt;&lt;/strong&gt;, and if you're planning a site that needs to stand out, &lt;a href="https://www.vicentereyes.org/contact" rel="noopener noreferrer"&gt;let's talk&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Built with React 19, Vite 8, Tailwind CSS 4 and Motion, on a Django REST backend.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>react</category>
      <category>webdev</category>
      <category>design</category>
      <category>showdev</category>
    </item>
    <item>
      <title>A Gemini 500 on a resume upload turned into a .docx bug, a frozen DigitalOcean droplet, a timed-out deploy, and a corrupted zip file. Here's every fix.</title>
      <dc:creator>Vicente G. Reyes</dc:creator>
      <pubDate>Wed, 21 Oct 2026 08:25:54 +0000</pubDate>
      <link>https://dev.to/highcenburg/a-gemini-500-on-a-resume-upload-turned-into-a-docx-bug-a-frozen-digitalocean-droplet-a-timed-out-3icc</link>
      <guid>https://dev.to/highcenburg/a-gemini-500-on-a-resume-upload-turned-into-a-docx-bug-a-frozen-digitalocean-droplet-a-timed-out-3icc</guid>
      <description>&lt;p&gt;It started with one Sentry alert on a Tuesday night:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ServerError: An internal error has occurred. Please retry or report in
https://developers.generativeai.google/guide/troubleshooting
Transaction: job_board.seekers.tasks.parse_resume
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://gigglegigs.com" rel="noopener noreferrer"&gt;GiggleGigs&lt;/a&gt; is a job board I run. When a job seeker uploads a resume, a Celery task sends it to Google's Gemini API, which pulls out their name, headline, skills, links and years of experience and fills in their profile. One seeker's resume had failed to parse.&lt;/p&gt;

&lt;p&gt;Easy fix, I thought. Gemini had a hiccup, so add a retry.&lt;/p&gt;

&lt;p&gt;Two days later I had fixed four separate bugs, frozen my production server, restarted it from the DigitalOcean dashboard, and learned more about the &lt;code&gt;.docx&lt;/code&gt; format than I ever wanted to. Here's how it went.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug #1: One transient error, permanent failure
&lt;/h2&gt;

&lt;p&gt;The task looked roughly like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nd"&gt;@shared_task&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;parse_resume&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;profile_id&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="bp"&gt;...&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;generate_content&lt;/span&gt;&lt;span class="p"&gt;(...)&lt;/span&gt;
        &lt;span class="c1"&gt;# parse JSON, fill in the profile
&lt;/span&gt;        &lt;span class="n"&gt;profile&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;parsing_status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;SeekerProfile&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ParsingStatus&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DONE&lt;/span&gt;
        &lt;span class="n"&gt;profile&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;Exception&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exception&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Resume parsing failed for profile %s&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;profile_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;profile&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;parsing_status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;SeekerProfile&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ParsingStatus&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;FAILED&lt;/span&gt;
        &lt;span class="n"&gt;profile&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;update_fields&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;parsing_status&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every exception was final. A &lt;code&gt;500 INTERNAL&lt;/code&gt; from Gemini, which is Google's problem and usually gone a minute later, permanently marked the resume as failed and sent an error to Sentry.&lt;/p&gt;

&lt;p&gt;The fix was to make the task bound, retry the error codes that are worth retrying, and back off exponentially:&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="c1"&gt;# Gemini errors worth retrying: quota exhausted (429), internal error (500),
# model overloaded (503). Retried after 1, 2, then 4 minutes.
&lt;/span&gt;&lt;span class="n"&gt;TRANSIENT_GEMINI_CODES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;429&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;503&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;PARSE_RESUME_MAX_RETRIES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;


&lt;span class="nd"&gt;@shared_task&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bind&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;max_retries&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;PARSE_RESUME_MAX_RETRIES&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;parse_resume&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;profile_id&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;google.genai&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;errors&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;genai_errors&lt;/span&gt;
    &lt;span class="bp"&gt;...&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="bp"&gt;...&lt;/span&gt;
    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="n"&gt;genai_errors&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;APIError&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;exc&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;exc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;code&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;TRANSIENT_GEMINI_CODES&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exception&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Resume parsing failed for profile %s&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;profile_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;retries&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;max_retries&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;retry&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;countdown&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;retries&lt;/span&gt;&lt;span class="p"&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;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;warning&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Gemini unavailable (%s); gave up parsing resume for profile %s: %s&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;exc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;code&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;profile_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;exc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;profile&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;parsing_status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;SeekerProfile&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ParsingStatus&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;FAILED&lt;/span&gt;
        &lt;span class="n"&gt;profile&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;update_fields&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;parsing_status&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;Exception&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="bp"&gt;...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details are worth pointing out.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Order matters.&lt;/strong&gt; &lt;code&gt;self.retry()&lt;/code&gt; raises &lt;code&gt;celery.exceptions.Retry&lt;/code&gt;, which is a subclass of &lt;code&gt;Exception&lt;/code&gt;. If the generic &lt;code&gt;except Exception&lt;/code&gt; came first, it would swallow the retry. Raising from inside one &lt;code&gt;except&lt;/code&gt; clause skips the sibling clauses, so this layout is safe.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Warning vs. error.&lt;/strong&gt; If Gemini is still down after three retries, that's an outage, not a bug, so it gets logged as a warning. Real bugs, like a &lt;code&gt;400&lt;/code&gt;, still go to Sentry as errors.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I deployed it and re-ran the failed resume. It failed again.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug #2: Gemini doesn't read Word files
&lt;/h2&gt;

&lt;p&gt;I opened the profile in the Django admin and saw the file name: &lt;code&gt;..._CV_Updated.docx&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The task sent every resume to Gemini as raw bytes with whatever MIME type matched the file extension:&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;MIME_MAP&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;.pdf&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;application/pdf&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;.docx&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;application/vnd.openxmlformats-officedocument.wordprocessingml.document&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;.doc&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;application/msword&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Gemini reads PDFs as inline documents. Word files aren't supported, and instead of a clear &lt;code&gt;400 Unsupported MIME type&lt;/code&gt;, the request came back as a &lt;code&gt;500 INTERNAL&lt;/code&gt;. So the "transient" error was actually permanent for this file, and my new retries would have tried it three more times and failed every time.&lt;/p&gt;

&lt;p&gt;The fix: for &lt;code&gt;.docx&lt;/code&gt;, extract the text locally and send it to Gemini as part of the prompt. PDFs still go as bytes. Legacy &lt;code&gt;.doc&lt;/code&gt; files (the old binary format) get skipped with a warning.&lt;/p&gt;

&lt;p&gt;My first version used &lt;code&gt;python-docx&lt;/code&gt;. That turned out to be the wrong choice, as you'll see in Bug #4.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug #3: The deploy that froze the server
&lt;/h2&gt;

&lt;p&gt;Adding &lt;code&gt;python-docx&lt;/code&gt; meant a new line in &lt;code&gt;requirements/base.txt&lt;/code&gt;. My deploy is a GitHub Action that SSHes into a DigitalOcean droplet and runs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git pull origin main
docker compose &lt;span class="nt"&gt;-f&lt;/span&gt; production.yml build
docker compose &lt;span class="nt"&gt;-f&lt;/span&gt; production.yml run &lt;span class="nt"&gt;--rm&lt;/span&gt; django python manage.py collectstatic &lt;span class="nt"&gt;--noinput&lt;/span&gt;
docker compose &lt;span class="nt"&gt;-f&lt;/span&gt; production.yml run &lt;span class="nt"&gt;--rm&lt;/span&gt; django python manage.py migrate
docker compose &lt;span class="nt"&gt;-f&lt;/span&gt; production.yml up &lt;span class="nt"&gt;-d&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Changing the requirements file invalidates Docker's cached &lt;code&gt;pip wheel&lt;/code&gt; layer, so every package is rebuilt from scratch. One of those packages was &lt;code&gt;psycopg[c]&lt;/code&gt;, the C-accelerated PostgreSQL driver, which &lt;strong&gt;compiles from source&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Here's what went wrong:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The build was still compiling &lt;code&gt;psycopg-c&lt;/code&gt; when &lt;code&gt;appleboy/ssh-action&lt;/code&gt; hit its default &lt;strong&gt;10-minute&lt;/strong&gt; &lt;code&gt;command_timeout&lt;/code&gt; and killed the session.&lt;/li&gt;
&lt;li&gt;The droplet has &lt;strong&gt;2 GB of RAM and no swap&lt;/strong&gt;. Compiling C while serving the site pushed it into memory exhaustion. The dashboard showed 90% memory and 83% CPU.&lt;/li&gt;
&lt;li&gt;The site stopped responding, and so did SSH.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I had to click &lt;strong&gt;Actions → Restart&lt;/strong&gt; in the DigitalOcean dashboard. Docker's &lt;code&gt;restart: unless-stopped&lt;/code&gt; policy brought the old containers back, so the site came back up on the previous image.&lt;/p&gt;

&lt;p&gt;Three fixes came out of this.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Add swap&lt;/strong&gt; so a heavy build slows down instead of freezing the machine:&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;fallocate &lt;span class="nt"&gt;-l&lt;/span&gt; 2G /swapfile
&lt;span class="nb"&gt;sudo chmod &lt;/span&gt;600 /swapfile
&lt;span class="nb"&gt;sudo &lt;/span&gt;mkswap /swapfile
&lt;span class="nb"&gt;sudo &lt;/span&gt;swapon /swapfile
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'/swapfile none swap sw 0 0'&lt;/span&gt; | &lt;span class="nb"&gt;sudo tee&lt;/span&gt; &lt;span class="nt"&gt;-a&lt;/span&gt; /etc/fstab
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;During the next build, &lt;code&gt;top&lt;/code&gt; showed about 920 MB of swap in use. Without it, the box would have frozen again.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Stop compiling on the server.&lt;/strong&gt; &lt;code&gt;psycopg[binary]&lt;/code&gt; ships precompiled wheels:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt;&lt;span class="gd"&gt;-psycopg[c]==3.2.2
&lt;/span&gt;&lt;span class="gi"&gt;+psycopg[binary]==3.2.2
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The next full rebuild went from timing out to about 3 minutes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Make the deploy job tolerant and fail-fast:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Deploy via SSH&lt;/span&gt;
  &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;appleboy/ssh-action@v1.0.3&lt;/span&gt;
  &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;# ...&lt;/span&gt;
    &lt;span class="c1"&gt;# A requirements change rebuilds every wheel, which can&lt;/span&gt;
    &lt;span class="c1"&gt;# outlast the action's 10m default.&lt;/span&gt;
    &lt;span class="na"&gt;command_timeout&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;30m&lt;/span&gt;
    &lt;span class="na"&gt;script_stop&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;script_stop: true&lt;/code&gt; matters as much as the timeout. Without it, a failed &lt;code&gt;build&lt;/code&gt; still continues on to &lt;code&gt;up -d&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Bonus bug: ghost containers
&lt;/h3&gt;

&lt;p&gt;While I was recovering, I ran the deploy by hand in &lt;code&gt;tmux&lt;/code&gt;, and the GitHub Action ran at the same time. The two &lt;code&gt;docker compose up -d&lt;/code&gt; runs collided and left behind containers with names like &lt;code&gt;430e938fc82c_gigglegigs-celerybeat-1&lt;/code&gt;. After that, every deploy failed 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 response from daemon: Conflict. The container name "/gigglegigs-celerybeat-1"
is already in use by container "430e938fc82c...".
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The fix is to remove the stateless app containers (never Postgres or Redis) and bring them back up:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker ps &lt;span class="nt"&gt;-a&lt;/span&gt; &lt;span class="nt"&gt;--format&lt;/span&gt; &lt;span class="s1"&gt;'{{.Names}}'&lt;/span&gt; &lt;span class="se"&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;'gigglegigs-(django|celeryworker|celerybeat|flower)-1$'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | xargs &lt;span class="nt"&gt;-r&lt;/span&gt; docker &lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt;
docker compose &lt;span class="nt"&gt;-f&lt;/span&gt; production.yml up &lt;span class="nt"&gt;-d&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The lesson: &lt;strong&gt;never run two deploys against the same Compose project at the same time.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug #4: A corrupted image inside a Word file
&lt;/h2&gt;

&lt;p&gt;With everything deployed, I re-ran the resume. &lt;code&gt;FAILED&lt;/code&gt;, again. The worker logs had the answer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;File "/usr/local/lib/python3.11/site-packages/docx/opc/phys_pkg.py", line 83, in blob_for
    return self._zipf.read(pack_uri.membername)
...
zipfile.BadZipFile: Bad CRC-32 for file 'word/media/image1.png'
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A &lt;code&gt;.docx&lt;/code&gt; is a zip archive. The text lives in &lt;code&gt;word/document.xml&lt;/code&gt;, and images live under &lt;code&gt;word/media/&lt;/code&gt;. This seeker's file had an embedded image (probably a profile photo) whose checksum didn't match. Word doesn't care and opens the file fine. &lt;code&gt;python-docx&lt;/code&gt;, however, loads &lt;strong&gt;every&lt;/strong&gt; part of the package when you open it, including images it never needs, so one bad PNG took down the whole parse.&lt;/p&gt;

&lt;p&gt;I only needed the text, so I dropped &lt;code&gt;python-docx&lt;/code&gt; and read the one XML file directly with the standard library:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;io&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;zipfile&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;xml.etree&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;ElementTree&lt;/span&gt;

&lt;span class="n"&gt;WORD_NS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;{http://schemas.openxmlformats.org/wordprocessingml/2006/main}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_docx_text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;file_content&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Plain text of a .docx, one line per paragraph (table cells included).
    Only word/document.xml is read, so a corrupt embedded image (which Word
    tolerates) doesn&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;t fail it.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;zipfile&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;ZipFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;io&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;BytesIO&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;file_content&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;archive&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;root&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ElementTree&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fromstring&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;archive&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;word/document.xml&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="n"&gt;lines&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="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;node&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;node&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;paragraph&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;iter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;WORD_NS&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;t&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;paragraph&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;root&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;iter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;WORD_NS&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;p&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="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;line&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;lines&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;line&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every paragraph is a &lt;code&gt;&amp;lt;w:p&amp;gt;&lt;/code&gt; and every run of text is a &lt;code&gt;&amp;lt;w:t&amp;gt;&lt;/code&gt;. Table cells contain paragraphs too, so resumes laid out in tables come through with no extra code. And I had one fewer dependency.&lt;/p&gt;

&lt;p&gt;To make sure this stays fixed, the test builds a &lt;code&gt;.docx&lt;/code&gt; by hand and deliberately corrupts the image. In &lt;code&gt;ZIP_STORED&lt;/code&gt; mode the bytes sit raw in the archive, so you can change them without updating the CRC:&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;_docx_bytes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;corrupt_image&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="nb"&gt;buffer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;io&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;BytesIO&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;zipfile&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;ZipFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;buffer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;w&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;archive&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;archive&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;writestr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;word/document.xml&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;DOCUMENT_XML&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;archive&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;writestr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;word/media/image1.png&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;IMAGE_BYTES&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;content&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getvalue&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;corrupt_image&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="c1"&gt;# Flip the stored image bytes without updating the zip's CRC-32.
&lt;/span&gt;        &lt;span class="n"&gt;content&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;content&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;IMAGE_BYTES&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;IMAGE_BYTES&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;upper&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;content&lt;/span&gt;


&lt;span class="nd"&gt;@pytest.mark.parametrize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;corrupt_image&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="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_docx_resume_is_sent_as_text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;profile&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;corrupt_image&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="bp"&gt;...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I checked that this fixture raises exactly the same &lt;code&gt;Bad CRC-32 for file 'word/media/image1.png'&lt;/code&gt; as the real resume before trusting the test.&lt;/p&gt;

&lt;p&gt;One more deploy, one more &lt;code&gt;parse_resume.delay(...)&lt;/code&gt;, and then:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;DONE
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What I took away
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Not every 5xx is transient.&lt;/strong&gt; Gemini returned &lt;code&gt;500 INTERNAL&lt;/code&gt; for an unsupported file type. Retries handle real outages, but read the error and what you sent before assuming it's the provider's fault.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Only parse what you need.&lt;/strong&gt; A full-document library is the wrong tool when all you want is text. &lt;code&gt;zipfile&lt;/code&gt; plus &lt;code&gt;ElementTree&lt;/code&gt; handled a file that &lt;code&gt;python-docx&lt;/code&gt; couldn't.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Small servers need swap.&lt;/strong&gt; On a 2 GB droplet, one heavy build can take down the site and SSH with it. Swap is cheap insurance.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't compile in production.&lt;/strong&gt; Prefer prebuilt wheels like &lt;code&gt;psycopg[binary]&lt;/code&gt;. If you must compile, build the image in CI and pull it, rather than building on the box that serves traffic.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deploy scripts should fail fast and never overlap.&lt;/strong&gt; &lt;code&gt;script_stop: true&lt;/code&gt;, a realistic timeout, and one deploy at a time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Run long server commands in &lt;code&gt;tmux&lt;/code&gt;.&lt;/strong&gt; If your SSH connection drops halfway through a build, the build keeps going.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A single user's resume exposed four weaknesses in the stack. All of them are fixed now, and the next person who uploads a slightly broken Word file won't notice anything happened.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I'm building &lt;a href="https://gigglegigs.com" rel="noopener noreferrer"&gt;GiggleGigs&lt;/a&gt;, a job board for gigs and contract work, with Django, Celery and React. If you've been bitten by a &lt;code&gt;.docx&lt;/code&gt; file too, I'd love to hear about it in the comments.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>django</category>
      <category>python</category>
      <category>devops</category>
      <category>debugging</category>
    </item>
    <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>
  </channel>
</rss>
