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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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




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

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

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

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

&lt;/div&gt;



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

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

&lt;/div&gt;



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

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

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

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

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

&lt;/div&gt;



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

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

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

&lt;/div&gt;



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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"engines"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"node"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"^22.22.2 || ^24.15.0 || &amp;gt;=26.0.0"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Node 20 doesn't have &lt;code&gt;markAsUncloneable&lt;/code&gt; — jsdom needs it. Every frontend test run was crashing before a single test file even executed. The actual backend test suite (&lt;code&gt;pytest&lt;/code&gt;) had been passing this entire time; it was just outvoted by two infrastructure bugs with nothing to do with the code being tested.&lt;/p&gt;

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

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

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

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

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

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

&lt;/div&gt;



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

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

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

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

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

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

&lt;/div&gt;



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

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

&lt;/div&gt;



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

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

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

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

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

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

&lt;/div&gt;





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

&lt;/div&gt;



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

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

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

&lt;p&gt;&lt;strong&gt;Gotcha 1 — &lt;code&gt;ssh-copy-id&lt;/code&gt; from inside the box.&lt;/strong&gt; Trying to install a public key onto the droplet while &lt;em&gt;already SSH'd into that droplet&lt;/em&gt; doesn't work: &lt;code&gt;ssh-copy-id&lt;/code&gt; opens a &lt;em&gt;new&lt;/em&gt; outbound connection back to the same host to install the key, but that connection has no valid key to authenticate with yet. Classic chicken-and-egg. Once you're already sitting in a shell on the target machine, skip &lt;code&gt;ssh-copy-id&lt;/code&gt; and just append directly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; ~/.ssh/id_ed25519.pub &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; ~/.ssh/authorized_keys
&lt;span class="nb"&gt;chmod &lt;/span&gt;700 ~/.ssh
&lt;span class="nb"&gt;chmod &lt;/span&gt;600 ~/.ssh/authorized_keys
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

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

&lt;/div&gt;



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

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

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

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

&lt;/div&gt;



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

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

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

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

&lt;/div&gt;



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

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

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

&lt;/div&gt;



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

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

&lt;/div&gt;



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

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




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

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

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

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

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

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

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

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


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

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

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

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




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

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

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

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

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




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



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

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

&lt;/div&gt;



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

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

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

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

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

&lt;p&gt;This is the real risk vector, and it's the one App Center will never show you.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rhE&lt;/span&gt; &lt;span class="s1"&gt;'^deb '&lt;/span&gt; /etc/apt/sources.list /etc/apt/sources.list.d/ 2&amp;gt;/dev/null
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

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

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

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

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

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

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

&lt;/div&gt;





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

&lt;/div&gt;



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

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

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

&lt;p&gt;This is the command that actually answers the malware question.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;apt &lt;span class="nb"&gt;install &lt;/span&gt;debsums
&lt;span class="nb"&gt;sudo &lt;/span&gt;debsums &lt;span class="nt"&gt;-s&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

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

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

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Missing is not modified, and the distinction is the whole finding.&lt;/strong&gt; Zero checksum mismatches means not one binary or library on the system differs from what Ubuntu published. What's missing is three launcher entries — which is also why those three showed gray placeholder icons back in the GUI. Almost certainly an app-grid cleanup I did and forgot. Malware adds &lt;code&gt;.desktop&lt;/code&gt; files for persistence; it has no reason to delete them from unrelated packages.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;apt &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;--reinstall&lt;/span&gt; btop flameshot gnome-system-monitor
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

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



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

&lt;/div&gt;



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

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

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

&lt;/div&gt;



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




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

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

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

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




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



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

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

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

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

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

&lt;/div&gt;



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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

</description>
      <category>devops</category>
      <category>networking</category>
      <category>security</category>
      <category>webdev</category>
    </item>
    <item>
      <title>The Bug That Threw No Errors: How a Repeat-Trial Guard Silently Killed Our Free Trial Funnel</title>
      <dc:creator>Vicente G. Reyes</dc:creator>
      <pubDate>Fri, 14 Aug 2026 12:06:09 +0000</pubDate>
      <link>https://dev.to/highcenburg/the-bug-that-threw-no-errors-how-a-repeat-trial-guard-silently-killed-our-free-trial-funnel-3l2h</link>
      <guid>https://dev.to/highcenburg/the-bug-that-threw-no-errors-how-a-repeat-trial-guard-silently-killed-our-free-trial-funnel-3l2h</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for &lt;a href="https://dev.to/bugsmash"&gt;DEV's Summer Bug Smash: Smash Stories&lt;/a&gt; powered by &lt;a href="https://sentry.io/" rel="noopener noreferrer"&gt;Sentry&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;I'm the lead developer on &lt;strong&gt;Rev6.fit&lt;/strong&gt;, a fitness membership platform built on WordPress + WooCommerce Subscriptions + LearnDash. It runs 49 plugins, five membership families, and a stack of custom code snippets — the kind of production environment where every change has a blast radius.&lt;/p&gt;

&lt;p&gt;Rev6's entire top-of-funnel is a &lt;strong&gt;7-day free trial&lt;/strong&gt;. Every consumer membership — All-Access, On-Demand, Vitality — leads with "$0 today, first charge in 7 days." If the trial disappears, new signups don't slow down. They stop.&lt;/p&gt;

&lt;p&gt;And one day, it disappeared. For every prospect. Silently.&lt;/p&gt;

&lt;h2&gt;
  
  
  The symptom
&lt;/h2&gt;

&lt;p&gt;Logged-out visitors — which is to say, &lt;strong&gt;every single potential new customer&lt;/strong&gt; — landed on membership product pages and saw... a full-price subscription. No "with a 7-day free trial" label. No "$0 today." Just the sticker price.&lt;/p&gt;

&lt;p&gt;Existing members saw everything correctly. The client saw everything correctly (logged in, obviously). The site threw zero errors. Sentry — which we run on both the PHP and JS sides — was green. PageSpeed was fine. Checkout worked.&lt;/p&gt;

&lt;p&gt;The funnel was just quietly bleeding out.&lt;/p&gt;

&lt;p&gt;The catch didn't come from a dashboard. It came from a human: &lt;strong&gt;Avie&lt;/strong&gt;, my primary client contact, noticed the trial messaging was gone from the membership pages and flagged it. That detail matters to the story — with all our monitoring green, the alerting system that actually fired was someone who knows the site well enough to feel when something's off. Every production stack has one of those. They're underrated.&lt;/p&gt;

&lt;h2&gt;
  
  
  The root cause
&lt;/h2&gt;

&lt;p&gt;Here's the part I love, because it's such a classic shape: &lt;strong&gt;the bug was inside a guard written for a different problem.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The site carried a custom snippet whose job was to enforce &lt;strong&gt;one free trial per customer&lt;/strong&gt; — if you'd already consumed your trial, the trial offer was stripped from the subscription for you. Reasonable policy for any trial-led membership business.&lt;/p&gt;

