<?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: Alex DevOps engineer</title>
    <description>The latest articles on DEV Community by Alex DevOps engineer (@oleksandr_devops).</description>
    <link>https://dev.to/oleksandr_devops</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%2F3907876%2F9865b7b5-fe2d-4576-867f-becdf82a1fef.png</url>
      <title>DEV Community: Alex DevOps engineer</title>
      <link>https://dev.to/oleksandr_devops</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/oleksandr_devops"/>
    <language>en</language>
    <item>
      <title>Shipping a coffee storefront before the data model was ready</title>
      <dc:creator>Alex DevOps engineer</dc:creator>
      <pubDate>Tue, 21 Jul 2026 12:21:12 +0000</pubDate>
      <link>https://dev.to/oleksandr_devops/shipping-a-coffee-storefront-before-the-data-model-was-ready-7a7</link>
      <guid>https://dev.to/oleksandr_devops/shipping-a-coffee-storefront-before-the-data-model-was-ready-7a7</guid>
      <description>&lt;p&gt;We just shipped two big surfaces for &lt;a href="https://brewly.online" rel="noopener noreferrer"&gt;&lt;strong&gt;Brewly Store&lt;/strong&gt;&lt;/a&gt;: a &lt;strong&gt;Catalog / Shop&lt;/strong&gt; page and a full &lt;strong&gt;product page (PDP)&lt;/strong&gt;. On the surface it's an ordinary e-commerce release — a filterable grid, a product detail view with size and grind pickers, a taste profile. The interesting part isn't the features. It's that we shipped all of it &lt;em&gt;before&lt;/em&gt; the backend &lt;code&gt;GET /products&lt;/code&gt; endpoint existed, and the whole release hinged on one discipline: &lt;strong&gt;never let a placeholder pretend to be a working feature.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Here's what that looks like in the actual code.&lt;/p&gt;

&lt;h2&gt;
  
  
  The project
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdrive.google.com%2Fuc%3Fid%3D1nNHKlPBhSCiUcpfC1s90DIkYRh7Eop_A%26export%3Ddownload" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdrive.google.com%2Fuc%3Fid%3D1nNHKlPBhSCiUcpfC1s90DIkYRh7Eop_A%26export%3Ddownload" alt="Brewly Store homepage — " width="1341" height="605"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://brewly.online" rel="noopener noreferrer"&gt;&lt;strong&gt;Brewly Store&lt;/strong&gt;&lt;/a&gt; is a coffee e-commerce platform running on Cloudflare's edge:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Frontend&lt;/strong&gt; — a Nuxt 4 storefront (Vue 3, Pinia, TailwindCSS) on Cloudflare Pages.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Backend&lt;/strong&gt; — a &lt;a href="https://developers.cloudflare.com/workers/" rel="noopener noreferrer"&gt;Cloudflare Workers&lt;/a&gt; API in &lt;a href="https://hono.dev/" rel="noopener noreferrer"&gt;Hono&lt;/a&gt; with &lt;code&gt;@hono/zod-openapi&lt;/code&gt;, backed by Cloudflare &lt;strong&gt;D1&lt;/strong&gt; (SQLite at the edge).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;i18n&lt;/strong&gt; — every surface is bilingual (EN / UA); the Ukrainian route is just &lt;code&gt;/uk&lt;/code&gt; in front of the path, e.g. &lt;code&gt;brewly.online/uk/catalog/coffee&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;At release time the products themselves still live in a static frontend module (&lt;code&gt;app/data/products.ts&lt;/code&gt;) — the &lt;code&gt;GET /products&lt;/code&gt; API isn't there yet. That single fact shaped every decision below.&lt;/p&gt;

&lt;h2&gt;
  
  
  What shipped
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Catalog / Shop&lt;/strong&gt; — &lt;a href="https://brewly.online/catalog/coffee" rel="noopener noreferrer"&gt;&lt;code&gt;/catalog/coffee&lt;/code&gt;&lt;/a&gt;: a "Brewly Shop" header with a live product count, sorting (Featured / Price ↑ / Price ↓), a filter sidebar (Roast level, Type, Grind size, Taste notes, Price), a 3-column grid with black "Add to Cart" buttons, and "Load more" pagination with numbered pages.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Product page (PDP)&lt;/strong&gt; — e.g. &lt;a href="https://brewly.online/product/ethiopia-yirgacheffe" rel="noopener noreferrer"&gt;&lt;code&gt;/product/ethiopia-yirgacheffe&lt;/code&gt;&lt;/a&gt;: breadcrumbs, a taste rating drawn as coffee beans (Bitterness / Sweetness / Acidity), size (250 g / 1000 g) and grind selection, a quantity stepper, a reactive price, accordions (Shipping / How to brew / Origin details), a Taste Profile block, and a "You may like" section.&lt;/p&gt;

&lt;p&gt;All of it renders, responds, and looks finished. Behind it, the data was uneven — and that's where the real work was.&lt;/p&gt;

&lt;h2&gt;
  
  
  The real story: shipping ahead of the data
&lt;/h2&gt;

&lt;p&gt;We had &lt;strong&gt;four real products&lt;/strong&gt; with confirmed data and dedicated pages — &lt;code&gt;brazil-santos&lt;/code&gt;, &lt;code&gt;colombia-supremo&lt;/code&gt;, &lt;code&gt;ethiopia-yirgacheffe&lt;/code&gt;, &lt;code&gt;kenya-nyeri&lt;/code&gt; — and a design that called for attributes and inventory the backend didn't carry yet. The temptation there is to fake it: fill the grid with fake cards, wire up filters that don't filter, and hope nobody clicks. That collapses the first time a real user clicks a control that does nothing.&lt;/p&gt;

&lt;p&gt;We took the opposite approach — ship what's real as &lt;em&gt;real&lt;/em&gt;, and mark everything else as visibly provisional. Three decisions carried the release.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Real filters vs. presentational placeholders
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdrive.google.com%2Fuc%3Fid%3D1BwxEvRc0olq0Tyl_RNz8M_pgLrvQAL5R%26export%3Ddownload" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdrive.google.com%2Fuc%3Fid%3D1BwxEvRc0olq0Tyl_RNz8M_pgLrvQAL5R%26export%3Ddownload" alt="Brewly Shop catalog — Roast level filter, 3-column grid, black Add to Cart buttons" width="1341" height="605"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Two filters are backed by actual product data and genuinely narrow the grid: &lt;strong&gt;Roast level&lt;/strong&gt; and &lt;strong&gt;Price&lt;/strong&gt;. The other three — Type, Grind size, Taste notes — are in the design, but there's no data behind them yet. So they render as UI, but the filtering logic simply doesn't consult them. That decision is one comment and one &lt;code&gt;return&lt;/code&gt; in the catalog page:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;filteredProducts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;computed&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
  &lt;span class="nx"&gt;allProducts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;product&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;roastMatch&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
      &lt;span class="nx"&gt;filters&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;roast&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;filters&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;roast&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;product&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;roastKey&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;priceMatch&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
      &lt;span class="nx"&gt;filters&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;price&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;
      &lt;span class="nx"&gt;filters&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;price&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;band&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;matchesPriceBand&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;product&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;basePrice&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;band&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

    &lt;span class="c1"&gt;// Type / Grind / Taste are visual-only until the backend exposes that data.&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;roastMatch&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;priceMatch&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;Active filters show up as removable chips with a "Clear all" reset — but only Roast and Price ever change the result set, so those are the only two that can actually strand you in an empty grid. When the backend grows a &lt;code&gt;type&lt;/code&gt; facet, it joins the &lt;code&gt;return&lt;/code&gt; line; nothing else moves.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Demo products vs. real products
