<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom" xml:lang="en">
  <title>News from the factory | MicroSaaS Toolkit</title>
  <link href="https://microsaaskit.dev/news/feed.xml" rel="self"/>
  <link href="https://microsaaskit.dev/news/"/>
  <id>https://microsaaskit.dev/news/</id>
  <updated>2026-10-23T00:00:00Z</updated>
  <author><name>Jure Hrastnik</name></author>
  <entry>
    <title>The cell, our unit of infrastructure: built by one command in 1.7 minutes</title>
    <link href="https://microsaaskit.dev/news/the-cell/"/>
    <id>https://microsaaskit.dev/news/the-cell/</id>
    <updated>2026-10-23T00:00:00Z</updated>
    <published>2026-10-23T00:00:00Z</published>
    <summary>By design, a cell is a self-contained unit, today one server, with everything a product runs on. The first one built by script ran a copy of a live product within 3.8 minutes.</summary>
    <content type="html">&lt;p&gt;&lt;img src=&quot;https://microsaaskit.dev/news/the-cell-en.png&quot; alt=&quot;Diagram: a cell, a self-contained unit, holds the edge with TLS, the products, backup, monitoring and a firewall. Nothing crosses cells at run time.&quot;&gt;&lt;/p&gt;&lt;p&gt;A factory that keeps adding products needs a unit of infrastructure that keeps its shape as it grows. Ours is the cell.&lt;/p&gt;
&lt;h2&gt;What a cell is&lt;/h2&gt;
&lt;p&gt;By design, a cell is a self-contained unit, today one server, with everything a product needs to serve its users: an edge holding the TLS certificates and names of the cell&amp;#39;s products, a nightly backup and monitoring, all behind a firewall of its own. Each product environment is placed in exactly one cell, and its name points at that cell.&lt;/p&gt;
&lt;p&gt;At run time, cells share nothing: no network, no edge, no database. Outside them stay the parts that serve no requests, such as CI and the image registries.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;/news/the-cell-figure-en.png&quot; alt=&quot;Diagram of the cell as designed: two cells, each behind its own firewall, with an edge, products, backup and monitoring. A product moves from cell A to cell B in three steps. Parts that serve no requests, such as CI and the image registries, stay outside.&quot;&gt;&lt;/p&gt;
&lt;p&gt;On 22 October the cell replaced our earlier plan, one central edge for every name of every product. With products on several servers, all would fail with the server holding that edge, and reaching the rest would need a private network and service discovery kept by hand. With cells, a failure stays in its cell, and the factory grows by adding cells, not by enlarging a server.&lt;/p&gt;
&lt;p&gt;By design, moving a product is three steps: the same image on the new cell, its data, and its name pointed at the new cell. What a product brings, images built in CI, configuration from its environment and a health check the deploy waits for, is what an orchestrator such as Kubernetes also asks for, so that choice stays open.&lt;/p&gt;
&lt;h2&gt;The first cell built by script&lt;/h2&gt;
&lt;p&gt;A unit of infrastructure is worth what the next one costs. &lt;code&gt;bin/cell.sh build&lt;/code&gt; turns a fresh Ubuntu server into a cell, each part by its own repository&amp;#39;s script: host settings, firewall, edge, monitoring and backup.&lt;/p&gt;
&lt;p&gt;On 23 October it ran on a throwaway server in another data centre: half a minute for the server, 1.7 minutes for the cell. Then we deployed a live product&amp;#39;s production release on it unchanged and restored its two databases from that night&amp;#39;s dumps. The whole run took 3.8 minutes and all 13 checks passed, from row counts against the dumps to the product&amp;#39;s site and API answering through the new edge. Its name was not pointed at the copy, on purpose, so the copy received no customer requests; then the server was deleted.&lt;/p&gt;
&lt;p&gt;The next cell starts from the same command, and the first one&amp;#39;s time is on record.&lt;/p&gt;
</content>
  </entry>
  <entry>
    <title>The site that announced our design-system kit now builds on it</title>
    <link href="https://microsaaskit.dev/news/landing-on-the-design-system/"/>
    <id>https://microsaaskit.dev/news/landing-on-the-design-system/</id>
    <updated>2026-10-21T00:00:00Z</updated>
    <published>2026-10-21T00:00:00Z</published>
    <summary>Since 21 October microsaaskit.dev pins design-system kit 1.1.0: our blue in twelve roles the kit checks, Tailwind inside the kit&#39;s cascade layers, and three checks that can stop the build.</summary>
    <content type="html">&lt;p&gt;&lt;img src=&quot;https://microsaaskit.dev/news/landing-on-the-design-system-en.png&quot; alt=&quot;Terminal: a rule appended to brand.css outside any layer stops the site&amp;#39;s build; a light blue action colour fails the kit&amp;#39;s brand test at 1.80 : 1, where at least 4.5 : 1 is needed.&quot;&gt;&lt;/p&gt;&lt;p&gt;Since 13 October Reputo and Leadgener have pinned one &lt;a href=&quot;/news/shared-design-system/&quot;&gt;design-system kit&lt;/a&gt;, and this site announced it the same day. The site itself did not use the kit: it kept its own colour values for both themes, IBM Plex Sans where the kit sets Inter, its own buttons and focus outline.&lt;/p&gt;
&lt;h2&gt;One package, two written exceptions&lt;/h2&gt;
&lt;p&gt;On 21 October 2026 the site moved onto the kit&amp;#39;s npm package, pinned at 1.1.0. In the light theme its surfaces, text and borders now take the kit&amp;#39;s roles, and its buttons, heading weights, text typeface and the focus ring on its controls are the kit&amp;#39;s. The toolkit&amp;#39;s blue lives in &lt;code&gt;brand.css&lt;/code&gt;: the twelve colour roles of the kit&amp;#39;s brand contract, checked by the kit&amp;#39;s &lt;code&gt;assertBrand&lt;/code&gt; in the site&amp;#39;s tests, as in Reputo and Leadgener. A light blue action colour fails that test at 1.80 : 1, below the minimum of 4.5 : 1.&lt;/p&gt;
&lt;p&gt;Pieces the kit has no counterpart for, such as the factory floor, stay the site&amp;#39;s own; for two of them, the decision to adopt the kit writes down why they stay out of it. The kit has no dark theme and no product needs one, so under our &lt;a href=&quot;/news/harvest-rule/&quot;&gt;harvest rule&lt;/a&gt; it does not go into the kit; 13 tests keep the site&amp;#39;s dark text and button colours at 4.5 : 1 or more. Nor does the kit have a filter chip, so the toggles that filter the factory floor and the news stay a site control on kit tokens until a product needs one.&lt;/p&gt;
&lt;h2&gt;Tailwind inside the kit&amp;#39;s layers&lt;/h2&gt;
&lt;p&gt;The kit declares seven cascade layers; the last, &lt;code&gt;product&lt;/code&gt;, is for a page&amp;#39;s own CSS. This site&amp;#39;s Tailwind emits CSS outside any layer, and such CSS beats every layer whatever its specificity: Tailwind&amp;#39;s reset would have undone the kit&amp;#39;s buttons. So the build runs Tailwind twice, putting its reset into the kit&amp;#39;s reset layer and everything else into &lt;code&gt;product&lt;/code&gt;. Three checks can then stop the build: the layers must appear in the kit&amp;#39;s order, no rule may stand outside a layer except &lt;code&gt;@font-face&lt;/code&gt;, and every local &lt;code&gt;url()&lt;/code&gt; must name a file the build wrote. We broke each on purpose; each stopped the build with its own message.&lt;/p&gt;
&lt;p&gt;The Docker image build on &lt;code&gt;main&lt;/code&gt; runs the same build, and the deploy waits for that image: the first pipeline installed the kit, built the image and finished the deploy 83 seconds after the change was merged.&lt;/p&gt;
&lt;p&gt;The kit&amp;#39;s CSS thus works here without any of its React components: two passes put Tailwind&amp;#39;s output inside the kit&amp;#39;s layers, and the build fails when a rule leaves them or their order changes.&lt;/p&gt;
</content>
  </entry>
  <entry>
    <title>A layer cache for image builds, and the canary that caught its first release</title>
    <link href="https://microsaaskit.dev/news/delivery-kit-build-cache/"/>
    <id>https://microsaaskit.dev/news/delivery-kit-build-cache/</id>
    <updated>2026-10-20T00:00:00Z</updated>
    <published>2026-10-20T00:00:00Z</published>
    <summary>Delivery kit 1.4 lets an image build reuse its layers from the container registry. The first release failed on real runners; Probe caught it in a merge request before any other product pinned it.</summary>
    <content type="html">&lt;p&gt;&lt;img src=&quot;https://microsaaskit.dev/news/delivery-kit-build-cache-en.png&quot; alt=&quot;The numbers 91 and 33: seconds for Probe&amp;#39;s API image build on main, first without a cache and then rerun with layers from the registry cache.&quot;&gt;&lt;/p&gt;&lt;p&gt;GitLab&amp;#39;s shared runners start every job with an empty Docker. An image build from the delivery kit therefore restored its NuGet packages and compiled the API from nothing on every run, including runs where no backend file had changed.&lt;/p&gt;
