<?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: Duvan Jamid Vargas</title>
    <description>The latest articles on DEV Community by Duvan Jamid Vargas (@duvanjamid).</description>
    <link>https://dev.to/duvanjamid</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%2F916223%2F018eae79-7e7c-4cd4-bfd1-4f88f8ce3cbc.jpeg</url>
      <title>DEV Community: Duvan Jamid Vargas</title>
      <link>https://dev.to/duvanjamid</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/duvanjamid"/>
    <language>en</language>
    <item>
      <title>Two Phones, One Dongle, One Car: Building a Phone Switcher for Android Auto (and Knowing When to Stop Reverse Engineering)</title>
      <dc:creator>Duvan Jamid Vargas</dc:creator>
      <pubDate>Mon, 26 Oct 2026 02:30:12 +0000</pubDate>
      <link>https://dev.to/duvanjamid/two-phones-one-dongle-one-car-building-a-phone-switcher-for-android-auto-and-knowing-when-to-55hi</link>
      <guid>https://dev.to/duvanjamid/two-phones-one-dongle-one-car-building-a-phone-switcher-for-android-auto-and-knowing-when-to-55hi</guid>
      <description>&lt;p&gt;It started with a small question: &lt;em&gt;what can we improve on our wireless Android Auto dongle?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Two people, one car, one &lt;strong&gt;Carlinkit CPC200-2Air&lt;/strong&gt; dongle (5.0 / U2AC, AutoKit firmware), and every time we swapped phones it was a manual, slightly miserable dance: turn Bluetooth off on one phone, wait, hope the other one connects first. A lot of poking later we had a full Android app with &lt;strong&gt;its own screen inside Android Auto&lt;/strong&gt;, a BLE presence protocol between phones, a live dongle monitor, a CI pipeline that ships signed releases, and a pile of research notes about what the dongle does inside, including the exact point where we decided to stop.&lt;/p&gt;

&lt;p&gt;This is the whole story, dead ends included. The code is MIT-licensed and public: &lt;strong&gt;&lt;a href="https://github.com/muvatec/switch-phone-aa" rel="noopener noreferrer"&gt;muvatec/switch-phone-aa&lt;/a&gt;&lt;/strong&gt;.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Disclaimer.&lt;/strong&gt; This is an independent project, not affiliated with Carlinkit, HeWei, Google or Android Auto. It uses the web API the dongle itself exposes for its own settings page, and it contains no manufacturer binaries or code. Use it at your own risk: some actions restart the dongle and may require re-pairing.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  1. The problem
&lt;/h2&gt;

&lt;p&gt;A wireless Android Auto dongle stores several paired phones, but it only keeps &lt;strong&gt;one&lt;/strong&gt; linked at a time. When two people share the car, "who gets the car now?" has no first-class answer:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The dongle has a list of saved phones, but no "connect to phone X" command.&lt;/li&gt;
&lt;li&gt;It doesn't tell you which phone is currently connected (more on that below).&lt;/li&gt;
&lt;li&gt;The phone that was connected before tends to reconnect on its own, because its Android Auto is in automatic mode.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So the goals grew as we learned:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Understand how the firmware works and what could be improved.&lt;/li&gt;
&lt;li&gt;See how the dongle behaves &lt;em&gt;without sitting in the car&lt;/em&gt;.&lt;/li&gt;
&lt;li&gt;Support &lt;strong&gt;several phones&lt;/strong&gt; and &lt;strong&gt;switch between them&lt;/strong&gt; easily, ideally from the car screen.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  2. First dead end: speaking the "Carlinkit protocol"
&lt;/h2&gt;

&lt;p&gt;Plugged into a Mac, the dongle enumerates as &lt;code&gt;12D1:0001&lt;/code&gt; named &lt;code&gt;AutoKit&lt;/code&gt; (later &lt;code&gt;VISION&lt;/code&gt; after a firmware update), with a vendor-specific interface, bulk endpoints and one interrupt endpoint.&lt;/p&gt;

&lt;p&gt;The community has built several open-source clients for these dongles (&lt;code&gt;pi-carplay&lt;/code&gt;, &lt;code&gt;FastCarPlay&lt;/code&gt;, &lt;code&gt;node-CarPlay&lt;/code&gt;) that speak a proprietary protocol with a &lt;code&gt;0x55AA55AA&lt;/code&gt; header, a length, a type and an inverted type. We patched &lt;code&gt;pi-carplay&lt;/code&gt; to accept this VID:PID and sent it the usual messages. The dongle answered &lt;strong&gt;every message with the same 12 bytes&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight armasm"&gt;&lt;code&gt;&lt;span class="nl"&gt;0c000000&lt;/span&gt; &lt;span class="err"&gt;03000520&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nv"&gt;n&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It never sent video, never reported a phase, and reset USB after two to four seconds of silence. We tried the optional USB encryption some newer firmwares use (header &lt;code&gt;0x55BB55BB&lt;/code&gt;, AES-128-CFB). No change.&lt;/p&gt;

&lt;p&gt;For a while we assumed this firmware spoke some newer, undocumented variant. We were wrong in an instructive way: &lt;strong&gt;those 12 bytes were MTP&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Later, reading the dongle's own plain-text boot scripts, we found &lt;code&gt;start_mtp.sh&lt;/code&gt;: in its idle mode (no car attached) the dongle presents itself as an &lt;strong&gt;MTP device&lt;/strong&gt; (&lt;code&gt;mtp-server&lt;/code&gt;). Decoding the reply as an MTP container:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Field&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;length&lt;/td&gt;
&lt;td&gt;&lt;code&gt;0x0000000c&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;12 bytes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;container type&lt;/td&gt;
&lt;td&gt;&lt;code&gt;0x0003&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;em&gt;Response&lt;/em&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;response code&lt;/td&gt;
&lt;td&gt;&lt;code&gt;0x2005&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Operation Not Supported&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;last field&lt;/td&gt;
&lt;td&gt;&lt;code&gt;&amp;lt;n&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;the &lt;em&gt;transaction id&lt;/em&gt;, copied from whatever bytes we sent&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The dongle wasn't speaking a secret protocol. It was politely telling us, twelve bytes at a time, that it didn't support what we were asking, because we were talking to the wrong USB personality. A good reminder to identify the &lt;em&gt;mode&lt;/em&gt; of a device before reverse engineering its protocol.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. The first big hit: the dongle is an Android accessory
&lt;/h2&gt;

&lt;p&gt;Sending the standard USB control request &lt;code&gt;51&lt;/code&gt; (&lt;code&gt;GET_PROTOCOL&lt;/code&gt;) from Android Open Accessory (AOA) returned &lt;strong&gt;&lt;code&gt;2&lt;/code&gt;&lt;/strong&gt;. The dongle presents itself to the car as an &lt;em&gt;Android phone in accessory mode&lt;/em&gt;:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;It enumerates as &lt;code&gt;12D1:0001&lt;/code&gt; in its idle MTP mode.&lt;/li&gt;
&lt;li&gt;When a "head unit" performs the AOA handshake, scripts kill &lt;code&gt;adbd&lt;/code&gt; and &lt;code&gt;mtp-server&lt;/code&gt; and the device switches to &lt;strong&gt;&lt;code&gt;18D1:2D00&lt;/code&gt;&lt;/strong&gt; (accessory).&lt;/li&gt;
&lt;li&gt;It then speaks &lt;strong&gt;Android Auto 1.7&lt;/strong&gt; over TLS 1.2 (&lt;code&gt;ECDHE-RSA-AES128-GCM-SHA256&lt;/code&gt;).&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That means Google's official &lt;strong&gt;Desktop Head Unit (DHU)&lt;/strong&gt;, the tool for testing Android Auto apps without a car, can act as the car, &lt;strong&gt;talking directly to the dongle over USB&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;DONGLE_SERIAL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&amp;lt;usb serial&amp;gt; tools/run-dhu.sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two gotchas we paid for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Always pass the dongle's serial.&lt;/strong&gt; Without &lt;code&gt;--usb=&amp;lt;serial&amp;gt;&lt;/code&gt; the DHU grabs every compatible USB device, including your phone on a cable, and flips it into accessory mode (which also kills USB tethering).&lt;/li&gt;
&lt;li&gt;For the dongle to stream anything, a phone must already be linked to it over Bluetooth &lt;strong&gt;and&lt;/strong&gt; WiFi, exactly like in a real car.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;From that moment we could develop and test the whole thing on a desk.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. The second big hit: the dongle's web API
&lt;/h2&gt;

&lt;p&gt;The dongle creates a WiFi network (&lt;code&gt;AutoKit_xx&lt;/code&gt;) and serves a configuration page on its own address using a small embedded web server (Boa) with a Vue front end. Chrome on Android often forces HTTPS and refuses to load it, which is one reason a native app is useful.&lt;/p&gt;

&lt;p&gt;We downloaded the page's JavaScript and read how it talks to the device. Everything goes through &lt;strong&gt;one CGI endpoint&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;POST /cgi-bin/server.cgi
Content-Type: application/x-www-form-urlencoded

cmd=&amp;lt;command&amp;gt;&amp;amp;[item=&amp;lt;key&amp;gt;&amp;amp;val=&amp;lt;value&amp;gt;&amp;amp;]ts=&amp;lt;epoch ms&amp;gt;&amp;amp;sign=&amp;lt;md5&amp;gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The signature is an MD5 over the sorted fields plus a constant that the page's own JavaScript sends to every client, so it is an integrity check on the form, not a secret. (A first &lt;code&gt;400 Bad Request&lt;/code&gt; turned out to be a missing &lt;code&gt;/cgi-bin/&lt;/code&gt; prefix.) The commands we use:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;code&gt;cmd&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;What it does&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;infos&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Everything: &lt;code&gt;BoxInfo&lt;/code&gt;, &lt;code&gt;Settings&lt;/code&gt;, &lt;code&gt;DevList&lt;/code&gt; (saved phones), &lt;code&gt;WifiChannelList&lt;/code&gt;, &lt;code&gt;ModuleInfo&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;set&lt;/code&gt; (&lt;code&gt;item&lt;/code&gt;, &lt;code&gt;val&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;Change a setting&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;restart&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Reboot the dongle (~20 s)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;BoxMonitor&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;CPU rate/temperature/frequency, memory rate, WiFi RX/TX in Mbps&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;carInfo&lt;/code&gt;, &lt;code&gt;syncTime&lt;/code&gt;, &lt;code&gt;logFile&lt;/code&gt; ...&lt;/td&gt;
&lt;td&gt;Car metadata, time sync, logs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;reset&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Factory reset (we don't touch it lightly)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;From &lt;code&gt;infos&lt;/code&gt; we could finally see the real firmware version, read every setting, and change a couple (&lt;code&gt;fastConnect&lt;/code&gt;, &lt;code&gt;mediaDelay&lt;/code&gt;). We also discovered what the API &lt;em&gt;doesn't&lt;/em&gt; give us, which shaped the entire design.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. What the dongle knows, and what it doesn't
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;DevList&lt;/code&gt; stores paired phones, but &lt;strong&gt;there is no "connect to X" command&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;There is no reliable "who is active" field. &lt;code&gt;MDLinkType&lt;/code&gt; says &lt;code&gt;AndroidAuto&lt;/code&gt; when linked, but &lt;code&gt;MDModel&lt;/code&gt; comes back &lt;strong&gt;empty&lt;/strong&gt;, and the &lt;code&gt;index&lt;/code&gt; field in &lt;code&gt;DevList&lt;/code&gt; lies (we first used it and marked the wrong phone as active).&lt;/li&gt;
&lt;li&gt;A saved phone may have &lt;strong&gt;no name&lt;/strong&gt;; you learn it when the phone connects, or rename it by hand.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So how do you switch? &lt;strong&gt;Restart the dongle&lt;/strong&gt;. When it comes back, whichever saved phone is actively looking connects first. With both people nearby this worked with no tricks, in about 30 seconds: ~5 s to shut down, ~21 s to boot, ~5 s for the new phone to connect.&lt;/p&gt;

&lt;p&gt;The remaining problem: &lt;strong&gt;who is where?&lt;/strong&gt; The dongle can't say. So the phones have to.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Presence over Bluetooth Low Energy
&lt;/h2&gt;

&lt;p&gt;Every phone running the app &lt;strong&gt;advertises its own state over BLE&lt;/strong&gt; and listens for the others. There is no GATT connection; everything rides in a non-connectable advertisement:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;service data (9 bytes) = hash(4) + flags(1) + target(4)

hash   = first 4 bytes of MD5(lowercase Bluetooth name)
flags  = bit0 projecting Android Auto
         bit1 requesting control
         bit2 rejected
         bit3 accepted
target = hash of the phone this message is addressed to (00000000 = nobody)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The 4-byte hash is matched against the names the dongle has saved, which yields three states per phone:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;State&lt;/th&gt;
&lt;th&gt;How it's decided&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;ACTIVE&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;The phone itself knows (&lt;code&gt;CarConnection&lt;/code&gt; says it's projecting); others see the &lt;code&gt;projecting&lt;/code&gt; flag in its beacon&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;AVAILABLE&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Saved in the dongle &lt;strong&gt;and&lt;/strong&gt; its beacon was heard in the last 45 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;SAVED&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Saved in the dongle, not heard&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  The request / accept protocol
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;Phone B sees that A is projecting and offers &lt;strong&gt;Switch to this phone&lt;/strong&gt; (or the user taps &lt;em&gt;Request connection&lt;/em&gt; in the app, in a quick-settings tile, or from a launcher shortcut).&lt;/li&gt;
&lt;li&gt;B sets the &lt;em&gt;requesting&lt;/em&gt; bit for 45 s, addressed to A.&lt;/li&gt;
&lt;li&gt;A hears it and shows an &lt;strong&gt;accept / reject&lt;/strong&gt; prompt: a heads-up notification delivered &lt;em&gt;through Android Auto itself&lt;/em&gt; (so it reaches the driver even while Waze or Spotify is on screen), a modal in the app, and a regular notification.&lt;/li&gt;
&lt;li&gt;If A accepts, it asks the dongle to restart. B, now the only phone looking, connects.&lt;/li&gt;
&lt;li&gt;A answers over the same channel for 30 s with &lt;em&gt;accepted&lt;/em&gt; or &lt;em&gt;rejected&lt;/em&gt;, so B can show what happened and let the user retry.
&lt;/li&gt;
&lt;/ol&gt;

&lt;pre data-lang="mermaid"&gt;&lt;code&gt;stateDiagram-v2
    [*] --&amp;gt; IDLE
    IDLE --&amp;gt; WAITING: user requests control
    WAITING --&amp;gt; ACCEPTED: A accepts
    WAITING --&amp;gt; REJECTED: A rejects
    WAITING --&amp;gt; TIMEOUT: no answer in 45 s
    REJECTED --&amp;gt; WAITING: retry
    TIMEOUT --&amp;gt; WAITING: retry
    ACCEPTED --&amp;gt; IDLE: phone B is now projecting&lt;/code&gt;&lt;/pre&gt;



&lt;h3&gt;
  
  
  The security trade-off we chose
&lt;/h3&gt;

&lt;p&gt;The beacon isn't authenticated: anyone who imitates a saved phone's 4-byte hash could &lt;em&gt;ask&lt;/em&gt; for a switch. So the active phone must approve. &lt;strong&gt;Auto-accept exists, but is off by default&lt;/strong&gt;, and the contribution rules forbid changes that enable anything that gives up the car without confirmation. The beacon contains only a hash of the Bluetooth name, no personal data.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. Drawing our own screen in the car
&lt;/h2&gt;

&lt;p&gt;Our first car UI used the standard Android for Cars &lt;strong&gt;templates&lt;/strong&gt; (IoT category): a list and a grid. It worked, and it looked like every other templated car app. You can't change backgrounds or cards.&lt;/p&gt;

&lt;p&gt;We wanted rounded cards, light/dark theme, colored states. The only way to draw freely on the car screen is the &lt;strong&gt;navigation category&lt;/strong&gt;: you receive a &lt;code&gt;Surface&lt;/code&gt; through a &lt;code&gt;SurfaceCallback&lt;/code&gt; and paint on it with a &lt;code&gt;Canvas&lt;/code&gt;. Touches arrive through &lt;code&gt;SurfaceCallback.onClick&lt;/code&gt;; the theme comes from &lt;code&gt;CarContext.isDarkMode&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The costs, stated plainly:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;It needs the &lt;code&gt;androidx.car.app.NAVIGATION_TEMPLATES&lt;/code&gt; and &lt;code&gt;ACCESS_SURFACE&lt;/code&gt; permissions.&lt;/li&gt;
&lt;li&gt;Navigation apps have extra review requirements on Google Play, so for now we distribute an &lt;strong&gt;APK&lt;/strong&gt; and rely on Android Auto's &lt;em&gt;Unknown sources&lt;/em&gt; developer option.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  The honest failure: it didn't show up in a real car
&lt;/h3&gt;

&lt;p&gt;Tested in a real car with a Pixel 9 and &lt;em&gt;Unknown sources&lt;/em&gt; enabled, &lt;strong&gt;the app did not appear&lt;/strong&gt; in the car's launcher or in "Customize launcher". In the DHU it appeared perfectly. The system log buffer (256 KB) had already rolled over by the time we looked, so we couldn't see why.&lt;/p&gt;

&lt;p&gt;Our hypothesis (not a confirmed fact): Android Auto is stricter with the navigation category for sideloaded apps; other sideloaded navigation apps report the same. What we did about it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Added a &lt;strong&gt;second entry in the IoT category&lt;/strong&gt; with standard templates as a fallback.&lt;/li&gt;
&lt;li&gt;Added &lt;code&gt;CarLog&lt;/code&gt;, a persistent 200 KB log of what Android Auto does with the app (service created, host, screen, size, touches) that can be shared from the app, so the &lt;em&gt;next&lt;/em&gt; attempt has evidence.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Everything has been validated with the DHU and the dongle on a desk. We say so in the repo.&lt;/p&gt;

&lt;h2&gt;
  
  
  8. Bugs that taught us something
&lt;/h2&gt;

&lt;p&gt;A short list of real ones from the project log:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Symptom&lt;/th&gt;
&lt;th&gt;Cause&lt;/th&gt;
&lt;th&gt;Fix&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;App crashed when opening in Android Auto&lt;/td&gt;
&lt;td&gt;A &lt;code&gt;Row&lt;/code&gt; without a title, because the dongle returned a phone with no name&lt;/td&gt;
&lt;td&gt;Always provide a label fallback&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Crash when switching phones&lt;/td&gt;
&lt;td&gt;A template in &lt;code&gt;loading&lt;/code&gt; state can't carry an icon or actions&lt;/td&gt;
&lt;td&gt;A separate template for the loading state&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;"Active" marked the wrong phone&lt;/td&gt;
&lt;td&gt;We trusted &lt;code&gt;DevList.index&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Each phone reports about itself&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The idle phone gave up the car without asking&lt;/td&gt;
&lt;td&gt;First design executed requests directly&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;RequestInbox&lt;/code&gt; plus explicit accept/reject&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;No way to retry&lt;/td&gt;
&lt;td&gt;The requester never learned the result&lt;/td&gt;
&lt;td&gt;BLE response and &lt;code&gt;WAITING / REJECTED / TIMEOUT / ACCEPTED&lt;/code&gt; states&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;"No requests load" on phone #2&lt;/td&gt;
&lt;td&gt;Its Bluetooth had been turned off; presence only started once at app launch&lt;/td&gt;
&lt;td&gt;Presence restarts with Bluetooth state; the screen now says &lt;em&gt;Bluetooth off&lt;/em&gt; / &lt;em&gt;Missing permissions&lt;/em&gt; / &lt;em&gt;Searching...&lt;/em&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two longer investigations are worth telling.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A new phone wouldn't connect.&lt;/strong&gt; Android Auto's logs showed &lt;code&gt;WIRELESS_SETUP_FAILED_TO_START_NO_HFP_FROM_HU_PRESENCE&lt;/code&gt;: the Bluetooth link to the dongle dropped after ~2 s, and wireless Android Auto &lt;em&gt;requires&lt;/em&gt; the dongle's hands-free profile (HFP). The dongle serves &lt;strong&gt;one Bluetooth connection at a time&lt;/strong&gt;. Turning off Bluetooth on the other phones, forgetting the pairing and re-pairing with "Phone calls" enabled fixed it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Android Auto felt slow on one phone.&lt;/strong&gt; The link itself was healthy: audio acks around 23 ms, 5 GHz WiFi at 270 to 300 Mbps. But the phone was nearly out of RAM with &lt;strong&gt;swap at 92 %&lt;/strong&gt;, &lt;code&gt;system_server&lt;/code&gt; at 94 % CPU, and Android Auto's frame limiter flapping between 60 and 30 fps. The dongle and the WiFi were innocent. That's why the app now &lt;strong&gt;measures the phone's own memory&lt;/strong&gt; and warns, next to a live dongle monitor (temperature, CPU, memory, WiFi throughput with an overheating alert).&lt;/p&gt;