&lt;/h3&gt;

&lt;p&gt;To make an early catalog feel populated without lying about inventory, the grid mixes the four real products with clearly-separated &lt;strong&gt;demo fillers&lt;/strong&gt;. Their whole reason for existing is written on the type in &lt;code&gt;data/products.ts&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="cm"&gt;/**
 * Placeholder catalog fillers used only to populate the 3-column shop grid and
 * pagination until the backend `GET /products` endpoint exists. These reuse the
 * four real product photos and carry hard-coded (non-i18n) proper names, since
 * they are demo SKUs, not real inventory. They are excluded from PDP linking.
 */&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;DemoProductEntry&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* ... */&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The two lists are merged in one composable, with the demo entries flagged so nothing downstream can mistake one for the other:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;useCatalogCards&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;realCards&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useProductCards&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;computed&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;ProductCardItem&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;demoCards&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;demoCatalog&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;entry&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="cm"&gt;/* ...entry */&lt;/span&gt; &lt;span class="na"&gt;isDemo&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="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nx"&gt;realCards&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;demoCards&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;   &lt;span class="c1"&gt;// real first, fillers after&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;And the flag is load-bearing in exactly one place that matters — the card's link. A demo card renders identically but has &lt;strong&gt;no&lt;/strong&gt; navigation overlay, so it can never route to a product page that doesn't exist:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight vue"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;NuxtLink&lt;/span&gt;
  &lt;span class="na"&gt;v-if=&lt;/span&gt;&lt;span class="s"&gt;"!product.soldOut &amp;amp;&amp;amp; !product.isDemo"&lt;/span&gt;
  &lt;span class="na"&gt;:to=&lt;/span&gt;&lt;span class="s"&gt;"productTo"&lt;/span&gt;
  &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"absolute inset-0 z-[1]"&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;This keeps the &lt;em&gt;mechanics&lt;/em&gt; — sorting, pagination, layout — testable end-to-end today, while the real catalog fills in behind them. (The same guard keeps the prerender crawler from ever hitting a non-existent PDP.)&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Optimistic "Add to Cart" before there's a cart
&lt;/h3&gt;

&lt;p&gt;There's no cart or checkout backend yet — our Pinia app store literally has no cart state in it. Rather than hide the primary call-to-action until it's fully wired, "Add to Cart" ships as an &lt;strong&gt;optimistic, visual-only&lt;/strong&gt; interaction: it flips a label to "Added to cart" for 1.6 seconds and does nothing else.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;handleAddToCart&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="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;soldOut&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;

  &lt;span class="nx"&gt;addedFlash&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="nf"&gt;clearTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;addedTimeout&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="nx"&gt;addedTimeout&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;addedFlash&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="mi"&gt;1600&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It's honest — it does exactly what it appears to do, no more — and it lets us validate the button's placement, states, and copy now. Swapping this stub for a real cart store action later touches one function, not the whole PDP.&lt;/p&gt;

&lt;p&gt;Meanwhile the parts that &lt;em&gt;are&lt;/em&gt; wired are fully real. The price genuinely reacts to the size selection, because the weight options carry a multiplier:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;productWeightOptions&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;label&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;250g&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;multiplier&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="na"&gt;label&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;1000g&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;multiplier&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;3.4&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;unitPrice&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;computed&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;option&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
    &lt;span class="nx"&gt;productWeightOptions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;o&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;label&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;selectedWeight&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nx"&gt;productWeightOptions&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="o"&gt;!&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;basePrice&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;option&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;multiplier&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;Same for the quantity stepper and the total. If a control moves a number, that number is real; if a control is decorative, it's decorative all the way down. No half-wired middle ground.&lt;/p&gt;

&lt;h2&gt;
  
  
  A small thing I'm happy with: the bean rating
&lt;/h2&gt;

&lt;p&gt;The taste scores (Bitterness / Sweetness / Acidity) aren't stars — they're little coffee beans, drawn as inline SVG with a gradient fill so they read as &lt;em&gt;beans&lt;/em&gt;, not dots, at their 22px size. Each instance mints unique gradient ids so several ratings on one page don't collide on the same &lt;code&gt;&amp;lt;defs&amp;gt;&lt;/code&gt;. It's a tiny component, but it's the kind of detail that makes a storefront feel considered rather than assembled.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we deliberately didn't ship
&lt;/h2&gt;

&lt;p&gt;Being explicit about the edges is part of the same discipline:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Mobile catalog&lt;/strong&gt; — the sidebar-to-drawer filter layout is desktop-only for now; the mobile pass is next.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Real cart / checkout&lt;/strong&gt; — the visual feedback above is the whole feature today.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Naming these keeps the release legible: the working parts are trustworthy, and the gaps are known rather than accidental.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Ship what's real as real, and make everything else visibly provisional.&lt;/strong&gt; A dead control that looks live is worse than an obvious placeholder — it teaches users the UI lies.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Put "is this backed by data?" in one place.&lt;/strong&gt; When live-vs-placeholder is a single comment on a &lt;code&gt;return&lt;/code&gt; (or a single &lt;code&gt;isDemo&lt;/code&gt; flag), turning a feature on is a one-liner instead of an archaeology project.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Demo data is fine — as scaffolding, not as fake inventory.&lt;/strong&gt; Fillers that exercise sorting and pagination are useful; fillers that route to product pages that don't exist are a trap. One flag keeps the two honest.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Optimistic UI is honest when it admits what it is.&lt;/strong&gt; "Added to cart" with no cart is fine &lt;em&gt;if&lt;/em&gt; it does exactly that and nothing more.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A full catalog and PDP, shipped on top of a data model that's still filling in — not by faking the missing half, but by drawing a clear line between what's real and what's coming.&lt;/p&gt;




&lt;h2&gt;
  
  
  About the author
&lt;/h2&gt;