&lt;h2&gt;The cache lives in the registry&lt;/h2&gt;
&lt;p&gt;Delivery kit 1.4.0, released on 20 October 2026, gives the &lt;code&gt;image-build&lt;/code&gt; component an input &lt;code&gt;cache&lt;/code&gt; with two values: &lt;code&gt;none&lt;/code&gt;, the default, and &lt;code&gt;registry&lt;/code&gt;. With &lt;code&gt;registry&lt;/code&gt; the build runs with buildx on the &lt;code&gt;docker-container&lt;/code&gt; driver and reads its layers from &lt;code&gt;&amp;lt;image&amp;gt;:buildcache&lt;/code&gt; in the project&amp;#39;s container registry. Only the default branch writes them back. A merge request build reads the cache and pushes nothing, so it cannot replace the layers that &lt;code&gt;main&lt;/code&gt; relies on. A product that does not set the input sees no change.&lt;/p&gt;
&lt;p&gt;The cache asks one thing of a Dockerfile: restored packages must stay in an image layer. A &lt;code&gt;RUN --mount=type=cache&lt;/code&gt; directory is never exported to the registry, so a restore layer taken from the cache would leave the next step without its packages. Probe, the product we keep to take every kit release first, dropped its NuGet cache mounts for that reason.&lt;/p&gt;
&lt;h2&gt;Caught on a real runner&lt;/h2&gt;
&lt;p&gt;Version 1.4.0 passed its tests on a workstation and failed on Probe&amp;#39;s first build five minutes after release. The kit&amp;#39;s jobs talk to Docker over TLS, and &lt;code&gt;docker buildx create&lt;/code&gt; refuses TLS settings taken from the environment. No other product had pinned it. Version 1.4.1, released 14 minutes after 1.4.0, creates the builder on a Docker context made from the same settings.&lt;/p&gt;
&lt;p&gt;Until then the kit&amp;#39;s own pipeline checked that &lt;code&gt;image-build&lt;/code&gt; renders, but never ran its script. A new job, &lt;code&gt;delivery:image-build-test&lt;/code&gt;, runs that script on a TLS Docker-in-Docker, as in a product&amp;#39;s pipeline, against a registry started inside it. It checks that the default branch writes the cache, that a new builder reads it, that another branch reads it and pushes nothing, and that a build without a cache still works. Against the 1.4.0 template it fails 7 of its 10 checks.&lt;/p&gt;
&lt;p&gt;On Probe&amp;#39;s &lt;code&gt;main&lt;/code&gt;, the first build of the API image took 91 seconds and wrote the cache. A rerun of the same commit, the best case, took 33 seconds, with the restore and publish steps served from the registry. In Probe&amp;#39;s Dockerfile the restore layer depends only on the project and build settings files, so a commit that changes only code should still reuse it.&lt;/p&gt;
&lt;p&gt;To use it, pin 1.4.1, set &lt;code&gt;cache: registry&lt;/code&gt; on the images with a slow restore, keep the &lt;code&gt;buildcache&lt;/code&gt; tag out of the registry&amp;#39;s cleanup policy, and remove package cache mounts from the Dockerfile first.&lt;/p&gt;
</content>
  </entry>
  <entry>
    <title>Security checks a product gets from the delivery kit</title>
    <link href="https://microsaaskit.dev/news/security-checks-from-the-kit/"/>
    <id>https://microsaaskit.dev/news/security-checks-from-the-kit/</id>
    <updated>2026-10-19T00:00:00Z</updated>
    <published>2026-10-19T00:00:00Z</published>
    <summary>Delivery kit 1.1 and 1.2 add a secret scan, a NuGet vulnerability scan and a deploy check that chosen addresses stay closed. Reputo and Leadgener took all three the same day.</summary>
    <content type="html">&lt;p&gt;&lt;img src=&quot;https://microsaaskit.dev/news/security-checks-from-the-kit-en.png&quot; alt=&quot;Terminal: Reputo&amp;#39;s deploy on delivery kit 1.3.0 passes its health gate and confirms that the identity server&amp;#39;s admin console, its master realm and the Swagger page answer 404.&quot;&gt;&lt;/p&gt;&lt;p&gt;On 19 October 2026 we moved three checks from our products&amp;#39; own pipelines into the delivery kit: a secret scan, a NuGet dependency check, and a check of the addresses that must stay closed after a deploy. A product started from Reputo&amp;#39;s pipeline has all three from its first pipeline. The kit&amp;#39;s frontend gate already ran &lt;code&gt;npm audit&lt;/code&gt;.&lt;/p&gt;