&lt;h2&gt;
  
  
  9. The wall: &lt;code&gt;server.cgi&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;The obvious next question: is there a hidden command to switch phones &lt;em&gt;without&lt;/em&gt; a 30-second reboot? The CGI binary behind that API might know.&lt;/p&gt;

&lt;p&gt;We took the public 2024.10 firmware published by the community (the same community whose work made much of this possible: &lt;a href="https://github.com/ludwig-v/wireless-carplay-dongle-reverse-engineering" rel="noopener noreferrer"&gt;ludwig-v/wireless-carplay-dongle-reverse-engineering&lt;/a&gt;) and ran &lt;code&gt;strings&lt;/code&gt; on &lt;code&gt;server.cgi&lt;/code&gt;. Nothing useful. The reason:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Fact&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Format&lt;/td&gt;
&lt;td&gt;32-bit ARM EABI5 static ELF, &lt;strong&gt;no section headers&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Size&lt;/td&gt;
&lt;td&gt;42,304 bytes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Entropy&lt;/td&gt;
&lt;td&gt;~7.9 bits/byte almost everywhere (compressed or encrypted)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;strings&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;478 lines, all noise&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A small disassembly of the loader (725 ARM instructions, with &lt;code&gt;capstone&lt;/code&gt;) showed a &lt;strong&gt;UPX-style in-memory self-extractor with LZMA, plus a hardware-encryption layer&lt;/strong&gt;:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Open &lt;code&gt;/proc/self/exe&lt;/code&gt; and locate the payload.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;mmap2&lt;/code&gt; an anonymous buffer.&lt;/li&gt;
&lt;li&gt;If the payload is large enough, open &lt;strong&gt;&lt;code&gt;/dev/hwaes&lt;/code&gt;&lt;/strong&gt; (the string is assembled byte by byte so it isn't greppable) and call &lt;code&gt;ioctl(fd, 0xC00C6206, {src, dst, len})&lt;/code&gt;: a request served by a &lt;strong&gt;kernel driver&lt;/strong&gt;, not by code in the file. The key never appears in the binary.&lt;/li&gt;
&lt;li&gt;Decompress LZMA, &lt;code&gt;cacheflush&lt;/code&gt;, &lt;code&gt;mprotect&lt;/code&gt;, and jump into the real program.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;We also checked the USB kernel-module archive of the same firmware (&lt;code&gt;g_android_accessory.ko&lt;/code&gt;, &lt;code&gt;g_android_autobox.ko&lt;/code&gt;, &lt;code&gt;g_iphone.ko&lt;/code&gt; and friends: Linux 3.14.52, ARMv7, GPL). None contains &lt;code&gt;hwaes&lt;/code&gt;. The decryption driver presumably lives inside the kernel image.&lt;/p&gt;

&lt;h3&gt;
  
  
  Where we deliberately stopped
&lt;/h3&gt;

&lt;p&gt;Going further means recovering device-bound cryptographic material from the manufacturer's hardware. We chose not to. That's a line we draw on purpose, not a limitation we lament, even though it's "our own" dongle and even though a plausible next step existed (unpacking the kernel image). The repository's contribution rules say the same: &lt;strong&gt;no code that tries to decrypt, decompile, or extract key material from the firmware binaries&lt;/strong&gt;, and no manufacturer binaries in the repo. &lt;strong&gt;Static analysis of plain-text scripts and unencrypted binaries is welcome.&lt;/strong&gt; The analysis was purely static: we never ran the binary, and we never tried guessed commands against the dongle's API (an unknown command could reboot it or worse).&lt;/p&gt;

&lt;h2&gt;
  
  
  10. What the plain-text scripts told us
&lt;/h2&gt;

&lt;p&gt;Everything legible was enough to map the system. The key script is &lt;code&gt;phone_link_deamon.sh &amp;lt;CarPlay|AndroidAuto|HiCar|ICCOA&amp;gt; &amp;lt;start|stop&amp;gt;&lt;/code&gt;. For Android Auto it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;copies the binaries to RAM (&lt;code&gt;/tmp/bin&lt;/code&gt;, &lt;code&gt;/tmp/lib&lt;/code&gt;; the flash is read-only),&lt;/li&gt;
&lt;li&gt;starts &lt;code&gt;hfpd&lt;/code&gt; (Bluetooth hands-free) in the background and &lt;code&gt;ARMAndroidAuto&lt;/code&gt; in the foreground,&lt;/li&gt;
&lt;li&gt;and runs inside a &lt;strong&gt;supervisor loop&lt;/strong&gt;: while a lock file exists, every second it relaunches whatever died.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That implies a much lighter "soft switch" than a full reboot: kill the two link processes &lt;strong&gt;without&lt;/strong&gt; removing the lock file and the supervisor restarts the link in 1 to 2 seconds. The candidate sequence, run &lt;em&gt;inside&lt;/em&gt; the dongle, would look like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;hcitool dc &amp;lt;phone MAC&amp;gt;          &lt;span class="c"&gt;# drop the Bluetooth link of the active phone&lt;/span&gt;
killall hfpd ARMAndroidAuto     &lt;span class="c"&gt;# the supervisor relaunches them within ~1 s&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;This is not implemented and has never been run.&lt;/strong&gt; There is no shell on the dongle in car mode (&lt;code&gt;adbd&lt;/code&gt; is killed when the AOA handshake happens), and we don't know whether the car session survives, nor which phone &lt;code&gt;ARMAndroidAuto&lt;/code&gt; picks after relaunch. It's documented as future work that would require a modified firmware, with the open questions written down.&lt;/p&gt;

&lt;h2&gt;
  
  
  11. A detour that didn't pan out: internet for passengers
&lt;/h2&gt;

&lt;p&gt;We also tried to share the phone's mobile data with everyone in the car. Android lets a normal app neither switch the hotspot on nor set its name and password (that needs privileged permissions that only the &lt;code&gt;shell&lt;/code&gt; user from &lt;code&gt;adb&lt;/code&gt; has). We built a card with name, password, QR code and a notification button, realized the configuration was still manual, so it added nothing, and &lt;strong&gt;removed it&lt;/strong&gt;. The open paths (Shizuku, a 4G router, or a firmware that makes the dongle a bridge) are written down so nobody repeats the experiment.&lt;/p&gt;

&lt;h2&gt;
  
  
  12. Shipping it
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Stack:&lt;/strong&gt; Kotlin, Jetpack Compose (Material 3) for the phone UI, AndroidX Car App Library 1.4, minSdk 26; roughly 3,000 lines of Kotlin in 16 files.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;HTTP in clear text only to the dongle's own address&lt;/strong&gt; (it doesn't support HTTPS); the network security config allows it for that single IP.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Never auto-update the dongle's firmware from the app.&lt;/strong&gt; The downside (a bricked dongle) is much bigger than the upside.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CI:&lt;/strong&gt; every push to &lt;code&gt;main&lt;/code&gt; builds the app and publishes a GitHub release (&lt;code&gt;v0.1.&amp;lt;run number&amp;gt;&lt;/code&gt;) with the APK, and an Android App Bundle for Google Play. Releases are signed with a release key kept in a self-hosted secrets manager; CI authenticates to it with GitHub OIDC, with no stored secret (I wrote about that setup in &lt;a href="https://dev.to/duvanjamid/stop-storing-secrets-in-github-actions-and-the-one-wildcard-that-opens-your-vault-to-every-repo-on-33gk"&gt;Stop Storing Secrets in GitHub Actions&lt;/a&gt;). One lesson: the first releases were signed with the debug key, whose signature changes between runs, so Android refuses to update in place. You had to uninstall first.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  13. What the app does today
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Multi-phone:&lt;/strong&gt; any number of phones can share the dongle, with ACTIVE / AVAILABLE / SAVED states.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One-tap switching&lt;/strong&gt; from the app, from the Android Auto screen, from a quick-settings tile or from an icon shortcut.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Request protocol&lt;/strong&gt; with accept/reject and retry.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Visible countdown&lt;/strong&gt; during the ~30 s reboot, with phases and a progress bar in the app, the car screen and notifications.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dongle settings built dynamically&lt;/strong&gt; from the keys your firmware reports, using the &lt;em&gt;exact&lt;/em&gt; labels and help texts of the manufacturer's own interface (names, connection, audio and calls, video, WiFi channel, language). Firmwares differ, so the app only shows what the firmware exposes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Live monitor&lt;/strong&gt; with donut charts and an overheating warning.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Phone memory monitor.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tools:&lt;/strong&gt; custom logo, car data, log download, restore.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A guide for adding a new phone&lt;/strong&gt;, with automatic detection when it registers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Car emulation without a car&lt;/strong&gt; through the DHU in direct AOA mode.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Known limits (all in &lt;code&gt;docs/LIMITATIONS.md&lt;/code&gt;): switching reboots the dongle, "available" only works for phones that run the app with Bluetooth and permissions, BLE range is roughly 10 to 30 m (plenty inside a car), each firmware update can hide or rename settings, and distribution is by APK for now.&lt;/p&gt;

&lt;h2&gt;
  
  
  14. Try it, break it, contribute
&lt;/h2&gt;