&lt;p&gt;I'm &lt;strong&gt;Alex&lt;/strong&gt; — a DevOps engineer building &lt;a href="https://brewly.online" rel="noopener noreferrer"&gt;&lt;strong&gt;Brewly Store&lt;/strong&gt;&lt;/a&gt;, a coffee e-commerce platform that runs entirely on Cloudflare's edge (Workers, D1, Pages). I write about edge architecture, shipping discipline, and the debugging stories that come with production.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;🌐 Live project: &lt;strong&gt;&lt;a href="https://brewly.online" rel="noopener noreferrer"&gt;brewly.online&lt;/a&gt;&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;🎥 YouTube — building the Brewly Telegram bot on Cloudflare Workers: &lt;a href="https://www.youtube.com/watch?v=Wm5E8TSZRhw" rel="noopener noreferrer"&gt;watch the walkthrough&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;💼 LinkedIn: &lt;a href="https://www.linkedin.com/in/alex-d-732900177/" rel="noopener noreferrer"&gt;alex-d&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;🐦 X: &lt;a href="https://x.com/mainoceanm" rel="noopener noreferrer"&gt;@mainoceanm&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;✉️ &lt;a href="mailto:mainoceanm@gmail.com"&gt;mainoceanm@gmail.com&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;How do you handle shipping UI ahead of the backend? Tell me in the comments.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>nuxt</category>
      <category>vue</category>
      <category>frontend</category>
      <category>ecommerce</category>
    </item>
    <item>
      <title>DevOps Interview AI: One Idea, Three Platforms</title>
      <dc:creator>Alex DevOps engineer</dc:creator>
      <pubDate>Sat, 18 Jul 2026 20:12:33 +0000</pubDate>
      <link>https://dev.to/oleksandr_devops/devops-interview-ai-one-idea-three-platforms-2g73</link>
      <guid>https://dev.to/oleksandr_devops/devops-interview-ai-one-idea-three-platforms-2g73</guid>
      <description>&lt;p&gt;&lt;strong&gt;DevOps Interview AI&lt;/strong&gt; runs realistic mock DevOps job interviews entirely on-device — no backend, no account, no anxiety of facing a real human interviewer. The project grows in parallel across three platforms — Android, Web, and iOS/macOS — and the latest round of changes brought Android and Web to a shared visual and functional standard.&lt;/p&gt;

&lt;p&gt;Here's what changed.&lt;/p&gt;

&lt;h2&gt;
  
  
  📱 Android: voice that no longer cuts you off, and design parity with the web app
&lt;/h2&gt;

&lt;p&gt;The most painful recent bug: voice input would cut the candidate's answer off mid-sentence. The speech recognizer on some devices (a Samsung tablet, specifically) ignored the app's silence-timeout hints and decided "answer done" far too early.&lt;/p&gt;

&lt;p&gt;The fix: instead of trusting the recognizer to decide when you're done, &lt;code&gt;SpeechToTextManager&lt;/code&gt; now restarts the recognition session itself every time the device ends it prematurely, accumulating what was actually said. The answer is only sent once real silence follows actual speech. A live "Listening…" caption shows the candidate they're genuinely being heard.&lt;/p&gt;

&lt;p&gt;The second focus was design parity with the web app: a Home button and an EN/UA language toggle now sit on both the interviewer-selection screen and the call screen, switching language instantly with no app restart.&lt;/p&gt;

&lt;p&gt;Third: a new avatar for HR persona Olivia — "Lil Avatar" by lilchen, CC BY 4.0 — replacing the old rig's baked-in background.&lt;/p&gt;

&lt;p&gt;Release: &lt;strong&gt;v1.10.0&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  🌐 Web: a full 5-stage hiring pipeline instead of a single chat
&lt;/h2&gt;

&lt;p&gt;The web app, meanwhile, grew from a simple chat into a full DevOps hiring flow. The home screen now surfaces every mode up front instead of dropping you straight into a chat:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdrive.google.com%2Fuc%3Fid%3D1KMFsX67SgvutFtePuBFH9a52PLlb6QGA%26export%3Ddownload" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdrive.google.com%2Fuc%3Fid%3D1KMFsX67SgvutFtePuBFH9a52PLlb6QGA%26export%3Ddownload" alt="Choose Your Interviewer — web home screen with all modes in the top bar" width="760" height="378"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;5 sequential interview stages&lt;/strong&gt; — Recruiter Emma → Senior DevOps Marcus → CTO David → Project Manager Olivia → a final Offer, each stage unlocking only after the previous one is complete&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdrive.google.com%2Fuc%3Fid%3D1YMZ-hSKWbDgSxF3quve6CGZVRiOvAWyZ%26export%3Ddownload" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdrive.google.com%2Fuc%3Fid%3D1YMZ-hSKWbDgSxF3quve6CGZVRiOvAWyZ%26export%3Ddownload" alt="Hiring Pipeline — five stages, unlocking one at a time" width="1916" height="953"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Practice &amp;amp; self-prep&lt;/strong&gt; — a question bank per persona plus a self-test quiz&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdrive.google.com%2Fuc%3Fid%3D1P8hbwydKOZ8NFBp2HwKyV2Im1FkctT0i%26export%3Ddownload" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdrive.google.com%2Fuc%3Fid%3D1P8hbwydKOZ8NFBp2HwKyV2Im1FkctT0i%26export%3Ddownload" alt="Practice &amp;amp; Self-Prep — full question bank per persona, reshuffled per visit" width="1916" height="953"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Resume review&lt;/strong&gt; — an automated improvement checklist, entirely client-side, nothing sent to a server&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdrive.google.com%2Fuc%3Fid%3D1PNAHn0QWXMeAXfkP9A09Zm4_w19ZNio9%26export%3Ddownload" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdrive.google.com%2Fuc%3Fid%3D1PNAHn0QWXMeAXfkP9A09Zm4_w19ZNio9%26export%3Ddownload" alt="Resume Review — paste-in checklist, nothing leaves the browser" width="1916" height="953"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Job search resources&lt;/strong&gt; — a curated list of resources and tips&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The same voice-input fix as Android&lt;/strong&gt; — the recognizer no longer cuts an answer off mid-thought&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdrive.google.com%2Fuc%3Fid%3D1iG9FPCnC_TBYg-HFlvgtIGwjhqP4sbNV%26export%3Ddownload" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdrive.google.com%2Fuc%3Fid%3D1iG9FPCnC_TBYg-HFlvgtIGwjhqP4sbNV%26export%3Ddownload" alt="In-call interface — live captions and in-call messages while talking to Marcus" width="1916" height="953"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What ties both platforms together
&lt;/h2&gt;

&lt;p&gt;Both apps now share one design language (glassmorphism cards, a Home + EN/UA top bar) and the same approach to voice reliability — don't blindly trust the recognizer's idea of "end of thought"; verify real silence after real speech instead. A small detail that directly decides whether a candidate feels like the app just interrupted them mid-interview.&lt;/p&gt;




&lt;h2&gt;
  
  
  About the author
&lt;/h2&gt;