&lt;p&gt;The right logic is:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Has &lt;em&gt;this user&lt;/em&gt; already had a trial? If yes, remove the trial."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;But the snippet carried an &lt;strong&gt;extra condition&lt;/strong&gt; that effectively turned it into:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Can I &lt;em&gt;confirm&lt;/em&gt; this user deserves a trial? If not, remove the trial."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;And for a logged-out visitor, there's no user to check. No account → no trial history → can't confirm eligibility → &lt;strong&gt;strip the trial&lt;/strong&gt;. The guard failed &lt;em&gt;closed&lt;/em&gt; against the exact people it should have failed &lt;em&gt;open&lt;/em&gt; for: first-time visitors who haven't created an account yet.&lt;/p&gt;

&lt;p&gt;A simplified reconstruction of the shape (the real snippet stays private, but this is the logic faithfully):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// The repeat-trial guard — intended behavior&lt;/span&gt;
&lt;span class="nf"&gt;add_filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'woocommerce_subscriptions_product_trial_length'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$trial_length&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$product&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$user_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;get_current_user_id&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="c1"&gt;// ❌ The extra condition: guests have no $user_id,&lt;/span&gt;
    &lt;span class="c1"&gt;// so they fell into the "no trial" branch too&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="nv"&gt;$user_id&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nf"&gt;rev6_user_has_used_trial&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$user_id&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="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// no trial for you&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$trial_length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="mi"&gt;10&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The subtle trap: on Rev6, &lt;strong&gt;guest checkout is disabled&lt;/strong&gt; — accounts are auto-created at signup. So a guest seeing the trial offer is completely safe; by the time money and trial entitlement are involved, they &lt;em&gt;have&lt;/em&gt; an account and the repeat-trial check can do its job. The guest branch in the guard wasn't just wrong — it was protecting against a scenario the platform's own architecture already made impossible.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;The fix was almost anticlimactic — remove the guest condition, keep the repeat-trial protection:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&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="nv"&gt;$user_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;return&lt;/span&gt; &lt;span class="nv"&gt;$trial_length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// ✅ guests see the trial — accounts are created at signup anyway&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nf"&gt;rev6_user_has_used_trial&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$user_id&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="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// repeat-trial protection stays intact&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$trial_length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One condition. That's the whole diff. But the verification checklist was the real work:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;✅ Logged-out visitor sees "with a 7-day free trial" on every membership product&lt;/li&gt;
&lt;li&gt;✅ Checkout shows &lt;strong&gt;$0 today / first charge in 7 days&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;✅ Account auto-creation at signup still works&lt;/li&gt;
&lt;li&gt;✅ A user who already consumed a trial still gets &lt;strong&gt;no&lt;/strong&gt; second trial&lt;/li&gt;
&lt;li&gt;✅ Verified on staging first, then live
The last item on that list matters most: it would have been very easy to "fix" this by deleting the guard entirely — and quietly reopen the repeat-trial loophole the snippet existed to close. Smashing a bug shouldn't resurrect its predecessor.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What Sentry taught me by &lt;em&gt;not&lt;/em&gt; catching this
&lt;/h2&gt;

&lt;p&gt;Rev6 runs Sentry on both PHP and JS, and it's caught real production issues for us — an orphaned marketing-automation job erroring every 60 seconds, broken script dependencies on product pages, a Stripe Express Checkout misconfiguration. It's earned its keep.&lt;/p&gt;

&lt;p&gt;But this bug was invisible to it, and that's the lesson I'd hand any dev working on revenue-critical flows:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Error monitoring catches code that fails. It can't catch code that succeeds at doing the wrong thing.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The trial guard executed perfectly. No exception, no warning, no log line. It returned &lt;code&gt;0&lt;/code&gt; exactly as written — the code was correct; the &lt;em&gt;condition&lt;/em&gt; was wrong. That's a whole class of bug that lives below the monitoring waterline: pricing logic, discount eligibility, visibility rules, permission gates. The stuff that decides whether money moves.&lt;/p&gt;

&lt;p&gt;My takeaways for that class of bug:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Test your money paths logged out.&lt;/strong&gt; Your default browsing state as a developer (logged in, admin bar on, caches primed) is the state your prospects will never be in. Incognito is a QA tool.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Guards should fail open or closed &lt;em&gt;deliberately&lt;/em&gt;.&lt;/strong&gt; Every early-return in an eligibility check is a policy decision. Write the comment. "Guests: allow, because accounts are created at signup" would have made this bug impossible to write.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Behavioral monitoring complements error monitoring.&lt;/strong&gt; An alert on "trial signups per day dropped to zero" would have caught this in hours — instead of waiting for a sharp-eyed human to happen across the page.
## The win&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The trial is back, the repeat-trial abuse protection still holds, and every first-time visitor to Rev6 now sees the offer that the entire funnel was designed around. No new plugins, no rewrite — one condition removed, with a verification list long enough to trust the change on a live revenue path.&lt;/p&gt;

&lt;p&gt;The bugs that throw exceptions are the easy ones. The legendary ones return &lt;code&gt;0&lt;/code&gt; and walk away clean.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I'm Ice — freelance full-stack dev (Django/React + WordPress/WooCommerce + Shopify). More at &lt;a href="https://vicentereyes.org" rel="noopener noreferrer"&gt;vicentereyes.org&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>bugsmash</category>
      <category>php</category>
      <category>wordpress</category>
    </item>
    <item>
      <title>The WebP Migration That Wasn't About WebP</title>
      <dc:creator>Vicente G. Reyes</dc:creator>
      <pubDate>Fri, 31 Jul 2026 10:58:28 +0000</pubDate>
      <link>https://dev.to/highcenburg/the-webp-migration-that-wasnt-about-webp-3n90</link>
      <guid>https://dev.to/highcenburg/the-webp-migration-that-wasnt-about-webp-3n90</guid>
      <description>&lt;p&gt;Nobody asked me to do this.&lt;/p&gt;

&lt;p&gt;I maintain a WooCommerce store bolted to LearnDash courses on a Divi theme — eight years of content, a few thousand images. Page weight had been nagging at me for a while, so I put "convert the images to WebP" on my own list. No ticket, no client request, just a maintenance item I thought would take an afternoon.&lt;/p&gt;

&lt;p&gt;I almost installed ShortPixel and called it a day.&lt;/p&gt;

&lt;p&gt;Instead I spent twenty minutes auditing first, and found that WebP was maybe the fourth most important thing wrong with how that site served images. This is what the audit turned up, and why "just install an optimizer plugin" would have made things worse.&lt;/p&gt;

&lt;h2&gt;
  
  
  Always inventory before you optimize
&lt;/h2&gt;