&lt;p&gt;If you own a Carlinkit/AutoKit dongle:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Try the app.&lt;/strong&gt; Download the latest APK from the &lt;a href="https://github.com/muvatec/switch-phone-aa/releases" rel="noopener noreferrer"&gt;releases page&lt;/a&gt; and install it on &lt;strong&gt;every&lt;/strong&gt; phone that shares the dongle.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tell us your model and firmware&lt;/strong&gt; if it differs from ours (CPC200-2Air, firmware &lt;code&gt;2025.12.29&lt;/code&gt;). Setting keys vary between firmwares, and the app is designed to adapt to that.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Contribute.&lt;/strong&gt; Bug fixes with reproduction steps, support for other dongle models, UI improvements, new dongle settings (always with the label and help text exactly as the dongle's own interface shows them, and a citation), translations and screenshots. Read &lt;code&gt;CONTRIBUTING.md&lt;/code&gt; first, and the project log &lt;code&gt;docs/CONTEXT.md&lt;/code&gt; so we don't walk the same dead ends twice.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The docs are currently written in &lt;strong&gt;Spanish&lt;/strong&gt; (the research notes, the architecture and the full project log). Translations are a very welcome kind of PR.&lt;/p&gt;

&lt;p&gt;Everything is in the repo:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Project log, dead ends included: &lt;code&gt;docs/CONTEXT.md&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Architecture: &lt;code&gt;docs/ARCHITECTURE.md&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Dongle web API: &lt;code&gt;docs/DONGLE_API.md&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;How the dongle works inside: &lt;code&gt;docs/RESEARCH.md&lt;/code&gt;, &lt;code&gt;docs/SOFT_SWITCH.md&lt;/code&gt;, &lt;code&gt;docs/SERVER_CGI.md&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Limitations: &lt;code&gt;docs/LIMITATIONS.md&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Repository: &lt;strong&gt;&lt;a href="https://github.com/muvatec/switch-phone-aa" rel="noopener noreferrer"&gt;github.com/muvatec/switch-phone-aa&lt;/a&gt;&lt;/strong&gt;, MIT license. Issues and PRs are welcome.&lt;/p&gt;

</description>
      <category>android</category>
      <category>kotlin</category>
      <category>opensource</category>
      <category>reverseengineering</category>
    </item>
    <item>
      <title>Self-Hosting Infisical on Coolify: A Production-Style Guide with Compose, TLS, Machine Identities and GitHub OIDC</title>
      <dc:creator>Duvan Jamid Vargas</dc:creator>
      <pubDate>Mon, 26 Oct 2026 02:22:53 +0000</pubDate>
      <link>https://dev.to/duvanjamid/self-hosting-infisical-on-coolify-a-production-style-guide-with-compose-tls-machine-identities-23fh</link>
      <guid>https://dev.to/duvanjamid/self-hosting-infisical-on-coolify-a-production-style-guide-with-compose-tls-machine-identities-23fh</guid>
      <description>&lt;p&gt;&lt;a href="https://infisical.com" rel="noopener noreferrer"&gt;Infisical&lt;/a&gt; is an open-source secrets manager. &lt;a href="https://coolify.io" rel="noopener noreferrer"&gt;Coolify&lt;/a&gt; is a self-hosted PaaS that runs Docker workloads behind a Traefik reverse proxy with automatic TLS. This guide puts them together: a &lt;strong&gt;production-style Infisical on Coolify&lt;/strong&gt;, with the real Compose file I ran, the Coolify-specific tricks that make it painless, the machine-identity options (and which one to pick), and the failure modes I hit.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Conventions.&lt;/strong&gt; Every hostname, ID and secret below is a placeholder. &lt;code&gt;secrets.example.com&lt;/code&gt; is your domain. Nothing here came from a real instance. Menu names reflect the Infisical and Coolify versions I used; both UIs change often, so verify against yours.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  What you'll build
&lt;/h2&gt;



&lt;pre data-lang="mermaid"&gt;&lt;code&gt;flowchart LR
    C["Client / CI"] -- HTTPS --&amp;gt; T["Traefik (Coolify proxy, Let's Encrypt)"]
    T --&amp;gt; I["infisical backend :8080 (API + UI)"]
    I --&amp;gt; P[("PostgreSQL: encrypted secrets, state")]
    I --&amp;gt; R[("Redis: queues, cache, rate limiting")]&lt;/code&gt;&lt;/pre&gt;



&lt;p&gt;Three containers on one Docker network. Only the Infisical container is routed through the proxy; Postgres and Redis publish no ports.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prerequisites
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A Coolify instance with at least one connected server. &lt;strong&gt;2 GB of RAM is a comfortable floor&lt;/strong&gt; for Infisical + Postgres + Redis. (I'll show a &lt;em&gt;lean profile&lt;/em&gt; below that runs in much less, with limits you can tune.)&lt;/li&gt;
&lt;li&gt;A DNS &lt;code&gt;A&lt;/code&gt; record, e.g. &lt;code&gt;secrets.example.com&lt;/code&gt;, pointing at that server &lt;strong&gt;before&lt;/strong&gt; you deploy, so Let's Encrypt can issue the certificate.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;openssl&lt;/code&gt; locally if you generate secrets yourself.&lt;/li&gt;
&lt;li&gt;A password manager or offline vault for one particular key (next section).&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  1. Understand the three secrets (and the one you must not lose)
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Variable&lt;/th&gt;
&lt;th&gt;What it does&lt;/th&gt;
&lt;th&gt;If you lose or change it&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ENCRYPTION_KEY&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Encrypts the instance's root key material. The documented way to generate it is &lt;code&gt;openssl rand -hex 16&lt;/code&gt; (32 hex characters)&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Every secret in the database becomes unreadable.&lt;/strong&gt; A database backup alone doesn't help&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;AUTH_SECRET&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Signs JWTs / sessions (&lt;code&gt;openssl rand -base64 32&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;Sessions break; users must sign in again&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;POSTGRES_PASSWORD&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Database password (&lt;code&gt;openssl rand -hex 24&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;You can't connect to the database&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Back up &lt;code&gt;ENCRYPTION_KEY&lt;/code&gt; outside the server on day one.&lt;/strong&gt; Also remember: anything printed in a chat, a ticket or a shared terminal should be treated as exposed. If it happens to a signing key or an encryption key, rotate it.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. The Compose file
&lt;/h2&gt;

&lt;p&gt;Two ways to provide those values: type them into Coolify's &lt;em&gt;Environment Variables&lt;/em&gt; tab, or let Coolify generate them with &lt;strong&gt;magic variables&lt;/strong&gt;. I'll show the second, because it removes copy-paste of secrets entirely, then the manual variant.&lt;/p&gt;

&lt;h3&gt;
  
  
  Option A: Coolify magic variables (what I deployed)
&lt;/h3&gt;

&lt;p&gt;Coolify generates values for env names that follow a pattern (documented in its Docker Compose knowledge base):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Pattern&lt;/th&gt;
&lt;th&gt;Generates&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;SERVICE_URL_&amp;lt;ID&amp;gt;&lt;/code&gt; / &lt;code&gt;SERVICE_FQDN_&amp;lt;ID&amp;gt;&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;The service URL (with scheme) / the domain only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;SERVICE_USER_&amp;lt;ID&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Random 16-character alphanumeric username&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;SERVICE_PASSWORD_&amp;lt;ID&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;32-character password, no symbols (&lt;code&gt;..._64_&amp;lt;ID&amp;gt;&lt;/code&gt; for 64)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;SERVICE_REALBASE64_&amp;lt;ID&amp;gt;&lt;/code&gt; / &lt;code&gt;_64_&lt;/code&gt; / &lt;code&gt;_128_&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Base64 of 32 / 64 / 128 random bytes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;SERVICE_HEX_32_&amp;lt;ID&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;32-character hex string&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Reusing the same full name gives every service the same stored value. A numeric suffix on &lt;code&gt;SERVICE_URL_*&lt;/code&gt; (for example &lt;code&gt;SERVICE_URL_BACKEND_8080&lt;/code&gt;) tells Coolify which &lt;strong&gt;container port&lt;/strong&gt; to route the generated domain to.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;backend&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;infisical/infisical:v0.154.6'&lt;/span&gt;     &lt;span class="c1"&gt;# pin a release; see "Upgrades"&lt;/span&gt;
    &lt;span class="na"&gt;depends_on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;db&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;condition&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;service_healthy&lt;/span&gt;
      &lt;span class="na"&gt;redis&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;condition&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;service_healthy&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;SERVICE_URL_BACKEND_8080&lt;/span&gt;                &lt;span class="c1"&gt;# Coolify: generate a domain and route to port 8080&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;NODE_ENV=production&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;SITE_URL=${SITE_URL:-${SERVICE_URL_BACKEND}}'&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;ENCRYPTION_KEY=${SERVICE_PASSWORD_ENCRYPTIONKEY}'&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;AUTH_SECRET=${SERVICE_REALBASE64_64_AUTHSECRET}'&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;DB_CONNECTION_URI=postgresql://${SERVICE_USER_POSTGRES}:${SERVICE_PASSWORD_POSTGRES}@db:5432/${POSTGRES_DB:-infisical}'&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;REDIS_URL=redis://redis:6379'&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;TELEMETRY_ENABLED=false&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;NODE_OPTIONS=--max-old-space-size=1024&lt;/span&gt;
    &lt;span class="na"&gt;mem_limit&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;1536m&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;

  &lt;span class="na"&gt;redis&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;redis:7'&lt;/span&gt;
    &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;redis-server --maxmemory 64mb --maxmemory-policy noeviction --save 300 &lt;/span&gt;&lt;span class="m"&gt;10&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;redis-data:/data'&lt;/span&gt;
    &lt;span class="na"&gt;healthcheck&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;test&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;CMD'&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;redis-cli'&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;ping'&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
      &lt;span class="na"&gt;interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;5s&lt;/span&gt;
      &lt;span class="na"&gt;timeout&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;5s&lt;/span&gt;
      &lt;span class="na"&gt;retries&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;10&lt;/span&gt;
    &lt;span class="na"&gt;mem_limit&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;96m&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;

  &lt;span class="na"&gt;db&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;postgres:14-alpine'&lt;/span&gt;
    &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgres -c shared_buffers=64MB -c max_connections=30 -c work_mem=4MB -c effective_cache_size=256MB -c maintenance_work_mem=32MB&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;POSTGRES_USER=${SERVICE_USER_POSTGRES}'&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;POSTGRES_PASSWORD=${SERVICE_PASSWORD_POSTGRES}'&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;POSTGRES_DB=${POSTGRES_DB:-infisical}'&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;pg-data:/var/lib/postgresql/data'&lt;/span&gt;
    &lt;span class="na"&gt;healthcheck&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;test&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;CMD-SHELL'&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;pg_isready&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;-U&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;$${POSTGRES_USER}&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;-d&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;$${POSTGRES_DB}'&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
      &lt;span class="na"&gt;interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;5s&lt;/span&gt;
      &lt;span class="na"&gt;timeout&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;5s&lt;/span&gt;
      &lt;span class="na"&gt;retries&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;10&lt;/span&gt;
    &lt;span class="na"&gt;mem_limit&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;256m&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;

&lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;redis-data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;pg-data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Details worth understanding rather than copying:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;$${POSTGRES_USER}&lt;/code&gt; (double dollar)&lt;/strong&gt; in the Postgres health check makes Compose pass a literal &lt;code&gt;${POSTGRES_USER}&lt;/code&gt; for the &lt;em&gt;container's shell&lt;/em&gt; to expand, instead of interpolating it at parse time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;--maxmemory-policy noeviction&lt;/code&gt; on Redis&lt;/strong&gt; matters. Infisical uses Redis for job queues; an eviction policy that drops keys under memory pressure can silently lose queued work. &lt;code&gt;noeviction&lt;/code&gt; makes Redis return errors instead, which you can see and alert on. &lt;code&gt;--save 300 10&lt;/code&gt; snapshots to disk so queues survive a restart.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The memory limits and Postgres tuning are a *lean profile&lt;/strong&gt;* for small VPSes: ~1.5 GB for the app (with Node's heap capped at 1 GB), ~96 MB for Redis and ~256 MB for Postgres with &lt;code&gt;max_connections=30&lt;/code&gt;. Raise them as your usage grows. If you run out of memory, you want a container restart you can see in the logs, not the host's OOM killer picking a victim.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep &lt;code&gt;depends_on&lt;/code&gt; with &lt;code&gt;service_healthy&lt;/code&gt;:&lt;/strong&gt; Infisical runs database migrations at startup, so starting it before Postgres is ready produces confusing first-boot errors.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Postgres major version:&lt;/strong&gt; don't bump &lt;code&gt;postgres:14-alpine&lt;/code&gt; to 16 on an existing volume; a major upgrade needs a dump/restore.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Telemetry:&lt;/strong&gt; &lt;code&gt;TELEMETRY_ENABLED=false&lt;/code&gt; turns off usage telemetry.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Option B: manual variables
&lt;/h3&gt;

&lt;p&gt;If you don't want magic variables (or you're not on Coolify), set these as secrets in the &lt;em&gt;Environment Variables&lt;/em&gt; tab and replace the &lt;code&gt;${SERVICE_*}&lt;/code&gt; references:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Variable&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ENCRYPTION_KEY&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;openssl rand -hex 16&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;AUTH_SECRET&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;openssl rand -base64 32&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;POSTGRES_PASSWORD&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;openssl rand -hex 24&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;SITE_URL&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;https://secrets.example.com&lt;/code&gt; (no trailing slash)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Mark them as secrets so Coolify hides them in the UI and in logs.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;One observation from my own deployment: Coolify's &lt;code&gt;SERVICE_PASSWORD_*&lt;/code&gt; (32 alphanumeric characters) was accepted by Infisical as &lt;code&gt;ENCRYPTION_KEY&lt;/code&gt;. The documented generation is still &lt;code&gt;openssl rand -hex 16&lt;/code&gt;; if you hit a key-format error at boot, switch to that.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  3. Create the resource in Coolify
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Projects → New project&lt;/strong&gt; (e.g. &lt;code&gt;platform&lt;/code&gt;), pick the &lt;code&gt;production&lt;/code&gt; environment, &lt;strong&gt;+ New resource&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Choose &lt;strong&gt;Docker Compose Empty&lt;/strong&gt; and paste the file. (If your Coolify ships an Infisical one-click template, you can use it: the variables are the same.)&lt;/li&gt;
&lt;li&gt;Open the service's &lt;strong&gt;Domains&lt;/strong&gt; and set it to &lt;code&gt;https://secrets.example.com&lt;/code&gt;. With the &lt;code&gt;SERVICE_URL_BACKEND_8080&lt;/code&gt; line, Coolify already knows the container port; in a manual setup, add &lt;code&gt;:8080&lt;/code&gt; to the domain so the proxy routes to the right port. Leave &lt;code&gt;db&lt;/code&gt; and &lt;code&gt;redis&lt;/code&gt; without a domain.&lt;/li&gt;
&lt;li&gt;Make sure the server's &lt;strong&gt;proxy (Traefik) is running&lt;/strong&gt; with the Let's Encrypt resolver. If you changed proxy settings, restart the proxy.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Deploy.&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;code&gt;SITE_URL&lt;/code&gt; must equal the public HTTPS URL users hit. If you change the domain later, change &lt;code&gt;SITE_URL&lt;/code&gt; too.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Verify
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; https://secrets.example.com/api/status
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A healthy instance answers with a small JSON document ("message": "Ok", plus flags such as whether email is configured). If you get a 502/504:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Check&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Domain routes to port &lt;strong&gt;8080&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;The most common mistake&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;db&lt;/code&gt; is healthy&lt;/td&gt;
&lt;td&gt;The backend waits for it, and fails to migrate otherwise&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Backend logs show migrations finishing&lt;/td&gt;
&lt;td&gt;First boot runs many of them; give it a minute&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The proxy is up and the certificate was issued&lt;/td&gt;
&lt;td&gt;Look at the Traefik/proxy logs for ACME errors; DNS must already resolve&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;I didn't add a container health check to the backend. If you want one, test that &lt;code&gt;wget&lt;/code&gt; or &lt;code&gt;curl&lt;/code&gt; exists in the image before relying on it, or monitor &lt;code&gt;/api/status&lt;/code&gt; from outside.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. First login, then lock the door
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Open the URL and &lt;strong&gt;sign up&lt;/strong&gt;. The &lt;strong&gt;first account becomes the instance admin&lt;/strong&gt;, so do this &lt;em&gt;before&lt;/em&gt; announcing the URL to anybody.&lt;/li&gt;
&lt;li&gt;Open the &lt;strong&gt;Server Console&lt;/strong&gt; (top-right) and &lt;strong&gt;disable open sign-ups&lt;/strong&gt;. Otherwise anyone who finds a public instance can register.&lt;/li&gt;
&lt;li&gt;Enable &lt;strong&gt;MFA&lt;/strong&gt; on the admin account.&lt;/li&gt;
&lt;li&gt;(Optional) Configure &lt;strong&gt;SMTP&lt;/strong&gt; (&lt;code&gt;SMTP_HOST&lt;/code&gt;, &lt;code&gt;SMTP_PORT&lt;/code&gt;, &lt;code&gt;SMTP_USERNAME&lt;/code&gt;, &lt;code&gt;SMTP_PASSWORD&lt;/code&gt;, &lt;code&gt;SMTP_FROM_ADDRESS&lt;/code&gt;, plus the TLS options your provider needs) for invites and password resets. Until you do, the UI shows an &lt;em&gt;SMTP has not been configured for this instance&lt;/em&gt; banner and email flows don't work.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  6. Projects and environments
&lt;/h2&gt;

&lt;p&gt;Create a project (e.g. &lt;code&gt;my-app&lt;/code&gt;). Infisical creates &lt;strong&gt;dev, staging and prod&lt;/strong&gt; environments by default. Use the &lt;em&gt;slug&lt;/em&gt; shown in the project settings (not the display name) in CI and CLI configurations; they're different things and mixing them up gives confusing "not found" errors.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. Machine identities: don't use a human account for automation
&lt;/h2&gt;

&lt;p&gt;A &lt;strong&gt;machine identity&lt;/strong&gt; is the non-human principal for apps and CI. The flow in the UI:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Organization → Access Control → Machine Identities → Create Organization Machine Identity&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Give it a name and an &lt;strong&gt;organization role&lt;/strong&gt;. Admin and Member are broad; &lt;strong&gt;No Access&lt;/strong&gt; is the right choice for a CI identity that only needs to read secrets from one project.&lt;/li&gt;
&lt;li&gt;Open it, and under &lt;strong&gt;Authentication&lt;/strong&gt; add an auth method. The menu offers: Token Auth, &lt;strong&gt;Universal Auth&lt;/strong&gt;, Kubernetes, GCP, Alibaba Cloud, AWS, Azure, OCI, &lt;strong&gt;OIDC&lt;/strong&gt;, LDAP, TLS Certificate and JWT.
&lt;em&gt;Note:&lt;/em&gt; a newly created identity comes with &lt;strong&gt;Universal Auth&lt;/strong&gt; already configured. If you plan to use something else, remove Universal Auth so there's only one way in.&lt;/li&gt;
&lt;li&gt;Add the identity to the project (identity page → &lt;em&gt;Projects&lt;/em&gt; → &lt;strong&gt;+&lt;/strong&gt;) with the narrowest role that works. Project roles include Admin, Developer, &lt;strong&gt;Viewer&lt;/strong&gt; and No Access; for "read the prod secrets", Viewer is enough.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Option 1: Universal Auth (client ID and client secret)
&lt;/h3&gt;

&lt;p&gt;The simple one. You get a &lt;strong&gt;client ID&lt;/strong&gt; and &lt;strong&gt;client secret&lt;/strong&gt;. A few defaults are worth changing:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Setting&lt;/th&gt;
&lt;th&gt;Default&lt;/th&gt;
&lt;th&gt;Recommendation&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Access token TTL / Max TTL&lt;/td&gt;
&lt;td&gt;2,592,000 s (30 days)&lt;/td&gt;
&lt;td&gt;Hours or less for CI&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Access token max uses&lt;/td&gt;
&lt;td&gt;0 (unlimited)&lt;/td&gt;
&lt;td&gt;Set a bound if the job reads a known number of times&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Client secret TTL&lt;/td&gt;
&lt;td&gt;0 (never expires)&lt;/td&gt;
&lt;td&gt;Set a TTL, and enable the expiry alert so your team is warned&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Client secret max uses&lt;/td&gt;
&lt;td&gt;0 (unlimited)&lt;/td&gt;
&lt;td&gt;Low, if the secret is for a single pipeline&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Trusted IPs&lt;/td&gt;
&lt;td&gt;0.0.0.0/0&lt;/td&gt;
&lt;td&gt;Restrict to your CI/server ranges; per Infisical's docs this restriction is a &lt;strong&gt;paid&lt;/strong&gt; feature&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Lockout&lt;/td&gt;
&lt;td&gt;On (threshold 3)&lt;/td&gt;
&lt;td&gt;Leave it on&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The documentation itself shows two different default TTLs for the access token (30 days in the configuration section, 7200 s in a login example), so &lt;strong&gt;check the effective values on your own identity&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Use it from the CLI:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# domain flag for self-hosted instances (older CLI versions may expect the API URL with /api)&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;INFISICAL_TOKEN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;infisical login &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--method&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;universal-auth &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--client-id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$INFISICAL_CLIENT_ID&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--client-secret&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$INFISICAL_CLIENT_SECRET&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--domain&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;https://secrets.example.com &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--silent&lt;/span&gt; &lt;span class="nt"&gt;--plain&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;

&lt;span class="c"&gt;# inject secrets as environment variables of one process; nothing is written to disk&lt;/span&gt;
infisical run &lt;span class="nt"&gt;--projectId&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&amp;lt;project-id&amp;gt;"&lt;/span&gt; &lt;span class="nt"&gt;--env&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;prod &lt;span class="nt"&gt;--domain&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;https://secrets.example.com &lt;span class="nt"&gt;--&lt;/span&gt; ./start-service
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(Confirm flags with &lt;code&gt;infisical login --help&lt;/code&gt; for your CLI version.) And from GitHub Actions. Note the &lt;code&gt;method&lt;/code&gt; value for the &lt;em&gt;action&lt;/em&gt; is &lt;code&gt;universal&lt;/code&gt;, not &lt;code&gt;universal-auth&lt;/code&gt; (that spelling is the CLI's):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Infisical/secrets-action@v1.0.9&lt;/span&gt;     &lt;span class="c1"&gt;# pin the version or, better, a commit SHA&lt;/span&gt;
  &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;universal&lt;/span&gt;
    &lt;span class="na"&gt;client-id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.INFISICAL_CLIENT_ID }}&lt;/span&gt;
    &lt;span class="na"&gt;client-secret&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.INFISICAL_CLIENT_SECRET }}&lt;/span&gt;
    &lt;span class="na"&gt;domain&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https://secrets.example.com&lt;/span&gt;
    &lt;span class="na"&gt;project-slug&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;my-app&lt;/span&gt;
    &lt;span class="na"&gt;env-slug&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;prod&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Only the client ID and client secret live in GitHub; the rest is fetched at runtime and exported as environment variables. Rotating a secret in Infisical needs no repo change.&lt;/p&gt;

&lt;h3&gt;
  
  
  Option 2 (recommended for GitHub Actions): OIDC, no stored secret
&lt;/h3&gt;

&lt;p&gt;Universal Auth still leaves a &lt;strong&gt;long-lived client secret&lt;/strong&gt; in GitHub. With &lt;strong&gt;OIDC Auth&lt;/strong&gt;, the workflow presents a short-lived token that GitHub signs, and Infisical checks its claims. Nothing to rotate, nothing to leak.&lt;/p&gt;