&lt;p&gt;I'm &lt;strong&gt;Alex&lt;/strong&gt; — a DevOps/engineer who builds things end-to-end and writes about the parts that don't make it into the README.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;📱 Android: &lt;strong&gt;&lt;a href="https://github.com/AI-DevOps-Interview-Avatar/devops-interview-ai" rel="noopener noreferrer"&gt;devops-interview-ai&lt;/a&gt;&lt;/strong&gt; (release v1.10.0)&lt;/li&gt;
&lt;li&gt;🌐 Web: &lt;strong&gt;&lt;a href="https://github.com/AI-DevOps-Interview-Avatar/devops-interview-web" rel="noopener noreferrer"&gt;devops-interview-web&lt;/a&gt;&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;💼 LinkedIn: &lt;a href="https://www.linkedin.com/in/alex-d-732900177/" rel="noopener noreferrer"&gt;alex-d&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;🐦 X: &lt;a href="https://x.com/mainoceanm" rel="noopener noreferrer"&gt;@mainoceanm&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;📣 Telegram — DevOps notes: &lt;a href="https://t.me/+cO9CESqrxkRjNzJi" rel="noopener noreferrer"&gt;join the channel&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;✉️ &lt;a href="mailto:mainoceanm@gmail.com"&gt;mainoceanm@gmail.com&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;Building interview prep tools of your own? I'd love to hear what platform you'd add next.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>android</category>
      <category>webdev</category>
      <category>ai</category>
      <category>kotlin</category>
    </item>
    <item>
      <title>Running an AI interviewer fully on-device with Gemma 3 1B — and engineering around what a 1B model can't do</title>
      <dc:creator>Alex DevOps engineer</dc:creator>
      <pubDate>Wed, 08 Jul 2026 20:35:35 +0000</pubDate>
      <link>https://dev.to/oleksandr_devops/running-an-ai-interviewer-fully-on-device-with-gemma-3-1b-and-engineering-around-what-a-1b-model-40ll</link>
      <guid>https://dev.to/oleksandr_devops/running-an-ai-interviewer-fully-on-device-with-gemma-3-1b-and-engineering-around-what-a-1b-model-40ll</guid>
      <description>&lt;p&gt;I built an Android app that runs a mock DevOps job interview with an AI interviewer that lives &lt;strong&gt;entirely on your phone&lt;/strong&gt;. No cloud API, no OpenAI key, no round-trip to a server. The language model — Gemma 3 1B — is downloaded once and every inference after that runs fully offline, on the subway, on a plane, with airplane mode on.&lt;/p&gt;

&lt;p&gt;The headline is "on-device LLM." But the honest story is the opposite of the hype: the model was the &lt;em&gt;easy&lt;/em&gt; part. The real work was engineering a wall of determinism &lt;strong&gt;around&lt;/strong&gt; the model — because a 1B model, running on a phone CPU, cannot be trusted to do the things you'd casually hand to GPT-4.&lt;/p&gt;

&lt;h2&gt;
  
  
  The project
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;DevOps Interview AI&lt;/strong&gt; is a native Android app that puts you in a mock interview against one of four AI personas, each a different seniority level:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Interviewer&lt;/th&gt;
&lt;th&gt;Level&lt;/th&gt;
&lt;th&gt;Focus&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Emma — Recruiter&lt;/td&gt;
&lt;td&gt;Easy&lt;/td&gt;
&lt;td&gt;Cultural fit, CV review, salary expectations&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Olivia — HR&lt;/td&gt;
&lt;td&gt;Medium&lt;/td&gt;
&lt;td&gt;Behavioural questions, team dynamics, soft skills&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Marcus — Senior DevOps&lt;/td&gt;
&lt;td&gt;Hard&lt;/td&gt;
&lt;td&gt;CI/CD, Kubernetes, IaC, observability, SRE&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;David — CTO&lt;/td&gt;
&lt;td&gt;Expert&lt;/td&gt;
&lt;td&gt;Architecture, scalability, engineering trade-offs&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;You pick an interviewer, join a Google Meet-style session (interviewer avatar tile, live subtitles, your camera in the corner), and answer five questions by voice. Speech-to-text turns your answer into text, the model asks the next question, text-to-speech speaks it back. At the end you get a scored evaluation. All of it offline.&lt;/p&gt;

&lt;p&gt;The stack:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;Technology&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Language&lt;/td&gt;
&lt;td&gt;Kotlin&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;UI&lt;/td&gt;
&lt;td&gt;Jetpack Compose + Material 3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AI engine&lt;/td&gt;
&lt;td&gt;Google MediaPipe Tasks GenAI 0.10.24&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Model&lt;/td&gt;
&lt;td&gt;Gemma 3 1B, INT4 quantized (&lt;code&gt;.task&lt;/code&gt; bundle)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Voice&lt;/td&gt;
&lt;td&gt;Android &lt;code&gt;SpeechRecognizer&lt;/code&gt; (STT) + &lt;code&gt;TextToSpeech&lt;/code&gt; (TTS)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Storage&lt;/td&gt;
&lt;td&gt;Room + DataStore&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Architecture&lt;/td&gt;
&lt;td&gt;ViewModel + StateFlow, strict package boundaries&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Why on-device at all?
&lt;/h2&gt;

&lt;p&gt;The motivation is privacy, and it's not a marketing checkbox — it's the whole point. Interview practice is where you're most exposed: you fumble answers, you second-guess your seniority, you say things you'd never put in front of a recruiter. Sending that stream to a third-party API felt wrong. On-device means the interview data physically cannot leave the phone. No telemetry, no cloud sync, nothing to leak.&lt;/p&gt;

&lt;p&gt;The side effects are nice too: zero inference cost, no rate limits, and it works with no connection at all. But privacy is the reason the whole thing runs the way it does.&lt;/p&gt;

&lt;h2&gt;
  
  
  Getting a gated model into a client app without leaking a token
&lt;/h2&gt;

&lt;p&gt;Here's the first real problem. Gemma 3 1B is distributed on Hugging Face behind a license gate (&lt;code&gt;litert-community/Gemma3-1B-IT&lt;/code&gt;). An anonymous download gets an HTTP 401 — you need an access token. And you &lt;em&gt;cannot&lt;/em&gt; ship a personal Hugging Face token inside an APK: anyone who unzips the APK can extract it and reuse it.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;.task&lt;/code&gt; bundle is also ~530 MB, so bundling it as an app asset is a non-starter (nobody ships a half-gigabyte APK).&lt;/p&gt;