&lt;p&gt;The site runs on a 4GB VPS that had already OOM-killed MySQL twice that quarter. Bulk image transcoding is CPU and memory heavy. So before touching anything:&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;wp-content/uploads
&lt;span class="nb"&gt;du&lt;/span&gt; &lt;span class="nt"&gt;-sh&lt;/span&gt; &lt;span class="nb"&gt;.&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"JPEG:  &lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;find &lt;span class="nb"&gt;.&lt;/span&gt; &lt;span class="nt"&gt;-type&lt;/span&gt; f &lt;span class="se"&gt;\(&lt;/span&gt; &lt;span class="nt"&gt;-iname&lt;/span&gt; &lt;span class="s1"&gt;'*.jpg'&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nt"&gt;-iname&lt;/span&gt; &lt;span class="s1"&gt;'*.jpeg'&lt;/span&gt; &lt;span class="se"&gt;\)&lt;/span&gt; | &lt;span class="nb"&gt;wc&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"PNG:   &lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;find &lt;span class="nb"&gt;.&lt;/span&gt; &lt;span class="nt"&gt;-type&lt;/span&gt; f &lt;span class="nt"&gt;-iname&lt;/span&gt; &lt;span class="s1"&gt;'*.png'&lt;/span&gt; | &lt;span class="nb"&gt;wc&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"WebP:  &lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;find &lt;span class="nb"&gt;.&lt;/span&gt; &lt;span class="nt"&gt;-type&lt;/span&gt; f &lt;span class="nt"&gt;-iname&lt;/span&gt; &lt;span class="s1"&gt;'*.webp'&lt;/span&gt; | &lt;span class="nb"&gt;wc&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The output stopped me cold:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;2.1G    .
JPEG:  3245
PNG:   1443
WebP:  12301
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Twelve thousand WebP files on a site that supposedly had no WebP.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding 1: 241MB of files nothing was serving
&lt;/h2&gt;

&lt;p&gt;The naming told the story — &lt;code&gt;image.jpg.webp&lt;/code&gt;, sidecar files in the same directory as the original. That's EWWW Image Optimizer's convention.&lt;/p&gt;

&lt;p&gt;EWWW was not installed. Somebody had run it, then removed the plugin.&lt;/p&gt;

&lt;p&gt;When EWWW generates sidecars, delivery depends on either an &lt;code&gt;.htaccess&lt;/code&gt; rewrite doing &lt;code&gt;Accept&lt;/code&gt;-header negotiation, or a &lt;code&gt;&amp;lt;picture&amp;gt;&lt;/code&gt; element filter. Both live in the plugin. Remove the plugin and the files become inert — 241MB of dead weight, still being scanned by the server's malware scanner on every pass, still counted in every backup.&lt;/p&gt;

&lt;p&gt;Verified they were genuinely unreferenced before deleting:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# no content references&lt;/span&gt;
wp db query &lt;span class="s2"&gt;"SELECT COUNT(*) FROM wp_posts WHERE post_content LIKE '%.jpg.webp%';"&lt;/span&gt;
&lt;span class="c"&gt;# no attachment metadata references&lt;/span&gt;
wp db query &lt;span class="s2"&gt;"SELECT COUNT(*) FROM wp_postmeta WHERE meta_value LIKE '%.jpg.webp%';"&lt;/span&gt;
&lt;span class="c"&gt;# no rewrite rule anywhere&lt;/span&gt;
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-in&lt;/span&gt; webp .htaccess ~/conf/&lt;span class="k"&gt;*&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three zeros. Deleted, 241MB back.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lesson:&lt;/strong&gt; image optimizer plugins leave their output behind when uninstalled. If you inherit a site, grep the uploads directory for orphaned formats before you add another optimizer to the pile.&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding 2: the size ladder had been demolished
&lt;/h2&gt;