&lt;p&gt;In the identity, add &lt;strong&gt;OIDC Auth&lt;/strong&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Field&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;OIDC Discovery URL&lt;/td&gt;
&lt;td&gt;&lt;code&gt;https://token.actions.githubusercontent.com&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Issuer&lt;/td&gt;
&lt;td&gt;&lt;code&gt;https://token.actions.githubusercontent.com&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Subject&lt;/td&gt;
&lt;td&gt;pin the owner (and ideally the branch): &lt;code&gt;repo:&amp;lt;OWNER&amp;gt;/*:ref:refs/heads/main&lt;/code&gt; (see the note below on GitHub's newer subject format)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Audiences&lt;/td&gt;
&lt;td&gt;the action's default is &lt;code&gt;https://github.com/&amp;lt;OWNER&amp;gt;&lt;/code&gt;; whatever you set must match exactly what the workflow requests&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;permissions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;id-token&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;write&lt;/span&gt;      &lt;span class="c1"&gt;# required to request the OIDC token&lt;/span&gt;
  &lt;span class="na"&gt;contents&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;read&lt;/span&gt;

&lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Infisical/secrets-action@v1.0.9&lt;/span&gt;
    &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;oidc&lt;/span&gt;
      &lt;span class="na"&gt;identity-id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ vars.INFISICAL_IDENTITY_ID }}&lt;/span&gt;   &lt;span class="c1"&gt;# an identifier, not a secret&lt;/span&gt;
      &lt;span class="na"&gt;domain&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ vars.INFISICAL_URL }}&lt;/span&gt;
      &lt;span class="na"&gt;project-slug&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;my-app&lt;/span&gt;
      &lt;span class="na"&gt;env-slug&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;prod&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things bite people:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Never use &lt;code&gt;*&lt;/code&gt; or &lt;code&gt;repo:*&lt;/code&gt; as the Subject.&lt;/strong&gt; The token issuer is shared by all of GitHub, so the Subject is the only thing separating your repos from everyone else's. Wildcards in Infisical's Subject also match &lt;code&gt;:&lt;/code&gt; and &lt;code&gt;/&lt;/code&gt;, so always put a &lt;code&gt;/&lt;/code&gt; or &lt;code&gt;@&lt;/code&gt; right after the owner.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GitHub changed the default &lt;code&gt;sub&lt;/code&gt; format&lt;/strong&gt; for new, renamed and transferred repositories (and for organizations that opt in) to include immutable IDs: &lt;code&gt;repo:&amp;lt;OWNER&amp;gt;@&amp;lt;OWNER_ID&amp;gt;/&amp;lt;REPO&amp;gt;@&amp;lt;REPO_ID&amp;gt;:ref:...&lt;/code&gt;. A policy written for the old name-only format stops matching. Print the real claims from a test job and compare them character by character before debugging anything else.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I covered the threat model in depth in &lt;a href="https://dev.to/duvanjamid/stop-storing-secrets-in-github-actions-and-the-one-wildcard-that-opens-your-vault-to-every-repo-on-33gk"&gt;Stop Storing Secrets in GitHub Actions&lt;/a&gt;. A side note for private repositories on GitHub's Free plan: &lt;em&gt;organization&lt;/em&gt; variables are only available to public repositories, so put &lt;code&gt;INFISICAL_IDENTITY_ID&lt;/code&gt; and &lt;code&gt;INFISICAL_URL&lt;/code&gt; in &lt;strong&gt;repository&lt;/strong&gt; variables.&lt;/p&gt;

&lt;h2&gt;
  
  
  8. Operations
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Backups.&lt;/strong&gt; Two independent things, stored separately:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# logical backup of the database (adjust the container name; run on the Docker host)&lt;/span&gt;
docker &lt;span class="nb"&gt;exec&lt;/span&gt; &amp;lt;db-container&amp;gt; pg_dump &lt;span class="nt"&gt;-U&lt;/span&gt; &amp;lt;user&amp;gt; &lt;span class="nt"&gt;-d&lt;/span&gt; infisical &lt;span class="nt"&gt;-Fc&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; infisical-&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%F&lt;span class="si"&gt;)&lt;/span&gt;.dump
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Plus &lt;code&gt;ENCRYPTION_KEY&lt;/code&gt; in an offline vault. If your Postgres is a Coolify-managed database resource, you can use Coolify's scheduled backups to S3-compatible storage instead. Either way, &lt;strong&gt;test a restore&lt;/strong&gt; into a scratch instance with the same &lt;code&gt;ENCRYPTION_KEY&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Upgrades.&lt;/strong&gt; Pin the image tag, read the release notes, take a database snapshot, bump the tag, redeploy, and watch the migration logs. Never ride &lt;code&gt;latest&lt;/code&gt; in production: Infisical migrates the schema on boot, so an unplanned image pull can change your database.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rotation.&lt;/strong&gt; Schedule rotation of client secrets (or move to OIDC and stop having them). Revoke any Coolify API tokens you created during setup once you're done with them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Audit.&lt;/strong&gt; The audit logs show which identity read which secret. Review them; a log nobody reads is decoration.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Network exposure.&lt;/strong&gt; Serve TLS only. If the instance is for internal use, restrict the proxy to a VPN or a tunnel instead of the open internet, and keep sign-ups disabled either way.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Monitoring.&lt;/strong&gt; Alert on &lt;code&gt;/api/status&lt;/code&gt;, on container restarts, on Redis errors (remember &lt;code&gt;noeviction&lt;/code&gt;), and on disk usage of the Postgres volume.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common pitfalls
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Symptom&lt;/th&gt;
&lt;th&gt;Likely cause&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;502 right after deploy&lt;/td&gt;
&lt;td&gt;Wrong container port in the domain (needs &lt;code&gt;8080&lt;/code&gt;), or Postgres still starting&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Certificate not issued&lt;/td&gt;
&lt;td&gt;DNS not pointing at the server yet, or the proxy isn't running/was not restarted after changes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Boot error about &lt;code&gt;ENCRYPTION_KEY&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Wrong length or format; regenerate with &lt;code&gt;openssl rand -hex 16&lt;/code&gt; (on a &lt;em&gt;fresh&lt;/em&gt; database only)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Everyone signed out after a redeploy&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;AUTH_SECRET&lt;/code&gt; changed between deploys (with magic variables, don't recreate the variable)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Secrets unreadable after a restore&lt;/td&gt;
&lt;td&gt;Restored the database with a different &lt;code&gt;ENCRYPTION_KEY&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;No invite or reset emails&lt;/td&gt;
&lt;td&gt;SMTP isn't configured&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CI says the project isn't found&lt;/td&gt;
&lt;td&gt;You used the project &lt;em&gt;name&lt;/em&gt; instead of its &lt;em&gt;slug&lt;/em&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;403&lt;/code&gt; on OIDC login&lt;/td&gt;
&lt;td&gt;Subject/audience mismatch, or the identity wasn't added to the project&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Backend killed repeatedly&lt;/td&gt;
&lt;td&gt;Memory limit too low for your usage; raise &lt;code&gt;mem_limit&lt;/code&gt; and the Node heap together&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  A note on automating this with AI agents
&lt;/h2&gt;

&lt;p&gt;If you let an agent drive the setup, treat it like any other automation account: give it a scoped identity, never paste keystores or passwords into the conversation, pass secrets through files or stdin rather than printing them, and rotate anything that did end up in a transcript. The convenience is real, and so is the blast radius.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;[ ] DNS resolves, proxy running, certificate issued&lt;/li&gt;
&lt;li&gt;[ ] Image tags pinned; Postgres major version consistent with the volume&lt;/li&gt;
&lt;li&gt;[ ] &lt;code&gt;ENCRYPTION_KEY&lt;/code&gt; backed up offline; database backup tested&lt;/li&gt;
&lt;li&gt;[ ] First admin created, &lt;strong&gt;open sign-ups disabled&lt;/strong&gt;, MFA on&lt;/li&gt;
&lt;li&gt;[ ] SMTP configured (if you need invites)&lt;/li&gt;
&lt;li&gt;[ ] CI uses &lt;strong&gt;OIDC&lt;/strong&gt; (or Universal Auth with short TTLs and limited uses)&lt;/li&gt;
&lt;li&gt;[ ] Identities have the narrowest role, one auth method each&lt;/li&gt;
&lt;li&gt;[ ] Alerts on &lt;code&gt;/api/status&lt;/code&gt; and container restarts&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>selfhosted</category>
      <category>devops</category>
      <category>security</category>
      <category>docker</category>
    </item>
    <item>
      <title>SSR and Hydration in a Small Framework: What Works, What Doesn't, and What's Next (Inside AngeLite, Part 7)</title>
      <dc:creator>Duvan Jamid Vargas</dc:creator>
      <pubDate>Mon, 26 Oct 2026 02:10:34 +0000</pubDate>
      <link>https://dev.to/duvanjamid/ssr-and-hydration-in-a-small-framework-what-works-what-doesnt-and-whats-next-inside-angelite-6gb</link>
      <guid>https://dev.to/duvanjamid/ssr-and-hydration-in-a-small-framework-what-works-what-doesnt-and-whats-next-inside-angelite-6gb</guid>
      <description>&lt;p&gt;Server-side rendering is where small frameworks usually get stuck: the runtime is full of &lt;code&gt;document.createElement&lt;/code&gt; calls, so it can't run on a server without pretending to be a browser. AngeLite has an unusual head start, and an honest incomplete spot. This final post covers both.&lt;/p&gt;

&lt;h2&gt;
  
  
  What exists today: render in jsdom, no second implementation
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;tools/ssr.mjs&lt;/code&gt; renders your component in Node &lt;strong&gt;inside jsdom&lt;/strong&gt;. That was a deliberate choice: there is no second renderer to keep in sync, so the server output is exactly what the browser would build, with the same URL sanitising and the same escaping.&lt;/p&gt;

&lt;p&gt;It exports three functions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;renderToString(Comp, options)&lt;/code&gt;&lt;/strong&gt;: the markup of the mount element (default &lt;code&gt;#app&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;renderPage(Comp, { shell, url, state })&lt;/code&gt;&lt;/strong&gt;: a page shell with &lt;code&gt;&amp;lt;!--app--&amp;gt;&lt;/code&gt; and &lt;code&gt;&amp;lt;!--state--&amp;gt;&lt;/code&gt; markers filled in.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;serializeState(state)&lt;/code&gt;&lt;/strong&gt;: JSON that is &lt;strong&gt;safe inside a &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt; tag&lt;/strong&gt;. It escapes &lt;code&gt;&amp;lt;&lt;/code&gt;, &lt;code&gt;&amp;gt;&lt;/code&gt;, &lt;code&gt;&amp;amp;&lt;/code&gt; and the Unicode line separators, which is the classic place for injection bugs in SSR.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Because jsdom provides the globals during a render, &lt;strong&gt;renders are queued&lt;/strong&gt;: two requests at once don't step on each other. Components that load data asynchronously can be awaited with a &lt;code&gt;ready&lt;/code&gt; option, and a &lt;code&gt;setup&lt;/code&gt; hook runs before mount (for example to call &lt;code&gt;provideRouter(routes, { mode: 'history' })&lt;/code&gt; so the router reads the request URL).&lt;/p&gt;

&lt;h2&gt;
  
  
  Server and client, concretely
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// server (Node)&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;renderPage&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;serializeState&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;@duvanjamid/angelite/tools/ssr.mjs&lt;/span&gt;&lt;span class="dl"&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;page&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;renderPage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;App&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;shell&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;state&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="c1"&gt;// shell contains &amp;lt;!--app--&amp;gt; and &amp;lt;!--state--&amp;gt;; state is embedded via serializeState&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// client&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;hydrate&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;@duvanjamid/angelite/hydrate&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nf"&gt;hydrate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;App&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getElementById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;app&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;hydrate()&lt;/code&gt; is a 15-line module (about 0.1 KB gzip). Read &lt;code&gt;window.__STATE__&lt;/code&gt; to start from the data the server used.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest incomplete part
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;hydrate()&lt;/code&gt; &lt;strong&gt;replaces the server HTML with the live app in the same task&lt;/strong&gt;, so there's no flicker, but it &lt;strong&gt;does not adopt the existing nodes&lt;/strong&gt;: the client renders again, and there is no streaming either. What you gain is visible content before the JavaScript arrives, and SEO. What you don't gain is the work saved by true hydration. The ROADMAP lists "hydration that adopts the server's existing nodes and streaming rendering" as the top &lt;em&gt;Next&lt;/em&gt; item, and the comparison table is explicit that this is smaller and less complete than Angular's incremental hydration or what React and Vue offer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the design could go: the blueprint is data
&lt;/h2&gt;

&lt;p&gt;Recall from post 2 that a template compiles once into a &lt;strong&gt;blueprint&lt;/strong&gt;: an HTML fragment plus a list of instructions ("this text node depends on this expression", "this element has this event"). The ahead-of-time (AOT) compiler already serialises that list &lt;strong&gt;per node position&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;That's an unusually good base for the &lt;em&gt;next&lt;/em&gt; version of SSR, and it's a direction, not a shipped feature:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;On the server:&lt;/strong&gt; evaluate the instructions &lt;em&gt;without a DOM&lt;/em&gt; and emit text, removing the jsdom dependency and its serialised queue.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;On the client:&lt;/strong&gt; walk the existing DOM with the same instructions (numbering by position already exists) to &lt;strong&gt;adopt&lt;/strong&gt; the server nodes instead of rendering again.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Few frameworks have that starting point, because their template output is code that creates elements rather than data describing them. The project's gap analysis flags it as the thing to exploit "before adding more features".&lt;br&gt;
&lt;/p&gt;

&lt;pre data-lang="mermaid"&gt;&lt;code&gt;flowchart LR
    T[Template] --&amp;gt; C["Compiler (AOT)"]
    C --&amp;gt; B["Blueprint = HTML + instruction list (data)"]
    B --&amp;gt; K["Client today: clone + apply instructions"]
    B -. "today" .-&amp;gt; J["Server today: render in jsdom"]
    B -. "future" .-&amp;gt; S["Server: evaluate instructions, no DOM"]
    B -. "future" .-&amp;gt; H["Client: adopt existing nodes"]&lt;/code&gt;&lt;/pre&gt;



&lt;h2&gt;
  
  
  Why SSR on a small framework is hard: global state
&lt;/h2&gt;

&lt;p&gt;The gap analysis names the two design limits that SSR runs into:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Module-level global state.&lt;/strong&gt; The pipe registry, the &lt;code&gt;inject()&lt;/code&gt; service map, the router, the set of delegated events and the router hook are singletons. You can't render on a server without mixing requests unless you serialise renders (hence the queue), and you can't run two isolated apps on one page. The direction is a &lt;code&gt;createApp()&lt;/code&gt; object that owns registries, injector and router.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Coupling to the global DOM.&lt;/strong&gt; The runtime calls &lt;code&gt;document&lt;/code&gt; directly, which rules out alternative renderers. The direction is to abstract the few DOM operations (create template, insert, move, remove).&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Neither is a bug; both are the price of a core that's about 6 KB. They're also the clearest "next step" list for anyone who wants to contribute.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tooling around it
&lt;/h2&gt;

&lt;p&gt;Combine it with the Vite plugin (&lt;code&gt;tools/vite.mjs&lt;/code&gt;, which reloads on template edits) and &lt;code&gt;angelite-check&lt;/code&gt; (&lt;code&gt;file:1: message&lt;/code&gt; output that reuses the AOT template checks) for a fast dev loop.&lt;/p&gt;

&lt;h2&gt;
  
  
  A caution that applies everywhere
&lt;/h2&gt;

&lt;p&gt;SSR multiplies the importance of the security model from post 3. Three rules hold:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Never&lt;/strong&gt; include user input in a template that gets compiled; precompile with the AOT tool.&lt;/li&gt;
&lt;li&gt;Escape-by-default text and URL filtering apply on the server render too, so server HTML and client DOM agree.&lt;/li&gt;
&lt;li&gt;Use &lt;code&gt;serializeState&lt;/code&gt; rather than hand-rolled &lt;code&gt;JSON.stringify&lt;/code&gt; inside a &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt; tag.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Series wrap-up
&lt;/h2&gt;

&lt;p&gt;Across seven posts the argument has been the same:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Small is a budget, not a goal.&lt;/strong&gt; It stays small because CI refuses to let it grow silently (post 4), and because optional really means optional (post 5).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fine-grained and boring.&lt;/strong&gt; Signals, microtask batching and cloned blueprints do the work; there's no virtual DOM and no Zone.js (post 2).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Secure by default means tested against attacks.&lt;/strong&gt; The first test run made the framework look worst of its peers, and that was the point (post 3).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Honest comparisons build trust.&lt;/strong&gt; Publish the method, the noise, and the losses (post 6).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Write the debt down.&lt;/strong&gt; Global state, DOM coupling and re-rendering hydration are in the open (this post).&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you want to try it or contribute, start at &lt;a href="https://github.com/duvanjamid/angelite" rel="noopener noreferrer"&gt;duvanjamid/angelite&lt;/a&gt; and the &lt;a href="https://duvanjamid.github.io/angelite-docs/" rel="noopener noreferrer"&gt;documentation&lt;/a&gt;. Issues, benchmark corrections and PRs are all welcome, and I'd love to hear from anyone who tries precompiled mode on a real app.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>webdev</category>
      <category>ssr</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Benchmarking Honestly: Where My Framework Wins and Where It Loses (Inside AngeLite, Part 6)</title>
      <dc:creator>Duvan Jamid Vargas</dc:creator>
      <pubDate>Mon, 26 Oct 2026 02:10:23 +0000</pubDate>
      <link>https://dev.to/duvanjamid/benchmarking-honestly-where-my-framework-wins-and-where-it-loses-inside-angelite-part-6-11ie</link>
      <guid>https://dev.to/duvanjamid/benchmarking-honestly-where-my-framework-wins-and-where-it-loses-inside-angelite-part-6-11ie</guid>
      <description>&lt;p&gt;Framework benchmark posts have a credibility problem: the author's framework wins. So this post is built the other way around. It describes how AngeLite is benchmarked, what the numbers show, and, more importantly, where the project's own comparison says it &lt;strong&gt;loses&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Method first
&lt;/h2&gt;

&lt;p&gt;The benchmark is a table app: &lt;code&gt;table &amp;gt; tbody &amp;gt; tr&lt;/code&gt; with 3 cells, one click handler per row, and the selected row marked with a class. The data comes from the same deterministic generator in every implementation. Each framework is written idiomatically:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;React 19&lt;/strong&gt; with &lt;code&gt;memo&lt;/code&gt; + &lt;code&gt;useCallback&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Preact&lt;/strong&gt; (core + hooks only, &lt;code&gt;shouldComponentUpdate&lt;/code&gt; on the row)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Vue 3&lt;/strong&gt; with a compiled template and &lt;code&gt;shallowRef&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Angular 20&lt;/strong&gt; with signals, &lt;code&gt;OnPush&lt;/code&gt;, &lt;code&gt;@for ... track&lt;/code&gt;, and zoneless change detection&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;AngeLite&lt;/strong&gt; with signals and &lt;code&gt;@for ... track&lt;/code&gt;, in two modes: compiled in the browser, and precompiled&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What's measured: &lt;code&gt;performance.now()&lt;/code&gt; from triggering an operation until a DOM condition holds (for example "there are 1,000 rows") and style + layout has been forced. &lt;strong&gt;It does not include painting.&lt;/strong&gt; Two warm-up iterations are discarded per operation, and rounds alternate framework order. Bundles are built with each framework's usual tool (esbuild, Vite, &lt;code&gt;ng build&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;Run on a shared container with no CPU throttling, so &lt;strong&gt;absolute numbers aren't comparable; positions within a run are&lt;/strong&gt;. The project uses a "within 10% is a tie" rule because differences of a few milliseconds are noise.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cd &lt;/span&gt;benchmarks
npm &lt;span class="nb"&gt;install
&lt;/span&gt;npm run build   &lt;span class="c"&gt;# builds the 5 apps&lt;/span&gt;
npm run bench   &lt;span class="c"&gt;# runs the suite -&amp;gt; benchmarks/results.json&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What it shows
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Precompiled AngeLite&lt;/th&gt;
&lt;th&gt;Best of the others&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;App size (gzip)&lt;/td&gt;
&lt;td&gt;6.1 KB (smallest)&lt;/td&gt;
&lt;td&gt;Preact 6.2 KB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Startup&lt;/td&gt;
&lt;td&gt;23.1 ms&lt;/td&gt;
&lt;td&gt;Preact 21.3 ms (technical tie: overlapping interquartile ranges)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Memory, 10,000 rows&lt;/td&gt;
&lt;td&gt;6.6 MB&lt;/td&gt;
&lt;td&gt;Angular 11.8 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;12 measurements in all&lt;/td&gt;
&lt;td&gt;lowest median in 7; within 10% in 4; behind in &lt;code&gt;remove&lt;/code&gt; (Preact, +16% in that run)&lt;/td&gt;
&lt;td&gt;n/a&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Overall score, as the geometric mean of time relative to the best per operation: &lt;strong&gt;1.03x for precompiled AngeLite; React at 1.55x&lt;/strong&gt;. Where AngeLite wins clearly: &lt;strong&gt;size, memory and list speed&lt;/strong&gt;. And in "no build" mode it's the only one with a small option: about 10 KB gzip, versus Vue's 63.7 KB with its compiler, and with no &lt;code&gt;eval&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Repeat-run honesty: &lt;code&gt;swap&lt;/code&gt; and &lt;code&gt;remove&lt;/code&gt; vary between runs. Two dedicated repetitions gave 4.70 ms vs Preact's 4.90, and another 4.75 vs 4.70. The project's own reading is "a tie with Preact and Angular", not a win.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it doesn't measure
&lt;/h2&gt;

&lt;p&gt;The page says it plainly: &lt;strong&gt;SSR, hydration, ecosystem, DevTools, accessibility, mobile performance and long-term memory behaviour&lt;/strong&gt; aren't covered. "Angular and React offer much more than AngeLite; this compares only the rendering core."&lt;/p&gt;

&lt;h2&gt;
  
  
  The comparison: where it loses
&lt;/h2&gt;

&lt;p&gt;Alongside the benchmark there's a feature-by-feature comparison table with a source for each cell:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;V&lt;/strong&gt;: verified by running the code or reading the installed package (React 19.3.1, Preact 10.29.8, Vue 3.5.44, Angular 20.3).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;D&lt;/strong&gt;: from official documentation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;G&lt;/strong&gt;: general knowledge that wasn't checked, flagged for review before quoting.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Of the table's rows, 16 are V, 2 are D and 19 are G, and the security rows are almost all V. Scoring features on a simple scale, AngeLite lands at 19.5 against 24.5 (Preact), 27 (Vue), 28.5 (React) and 33 (Angular). The document is explicit: "It is not a ranking, but the gap is real."&lt;/p&gt;

&lt;p&gt;Where it loses is &lt;strong&gt;everything around the core&lt;/strong&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Missing or immature&lt;/th&gt;
&lt;th&gt;Who has it&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Compile-time optimisations (hoisted static subtrees, folded constants, per-node flags)&lt;/td&gt;
&lt;td&gt;Vue, React Compiler, Angular AOT&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Concurrent rendering and transitions&lt;/td&gt;
&lt;td&gt;React (and the recommendation here is &lt;em&gt;not&lt;/em&gt; to copy it: the complexity is enormous for a lightweight framework)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Incremental hydration and adopting server nodes&lt;/td&gt;
&lt;td&gt;React, Vue, Angular&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Editor language service / template autocompletion&lt;/td&gt;
&lt;td&gt;Angular, Vue&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Browser DevTools extension, async validators, &lt;code&gt;FormBuilder&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Angular and others&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Years of security review and a community&lt;/td&gt;
&lt;td&gt;All of them&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Types and template checking&lt;/td&gt;
&lt;td&gt;Today the &lt;code&gt;.d.ts&lt;/code&gt; is checked, but templates are checked for elements, pipes and names, not types&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Limits of its own design (verified in the code)
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;One effect per view, not per binding.&lt;/strong&gt; In a component with hundreds of bindings and a hot signal this can cost; Vue and Solid are finer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Immutable lists.&lt;/strong&gt; Identity is compared; mutate-in-place-and-reassign-the-same-array doesn't update.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Measurement noise.&lt;/strong&gt; Shared container, no painting; differences under 10% aren't conclusive.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Why publish this?
&lt;/h2&gt;

&lt;p&gt;Three reasons:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;It makes the wins believable.&lt;/strong&gt; If a document says "we lose here, and here, and here", the claim "we're smallest" gets read as measured, not marketed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It prevents the wrong adoption.&lt;/strong&gt; The best outcome of an honest comparison is someone deciding React or Angular is the right call &lt;em&gt;for them&lt;/em&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It's a roadmap.&lt;/strong&gt; The gap table is the backlog, ordered by value (see the project's ROADMAP: hydration that adopts existing nodes and streaming rendering, route and list animations, &lt;code&gt;FormBuilder&lt;/code&gt; and async validators, editor autocompletion, i18n message extraction, template type-checking).&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Reproduce it, and argue
&lt;/h2&gt;

&lt;p&gt;Everything is in the repo: the apps, the harness, the raw JSON results and the security probe (&lt;code&gt;npm run probe:security&lt;/code&gt; in &lt;code&gt;benchmarks/&lt;/code&gt;). If a number is wrong, or an idiomatic implementation of your favourite framework is unfair, a PR is more useful than an argument.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;State the method, the noise and the non-goals &lt;em&gt;before&lt;/em&gt; the table.&lt;/li&gt;
&lt;li&gt;Use a tie rule (here, 10%) and say when a result flips between runs.&lt;/li&gt;
&lt;li&gt;Label every claim by how it was verified.&lt;/li&gt;
&lt;li&gt;Put the losses in the same document as the wins.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Next: the one feature that stresses all of this at once, server-side rendering, and why "the blueprint is data" makes it a natural next step.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>performance</category>
      <category>webdev</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Optional by Design: What Every Core Feature Costs in Bytes (Inside AngeLite, Part 5)</title>
      <dc:creator>Duvan Jamid Vargas</dc:creator>
      <pubDate>Mon, 26 Oct 2026 02:10:12 +0000</pubDate>
      <link>https://dev.to/duvanjamid/optional-by-design-what-every-core-feature-costs-in-bytes-inside-angelite-part-5-1d0g</link>
      <guid>https://dev.to/duvanjamid/optional-by-design-what-every-core-feature-costs-in-bytes-inside-angelite-part-5-1d0g</guid>
      <description>&lt;p&gt;"Optional modules" is easy to say and hard to make true. Most libraries that claim it still end up with a core that drags everything along: a registry that imports the router, a compiler that knows about animations, a component model that assumes forms. This post is about how AngeLite keeps "you only pay for what you import" literally true, and what each piece of the core costs in bytes.&lt;/p&gt;

&lt;h2&gt;
  
  
  The registry trick: &lt;code&gt;ops&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;A template compiles to a list of instructions (see post 2). Some instructions are core: text bindings, property bindings, events. Others are specialised: portals, &lt;code&gt;@defer&lt;/code&gt;, template refs, animations. If the runtime's main loop knew about all of them, every app would ship all of them.&lt;/p&gt;

&lt;p&gt;Instead, the runtime exposes a registry:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="cm"&gt;/**
 * Registry for optional template instructions (portals, @defer, template refs...).
 * A module adds its handler with `ops.portal = (op, node, view, scope, ctx) =&amp;gt; {...}`
 * when imported, so apps that never use it do not ship it.
 */&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ops&lt;/span&gt; &lt;span class="o"&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 module like &lt;code&gt;portal.js&lt;/code&gt; is 16 lines. Importing it registers its handler; not importing it means the handler never exists in your bundle. The runtime's dispatch for unknown ops is the only core cost (about 55 B), and it's what lets portals, &lt;code&gt;@defer&lt;/code&gt;, refs and animations weigh &lt;em&gt;nothing&lt;/em&gt; in an app that doesn't use them.&lt;br&gt;