&lt;p&gt;The solution splits distribution in two:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The &lt;strong&gt;source&lt;/strong&gt; app is one repository.&lt;/li&gt;
&lt;li&gt;The &lt;strong&gt;model bundle&lt;/strong&gt; is published as a &lt;strong&gt;public GitHub Release asset&lt;/strong&gt; in a &lt;em&gt;separate&lt;/em&gt; repo. GitHub Release assets are served over plain HTTPS with no credential — no token to embed, no gate to pass.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;On first launch, the app streams the model straight from that release into its private storage:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="cm"&gt;/**
 * Streams the Gemma 3 1B `.task` bundle from a GitHub Release asset straight
 * into Context.getFilesDir ...
 *
 * Hosted as a Release asset (not the original Hugging Face repo) because
 * litert-community/Gemma3-1B-IT is gated behind license acceptance — an
 * anonymous request gets HTTP 401, and embedding a personal HF access token
 * in a client APK would let anyone who unzips the APK extract and reuse it.
 *
 * Writes to a `.part` file first and renames atomically on success, so a
 * half-downloaded file is never mistaken for a ready model.
 */&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That last line matters more than it looks. The "is the model ready?" check is deliberately dumb — a single file-exists lookup:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;MODEL_FILE_NAME&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"gemma-2b-it-gpu-int4.task"&lt;/span&gt;

&lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;isModelReady&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nc"&gt;Boolean&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;
    &lt;span class="nc"&gt;File&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;filesDir&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;MODEL_FILE_NAME&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;exists&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the download wrote directly to that filename and the process got killed at 60%, &lt;code&gt;isModelReady()&lt;/code&gt; would return &lt;code&gt;true&lt;/code&gt; for a corrupt file, and the engine would crash on load forever. Downloading to &lt;code&gt;model.task.part&lt;/code&gt; and renaming atomically on completion means the real filename only ever exists as a complete file. Crash-safety from one &lt;code&gt;rename()&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;(An aside on that filename: it says &lt;code&gt;gemma-2b&lt;/code&gt;, but the model is Gemma 3 1B. MediaPipe's &lt;code&gt;LlmInferenceOptions&lt;/code&gt; doesn't care what the model is called — it loads whatever &lt;code&gt;.task&lt;/code&gt; sits at the path. So the filename is just a fixed interface contract that the downloader, the splash bootstrap check, and the engine all agree on. I left the legacy name rather than pretend it means anything.)&lt;/p&gt;

&lt;h2&gt;
  
  
  Loading the model: CPU, on purpose
&lt;/h2&gt;

&lt;p&gt;MediaPipe makes the actual load short:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;options&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;LlmInferenceOptions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setModelPath&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;modelPath&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setMaxTokens&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;LlmConfig&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;MAX_TOKENS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;        &lt;span class="c1"&gt;// 2048 — input + output combined&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setMaxTopK&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;LlmConfig&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;TOP_K&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setPreferredBackend&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;LlmInference&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Backend&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;CPU&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;build&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="n"&gt;inference&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;LlmInference&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createFromOptions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things I learned the hard way:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;MAX_TOKENS&lt;/code&gt; is the whole budget, not just output.&lt;/strong&gt; MediaPipe rejects the entire request if the &lt;em&gt;input&lt;/em&gt; alone (system prompt + conversation history) exceeds it. On a 2048-token budget, an interview's growing history is a real constraint you have to manage, not an afterthought.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;I force the CPU backend.&lt;/strong&gt; The GPU path was unstable on the devices I tested, and a 1B model runs perfectly well on CPU/XNNPACK. Chasing the GPU wasn't worth the crashes.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Streaming tokens so it doesn't feel frozen
&lt;/h2&gt;

&lt;p&gt;A 1B model on a phone CPU is not instant. If you wait for the full response, the UI looks hung. So generation streams token-by-token through a &lt;code&gt;callbackFlow&lt;/code&gt;, and the subtitles fill in live:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="k"&gt;override&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;generateResponseStream&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nc"&gt;Flow&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;callbackFlow&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;listener&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;ProgressListener&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;partial&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;done&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;partial&lt;/span&gt; &lt;span class="p"&gt;!=&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;trySend&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;partial&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;// emit each delta as it lands&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;done&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;future&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;engine&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;generateResponseAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;listener&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;future&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addListener&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
        &lt;span class="nf"&gt;runCatching&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;future&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;}.&lt;/span&gt;&lt;span class="nf"&gt;onFailure&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;it&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="nc"&gt;Runnable&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;run&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;awaitClose&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;flowOn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Dispatchers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Default&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Measuring &lt;em&gt;first-token latency&lt;/em&gt; (not total time) turned out to be the number that actually predicts whether the app feels alive.&lt;/p&gt;

&lt;h2&gt;
  
  
  The real lesson: don't trust the 1B model with anything you can compute yourself
&lt;/h2&gt;

&lt;p&gt;This is the part I want other people building on small on-device models to hear.&lt;/p&gt;

&lt;p&gt;A 1B model is not a small GPT-4. It's a text predictor that will happily forget which question number it's on, introduce itself with the wrong name, or invent a technical question that's off-topic. So the rule I converged on is: &lt;strong&gt;anything that has to be correct, compute in Kotlin. Only ask the model to do the part that genuinely needs generation.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Concretely:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The greeting is not generated.&lt;/strong&gt; The interviewer's name and title must always be right, so a plain string builds it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="c1"&gt;// A 1B model can't be trusted to introduce itself reliably, and the greeting&lt;/span&gt;
&lt;span class="c1"&gt;// is a hard product requirement.&lt;/span&gt;
&lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;buildGreeting&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;interviewer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;InterviewerProfile&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;
    &lt;span class="s"&gt;"Hello, I am ${interviewer.name}, your ${interviewer.title} interviewer today."&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The question counter lives in Kotlin, not in the model's head.&lt;/strong&gt; The &lt;code&gt;ViewModel&lt;/code&gt; owns "we're on question 3 of 5" and "all answers are in, now evaluate." Each turn injects that state as an explicit control block, because the model cannot reliably count its own turns:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="c1"&gt;// The question counter and the evaluation switch are decided in Kotlin, not by&lt;/span&gt;
&lt;span class="c1"&gt;// the model — a 1B model cannot reliably count its own turns. Each call injects&lt;/span&gt;
&lt;span class="c1"&gt;// the current state as an explicit control block so Gemma always knows exactly&lt;/span&gt;
&lt;span class="c1"&gt;// what to do next.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Technical questions come from a bank, not the model.&lt;/strong&gt; For the Hard/Expert interviewers, the exact question text is pulled from a curated &lt;code&gt;QuestionBank&lt;/code&gt; and handed to the model to present (lightly rephrased at most). This keeps generations short, on-topic, and actually about CI/CD and Kubernetes instead of whatever a 1B model free-associates into.&lt;/p&gt;

&lt;p&gt;And the prompt itself is hand-built in Gemma's turn format, priming the model with an acknowledgement turn so it stays in character:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="n"&gt;sb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"${SOT}user\n$systemPrompt$EOT\n"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;sb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"${SOT}model\nUnderstood. I am ready to conduct the interview.$EOT\n"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c1"&gt;// ...conversation history, then the control block + user's answer&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The pattern generalizes: &lt;strong&gt;the model generates prose; deterministic code owns state, identity, and control flow.&lt;/strong&gt; That division is what makes a 1B model feel like a product instead of a toy.&lt;/p&gt;