&lt;h2&gt;Three checks in the kit&lt;/h2&gt;
&lt;p&gt;Delivery kit 1.1.0 adds a component, &lt;code&gt;secret-scan&lt;/code&gt;, that runs gitleaks over the commits a pipeline brings: the merge request&amp;#39;s, or a branch&amp;#39;s since its previous head. It needs no other job, so it never waits for the build or holds it back. The &lt;code&gt;dotnet-test&lt;/code&gt; component now runs &lt;code&gt;dotnet list package --vulnerable --include-transitive&lt;/code&gt; right after its restore and fails on a high or critical advisory. It reuses that restore, so the scan adds seconds, not a second restore. Turning it off takes an input, &lt;code&gt;vulnerability_scan: false&lt;/code&gt;, which the kit&amp;#39;s documentation allows only with a dated reason in the product&amp;#39;s pipeline.&lt;/p&gt;
&lt;p&gt;Version 1.2.0, released the same day, gives the &lt;code&gt;deploy&lt;/code&gt; component an input &lt;code&gt;closed_urls&lt;/code&gt;: addresses that must answer 401, 403 or 404 once the deploy is done. A 2xx or 3xx answer fails the check at once, without retries.&lt;/p&gt;
&lt;h2&gt;Adopted on release day&lt;/h2&gt;
&lt;p&gt;Reputo and Leadgener moved to 1.1.0 that afternoon. Both pipelines ran the secret scan and the NuGet scan, and neither scan found anything.&lt;/p&gt;
&lt;p&gt;That evening, on delivery kit 1.3.0, both products set &lt;code&gt;closed_urls&lt;/code&gt; for three addresses: the identity server&amp;#39;s admin console, its master realm and the Swagger page. Every deploy now proves that they stay closed: Reputo&amp;#39;s dev deploy at 17:43 UTC and Leadgener&amp;#39;s at 17:47 UTC logged all three as closed (404).&lt;/p&gt;
&lt;p&gt;The check lives in the kit and the rules do not, by design: what closes an address differs by product (a gateway rule, the API&amp;#39;s own settings), while whether it is closed is the same question everywhere. A product started from Reputo&amp;#39;s pipeline inherits the three addresses, so its first deploy fails if anything that closes them is missing.&lt;/p&gt;
</content>
  </entry>
  <entry>
    <title>Toolkit 1.0.3 reached all three live products within an hour of release</title>
    <link href="https://microsaaskit.dev/news/toolkit-1-0-3-rollout/"/>
    <id>https://microsaaskit.dev/news/toolkit-1-0-3-rollout/</id>
    <updated>2026-10-19T00:00:00Z</updated>
    <published>2026-10-19T00:00:00Z</published>
    <summary>One script builds every product against a toolkit release before it ships, another opens the upgrade merge requests. Version 1.0.3 ran in all three live products 54 minutes after publishing.</summary>
    <content type="html">&lt;p&gt;&lt;img src=&quot;https://microsaaskit.dev/news/toolkit-1-0-3-rollout-en.png&quot; alt=&quot;The number 54 with the caption: minutes from publishing toolkit 1.0.3 to the last of three production deploys.&quot;&gt;&lt;/p&gt;&lt;p&gt;Every product pins the toolkit in one line, and a release does nothing until a product changes the version in that line. Until 19 October 2026 that was manual: a release meant editing the pin in each product and finding out from each product&amp;#39;s pipeline whether it still built.&lt;/p&gt;
&lt;h2&gt;Two scripts, before and after the release&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;factory/check_consumers.py&lt;/code&gt; takes every active product in the factory registry that pins the toolkit and builds it against a toolkit checkout instead of the published packages, before anything is released. &lt;code&gt;factory/propose_upgrade.py&lt;/code&gt; then opens the upgrade merge request in every product that pins an older version. It changes the pin and nothing else, and each merge request&amp;#39;s description carries the changelog sections of every version newer than the product&amp;#39;s pin, so whoever merges sees what the product must act on.&lt;/p&gt;
&lt;p&gt;Toolkit 1.0.3 removed the one place where a package referenced one in a higher layer: &lt;code&gt;Tenancy.Runtime&lt;/code&gt; no longer depends on &lt;code&gt;Organizations.Api&lt;/code&gt;. It also pins the Stripe library to an exact version. Before release, &lt;code&gt;check_consumers.py&lt;/code&gt; built all five products against the change.&lt;/p&gt;
&lt;h2&gt;The timeline on 19 October&lt;/h2&gt;
&lt;p&gt;The 19 packages of 1.0.3 were published at 16:00 UTC. The first real run of &lt;code&gt;propose_upgrade.py&lt;/code&gt; pushed the branches to Gostly, MojRazpis and SnapMind but opened no merge requests: it took the source project from the directory it ran in. The fix creates them through the API by project name and lets a rerun finish what a failed run started; the rerun opened the three at 16:04. Reputo and Leadgener took the release in merge requests of their own, together with the delivery kit&amp;#39;s new security scans. All five were merged by 16:09.&lt;/p&gt;
&lt;p&gt;Production deploys are still a button the owner presses. MojRazpis and SnapMind ran 1.0.3 in production from 16:39, and Gostly, whose changes go to its development branch first, from 16:54. From publishing to the last production deploy took 54 minutes, and the factory registry records the release with the date it reached every live product.&lt;/p&gt;
&lt;p&gt;Nothing made the five move together: each merge request ran its own pipeline, and a product that leaves its merge request open stays on its old pin. Version 1.0.3 was not a security release. A security fix will take the same path: built against every product first, then proposed to each of them.&lt;/p&gt;
</content>
  </entry>
  <entry>
    <title>The delivery kit: one pipeline and deploy instead of two hand-kept copies</title>
    <link href="https://microsaaskit.dev/news/delivery-kit/"/>
    <id>https://microsaaskit.dev/news/delivery-kit/</id>
    <updated>2026-10-15T00:00:00Z</updated>
    <published>2026-10-15T00:00:00Z</published>
    <summary>Reputo&#39;s and Leadgener&#39;s pipelines and deploys were copies that had drifted apart. Delivery kit 1.0.0 replaces both with six CI/CD components and an ops image; both moved to it on release day.</summary>
    <content type="html">&lt;p&gt;&lt;img src=&quot;https://microsaaskit.dev/news/delivery-kit-en.png&quot; alt=&quot;Terminal: Leadgener&amp;#39;s first restore test on delivery kit 1.0.0 checks the backup&amp;#39;s checksums, restores it into a throwaway container, finds 25 tables with equal row counts and 3,391 files with matching SHA-256, and ends OK.&quot;&gt;&lt;/p&gt;&lt;p&gt;Reputo and Leadgener, our two products in development, were built, tested and deployed with copies of one pipeline and one set of deploy scripts, kept by hand. By 15 October the copies had drifted. The two deploy scripts (116 and 111 lines) differed in 47 lines, and the two scripts that start the stack on the server (64 and 82 lines) in 66. Part of that is product names and comments; part is behaviour: the two products took their backups in two different ways. A fix in one copy did not reach the other, and &lt;a href=&quot;/news/leadgener-first-days/&quot;&gt;Leadgener&amp;#39;s first pipeline&lt;/a&gt; had already lost a build flag in the copying.&lt;/p&gt;
&lt;h2&gt;Six components and an ops image&lt;/h2&gt;
&lt;p&gt;On 15 October 2026 we released delivery kit 1.0.0. It does for build and deploy what the NuGet packages did for the backend and the Keycloak kit for identity: six GitLab CI/CD components (backend tests, a frontend gate, sharded frontend tests, image builds, the deploy and a restore test) and an ops image that runs the deploy and the restore test. A product includes every component at &lt;code&gt;@delivery-v1.0.0&lt;/code&gt;. There is no &lt;code&gt;latest&lt;/code&gt;, and the changelog flags each change by what it touches: &lt;code&gt;[contract]&lt;/code&gt;, &lt;code&gt;[behaviour]&lt;/code&gt; or &lt;code&gt;[pipeline]&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Version 1.0 is a faithful port of the two pipelines; the design lists each change in behaviour with its reason. The backup follows Leadgener&amp;#39;s design: a dump with checksums and the row count of every table in the application database. One thing changes: the kit takes those counts inside the dump&amp;#39;s snapshot. A product keeps its inputs, its compose files and hooks for what only it needs, such as provisioning its Keycloak realm.&lt;/p&gt;
&lt;h2&gt;Proven on both products the same day&lt;/h2&gt;
&lt;p&gt;Before the release, Reputo adopted the kit at a pinned toolkit commit. A report compared its merged pipeline before and after, job by job, and the merge request explains every difference. Test totals were unchanged: 2,298 backend and 2,591 frontend tests. On &lt;code&gt;main&lt;/code&gt;, the kit&amp;#39;s deploy backed up both databases, provisioned Keycloak through Reputo&amp;#39;s own hook and passed the health gate, and the restore test restored that backup into a throwaway container: 33 tables, every row count equal.&lt;/p&gt;
&lt;p&gt;Reputo then pinned 1.0.0, and Leadgener moved the same evening. Its first restore test on the kit checked 25 tables and, through its own hook, that the 3,391 files its database points to are present with matching checksums. Both products deleted their deploy scripts, and Reputo&amp;#39;s &lt;code&gt;.gitlab-ci.yml&lt;/code&gt; went from 290 lines to 172.&lt;/p&gt;
&lt;p&gt;A change to the shared jobs, the deploy, the backup or the restore test is now made once, in the kit; what only one product needs stays in that product&amp;#39;s inputs and hooks.&lt;/p&gt;
</content>
  </entry>
  <entry>
    <title>Design-system kit 1.1.0: a new look, released once, pinned twice</title>
    <link href="https://microsaaskit.dev/news/design-system-1-1-refresh/"/>
    <id>https://microsaaskit.dev/news/design-system-1-1-refresh/</id>
    <updated>2026-10-14T00:00:00Z</updated>
    <published>2026-10-14T00:00:00Z</published>
    <summary>Design-system kit 1.1.0 is a new surface: 33 kit stylesheets changed, no markup. In their app code, Reputo changed three of its own stylesheets; Leadgener its stylesheets and one table.</summary>
    <content type="html">&lt;p&gt;&lt;img src=&quot;https://microsaaskit.dev/news/design-system-1-1-refresh-en.png&quot; alt=&quot;Terminal: from design-system 1.0.1 to 1.1.0 the kit&amp;#39;s CSS changes in 33 files; in the frontend src, Reputo&amp;#39;s merge request changes 3 files and Leadgener&amp;#39;s 7.&quot;&gt;&lt;/p&gt;&lt;p&gt;The design-system kit 1.0 kept the look our products already had: Arial, square corners, 2 px borders on buttons and inputs, uppercase labels on tags and KPI tiles, a dark footer. We wanted them to look like current SaaS products. In two copies of the design system that would have been the same restyle twice; since the &lt;a href=&quot;/news/shared-design-system/&quot;&gt;shared kit&lt;/a&gt; of 13 October, it is one release.&lt;/p&gt;
&lt;h2&gt;A new surface on the same components&lt;/h2&gt;
&lt;p&gt;On 14 October 2026 we released design-system kit 1.1.0. Inter replaces Arial and ships inside the package, never from a font CDN. Corners get small radii: 3 px for chips, 4 for controls, 6 for cards. Borders on buttons and inputs drop to 1 px, cards get a soft shadow, and the footer is a light panel.&lt;/p&gt;
&lt;p&gt;Markup and page structure stay as they were, and no component prop was removed or renamed. The look is core: a product still sets only its twelve colour roles, because roles for type, radius or depth would let the products drift apart again. The kit&amp;#39;s guarantees did not change: the focus ring, 44 px touch targets, contrast, tones marked by shape as well as colour.&lt;/p&gt;
&lt;p&gt;Between 1.0.1 and 1.1.0 the kit&amp;#39;s stylesheets changed in 33 files, 466 lines added and 146 removed, and every one of its 384 baseline captures changed.&lt;/p&gt;
&lt;h2&gt;What the products changed&lt;/h2&gt;
&lt;p&gt;Reputo and Leadgener each pin the kit in one line of their frontend&amp;#39;s &lt;code&gt;package.json&lt;/code&gt;; the frontend also builds their login pages. Reputo moved that pin from 1.0.0 to 1.1.0 an hour after the kit&amp;#39;s merge request merged, so it took the 1.0.1 patch as well. In its app code, the frontend&amp;#39;s &lt;code&gt;src&lt;/code&gt;, three files changed: its check, incident and DNS-record cards now read the kit&amp;#39;s new card radius and elevation tokens, 20 lines added and 7 removed.&lt;/p&gt;
&lt;p&gt;Leadgener pinned 1.1.0 two hours later. In its app code, its own stylesheets now read the kit&amp;#39;s font-weight tokens instead of a literal 700. One screen needed a code change: its opportunities table has nine columns, so it takes the new &lt;code&gt;stackBelow=&amp;quot;wide&amp;quot;&lt;/code&gt; option: below 1160 px of its own width, it shows each row as a list of labelled fields, with the date and the time as separate parts.&lt;/p&gt;
&lt;p&gt;On the toolkit, a new look is a minor version: you pin it, run the checks its changelog lists for the release, and re-check your screens; a table too wide for its box takes one prop.&lt;/p&gt;
</content>
  </entry>
  <entry>
    <title>The factory registry: what each product pins, checked against its repository</title>
    <link href="https://microsaaskit.dev/news/factory-registry/"/>
    <id>https://microsaaskit.dev/news/factory-registry/</id>
    <updated>2026-10-13T00:00:00Z</updated>
    <published>2026-10-13T00:00:00Z</published>
    <summary>One file declares which kit versions each product pins and which commit runs in production. A script compares it with the repositories and GitLab, and exits with code 1 when they disagree.</summary>
    <content type="html">&lt;p&gt;&lt;img src=&quot;https://microsaaskit.dev/news/factory-registry-en.png&quot; alt=&quot;Terminal: shortened output of the registry check, which reports DRIFT for the Keycloak kit pin in Reputo and Leadgener and exits with code 1.&quot;&gt;&lt;/p&gt;&lt;p&gt;A product pins our kits in its own repository: the toolkit version in &lt;code&gt;Directory.Build.props&lt;/code&gt;, the Keycloak kit in a Dockerfile, the design-system kit in &lt;code&gt;frontend/package.json&lt;/code&gt;. Until 11 October nothing recorded which product used which version, or whether a shared change had reached production.&lt;/p&gt;
&lt;h2&gt;One declaration, checked&lt;/h2&gt;
&lt;p&gt;On 11 October 2026 we added &lt;code&gt;factory/products.toml&lt;/code&gt;, the factory&amp;#39;s product registry. For each product it declares the stage, the owner, the integration and production branches, the versions under &lt;code&gt;kits.nuget&lt;/code&gt;, &lt;code&gt;kits.keycloak&lt;/code&gt; and &lt;code&gt;kits.design_system&lt;/code&gt;, and, for a product in production, the deploy job and the commit that production runs.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;factory/check_registry.py&lt;/code&gt; compares that declaration with each product&amp;#39;s repository at &lt;code&gt;origin/&amp;lt;branch&amp;gt;&lt;/code&gt;, never the working tree, so unpushed work does not count. &lt;code&gt;--fetch&lt;/code&gt; fetches every checkout first; &lt;code&gt;--gitlab&lt;/code&gt; asks GitLab for the commit of the newest successful deploy job. Each finding is one line with a verdict. &lt;code&gt;DRIFT&lt;/code&gt; means the registry and the repository disagree, and either may be wrong. &lt;code&gt;FAIL&lt;/code&gt; marks an operational risk, such as a vendored toolkit, a target framework within 90 days of its end of support, or a design-system pin other than the registry&amp;#39;s. Any &lt;code&gt;FAIL&lt;/code&gt; or &lt;code&gt;DRIFT&lt;/code&gt; makes the script exit with code 1.&lt;/p&gt;
&lt;h2&gt;What it found on 13 October&lt;/h2&gt;
&lt;p&gt;The registry&amp;#39;s header asks that each change to it go in the same merge request as the change that makes it true. A kit pin cannot follow that rule: the pin changes in the product&amp;#39;s repository, the registry in ours. On 12 October the registry recorded Keycloak kit 1.0.1 for Reputo and Leadgener. That evening both moved to 2.0.0. Two merge requests changed the registry the next day; neither touched those lines.&lt;/p&gt;
&lt;p&gt;A run on 13 October reported the drift among its other findings:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;DRIFT  reputo       main: Keycloak kit pins [&amp;#39;2.0.0&amp;#39;], registry says 1.0.1
DRIFT  leadgener    main: Keycloak kit pins [&amp;#39;2.0.0&amp;#39;], registry says 1.0.1
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The design-system pins, added to the registry a few hours earlier, matched in both products. The fix is one line per product in &lt;code&gt;products.toml&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;For a team building on the toolkit, the registry checks a rule we set for our own upgrades: an upgrade is done when production runs it, not when its merge request merges. With &lt;code&gt;--gitlab&lt;/code&gt;, for a product in production, the check reads the toolkit version from the commit GitLab last deployed and warns when it is not the version the registry declares. The registry records, for each product, the kit versions its integration branch pins, and one run of the check shows where that record and the repository disagree.&lt;/p&gt;
</content>
  </entry>
  <entry>
    <title>The Keycloak kit: one pinned image, a theme per product</title>
    <link href="https://microsaaskit.dev/news/keycloak-kit/"/>
    <id>https://microsaaskit.dev/news/keycloak-kit/</id>
    <updated>2026-10-13T00:00:00Z</updated>
    <published>2026-10-13T00:00:00Z</published>
    <summary>Our Keycloak kit is one image pinned by digest. It carries what every realm must enforce (token claims and acceptance of the terms of use) and Keycloak&#39;s login messages in Slovenian.</summary>
    <content type="html">&lt;p&gt;&lt;img src=&quot;https://microsaaskit.dev/news/keycloak-kit-en.png&quot; alt=&quot;Diagram: the Keycloak kit feeds Reputo and Leadgener; each adds its own login theme on top of the same pinned image.&quot;&gt;&lt;/p&gt;&lt;p&gt;Each product signs users in through its own Keycloak, and the toolkit&amp;#39;s API packages trust what that Keycloak writes into a token. Reputo and Leadgener used to build from &lt;code&gt;quay.io/keycloak/keycloak:26.0&lt;/code&gt;, a tag that moves with every patch, so two builds of one commit could run different Keycloak versions.&lt;/p&gt;
&lt;h2&gt;What the kit holds, and what it leaves out&lt;/h2&gt;
&lt;p&gt;On 11 October 2026 we decided that the Keycloak kit would be one base image carrying only what must be the same in every product&amp;#39;s Keycloak: Keycloak pinned by digest, &lt;code&gt;jq&lt;/code&gt; pinned by checksum, 32 of Keycloak&amp;#39;s login messages in Slovenian, and two commands that run inside the container through &lt;code&gt;docker exec&lt;/code&gt;.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;setup&lt;/code&gt; provisions what the API reads from a token: the audience, realm roles flattened into a &lt;code&gt;roles&lt;/code&gt; claim that ASP.NET reads, and a declared &lt;code&gt;organization_id&lt;/code&gt; attribute, which Keycloak 26 would otherwise drop without an error.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;require-terms&lt;/code&gt; makes accepting the terms of use a required action for every user, so Keycloak issues no token until the user accepts them. Only the time of acceptance is stored.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The kit holds no product defaults: realm name, client IDs and origins live in a wrapper in the product, and &lt;code&gt;setup&lt;/code&gt; stops before any change, naming each one that is missing. It holds no login theme either, because a theme carries its product&amp;#39;s brand on top of the shared design system. The product&amp;#39;s Dockerfile builds the theme JAR in an earlier stage, then:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dockerfile&quot;&gt;FROM &amp;lt;kit image&amp;gt;:&amp;lt;X.Y.Z&amp;gt;@sha256:&amp;lt;digest&amp;gt;
COPY --from=theme /app/keycloak-theme/dist_keycloak/reputo-keycloak-theme.jar /opt/keycloak/providers/
RUN /opt/keycloak/bin/kc.sh build --db=postgres --health-enabled=true
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Tested, pinned, upgraded by choice&lt;/h2&gt;
&lt;p&gt;Every merge request that changes the kit runs six test suites against the built image. One checks each Slovenian message against the Keycloak inside the image: a key it knows, the same placeholders.&lt;/p&gt;
&lt;p&gt;There is no &lt;code&gt;latest&lt;/code&gt; tag, and a published version is never overwritten. On 13 October Reputo and Leadgener were pinned to the same version and digest. The changelog flags what a version asks of a product: &lt;code&gt;[keycloak]&lt;/code&gt; (back up the database first), &lt;code&gt;[contract]&lt;/code&gt; (a command, variable or path changed) or &lt;code&gt;[behaviour]&lt;/code&gt; (the realm comes out different). Any Keycloak version change is a major kit version, because Keycloak migrates its database one way. By 13 October the kit had five versions; how the kit was harvested is told in the &lt;a href=&quot;/news/harvest-rule/&quot;&gt;post on the harvest rule&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;For a team building on the toolkit, the identity rules arrive with the image: tokens carry what the API checks, and no token is issued before the terms are accepted. Your login pages and your realm&amp;#39;s values stay yours.&lt;/p&gt;
</content>
  </entry>
  <entry>
    <title>One design system, a brand layer per product</title>
    <link href="https://microsaaskit.dev/news/shared-design-system/"/>
    <id>https://microsaaskit.dev/news/shared-design-system/</id>
    <updated>2026-10-13T00:00:00Z</updated>
    <published>2026-10-13T00:00:00Z</published>
    <summary>Two copies of our design system diverged within days. Since 13 October Reputo and Leadgener pin one design-system kit and keep only their brand and their own components.</summary>
    <content type="html">&lt;p&gt;&lt;img src=&quot;https://microsaaskit.dev/news/shared-design-system-en.png&quot; alt=&quot;Diagram: the design-system kit feeds Reputo and Leadgener; each adds only its brand layer, and the port was compared with both products pixel for pixel.&quot;&gt;&lt;/p&gt;&lt;p&gt;Our design system started in Reputo: CSS tokens, React components, lint rules that keep screens from setting their own styles, and a catalogue. On 8 October Leadgener copied its 209 files and changed the palette. Four days later, 26 files present in both copies differed and 14 existed in only one. Some differences were Leadgener&amp;#39;s own components; one was a fix that stayed where it was made, a narrower tab padding that fits three tabs on one row of a 375 px wide phone screen. Reputo&amp;#39;s tabs still wrapped.&lt;/p&gt;
&lt;h2&gt;The core is shared, the brand is not&lt;/h2&gt;
&lt;p&gt;Under our &lt;a href=&quot;/news/harvest-rule/&quot;&gt;harvest rule&lt;/a&gt;, look and feel were copied on purpose. On 12 October we made the design system the exception. Its core sets the quality of the interface and must not diverge: the focus ring, 44 px touch targets, contrast, tones marked by shape as well as colour, and the type and spacing scales. It became the design-system kit, an npm package released like our other kits, with a dated changelog section and a protected tag.&lt;/p&gt;
&lt;p&gt;A product keeps a brand layer: &lt;code&gt;brand.css&lt;/code&gt; with exactly twelve colour roles, its mark, and its own components under its own prefix. Its tests call &lt;code&gt;assertBrand&lt;/code&gt;, which fails when a role is missing, when the file sets any other kit property, when a role falls below its WCAG 2.1 contrast minimum, or when an action colour could pass for a success, warning or danger state. The app shell, the login theme and the landing page stay copies, but in Reputo and Leadgener each imports the kit&amp;#39;s stylesheet, then &lt;code&gt;brand.css&lt;/code&gt;.&lt;/p&gt;
&lt;h2&gt;Compared, then released&lt;/h2&gt;
&lt;p&gt;Kit 1.0.0 is a faithful port of Leadgener&amp;#39;s copy. We captured every catalogue entry at 375, 768 and 1366 px and compared the kit with both products pixel for pixel. Apart from entries listed as different by construction, the only rendering change is the tab fix, which now reaches Reputo. The kit&amp;#39;s 384 captures became its baseline for every merge request that changes it.&lt;/p&gt;
&lt;p&gt;Before it publishes, the release job requires a rehearsed candidate of the same code to verify in the registry, and runs the suite again on the merge commit: 615 tests for 1.0.0. Reputo and Leadgener adopted the candidate, compared their catalogue and all nine login theme pages with what they rendered before, and pinned 1.0.0 the same day.&lt;/p&gt;
&lt;p&gt;If you build on the toolkit, your product&amp;#39;s colours are one file the kit checks: a palette that fails contrast fails your brand test before it reaches a screen. A fix like the tab padding is made once, in the kit, not in each copy.&lt;/p&gt;
</content>
  </entry>
  <entry>
    <title>Keycloak 26.0 → 26.8: what bit us, and where the kit keeps it</title>
    <link href="https://microsaaskit.dev/news/keycloak-26-upgrade/"/>
    <id>https://microsaaskit.dev/news/keycloak-26-upgrade/</id>
    <updated>2026-10-12T00:00:00Z</updated>
    <published>2026-10-12T00:00:00Z</published>
    <summary>In one release, the Keycloak kit moved eight Keycloak minor versions ahead. What surprised us went into a test or the changelog; Reputo and Leadgener only changed a pin.</summary>
    <content type="html">&lt;p&gt;&lt;img src=&quot;https://microsaaskit.dev/news/keycloak-26-upgrade-en.png&quot; alt=&quot;Terminal: the Dockerfile&amp;#39;s FROM moves from Keycloak 26.0.8 to 26.8.0, the messages suite fails because 26.8 ships a second themes JAR, and passes once it reads exactly one.&quot;&gt;&lt;/p&gt;&lt;p&gt;Keycloak kit 1.x was pinned to 26.0.8, eight minor versions behind 26.8, and received none of their fixes. Had each product upgraded on its own, each would have met the same surprises in its own pipeline.&lt;/p&gt;
&lt;h2&gt;One upgrade, in the kit&lt;/h2&gt;
&lt;p&gt;On 12 October 2026 we released Keycloak kit 2.0.0 on Keycloak 26.8.0. In the kit&amp;#39;s Dockerfile that is two lines: the &lt;code&gt;FROM&lt;/code&gt; with its digest, and a &lt;code&gt;LABEL&lt;/code&gt; repeating the version, which the structure suite now checks against that &lt;code&gt;FROM&lt;/code&gt;. What bit us went into a test or the changelog.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;A test that read nothing.&lt;/strong&gt; Keycloak 26.8 ships &lt;code&gt;keycloak-themes-vendor-*.jar&lt;/code&gt; beside the themes JAR. Our messages suite looked for &lt;code&gt;keycloak-themes-*.jar&lt;/code&gt;, matched both and copied neither, so all 32 Slovenian keys looked unknown. The translation was fine; the test was not. It now accepts exactly one JAR, and fails on its own when it reads fewer than 100 English keys.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;New realm defaults.&lt;/strong&gt; Compared with 1.x, the realm the kit provisions differs only in Keycloak&amp;#39;s own defaults for a new realm, as the diff of the golden realm exports shows. Among them: three new required actions, WebAuthn passwordless requiring a resident key and user verification, no &lt;code&gt;X-XSS-Protection&lt;/code&gt; header, and a &lt;code&gt;realm-management&lt;/code&gt; client that is read-only in the admin API. The provisioning did not change, and accepting the terms of use is still the default required action. An existing realm is migrated by Keycloak, not re-created, so its values can differ.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Login themes.&lt;/strong&gt; Since 26.7, templates run with FreeMarker 2.3.32 defaults, and since 26.5, no message renders HTML except &lt;code&gt;termsText&lt;/code&gt;, which kit 2.1.0 can set to link the terms of use and the privacy policy. Before the release we built Reputo&amp;#39;s theme on the new image: the login and terms pages rendered without template errors.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The changelog entry lists the rest: PostgreSQL 14 or later, single-use required-action links, the refusal of redirect URIs that carry &lt;code&gt;state&lt;/code&gt; or &lt;code&gt;code&lt;/code&gt;, and the upgrade steps from kit 1.x.&lt;/p&gt;
&lt;p&gt;Reputo and Leadgener moved the same day, each in a merge request that changes only the kit pin and its comment, and both dev environments were deployed on 26.8.0 that evening.&lt;/p&gt;
&lt;p&gt;For a team on the Keycloak kit, a Keycloak upgrade arrives as a kit version that has already passed these tests, its changes written in the changelog. Your part is a backup of the Keycloak database before the first start of the new version, the pin, a build of your login theme on the new image if you have one, and one look at the login, registration and terms pages on dev.&lt;/p&gt;
</content>
  </entry>
  <entry>
    <title>We shipped Keycloak kit 1.0.0 broken</title>
    <link href="https://microsaaskit.dev/news/keycloak-kit-1-0-0/"/>
    <id>https://microsaaskit.dev/news/keycloak-kit-1-0-0/</id>
    <updated>2026-10-12T00:00:00Z</updated>
    <published>2026-10-12T00:00:00Z</published>
    <summary>Every test passed on an image we never published; the one we published could not run its own provisioning. Since 1.0.1 the tests use the release builder and run again on the pushed image.</summary>
    <content type="html">&lt;p&gt;&lt;img src=&quot;https://microsaaskit.dev/news/keycloak-kit-1-0-0-en.png&quot; alt=&quot;Terminal: the 1.0.0 tests on a docker build image end with FAILS=0, Reputo&amp;#39;s dev deploy stops with Permission denied, and 1.0.1 passes the mode check after the push.&quot;&gt;&lt;/p&gt;&lt;p&gt;On 11 October 2026 the Keycloak kit&amp;#39;s CI ran all its test suites against the 1.0.0 commit and reported &lt;code&gt;FAILS=0&lt;/code&gt;. The release job then pushed the image. Fourteen minutes later, Reputo&amp;#39;s dev deploy stopped at provisioning:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;bash: /opt/microsaas/keycloak/setup: Permission denied
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;What was broken, and why the tests passed&lt;/h2&gt;
&lt;p&gt;The directory &lt;code&gt;/opt/microsaas/keycloak&lt;/code&gt; had mode 644. Without the execute bit, Keycloak&amp;#39;s user, uid 1000, could not enter it, so neither &lt;code&gt;setup&lt;/code&gt; nor &lt;code&gt;require-terms&lt;/code&gt; could run. The Dockerfile never created that directory itself: a &lt;code&gt;COPY --chmod=644&lt;/code&gt; of the Slovenian messages file created it as a parent, and the mode of such a parent is up to the BuildKit that runs the build.&lt;/p&gt;
&lt;p&gt;Two BuildKits built 1.0.0. The test job ran &lt;code&gt;docker build&lt;/code&gt;, with Docker&amp;#39;s built-in BuildKit, and got 755. The release job ran buildx with the &lt;code&gt;moby/buildkit&lt;/code&gt; image it pulls by default, unpinned and at that time v0.33.1, and got 644. One commit, one Dockerfile, two images. Every suite passed on the image we did not ship, and nothing tested the one we did.&lt;/p&gt;
&lt;p&gt;Reputo&amp;#39;s dev environment kept serving, because its realm lives in the database. Only Reputo and Leadgener had pinned 1.0.0, and neither runs in production yet.&lt;/p&gt;
&lt;h2&gt;Test the artifact you ship&lt;/h2&gt;
&lt;p&gt;The rule we adopted: test the artifact you ship, built by the builder that ships it. The main fix was merged that evening, and 1.0.1 was published the next morning. What changed in CI:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;One BuildKit, pinned by version and digest in &lt;code&gt;release/builder.sh&lt;/code&gt;, builds both the image the tests run on and the image that is pushed. The Dockerfile frontend is pinned by digest too.&lt;/li&gt;
&lt;li&gt;The Dockerfile creates every kit directory with mode 755 before any file is copied in, so the modes no longer depend on the builder.&lt;/li&gt;
&lt;li&gt;The structure suite checks, as uid 1000, that every directory can be entered and every file read or run.&lt;/li&gt;
&lt;li&gt;After the push, the release job pulls what the registry serves and tests that. Since 1.0.2 it compares every path under &lt;code&gt;/opt/microsaas&lt;/code&gt;, with its mode and owner, in both the amd64 and arm64 images against a fixed list, and creates the version&amp;#39;s tag only after both images pass.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;For a team building on the toolkit, every Keycloak kit version since 1.0.2 was tested on an image from the builder that pushed it, then checked again as the image you pull, on both platforms. You take its digest from its release notes, and a version that fails the check after its push gets neither notes nor tag: its changelog section is marked defective, and the fix ships as the next patch.&lt;/p&gt;
</content>
  </entry>
  <entry>
    <title>A landing page with no third parties before consent</title>
    <link href="https://microsaaskit.dev/news/landing-no-third-parties/"/>
    <id>https://microsaaskit.dev/news/landing-no-third-parties/</id>
    <updated>2026-10-12T00:00:00Z</updated>
    <published>2026-10-12T00:00:00Z</published>
    <summary>Until a visitor allows analytics, microsaaskit.dev loads nothing from other hosts, fonts and icons included. Crawlers without JavaScript get llms.txt; hreflang links the two language versions.</summary>
    <content type="html">&lt;p&gt;&lt;img src=&quot;https://microsaaskit.dev/news/landing-no-third-parties-en.png&quot; alt=&quot;The number 0 with the caption: third-party requests before consent, recorded in headless Chrome with a fresh profile; the previous version&amp;#39;s home page made at least 11.&quot;&gt;&lt;/p&gt;&lt;p&gt;Until 12 October, before a visitor read a word, microsaaskit.dev fetched IBM Plex from Google Fonts, Font Awesome from a CDN, Tailwind as a script that compiled the styles in the browser, and Google Analytics. We rebuilt the last version before the change and loaded its home page in headless Chrome: on a first visit it made 11 requests to five other hosts without asking, not counting the Google Analytics request, which our run blocked.&lt;/p&gt;
&lt;h2&gt;What we changed&lt;/h2&gt;
&lt;p&gt;Google Analytics now loads only after the visitor chooses “Allow analytics”. “Decline” sits next to it in the same size and style, and “Cookie settings” in the footer reopens the banner. The fonts come from the site: two IBM Plex families in the latin and latin-ext subsets, 14 files the build copies from Fontsource. Lucide outline icons and Simple Icons brand marks replaced Font Awesome, inlined as one SVG sprite per page; the build fails if a page uses an icon the sprite lacks. Tailwind is compiled at build time.&lt;/p&gt;
&lt;p&gt;The same day, the page became two static pages, English at &lt;code&gt;/&lt;/code&gt; and Slovenian at &lt;code&gt;/sl/&lt;/code&gt;, linked with &lt;code&gt;hreflang&lt;/code&gt;, with &lt;code&gt;/&lt;/code&gt; as the &lt;code&gt;x-default&lt;/code&gt;; &lt;code&gt;sitemap.xml&lt;/code&gt; lists both alternates of every page. The factory floor is drawn by JavaScript; without it, its text is only a JSON block. &lt;code&gt;llms.txt&lt;/code&gt; gives a crawler that does not run JavaScript the same facts in Markdown, generated from the same &lt;code&gt;en.json&lt;/code&gt; and &lt;code&gt;factory-data.js&lt;/code&gt; as the page.&lt;/p&gt;
&lt;h2&gt;The request list&lt;/h2&gt;
&lt;p&gt;The source is not the proof; the browser&amp;#39;s request list is. We loaded &lt;code&gt;/&lt;/code&gt;, &lt;code&gt;/sl/&lt;/code&gt; and both privacy notices on the live site, and the news pages from a local build, in headless Chrome with a fresh profile and no consent, and recorded every request through the DevTools protocol. Every request went to the page&amp;#39;s own origin, 7 to 16 per page. The Slovenian pages also fetched the latin-ext font files, for č, š and ž.&lt;/p&gt;
&lt;p&gt;On the local build we then clicked “Allow analytics”. Two hosts appeared: one serves the Google tag script, the other the collection endpoint, with one request each per page. After “Decline” under “Cookie settings”, the &lt;code&gt;_ga&lt;/code&gt; cookies were gone and the reloaded page again requested only its own origin.&lt;/p&gt;
&lt;p&gt;For a team building on the toolkit: a consent banner is a claim, and the request list is its test. Before a product&amp;#39;s landing page goes live, load it with a fresh browser profile and no consent, and read the hosts. If the page draws its content with JavaScript, generate a plain-text version from the same source, as &lt;code&gt;llms.txt&lt;/code&gt; is here.&lt;/p&gt;
</content>
  </entry>
  <entry>
    <title>The harvest rule: when code moves into a kit</title>
    <link href="https://microsaaskit.dev/news/harvest-rule/"/>
    <id>https://microsaaskit.dev/news/harvest-rule/</id>
    <updated>2026-10-11T00:00:00Z</updated>
    <published>2026-10-11T00:00:00Z</published>
    <summary>Code moves into a kit only when a second product needs it and it must not diverge. Products pin a kit version instead of editing kit code. The Keycloak kit shows how.</summary>
    <content type="html">&lt;p&gt;&lt;img src=&quot;https://microsaaskit.dev/news/harvest-rule-en.png&quot; alt=&quot;Diagram: Reputo and Leadgener hand their copies to one Keycloak kit; the port is faithful, and realm exports prove it equal.&quot;&gt;&lt;/p&gt;&lt;p&gt;A product builds what it needs in its own repository first. The question is when a piece becomes a kit. Too early, and we design for consumers that do not exist. Too late, and the copies drift: on 10 October one fix to the Keycloak setup reached Reputo and Leadgener as two merge requests, one per copy.&lt;/p&gt;
&lt;h2&gt;Two conditions, both required&lt;/h2&gt;
&lt;p&gt;We move code into a kit only when a second product needs the same behaviour and that behaviour must not diverge: security, legal obligations, operations or an API contract. If only the first holds, the second product copies the piece and owns its copy; the app shell, the login theme and the landing page stay copies for that reason. A kit needs at least two consumers within six months, or it goes back into its product.&lt;/p&gt;
&lt;p&gt;A product, in turn, does not edit kit code: it pins a version, and a kit change is made in the kit and released. For the NuGet packages, all five products carry the same check in their CI file:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sh&quot;&gt;test ! -e libs/microsaas-toolkit || { echo &amp;quot;libs/microsaas-toolkit is back; change the toolkit in its own repository&amp;quot;; exit 1; }
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;The Keycloak kit, harvested&lt;/h2&gt;
&lt;p&gt;Reputo and Leadgener each carried a copy of the realm provisioning script, 347 and 348 lines long respectively. It sets what every realm must share, such as self-registration off and PKCE with S256 for browser clients. Leadgener&amp;#39;s copy appeared on 8 October; three days later, beyond each product&amp;#39;s defaults, the two differed in comments and one role description.&lt;/p&gt;
&lt;p&gt;On 11 October 2026 we moved it into the Keycloak kit as a faithful port: the same values in, the same realm out, no cleanup in the same step. We ran each product&amp;#39;s own scripts, then the kit, on a fresh Keycloak and compared the normalized realm exports. They were identical byte for byte, except for that role description, which a divergence file records with its reason.&lt;/p&gt;
&lt;p&gt;Each product then replaced its copy in its own merge request. The provisioning script became a 46-line wrapper in Reputo and a 47-line one in Leadgener, holding only the product&amp;#39;s defaults, and each product pins the kit&amp;#39;s image by version and digest.&lt;/p&gt;
&lt;p&gt;The Keycloak kit is therefore code that already ran in two products, ported so that it behaves as it did there, proved by tests a reviewer reads as a diff. When a kit does not do what you need, you do not patch it: you ask for an option, which arrives in a minor release with the old behaviour as the default, and you pin it when you choose. What no other product needs stays yours.&lt;/p&gt;
</content>
  </entry>
  <entry>
    <title>Leadgener from an empty repository: 45 hours to a dev environment</title>
    <link href="https://microsaaskit.dev/news/leadgener-first-days/"/>
    <id>https://microsaaskit.dev/news/leadgener-first-days/</id>
    <updated>2026-10-10T00:00:00Z</updated>
    <published>2026-10-10T00:00:00Z</published>
    <summary>Leadgener started on 8 October 2026 from our reference product. Its first deploy passed its health checks 45 hours later; the work in between went mostly into its own domain.</summary>
    <content type="html">&lt;p&gt;&lt;img src=&quot;https://microsaaskit.dev/news/leadgener-first-days-en.png&quot; alt=&quot;The number 45 with the caption: hours from Leadgener&amp;#39;s first commit to its first deployed dev environment; the toolkit and the backend skeleton were in place 12 minutes in.&quot;&gt;&lt;/p&gt;&lt;p&gt;Leadgener finds and tracks business opportunities in public sources. It started from an empty repository on 8 October 2026; it is still being built and runs only in a dev environment. A new product cannot be judged until it runs deployed, behind its own sign-in.&lt;/p&gt;
&lt;h2&gt;Copy the reference, change the palette&lt;/h2&gt;
&lt;p&gt;Leadgener&amp;#39;s ADR-02, accepted on the first day, makes Reputo, our &lt;a href=&quot;/news/reputo-on-the-toolkit/&quot;&gt;reference product&lt;/a&gt;, the source of truth for technology, patterns and components. Only the colour palette changes; any other deviation needs its own ADR.&lt;/p&gt;
&lt;p&gt;The first commit held only product documents. Ten seconds later came the toolkit, vendored unchanged: 167 files, 11,414 lines. Twelve minutes in, the backend skeleton followed on Reputo&amp;#39;s patterns, with tenancy, onboarding and organizations and without billing.&lt;/p&gt;
&lt;p&gt;The next six hours went into Leadgener&amp;#39;s domain: capturing notices from public sources, documents and a keyword dictionary, an evidence-based assessment with a selectable model provider, and screens to review the results. At hour 23 came the deploy, ported from Reputo: CI file, Dockerfiles, Compose file and deploy scripts.&lt;/p&gt;
&lt;h2&gt;Where the 45 hours went&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;Hour 30: the GitLab project was created; by choice, the repository stayed local until then.&lt;/li&gt;
&lt;li&gt;Hour 31: on main the tests passed, then &lt;code&gt;dotnet restore&lt;/code&gt; in the image build hung: &lt;code&gt;NU1301&lt;/code&gt; after ten minutes, still retrying half an hour later. Reputo had hit the same hang on 2 October and fixed it with &lt;code&gt;docker build --network host&lt;/code&gt;: the Docker-in-Docker bridge has a larger MTU than the runner&amp;#39;s network. The port had dropped the flag. One merge request restored it, and the images were built nine minutes after it merged.&lt;/li&gt;
&lt;li&gt;Hour 45: &lt;code&gt;deploy:dev&lt;/code&gt; is a manual job, and it waited 13.5 hours overnight. Then, in 1 minute 50 seconds, it started seven containers, created the Keycloak realm and passed a health gate of six checks: API, app, realm, login theme and two landing pages.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;For a team starting a product on the toolkit, Leadgener&amp;#39;s record is about two days to a deployed dev environment, and the work in them went mostly into the product. Later that day, Leadgener replaced its vendored toolkit with the NuGet packages, pinned to one version. The CI file and deploy scripts remained ported, and Leadgener&amp;#39;s first pipeline shows the cost: the reference&amp;#39;s CI file carried the flag with a comment explaining it, and the port dropped both. Diff your pipeline against the reference&amp;#39;s before your first push to main.&lt;/p&gt;
</content>
  </entry>
  <entry>
    <title>19 packages, one version</title>
    <link href="https://microsaaskit.dev/news/toolkit-one-version/"/>
    <id>https://microsaaskit.dev/news/toolkit-one-version/</id>
    <updated>2026-10-10T00:00:00Z</updated>
    <published>2026-10-10T00:00:00Z</published>
    <summary>We replaced five vendored copies of our SaaS modules with 19 NuGet packages under one version. Each product pins it in one line and upgrades when it chooses.</summary>
    <content type="html">&lt;p&gt;&lt;img src=&quot;https://microsaaskit.dev/news/toolkit-one-version-en.png&quot; alt=&quot;The number 19 with the caption: NuGet packages, one version, no lockstep upgrades.&quot;&gt;&lt;/p&gt;&lt;p&gt;Before 1.0.0, each of our five products carried its own copy of the shared SaaS modules: tenancy, onboarding, organizations, entitlements, usage, billing and admin. The copies lived inside each repository, and the oldest was about eight months behind the newest. When we wrote the upgrade notes for the first release, Gostly needed one list, MojRazpis and SnapMind a longer one. A fix made in one copy reached the others only if someone carried it over by hand.&lt;/p&gt;
&lt;h2&gt;One version for every package&lt;/h2&gt;
&lt;p&gt;On 10 October 2026 we tagged &lt;code&gt;v1.0.0&lt;/code&gt; and published the shared code as 19 &lt;code&gt;MicroSaas.Toolkit.*&lt;/code&gt; NuGet packages with a single version. One &lt;code&gt;Directory.Build.props&lt;/code&gt; holds the build settings, and the tag pipeline passes &lt;code&gt;-p:Version=X.Y.Z&lt;/code&gt; from the tag to every project, so the packages also depend on each other at that version. The publish job stops if &lt;code&gt;CHANGELOG.md&lt;/code&gt; has no section for the version, or still marks it unreleased, and it counts what it packed:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sh&quot;&gt;[ &amp;quot;$count&amp;quot; -eq 19 ] || { echo &amp;quot;expected 19 packages, packed $count&amp;quot;; exit 1; }
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The package registry is set to refuse a version it already holds, so 1.0.0 is the same code for every product.&lt;/p&gt;
&lt;p&gt;In a product, the version is one property in its own &lt;code&gt;Directory.Build.props&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-xml&quot;&gt;&amp;lt;MicroSaasToolkitVersion&amp;gt;1.0.0&amp;lt;/MicroSaasToolkitVersion&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Every toolkit &lt;code&gt;PackageReference&lt;/code&gt; reads that property. Products reference different subsets: Leadgener 6 of the 19 packages; Reputo 16; Gostly, MojRazpis and SnapMind 17 each.&lt;/p&gt;
&lt;h2&gt;What it costs, and what it does not mean&lt;/h2&gt;
&lt;p&gt;The cost is written into our design: a product cannot take an Organizations fix without the Billing changes of the same release. With one maintainer, one version line is cheaper than nineteen, and the changelog makes the cost visible in advance. It flags each entry that needs action as &lt;code&gt;[migration]&lt;/code&gt;, &lt;code&gt;[contract]&lt;/code&gt; or &lt;code&gt;[behaviour]&lt;/code&gt;, and lists, for each starting point, what a product has to change before it upgrades.&lt;/p&gt;
&lt;p&gt;One version does not mean lockstep. A release never requires two products to upgrade together. Each product moved to 1.0.0 through its own merge request in its own repository. All five merged on the same day because all five were leaving their vendored copies, not because the release demanded it. The next release is a one-line change in whichever product wants it, made when that product chooses.&lt;/p&gt;
&lt;p&gt;For a team building on the toolkit, that means one number to pin, the changelog sections between your version and the new one to read, and an upgrade on your schedule, not ours. Two products on different toolkit versions are both valid, because they share code, never a running instance.&lt;/p&gt;
</content>
  </entry>
  <entry>
    <title>Reputo on the toolkit: what came on day one, and what we built</title>
    <link href="https://microsaaskit.dev/news/reputo-on-the-toolkit/"/>
    <id>https://microsaaskit.dev/news/reputo-on-the-toolkit/</id>
    <updated>2026-10-02T00:00:00Z</updated>
    <published>2026-10-02T00:00:00Z</published>
    <summary>Reputo tells a small business whether its email is arriving and what to ask IT to fix. On its first morning, 34 of its 42 API routes came from the toolkit; the next 12 days went into the product.</summary>
    <content type="html">&lt;p&gt;&lt;img src=&quot;https://microsaaskit.dev/news/reputo-on-the-toolkit-en.png&quot; alt=&quot;The number 34 with the caption: of 42 API routes on day one came from the toolkit; 12 days from the first commit to the first dev deploy.&quot;&gt;&lt;/p&gt;&lt;p&gt;Reputo is for the marketing lead of a small business that sends its own campaigns. It says, in plain language, whether the company&amp;#39;s email is arriving, what is wrong, and what to ask IT to change. It runs in a dev environment and is not in production yet.&lt;/p&gt;
&lt;p&gt;Behind those answers sits a multi-tenant SaaS: organizations, members, plans, usage limits, an admin interface for the operator. A buyer pays for none of it, and all of it has to work before the first customer signs in.&lt;/p&gt;
&lt;h2&gt;Day one: the toolkit&lt;/h2&gt;
&lt;p&gt;Reputo&amp;#39;s first commit, on 20 September 2026, held a domain-check core, rebuilt from earlier work, and a vendored copy of our toolkit. The same morning, the API ran against a real PostgreSQL database and listed 42 routes. Thirty-four came from the toolkit: onboarding, organizations, entitlements, usage, billing and admin. Of the 12 tables in the first migration, 11 were the toolkit&amp;#39;s.&lt;/p&gt;
&lt;p&gt;Reputo&amp;#39;s &lt;code&gt;AppDbContext&lt;/code&gt; derives from the toolkit&amp;#39;s &lt;code&gt;OrganizationDbContextBase&lt;/code&gt;, so every entity that belongs to an organization gets a tenant filter, and Reputo&amp;#39;s queries over monitored domains rely on it. On the SaaS side, Reputo&amp;#39;s own code that day was the wiring in &lt;code&gt;Program.cs&lt;/code&gt; and its plans: Free, Pro and Business with their quotas, and a hook that gives each new organization the Free plan.&lt;/p&gt;
&lt;h2&gt;Twelve days: the product&lt;/h2&gt;
&lt;p&gt;The next twelve days went into Reputo&amp;#39;s own code around that core: DMARC aggregate reports collected from a mailbox, per receiving provider; incidents with alert mail in Slovenian; integrations with Google Postmaster and Microsoft SNDS; and the screens. On 2 October main was first deployed to a dev environment. Keycloak did not start on the first attempt; the second, under half an hour later, passed the checks of the API, the app and the Keycloak realm.&lt;/p&gt;
&lt;p&gt;For a team starting a product on the toolkit, the measure is where the first weeks go. In Reputo, tenancy, members, entitlements, usage limits and the admin endpoints were wired into the API on the first morning, and the twelve days to a dev environment went into the checks, reports and alerts its buyer would pay for.&lt;/p&gt;
</content>
  </entry>
</feed>