&lt;/p&gt;

&lt;pre data-lang="mermaid"&gt;&lt;code&gt;flowchart LR
    T["Template instructions"] --&amp;gt; R["runtime.js (core)"]
    R -- "ops.portal?" --&amp;gt; P["portal.js (only if imported)"]
    R -- "ops.defer?" --&amp;gt; D["defer.js (only if imported)"]
    R -- "ops.ref?" --&amp;gt; F["refs.js (only if imported)"]
    R -- "detach hook" --&amp;gt; A["animate.js (only if imported)"]&lt;/code&gt;&lt;/pre&gt;



&lt;h2&gt;
  
  
  The module map
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Import&lt;/th&gt;
&lt;th&gt;For&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;@duvanjamid/angelite&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;signals, templates (&lt;code&gt;@if&lt;/code&gt;, &lt;code&gt;@for&lt;/code&gt;, &lt;code&gt;@defer&lt;/code&gt;), components, DI, pipes, router&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;.../runtime&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;the runtime only, for precompiled templates&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;.../forms&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;reactive forms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;.../http&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;HTTP client, interceptors and &lt;code&gt;resource()&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;.../i18n&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;languages, plurals and formats&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;.../ui&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;library of 33 accessible components&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;.../testing&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;testing utilities&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;.../devtools&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;component and signal inspector&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;.../hydrate&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;hydration of server-rendered HTML&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;.../tools/aot.mjs&lt;/code&gt;, &lt;code&gt;ssr.mjs&lt;/code&gt;, &lt;code&gt;vite.mjs&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;build-time compiler, server rendering, Vite plugin&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Each add-on is measured and budgeted (minified, gzip): forms about 0.9 KB, HTTP 1.3 KB, i18n 0.7 KB, DevTools 0.9 KB, hydrate 0.1 KB, testing 0.7 KB. The UI library is marked &lt;em&gt;pure&lt;/em&gt; per component, so a bundler keeps only the ones you import: an app that imports a single Button is about 7 KB gzip smaller than one that imports the whole library (the full UI bundle is about 11.8 KB gzip).&lt;/p&gt;

&lt;h2&gt;
  
  
  What the core actually pays for
&lt;/h2&gt;

&lt;p&gt;The precompiled table app went from 5,968 B to 6,223 B gzip across two waves of features that added a router with guards and lazy loading, DI with scopes, error boundaries, portals, named slots, &lt;code&gt;@defer&lt;/code&gt;, refs, forms, HTTP, i18n, animations, SSR, DevTools and testing utilities. That's &lt;strong&gt;+255 B, about 4%&lt;/strong&gt;. Where those bytes live:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Piece&lt;/th&gt;
&lt;th&gt;Approx.&lt;/th&gt;
&lt;th&gt;Why it's in the core&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Component context + &lt;code&gt;reportError&lt;/code&gt; (error boundaries)&lt;/td&gt;
&lt;td&gt;~110 B&lt;/td&gt;
&lt;td&gt;errors, scoped DI and slots share it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The &lt;code&gt;ops&lt;/code&gt; registry and its dispatch&lt;/td&gt;
&lt;td&gt;~55 B&lt;/td&gt;
&lt;td&gt;lets portals, &lt;code&gt;@defer&lt;/code&gt;, refs, animations weigh nothing if unused&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Named slots&lt;/td&gt;
&lt;td&gt;~40 B&lt;/td&gt;
&lt;td&gt;changes how content is projected&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;detach&lt;/code&gt; (animated exit) and &lt;code&gt;host.$c&lt;/code&gt; (DevTools)&lt;/td&gt;
&lt;td&gt;~28 B&lt;/td&gt;
&lt;td&gt;minimal hook for two optional modules&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Everything else doesn't show up in that app at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  Error boundaries, cheaply
&lt;/h2&gt;