&lt;h2&gt;
  
  
  A small delightful detail: two interviewers, one voice — on purpose
&lt;/h2&gt;

&lt;p&gt;Voices are on-device TTS. Marcus (Senior DevOps) and David (CTO) deliberately share the &lt;em&gt;exact&lt;/em&gt; same male voice and pitch:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Marcus shares David's exact male voice (MALE_SECONDARY + 0.78 pitch) by&lt;/span&gt;
&lt;span class="c1"&gt;// requirement — keep these two lines identical to David's below.&lt;/span&gt;
&lt;span class="n"&gt;voicePitch&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.78f&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="n"&gt;voiceProfile&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;VoiceProfile&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;MALE_SECONDARY&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 like a bug in a diff — two profiles with identical voice config — so the comment exists to stop a future me (or a reviewer) from "fixing" it. Product requirements that look like mistakes need a paper trail in the code.&lt;/p&gt;

&lt;h2&gt;
  
  
  Architecture &amp;amp; process
&lt;/h2&gt;

&lt;p&gt;The package layout enforces clean boundaries — &lt;code&gt;features&lt;/code&gt; / &lt;code&gt;core&lt;/code&gt; (LLM, voice, download) / &lt;code&gt;domain&lt;/code&gt; (models) / &lt;code&gt;data&lt;/code&gt; (storage) — with no Android framework imports leaking into the domain models. Development follows a strict Git Flow (&lt;code&gt;main&lt;/code&gt; → &lt;code&gt;develop&lt;/code&gt; → &lt;code&gt;feature/*&lt;/code&gt;), annotated release tags, task IDs on every commit, and GitHub Actions for CI. Currently at v1.7.2.&lt;/p&gt;

&lt;h2&gt;
  
  
  License &amp;amp; privacy
&lt;/h2&gt;

&lt;p&gt;The app is licensed under the &lt;strong&gt;Business Source License 1.1&lt;/strong&gt; (© 2026 Alex Korchenko) — not MIT, and I'd rather be accurate than slap an "open source" badge on it. What's genuinely public is what a user needs: the &lt;strong&gt;APK&lt;/strong&gt; and the &lt;strong&gt;model bundle&lt;/strong&gt; are distributed openly, no login or token required. The privacy guarantee is the important promise, and it's structural, not a policy: interview data never leaves the device because there is no server for it to go to.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;On-device LLM ≠ small cloud LLM.&lt;/strong&gt; Budget your context window as input &lt;em&gt;plus&lt;/em&gt; output, pin a stable backend, and measure first-token latency, not total time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Never embed a credential in a client to reach a gated model.&lt;/strong&gt; Re-host the artifact somewhere public (a GitHub Release asset works great) and stream it in — download to &lt;code&gt;.part&lt;/code&gt;, rename atomically, and let a file-exists check be your "ready" signal.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Compute everything you can; only generate what you must.&lt;/strong&gt; Names, counters, control flow, and canned questions belong in your own code. A 1B model is a prose engine wrapped in deterministic scaffolding — the scaffolding is the product.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Comment the requirements that look like bugs.&lt;/strong&gt; Two identical voice configs, a legacy filename — a one-line comment saves the next person from "fixing" a deliberate choice.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The flashy line is "AI interviewer in your pocket, fully offline." The engineering truth is quieter: a small model plus a lot of boring, deterministic Kotlin around it.&lt;/p&gt;




&lt;h2&gt;
  
  
  About the author
&lt;/h2&gt;

&lt;p&gt;I'm &lt;strong&gt;Alex&lt;/strong&gt; — a DevOps engineer who builds things end-to-end and writes about the parts that don't make it into the README. This project came out of wanting realistic interview practice without handing my worst answers to someone else's server.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;📱 App + model (public releases): &lt;strong&gt;&lt;a href="https://github.com/AI-DevOps-Interview-Avatar/devops-interview-app" rel="noopener noreferrer"&gt;AI-DevOps-Interview-Avatar&lt;/a&gt;&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;💼 LinkedIn: &lt;a href="https://www.linkedin.com/in/alex-d-732900177/" rel="noopener noreferrer"&gt;alex-d&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;🐦 X: &lt;a href="https://x.com/mainoceanm" rel="noopener noreferrer"&gt;@mainoceanm&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;✉️ &lt;a href="mailto:mainoceanm@gmail.com"&gt;mainoceanm@gmail.com&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;Building on a small on-device model too? I'd love to compare notes on what you had to compute yourself — drop it in the comments.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>android</category>
      <category>kotlin</category>
      <category>ai</category>
      <category>machinelearning</category>
    </item>
    <item>
      <title>How a rotating refresh token turned into a 401 storm on Cloudflare Workers</title>
      <dc:creator>Alex DevOps engineer</dc:creator>
      <pubDate>Wed, 08 Jul 2026 16:25:03 +0000</pubDate>
      <link>https://dev.to/oleksandr_devops/how-a-rotating-refresh-token-turned-into-a-401-storm-on-cloudflare-workers-1bel</link>
      <guid>https://dev.to/oleksandr_devops/how-a-rotating-refresh-token-turned-into-a-401-storm-on-cloudflare-workers-1bel</guid>
      <description>&lt;p&gt;We run a Cloudflare Workers + D1 backend with rotating refresh tokens. One afternoon production started sending a steady stream of 401s on &lt;code&gt;POST /v1/auth/refresh&lt;/code&gt; — not a spike, a &lt;em&gt;storm&lt;/em&gt;: dozens of alerts, spread out, never stopping.&lt;/p&gt;

&lt;p&gt;The obvious suspect (a rotation race between two browser tabs sharing one refresh cookie) turned out to only be part of the story. The actual fix took three small, independent changes:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The frontend called refresh on &lt;em&gt;every&lt;/em&gt; page load, even with a still-valid access token — turning ordinary browsing into constant unnecessary rotation.&lt;/li&gt;
&lt;li&gt;A rejected refresh never cleared the httpOnly cookie, so a browser with a dead session kept replaying the same doomed request forever.&lt;/li&gt;
&lt;li&gt;Anonymous visitors (no session at all) were still calling refresh and getting a guaranteed 401 on every view.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;None of this needed a new backend mechanism — just removing assumptions that had quietly stopped being true.&lt;/p&gt;

&lt;h2&gt;
  
  
  The project
&lt;/h2&gt;

&lt;p&gt;This is &lt;a href="https://brewly.online" rel="noopener noreferrer"&gt;&lt;strong&gt;Brewly Store&lt;/strong&gt;&lt;/a&gt;, a coffee e-commerce platform built entirely on Cloudflare's edge:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Backend&lt;/strong&gt; — a &lt;a href="https://developers.cloudflare.com/workers/" rel="noopener noreferrer"&gt;Cloudflare Workers&lt;/a&gt; API written in &lt;a href="https://hono.dev/" rel="noopener noreferrer"&gt;Hono&lt;/a&gt; with &lt;code&gt;@hono/zod-openapi&lt;/code&gt;, backed by Cloudflare &lt;strong&gt;D1&lt;/strong&gt; (SQLite at the edge). It owns auth, users and products.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Frontend&lt;/strong&gt; — a Nuxt 4 storefront (Vue 3, Pinia, TailwindCSS) deployed to Cloudflare Pages.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bot&lt;/strong&gt; — a Telegram bot, also on Workers, sharing the same D1 database.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Auth uses a short-lived &lt;strong&gt;access token&lt;/strong&gt; (in memory on the client) plus a rotating &lt;strong&gt;refresh token&lt;/strong&gt; kept in an &lt;code&gt;httpOnly&lt;/code&gt;, cross-origin cookie. Because the cookie is &lt;code&gt;httpOnly&lt;/code&gt; and set on a different origin than the storefront, &lt;strong&gt;the browser can send it but JavaScript can never read it.&lt;/strong&gt; Remember that constraint — it's the seed of the whole storm.&lt;/p&gt;

&lt;h2&gt;
  
  
  The storm
&lt;/h2&gt;

&lt;p&gt;The symptom was a flat, unrelenting line of &lt;code&gt;401 Unauthorized&lt;/code&gt; on &lt;code&gt;POST /v1/auth/refresh&lt;/code&gt;. Not correlated with a deploy, not correlated with traffic peaks — just a constant background hum of failed refreshes, each one firing an alert.&lt;/p&gt;

&lt;p&gt;Latency and error budgets were fine for &lt;em&gt;real&lt;/em&gt; endpoints. The 401s were "successful failures": the Worker was doing exactly what it was told, rejecting refresh requests that could never succeed. The bug wasn't in the rejection — it was in &lt;em&gt;who kept asking&lt;/em&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix 1 — stop refreshing on every page load
&lt;/h2&gt;

&lt;p&gt;The Pinia auth store refreshed the session on initialization, unconditionally. Every navigation that re-hydrated the store fired a refresh, even when the access token had 40 minutes of life left. On a rotating-token scheme that's not just wasteful — every refresh &lt;strong&gt;rotates&lt;/strong&gt; the token, so two quick navigations could race each other.&lt;/p&gt;

&lt;p&gt;The fix is a threshold: only refresh when the access token is actually near expiry.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;REFRESH_THRESHOLD_MS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;needsAccessTokenRefresh&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;expiresAt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;expiresAt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nx"&gt;REFRESH_THRESHOLD_MS&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now ordinary browsing rides the existing access token and only refreshes in the last minute of its lifetime, instead of on every mount.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix 2 — clear the dead cookie when refresh is rejected
&lt;/h2&gt;

&lt;p&gt;Here's the nasty one. When the backend rejected a refresh (expired/rotated/revoked token), it returned a 401 — but left the &lt;code&gt;httpOnly&lt;/code&gt; refresh cookie in place. The browser, having no way to inspect that cookie, had no idea it was dead. So on the next page load it dutifully sent the same doomed cookie again... and again... forever. One dead session = an infinite private 401 stream.&lt;/p&gt;

&lt;p&gt;The backend now clears the cookie on the rejected path, so the browser stops resending it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;clearRefreshTokenCookie&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;../../lib/refresh-cookie&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="c1"&gt;// inside the refresh handler, on the failure branch:&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;clearRefreshTokenCookie&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;          &lt;span class="c1"&gt;// tell the browser to drop the dead cookie&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Unauthorized&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="mi"&gt;401&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;A &lt;code&gt;Set-Cookie&lt;/code&gt; with an expired/&lt;code&gt;Max-Age=0&lt;/code&gt; value turns "replay forever" into "fail once, then go quiet."&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix 3 — don't let anonymous visitors refresh at all
&lt;/h2&gt;

&lt;p&gt;Because the refresh cookie is invisible to JavaScript, the client couldn't tell a logged-out visitor apart from a logged-in one at boot time — so it optimistically tried to refresh for &lt;em&gt;everyone&lt;/em&gt;, including first-time anonymous visitors who never had a session. Each of those is a guaranteed 401.&lt;/p&gt;

&lt;p&gt;The client can't read the cookie, but it &lt;em&gt;can&lt;/em&gt; remember that a session once existed in this browser. A tiny &lt;code&gt;localStorage&lt;/code&gt; marker does exactly that:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// The refresh cookie is httpOnly on a cross-origin API, so the client can't see&lt;/span&gt;
&lt;span class="c1"&gt;// it — this marker records "a session once existed in this browser" instead, so&lt;/span&gt;
&lt;span class="c1"&gt;// anonymous visitors never fire a doomed POST /v1/auth/refresh on page load.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;HAD_SESSION_MARKER_KEY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;brewly-had-session&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;hadSessionInThisBrowser&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="k"&gt;import&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;meta&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;localStorage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;HAD_SESSION_MARKER_KEY&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;1&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The marker is set when a session is stored and removed in &lt;code&gt;clearSession()&lt;/code&gt; (right next to fix #2 on the client side). At boot, the store only attempts a refresh if &lt;code&gt;hadSessionInThisBrowser()&lt;/code&gt; is true. Anonymous visitors now make zero refresh calls.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rotation race (the suspect that wasn't the whole story)
&lt;/h2&gt;

&lt;p&gt;The original hypothesis — two tabs sharing one refresh cookie, each rotating it and invalidating the other — was real, but it was a &lt;em&gt;contributor&lt;/em&gt;, not the root cause. Once fixes 1–3 collapsed the volume of refresh calls, the race window shrank dramatically: fewer calls, fewer concurrent rotations. (We also added a short rotation grace so a just-rotated token isn't instantly rejected for an in-flight second request — but that's a smaller, separate story.)&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A 401 storm is a "who's asking" problem as often as a "why rejected" problem.&lt;/strong&gt; The rejection logic was correct the whole time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;httpOnly&lt;/code&gt; cross-origin cookies are invisible to the client — design for that.&lt;/strong&gt; If the client can't see the cookie, give it a separate signal (a marker) so it can reason about session state without guessing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Always clear a credential the server just rejected.&lt;/strong&gt; Otherwise the client will replay it until the heat death of the browser tab.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Refresh on need, not on mount.&lt;/strong&gt; Rotation schemes punish over-eager refreshing.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Three small, independent changes — no new backend architecture, no schema migration. Just deleting assumptions that had quietly stopped being true.&lt;/p&gt;




&lt;h2&gt;
  
  
  About the author
&lt;/h2&gt;

&lt;p&gt;I'm &lt;strong&gt;Alex&lt;/strong&gt; — a DevOps engineer building &lt;a href="https://brewly.online" rel="noopener noreferrer"&gt;&lt;strong&gt;Brewly Store&lt;/strong&gt;&lt;/a&gt;, a coffee e-commerce platform that runs entirely on Cloudflare's edge (Workers, D1, Pages). I write about edge architecture, auth, and the debugging stories that come with shipping to production.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;🌐 Live project: &lt;strong&gt;&lt;a href="https://brewly.online" rel="noopener noreferrer"&gt;brewly.online&lt;/a&gt;&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;🎥 YouTube — building the Brewly Telegram bot on Cloudflare Workers: &lt;a href="https://www.youtube.com/watch?v=Wm5E8TSZRhw" rel="noopener noreferrer"&gt;watch the walkthrough&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;💼 LinkedIn: &lt;a href="https://www.linkedin.com/in/alex-d-732900177/" rel="noopener noreferrer"&gt;alex-d&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;🐦 X: &lt;a href="https://x.com/mainoceanm" rel="noopener noreferrer"&gt;@mainoceanm&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;✉️ &lt;a href="mailto:mainoceanm@gmail.com"&gt;mainoceanm@gmail.com&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;Questions or war stories about edge auth? Drop them in the comments.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>cloudflare</category>
      <category>devops</category>
      <category>backend</category>
      <category>debugging</category>
    </item>
    <item>
      <title>Shipping at the Edge: Migrating a Coffee Subscription Platform to Cloudflare Workers</title>
      <dc:creator>Alex DevOps engineer</dc:creator>
      <pubDate>Fri, 22 May 2026 13:10:47 +0000</pubDate>
      <link>https://dev.to/oleksandr_devops/shipping-at-the-edge-migrating-a-coffee-subscription-platform-to-cloudflare-workers-3ip4</link>
      <guid>https://dev.to/oleksandr_devops/shipping-at-the-edge-migrating-a-coffee-subscription-platform-to-cloudflare-workers-3ip4</guid>
      <description>&lt;p&gt;&lt;strong&gt;Building a D2C platform isn't just about the product; it's about the resilience of the delivery chain. I've been working on Brewly Store, a coffee subscription service, and recently decided to tear down our traditional setup to embrace a more modern, edge-first architecture.&lt;/strong&gt;&lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Feq184jhv53ei492cxt8x.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Feq184jhv53ei492cxt8x.png" alt=" " width="800" height="400"&gt;&lt;/a&gt;&lt;br&gt;
&lt;/p&gt;
&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://brewly-frontend.pages.dev/" rel="noopener noreferrer" class="c-link fw-bold flex items-center"&gt;
          &lt;span class="mr-2"&gt;brewly-frontend.pages.dev&lt;/span&gt;
          

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


&lt;p&gt;&lt;strong&gt;The Tech Stack 🛠:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Runtime: Cloudflare Workers (using the Hono framework).&lt;/p&gt;

&lt;p&gt;Language: TypeScript.&lt;/p&gt;

&lt;p&gt;Infrastructure: AWS EKS for core services.&lt;/p&gt;

&lt;p&gt;Delivery: A full GitOps pipeline using ArgoCD and GitHub Actions.&lt;/p&gt;

&lt;p&gt;Why the Migration?&lt;br&gt;
We moved from a containerized AWS ECR setup to Cloudflare Workers. Why?&lt;/p&gt;

&lt;p&gt;Latency: Moving logic closer to the user.&lt;/p&gt;

&lt;p&gt;Scalability: Handling subscription spikes without managing scaling groups.&lt;/p&gt;

&lt;p&gt;Developer Experience: Hono provides a lightweight, expressive way to handle routing that feels incredibly fast.&lt;/p&gt;

&lt;p&gt;The Security-First Mindset 🔒:&lt;br&gt;
As a DevOps specialist, I believe infrastructure is only as good as its security. For Brewly, I’ve implemented:&lt;/p&gt;

&lt;p&gt;GPG-signed commits to ensure the integrity of our code.&lt;/p&gt;

&lt;p&gt;Strict secret management using tools like Kleopatra and PwPush.&lt;/p&gt;

&lt;p&gt;GitOps-driven deployments where ArgoCD ensures the cluster state matches our repository exactly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lessons Learned from the Trenches:&lt;/strong&gt;&lt;br&gt;
The biggest challenge wasn't the code—it was the shift in thinking from "servers" to "distributed functions." Debugging edge cases in a serverless environment requires a robust observability stack, which we are currently refining within our EKS cluster.&lt;br&gt;
&lt;/p&gt;
&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__cover"&gt;
          &lt;a href="https://www.youtube.com/@devopseng" class="c-link align-middle" rel="noopener noreferrer"&gt;
            &lt;img alt="" src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fyt3.googleusercontent.com%2FfKKUnpAd3y9pScxLrZS41AiKqwnvWlOsRm-dRhzviopMBg17y_EODownVf5D-A0tWH4nU40TRRs%3Ds900-c-k-c0x00ffffff-no-rj" height="900" class="m-0" width="900"&gt;
          &lt;/a&gt;
        &lt;/div&gt;
      &lt;div class="c-embed__body"&gt;
        &lt;h2 class="fs-xl lh-tight"&gt;
          &lt;a href="https://www.youtube.com/@devopseng" rel="noopener noreferrer" class="c-link"&gt;
            DevOps Journey UA - YouTube
          &lt;/a&gt;
        &lt;/h2&gt;
          &lt;p class="truncate-at-3"&gt;
            Welcome to the channel! I'm Alex, a DevOps Specialist. On this channel, I share hands-on experience in building modern IT infrastructure, automation, and implementing BigTech standards in real-world projects.

What to expect:
Cloud Solutions: Deep dives into AWS (EKS, ECR, Secrets Manager) and transitioning to Serverless architecture (Cloudflare Workers, D1).

Containerization: Everything about Docker, Kubernetes, and CI/CD pipeline optimization.

Security First: Practical advice on secret management, GPG-signed commits, and infrastructure security.

IT Productivity: Reviews of task managers (Jira, Linear, ClickUp) and building effective workflows for developers.

Why subscribe?
I don't just talk about theory—I show the "end-to-end" process: how to migrate a real service from AWS to Cloudflare, how to set up automated NAS backups, or how to organize a team's work in Jira. All content is based on my daily work with Linux (Ubuntu) and CLI-driven environments.

          &lt;/p&gt;
        &lt;div class="color-secondary fs-s flex items-center"&gt;
            &lt;img alt="favicon" class="c-embed__favicon m-0 mr-2 radius-0" src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fwww.youtube.com%2Fs%2Fdesktop%2F5af4fee3%2Fimg%2Ffavicon.ico" width="48" height="48"&gt;
          youtube.com
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;
&lt;/div&gt;


</description>
      <category>ai</category>
      <category>webdev</category>
      <category>programming</category>
      <category>devops</category>
    </item>
  </channel>
</rss>