&lt;p&gt;This is the one that mattered.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;wp &lt;span class="nb"&gt;eval&lt;/span&gt; &lt;span class="s1"&gt;'print_r( get_intermediate_image_sizes() );'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;Array&lt;/span&gt;
&lt;span class="p"&gt;(&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;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;large&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;gform&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;image&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;choice&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;sm&lt;/span&gt;
    &lt;span class="p"&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="n"&gt;gform&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;image&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;choice&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;md&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;18&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;gform&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;image&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;choice&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;lg&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four registered sizes. No &lt;code&gt;thumbnail&lt;/code&gt;, no &lt;code&gt;medium&lt;/code&gt;, no &lt;code&gt;medium_large&lt;/code&gt;. And critically — no &lt;code&gt;woocommerce_thumbnail&lt;/code&gt;, no &lt;code&gt;woocommerce_gallery_thumbnail&lt;/code&gt;, none of Divi's.&lt;/p&gt;

&lt;p&gt;Look at the array keys. They're non-sequential: 3, 16, 17, 18. The array originally held 0 through 18 and something had &lt;code&gt;unset()&lt;/code&gt; entries without reindexing. That's a filter stripping sizes at runtime, not sizes that were never registered.&lt;/p&gt;

&lt;p&gt;The culprit was a plugin called &lt;code&gt;disable-thumbnails-and-threshold&lt;/code&gt;, hooking &lt;code&gt;intermediate_image_sizes&lt;/code&gt; at priority 100, with eighteen sizes checked off in its settings. A previous dev had installed it to fight disk pressure. Understandable instinct, catastrophic execution.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why this is worse than it sounds
&lt;/h3&gt;

&lt;p&gt;WooCommerce still &lt;em&gt;believed&lt;/em&gt; it had a 300×300 thumbnail size:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;wp &lt;span class="nb"&gt;eval&lt;/span&gt; &lt;span class="s1"&gt;'print_r( wc_get_image_size("woocommerce_thumbnail") );'&lt;/span&gt;
&lt;span class="c"&gt;# =&amp;gt; width 300, height 300, crop 1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The size was registered in WooCommerce's config, but the files were never generated. WooCommerce ships &lt;code&gt;WC_Regenerate_Images&lt;/code&gt;, which hooks &lt;code&gt;wp_get_attachment_image_src&lt;/code&gt; and resizes on the fly when a requested size is missing.&lt;/p&gt;

&lt;p&gt;I counted actual coverage:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;find wp-content/uploads &lt;span class="nt"&gt;-type&lt;/span&gt; f &lt;span class="se"&gt;\(&lt;/span&gt; &lt;span class="nt"&gt;-iname&lt;/span&gt; &lt;span class="s1"&gt;'*-300x300.jpg'&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nt"&gt;-iname&lt;/span&gt; &lt;span class="s1"&gt;'*-300x300.png'&lt;/span&gt; &lt;span class="se"&gt;\)&lt;/span&gt; | &lt;span class="nb"&gt;wc&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt;
&lt;span class="c"&gt;# =&amp;gt; 141&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;141 files out of 2,216 attachments. Six percent coverage.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Meaning: for roughly 94% of product image requests, WooCommerce was doing an on-the-fly Imagick resize. On a box the host had already flagged for CPU saturation.&lt;/p&gt;

&lt;p&gt;And the &lt;code&gt;srcset&lt;/code&gt; consequence: with only &lt;code&gt;large&lt;/code&gt; (1024px) registered, every responsive image had exactly one candidate. A phone loading a product grid pulled 1024px files into 300px slots. WebP shaves ~30% off a file. Serving the right dimensions shaves 90%.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The format was never the problem. The missing size ladder was.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding 3: the images weren't the disk hog
&lt;/h2&gt;

&lt;p&gt;Adding up the raster formats: 705MB PNG + 242MB WebP + 192MB JPEG = 1,139MB. Total uploads: 2.1GB.&lt;/p&gt;

&lt;p&gt;Where was the other gigabyte?&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;find &lt;span class="nb"&gt;.&lt;/span&gt; &lt;span class="nt"&gt;-type&lt;/span&gt; f &lt;span class="nt"&gt;-printf&lt;/span&gt; &lt;span class="s1"&gt;'%f\n'&lt;/span&gt; | &lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="nt"&gt;-F&lt;/span&gt;&lt;span class="nb"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;'NF&amp;gt;1{print tolower($NF)}'&lt;/span&gt; | &lt;span class="nb"&gt;sort&lt;/span&gt; | &lt;span class="nb"&gt;uniq&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; | &lt;span class="nb"&gt;sort&lt;/span&gt; &lt;span class="nt"&gt;-rn&lt;/span&gt; | &lt;span class="nb"&gt;head&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;122 PDFs, 12 MP4s, 2 Keynote files. Course manuals and lesson videos — two videos alone were 170MB, and one training manual existed in four copies across two editions for 185MB.&lt;/p&gt;

&lt;p&gt;If I'd gone in with "let's reclaim disk by optimizing images," I'd have spent a day of CPU to reclaim less than the duplicate PDFs were wasting.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix: three filters, no new plugins
&lt;/h2&gt;

&lt;p&gt;I considered ShortPixel and Imagify. Both are good. Both were wrong here:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;They'd add ~17,000 API credits of cost for 2,216 attachments across 8 sizes&lt;/li&gt;
&lt;li&gt;They write sidecar files and need a delivery mechanism — &lt;strong&gt;the exact pattern that left 241MB of orphans when EWWW was removed&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;They'd be plugin #50 on a site already carrying too many
WordPress core has done this natively since 6.x. Three snippets, no dependencies.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  1. Restore the size ladder
&lt;/h3&gt;

&lt;p&gt;Rather than deactivating the disable-thumbnails plugin — which would swing from "no sizes" to "all nineteen sizes" and regenerate every unused Divi variant — I unchecked exactly five and left thirteen stripped:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$o&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;get_option&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'dtat_disablethumbnails_option_name'&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="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt; &lt;span class="s1"&gt;'thumbnail'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'medium'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'medium_large'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s1"&gt;'woocommerce_thumbnail'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'woocommerce_gallery_thumbnail'&lt;/span&gt; &lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$k&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;unset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$o&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt; &lt;span class="nv"&gt;$k&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="nf"&gt;update_option&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'dtat_disablethumbnails_option_name'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$o&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nine registered sizes instead of four or nineteen. The Divi &lt;code&gt;et-pb-*&lt;/code&gt; sizes stay stripped — the theme's own bloat wasn't worth reintroducing.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. WebP output for generated subsizes
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nf"&gt;add_filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'image_editor_output_format'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$formats&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$filename&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$mime_type&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="nb"&gt;in_array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$mime_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt; &lt;span class="s1"&gt;'image/jpeg'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'image/png'&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="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$formats&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt; &lt;span class="nv"&gt;$mime_type&lt;/span&gt; &lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'image/webp'&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="nv"&gt;$formats&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="mi"&gt;10&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="nf"&gt;add_filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'wp_editor_set_quality'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$quality&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$mime_type&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="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'image/webp'&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nv"&gt;$mime_type&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="mi"&gt;82&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;$quality&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="mi"&gt;10&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The critical property here: WordPress preserves the source file as &lt;code&gt;original_image&lt;/code&gt; in attachment metadata. Every future regeneration encodes from the lossless master, not from the WebP. Verify it before you commit to a bulk run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;wp &lt;span class="nb"&gt;eval&lt;/span&gt; &lt;span class="s1"&gt;'$m = wp_get_attachment_metadata( 123 ); var_dump( $m["original_image"] ?? "ABSENT" );'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If that returns &lt;code&gt;ABSENT&lt;/code&gt;, stop. You'd be setting up cumulative quality loss — every regeneration re-encoding lossy from lossy, degrading invisibly over months until it's baked into everything.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Rewrite hardcoded URLs at render time
&lt;/h3&gt;

&lt;p&gt;This is the gotcha that nearly shipped broken.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;image_editor_output_format&lt;/code&gt; governs what WordPress &lt;em&gt;generates&lt;/em&gt;. It does nothing about URLs already written into &lt;code&gt;post_content&lt;/code&gt;. A Gutenberg &lt;code&gt;wp-block-image&lt;/code&gt; figure has the path baked into the block markup. So does a Divi shortcode. Regeneration doesn't touch stored markup.&lt;/p&gt;

&lt;p&gt;I measured the exposure:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;post_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;COUNT&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="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;wp_posts&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;post_status&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'publish'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'draft'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'inherit'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;post_content&lt;/span&gt; &lt;span class="n"&gt;REGEXP&lt;/span&gt; &lt;span class="s1"&gt;'wp-content/uploads/[^"]+[.](png|jpe?g)'&lt;/span&gt;
&lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;post_type&lt;/span&gt; &lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;190 non-revision posts — including 25 Divi Library items and 3 Theme Builder layouts, which inject into many pages each. Left alone, most of the site's content images would have kept serving PNG while their WebP siblings sat unused on disk.&lt;/p&gt;

&lt;p&gt;Fixed at render time rather than with a database &lt;code&gt;search-replace&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nf"&gt;add_filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'wp_content_img_tag'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$html&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$attachment_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="kc"&gt;false&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nb"&gt;strpos&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$html&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'/wp-content/uploads/'&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="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$html&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nv"&gt;$dir&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;wp_get_upload_dir&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="c1"&gt;// Original filename -&amp;gt; current primary file, for -scaled attachments.&lt;/span&gt;
    &lt;span class="nv"&gt;$orig_map&lt;/span&gt; &lt;span class="o"&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="nv"&gt;$attachment_id&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$meta&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;wp_get_attachment_metadata&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$attachment_id&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="k"&gt;empty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$meta&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'original_image'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="k"&gt;empty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$meta&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'file'&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="p"&gt;{&lt;/span&gt;
            &lt;span class="nv"&gt;$orig_map&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt; &lt;span class="nv"&gt;$meta&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'original_image'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;wp_basename&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$meta&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'file'&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="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="nv"&gt;$cache&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;preg_replace_callback&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="s1"&gt;'#(/wp-content/uploads/[^\s"\']+?)\.(png|jpe?g)#i'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$m&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$dir&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$orig_map&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nv"&gt;$cache&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nv"&gt;$key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$m&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&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="k"&gt;isset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$cache&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt; &lt;span class="nv"&gt;$key&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="p"&gt;{&lt;/span&gt;
                &lt;span class="nv"&gt;$rel&lt;/span&gt;           &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;substr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$m&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="nb"&gt;strlen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'/wp-content/uploads'&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
                &lt;span class="nv"&gt;$cache&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt; &lt;span class="nv"&gt;$key&lt;/span&gt; &lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;file_exists&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$dir&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'basedir'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nv"&gt;$rel&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;'.webp'&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="nv"&gt;$cache&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt; &lt;span class="nv"&gt;$key&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="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$m&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="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;'.webp'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;

            &lt;span class="nv"&gt;$base&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;wp_basename&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$m&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&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="k"&gt;isset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$orig_map&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt; &lt;span class="nv"&gt;$base&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="p"&gt;{&lt;/span&gt;
                &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;dirname&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$m&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="p"&gt;)&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;'/'&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nv"&gt;$orig_map&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt; &lt;span class="nv"&gt;$base&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="nv"&gt;$m&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
        &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="nv"&gt;$html&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="mi"&gt;20&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  The &lt;code&gt;-scaled&lt;/code&gt; trap
&lt;/h3&gt;

&lt;p&gt;My first version of that filter was ten lines shorter — just the extension swap and the&lt;br&gt;
&lt;code&gt;file_exists()&lt;/code&gt; guard. It tested clean and shipped, and then I found this in a Divi module:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;img&lt;/span&gt; &lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;".../DrEdythe_7.2024.png"&lt;/span&gt;
     &lt;span class="na"&gt;srcset=&lt;/span&gt;&lt;span class="s"&gt;".../DrEdythe_7.2024-scaled.webp 1831w,
             .../DrEdythe_7.2024-215x300.webp 215w, ..."&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every &lt;code&gt;srcset&lt;/code&gt; candidate WebP. The &lt;code&gt;src&lt;/code&gt; still a 9.9MB PNG — the largest file on the site.&lt;/p&gt;

&lt;p&gt;WordPress scales any upload over &lt;code&gt;big_image_size_threshold&lt;/code&gt; (2560px by default) and makes the&lt;br&gt;
scaled copy the primary file, keeping the upload as &lt;code&gt;original_image&lt;/code&gt;. With WebP output enabled&lt;br&gt;
you get &lt;code&gt;foo-scaled.webp&lt;/code&gt; as primary and &lt;code&gt;foo.png&lt;/code&gt; as the preserved master. &lt;strong&gt;There is no&lt;br&gt;
&lt;code&gt;foo.webp&lt;/code&gt;.&lt;/strong&gt; So the naive swap looks for a file that will never exist, finds nothing, and&lt;br&gt;
leaves the PNG in place — silently, on exactly the biggest images you most wanted to fix.&lt;/p&gt;

&lt;p&gt;The fix is to stop guessing filenames. &lt;code&gt;wp_content_img_tag&lt;/code&gt; passes the attachment ID, so&lt;br&gt;
metadata can tell you the real current filename. Just remember &lt;code&gt;, 20, 3&lt;/code&gt; on the &lt;code&gt;add_filter&lt;/code&gt;&lt;br&gt;
call — without the argument count the ID never arrives and the branch is dead code.&lt;/p&gt;

&lt;p&gt;Why render-time over &lt;code&gt;wp search-replace&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No database mutation.&lt;/strong&gt; Rollback is deactivating a snippet.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It's conditional.&lt;/strong&gt; It only rewrites when a real target exists — a &lt;code&gt;.webp&lt;/code&gt; sibling on disk, or a primary file named in attachment metadata. A blind &lt;code&gt;.png&lt;/code&gt; → &lt;code&gt;.webp&lt;/code&gt; search-replace would have broken every reference to the ~54 icons that were correctly never converted.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It covers everything.&lt;/strong&gt; Gutenberg blocks, Divi modules, classic editor content — all pass through &lt;code&gt;the_content&lt;/code&gt;.
The per-request static cache keeps &lt;code&gt;file_exists()&lt;/code&gt; negligible, and page caching absorbs the rest.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  Running the regeneration
&lt;/h2&gt;

&lt;p&gt;The site had Regenerate Thumbnails installed with a perfectly good UI. I used WP-CLI anyway, for two reasons.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Disk visibility.&lt;/strong&gt; The plugin runs until done or until the volume fills. The box was at 84%. Batching from CLI let me check headroom between chunks:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;wp post list &lt;span class="nt"&gt;--post_type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;attachment &lt;span class="nt"&gt;--post_mime_type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;image/jpeg,image/png &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--format&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;ids &lt;span class="nt"&gt;--posts_per_page&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nt"&gt;-1&lt;/span&gt; | &lt;span class="nb"&gt;tr&lt;/span&gt; &lt;span class="s1"&gt;' '&lt;/span&gt; &lt;span class="s1"&gt;'\n'&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /tmp/ids.txt
&lt;span class="nb"&gt;split&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt; 200 /tmp/ids.txt /tmp/idb_

&lt;span class="k"&gt;for &lt;/span&gt;b &lt;span class="k"&gt;in&lt;/span&gt; /tmp/idb_&lt;span class="k"&gt;*&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;nice&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; 19 wp media regenerate &lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;tr&lt;/span&gt; &lt;span class="s1"&gt;'\n'&lt;/span&gt; &lt;span class="s1"&gt;' '&lt;/span&gt; &amp;lt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$b&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt; &lt;span class="nt"&gt;--only-missing&lt;/span&gt; &lt;span class="nt"&gt;--yes&lt;/span&gt;
  &lt;span class="nb"&gt;du&lt;/span&gt; &lt;span class="nt"&gt;-sm&lt;/span&gt; wp-content/uploads
  free &lt;span class="nt"&gt;-m&lt;/span&gt; | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-2&lt;/span&gt;
  &lt;span class="nb"&gt;sleep &lt;/span&gt;30
&lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Action Scheduler.&lt;/strong&gt; The site had a chronic Action Scheduler backlog the host had already flagged as a CPU source. Admin-driven bulk tools queue work there. WP-CLI is synchronous and bypasses it entirely.&lt;/p&gt;

&lt;p&gt;I also blocked WooCommerce's own background regeneration for the duration, since re-registering &lt;code&gt;woocommerce_thumbnail&lt;/code&gt; can trigger it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nf"&gt;add_filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'woocommerce_background_image_regeneration'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'__return_false'&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;nice -n 19&lt;/code&gt; matters more than it looks. It means the kernel gives live traffic priority over the migration on every single scheduling decision. CPU sat around 69% during the run and the site stayed responsive, because the 69% was work nobody else wanted.&lt;/p&gt;

&lt;p&gt;Measured throughput: &lt;strong&gt;66 seconds per 200 attachments.&lt;/strong&gt; The full run took under twenty minutes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rolling it out to production
&lt;/h2&gt;

&lt;p&gt;Staging and production were separate applications on the same server. I wrote the whole thing up as a runbook and replayed it, and it went nearly identically — which is the point of rehearsing.&lt;/p&gt;

&lt;p&gt;Nearly. One thing bit me.&lt;/p&gt;

&lt;p&gt;The host gives each application a &lt;strong&gt;private &lt;code&gt;/tmp&lt;/code&gt; namespace&lt;/strong&gt;. My batch files, ID list, and log had all been written to &lt;code&gt;/tmp&lt;/code&gt; during the rehearsal, owned by the staging user. On production, every &lt;code&gt;/tmp&lt;/code&gt; write failed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;bash: /tmp/ids.txt: Permission denied
rm: cannot remove '/tmp/idb_aa': Operation not permitted
split: /tmp/idb_aa: Permission denied
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Except the reads didn't fail. &lt;code&gt;wc -l /tmp/ids.txt&lt;/code&gt; cheerfully returned 2216 — &lt;strong&gt;staging's file&lt;/strong&gt;. My first production batch ran against the rehearsal environment's ID list. It happened to work, because the two databases share attachment IDs, but that was luck. The background script never started at all, and I only noticed because the log line count sat at zero.&lt;/p&gt;

&lt;p&gt;Moved everything to a private directory outside the web root and it ran clean.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The lesson isn't "check permissions."&lt;/strong&gt; It's that a partially-failed setup is more dangerous than a fully-failed one. If &lt;code&gt;/tmp&lt;/code&gt; had been readable &lt;em&gt;and&lt;/em&gt; unwritable in a consistent way I'd have caught it instantly. Instead the writes failed loudly and the reads succeeded silently, and the silent success is what nearly shipped a run against the wrong data.&lt;/p&gt;

&lt;p&gt;Final numbers matched the rehearsal exactly: 2,097 of 2,216 converted, 55 sub-threshold icons untouched, 64 attachments whose files were already missing, and every oversized &lt;code&gt;-scaled&lt;/code&gt; image passing the URL-rewrite check.&lt;/p&gt;

&lt;h2&gt;
  
  
  Results
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Before&lt;/th&gt;
&lt;th&gt;After&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;WooCommerce thumbnail coverage&lt;/td&gt;
&lt;td&gt;141 / 2,216 (6%)&lt;/td&gt;
&lt;td&gt;complete&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;srcset&lt;/code&gt; candidates per image&lt;/td&gt;
&lt;td&gt;1 (1024px)&lt;/td&gt;
&lt;td&gt;4 (100/150/300/768)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Attachments with WebP primary&lt;/td&gt;
&lt;td&gt;18&lt;/td&gt;
&lt;td&gt;2,097&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Uploads directory&lt;/td&gt;
&lt;td&gt;~2,100MB&lt;/td&gt;
&lt;td&gt;~1,900MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sample: 1.33MB PNG hero&lt;/td&gt;
&lt;td&gt;1,332,273 B&lt;/td&gt;
&lt;td&gt;27,634 B&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sample: 322KB JPEG headshot&lt;/td&gt;
&lt;td&gt;322,419 B&lt;/td&gt;
&lt;td&gt;37,214 B&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;The uploads directory got smaller.&lt;/strong&gt; Adding roughly 11,000 WebP files while deleting 12,283 orphaned sidecars nets out negative. Disk was never the constraint I'd braced for.&lt;/p&gt;

&lt;p&gt;The biggest win isn't in that table. It's that WooCommerce stopped resizing images on demand for 94% of product requests. That was real CPU on every uncached page load, and it's simply gone now.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's still wrong
&lt;/h2&gt;

&lt;p&gt;I'd have called this done, except the first Divi page I checked afterward served this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;img&lt;/span&gt; &lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;".../DrEdythe_7.2024-scaled.webp"&lt;/span&gt;
     &lt;span class="na"&gt;srcset=&lt;/span&gt;&lt;span class="s"&gt;".../DrEdythe_7.2024-scaled.webp 1831w,
             .../DrEdythe_7.2024-732x1024.webp 732w, ..."&lt;/span&gt;
     &lt;span class="na"&gt;sizes=&lt;/span&gt;&lt;span class="s"&gt;"(max-width: 1831px) 100vw, 1831px"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Correct format, correct &lt;code&gt;src&lt;/code&gt;, four &lt;code&gt;srcset&lt;/code&gt; candidates. And still wrong.&lt;/p&gt;

&lt;p&gt;Read the &lt;code&gt;sizes&lt;/code&gt; attribute. It tells the browser the image occupies the full viewport width up to 1831px. The module actually renders in a column a third of that. So the browser dutifully picks the 1831w candidate — 574KB — when the 732w file at 99KB would have covered the slot.&lt;/p&gt;

&lt;p&gt;Which is the same failure as the thumbnail ladder, wearing a different hat. I spent a session making sure the right files existed and the right URLs pointed at them, and the browser is still downloading 6× more than it needs because one attribute lies about the layout.&lt;/p&gt;

&lt;p&gt;That one's fixable through &lt;code&gt;wp_calculate_image_sizes&lt;/code&gt;, but it needs measuring against real module widths rather than guessed — and it's a bigger remaining win than the format change was.&lt;/p&gt;

&lt;p&gt;There's also a cleanup I deliberately didn't do. Superseded originals are still on disk: &lt;code&gt;DrEdythe_7.2024-732x1024.png&lt;/code&gt; at 1.36MB sitting inert next to its 99KB WebP, and hundreds more like it. Tempting to sweep. But while the render-time filter is doing delivery, those files &lt;em&gt;are&lt;/em&gt; the rollback path — deactivating the snippet reverts URLs to &lt;code&gt;.png&lt;/code&gt;, which only works if the &lt;code&gt;.png&lt;/code&gt; is still there. That deletion waits until WebP has been boring in production for a month.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd tell past-me
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Audit before you optimize.&lt;/strong&gt; The task I'd written down for myself was "convert images to WebP." The actual problem was a stripped size ladder causing on-the-fly resizing and 3× mobile payloads. Twenty minutes of &lt;code&gt;find&lt;/code&gt; and &lt;code&gt;wp eval&lt;/code&gt; reordered the entire job — and turned a cosmetic improvement into a real one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Non-sequential array keys are a fingerprint.&lt;/strong&gt; &lt;code&gt;[3], [16], [17], [18]&lt;/code&gt; told me a filter was unsetting entries at runtime, not that sizes were never registered. That one detail pointed straight at the plugin responsible.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Check what the previous optimizer left behind.&lt;/strong&gt; Uninstalling an image plugin doesn't remove its output. Grep for orphaned formats before adding another.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;original_image&lt;/code&gt; is the check that prevents slow-motion disaster.&lt;/strong&gt; Without it you get lossy-from-lossy regeneration compounding over years, and nobody notices until every image on the site is soft.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Check the biggest images last, not first.&lt;/strong&gt; My URL-rewrite filter passed every test I wrote&lt;br&gt;
and still left the single largest file on the site unconverted, because &lt;code&gt;-scaled&lt;/code&gt; originals&lt;br&gt;
don't have the filename the naive swap looks for. Verify against your worst-case asset, not a&lt;br&gt;
representative one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A verification query can measure itself.&lt;/strong&gt; Those 64 missing files bothered me, so I wrote a&lt;br&gt;
check to find out whether anything referenced them. It reported 64 out of 64 referenced —&lt;br&gt;
alarming, and wrong. It was counting each attachment's own &lt;code&gt;_wp_attached_file&lt;/code&gt; and&lt;br&gt;
&lt;code&gt;_wp_attachment_metadata&lt;/code&gt; rows as evidence that something else pointed at it, and matching a&lt;br&gt;
filename stem like &lt;code&gt;back&lt;/code&gt; against every post containing the word "back." Every row came back&lt;br&gt;
"used" because the query couldn't tell a reference from a reflection. Walking the actual pages&lt;br&gt;
in a browser had already given the right answer; I just trusted the SQL more because it had a&lt;br&gt;
number in it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Generating the right sizes beats changing the format — and telling the browser the truth beats both.&lt;/strong&gt; WebP is maybe 30% off a file. Serving a 300px image instead of a 1024px one is 90%. And an honest &lt;code&gt;sizes&lt;/code&gt; attribute is what makes the browser actually choose the 300px one. Format, dimensions, declaration — in ascending order of how much they matter and descending order of how much attention they get.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Notes: written up from a real migration on a WooCommerce + LearnDash + Divi site I maintain. Proactive maintenance work, not a client request. Site details anonymized — swap in specifics if you have sign-off. Table prefix shown as &lt;code&gt;wp_&lt;/code&gt; throughout; use &lt;code&gt;$(wp db prefix)&lt;/code&gt; on real sites.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>wordpress</category>
      <category>woocommerce</category>
      <category>php</category>
    </item>
    <item>
      <title>How fast do you finish your usage on Claude Code?</title>
      <dc:creator>Vicente G. Reyes</dc:creator>
      <pubDate>Wed, 29 Jul 2026 04:24:30 +0000</pubDate>
      <link>https://dev.to/highcenburg/how-fast-do-you-finish-your-usage-on-claude-code-4n76</link>
      <guid>https://dev.to/highcenburg/how-fast-do-you-finish-your-usage-on-claude-code-4n76</guid>
      <description>&lt;p&gt;On heavy coding work, I finish Claude's usage in more or less 2 hours. Is that fine or not? I'm on Claude Pro. How about you?&lt;/p&gt;

</description>
      <category>discuss</category>
      <category>code</category>
    </item>
    <item>
      <title>The Case of the Disappearing Free Trial</title>
      <dc:creator>Vicente G. Reyes</dc:creator>
      <pubDate>Fri, 24 Jul 2026 14:18:17 +0000</pubDate>
      <link>https://dev.to/highcenburg/the-case-of-the-disappearing-free-trial-26m2</link>
      <guid>https://dev.to/highcenburg/the-case-of-the-disappearing-free-trial-26m2</guid>
      <description>&lt;p&gt;&lt;em&gt;A debugging story about WooCommerce Subscriptions, caching red herrings, and one deliberate line of code that quietly broke a conversion funnel.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The Symptom
&lt;/h2&gt;

&lt;p&gt;A client's fitness membership site offers a 7-day free trial on its subscription products. Simple enough: sign up, browse the workout library free for a week, get charged automatically after — standard WooCommerce Subscriptions behavior.&lt;/p&gt;

&lt;p&gt;Except guests visiting the site couldn't see the trial at all. The product page just showed the regular monthly price. No trial messaging, no "$0 due today." And when a guest actually went through checkout, they were charged the full price immediately — no 7-day grace period in sight.&lt;/p&gt;

&lt;p&gt;Logged-in users saw the trial just fine. Every time.&lt;/p&gt;

&lt;p&gt;That single fact — &lt;strong&gt;logged in: works, logged out: doesn't&lt;/strong&gt; — turned out to be both the biggest clue and the biggest trap of the entire investigation.&lt;/p&gt;

&lt;h2&gt;
  
  
  First Theories (All Wrong)
&lt;/h2&gt;

&lt;p&gt;The obvious first guess: maybe this particular product's trial configuration was broken. A quick look at the product's variations showed the trial length was set correctly — 7 days, right there in the admin. So that wasn't it.&lt;/p&gt;

&lt;p&gt;Next theory: maybe it was a caching problem. The site ran multiple cache layers — a page cache, a reverse proxy cache, and a persistent object cache. Guest requests are cached far more aggressively than logged-in ones, so a stale cached copy of the page (generated before some earlier fix) could easily explain "guests see the old broken version, logged-in users always get a fresh page."&lt;/p&gt;

&lt;p&gt;This felt promising enough to chase hard:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Purged the page cache. No change.&lt;/li&gt;
&lt;li&gt;Purged the reverse-proxy cache. No change.&lt;/li&gt;
&lt;li&gt;Flushed the persistent object cache directly via WP-CLI. No change.&lt;/li&gt;
&lt;li&gt;Tested with cache-busting query strings and &lt;code&gt;Cache-Control: no-cache&lt;/code&gt; headers, confirmed via response headers that the request was a genuine cache &lt;strong&gt;MISS&lt;/strong&gt;, generated fresh, at that exact moment. &lt;strong&gt;Still no trial for the guest request.&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last test was important. It proved, with certainty, that this wasn't caching at all. PHP was executing fully, fresh, for an anonymous visitor — and still choosing not to show the trial. Whatever was happening, it was happening in application logic, not in a cache layer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Ruling Out an Entire Stack, One Piece at a Time
&lt;/h2&gt;

&lt;p&gt;With caching off the table, the search moved into the code itself. Over the following days:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Product-level theories&lt;/strong&gt; (duplicate variations, a "Virtual" checkbox left unchecked, a product-type mismatch between simple vs. variable subscriptions) were tested and eliminated — the same behavior showed up across &lt;em&gt;every&lt;/em&gt; subscription product on the site, not just one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Custom code&lt;/strong&gt; — theme files, must-use plugins, active Code Snippets — was searched exhaustively via &lt;code&gt;grep&lt;/code&gt; for anything referencing trial length, price display, or login state. All clean.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;WooCommerce Dynamic Pricing&lt;/strong&gt;, a plugin capable of role-based price rules, was a strong suspect for a while. Ruled out by testing as a plain logged-in Customer account (no special role) — the trial showed correctly, meaning it wasn't about &lt;em&gt;which&lt;/em&gt; role you had, only &lt;em&gt;whether&lt;/em&gt; you were logged in at all.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A subscription "Enhancer" plugin&lt;/strong&gt; with its own trial-limiting logic looked like the smoking gun for a moment. Tracing its actual function line-by-line showed it explicitly left guests untouched — it only affected logged-in users checking for repeat trials. Not the cause.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;WooCommerce Subscriptions' own core code&lt;/strong&gt; was traced end-to-end: the function that reads a product's trial length, the function that turns that into the "with a 7-day free trial" string, the method that assembles the final price HTML. No login check anywhere in any of it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;By this point, every obvious suspect — and several non-obvious ones — had been checked and cleared. The trial data was correct. The display logic was correct. And yet, for a guest, the number was still coming out as zero.&lt;/p&gt;

&lt;h2&gt;
  
  
  Following the Data, Not the Guesses
&lt;/h2&gt;

&lt;p&gt;The turning point was switching from "search the files" to "ask WordPress directly what's actually happening at runtime."&lt;/p&gt;

&lt;p&gt;WP-CLI made this possible:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;wp &lt;span class="nb"&gt;eval&lt;/span&gt; &lt;span class="s1"&gt;'echo WC_Subscriptions_Product::get_trial_length(11343);'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run as an anonymous request (user ID 0, matching a real guest), this returned &lt;code&gt;0&lt;/code&gt;. Run with an authenticated user context, it correctly returned &lt;code&gt;7&lt;/code&gt;. Confirmed, cleanly: the discrepancy was real, reproducible, and happening inside this exact function call.&lt;/p&gt;

&lt;p&gt;That function's actual implementation was almost embarrassingly simple:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;get_trial_length&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$product&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="nf"&gt;apply_filters&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'woocommerce_subscriptions_product_trial_length'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;get_meta_data&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;...&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mf"&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;It reads the correct value from the database (&lt;code&gt;7&lt;/code&gt;, confirmed directly via &lt;code&gt;wp post meta get&lt;/code&gt;) — then passes it through a WordPress filter before returning it. Somewhere, something was hooked onto that filter and changing the number.&lt;/p&gt;

&lt;p&gt;A plain-text search for that filter name across the plugin and theme files found only two matches: the definition itself, and one plugin's filter (already cleared above). Nothing else — which didn't make sense, because &lt;em&gt;something&lt;/em&gt; was clearly modifying the value.&lt;/p&gt;

&lt;p&gt;The missing piece was that &lt;strong&gt;custom code snippets aren't stored as files at all&lt;/strong&gt; — they live in the database, injected into the request at runtime. No filesystem search will ever find them.&lt;/p&gt;

&lt;p&gt;The real breakthrough came from asking WordPress's own hook registry directly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;wp &lt;span class="nb"&gt;eval&lt;/span&gt; &lt;span class="s1"&gt;'global $wp_filter; 
foreach ($wp_filter["woocommerce_subscriptions_product_trial_length"]-&amp;gt;callbacks as $priority =&amp;gt; $callbacks) { 
    foreach ($callbacks as $cb) { 
        echo "Priority $priority: " . (is_array($cb["function"]) 
            ? get_class($cb["function"][0]) . "::" . $cb["function"][1] 
            : "Closure") . "\n"; 
    } 
}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This listed every single callback actually hooked onto that filter, in execution order — including a &lt;strong&gt;Closure&lt;/strong&gt;, running before everything else, that no static search had caught. Using PHP's reflection API to ask that closure where it was defined pointed straight at a database-stored code snippet.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Actual Bug
&lt;/h2&gt;

&lt;p&gt;The snippet in question, named &lt;em&gt;"Blocks Multiple Free Trials,"&lt;/em&gt; was a perfectly reasonable piece of anti-abuse logic: don't let a customer claim a second free trial on a product they've already subscribed to before. Good instinct, sound feature.&lt;/p&gt;

&lt;p&gt;But nested inside it was this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&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="nf"&gt;is_user_logged_in&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="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// Stronger enforcement: require login for any trial&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Not a bug in the traditional sense — a deliberate design decision, with a comment explaining exactly what it did. Someone had decided that requiring a login before offering any trial was a form of "stronger enforcement." It technically worked as written. It just wasn't what the business actually wanted: real customers were landing on the site from social media, seeing a product that advertised a 7-day trial, and getting charged in full the moment they checked out as a guest.&lt;/p&gt;

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

&lt;p&gt;The fix didn't touch the legitimate repeat-trial protection at all — only the guest-blocking branch:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&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="nf"&gt;is_user_logged_in&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="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$trial_length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// Guests get the trial; repeat-check only applies to logged-in users&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Guests now see the trial correctly, first-time visitors get $0-today checkout with the first charge seven days later, and returning customers still can't double-dip on a second trial. One line changed; the intended behavior preserved.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Made This One Hard
&lt;/h2&gt;

&lt;p&gt;A few things stack up to make a bug like this genuinely difficult, worth naming for anyone hitting something similar:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The correlation was real but the causation was wrong.&lt;/strong&gt; "Logged in vs. logged out" was a completely accurate description of the symptom the entire time — it just pointed everyone toward login-related plugin settings and eligibility logic, when the actual cause was a single conditional buried three layers deep in unrelated anti-abuse code.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Every individual system was innocent.&lt;/strong&gt; WooCommerce core, the theme, the caching layers, the pricing plugin — each one, checked in isolation, was completely correct. The bug lived in custom glue code that no one else's system could see or account for.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Code Snippets don't show up in a filesystem search.&lt;/strong&gt; Anything stored and evaluated from the database is invisible to &lt;code&gt;grep&lt;/code&gt;, and easy to forget existed at all if it isn't the snippet you're actively thinking about.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A deliberate decision looks identical to a bug from the outside.&lt;/strong&gt; The code wasn't broken; it was doing exactly what it was told. The mismatch was between what the code said and what the business actually wanted — which no amount of debugging tools can catch unless you're willing to trace all the way down to the literal returned value and ask "why."&lt;/li&gt;
&lt;/ol&gt;

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

&lt;ul&gt;
&lt;li&gt;When behavior differs by user state, don't assume it's a permissions/eligibility &lt;em&gt;setting&lt;/em&gt; — check for &lt;strong&gt;custom filters&lt;/strong&gt; hooked into the same pipeline, especially in code you didn't write yourself.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;grep&lt;/code&gt; is powerful, but it can't see what's stored in a database. If a site has any kind of snippet manager, check its actual contents directly — not just "how many snippets are active" in an admin list.&lt;/li&gt;
&lt;li&gt;When static analysis stalls, ask the running application directly. &lt;code&gt;wp eval&lt;/code&gt; and inspecting &lt;code&gt;$wp_filter&lt;/code&gt; at runtime found in minutes what days of file searching couldn't.&lt;/li&gt;
&lt;li&gt;Not every "bug" is broken code. Sometimes it's a correct implementation of an incorrect assumption — and the fix is a conversation about intent, not just a patch.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>wordpress</category>
      <category>php</category>
      <category>webdev</category>
      <category>debugging</category>
    </item>
  </channel>
</rss>