&lt;p&gt;An example of a feature small enough for the core. Errors go to the nearest component (or the one wrapping the projected content) that defines &lt;code&gt;ngOnError(error, where)&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&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;reportError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;what&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(;&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;up&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;target&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;guard&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;self&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;target&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ngOnError&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ngOnError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;what&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nf"&gt;logError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ngOnError&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="k"&gt;return&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;logError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;what&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 walks the component context chain and falls back to &lt;code&gt;console.error&lt;/code&gt;. The same context carries scoped DI providers and slots, which is why the cost is shared. (An honest limit from the docs: boundaries don't catch errors thrown in the constructor or &lt;code&gt;ngOnInit&lt;/code&gt;.)&lt;/p&gt;

&lt;h2&gt;
  
  
  Dev warnings from the outside
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;enableDevWarnings()&lt;/code&gt; doesn't live in the core: it installs warnings by wrapping internals (&lt;code&gt;A.prop&lt;/code&gt;, &lt;code&gt;A.state&lt;/code&gt;, &lt;code&gt;ops&lt;/code&gt;) &lt;strong&gt;from the outside&lt;/strong&gt;. The production build contains none of those bytes. This is the same principle as the &lt;code&gt;ops&lt;/code&gt; registry: if something is only for development, it must be impossible to ship by accident.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lazy loading, two different things
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;@defer&lt;/code&gt; delays &lt;em&gt;rendering&lt;/em&gt; of a block until a trigger fires (&lt;code&gt;on idle&lt;/code&gt;, &lt;code&gt;on viewport&lt;/code&gt;, &lt;code&gt;on interaction&lt;/code&gt;, &lt;code&gt;on hover&lt;/code&gt;, &lt;code&gt;on timer(2s)&lt;/code&gt;, &lt;code&gt;on immediate&lt;/code&gt;, or &lt;code&gt;when expr&lt;/code&gt;), and you can combine triggers with &lt;code&gt;;&lt;/code&gt;. It does &lt;strong&gt;not&lt;/strong&gt; split code. To split the code of a whole screen into another file, use &lt;code&gt;load: () =&amp;gt; import('./screen.js')&lt;/code&gt; in a route. Conflating the two is a common source of "why didn't my bundle shrink?".&lt;/p&gt;

&lt;h2&gt;
  
  
  The architectural debt, written down
&lt;/h2&gt;

&lt;p&gt;The project's gap analysis lists real weaknesses of the current design, which is a good test of whether "optional modules" are being honest:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Module-level global state&lt;/strong&gt; (pipe registry, DI map, router, delegated events). You can't run two isolated apps on one page or render on a server without care. Direction: a &lt;code&gt;createApp()&lt;/code&gt; object holding registries, injector and router.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Coupling to the global DOM.&lt;/strong&gt; The runtime calls &lt;code&gt;document&lt;/code&gt; directly, which constrains other renderers. Direction: abstract the few DOM operations.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scope by prototype chain and text keys.&lt;/strong&gt; Fast and compact, but not statically analysable (no template type-checking), and &lt;code&gt;$event&lt;/code&gt; / &lt;code&gt;$index&lt;/code&gt; can collide with user fields.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Events delegated on &lt;code&gt;document&lt;/code&gt;.&lt;/strong&gt; Great for memory, but doesn't work inside Shadow DOM or iframes and &lt;code&gt;event.currentTarget&lt;/code&gt; isn't the element. Direction: delegate on each app's root.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Properties added to elements&lt;/strong&gt; (&lt;code&gt;$s&lt;/code&gt;, &lt;code&gt;$click&lt;/code&gt;) can collide with other libraries.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No stable-API contract or full types for templates yet.&lt;/strong&gt; That slows adoption more than any benchmark.&lt;/li&gt;
&lt;/ol&gt;

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

&lt;ul&gt;
&lt;li&gt;A tiny registry (&lt;code&gt;ops&lt;/code&gt;) is what turns "optional" from marketing into bytes.&lt;/li&gt;
&lt;li&gt;Put development-only code outside the core so it can't ship by accident.&lt;/li&gt;
&lt;li&gt;Publish the cost of every core feature, and publish the known design debt next to it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Next: how to benchmark and compare honestly, including where AngeLite loses.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>architecture</category>
      <category>performance</category>
      <category>opensource</category>
    </item>
    <item>
      <title>How a CI Performance Budget Keeps a 6 KB Framework From Growing (Inside AngeLite, Part 4)</title>
      <dc:creator>Duvan Jamid Vargas</dc:creator>
      <pubDate>Mon, 26 Oct 2026 02:10:00 +0000</pubDate>
      <link>https://dev.to/duvanjamid/how-a-ci-performance-budget-keeps-a-6-kb-framework-from-growing-inside-angelite-part-4-11pd</link>
      <guid>https://dev.to/duvanjamid/how-a-ci-performance-budget-keeps-a-6-kb-framework-from-growing-inside-angelite-part-4-11pd</guid>
      <description>&lt;p&gt;Every framework starts fast and gets slower. Features accumulate, each justified on its own, and one day the "lightweight" library isn't. AngeLite's defence is a rule: &lt;strong&gt;no feature lands if it makes the core slower, bigger or hungrier for memory&lt;/strong&gt;, and the rule is enforced by CI, not by good intentions.&lt;/p&gt;

&lt;p&gt;This post explains the four layers of that enforcement and why they're arranged from cheapest and exact to expensive and noisy.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem with measuring time in CI
&lt;/h2&gt;

&lt;p&gt;The obvious approach is "run the benchmark on every PR and fail if it got slower". It doesn't work. On a shared CI runner, time varies 10-20% from run to run, so a threshold tight enough to catch regressions is loose enough to flake constantly.&lt;/p&gt;

&lt;p&gt;What &lt;em&gt;is&lt;/em&gt; exact: &lt;strong&gt;how much work the code does&lt;/strong&gt; (DOM calls, effect runs) and &lt;strong&gt;how many bytes it ships&lt;/strong&gt;. So the budget leans on exact measurements first and uses noisy ones only as a backstop.&lt;/p&gt;

&lt;h2&gt;
  
  
  The four layers
&lt;/h2&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;What it measures&lt;/th&gt;
&lt;th&gt;Where it runs&lt;/th&gt;
&lt;th&gt;Noise&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1. Size&lt;/td&gt;
&lt;td&gt;gzip bytes of every bundle, and of what a user really downloads (the table app built with esbuild)&lt;/td&gt;
&lt;td&gt;CI, every PR&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2. Work&lt;/td&gt;
&lt;td&gt;DOM calls and effect runs per scenario (1,000 rows) against a stored budget&lt;/td&gt;
&lt;td&gt;CI (&lt;code&gt;npm test&lt;/code&gt;), every PR&lt;/td&gt;
&lt;td&gt;none: deterministic&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3. Relative time&lt;/td&gt;
&lt;td&gt;AngeLite vs Preact in the &lt;em&gt;same run&lt;/em&gt;; ratio compared with a baseline&lt;/td&gt;
&lt;td&gt;on demand, weekly, and PRs touching the core; doesn't block&lt;/td&gt;
&lt;td&gt;medium&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4. Full benchmark&lt;/td&gt;
&lt;td&gt;five frameworks, 12 measurements, memory and startup&lt;/td&gt;
&lt;td&gt;by hand before publishing&lt;/td&gt;
&lt;td&gt;high&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;



&lt;pre data-lang="mermaid"&gt;&lt;code&gt;flowchart TB
    PR[Pull request] --&amp;gt; L1["Layer 1: bundle size (exact)"]
    PR --&amp;gt; L2["Layer 2: work budget (exact)"]
    L1 --&amp;gt; Gate{"Within budget?"}
    L2 --&amp;gt; Gate
    Gate -- no --&amp;gt; Fail["CI fails and names the figure"]
    Gate -- yes --&amp;gt; Merge[Mergeable]
    PR -. "touches core" .-&amp;gt; L3["Layer 3: relative time vs Preact (noisy, non-blocking)"]
    Release[Before publishing] --&amp;gt; L4["Layer 4: full 5-framework benchmark (by hand)"]&lt;/code&gt;&lt;/pre&gt;



&lt;h3&gt;
  
  
  Layer 2 in detail: count the work, not the time
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;tests/perf-budget.test.mjs&lt;/code&gt; mounts the table app &lt;strong&gt;with every optional module loaded&lt;/strong&gt; (portal, &lt;code&gt;@defer&lt;/code&gt;, animations...) and counts, per scenario, calls to &lt;code&gt;createElement&lt;/code&gt;, &lt;code&gt;cloneNode&lt;/code&gt;, &lt;code&gt;appendChild&lt;/code&gt;, &lt;code&gt;insertBefore&lt;/code&gt;, &lt;code&gt;remove&lt;/code&gt;, &lt;code&gt;setAttribute&lt;/code&gt;, &lt;code&gt;classList.toggle&lt;/code&gt;, &lt;code&gt;textContent =&lt;/code&gt;, &lt;code&gt;addEventListener&lt;/code&gt; and so on, plus &lt;code&gt;Effect.run&lt;/code&gt; executions. It also asserts that the in-browser compiler and the precompiled template do &lt;strong&gt;exactly&lt;/strong&gt; the same work. Today, for 1,000 rows:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Scenario&lt;/th&gt;
&lt;th&gt;DOM calls&lt;/th&gt;
&lt;th&gt;Effects&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;create 1,000&lt;/td&gt;
&lt;td&gt;5,002&lt;/td&gt;
&lt;td&gt;1,001&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;replace 1,000&lt;/td&gt;
&lt;td&gt;5,004&lt;/td&gt;
&lt;td&gt;1,001&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;update every 10th&lt;/td&gt;
&lt;td&gt;100&lt;/td&gt;
&lt;td&gt;101&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;select a row&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;1,000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;swap 2 rows&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;remove a row&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;append 1,000&lt;/td&gt;
&lt;td&gt;5,000&lt;/td&gt;
&lt;td&gt;1,001&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;clear&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If a change raises any figure, the test fails and says which one. If it's intentional, you explain it in the PR and run &lt;code&gt;UPDATE_PERF=1 npm test&lt;/code&gt;, which rewrites the budget in the &lt;em&gt;same commit&lt;/em&gt;, so the change is visible in review. And if a figure &lt;strong&gt;drops&lt;/strong&gt;, the test says so too, so the improvement gets locked in.&lt;/p&gt;

&lt;p&gt;This catches things time can't: a feature that adds one DOM call per row won't move a noisy millisecond number, but it changes a count of 5,002 to 6,002 and fails immediately.&lt;/p&gt;

&lt;h3&gt;
  
  
  Layer 3: relative time, because absolute time lies
&lt;/h3&gt;

&lt;p&gt;Comparing AngeLite against Preact &lt;strong&gt;within the same run&lt;/strong&gt; cancels most machine noise. A single operation fails if its ratio gets worse by more than 35%, or the geometric mean of all operations by more than 10%. It doesn't block merges because it's still statistical, but it catches what the exact layers miss, such as a new property access in a hot loop.&lt;/p&gt;

&lt;h2&gt;
  
  
  Design rules that keep the budget achievable
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Optional module by default.&lt;/strong&gt; A feature not every app needs goes in its own file and hooks in through the &lt;code&gt;ops&lt;/code&gt; registry or a module imported only when used. Apps that don't import it don't pay in bytes or in time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't touch the hot paths without measuring.&lt;/strong&gt; They are &lt;code&gt;instantiate&lt;/code&gt;, &lt;code&gt;runView&lt;/code&gt;, &lt;code&gt;ForState.reconcile&lt;/code&gt; and &lt;code&gt;removeView&lt;/code&gt;. A new property on every row gets multiplied by thousands.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pay per component or template, not per row.&lt;/strong&gt; Compile-time work in the AOT is nearly free; per-row work isn't.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mind object shapes.&lt;/strong&gt; Adding fields to &lt;code&gt;View&lt;/code&gt;, to the context or to elements changes their shape and can slow everything that touches them. Optional fields are created only when used (DI providers do this: the map is created on the first &lt;code&gt;inject&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dev warnings don't live in the core.&lt;/strong&gt; &lt;code&gt;enableDevWarnings()&lt;/code&gt; wraps internals from the outside; the production build includes none of it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Every byte of the core is debated.&lt;/strong&gt; Size budgets live in scripts and only go up in a commit that explains why.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  What a budget buys you: an audit trail
&lt;/h2&gt;

&lt;p&gt;Because budgets are data in the repo, the cost of every core feature is public. Before the "wave 1" feature work the precompiled table app was 5,968 bytes gzip; today it's 6,223 (+255 B, +4%). Post 5 breaks down where those bytes went.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pending improvements, with data
&lt;/h2&gt;

&lt;p&gt;The budget also acts as a to-do list, ordered by what the numbers already show:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Selecting a row runs 1,000 effects&lt;/strong&gt; (every row reads &lt;code&gt;selected()&lt;/code&gt; and is re-evaluated to change a class on two rows). About 1.3 ms and tied with Preact and Angular, but it's the best candidate for per-binding effects.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;create1k&lt;/code&gt; does 5 DOM calls per row&lt;/strong&gt;, two of which (a text write of the initial value and a &lt;code&gt;classList.toggle&lt;/code&gt; to &lt;code&gt;false&lt;/code&gt;) can be skipped.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;remove&lt;/code&gt;/&lt;code&gt;swap&lt;/code&gt; hover around a tie with Preact.&lt;/strong&gt; A Chromium trace profile is still missing to separate noise from real cost before touching anything.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;ul&gt;
&lt;li&gt;Measure exact things (bytes, DOM calls, effect runs) on every PR; use noisy time only as a ratio and only as a backstop.&lt;/li&gt;
&lt;li&gt;Make budgets data in the repo, with an explicit update command, so every change is reviewed.&lt;/li&gt;
&lt;li&gt;A performance rule you can't break by accident is worth ten you remember to follow.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Next: the module system that makes "optional" real, and what each core feature costs.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>performance</category>
      <category>testing</category>
      <category>webdev</category>
    </item>
    <item>
      <title>My Framework Ranked Last in the Security Tests. Here Are the 6 Holes I Fixed (Inside AngeLite, Part 3)</title>
      <dc:creator>Duvan Jamid Vargas</dc:creator>
      <pubDate>Mon, 26 Oct 2026 02:09:49 +0000</pubDate>
      <link>https://dev.to/duvanjamid/my-framework-ranked-last-in-the-security-tests-here-are-the-6-holes-i-fixed-inside-angelite-part-54ao</link>
      <guid>https://dev.to/duvanjamid/my-framework-ranked-last-in-the-security-tests-here-are-the-6-holes-i-fixed-inside-angelite-part-54ao</guid>
      <description>&lt;p&gt;When I ran an attack-payload test suite against my own framework and against Preact, Vue, React and Angular, AngeLite came out as the &lt;strong&gt;worst&lt;/strong&gt; of the group. That was the useful moment: the security model had been assumed, not tested.&lt;/p&gt;

&lt;p&gt;This post is about the six gaps that test found, how each one was closed, and what AngeLite still does &lt;em&gt;not&lt;/em&gt; protect you from. If you build a UI framework, or use one, the threat model is probably the most reusable part.&lt;/p&gt;

&lt;h2&gt;
  
  
  The trust model
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Templates are code you wrote. The data you render is untrusted.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;AngeLite is designed so untrusted data can't become markup, script, or a dangerous URL by accident. Everything below follows from that sentence.&lt;/p&gt;

&lt;h2&gt;
  
  
  What was verified
&lt;/h2&gt;

&lt;p&gt;The project ships &lt;code&gt;benchmarks/security-probe.mjs&lt;/code&gt;, which runs the same hostile inputs against AngeLite, Preact, Vue and React (the Angular column comes from its documentation and the project's checks). A few of the probes and the results at the time of the audit, before and after the fixes:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Test&lt;/th&gt;
&lt;th&gt;AngeLite (before)&lt;/th&gt;
&lt;th&gt;AngeLite (after)&lt;/th&gt;
&lt;th&gt;Preact&lt;/th&gt;
&lt;th&gt;Vue&lt;/th&gt;
&lt;th&gt;React&lt;/th&gt;
&lt;th&gt;Angular&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Escaped text by default&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;href&lt;/code&gt; with &lt;code&gt;javascript:&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;let it through&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;unsafe:&lt;/code&gt; + warning&lt;/td&gt;
&lt;td&gt;lets it through&lt;/td&gt;
&lt;td&gt;lets it through&lt;/td&gt;
&lt;td&gt;neutralises it&lt;/td&gt;
&lt;td&gt;sanitises it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;onclick&lt;/code&gt; as text&lt;/td&gt;
&lt;td&gt;created the attribute&lt;/td&gt;
&lt;td&gt;compile error&lt;/td&gt;
&lt;td&gt;does not create it&lt;/td&gt;
&lt;td&gt;creates the attribute&lt;/td&gt;
&lt;td&gt;does not create it&lt;/td&gt;
&lt;td&gt;forbids it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;HTML in &lt;code&gt;innerHTML&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;unsanitised&lt;/td&gt;
&lt;td&gt;only with &lt;code&gt;trustHtml()&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;unsanitised&lt;/td&gt;
&lt;td&gt;unsanitised&lt;/td&gt;
&lt;td&gt;unsanitised&lt;/td&gt;
&lt;td&gt;sanitises&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Expressions reach &lt;code&gt;window&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;yes, via &lt;code&gt;$el&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;no, by no path&lt;/td&gt;
&lt;td&gt;n/a (JSX)&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;n/a (JSX)&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Works without &lt;code&gt;eval&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;runtime-only build only&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;AOT only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Trusted Types&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;yes (&lt;code&gt;angelite&lt;/code&gt; policy)&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;one reference&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;built in&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  The six gaps, and the fixes
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. &lt;code&gt;javascript:&lt;/code&gt; URLs.&lt;/strong&gt; &lt;code&gt;[href]&lt;/code&gt; and &lt;code&gt;href="{{ }}"&lt;/code&gt; accepted them. &lt;em&gt;Fix:&lt;/em&gt; an allow-list of schemes (&lt;code&gt;http&lt;/code&gt;, &lt;code&gt;https&lt;/code&gt;, &lt;code&gt;mailto&lt;/code&gt;, &lt;code&gt;tel&lt;/code&gt;, &lt;code&gt;sms&lt;/code&gt;, &lt;code&gt;ftp&lt;/code&gt;, relative) on &lt;code&gt;href&lt;/code&gt;, &lt;code&gt;src&lt;/code&gt;, &lt;code&gt;action&lt;/code&gt;, &lt;code&gt;formaction&lt;/code&gt;, &lt;code&gt;data&lt;/code&gt; and &lt;code&gt;xlink:href&lt;/code&gt;. It also ignores characters browsers drop inside a scheme (&lt;code&gt;java\tscript:&lt;/code&gt;). Everything else becomes &lt;code&gt;unsafe:&amp;lt;url&amp;gt;&lt;/code&gt; with a warning, and &lt;code&gt;trustUrl()&lt;/code&gt; is the explicit escape hatch. &lt;em&gt;Test:&lt;/em&gt; a matrix of 12 dangerous and 13 safe URLs across 7 contexts.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Event handler attributes from data.&lt;/strong&gt; &lt;code&gt;[attr.onclick]&lt;/code&gt; could create a handler. &lt;em&gt;Fix:&lt;/em&gt; a &lt;strong&gt;compile-time error&lt;/strong&gt; for &lt;code&gt;[on*]&lt;/code&gt;, &lt;code&gt;[attr.on*]&lt;/code&gt; and &lt;code&gt;on*="{{ }}"&lt;/code&gt;, in both the browser compiler and the AOT compiler, case-insensitively.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Unsanitised &lt;code&gt;[innerHTML]&lt;/code&gt;.&lt;/strong&gt; &lt;em&gt;Fix:&lt;/em&gt; it only renders markup wrapped in &lt;code&gt;trustHtml()&lt;/code&gt;; any other value is inserted as text and logs a warning. Sanitise first (for example with DOMPurify), then wrap.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. &lt;code&gt;[srcdoc]&lt;/code&gt; and &lt;code&gt;[outerHTML]&lt;/code&gt;.&lt;/strong&gt; &lt;em&gt;Fix:&lt;/em&gt; compile error.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. Reaching &lt;code&gt;window&lt;/code&gt; from an expression.&lt;/strong&gt; This was the sneaky one. A template could do &lt;code&gt;$el.ownerDocument.defaultView.fn()&lt;/code&gt;. &lt;em&gt;Fix:&lt;/em&gt; filter &lt;strong&gt;by value&lt;/strong&gt;, not by name. The global object and documents are never returned to a template, however they were reached (&lt;code&gt;$el.ownerDocument&lt;/code&gt;, &lt;code&gt;$event.view&lt;/code&gt;, &lt;code&gt;getRootNode()&lt;/code&gt;, &lt;code&gt;composedPath()[n]&lt;/code&gt;, a component field). DOM methods that turn text into markup or attributes (&lt;code&gt;insertAdjacentHTML&lt;/code&gt;, &lt;code&gt;setAttribute&lt;/code&gt;, &lt;code&gt;write&lt;/code&gt;) are also blocked by name. &lt;em&gt;Test:&lt;/em&gt; 8 attack paths plus events.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;6. Styles under a strict CSP.&lt;/strong&gt; The UI package injected &lt;code&gt;&amp;lt;style&amp;gt;&lt;/code&gt; without a nonce. &lt;em&gt;Fix:&lt;/em&gt; constructable stylesheets (&lt;code&gt;adoptedStyleSheets&lt;/code&gt;) need no nonce; where unavailable, it falls back to a &lt;code&gt;&amp;lt;style&amp;gt;&lt;/code&gt; carrying the nonce from &lt;code&gt;&amp;lt;meta name="csp-nonce"&amp;gt;&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Also added: compile errors for bindings and interpolation on &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt;, &lt;code&gt;&amp;lt;style&amp;gt;&lt;/code&gt;, &lt;code&gt;&amp;lt;base&amp;gt;&lt;/code&gt;, &lt;code&gt;&amp;lt;link&amp;gt;&lt;/code&gt; and &lt;code&gt;&amp;lt;meta&amp;gt;&lt;/code&gt;; a single Trusted Types policy named &lt;code&gt;angelite&lt;/code&gt; for templates and &lt;code&gt;trustHtml()&lt;/code&gt;; a &lt;code&gt;SECURITY.md&lt;/code&gt; documenting the trust model and how to report issues; CodeQL with extended queries, Dependabot and &lt;code&gt;npm audit&lt;/code&gt; in CI.&lt;br&gt;
&lt;/p&gt;

&lt;pre data-lang="mermaid"&gt;&lt;code&gt;flowchart LR
    D[Untrusted data] --&amp;gt; T{"Binding type"}
    T -- "{{ }} text" --&amp;gt; X[Inserted as text]
    T -- "href / src / ..." --&amp;gt; U{"Scheme allow-list"}
    U -- "allowed" --&amp;gt; OK[Applied]
    U -- "other" --&amp;gt; UN["unsafe:... + warning"]
    T -- "[innerHTML]" --&amp;gt; H{"trustHtml()?"}
    H -- "yes" --&amp;gt; OK
    H -- "no" --&amp;gt; X
    T -- "on* / srcdoc" --&amp;gt; CE[Compile error]&lt;/code&gt;&lt;/pre&gt;



&lt;h2&gt;
  
  
  Content-Security-Policy recipe
&lt;/h2&gt;

&lt;p&gt;Because nothing uses &lt;code&gt;eval&lt;/code&gt; or &lt;code&gt;new Function&lt;/code&gt; (not the browser build, not the precompiled runtime), AngeLite works with a strict policy:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self';
  require-trusted-types-for 'script'; trusted-types angelite
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What it does NOT protect you from
&lt;/h2&gt;

&lt;p&gt;This section matters more than the table above.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The expression sandbox is not a security boundary.&lt;/strong&gt; A template can still call methods of its own component and of DOM elements it can reach. It's defence in depth. &lt;strong&gt;Never compile a template that includes user input&lt;/strong&gt;, which is the same advice Vue gives about its runtime compiler. In production, precompile with &lt;code&gt;tools/aot.mjs&lt;/code&gt; so nothing is compiled in the browser.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;There's no built-in HTML sanitiser.&lt;/strong&gt; Angular has one; AngeLite requires &lt;code&gt;trustHtml()&lt;/code&gt; and recommends DOMPurify before wrapping. Writing a sanitiser of your own costs several KB and is easy to get wrong.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Escape hatches are yours.&lt;/strong&gt; &lt;code&gt;trustHtml()&lt;/code&gt; and &lt;code&gt;trustUrl()&lt;/code&gt; are explicit, like Angular's &lt;code&gt;bypassSecurityTrust*&lt;/code&gt;. Don't wrap user input.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No external audit, no track record.&lt;/strong&gt; The others have years of review and disclosure processes. This one has tests, CodeQL and a policy.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Lessons I'd reuse on any project
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Test against attack payloads, not intent.&lt;/strong&gt; The first run made the framework look worst in its peer group, which is exactly what you want to learn from a test.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prefer compile-time errors to runtime sanitisation&lt;/strong&gt; when a construct has no legitimate use (&lt;code&gt;on*&lt;/code&gt; bindings, &lt;code&gt;srcdoc&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Filter by value, not by path&lt;/strong&gt;, when the dangerous object can be reached many ways.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Document the non-goals.&lt;/strong&gt; A security policy that says what it doesn't cover is more trustworthy than one that claims everything.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Make the safe thing the default and the unsafe thing greppable&lt;/strong&gt;: &lt;code&gt;trustHtml(&lt;/code&gt; is easy to search for in review.&lt;/li&gt;
&lt;/ol&gt;

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

&lt;ul&gt;
&lt;li&gt;Treat templates as code and data as hostile; build everything from that.&lt;/li&gt;
&lt;li&gt;Six concrete holes were found by testing and closed with tests of their own.&lt;/li&gt;
&lt;li&gt;The remaining risk (untrusted templates, no sanitiser, no audit) is documented, not hidden.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Next: how a performance budget in CI keeps all of this from making the framework slower.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>security</category>
      <category>webdev</category>
      <category>opensource</category>
    </item>
    <item>
      <title>No Zone.js, No Virtual DOM: How Signals and a Microtask Update Only What Changed (Inside AngeLite, Part 2)</title>
      <dc:creator>Duvan Jamid Vargas</dc:creator>
      <pubDate>Mon, 26 Oct 2026 02:09:36 +0000</pubDate>
      <link>https://dev.to/duvanjamid/no-zonejs-no-virtual-dom-how-signals-and-a-microtask-update-only-what-changed-inside-angelite-5hf4</link>
      <guid>https://dev.to/duvanjamid/no-zonejs-no-virtual-dom-how-signals-and-a-microtask-update-only-what-changed-inside-angelite-5hf4</guid>
      <description>&lt;p&gt;In &lt;a href="https://github.com/duvanjamid/angelite" rel="noopener noreferrer"&gt;part 1&lt;/a&gt; I claimed AngeLite updates "only what changes" without Zone.js or a virtual DOM. This post shows the mechanism, using the actual source: a signals module of about 136 lines and a runtime of about 750.&lt;/p&gt;

&lt;h2&gt;
  
  
  The reactive core: three primitives
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Function&lt;/th&gt;
&lt;th&gt;What it is&lt;/th&gt;
&lt;th&gt;Usage&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;signal(value)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;a value you can change&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;s()&lt;/code&gt; reads, &lt;code&gt;s.set(x)&lt;/code&gt; writes, &lt;code&gt;s.update(v =&amp;gt; ...)&lt;/code&gt; transforms, &lt;code&gt;s.peek()&lt;/code&gt; reads without subscribing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;computed(fn)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;a derived value, recalculated only when something it reads changes&lt;/td&gt;
&lt;td&gt;&lt;code&gt;total()&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;effect(fn)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;code that runs now and whenever the signals it reads change&lt;/td&gt;
&lt;td&gt;returns a stop function&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Here is the heart of it, trimmed from &lt;code&gt;signals.js&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&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;signal&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="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;subs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;                       &lt;span class="c1"&gt;// allocated lazily, only if someone reads&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;read&lt;/span&gt; &lt;span class="o"&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;active&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;track&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;subs&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;subs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Set&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;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;read&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kd"&gt;set&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;next&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;is&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;next&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="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;// no change, no notification&lt;/span&gt;
    &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;next&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;subs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;subs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;forEach&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;notify&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="nx"&gt;read&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;update&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;fn&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;read&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;fn&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;read&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;peek&lt;/span&gt; &lt;span class="o"&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="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="nx"&gt;read&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;Three details are doing a lot of work:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;active&lt;/code&gt; is a module-level pointer&lt;/strong&gt; to the effect currently running. Reading a signal while an effect is active subscribes that effect. That's the whole dependency-tracking trick.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Subscriber sets are allocated lazily&lt;/strong&gt;, so a signal nobody reads costs almost nothing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;Object.is&lt;/code&gt; short-circuits writes&lt;/strong&gt; of the same value.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Batching in a microtask
&lt;/h2&gt;

&lt;p&gt;An &lt;code&gt;Effect&lt;/code&gt; re-runs not immediately but through a queue flushed in a microtask:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nf"&gt;notify&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;queued&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="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;queued&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;queue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&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;pending&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;pending&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nf"&gt;queueMicrotask&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;flush&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So three &lt;code&gt;set()&lt;/code&gt; calls in a row cause one re-run and one repaint. The flip side is documented behaviour you should know: right after a &lt;code&gt;set()&lt;/code&gt; the DOM doesn't reflect the change yet. &lt;code&gt;await Promise.resolve()&lt;/code&gt; (or &lt;code&gt;flush()&lt;/code&gt; in tests) is enough to wait.&lt;br&gt;
&lt;/p&gt;

&lt;pre data-lang="mermaid"&gt;&lt;code&gt;sequenceDiagram
    participant U as Your code
    participant S as signal
    participant Q as microtask queue
    participant E as view effect
    participant D as DOM
    U-&amp;gt;&amp;gt;S: count.set(1)
    S-&amp;gt;&amp;gt;Q: notify(effect) (queued once)
    U-&amp;gt;&amp;gt;S: count.set(2)
    S-&amp;gt;&amp;gt;Q: already queued, skip
    Note over U,Q: current task ends
    Q-&amp;gt;&amp;gt;E: flush()
    E-&amp;gt;&amp;gt;S: read count() (re-subscribes)
    E-&amp;gt;&amp;gt;D: write only values that changed&lt;/code&gt;&lt;/pre&gt;



&lt;h2&gt;
  
  
  Templates compile to instructions, not to a virtual tree
&lt;/h2&gt;

&lt;p&gt;A template is HTML with a few extensions:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Syntax&lt;/th&gt;
&lt;th&gt;What it does&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;{{ expr }}&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;interpolation, always text&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;[prop]="expr"&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;binds a DOM property, or an &lt;code&gt;input()&lt;/code&gt; for components&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;[class.x]="cond"&lt;/code&gt;, &lt;code&gt;[style.width.px]="n"&lt;/code&gt;, &lt;code&gt;[attr.x]="v"&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;class, style, attribute (null/false removes)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;(event)="statement"&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;listens to an event&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;[(ngModel)]="sig"&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;two-way binding with a writable signal&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;`{{ x \&lt;/td&gt;
&lt;td&gt;pipe:arg }}`&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The compiler turns each template &lt;strong&gt;once&lt;/strong&gt; into a &lt;em&gt;blueprint&lt;/em&gt;: an HTML fragment plus a list of instructions ("this text node depends on this expression", "this element has this event"). Creating a view is then:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;clone&lt;/strong&gt; the blueprint's fragment,&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;walk it once&lt;/strong&gt; applying the instructions,&lt;/li&gt;
&lt;li&gt;attach &lt;strong&gt;one lean effect per view&lt;/strong&gt; that re-evaluates the view's bindings and writes to the DOM &lt;em&gt;only when a value changed&lt;/em&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;No virtual tree is built or diffed. Expressions are evaluated by a small interpreter of our own (no &lt;code&gt;eval&lt;/code&gt;, no &lt;code&gt;new Function&lt;/code&gt;): literals, arithmetic, comparison and logical operators, ternary, &lt;code&gt;??&lt;/code&gt;, &lt;code&gt;.&lt;/code&gt;/&lt;code&gt;?.&lt;/code&gt;/&lt;code&gt;[ ]&lt;/code&gt;, arrays, and calls to component methods.&lt;/p&gt;

&lt;h3&gt;
  
  
  Events are delegated
&lt;/h3&gt;

&lt;p&gt;Handlers don't create a listener and closure per element. Events are &lt;strong&gt;delegated to &lt;code&gt;document&lt;/code&gt;&lt;/strong&gt; and stored as properties on the elements, so a table of 10,000 rows carries no per-row listeners. That's a big part of why memory stays low (about 6.6 MB for 10,000 rows in the project's benchmark versus 11.8 MB for the next lowest).&lt;/p&gt;

&lt;h2&gt;
  
  
  Control flow and keyed lists
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;@if&lt;/code&gt;, &lt;code&gt;@else if&lt;/code&gt;, &lt;code&gt;@else&lt;/code&gt;, &lt;code&gt;@for&lt;/code&gt; and &lt;code&gt;@empty&lt;/code&gt; use the Angular 17+ syntax:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;@for (t of todos(); track t.id) {
  &lt;span class="nt"&gt;&amp;lt;li&lt;/span&gt; &lt;span class="na"&gt;[class.done]=&lt;/span&gt;&lt;span class="s"&gt;"t.done"&lt;/span&gt; &lt;span class="na"&gt;(click)=&lt;/span&gt;&lt;span class="s"&gt;"toggle(t)"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;{{ t.title }}&lt;span class="nt"&gt;&amp;lt;/li&amp;gt;&lt;/span&gt;
} @empty {
  &lt;span class="nt"&gt;&amp;lt;li&amp;gt;&lt;/span&gt;Nothing to do&lt;span class="nt"&gt;&amp;lt;/li&amp;gt;&lt;/span&gt;
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;@for&lt;/code&gt; reuses DOM nodes according to the &lt;code&gt;track&lt;/code&gt; key, so reordering or updating a list moves existing elements instead of recreating them. When a list keeps the same keys in the same order, a fast path skips reconciliation (that optimisation took the "update every 10th row" scenario from about 9.8 ms to 7.9 ms in one benchmark run). Inside &lt;code&gt;@for&lt;/code&gt; you get &lt;code&gt;$index&lt;/code&gt;, &lt;code&gt;$count&lt;/code&gt;, &lt;code&gt;$first&lt;/code&gt;, &lt;code&gt;$last&lt;/code&gt;, &lt;code&gt;$even&lt;/code&gt; and &lt;code&gt;$odd&lt;/code&gt;. Always give a &lt;strong&gt;stable key&lt;/strong&gt;: without &lt;code&gt;track&lt;/code&gt;, the key is the item itself, which is fine for strings and numbers but not for objects you recreate.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rules that follow from the model
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Plain properties don't repaint.&lt;/strong&gt; &lt;code&gt;this.name = 'Ada'&lt;/code&gt; notifies nobody. State you want reflected must be a signal: &lt;code&gt;name = signal('Ada')&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Treat lists and objects as immutable.&lt;/strong&gt; Identity is compared, so mutate-in-place-and-reassign-the-same-array does nothing. Write &lt;code&gt;items.update(l =&amp;gt; [...l, next])&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Logic goes in the class.&lt;/strong&gt; Expressions don't support arrow functions, object literals, assignments or &lt;code&gt;new&lt;/code&gt;. If an expression needs more than one operator, give it a name (a method or a &lt;code&gt;computed&lt;/code&gt;).&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  A limit worth knowing
&lt;/h2&gt;

&lt;p&gt;There is &lt;strong&gt;one effect per view, not per binding&lt;/strong&gt;. When a signal a view reads changes, &lt;em&gt;every binding in that view&lt;/em&gt; is re-evaluated (but only written to the DOM if its value changed). For rows with few bindings that's the cheapest approach. For a component with hundreds of bindings and a signal that changes many times per second, finer-grained systems like Solid or Vue can win. The project's gap analysis lists this explicitly, along with its best-known cost: selecting a row re-runs 1,000 effects (about 1.3 ms, tied with Preact and Angular in the benchmark) and is the top candidate for a per-binding refinement.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;Signals plus an &lt;code&gt;active&lt;/code&gt; pointer give automatic dependency tracking in about 130 lines.&lt;/li&gt;
&lt;li&gt;Microtask batching makes writes cheap and cleanup simple, at the cost of async DOM visibility.&lt;/li&gt;
&lt;li&gt;Blueprints and delegated events replace the virtual DOM and per-element closures.&lt;/li&gt;
&lt;li&gt;One effect per view is a conscious trade-off, and it's documented with its costs.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Next: the security model, and the six holes I found in my own framework.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>webdev</category>
      <category>performance</category>
      <category>frontend</category>
    </item>
    <item>
      <title>A 6 KB Framework With Angular Syntax? Meet AngeLite (Inside AngeLite, Part 1)</title>
      <dc:creator>Duvan Jamid Vargas</dc:creator>
      <pubDate>Mon, 26 Oct 2026 02:09:25 +0000</pubDate>
      <link>https://dev.to/duvanjamid/a-6-kb-framework-with-angular-syntax-meet-angelite-inside-angelite-part-1-4a09</link>
      <guid>https://dev.to/duvanjamid/a-6-kb-framework-with-angular-syntax-meet-angelite-inside-angelite-part-1-4a09</guid>
      <description>&lt;p&gt;If you've written modern Angular, you probably miss a few things when you step outside it: the &lt;code&gt;@if&lt;/code&gt; / &lt;code&gt;@for&lt;/code&gt; control-flow syntax, templates with &lt;code&gt;[prop]&lt;/code&gt; and &lt;code&gt;(event)&lt;/code&gt;, components with inputs and outputs, dependency injection, a router. If you've written React or Preact, you may miss something else: not shipping a framework's worth of bytes to render a button.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;AngeLite&lt;/strong&gt; is my attempt to have both. It's a &lt;strong&gt;signals framework with Angular-style templates&lt;/strong&gt;, a core of about 6 KB gzip when templates are precompiled, no Zone.js, no virtual DOM, and a security model that assumes your data is hostile. This post is the "why"; the rest of the series is the "how".&lt;/p&gt;

&lt;p&gt;Repository: &lt;a href="https://github.com/duvanjamid/angelite" rel="noopener noreferrer"&gt;duvanjamid/angelite&lt;/a&gt; · Docs (English and Spanish): &lt;a href="https://duvanjamid.github.io/angelite-docs/" rel="noopener noreferrer"&gt;duvanjamid.github.io/angelite-docs&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The 30-second version
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&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;signal&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;component&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;mount&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;@duvanjamid/angelite&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Counter&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;count&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;signal&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="nf"&gt;inc&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;count&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nf"&gt;component&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;Counter&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;selector&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;app-counter&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;template&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`&amp;lt;button (click)="inc()"&amp;gt;Clicks: {{ count() }}&amp;lt;/button&amp;gt;`&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nf"&gt;mount&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;Counter&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No build step required either: include a &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt; tag and templates compile in the browser (about 10 KB gzip including the compiler). When the app grows, precompile templates and ship only the runtime (about 6 KB). The component code doesn't change.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it's built from
&lt;/h2&gt;

&lt;p&gt;Three ideas:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Signals&lt;/strong&gt; (&lt;code&gt;signal&lt;/code&gt;, &lt;code&gt;computed&lt;/code&gt;, &lt;code&gt;effect&lt;/code&gt;). State lives in reactive values; when one changes, only what depends on it updates. There is no global change detection.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Angular-style templates.&lt;/strong&gt; HTML with &lt;code&gt;{{ }}&lt;/code&gt;, &lt;code&gt;[prop]&lt;/code&gt;, &lt;code&gt;(event)&lt;/code&gt;, &lt;code&gt;@if&lt;/code&gt; and &lt;code&gt;@for&lt;/code&gt;, compiled once into a list of instructions. There is no virtual DOM: creating a view is cloning the HTML and applying the instructions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Optional modules.&lt;/strong&gt; The core is small; the router, forms, HTTP client, i18n, animations, SSR, DevTools and a 33-component accessible UI library are separate imports you pay for only if you use them.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Why not just use X?
&lt;/h2&gt;

&lt;p&gt;An honest answer, because the project's comparison page is honest about it too:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;If you need &lt;strong&gt;the ecosystem&lt;/strong&gt;, hiring pool and a decade of battle-testing, use React, Vue or Angular. AngeLite has no track record, no external security audit and a very small community.&lt;/li&gt;
&lt;li&gt;If you want &lt;strong&gt;Preact-sized output but Angular-shaped templates and signals&lt;/strong&gt;, that's the gap AngeLite aims at.&lt;/li&gt;
&lt;li&gt;If you want a &lt;strong&gt;small, secure-by-default&lt;/strong&gt; UI layer for widgets, dashboards or embedded apps, where bundle size and strict CSP matter more than ecosystem, that's the sweet spot.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The four commitments
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Small
&lt;/h3&gt;

&lt;p&gt;An app with precompiled templates weighs as much as the lightest framework in the project's benchmark: about 6.1 KB gzip for the table app versus Preact's 6.2 KB. What you don't import isn't downloaded: forms add about 0.9 KB, HTTP 1.3 KB, i18n 0.7 KB, and so on, each only for those who import them.&lt;/p&gt;

&lt;h3&gt;
  
  
  Fast without tricks
&lt;/h3&gt;

&lt;p&gt;Fine-grained signals, delegated events and keyed list reconciliation. Crucially, &lt;strong&gt;a performance budget runs in CI&lt;/strong&gt;, so a new feature can't quietly make the core slower (post 4).&lt;/p&gt;

&lt;h3&gt;
  
  
  Secure by default
&lt;/h3&gt;

&lt;p&gt;Interpolation is always text. URLs are filtered. &lt;code&gt;[innerHTML]&lt;/code&gt; requires an explicit &lt;code&gt;trustHtml()&lt;/code&gt;. Expressions can never reach &lt;code&gt;window&lt;/code&gt;. There's no &lt;code&gt;eval&lt;/code&gt; or &lt;code&gt;new Function&lt;/code&gt; anywhere, so it works under a strict CSP and Trusted Types (post 3).&lt;/p&gt;

&lt;h3&gt;
  
  
  Familiar
&lt;/h3&gt;

&lt;p&gt;If you've written Angular 17+, you already know the syntax: &lt;code&gt;@if&lt;/code&gt;, &lt;code&gt;@for&lt;/code&gt;, &lt;code&gt;[prop]&lt;/code&gt;, &lt;code&gt;(event)&lt;/code&gt;, &lt;code&gt;[(ngModel)]&lt;/code&gt;, pipes, inputs and outputs. What changes is state: you write to signals instead of mutating properties.&lt;/p&gt;

&lt;h2&gt;
  
  
  A short history (and a rewrite)
&lt;/h2&gt;

&lt;p&gt;AngeLite started as an earlier, larger iteration (decorators, directives, its own forms and router). The version described in this series is a deliberate rewrite around a small signals core. The old, unhardened code was removed from the tree (it stays in git history) and the new one was built under a rule: &lt;strong&gt;no feature lands if it breaks the performance budget&lt;/strong&gt;, and the security model is tested against real attack payloads rather than assumed.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this series covers
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Why AngeLite&lt;/strong&gt; (this post)&lt;/li&gt;
&lt;li&gt;Signals, templates and fine-grained updates, with no Zone.js and no virtual DOM&lt;/li&gt;
&lt;li&gt;Secure by default: six gaps I found in my own framework&lt;/li&gt;
&lt;li&gt;A performance budget that runs in CI&lt;/li&gt;
&lt;li&gt;Optional by design: the module system and what each core feature costs&lt;/li&gt;
&lt;li&gt;Honest benchmarks and an honest comparison&lt;/li&gt;
&lt;li&gt;SSR, hydration and "the blueprint is data"&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install&lt;/span&gt; @duvanjamid/angelite
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or drop in the script bundle from the docs and open an HTML file. The &lt;a href="https://duvanjamid.github.io/angelite-docs/" rel="noopener noreferrer"&gt;documentation&lt;/a&gt; has runnable examples, a component library page with live controls, the benchmark, and a feature-by-feature comparison (including what's missing).&lt;/p&gt;

&lt;p&gt;Next post: how a signal, a template and a microtask turn into DOM updates in about 750 lines of runtime.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>webdev</category>
      <category>opensource</category>
      <category>angular</category>
    </item>
    <item>
      <title>Production-Hardening a Java Framework: Resilience, JWT/JWKS, Native Image and an MCP Server (Building Ligero, Part 7)</title>
      <dc:creator>Duvan Jamid Vargas</dc:creator>
      <pubDate>Mon, 26 Oct 2026 02:09:00 +0000</pubDate>
      <link>https://dev.to/duvanjamid/production-hardening-a-java-framework-resilience-jwtjwks-native-image-and-an-mcp-server-59ep</link>
      <guid>https://dev.to/duvanjamid/production-hardening-a-java-framework-resilience-jwtjwks-native-image-and-an-mcp-server-59ep</guid>
      <description>&lt;p&gt;A framework core can stay tiny only if the things real services need live somewhere else. In Ligero that "somewhere else" is a set of small, optional, mostly dependency-free modules. This last post of the series tours the production-hardening ones: resilience, scheduling, events, authentication, native image, and a couple of newer experiments (an HTTP &lt;code&gt;QUERY&lt;/code&gt; method and an MCP server).&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;As with the rest of the series: Ligero is pre-1.0. Check the &lt;a href="https://github.com/ligero-framework/ligero/blob/main/CHANGELOG.md" rel="noopener noreferrer"&gt;changelog&lt;/a&gt; for what's released versus unreleased.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Resilience: &lt;code&gt;ligero-resilience&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Outbound calls fail. The module gives you three primitives with no dependencies:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;Retry&lt;/code&gt;&lt;/strong&gt; with fixed or exponential backoff,&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;Timeout&lt;/code&gt;&lt;/strong&gt;, which runs the call on a virtual thread and abandons overruns,&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;CircuitBreaker&lt;/code&gt;&lt;/strong&gt; with the usual closed, open, half-open states.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The virtual-thread timeout is a nice example of Java 21 paying off: you can run the work on a cheap thread, wait with a deadline, and walk away from a hung call without a bespoke thread-pool strategy.&lt;br&gt;
&lt;/p&gt;

&lt;pre data-lang="mermaid"&gt;&lt;code&gt;stateDiagram-v2
    [*] --&amp;gt; Closed
    Closed --&amp;gt; Open: failures exceed threshold
    Open --&amp;gt; HalfOpen: after cool-down
    HalfOpen --&amp;gt; Closed: probe succeeds
    HalfOpen --&amp;gt; Open: probe fails&lt;/code&gt;&lt;/pre&gt;



&lt;h2&gt;
  
  
  Scheduling and events
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;ligero-scheduler&lt;/code&gt;&lt;/strong&gt; is a small scheduler for background work: &lt;code&gt;fixedRate&lt;/code&gt;, &lt;code&gt;fixedDelay&lt;/code&gt;, &lt;code&gt;once&lt;/code&gt; and &lt;code&gt;dailyAt&lt;/code&gt;. Timing runs on a small daemon pool, but each task runs on its &lt;strong&gt;own virtual thread&lt;/strong&gt;, so a slow or throwing job never stalls the timer or cancels its own schedule.&lt;/p&gt;

&lt;p&gt;In &lt;code&gt;ligero-core&lt;/code&gt;, an in-process &lt;strong&gt;&lt;code&gt;Events&lt;/code&gt;&lt;/strong&gt; bus provides typed publish/subscribe (with supertype delivery and isolated handler failures), and &lt;code&gt;Ligero.onStart(...)&lt;/code&gt; / &lt;code&gt;onStop(...)&lt;/code&gt; hooks run when the server starts and stops. There's also a &lt;strong&gt;&lt;code&gt;Cache&amp;lt;K,V&amp;gt;&lt;/code&gt;&lt;/strong&gt; SPI with load-through helpers and TTLs, an in-process &lt;code&gt;InMemoryCache&lt;/code&gt;, and a distributed &lt;code&gt;RedisCache&lt;/code&gt; in &lt;code&gt;ligero-redis&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Authentication and authorization: &lt;code&gt;ligero-auth&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;JWT support is implemented with the JDK's own crypto (&lt;code&gt;javax.crypto&lt;/code&gt; / &lt;code&gt;java.security&lt;/code&gt;), so there are no external dependencies. Properties worth calling out:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;alg=none&lt;/code&gt; and algorithm-confusion tokens are rejected;&lt;/li&gt;
&lt;li&gt;signature comparison is constant-time;&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;exp&lt;/code&gt; and &lt;code&gt;nbf&lt;/code&gt; are enforced;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;RS256 and ES256&lt;/strong&gt; (sign and verify) sit alongside HS256, and &lt;code&gt;Jwks.parse(json, mapper)&lt;/code&gt; turns an OIDC provider's JWKS into JDK public keys by &lt;code&gt;kid&lt;/code&gt;, so a service can validate tokens issued by an external identity provider. The verifier &lt;strong&gt;pins the algorithm&lt;/strong&gt;, which defends against alg-confusion.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nc"&gt;Jwt&lt;/span&gt; &lt;span class="n"&gt;jwt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Jwt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;hs256&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;secret&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;JacksonBodyMapper&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;   &lt;span class="c1"&gt;// secret &amp;gt;= 32 bytes&lt;/span&gt;
&lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;jwt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;sign&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Map&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;of&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"sub"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"ada"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"roles"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;of&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"admin"&lt;/span&gt;&lt;span class="o"&gt;)),&lt;/span&gt; &lt;span class="nc"&gt;Duration&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;ofHours&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;

&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;use&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/api"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;JwtAuthMiddleware&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;of&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;jwt&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;
&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;get&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/api/admin/stats"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ctx&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;JwtAuthMiddleware&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;requireRole&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"admin"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;        &lt;span class="c1"&gt;// 403 without the role&lt;/span&gt;
    &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;attribute&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;JwtAuthMiddleware&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;USER_ATTRIBUTE&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="o"&gt;...&lt;/span&gt;
&lt;span class="o"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The module also covers stateless double-submit &lt;strong&gt;CSRF&lt;/strong&gt; (&lt;code&gt;XSRF-TOKEN&lt;/code&gt; cookie echoed in &lt;code&gt;X-XSRF-TOKEN&lt;/code&gt;), HMAC-signed-cookie &lt;strong&gt;sessions&lt;/strong&gt; with a pluggable &lt;code&gt;SessionStore&lt;/code&gt;, and Basic auth. Scale-out is a module away: &lt;code&gt;ligero-redis&lt;/code&gt; provides Redis-backed rate-limit and session stores shared across instances.&lt;/p&gt;

&lt;h2&gt;
  
  
  Data, your way
&lt;/h2&gt;

&lt;p&gt;Three independent modules so you take only what you want: &lt;code&gt;ligero-jdbc&lt;/code&gt; (SQL to records, transactions, no ORM, with optional HikariCP pooling via &lt;code&gt;DataSources.pooled(...)&lt;/code&gt;), &lt;code&gt;ligero-jpa&lt;/code&gt; (a thin Hibernate helper, bring your own provider), and &lt;code&gt;ligero-migrations&lt;/code&gt; (Flyway at startup). Request validation is annotation-based (&lt;code&gt;ligero-validation&lt;/code&gt;) and turns violations into automatic &lt;code&gt;400&lt;/code&gt;s.&lt;/p&gt;

&lt;h2&gt;
  
  
  Native image
&lt;/h2&gt;

&lt;p&gt;GraalVM &lt;code&gt;native-image&lt;/code&gt; does a closed-world analysis: anything reached by reflection, dynamic proxies or unregistered resources must be declared in metadata. Ligero's earlier design decisions pay off here:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Reflection-free core:&lt;/strong&gt; routing, the middleware pipeline and &lt;code&gt;Beans&lt;/code&gt; use no runtime reflection (an architecture test in the repo enforces this), so there is nothing to register.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Compile-time DI option:&lt;/strong&gt; &lt;code&gt;ligero-processor&lt;/code&gt; generates the &lt;code&gt;bind(...)&lt;/code&gt; wiring at compile time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;ServiceLoader&lt;/code&gt; over scanning:&lt;/strong&gt; engines, body mappers and template engines are discovered via &lt;code&gt;META-INF/services&lt;/code&gt;, which &lt;code&gt;ligero-core&lt;/code&gt; already registers for native.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Modules that are native-ready by construction include &lt;code&gt;ligero-core&lt;/code&gt;, &lt;code&gt;ligero-server-jdk&lt;/code&gt;, &lt;code&gt;ligero-scheduler&lt;/code&gt;, &lt;code&gt;ligero-resilience&lt;/code&gt;, &lt;code&gt;ligero-auth&lt;/code&gt; and &lt;code&gt;ligero-jdbc&lt;/code&gt;. The reflective adapters (Jackson, Hibernate) bring their own metadata needs, and the design note documents those boundaries rather than pretending they don't exist. The CLI itself ships as a native binary.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two experiments worth watching
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;HTTP &lt;code&gt;QUERY&lt;/code&gt;.&lt;/strong&gt; &lt;code&gt;app.query(path, handler)&lt;/code&gt; registers handlers for the IETF &lt;code&gt;QUERY&lt;/code&gt; method: a safe, idempotent method like &lt;code&gt;GET&lt;/code&gt; that carries a request body, for queries too large or structured for a URL. You read the body with &lt;code&gt;ctx.body(...)&lt;/code&gt; as for &lt;code&gt;POST&lt;/code&gt;. It's an early standard, so treat it as an experiment.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;MCP servers: &lt;code&gt;ligero-mcp&lt;/code&gt;.&lt;/strong&gt; The Model Context Protocol lets an LLM call &lt;em&gt;tools&lt;/em&gt; exposed by your app over JSON-RPC 2.0 (Streamable HTTP). With Ligero (illustrative sketch; see the docs for the exact schema type):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nc"&gt;McpServer&lt;/span&gt; &lt;span class="n"&gt;mcp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;McpServer&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;create&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"orders"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"1.0.0"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;tool&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"find_order"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"Look up an order by id"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;schema&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;args&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;lookup&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;

&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;use&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mcp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;http&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/mcp"&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It implements the &lt;code&gt;initialize&lt;/code&gt; handshake, &lt;code&gt;ping&lt;/code&gt;, &lt;code&gt;tools/list&lt;/code&gt; and &lt;code&gt;tools/call&lt;/code&gt;, surfaces tool failures as MCP error results, and, like the rest of the framework, is dependency-free and reflection-free. If you were going to expose existing service capabilities to an AI agent anyway, doing it in the same process as your authenticated, observable HTTP stack is a pragmatic place to start.&lt;/p&gt;

&lt;h2&gt;
  
  
  OpenAPI
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;ligero-openapi&lt;/code&gt; generates an OpenAPI 3 document from your routes, with an opt-in Swagger UI. &lt;code&gt;OpenApi.model(Class)&lt;/code&gt; generates &lt;code&gt;components/schemas&lt;/code&gt; from your &lt;strong&gt;record&lt;/strong&gt; types (scalars, enums, temporals, collections and nested records), using reflection only at document-build time, never on the request path.&lt;/p&gt;

&lt;h2&gt;
  
  
  The shape of the whole
&lt;/h2&gt;



&lt;pre data-lang="mermaid"&gt;&lt;code&gt;flowchart TB
    core["ligero-core (router, middleware, Beans, SPIs)"]
    core --&amp;gt; eng["engine: jdk | jetty"]
    core --&amp;gt; obs["observability: otel, micrometer"]
    core --&amp;gt; sec["auth, validation, redis"]
    core --&amp;gt; data["jdbc, jpa, migrations"]
    core --&amp;gt; ops["resilience, scheduler, cache, events"]
    core --&amp;gt; dev["devtools, openapi, mcp"]&lt;/code&gt;&lt;/pre&gt;



&lt;p&gt;Everything hangs off a small core through SPIs and middleware. That's the series in one picture.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd say after building it
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Small core, sharp edges removed.&lt;/strong&gt; The things that hurt in production (startup, hidden wiring, missing defaults) are the ones the core addresses directly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Honesty is a feature.&lt;/strong&gt; Docs state boundaries (what's native-ready, what needs metadata, what the JDK engine can't do), and the benchmark harness is public.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It's an open project.&lt;/strong&gt; If you want to try it, break it, or contribute, start at the &lt;a href="https://github.com/ligero-framework/ligero" rel="noopener noreferrer"&gt;repo&lt;/a&gt;, the &lt;a href="https://ligero-framework.github.io/ligero-docs/" rel="noopener noreferrer"&gt;docs&lt;/a&gt; and the &lt;a href="https://github.com/ligero-framework/ligero-examples" rel="noopener noreferrer"&gt;examples&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Thanks for reading the series. If there's a part you want to see go deeper, tell me in the comments.&lt;/p&gt;

</description>
      <category>java</category>
      <category>security</category>
      <category>graalvm</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Observability in a Lightweight Java Framework: Health, Metrics, Traces and OpenTelemetry (Building Ligero, Part 6)</title>
      <dc:creator>Duvan Jamid Vargas</dc:creator>
      <pubDate>Mon, 26 Oct 2026 02:08:49 +0000</pubDate>
      <link>https://dev.to/duvanjamid/observability-in-a-lightweight-java-framework-health-metrics-traces-and-opentelemetry-building-3ng</link>
      <guid>https://dev.to/duvanjamid/observability-in-a-lightweight-java-framework-health-metrics-traces-and-opentelemetry-building-3ng</guid>
      <description>&lt;p&gt;Devtools (previous post) is for development. In production you need something different: signals you can alert on, correlate across services and export to whatever backend your team already runs.&lt;/p&gt;

&lt;p&gt;Ligero's approach to observability is shaped by one rule: &lt;strong&gt;the framework defines vocabulary and SPIs; the client chooses the backend&lt;/strong&gt;. This post walks through the four pillars (health, metrics, logs, traces) and the design decisions behind each.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Health checks
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;use&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;HealthMiddleware&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;builder&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/health"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;check&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"db"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;dataSource&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getConnection&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;isValid&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt;
    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;GET /health&lt;/code&gt; returns &lt;code&gt;200 {"status":"UP","checks":{"db":"UP"}}&lt;/code&gt;, or &lt;code&gt;503&lt;/code&gt; with the failing checks marked &lt;code&gt;DOWN&lt;/code&gt;. Named checks matter operationally: the response tells the on-call engineer &lt;em&gt;which&lt;/em&gt; dependency is down, and a &lt;code&gt;503&lt;/code&gt; drives readiness in orchestrators.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Metrics: label by route pattern, not by path
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;MetricsMiddleware&lt;/code&gt; records method, &lt;strong&gt;matched route pattern&lt;/strong&gt;, status and duration through the &lt;code&gt;MetricsCollector&lt;/code&gt; SPI.&lt;/p&gt;

&lt;p&gt;The key word is &lt;em&gt;pattern&lt;/em&gt;: &lt;code&gt;/users/{id}&lt;/code&gt;, not &lt;code&gt;/users/48213&lt;/code&gt;. This is the single most important decision in HTTP metrics. Using raw paths as a label creates one time series per user id, which is unbounded cardinality, which is how you take down a metrics backend. The router already knows which pattern matched, so the middleware gets a bounded label for free.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="c1"&gt;// dependency-free; expose however you like&lt;/span&gt;
&lt;span class="nc"&gt;InMemoryMetricsCollector&lt;/span&gt; &lt;span class="n"&gt;metrics&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;InMemoryMetricsCollector&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;use&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;MetricsMiddleware&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;metrics&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;
&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;get&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/metrics"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ctx&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;json&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;metrics&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;snapshot&lt;/span&gt;&lt;span class="o"&gt;()));&lt;/span&gt;

&lt;span class="c1"&gt;// or Micrometer (ligero-metrics-micrometer) for Prometheus/Datadog/...&lt;/span&gt;
&lt;span class="nc"&gt;MeterRegistry&lt;/span&gt; &lt;span class="n"&gt;registry&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;PrometheusMeterRegistry&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;PrometheusConfig&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;DEFAULT&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;use&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;MetricsMiddleware&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;MicrometerMetricsCollector&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;registry&lt;/span&gt;&lt;span class="o"&gt;)));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two adapters, one SPI: the dependency-free in-memory collector for small services and tests, Micrometer for everything else.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Logs: structured, and joined to traces
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;use&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;RequestLoggingMiddleware&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;     &lt;span class="c1"&gt;// GET /users -&amp;gt; 200 (431 µs)&lt;/span&gt;
&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;use&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;RequestLoggingMiddleware&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;json&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;    &lt;span class="c1"&gt;// {"method":"GET","path":"/users","status":200,...}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The JSON form includes the request id when &lt;code&gt;RequestIdMiddleware&lt;/code&gt; runs first. Logs are only useful in an incident if you can pivot from a log line to the trace and back, so the next piece matters.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Traces: W3C context and a vendor-neutral SPI
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;RequestIdMiddleware&lt;/code&gt; handles &lt;code&gt;X-Request-Id&lt;/code&gt; passthrough/generation and parses the W3C &lt;strong&gt;&lt;code&gt;traceparent&lt;/code&gt;&lt;/strong&gt; header. Even with no tracing backend installed, it exposes the &lt;code&gt;traceId&lt;/code&gt; as a context attribute, so your logs are correlatable across services.&lt;/p&gt;

&lt;p&gt;For real tracing, the core defines a &lt;code&gt;Tracer&lt;/code&gt; SPI and a &lt;code&gt;TracingMiddleware&lt;/code&gt;; the first adapter is &lt;strong&gt;&lt;code&gt;ligero-otel&lt;/code&gt;&lt;/strong&gt; (OpenTelemetry). Datadog, New Relic, Jaeger and friends work through the OTel SDK or agent exporters, or you can implement the SPI directly.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="c1"&gt;// With the OTel Java agent (or SDK autoconfigure), ServiceLoader finds the tracer:&lt;/span&gt;
&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;use&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;TracingMiddleware&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;fromServiceLoader&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;

&lt;span class="c1"&gt;// Or wire an explicit SDK:&lt;/span&gt;
&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;use&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;TracingMiddleware&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;OtelTracer&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;openTelemetrySdk&lt;/span&gt;&lt;span class="o"&gt;)));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Per request you get a &lt;code&gt;SERVER&lt;/code&gt; span that:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;joins the incoming W3C &lt;code&gt;traceparent&lt;/code&gt;, so traces continue across services;&lt;/li&gt;
&lt;li&gt;is &lt;strong&gt;named by the matched route pattern&lt;/strong&gt; (&lt;code&gt;GET /users/{id}&lt;/code&gt;), again bounded cardinality;&lt;/li&gt;
&lt;li&gt;carries &lt;code&gt;http.request.method&lt;/code&gt;, &lt;code&gt;url.path&lt;/code&gt;, &lt;code&gt;http.route&lt;/code&gt; and &lt;code&gt;http.response.status_code&lt;/code&gt;;&lt;/li&gt;
&lt;li&gt;records exceptions and marks the span &lt;code&gt;ERROR&lt;/code&gt; on failures;&lt;/li&gt;
&lt;li&gt;exposes its trace id as the &lt;code&gt;traceId&lt;/code&gt; context attribute for log correlation.
&lt;/li&gt;
&lt;/ul&gt;

&lt;pre data-lang="mermaid"&gt;&lt;code&gt;flowchart LR
    A[Service A] -- traceparent --&amp;gt; L[Ligero service]
    L -- "SERVER span per route pattern" --&amp;gt; O[OTel SDK / agent]
    O --&amp;gt; V[(Backend of your choice)]
    L -- JSON access log + traceId --&amp;gt; G[(Log pipeline)]
    L -- route-pattern metrics --&amp;gt; P[(Metrics backend)]&lt;/code&gt;&lt;/pre&gt;



&lt;h2&gt;
  
  
  Why SPIs instead of picking a vendor
&lt;/h2&gt;

&lt;p&gt;Three reasons, all about who pays the cost of change:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Teams already have a stack.&lt;/strong&gt; Hard-wiring one vendor means every adopter either accepts it or forks.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;OpenTelemetry is the lingua franca.&lt;/strong&gt; One adapter covers most backends, and the SPI covers the rest.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The core stays dependency-free.&lt;/strong&gt; &lt;code&gt;ligero-core&lt;/code&gt; depends only on &lt;code&gt;slf4j-api&lt;/code&gt;; tracing and metrics libraries arrive only if you add the module.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  A practical checklist
&lt;/h2&gt;

&lt;p&gt;If you operate services like this, a few habits pay off regardless of framework:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Bound every label.&lt;/strong&gt; Route patterns, not raw paths; status classes if you need to reduce further.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Propagate context everywhere.&lt;/strong&gt; &lt;code&gt;traceparent&lt;/code&gt; in, &lt;code&gt;traceparent&lt;/code&gt; out on every outbound call.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Log the trace id.&lt;/strong&gt; Make "log line to trace" one click.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Separate liveness from readiness.&lt;/strong&gt; A &lt;code&gt;/health&lt;/code&gt; that checks the database tells an orchestrator to &lt;em&gt;stop sending traffic&lt;/em&gt;, which is not always the same as &lt;em&gt;restart me&lt;/em&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Alert on symptoms.&lt;/strong&gt; Latency and error rate per route pattern beat host-level resource graphs.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;ul&gt;
&lt;li&gt;Health, metrics, logs and traces are built into the middleware pipeline, not bolted on.&lt;/li&gt;
&lt;li&gt;Route-pattern labels give bounded cardinality for both metrics and span names.&lt;/li&gt;
&lt;li&gt;The framework owns the vocabulary (SPIs); you own the backend (OTel, Micrometer or your own).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Next: what "production-ready" means beyond observability, covering resilience, auth, scheduling, native image and an MCP server.&lt;/p&gt;

</description>
      <category>java</category>
      <category>observability</category>
      <category>opentelemetry</category>
      <category>devops</category>
    </item>
    <item>
      <title>I Built a Dashboard That Lights Up Your Java Beans in Real Time (Building Ligero, Part 5)</title>
      <dc:creator>Duvan Jamid Vargas</dc:creator>
      <pubDate>Mon, 26 Oct 2026 02:08:38 +0000</pubDate>
      <link>https://dev.to/duvanjamid/i-built-a-dashboard-that-lights-up-your-java-beans-in-real-time-building-ligero-part-5-533e</link>
      <guid>https://dev.to/duvanjamid/i-built-a-dashboard-that-lights-up-your-java-beans-in-real-time-building-ligero-part-5-533e</guid>
      <description>&lt;p&gt;Layered architectures have a visibility problem. You draw clean boxes on a whiteboard (controller, service, repository), but at runtime all you have is logs and a debugger. Which beans did this request touch? What went into the service layer, and what came out? How long did each layer take?&lt;/p&gt;

&lt;p&gt;Ligero ships a development-time answer: &lt;strong&gt;&lt;code&gt;ligero-devtools&lt;/code&gt;&lt;/strong&gt;, a dashboard served at &lt;code&gt;/ligero/dev&lt;/code&gt; where you run a request, watch it flow through your beans, and inspect the JSON going in and out of every layer, without a debugger, log statements, or Postman.&lt;/p&gt;

&lt;p&gt;Think of it as "Swagger's try-it-out", plus a live dependency graph, plus a per-request tracer, in one self-contained page (no CDN assets) shipped inside your jar.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you get
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Without devtools&lt;/th&gt;
&lt;th&gt;With devtools&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Fire requests from curl or Postman in another window&lt;/td&gt;
&lt;td&gt;Run any route straight from the dashboard&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Read logs to guess which beans ran&lt;/td&gt;
&lt;td&gt;See the exact path light up in the graph&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Add &lt;code&gt;System.out.println&lt;/code&gt; to see a layer's args/result&lt;/td&gt;
&lt;td&gt;Click a node: arguments and return value shown as JSON&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Wonder how long each layer took&lt;/td&gt;
&lt;td&gt;Every call carries its timing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Keep a mental model of your wiring&lt;/td&gt;
&lt;td&gt;The dependency graph is drawn for you, coloured by stereotype&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

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



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight groovy"&gt;&lt;code&gt;&lt;span class="c1"&gt;// development only&lt;/span&gt;
&lt;span class="n"&gt;implementation&lt;/span&gt; &lt;span class="s1"&gt;'com.ligeroframework:ligero-devtools:&amp;lt;version&amp;gt;'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nc"&gt;Devtools&lt;/span&gt; &lt;span class="n"&gt;devtools&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Devtools&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;create&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;

&lt;span class="nc"&gt;Beans&lt;/span&gt; &lt;span class="n"&gt;beans&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Beans&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;builder&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;bind&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;NameRepo&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;class&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt;     &lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;JdbcNameRepo&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;get&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;DataSource&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;class&lt;/span&gt;&lt;span class="o"&gt;)))&lt;/span&gt;
    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;bind&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;GreetService&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;class&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;DefaultGreetService&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;get&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;NameRepo&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;class&lt;/span&gt;&lt;span class="o"&gt;)))&lt;/span&gt;
    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;instrument&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;devtools&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;recorder&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt;   &lt;span class="c1"&gt;// spies interface-typed beans&lt;/span&gt;
    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;start&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;

&lt;span class="nc"&gt;Ligero&lt;/span&gt; &lt;span class="n"&gt;app&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Ligero&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;create&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;beans&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;beans&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;devtools&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;install&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;beans&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;          &lt;span class="c1"&gt;// mounts /ligero/dev&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Open &lt;code&gt;http://localhost:8080/ligero/dev&lt;/code&gt;, pick a route, fill in path params, query and a JSON body, and send it.&lt;/p&gt;

&lt;h2&gt;
  
  
  How the spying works (no agents, no bytecode)
&lt;/h2&gt;

&lt;p&gt;This is the part I like, because it's built on the design decisions from the previous posts rather than on tricks.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The container exposes an &lt;strong&gt;instrumentation hook&lt;/strong&gt;: &lt;code&gt;Beans.Builder.instrument(BeanDecorator)&lt;/code&gt; can wrap every bean as it's created.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;devtools.recorder()&lt;/code&gt; is such a decorator. Beans bound &lt;strong&gt;as interfaces&lt;/strong&gt; get wrapped in a &lt;strong&gt;JDK dynamic proxy&lt;/strong&gt;: no bytecode generation, no Java agent.&lt;/li&gt;
&lt;li&gt;Beans bound as &lt;strong&gt;concrete classes&lt;/strong&gt; can't be proxied by the JDK. They pass through untouched and the dashboard marks them "not traceable". Bind by interface if you want them traced (which is the architecture Ligero promotes anyway).&lt;/li&gt;
&lt;li&gt;A middleware opens &lt;strong&gt;one trace per request&lt;/strong&gt;, recording the matched route, request inputs and response body, and keeps the last 100 traces.&lt;/li&gt;
&lt;li&gt;The dashboard's own endpoints are never traced.
&lt;/li&gt;
&lt;/ol&gt;

&lt;pre data-lang="mermaid"&gt;&lt;code&gt;sequenceDiagram
    participant B as Browser (dashboard)
    participant M as TraceMiddleware
    participant C as Controller (proxy)
    participant S as Service (proxy)
    participant R as Repository (proxy)
    B-&amp;gt;&amp;gt;M: POST /api/orders (X-Ligero-Dev: trace-id)
    M-&amp;gt;&amp;gt;C: handle
    C-&amp;gt;&amp;gt;S: placeOrder(args)
    S-&amp;gt;&amp;gt;R: save(args)
    R--&amp;gt;&amp;gt;S: result + timing
    S--&amp;gt;&amp;gt;C: result + timing
    C--&amp;gt;&amp;gt;M: response
    M--&amp;gt;&amp;gt;B: response + trace (SSE)&lt;/code&gt;&lt;/pre&gt;



&lt;p&gt;Each proxy records arguments, return value and elapsed time. A small, dependency-free reflective serializer (&lt;code&gt;JsonValue&lt;/code&gt;) renders any layer's arguments and results as real JSON (records, beans, collections, maps) with depth and size caps and cycle guards.&lt;/p&gt;

&lt;h2&gt;
  
  
  Request correlation
&lt;/h2&gt;

&lt;p&gt;When the dashboard fires a request it stamps an &lt;code&gt;X-Ligero-Dev&lt;/code&gt; header. The trace middleware uses it as the trace id, so the browser can match the trace it receives over the live &lt;strong&gt;SSE stream&lt;/strong&gt; to the request it sent. Outside the dashboard, the middleware reuses the request id when the request-id middleware is on. It's a good example of keeping a feature useful without adding a new protocol.&lt;/p&gt;

&lt;h2&gt;
  
  
  The graph is the real graph
&lt;/h2&gt;

&lt;p&gt;The dependency graph isn't inferred from annotations or file layout. &lt;code&gt;beans.graph()&lt;/code&gt; returns nodes tagged by stereotype and edges captured from the &lt;strong&gt;real resolution order&lt;/strong&gt; at startup. Selecting a request highlights the transitive closure of beans it &lt;em&gt;can&lt;/em&gt; reach (its dependency graph), and sending it overlays the &lt;em&gt;actual execution path&lt;/em&gt; in the HTTP method's colour. Branches a run didn't take stay visible as dependencies but aren't part of the execution. Clicking a node shows each invocation with arguments, return value and timing.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;commerce-devtools&lt;/code&gt; example is a good stress test: an e-commerce backend with three modules (catalog, customers, orders) and around 15 beans with cross-module dependencies. Run it, pick &lt;code&gt;POST /api/orders&lt;/code&gt;, and watch about 21 calls light up the path across services and repositories.&lt;/p&gt;

&lt;h2&gt;
  
  
  Endpoints
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Endpoint&lt;/th&gt;
&lt;th&gt;What&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;GET /ligero/dev&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;the dashboard (self-contained HTML)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;GET /ligero/dev/api/routes&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;registered routes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;GET /ligero/dev/api/graph&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;bean graph JSON (nodes, edges, unspied list)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;GET /ligero/dev/api/requests&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;last 100 traces, most recent first&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;GET /ligero/dev/api/stream&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;SSE stream of completed traces&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Never in production
&lt;/h2&gt;

&lt;p&gt;Devtools exposes the arguments and return values of your beans. Two switches keep it out of production:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Classpath (recommended):&lt;/strong&gt; keep the dependency out of production builds. Zero overhead, because nothing is there. The CLI can scaffold a project without it (&lt;code&gt;--devtools false&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Environment:&lt;/strong&gt; &lt;code&gt;LIGERO_DEVTOOLS=false&lt;/code&gt; turns &lt;code&gt;install()&lt;/code&gt; into a no-op without a code change.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Why this was cheap to build
&lt;/h2&gt;

&lt;p&gt;Notice what devtools did &lt;em&gt;not&lt;/em&gt; need: a classpath scanner, an agent, or annotation processing. Because wiring is explicit (post 3) and layers talk through interfaces (post 1), the framework already had the two things a tracer needs: a place to wrap objects and a graph of who depends on whom. Good architecture makes tooling cheap.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;Instrumentation hooks in the container plus interface-typed beans let you trace every layer with plain JDK proxies.&lt;/li&gt;
&lt;li&gt;Draw the real dependency graph from real resolution order, not from conventions.&lt;/li&gt;
&lt;li&gt;Make dev tooling impossible to ship by accident: keep it off the production classpath.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Next: observability for production, where we trade the dashboard for health checks, route-pattern metrics, trace context and OpenTelemetry.&lt;/p&gt;

</description>
      <category>java</category>
      <category>devtools</category>
      <category>architecture</category>
      <category>opensource</category>
    </item>
  </channel>
</rss>
