<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Eugene Zimin</title>
    <description>The latest articles on DEV Community by Eugene Zimin (@eugene-zimin).</description>
    <link>https://dev.to/eugene-zimin</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F1237860%2F9be1289e-94c0-47ce-b8c9-b7385ee2ab47.png</url>
      <title>DEV Community: Eugene Zimin</title>
      <link>https://dev.to/eugene-zimin</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/eugene-zimin"/>
    <language>en</language>
    <item>
      <title>Removing Microsoft's Bloatware</title>
      <dc:creator>Eugene Zimin</dc:creator>
      <pubDate>Sun, 25 Oct 2026 11:16:46 +0000</pubDate>
      <link>https://dev.to/eugene-zimin/removing-microsofts-bloatware-12g7</link>
      <guid>https://dev.to/eugene-zimin/removing-microsofts-bloatware-12g7</guid>
      <description>&lt;h2&gt;
  
  
  The updater that outlived its apps
&lt;/h2&gt;

&lt;p&gt;About a year ago I deleted Office from my Mac the way every Mac user is taught to delete anything, by dragging it to the Trash, and two weeks later Microsoft AutoUpdate opened a window to offer me updates for the applications I no longer had. The moment was funny, but it pointed at something real, because the Trash only works as an uninstaller when an app keeps all of itself inside its own bundle, and Microsoft's software does not. What looks like a handful of applications in &lt;code&gt;/Applications&lt;/code&gt; is the visible top of a platform that installs its own updater, registers background jobs with the system, and parks a helper tool that runs as root, and none of those pieces live inside the apps you can see.&lt;/p&gt;

&lt;p&gt;You can check this on your own machine with two commands, and Figure 1 shows what they printed on mine. The first lists Microsoft's entries in the three system folders where software asks macOS to run something on its behalf, and the second opens one of those entries to show when it runs.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fd8tluh8lcg7ul62r368v.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fd8tluh8lcg7ul62r368v.png" alt="Figure 1. The AutoUpdate agent, daemon and root helper in /Library" width="799" height="290"&gt;&lt;/a&gt;&lt;em&gt;Figure 1. The AutoUpdate agent, its daemon and its root helper sit in /Library, outside every app bundle, and the agent is set to start at login and again every 7,200 seconds.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Each of the three files does a different job. &lt;code&gt;com.microsoft.update.agent.plist&lt;/code&gt; is a launch agent, a small property list that tells launchd, the process that starts and supervises background work on macOS, to run Microsoft Update Assistant whenever someone logs in (&lt;code&gt;RunAtLoad&lt;/code&gt;) and every two hours after that (a &lt;code&gt;StartInterval&lt;/code&gt; of 7,200 seconds). The plist in &lt;code&gt;LaunchDaemons&lt;/code&gt; describes &lt;code&gt;com.microsoft.autoupdate.helper&lt;/code&gt;, and because launch daemons run in the system domain rather than inside a user's session, the binary it points to in &lt;code&gt;PrivilegedHelperTools&lt;/code&gt; runs as root and offers services with names like &lt;code&gt;install&lt;/code&gt;, &lt;code&gt;clone&lt;/code&gt; and &lt;code&gt;cleanup&lt;/code&gt;. That arrangement is how AutoUpdate replaces apps in &lt;code&gt;/Applications&lt;/code&gt; without asking for your password each time, and it is also why deleting Word does nothing to it. The helper was installed once, by a package you approved, it has no window and no menu, and nothing in the Finder will ever show it to you.&lt;/p&gt;

&lt;p&gt;I ended up writing a script, &lt;a href="https://github.com/eugenezimin/development-utilities/tree/main/ms-uninstall" rel="noopener noreferrer"&gt;&lt;code&gt;ms-uninstall.sh&lt;/code&gt;&lt;/a&gt;, to find and remove all of this properly. The deleting turned out to be the easy part, since it comes down to &lt;code&gt;mv&lt;/code&gt; or &lt;code&gt;rm&lt;/code&gt;, and almost all of the work went into two other questions, namely which files belong to which product, and how to avoid deleting anything that a product you decided to keep still needs. The rest of this article follows the script in the order in which it answers them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding Microsoft's leftovers
&lt;/h2&gt;

&lt;p&gt;The obvious first move is to search &lt;code&gt;/Applications&lt;/code&gt; for anything whose name starts with "Microsoft", and it fails almost at once. Remote Desktop is now called Windows App, Teams has shipped as both "Microsoft Teams classic" and plain "Microsoft Teams", and OneDrive has never carried the company's name in its title. Names belong to marketing, and marketing renames things.&lt;/p&gt;

&lt;p&gt;What does not change is the bundle identifier, a reverse-DNS string such as &lt;code&gt;com.microsoft.Word&lt;/code&gt; that every app declares in its &lt;code&gt;Info.plist&lt;/code&gt; under the key &lt;code&gt;CFBundleIdentifier&lt;/code&gt;. macOS keys nearly everything about an app on that string, from the location of its sandbox to the name of its preferences file, which makes it the one name worth matching on. Figure 2 sets the two kinds of name side by side for the apps that make the point most clearly.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7bj10tdn9i4dhbc6vnd7.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7bj10tdn9i4dhbc6vnd7.png" alt="Figure 2. Finder names versus bundle identifiers" width="800" height="336"&gt;&lt;/a&gt;&lt;em&gt;Figure 2. Finder names drift with every rebrand, while the bundle identifier that macOS stores data under stays put, so matching on com.microsoft.* finds apps that a search by name would miss.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Reading the identifier takes a single command in principle, and the script wraps it in a small function that tries two different readers in turn.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;bundle_id_of&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
  &lt;span class="nb"&gt;local &lt;/span&gt;&lt;span class="nv"&gt;plist&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$1&lt;/span&gt;&lt;span class="s2"&gt;/Contents/Info.plist"&lt;/span&gt;
  &lt;span class="o"&gt;[[&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$plist&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="k"&gt;return &lt;/span&gt;0
  defaults &lt;span class="nb"&gt;read&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$1&lt;/span&gt;&lt;span class="s2"&gt;/Contents/Info"&lt;/span&gt; CFBundleIdentifier 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  plutil &lt;span class="nt"&gt;-extract&lt;/span&gt; CFBundleIdentifier raw &lt;span class="nt"&gt;-o&lt;/span&gt; - &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$plist&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;defaults&lt;/code&gt; reads most property lists without complaint, &lt;code&gt;plutil&lt;/code&gt; serves as the fallback for the ones it cannot parse, and the closing &lt;code&gt;|| true&lt;/code&gt; makes an unreadable bundle return an empty string instead of halting a script that runs under &lt;code&gt;set -euo pipefail&lt;/code&gt;. With that function in hand, the scan walks &lt;code&gt;/Applications&lt;/code&gt;, &lt;code&gt;/Applications/Utilities&lt;/code&gt; and &lt;code&gt;~/Applications&lt;/code&gt;, keeps every bundle whose identifier begins with &lt;code&gt;com.microsoft.&lt;/code&gt; or &lt;code&gt;com.skype.&lt;/code&gt;, and adds one last entry for Microsoft data that no installed app claims. Figure 3 is that first screen on my Mac, which currently has Teams and Windows App installed.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvnw7byxqz870k2au3j52.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvnw7byxqz870k2au3j52.png" alt="Figure 3. The discovery screen of ms-uninstall.sh" width="800" height="395"&gt;&lt;/a&gt;&lt;em&gt;Figure 3. The discovery screen lists each product with its bundle identifier, its total size and the number of paths it owns, followed by leftovers that no installed app claims.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The sizes are the first surprise. The Teams bundle itself weighs 1.1 GB, yet the product comes to 3.4 GB, because its container in &lt;code&gt;~/Library&lt;/code&gt; holds another 2.3 GB that dragging the app to the Trash would have left exactly where it was. The "12 items" beside it raises the obvious next question, which is where those twelve paths come from.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where each app actually lives
&lt;/h2&gt;

&lt;p&gt;A modern Mac app keeps its state in a set of well-known folders, each named after its bundle identifier, so that once you know the identifier you can predict nearly every place the app has written to. Figure 4 maps those places, using Teams as the example and grouping the folders by what they hold rather than by where they sit.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2t1nrsugqfqrffz29cry.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2t1nrsugqfqrffz29cry.png" alt="Figure 4. One bundle identifier spread across ~/Library and /Library" width="800" height="466"&gt;&lt;/a&gt;&lt;em&gt;Figure 4. One bundle identifier spreads across about two dozen locations in ~/Library and /Library, and the red column, the part that keeps running, is the part people forget.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The green column holds the app's real data. &lt;code&gt;Containers/$id&lt;/code&gt; is the sandbox, a private home directory that macOS gives every sandboxed app, complete with its own &lt;code&gt;Data/Documents&lt;/code&gt; and &lt;code&gt;Data/Library&lt;/code&gt;, and it is usually the largest piece by far. The yellow column holds what the app can rebuild on its own, such as caches, web storage, cookies and logs. Those first two columns are the ones people remember when they clean up by hand, and they are also the least harmful to leave behind, since a stale cache takes up space but never does anything.&lt;/p&gt;

&lt;p&gt;The red column is different in kind. A launch agent in &lt;code&gt;~/Library/LaunchAgents&lt;/code&gt; runs as you when you log in, an agent in &lt;code&gt;/Library/LaunchAgents&lt;/code&gt; runs in the session of every user on the machine, and a launch daemon in &lt;code&gt;/Library/LaunchDaemons&lt;/code&gt; runs as root from boot, usually pointing at a binary in &lt;code&gt;PrivilegedHelperTools&lt;/code&gt;. Each of these files is an instruction to launchd, and launchd carries it out whether or not the app that wrote it still exists, which is the entire story behind Figure 1.&lt;/p&gt;

&lt;p&gt;In the script, all of this lives in one function that turns an identifier into candidate paths and prints the ones that exist. The excerpt below keeps the pattern and trims the middle of the list.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;paths_for_id&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
  &lt;span class="nb"&gt;local id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$1&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; p
  &lt;span class="nb"&gt;shopt&lt;/span&gt; &lt;span class="nt"&gt;-s&lt;/span&gt; nullglob
  &lt;span class="nb"&gt;local &lt;/span&gt;&lt;span class="nv"&gt;cands&lt;/span&gt;&lt;span class="o"&gt;=(&lt;/span&gt;
    &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$HOME_DIR&lt;/span&gt;&lt;span class="s2"&gt;/Library/Containers/&lt;/span&gt;&lt;span class="nv"&gt;$id&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
    &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$HOME_DIR&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;/Library/Containers/&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$id&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;.&lt;span class="k"&gt;*&lt;/span&gt;
    &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$HOME_DIR&lt;/span&gt;&lt;span class="s2"&gt;/Library/Application Scripts/&lt;/span&gt;&lt;span class="nv"&gt;$id&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
    &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$HOME_DIR&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;/Library/Application&lt;span class="se"&gt;\ &lt;/span&gt;Scripts/&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$id&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;.&lt;span class="k"&gt;*&lt;/span&gt;
    &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$HOME_DIR&lt;/span&gt;&lt;span class="s2"&gt;/Library/Preferences/&lt;/span&gt;&lt;span class="nv"&gt;$id&lt;/span&gt;&lt;span class="s2"&gt;.plist"&lt;/span&gt;
    &lt;span class="c"&gt;# ... caches, cookies, saved state, logs, launch agents ...&lt;/span&gt;
    &lt;span class="s2"&gt;"/Library/LaunchDaemons/&lt;/span&gt;&lt;span class="nv"&gt;$id&lt;/span&gt;&lt;span class="s2"&gt;.plist"&lt;/span&gt;
    &lt;span class="s2"&gt;"/Library/PrivilegedHelperTools/&lt;/span&gt;&lt;span class="nv"&gt;$id&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  &lt;span class="o"&gt;)&lt;/span&gt;
  &lt;span class="nb"&gt;shopt&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; nullglob
  &lt;span class="k"&gt;for &lt;/span&gt;p &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;cands&lt;/span&gt;&lt;span class="p"&gt;[@]+&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;cands&lt;/span&gt;&lt;span class="p"&gt;[@]&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
    &lt;span class="o"&gt;[[&lt;/span&gt; &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$p&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nt"&gt;-L&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$p&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'%s\n'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$p&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  &lt;span class="k"&gt;done&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;"$id".*&lt;/code&gt; patterns are there because apps ship extensions alongside themselves, and macOS sandboxes each extension separately, so Teams owns &lt;code&gt;com.microsoft.teams2&lt;/code&gt; together with &lt;code&gt;com.microsoft.teams2.agent&lt;/code&gt;, &lt;code&gt;.notificationcenter&lt;/code&gt;, &lt;code&gt;.respawn&lt;/code&gt; and &lt;code&gt;.widgetextension&lt;/code&gt;, every one of them with a container and an &lt;code&gt;Application Scripts&lt;/code&gt; folder of its own. Orphan discovery leans on the same naming rule from the other side, skipping any folder whose name begins with an installed product's identifier, so that a widget extension is never mistaken for a leftover of an app that is gone. The &lt;code&gt;nullglob&lt;/code&gt; setting, meanwhile, stops a pattern that matches nothing from expanding to itself, since without it bash would hand the literal string &lt;code&gt;.../com.microsoft.teams2.*&lt;/code&gt; to the loop as though it were a path. The existence test at the bottom would catch that string here, but literal patterns are the most dangerous thing a removal script can produce, and section 8 comes back to them.&lt;/p&gt;

&lt;h2&gt;
  
  
  One product under many faces
&lt;/h2&gt;

&lt;p&gt;Prefix matching handles extensions, but it cannot help when a product's helpers carry identifiers that share no prefix with the app. OneDrive is the clearest case. The app you see is &lt;code&gt;com.microsoft.OneDrive&lt;/code&gt;, while its updater, its standalone updater, the daemon behind that standalone updater, its launcher and its SharePoint integration each have identifiers of their own, and several of them install launch items. Figure 5 shows the whole family.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbumrf55ea53v7jcusqtg.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbumrf55ea53v7jcusqtg.png" alt="Figure 5. OneDrive expanded into seven bundle identifiers" width="800" height="360"&gt;&lt;/a&gt;&lt;em&gt;Figure 5. A single visible OneDrive.app expands into seven bundle identifiers, and each one is searched separately for preferences, caches and launch items.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Nothing inside the app bundle mentions these relatives, so the only option is to look them up once and write them down. The script keeps them in a &lt;code&gt;case&lt;/code&gt; statement that is about as glamorous as a phone book.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;sibling_ids&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$1&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="k"&gt;in
    &lt;/span&gt;com.microsoft.OneDrive&lt;span class="k"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'%s\n'&lt;/span&gt; com.microsoft.OneDrive com.microsoft.OneDrive-mac &lt;span class="se"&gt;\&lt;/span&gt;
        com.microsoft.OneDriveUpdater com.microsoft.OneDriveStandaloneUpdater &lt;span class="se"&gt;\&lt;/span&gt;
        com.microsoft.OneDriveLauncher com.microsoft.SharePoint-mac &lt;span class="se"&gt;\&lt;/span&gt;
        com.microsoft.OneDriveStandaloneUpdaterDaemon &lt;span class="p"&gt;;;&lt;/span&gt;
    com.microsoft.wdav&lt;span class="k"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'%s\n'&lt;/span&gt; com.microsoft.wdav com.microsoft.wdav.tray com.microsoft.wdav.epsext &lt;span class="se"&gt;\&lt;/span&gt;
        com.microsoft.wdav.netext com.microsoft.dlp com.microsoft.dlp.daemon &lt;span class="p"&gt;;;&lt;/span&gt;
    &lt;span class="c"&gt;# ... Teams, Edge, Outlook, AutoUpdate, Remote Desktop ...&lt;/span&gt;
    &lt;span class="k"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'%s\n'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$1&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="p"&gt;;;&lt;/span&gt;
  &lt;span class="k"&gt;esac&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Defender, which identifies itself as &lt;code&gt;com.microsoft.wdav&lt;/code&gt;, is the other large family, bringing a menu bar app, a network extension, an endpoint security extension and two data loss prevention components along with it. Every discovered app passes through &lt;code&gt;sibling_ids&lt;/code&gt; first, and every identifier that comes out passes through &lt;code&gt;paths_for_id&lt;/code&gt;, so the list that finally reaches you covers the whole product rather than only the part with an icon. A table like this goes stale whenever Microsoft ships a new helper, which is the price of knowledge that cannot be derived from the system, and the orphan entry at the bottom of the menu exists partly to catch whatever the table misses.&lt;/p&gt;

&lt;h2&gt;
  
  
  Shared parts and the keeper's veto
&lt;/h2&gt;

&lt;p&gt;Removing everything is straightforward. The hard case, and the common one, is removing only some things, Teams but not Word, say, or Office but not the Defender agent your employer requires, because Microsoft's shared components really are shared, and deleting one on behalf of an app you are removing can quietly break an app you are keeping.&lt;/p&gt;

&lt;p&gt;The group container is the best example. Apps signed by the same developer can share a folder in &lt;code&gt;~/Library/Group Containers&lt;/code&gt; named after that developer's Apple team ID, and Microsoft's team ID is &lt;code&gt;UBF8T346G9&lt;/code&gt;. Word and Excel keep settings and autosave state in those folders, and Outlook keeps its entire local mail store under &lt;code&gt;UBF8T346G9.Office&lt;/code&gt;, so deleting that folder while Outlook is still installed uninstalls nothing at all and simply empties someone's mailbox. AutoUpdate, which serves every Microsoft product at once, and the fonts in &lt;code&gt;/Library/Fonts/Microsoft&lt;/code&gt;, which anything that opens a &lt;code&gt;.docx&lt;/code&gt; relies on, are in the same position.&lt;/p&gt;

&lt;p&gt;The script's answer is to make every dependency explicit. Each shared component is registered with the bundle identifier prefixes of the apps that still need it, and each product you decide to keep becomes a keeper that holds a veto over the components it relies on. Here is the registration for the group containers, exactly as it appears in the script.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;shared_add &lt;span class="s2"&gt;"Office group containers (documents, settings, Outlook mail store)"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"com.microsoft.Word com.microsoft.Excel com.microsoft.Powerpoint com.microsoft.Outlook com.microsoft.onenote.mac com.microsoft.OneDrive com.microsoft.teams com.microsoft.rdc com.microsoft.wdav com.microsoft.CompanyPortalMac com.skype."&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$HOME_DIR&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;/Library/Group&lt;span class="se"&gt;\ &lt;/span&gt;Containers/UBF8T346G9.&lt;span class="k"&gt;*&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$HOME_DIR&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;/Library/Application&lt;span class="se"&gt;\ &lt;/span&gt;Scripts/UBF8T346G9.&lt;span class="k"&gt;*&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The second argument is the list of apps with a claim on the component. Once you have made your selection, the script gathers the identifiers of every product you did not select, and any shared component whose list matches one of them is marked as kept and taken off the menu. Figure 6 traces that logic for a typical case, in which all of Office goes and Teams stays.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhqepundd9c5fqf7lv810.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhqepundd9c5fqf7lv810.png" alt="Figure 6. Kept apps veto the shared components they depend on" width="799" height="407"&gt;&lt;/a&gt;&lt;em&gt;Figure 6. Teams still depends on AutoUpdate and on the group containers, so both stay, while the Office helper data and the fonts, which only Office apps need, are offered for removal.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The dashed lines matter as much as the solid ones. Word, Excel and the others depend on the group containers too, but they are leaving, so their claims no longer count, and the only thing standing between those folders and the Trash is Teams.&lt;/p&gt;

&lt;p&gt;A vetoed component is still printed, together with the reason, because hiding it would leave you wondering why a 214 MB folder survived, whereas a line naming the app that holds it back tells you exactly which answer to change if that was not what you meant. Figure 7 is a real dry run on my Mac in which I chose Teams and kept Windows App.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjfuwzb2l3jo9ox1xpu2q.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjfuwzb2l3jo9ox1xpu2q.png" alt="Figure 7. Shared components marked KEPT during a dry run" width="800" height="377"&gt;&lt;/a&gt;&lt;em&gt;Figure 7. With Windows App kept, every shared component is marked KEPT and labeled with the app that needs it, so removing Teams touches only Teams' own files.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Read the plan! Before, not After!
&lt;/h2&gt;

&lt;p&gt;Everything described so far only reads the disk. Nothing changes until the script has shown you the complete plan, and with &lt;code&gt;--dry-run&lt;/code&gt; it stops at exactly that point, which makes a dry run the right first command on any Mac. Figure 8 shows the plan for the most aggressive choice available, every product and every shared component, trimmed where the lists repeat themselves.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4khgvskpxmv6wkh3vlbz.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4khgvskpxmv6wkh3vlbz.png" alt="Figure 8. The removal plan for a full removal" width="800" height="865"&gt;&lt;/a&gt;&lt;em&gt;Figure 8. The removal plan groups every path by product with its size, warns about files inside containers that are about to go, and ends with a total and the removal method.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The note at the top exists because some apps keep real documents inside their sandbox, in &lt;code&gt;Data/Documents&lt;/code&gt;, and the script lists them before the plan so that you can copy them out first. Here the only file is a &lt;code&gt;README.txt&lt;/code&gt; from Windows App's health checks, but on a Mac that has run Office for years the same note can reveal a folder of files you assumed were somewhere else. Lower down, the &lt;code&gt;[Keychain]&lt;/code&gt; block appears only when no Office app is being kept, and it removes the cached sign-in and license entries that Office stores in the login keychain, at the cost of signing in again if you ever reinstall. The closing lines state the method, and the default is a move to &lt;code&gt;~/.Trash&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Without &lt;code&gt;--dry-run&lt;/code&gt;, the script asks you to type the word &lt;code&gt;REMOVE&lt;/code&gt; exactly, treats any other answer as a cancellation that exits with code 3, and refuses to continue at all when standard input is not a terminal, so that a confirmation can never be piped in by accident. For automation there are &lt;code&gt;--select&lt;/code&gt; and &lt;code&gt;--shared&lt;/code&gt;, which accept the same answers you would type at the two prompts, such as &lt;code&gt;1,3&lt;/code&gt; or &lt;code&gt;all&lt;/code&gt;, but even then the final &lt;code&gt;REMOVE&lt;/code&gt; has to come from a person at the keyboard.&lt;/p&gt;

&lt;p&gt;One preparation is easy to miss. macOS shields the data in app containers from other processes, and a terminal without Full Disk Access receives "Operation not permitted" when it tries to read &lt;code&gt;~/Library/Group Containers/UBF8T346G9.*&lt;/code&gt;. On my Mac that made the group containers appear as 0 MB until I granted Full Disk Access to my terminal in System Settings, under Privacy &amp;amp; Security, and a real removal would have failed on those folders halfway through. Grant the permission before you run the script, and revoke it afterward if you prefer to keep your terminal unprivileged.&lt;/p&gt;

&lt;h2&gt;
  
  
  Run it!
&lt;/h2&gt;

&lt;p&gt;Once you confirm, the removal proceeds in four steps, and their order is anything but arbitrary, since each step exists to prevent a specific failure that the next one would otherwise cause. Figure 9 pairs every step with the failure it prevents.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxvy8x0vf4o2tkbpw4xkw.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxvy8x0vf4o2tkbpw4xkw.png" alt="Figure 9. The four removal steps and what each prevents" width="800" height="276"&gt;&lt;/a&gt;&lt;em&gt;Figure 9. The script quits the apps, unloads their launch jobs, moves their files and restarts cfprefsd, in that order, and each box below shows what goes wrong when the step above it is skipped.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The apps go first. The script asks each running app to quit through AppleScript, which gives it the chance to save its work or to ask you to, then checks with &lt;code&gt;pgrep&lt;/code&gt; two seconds later and aborts the whole run if the app is still alive, because a save dialog left open is precisely the situation in which deleting files would destroy work. The launch jobs come next, unloaded with &lt;code&gt;launchctl bootout&lt;/code&gt; while their plists still exist, since launchd keeps a loaded job in memory after its plist disappears, and a deleted agent with an interval like AutoUpdate's would go on trying to fire every two hours until the next reboot. Only after both of those steps does any file move.&lt;/p&gt;

&lt;p&gt;The final step looks like an afterthought.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;killall cfprefsd &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null 2&amp;gt;&amp;amp;1 &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;cfprefsd&lt;/code&gt; is the daemon through which every app's preferences pass, and it caches the contents of preference files in memory, so if it stays running after the plists are gone it can write the cached values back to disk, which is the usual explanation for a deleted preference that reappears a few minutes later. Killing it is safe, because launchd restarts it at once and the fresh copy reads only what is actually on disk.&lt;/p&gt;

&lt;h2&gt;
  
  
  Guard rails
&lt;/h2&gt;

&lt;p&gt;A script that runs &lt;code&gt;rm -rf&lt;/code&gt; under &lt;code&gt;sudo&lt;/code&gt; deserves protections beyond a confirmation prompt, however much its author trusts it, and this one has two.&lt;/p&gt;

&lt;p&gt;The first is the Trash default. Every path moves to &lt;code&gt;~/.Trash&lt;/code&gt; unless you pass &lt;code&gt;--hard-delete&lt;/code&gt;, and when two items share a name, as the three separate folders called &lt;code&gt;Microsoft&lt;/code&gt; do, the later arrivals receive a timestamped suffix instead of overwriting the earlier ones. Paths that needed &lt;code&gt;sudo&lt;/code&gt; to move are handed back to your user afterward, so that you can empty the Trash, or drag something back out of it, without a password. Uninstalling is the one operation where you tend to discover an hour later that some container held the only copy of something, and the Trash turns that discovery from a restore from backup into a drag and drop.&lt;/p&gt;

&lt;p&gt;The second is a denylist inside &lt;code&gt;remove_path&lt;/code&gt;, checked against every path immediately before it is touched.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$path&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt;
  /|/Applications|/Applications/Utilities|/Library|/Library/Fonts|/Library/Caches|&lt;span class="se"&gt;\&lt;/span&gt;
  /Library/Preferences|/Library/LaunchAgents|/Library/LaunchDaemons|&lt;span class="se"&gt;\&lt;/span&gt;
  /Library/PrivilegedHelperTools|/Library/Application&lt;span class="se"&gt;\ &lt;/span&gt;Support|&lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$HOME_DIR&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$HOME_DIR&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;/Library|&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$HOME_DIR&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;/Library/Containers|&lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$HOME_DIR&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;/Library/Preferences|&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$HOME_DIR&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;/Library/Caches|&lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$HOME_DIR&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;/Library/Group&lt;span class="se"&gt;\ &lt;/span&gt;Containers|&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$HOME_DIR&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;/Library/LaunchAgents|&lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$HOME_DIR&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;/Library/Application&lt;span class="se"&gt;\ &lt;/span&gt;Support&lt;span class="p"&gt;)&lt;/span&gt;
    die &lt;span class="s2"&gt;"internal: refusing to delete &lt;/span&gt;&lt;span class="nv"&gt;$path&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="p"&gt;;;&lt;/span&gt;
&lt;span class="k"&gt;esac&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;None of these paths should ever reach this point, and that is exactly why the check is there. Removal scripts classically go wrong in one of two ways, either through a variable that turns out to be empty, so that a path built as &lt;code&gt;"$dir/$id"&lt;/code&gt; collapses to &lt;code&gt;/&lt;/code&gt;, or through a glob that matches nothing and survives as a literal, and in both cases a precise delete becomes the deletion of a parent directory. The script already defends against each of those separately, with &lt;code&gt;set -u&lt;/code&gt; for unset variables, &lt;code&gt;nullglob&lt;/code&gt; for patterns, and a refusal to run as root at all, so the denylist serves as the final check, cheap enough to keep even though it should never fire.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's left, and what to leave
&lt;/h2&gt;

&lt;p&gt;A completed run ends with a verification sweep that lists any Microsoft items still on disk and any Microsoft processes still running, and on a Mac where you kept something, some of both are expected. A few things fall outside the script's reach even after a full removal, and Figure 10 shows two of them on my machine.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmgbdorhdftcor5f90677.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmgbdorhdftcor5f90677.png" alt="Figure 10. Package receipts and the Teams audio driver" width="800" height="256"&gt;&lt;/a&gt;&lt;em&gt;Figure 10. Package receipts outlive the files they describe, and one of them points to a Teams audio driver in /Library/Audio/Plug-Ins/HAL that the script does not yet look for.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Package receipts come first. macOS records every installer package in a database of its own, so &lt;code&gt;pkgutil --pkgs&lt;/code&gt; keeps listing Microsoft packages after their files are gone, which is harmless bookkeeping that &lt;code&gt;sudo pkgutil --forget &amp;lt;package-id&amp;gt;&lt;/code&gt; will clear if it bothers you. The receipts earn their keep in another way, because you can ask any of them what it installed, and that is how I found &lt;code&gt;MSTeamsAudioDevice.driver&lt;/code&gt;, a virtual audio device that Teams places in &lt;code&gt;/Library/Audio/Plug-Ins/HAL&lt;/code&gt; so that it can share your computer's sound during a meeting. It belongs to none of the bundle identifiers the script searches, so it survives a Teams removal, and until the script learns about it, two commands remove it by hand.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo rm&lt;/span&gt; &lt;span class="nt"&gt;-rf&lt;/span&gt; /Library/Audio/Plug-Ins/HAL/MSTeamsAudioDevice.driver
&lt;span class="nb"&gt;sudo &lt;/span&gt;killall coreaudiod
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The second command restarts Core Audio, so the device drops out of the sound settings without a reboot.&lt;/p&gt;

&lt;p&gt;Edge escapes for a related reason, since it keeps its profile in &lt;code&gt;~/Library/Application Support/Microsoft Edge&lt;/code&gt;, a folder named after the product rather than its identifier, which the per-identifier search never reaches and the shared &lt;code&gt;Microsoft&lt;/code&gt; folder does not include. Visual Studio Code genuinely is &lt;code&gt;com.microsoft.VSCode&lt;/code&gt; and appears in the list like everything else, which is one more reason to read the list before typing &lt;code&gt;all&lt;/code&gt;. If your employer manages your Mac, leave Defender, Company Portal and the Intune agent alone entirely, because device management will put them back at its next check-in, and the removal is likely to raise a compliance alert along the way.&lt;/p&gt;

&lt;p&gt;The gentlest way to begin is &lt;code&gt;./ms-uninstall.sh --dry-run&lt;/code&gt;, which runs the discovery, prints the plan and changes nothing. That output is worth seeing even if you never remove a thing, because the honest answer to the question of what Microsoft is running on your Mac tends to be longer than you would guess. On mine it came to a 3.4 GB chat app, an agent that wakes every two hours, a helper that runs as root, and an audio driver I had never heard of, and once you can read a list like that, the Trash looks less like an uninstaller and more like the first of several steps.&lt;/p&gt;

</description>
      <category>microsoft</category>
      <category>software</category>
      <category>cleancode</category>
      <category>management</category>
    </item>
    <item>
      <title>From Source Code to a Running Pod</title>
      <dc:creator>Eugene Zimin</dc:creator>
      <pubDate>Sun, 25 Oct 2026 09:38:02 +0000</pubDate>
      <link>https://dev.to/eugene-zimin/from-source-code-to-a-running-pod-4p6d</link>
      <guid>https://dev.to/eugene-zimin/from-source-code-to-a-running-pod-4p6d</guid>
      <description>&lt;h2&gt;
  
  
  The road from a source file to a Service
&lt;/h2&gt;

&lt;p&gt;At the end of &lt;a href="https://dev.to/eugene-zimin/a-kubernetes-cluster-of-your-own-4lbh-temp-slug-9893045"&gt;Part 2&lt;/a&gt;, the cluster in our VM could run any image that somebody had already built and published, as it did with the &lt;code&gt;nginx&lt;/code&gt; web server. The step that remains is the one every enterprise developer takes many times a day, which is turning code they have just written into something the cluster can run, and that step is less a single leap than a chain of small transformations. Source code becomes a JAR file, the JAR becomes a container image, the image is pushed to a registry, the cluster pulls it to start pods, and a Service routes requests to those pods. Figure 1 draws the chain.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fa6vpw02n5fdb5706j2q1.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fa6vpw02n5fdb5706j2q1.png" alt="From source code to a Kubernetes Service" width="800" height="129"&gt;&lt;/a&gt;&lt;em&gt;Figure 1. The path from source code to a running Service, in which Maven turns the code into a JAR, Docker turns the JAR into an image and pushes it to the registry, and Kubernetes starts two pods from the image behind a NodePort Service, with a check available after each link.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Each link in that chain can be checked on its own, as the line beneath the figure reminds us. A JAR can be started with &lt;code&gt;java -jar&lt;/code&gt;, an image can be started with &lt;code&gt;docker run&lt;/code&gt;, and a Service can be called with &lt;code&gt;curl&lt;/code&gt;. That property is what makes the chain practical, because when the final request fails, a developer who understands the links can test them one at a time and find the broken one in minutes, whereas a developer who knows only the first and last steps can do nothing but start again from the beginning. This article therefore walks the chain from left to right, stopping at each link to make sure it holds before moving on.&lt;/p&gt;

&lt;h2&gt;
  
  
  The toolchain
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Java and Maven
&lt;/h3&gt;

&lt;p&gt;Our service is written in Java, so the VM needs a Java Development Kit, the compiler and runtime together, and Maven, the build tool that downloads libraries and packages the compiled code into a JAR. The commands below install OpenJDK 25, the current long-term-support release of Java, together with Maven and &lt;code&gt;curl&lt;/code&gt;, a small command-line HTTP client that the default Ubuntu 26.04 desktop does not include, and then print the versions of all three Java tools.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;apt-get &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; openjdk-25-jdk maven curl
java &lt;span class="nt"&gt;-version&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; javac &lt;span class="nt"&gt;-version&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; mvn &lt;span class="nt"&gt;-v&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;-y&lt;/code&gt; option answers yes to the installer's confirmation question in advance. If every command prints a version, as in Figure 2, the Java toolchain is ready. The screenshot pipes the Maven output through &lt;code&gt;head -1&lt;/code&gt; to keep it to a single line, and your own output of &lt;code&gt;mvn -v&lt;/code&gt; will be a few lines longer.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flf55h3dsc61kchs5rkyz.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flf55h3dsc61kchs5rkyz.png" alt="OpenJDK 25 and Maven installed" width="800" height="500"&gt;&lt;/a&gt;&lt;em&gt;Figure 2. The version check after installation, in which java and javac report OpenJDK 25.0.4.1 and mvn reports Apache Maven 3.9.12.&lt;/em&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Docker
&lt;/h3&gt;

&lt;p&gt;Building container images requires Docker, which we used in the containerization module. Ubuntu packages the Docker Engine in its own repositories under the name &lt;code&gt;docker.io&lt;/code&gt;, and the separate &lt;code&gt;docker-buildx&lt;/code&gt; package adds BuildKit, the modern build component that Docker uses to execute a Dockerfile. After installing both, add your user to the &lt;code&gt;docker&lt;/code&gt; group, so that Docker commands work without &lt;code&gt;sudo&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;apt-get &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; docker.io docker-buildx
&lt;span class="nb"&gt;sudo &lt;/span&gt;usermod &lt;span class="nt"&gt;-aG&lt;/span&gt; docker &lt;span class="nv"&gt;$USER&lt;/span&gt;     &lt;span class="c"&gt;# then log out and log in again&lt;/span&gt;
docker run &lt;span class="nt"&gt;--rm&lt;/span&gt; hello-world
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;As with the &lt;code&gt;microk8s&lt;/code&gt; group in &lt;a href="https://dev.to/eugene-zimin/a-kubernetes-cluster-of-your-own-4lbh-temp-slug-9893045"&gt;Part 2&lt;/a&gt;, group membership takes effect only in a new login session, so log out and back in before running the last command. If you forget, the command fails with a message containing "permission denied" and &lt;code&gt;docker.sock&lt;/code&gt;, which means only that the current session does not yet belong to the group. In the new session, the &lt;code&gt;hello-world&lt;/code&gt; image downloads, runs, prints its greeting, and is removed again because of the &lt;code&gt;--rm&lt;/code&gt; option, as Figures 3 and 4 show.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmbjgf5pf51gen9666sbz.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmbjgf5pf51gen9666sbz.png" alt="Installing Docker Engine" width="800" height="500"&gt;&lt;/a&gt;&lt;em&gt;Figure 3. Installing the Docker Engine and BuildKit from the Ubuntu repositories.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fupmsqlrq1rmxvtcanlgv.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fupmsqlrq1rmxvtcanlgv.png" alt="Docker works" width="800" height="500"&gt;&lt;/a&gt;&lt;em&gt;Figure 4. The hello-world container confirming that Docker can download and run images.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Anyone who meet the &lt;strong&gt;"permission denied"&lt;/strong&gt; message sometimes find advice online to make the Docker socket writable by everyone, with a command such as &lt;code&gt;chmod 666 /var/run/docker.sock&lt;/code&gt;. Never follow it. The socket is the door through which programs give orders to the Docker daemon, and the daemon runs as root, so anyone who can write to the socket can start a container that mounts the host's entire file system and do anything they like with it. Membership in the &lt;code&gt;docker&lt;/code&gt; group grants the same power, which is why that group is treated as equivalent to administrator rights, but it grants it only to the users you deliberately add.&lt;/p&gt;

&lt;h2&gt;
  
  
  The IDE and the project
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Installing IntelliJ IDEA
&lt;/h3&gt;

&lt;p&gt;Any IDE you are comfortable with will do for this course, whether IntelliJ IDEA, Visual Studio Code with the Java extensions, Eclipse, or NetBeans, and this article uses IntelliJ IDEA. Since 2025 IntelliJ IDEA has been a single product, free for non-commercial use, with its Ultimate features available through a trial or through a free educational license, for which you can apply at &lt;a href="https://www.jetbrains.com/community/education/" rel="noopener noreferrer"&gt;jetbrains.com/community/education&lt;/a&gt; with your college e-mail address. Like MicroK8s, it is distributed as a snap, so one command installs it, as Figure 5 shows.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;snap &lt;span class="nb"&gt;install &lt;/span&gt;intellij-idea &lt;span class="nt"&gt;--classic&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F73iyweoi12a3lkrifzq2.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F73iyweoi12a3lkrifzq2.png" alt="Installing IntelliJ IDEA from the snap store" width="800" height="500"&gt;&lt;/a&gt;&lt;em&gt;Figure 5. Installing IntelliJ IDEA from the snap store.&lt;/em&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Generating the project with Spring Initializr
&lt;/h3&gt;

&lt;p&gt;A Spring Boot project needs a build file that lists the right libraries in compatible versions, a main class, a configuration file, and a Maven wrapper, and writing all of that by hand is both tedious and error-prone. Spring Initializr, a web service at &lt;a href="https://start.spring.io/" rel="noopener noreferrer"&gt;start.spring.io&lt;/a&gt;, generates it for us. Open it in Firefox inside the VM and make the choices shown in Figure 6, which means Maven as the build tool, Java as the language, Spring Boot 4.1.x, the group &lt;code&gt;com.cst8277&lt;/code&gt; and the artifact &lt;code&gt;demo&lt;/code&gt;, Jar packaging, Java 25, and two dependencies, Spring Web for writing REST controllers and Spring Boot Actuator for the health endpoints that Kubernetes will use later.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F40zodj4i9vbhg8bi7k5i.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F40zodj4i9vbhg8bi7k5i.png" alt="Generating the project with Spring Initializr" width="800" height="500"&gt;&lt;/a&gt;&lt;em&gt;Figure 6. Spring Initializr configured for a Maven project with Spring Boot 4.1, Java 25, the group com.cst8277, the artifact demo, and the Spring Web and Spring Boot Actuator dependencies.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Click &lt;em&gt;Generate&lt;/em&gt;. The browser downloads a file named &lt;code&gt;demo.zip&lt;/code&gt;, which the commands below unpack into a &lt;code&gt;projects&lt;/code&gt; folder in your home directory, after which the last command opens the new project in IntelliJ IDEA, the trailing &lt;code&gt;&amp;amp;&lt;/code&gt; letting the IDE run in the background so that the terminal stays free.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; ~/projects &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;cd&lt;/span&gt; ~/projects
unzip ~/Downloads/demo.zip
intellij-idea ~/projects/demo &amp;amp;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Opening the project and choosing the JDK
&lt;/h3&gt;

&lt;p&gt;On its first start, IntelliJ IDEA asks you to accept its user agreement and to confirm that you trust the project, which matters because opening a Maven project runs code from its build file. It then loads the project and downloads the libraries listed in &lt;code&gt;pom.xml&lt;/code&gt;, which takes a minute or two the first time.&lt;/p&gt;

&lt;p&gt;The IDE does not always notice the JDK installed by itself, in which case the editor shows the warning &lt;em&gt;Project JDK is not defined&lt;/em&gt;, as in Figure 7. Click &lt;em&gt;Setup SDK&lt;/em&gt; and select the detected Java 25 installation, as in Figure 8, and the warning disappears.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1ddld13tjogpmealkjlb.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1ddld13tjogpmealkjlb.png" alt="IntelliJ IDEA asks for the project JDK" width="800" height="500"&gt;&lt;/a&gt;&lt;em&gt;Figure 7. The editor warning that no project JDK is defined, with the Setup SDK link that resolves it.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwitzun7k12n61cf884lm.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwitzun7k12n61cf884lm.png" alt="Selecting the detected OpenJDK 25" width="800" height="500"&gt;&lt;/a&gt;&lt;em&gt;Figure 8. Selecting the OpenJDK 25 installation that IntelliJ IDEA detected on the VM.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Earlier versions of this course added Lombok at this point, a library that generates getters, constructors, and similar boilerplate during compilation. It is no longer needed, because Java records give the same benefit with plain Java and without an extra plugin in the IDE.&lt;/p&gt;

&lt;h2&gt;
  
  
  Hello World as a REST service
&lt;/h2&gt;

&lt;h3&gt;
  
  
  A record for the response
&lt;/h3&gt;

&lt;p&gt;Our service will answer an HTTP request with a small JSON document containing a greeting and a counter. The shape of that document is described by a data transfer object, or DTO, a class whose only job is to carry data across a boundary, and a record is the natural way to write one. Create a package named &lt;code&gt;dto&lt;/code&gt; inside &lt;code&gt;com.cst8277.demo&lt;/code&gt; and, in it, the following file.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kn"&gt;package&lt;/span&gt; &lt;span class="nn"&gt;com.cst8277.demo.dto&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;record&lt;/span&gt; &lt;span class="nf"&gt;Greeting&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;long&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That single line declares an immutable class with two fields, a constructor that sets both, accessor methods named &lt;code&gt;id()&lt;/code&gt; and &lt;code&gt;content()&lt;/code&gt;, and sensible &lt;code&gt;equals&lt;/code&gt;, &lt;code&gt;hashCode&lt;/code&gt;, and &lt;code&gt;toString&lt;/code&gt; methods. When Spring later converts a &lt;code&gt;Greeting&lt;/code&gt; to JSON, it uses the accessors, so the response will contain exactly the keys &lt;code&gt;id&lt;/code&gt; and &lt;code&gt;content&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  The controller
&lt;/h3&gt;

&lt;p&gt;The controller is the class that receives HTTP requests and decides what to return. Create a second package, &lt;code&gt;controllers&lt;/code&gt;, and in it the class below, which answers GET requests to the root URL with a greeting and counts the requests it has served.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kn"&gt;package&lt;/span&gt; &lt;span class="nn"&gt;com.cst8277.demo.controllers&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;com.cst8277.demo.dto.Greeting&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;java.util.concurrent.atomic.AtomicLong&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;org.springframework.web.bind.annotation.GetMapping&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;org.springframework.web.bind.annotation.RequestParam&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;org.springframework.web.bind.annotation.RestController&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

&lt;span class="nd"&gt;@RestController&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;GreetingController&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;

    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="no"&gt;TEMPLATE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"Hello, %s!"&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="nc"&gt;AtomicLong&lt;/span&gt; &lt;span class="n"&gt;counter&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;AtomicLong&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;

    &lt;span class="nd"&gt;@GetMapping&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"/"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;Greeting&lt;/span&gt; &lt;span class="nf"&gt;greet&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
            &lt;span class="nd"&gt;@RequestParam&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;defaultValue&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"World"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;Greeting&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;counter&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;incrementAndGet&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt;
                            &lt;span class="no"&gt;TEMPLATE&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;formatted&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The annotation &lt;code&gt;@RestController&lt;/code&gt; tells Spring that this class handles web requests and that whatever its methods return should be written into the response body, converted to JSON, rather than interpreted as the name of an HTML page. The annotation &lt;code&gt;@GetMapping("/")&lt;/code&gt; maps GET requests for the root URL to the &lt;code&gt;greet&lt;/code&gt; method, and &lt;code&gt;@RequestParam&lt;/code&gt; reads the query parameter &lt;code&gt;name&lt;/code&gt; from the URL, substituting &lt;code&gt;World&lt;/code&gt; when the parameter is absent. The counter is an &lt;code&gt;AtomicLong&lt;/code&gt; rather than a plain &lt;code&gt;long&lt;/code&gt; because Spring serves requests on many threads at once, and an atomic counter guarantees that two simultaneous requests never receive the same number. Remember that counter, because later it will tell us something important about how Kubernetes distributes requests.&lt;/p&gt;

&lt;h3&gt;
  
  
  Health probes
&lt;/h3&gt;

&lt;p&gt;The last piece of code is a line of configuration. Spring Boot Actuator can report two kinds of health that Kubernetes understands, liveness, which says whether the application is still working at all, and readiness, which says whether it is ready to receive traffic. Spring Boot switches these probes on automatically when it detects that it is running inside Kubernetes, and the line below switches them on everywhere, so that we can also see them when the application runs in the IDE. Open &lt;code&gt;src/main/resources/application.properties&lt;/code&gt;, which Spring Initializr has already created with the application's name, and make it read as follows.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight properties"&gt;&lt;code&gt;&lt;span class="py"&gt;spring.application.name&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;demo&lt;/span&gt;
&lt;span class="py"&gt;management.endpoint.health.probes.enabled&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Running it
&lt;/h3&gt;

&lt;p&gt;Figure 9 shows the finished project in IntelliJ IDEA, with the two new packages beside the generated &lt;code&gt;DemoApplication&lt;/code&gt; class. The figure also shows a &lt;code&gt;Dockerfile&lt;/code&gt; and a file named &lt;code&gt;demo-app.yaml&lt;/code&gt; in the project root, which we write a bit later.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fogpmwgkzkn964m7vrfnn.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fogpmwgkzkn964m7vrfnn.png" alt="The project structure in IntelliJ IDEA" width="800" height="500"&gt;&lt;/a&gt;&lt;em&gt;Figure 9. The project in IntelliJ IDEA, with the controllers and dto packages under com.cst8277.demo and GreetingController open in the editor.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Run the application by clicking the green Run button next to &lt;code&gt;DemoApplication&lt;/code&gt;. The Run panel at the bottom of the window shows Spring Boot's log, in which the application reports that its embedded web server is listening on port 8080, and, for Spring Boot projects, tabs that list the application's beans, its health, its request mappings, and its environment, as Figure 10 shows.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdrm9beayukk53p30sd5q.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdrm9beayukk53p30sd5q.png" alt="Running the application in the IDE" width="800" height="500"&gt;&lt;/a&gt;&lt;em&gt;Figure 10. The application running in the IDE, with the Spring Boot log in the Console tab and the Beans, Health, Mappings, and Environment tabs beside it in the Run panel.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Now open &lt;code&gt;http://localhost:8080/?name=CST8277&lt;/code&gt; in Firefox. The browser displays the JSON in Figure 11, whose &lt;code&gt;content&lt;/code&gt; is "Hello, CST8277!" and whose &lt;code&gt;id&lt;/code&gt; is 1. Reload the page, and the &lt;code&gt;id&lt;/code&gt; grows by one each time, because the same controller object, and therefore the same counter, serves every request. Remove the parameter from the URL, and the greeting becomes "Hello, World!", the default that &lt;code&gt;@RequestParam&lt;/code&gt; supplied.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Feehut94ncok71b9tg359.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Feehut94ncok71b9tg359.png" alt="The JSON response of the service" width="800" height="500"&gt;&lt;/a&gt;&lt;em&gt;Figure 11. The service's JSON response in Firefox, containing the request counter and the greeting built from the name parameter.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Before continuing, stop the application in the IDE with the red Stop button. That is necessary as the next section starts the same application in a container that also listens on port 8080, and only one program can listen on a port at a time, so an application left running in the IDE would make the container fail to start.&lt;/p&gt;

&lt;h2&gt;
  
  
  From JAR to image
&lt;/h2&gt;

&lt;h3&gt;
  
  
  A multi-stage Dockerfile
&lt;/h3&gt;

&lt;p&gt;The second link in the chain turns the application into a container image, and the recipe for an image is a Dockerfile, a text file of instructions that Docker executes one after another. Create a file named &lt;code&gt;Dockerfile&lt;/code&gt; in the project root, next to &lt;code&gt;pom.xml&lt;/code&gt;, with the content below, which Figure 12 also shows in the editor.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="c"&gt;# ---- build stage: compile and package with the full JDK ----&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;eclipse-temurin:25-jdk&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;build&lt;/span&gt;
&lt;span class="k"&gt;WORKDIR&lt;/span&gt;&lt;span class="s"&gt; /src&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; .mvn .mvn&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; mvnw pom.xml ./&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;./mvnw &lt;span class="nt"&gt;-q&lt;/span&gt; dependency:go-offline
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; src src&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;./mvnw &lt;span class="nt"&gt;-q&lt;/span&gt; package &lt;span class="nt"&gt;-DskipTests&lt;/span&gt;

&lt;span class="c"&gt;# ---- runtime stage: only the JRE and the application ----&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="s"&gt; eclipse-temurin:25-jre&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;useradd &lt;span class="nt"&gt;--system&lt;/span&gt; &lt;span class="nt"&gt;--uid&lt;/span&gt; 10001 app
&lt;span class="k"&gt;WORKDIR&lt;/span&gt;&lt;span class="s"&gt; /app&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; --from=build /src/target/*.jar app.jar&lt;/span&gt;
&lt;span class="k"&gt;USER&lt;/span&gt;&lt;span class="s"&gt; 10001&lt;/span&gt;
&lt;span class="k"&gt;EXPOSE&lt;/span&gt;&lt;span class="s"&gt; 8080&lt;/span&gt;
&lt;span class="k"&gt;ENTRYPOINT&lt;/span&gt;&lt;span class="s"&gt; ["java", "-jar", "app.jar"]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvi0tbvtqyng1c1mr42cx.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvi0tbvtqyng1c1mr42cx.png" alt="The multi-stage Dockerfile" width="800" height="500"&gt;&lt;/a&gt;&lt;em&gt;Figure 12. The multi-stage Dockerfile in the project root.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The file describes two images, as we saw in the containerization module, and only the second becomes the result. The first stage, named &lt;code&gt;build&lt;/code&gt;, starts from &lt;code&gt;eclipse-temurin:25-jdk&lt;/code&gt;, a full Java 25 development kit, and compiles the application with the Maven wrapper &lt;code&gt;mvnw&lt;/code&gt;, a script that Spring Initializr placed in the project so that the build uses a known Maven version regardless of what is installed. The order of its instructions is deliberate. The build file is copied and the dependencies are downloaded before the source code is copied, because Docker caches each step and reuses it as long as its inputs have not changed, so that editing a Java file later reruns only the compilation and not the much slower download of every library.&lt;/p&gt;

&lt;p&gt;The second stage starts afresh from &lt;code&gt;eclipse-temurin:25-jre&lt;/code&gt;, which contains only the Java runtime, and copies in nothing from the first stage except the finished JAR. The compiler, Maven, the downloaded libraries, and the source code all stay behind in the discarded build stage, which makes the final image smaller and leaves an attacker who might break into the container with far fewer tools. The same reasoning explains the &lt;code&gt;useradd&lt;/code&gt; and &lt;code&gt;USER 10001&lt;/code&gt; lines, which make the application run as an ordinary, unprivileged user instead of as root, so that a flaw in the application cannot easily become control of the container. The last two instructions document the port the application listens on and the command that starts it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Building, trying, and pushing the image
&lt;/h3&gt;

&lt;p&gt;The commands below build the image, start a container from it to check that it works, remove that container, and finally push the image to the registry that &lt;a href="https://dev.to/eugene-zimin/a-kubernetes-cluster-of-your-own-4lbh-temp-slug-9893045"&gt;Part 2&lt;/a&gt; created.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cd&lt;/span&gt; ~/projects/demo
docker build &lt;span class="nt"&gt;-t&lt;/span&gt; localhost:32000/cst8277/demo-app:1.0 &lt;span class="nb"&gt;.&lt;/span&gt;
&lt;span class="c"&gt;# try the container locally&lt;/span&gt;
docker run &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;--name&lt;/span&gt; demo &lt;span class="nt"&gt;-p&lt;/span&gt; 8080:8080 &lt;span class="se"&gt;\&lt;/span&gt;
    localhost:32000/cst8277/demo-app:1.0
curl &lt;span class="s1"&gt;'http://localhost:8080/?name=Docker'&lt;/span&gt;
docker &lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; demo
&lt;span class="c"&gt;# publish it to the cluster's registry&lt;/span&gt;
docker push localhost:32000/cst8277/demo-app:1.0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The name given with &lt;code&gt;-t&lt;/code&gt; deserves a close look, because it does two jobs at once. The part before the first slash, &lt;code&gt;localhost:32000&lt;/code&gt;, tells Docker which registry the image belongs to, so that &lt;code&gt;docker push&lt;/code&gt; later knows where to send it without being told again. The part after it, &lt;code&gt;cst8277/demo-app&lt;/code&gt;, names the image, and the tag after the colon, &lt;code&gt;1.0&lt;/code&gt;, names this particular version. The final dot tells Docker to use the current folder, with its Dockerfile and source code, as the build context. Figure 13 shows the build, in which the build stage runs Maven and the runtime stage copies in the JAR.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fr86dr9a2puiqptcru0fy.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fr86dr9a2puiqptcru0fy.png" alt="Building the image" width="800" height="500"&gt;&lt;/a&gt;&lt;em&gt;Figure 13. Building the image, in which the build stage runs Maven inside the JDK image and the runtime stage copies the resulting JAR into the JRE image.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;docker run&lt;/code&gt; command starts the image in the background, because of &lt;code&gt;-d&lt;/code&gt;, under the name &lt;code&gt;demo&lt;/code&gt;, and connects port 8080 of the VM to port 8080 of the container, because of &lt;code&gt;-p 8080:8080&lt;/code&gt;. Spring Boot needs a few seconds to start, so if &lt;code&gt;curl&lt;/code&gt; reports that the connection was reset or that the server returned an empty reply, wait a moment and try again. Figure 14 shows the image and the running container, together with the answer "Hello, Docker!".&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkeqkiwgf0z3hksm23w6m.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkeqkiwgf0z3hksm23w6m.png" alt="The image size and the running container" width="800" height="500"&gt;&lt;/a&gt;&lt;em&gt;Figure 14. The image listed with a content size of 135 MB and a disk usage of 534 MB, the container running with port 8080 published, and the service answering with "Hello, Docker!".&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The two sizes in the image listing measure different things. The content size of 135 MB is roughly what has to travel over the network when the image is pushed or pulled, since image layers are stored and transferred in compressed form, while the disk usage of 534 MB is the space the unpacked layers occupy on the VM. Either figure is far below what a single-stage image containing the full JDK and Maven would need, and that difference is what the second stage of the Dockerfile buys.&lt;/p&gt;

&lt;p&gt;Once the test succeeds, &lt;code&gt;docker rm -f demo&lt;/code&gt; stops and removes the container, freeing port 8080 again, and &lt;code&gt;docker push&lt;/code&gt; uploads the image to the registry, as Figure 15 shows. From this moment the image exists in a place where Kubernetes can find it.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Faht5nrit7gzdesibm0od.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Faht5nrit7gzdesibm0od.png" alt="Pushing the image to the MicroK8s registry" width="800" height="500"&gt;&lt;/a&gt;&lt;em&gt;Figure 15. Pushing the image to the MicroK8s registry at localhost:32000.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;We are now halfway along the chain in Figure 1, with source code, JAR, image, and registry behind us, and only the pods and the Service still to come.&lt;/p&gt;

&lt;h2&gt;
  
  
  From image to Service
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Describing the desired state
&lt;/h3&gt;

&lt;p&gt;In &lt;a href="https://dev.to/eugene-zimin/a-kubernetes-cluster-of-your-own-4lbh-temp-slug-9893045"&gt;Part 2&lt;/a&gt; we started &lt;code&gt;nginx&lt;/code&gt; with imperative commands. This time we write the desired state into a file, so that it can be read, reviewed, kept in version control, and applied again on any cluster. Create &lt;code&gt;demo-app.yaml&lt;/code&gt; in the project root with the content below. It holds two objects separated by the line &lt;code&gt;---&lt;/code&gt;, a Deployment that runs two replicas of our image and a Service that makes them reachable on port 30080 of the node.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;apps/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Deployment&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;demo&lt;/span&gt;
  &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;demo&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;replicas&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt;
  &lt;span class="na"&gt;selector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;matchLabels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;demo&lt;/span&gt;
  &lt;span class="na"&gt;template&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;demo&lt;/span&gt;
    &lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;containers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;demo-app&lt;/span&gt;
          &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;localhost:32000/cst8277/demo-app:1.0&lt;/span&gt;
          &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;containerPort&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;8080&lt;/span&gt;
          &lt;span class="na"&gt;readinessProbe&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;httpGet&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/actuator/health/readiness&lt;/span&gt;
              &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;8080&lt;/span&gt;
          &lt;span class="na"&gt;livenessProbe&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;httpGet&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/actuator/health/liveness&lt;/span&gt;
              &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;8080&lt;/span&gt;
          &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;requests&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;cpu&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;100m&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;memory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;256Mi&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
            &lt;span class="na"&gt;limits&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;memory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;512Mi&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Service&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;demo&lt;/span&gt;
  &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;demo&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;NodePort&lt;/span&gt;
  &lt;span class="na"&gt;selector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;demo&lt;/span&gt;
  &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;8080&lt;/span&gt;
      &lt;span class="na"&gt;targetPort&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;8080&lt;/span&gt;
      &lt;span class="na"&gt;nodePort&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;30080&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The file is long, but every part of it answers a question that Kubernetes needs answered. The Deployment asks for &lt;code&gt;replicas: 2&lt;/code&gt;, two identical pods, and its &lt;code&gt;template&lt;/code&gt; describes what each pod looks like, including the label &lt;code&gt;app: demo&lt;/code&gt; that every pod receives. The &lt;code&gt;selector&lt;/code&gt; tells the Deployment which pods it is responsible for, namely those carrying that same label, and the Service at the bottom uses the same label to decide which pods should receive its traffic. Labels and selectors are therefore the glue of the whole file, and a mismatch between them is a classic reason why a Service answers nothing.&lt;/p&gt;

&lt;p&gt;The container section names the image we pushed, down to its registry and tag, and declares the two probes. Kubernetes calls the readiness endpoint and sends traffic to a pod only after it answers successfully, so that a pod still starting up never receives requests it cannot serve. It calls the liveness endpoint throughout the pod's life and restarts the container if that endpoint stops answering, which is how a hung application gets a fresh start without anyone noticing. The &lt;code&gt;resources&lt;/code&gt; block tells the scheduler that each pod needs at least a tenth of a processor core, written &lt;code&gt;100m&lt;/code&gt;, and 256 MiB of memory, and it caps the memory at 512 MiB, beyond which the container is stopped rather than allowed to starve its neighbors. Finally, the Service is of type NodePort, as in &lt;a href="https://dev.to/eugene-zimin/a-kubernetes-cluster-of-your-own-4lbh-temp-slug-9893045"&gt;Part 2&lt;/a&gt;, but this time it fixes the node port at 30080 instead of letting Kubernetes choose one.&lt;/p&gt;

&lt;h3&gt;
  
  
  Applying it and calling the service
&lt;/h3&gt;

&lt;p&gt;Apply the file, wait for the rollout to complete, and list what was created. Then call the service four times, and finally delete both pods and look at what remains.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl apply &lt;span class="nt"&gt;-f&lt;/span&gt; demo-app.yaml
kubectl rollout status deployment/demo
kubectl get pods,svc &lt;span class="nt"&gt;-l&lt;/span&gt; &lt;span class="nv"&gt;app&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;demo &lt;span class="nt"&gt;-o&lt;/span&gt; wide
&lt;span class="c"&gt;# call the service several times&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;i &lt;span class="k"&gt;in &lt;/span&gt;1 2 3 4&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s1"&gt;'http://localhost:30080/?name=Kubernetes'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;echo
&lt;/span&gt;&lt;span class="k"&gt;done&lt;/span&gt;
&lt;span class="c"&gt;# self-healing: delete the pods and watch them come back&lt;/span&gt;
kubectl delete pod &lt;span class="nt"&gt;-l&lt;/span&gt; &lt;span class="nv"&gt;app&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;demo &lt;span class="nt"&gt;--wait&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;false
&lt;/span&gt;kubectl get pods &lt;span class="nt"&gt;-l&lt;/span&gt; &lt;span class="nv"&gt;app&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;demo
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Figure 16 shows the first three commands. The rollout reports that first zero, then one, then both of the two replicas became available, the pod listing shows two pods with their own cluster addresses on the node &lt;code&gt;cst8277-vm&lt;/code&gt;, and the Service listing shows the mapping &lt;code&gt;8080:30080/TCP&lt;/code&gt; together with the selector &lt;code&gt;app=demo&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fiomdfotwdd741aw24xn8.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fiomdfotwdd741aw24xn8.png" alt="The deployment rolled out" width="800" height="500"&gt;&lt;/a&gt;&lt;em&gt;Figure 16. The deployment rolled out, with two running pods, each with its own address on the node cst8277-vm, and a NodePort Service mapping port 30080 to port 8080 of the pods selected by app=demo.&lt;/em&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  What the counter reveals
&lt;/h3&gt;

&lt;p&gt;Figure 17 shows the rest, and it rewards a careful look.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmem9d3lojpdpx2x6uzxb.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmem9d3lojpdpx2x6uzxb.png" alt="Load balancing and self-healing" width="800" height="500"&gt;&lt;/a&gt;&lt;em&gt;Figure 17. Four requests answered with the ids 1, 1, 2, and 3, which shows that two pods with separate counters shared the requests, followed by the deletion of the pods and their replacement by new pods, one of them not yet ready.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;When the application ran in the IDE, the &lt;code&gt;id&lt;/code&gt; rose by exactly one with every request. Here the four answers carry the ids 1, 1, 2, and 3, which can only mean that the requests were served by two different pods, each holding its own counter in its own memory, with the Service distributing them between the two. We are watching horizontal scaling at work, and we are also watching its most important consequence. A counter kept in memory belongs to one replica and is invisible to the others, so any state that must be shared, whether a counter, a shopping cart, or a login session, has to live outside the pods, in a database or a cache, and the pods themselves should be stateless.&lt;/p&gt;

&lt;p&gt;The second half of the figure repeats the experiment from &lt;a href="https://dev.to/eugene-zimin/a-kubernetes-cluster-of-your-own-4lbh-temp-slug-9893045"&gt;Part 2&lt;/a&gt; with our own code. After the pods are deleted, the Deployment immediately creates new ones, and the listing catches one of them still starting, shown as &lt;code&gt;0/1&lt;/code&gt; in the READY column, because its readiness probe has not yet succeeded. Until it does, the Service sends that pod no traffic at all, so a client calling the service during the restart is answered by the pod that is already ready.&lt;/p&gt;

&lt;h2&gt;
  
  
  Watching it in Headlamp
&lt;/h2&gt;

&lt;p&gt;The same events can be followed in Headlamp, which &lt;a href="https://dev.to/eugene-zimin/a-kubernetes-cluster-of-your-own-4lbh-temp-slug-9893045"&gt;Part 2&lt;/a&gt; installed. If the port forward from &lt;a href="https://dev.to/eugene-zimin/a-kubernetes-cluster-of-your-own-4lbh-temp-slug-9893045"&gt;Part 2&lt;/a&gt; is no longer running, for example because the VM has been restarted since, start it again, create a fresh token if the old one has expired, and open &lt;code&gt;http://localhost:8888&lt;/code&gt; in Firefox.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl &lt;span class="nt"&gt;-n&lt;/span&gt; kube-system create token headlamp-admin
kubectl &lt;span class="nt"&gt;-n&lt;/span&gt; kube-system port-forward svc/headlamp 8888:80
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The cluster overview in Figure 18 summarizes the resources of the cluster, which in the screenshot are four processor units, about 7 GB of memory, eight pods, and one ready node, and lists recent warning events. Among them you will probably find readiness probes that failed with "connection refused" while the demo pods were starting, which is the same moment the &lt;code&gt;0/1&lt;/code&gt; in Figure 17 captured, recorded this time as an event rather than as a line of command output.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Faex3zp3re013ulgv2rfy.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Faex3zp3re013ulgv2rfy.png" alt="Headlamp cluster overview" width="800" height="500"&gt;&lt;/a&gt;&lt;em&gt;Figure 18. The Headlamp cluster overview, with one ready node and eight pods, and four warning events reporting readiness probes that were refused while the demo pods were starting.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The Workloads page in Figure 19 counts the controllers in the cluster by kind. It shows eight healthy pods, six Deployments with their six ReplicaSets, which are the system components, the registry, Headlamp, and our own &lt;code&gt;demo&lt;/code&gt;, and the single DaemonSet that runs Calico on the node, and a ring beside each count shows what share of them is running. Below the summary it lists the individual workloads. The Pods page in Figure 20 lists every pod in every namespace, including our two demo pods beside the system pods, the registry, and Headlamp itself.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4civmd0otjxv06sb1la6.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4civmd0otjxv06sb1la6.png" alt="Workloads overview" width="800" height="500"&gt;&lt;/a&gt;&lt;em&gt;Figure 19. The Workloads page, with eight healthy pods, six Deployments, six ReplicaSets, and one DaemonSet, all of them running.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fow1lnyv8xgjfltxszbv8.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fow1lnyv8xgjfltxszbv8.png" alt="All pods, including the two demo pods" width="800" height="500"&gt;&lt;/a&gt;&lt;em&gt;Figure 20. The Pods page, listing every pod in the cluster, among them the two demo pods.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Finally, the Map in Figure 21 draws the cluster as a picture, grouped by namespace. The system components, from CoreDNS and Calico to Headlamp and the storage provisioner, sit together in &lt;code&gt;kube-system&lt;/code&gt;, the registry has a namespace of its own, and our &lt;code&gt;demo&lt;/code&gt; Deployment sits in &lt;code&gt;default&lt;/code&gt; beside the &lt;code&gt;kubernetes&lt;/code&gt; Service that leads to the API server. Each card can be expanded to show the objects that belong to it, which makes the Map a good place to return to whenever a listing on the command line becomes hard to read.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F17khzxmhvx6z13id2kvn.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F17khzxmhvx6z13id2kvn.png" alt="The Map view of the cluster" width="800" height="500"&gt;&lt;/a&gt;&lt;em&gt;Figure 21. The Map view grouped by namespace, with the system components in kube-system, the registry in container-registry, and the demo Deployment in default.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  When it breaks
&lt;/h2&gt;

&lt;p&gt;Sooner or later a deployment fails, and the first command to run is always &lt;code&gt;kubectl get pods&lt;/code&gt;, because the status of the pods says which link of the chain has broken. Figure 22 turns that observation into a decision guide.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2cumjj6ci2t9ia4svu1i.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2cumjj6ci2t9ia4svu1i.png" alt="Troubleshooting a deployment" width="800" height="372"&gt;&lt;/a&gt;&lt;em&gt;Figure 22. A decision guide for troubleshooting, which starts from the pod status reported by kubectl get pods and leads to the likely cause and the next command for each status.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;A pod in the state &lt;code&gt;ImagePullBackOff&lt;/code&gt; could not obtain its image, which nearly always means that the image name or tag in the YAML file does not match what was pushed, or that the push itself failed, so the remedy is to compare the two names character by character and to repeat the &lt;code&gt;docker push&lt;/code&gt;. A pod in &lt;code&gt;CrashLoopBackOff&lt;/code&gt; has an image that starts but exits almost at once, and Kubernetes keeps restarting it, waiting longer each time, exactly as &lt;a href="https://dev.to/eugene-zimin/a-kubernetes-cluster-of-your-own-4lbh-temp-slug-9893045"&gt;Part 2&lt;/a&gt; warned. The reason is in the application's own output, which &lt;code&gt;kubectl logs&lt;/code&gt; followed by the pod's name displays. A pod that is Running but shows &lt;code&gt;0/1&lt;/code&gt; in the READY column has a failing readiness probe, and &lt;code&gt;kubectl describe pod&lt;/code&gt; shows the probe's recent results, which usually point to a wrong path or port. A pod stuck in Pending is waiting for memory or processor time that the node cannot provide, which calls for lower resource requests or, as &lt;a href="https://dev.to/eugene-zimin/a-computer-inside-your-computer-1203-temp-slug-9280101"&gt;Part 1&lt;/a&gt; explained, a VM with more resources. Finally, a pod that is Running and &lt;code&gt;1/1&lt;/code&gt; ready while &lt;code&gt;curl&lt;/code&gt; on port 30080 is refused points away from the pod and toward the Service, whose selector and ports deserve a second look.&lt;/p&gt;

&lt;p&gt;Two problems from earlier in this article belong on the same list, even though they occur before any pod exists. A "permission denied" message that mentions &lt;code&gt;docker.sock&lt;/code&gt; means that the current login session is not yet in the &lt;code&gt;docker&lt;/code&gt; group, which a log-out and log-in cures. A container or application that cannot start because port 8080 is already in use means that something else, usually the application still running in the IDE or a forgotten test container, holds the port, and stopping it solves the problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you have built
&lt;/h2&gt;

&lt;p&gt;The VM that began &lt;a href="https://dev.to/eugene-zimin/a-computer-inside-your-computer-1203-temp-slug-9280101"&gt;Part 1&lt;/a&gt; as an empty disk now holds a complete, modern development environment, with Ubuntu 26.04 LTS running OpenJDK 25 and Maven, Docker, IntelliJ IDEA, and a MicroK8s 1.36 cluster with its own image registry and Headlamp. More important than the list of tools is the path we walked with them. We generated a project, wrote a REST controller with a record as its response, ran it in the IDE, packaged it with a multi-stage Dockerfile that leaves the compiler behind and runs as an unprivileged user, published the image to a registry, and deployed it declaratively to Kubernetes, where we watched requests spread across two replicas, pods replaced without anyone asking, and readiness probes holding traffic back from a pod that was not yet ready.&lt;/p&gt;

</description>
      <category>devops</category>
      <category>kubernetes</category>
      <category>tutorial</category>
      <category>google</category>
    </item>
    <item>
      <title>A Kubernetes Cluster of Your Own</title>
      <dc:creator>Eugene Zimin</dc:creator>
      <pubDate>Sun, 25 Oct 2026 09:37:46 +0000</pubDate>
      <link>https://dev.to/eugene-zimin/a-kubernetes-cluster-of-your-own-479i</link>
      <guid>https://dev.to/eugene-zimin/a-kubernetes-cluster-of-your-own-479i</guid>
      <description>&lt;h2&gt;
  
  
  From running a container to keeping it running
&lt;/h2&gt;

&lt;p&gt;In the module on containerization we ran containers with Docker, one command at a time, and watched each one start, answer a request, and stop. That way of working is perfect for a developer's laptop and hopeless for a production system, because it depends on a person being present at every moment something changes. If a container crashes at three in the morning, nobody restarts it, and if traffic doubles at noon, nobody starts a second copy, so the service is down, or slow, until someone notices.&lt;/p&gt;

&lt;p&gt;Kubernetes removes the person from that loop by changing what we tell the system. Instead of issuing commands such as "start this container", we describe the state we want, for example "two copies of this image should always be running and reachable on this port", and Kubernetes compares that description with what is actually running and acts whenever the two differ. A thermostat works on the same principle, since it does not switch the heater on at a particular hour but compares the room's temperature with the one you asked for and acts on the difference, again and again, for as long as it is switched on. Kubernetes runs many such control loops at once, one for each kind of object it manages, and most of what it does can be understood as a loop of this kind noticing a difference and closing it.&lt;/p&gt;

&lt;p&gt;The analogy has a limit worth stating now, because students meet it in Part 3. A thermostat cannot repair a broken heater, and Kubernetes cannot repair a broken program either. If an application crashes as soon as it starts, Kubernetes faithfully restarts it, watches it crash again, and keeps restarting it, more slowly each time, which is a symptom to diagnose rather than a fault in Kubernetes.&lt;/p&gt;

&lt;p&gt;This article installs a complete Kubernetes cluster inside the virtual machine from &lt;a href="https://dev.to/eugene-zimin/a-computer-inside-your-computer-1203-temp-slug-9280101"&gt;Part 1&lt;/a&gt;, takes a first look at what is running in it, starts and deliberately damages a small workload to watch the control loop at work, and then adds the two tools that Part 3 needs, an image registry to deliver our own images to the cluster and a graphical interface to watch the cluster from a browser.&lt;/p&gt;

&lt;h2&gt;
  
  
  Choosing a Kubernetes that fits on one machine
&lt;/h2&gt;

&lt;p&gt;A production cluster spreads across many machines, called nodes, so that the loss of any one of them does not take the service down. For learning, a single node is enough, and several projects package a full Kubernetes so that it runs on one computer. Minikube, the oldest of them, starts a cluster inside a VM or a container of its own, while kind, whose name abbreviates Kubernetes in Docker, runs each node as a Docker container and is popular in automated test pipelines. K3s is a lightweight distribution packaged as a single binary, aimed at small devices and edge computing, and MicroK8s, from Canonical, the company behind Ubuntu, installs a complete single-node cluster as one snap package.&lt;/p&gt;

&lt;p&gt;All four would serve the course, and the choice is made here for convenience. Because our VM already runs Ubuntu, and because the snap system that Ubuntu uses for self-contained packages installs, updates, and removes MicroK8s with one command each, MicroK8s is the shortest path from an empty VM to a running cluster. It also ships optional add-ons, such as the image registry we need later, that are enabled with a single command rather than assembled by hand.&lt;/p&gt;

&lt;h2&gt;
  
  
  Installing MicroK8s
&lt;/h2&gt;

&lt;p&gt;Open a terminal in the VM, either on its desktop with Ctrl+Alt+T or through the SSH connection described in &lt;a href="https://dev.to/eugene-zimin/a-computer-inside-your-computer-1203-temp-slug-9280101"&gt;Part 1&lt;/a&gt;, and run the commands below. The first installs MicroK8s, the second and third give your user account the right to control it without &lt;code&gt;sudo&lt;/code&gt;, and the last waits until the cluster has started.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;snap &lt;span class="nb"&gt;install &lt;/span&gt;microk8s &lt;span class="nt"&gt;--classic&lt;/span&gt; &lt;span class="nt"&gt;--channel&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1.36/stable
&lt;span class="c"&gt;# let your user run microk8s without sudo, then log out and in&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;usermod &lt;span class="nt"&gt;-a&lt;/span&gt; &lt;span class="nt"&gt;-G&lt;/span&gt; microk8s &lt;span class="nv"&gt;$USER&lt;/span&gt;
&lt;span class="nb"&gt;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; ~/.kube &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;sudo chown&lt;/span&gt; &lt;span class="nt"&gt;-R&lt;/span&gt; &lt;span class="nv"&gt;$USER&lt;/span&gt; ~/.kube
microk8s status &lt;span class="nt"&gt;--wait-ready&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each option in the first command has a purpose. The &lt;code&gt;--classic&lt;/code&gt; flag gives the snap classic confinement, which means full access to the system rather than the sandbox most snaps run in, and Kubernetes needs that access because it manages networking, storage, and processes for everything that runs on the node. The &lt;code&gt;--channel=1.36/stable&lt;/code&gt; option pins the installation to the stable releases of Kubernetes 1.36, so that automatic snap updates bring security fixes within that version but never move the cluster to a new version in the middle of the term. Figure 1 shows the installation completing.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1tjc2t9xu2mk3z20bflp.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1tjc2t9xu2mk3z20bflp.png" alt="Installing MicroK8s 1.36 from the snap store" width="800" height="500"&gt;&lt;/a&gt;&lt;em&gt;Figure 1. MicroK8s 1.36 installed from the snap store.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;usermod&lt;/code&gt; command adds your user to the &lt;code&gt;microk8s&lt;/code&gt; group, the group whose members may control the cluster, and the &lt;code&gt;~/.kube&lt;/code&gt; directory is where Kubernetes tools keep their configuration, which is why it must belong to you rather than to the administrator account. Group membership, however, is read only when you log in, so the change made by &lt;code&gt;usermod&lt;/code&gt; does not yet apply to the terminal in which you typed it. Running &lt;code&gt;microk8s status&lt;/code&gt; in that same terminal fails with the message "Insufficient permissions to access MicroK8s", which looks like the sign of a broken installation but only means that the old login session does not know about the new group.&lt;/p&gt;

&lt;p&gt;The cure is to log out of Ubuntu completely and log in again. Older guides suggest the command &lt;code&gt;newgrp microk8s&lt;/code&gt; as a shortcut, but it is no longer available on Ubuntu 26.04 Desktop, so the full log-out is the reliable way. In the new session, &lt;code&gt;microk8s status --wait-ready&lt;/code&gt; returns once the cluster is up and prints its state, as Figure 2 shows.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fui609edi7iaas7de8rmb.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fui609edi7iaas7de8rmb.png" alt="MicroK8s status and available add-ons" width="800" height="500"&gt;&lt;/a&gt;&lt;em&gt;Figure 2. The output of microk8s status, reporting that MicroK8s is running on a single node, with the dns, ha-cluster, helm, and helm3 add-ons enabled and a long list of others, including registry, available but disabled.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The output is worth reading line by line. The words "high-availability: no" say what we already know, that a single node cannot survive its own failure. The list of enabled add-ons includes &lt;code&gt;dns&lt;/code&gt;, which runs CoreDNS, the service that lets programs in the cluster find one another by name, and the long list of disabled add-ons is a catalog of optional features, among them the &lt;code&gt;registry&lt;/code&gt; add-on which we need to enable later.&lt;/p&gt;

&lt;h2&gt;
  
  
  Meeting the cluster
&lt;/h2&gt;

&lt;h3&gt;
  
  
  One command for the API
&lt;/h3&gt;

&lt;p&gt;Every interaction with a Kubernetes cluster goes through its API server, and the standard client for that API is a command-line program called &lt;code&gt;kubectl&lt;/code&gt;. MicroK8s bundles its own copy, invoked as &lt;code&gt;microk8s kubectl&lt;/code&gt;, which guarantees that client and cluster have matching versions. Since every guide, book, and error message in the Kubernetes world writes plain &lt;code&gt;kubectl&lt;/code&gt;, it is convenient to define an alias, so that the shorter name works too, and then to ask the cluster what it is running.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"alias kubectl='microk8s kubectl'"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; ~/.bash_aliases
&lt;span class="nb"&gt;source&lt;/span&gt; ~/.bash_aliases
kubectl get all &lt;span class="nt"&gt;--all-namespaces&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first line appends the alias to &lt;code&gt;~/.bash_aliases&lt;/code&gt;, a file that the Ubuntu shell reads each time a terminal opens, so the alias survives restarts. The second loads it into the terminal that is already open, and the third lists the main objects in every namespace of the cluster, producing the output in Figure 3.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9u2e9a47e1b8oq8h0mfg.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9u2e9a47e1b8oq8h0mfg.png" alt="System components of a fresh cluster" width="800" height="500"&gt;&lt;/a&gt;&lt;em&gt;Figure 3. The objects in a freshly installed cluster, where the kube-system namespace holds the Calico networking components and CoreDNS, each running as a pod, and the default namespace holds only the kubernetes service that leads to the API server.&lt;/em&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Reading the first listing
&lt;/h3&gt;

&lt;p&gt;Although we have not deployed anything yet, the cluster is far from empty, and its contents introduce most of the vocabulary that the rest of the series uses. The first column names a namespace, a label that divides a cluster into separate areas so that system components do not mix with applications. Everything Kubernetes needs to run itself lives in &lt;code&gt;kube-system&lt;/code&gt;, and our own work goes into &lt;code&gt;default&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The smallest objects in the listing are pods. A pod is one or more containers that Kubernetes schedules together, and in practice it is usually a single container wrapped in the information Kubernetes needs to manage it. Three pods are running here. Two of them, &lt;code&gt;calico-node&lt;/code&gt; and &lt;code&gt;calico-kube-controllers&lt;/code&gt;, belong to Calico, the networking layer that gives each pod an address of its own and lets pods reach one another, and the third, &lt;code&gt;coredns&lt;/code&gt;, is the name service mentioned in section above.&lt;/p&gt;

&lt;p&gt;Below the pods, the listing shows the objects that keep them alive, and here the control loop becomes visible. A Deployment, such as &lt;code&gt;deployment.apps/coredns&lt;/code&gt;, records the desired state, which here is one ready copy of the CoreDNS pod. It does not create pods directly but through a ReplicaSet, whose only job is to keep the right number of identical pods running, replacing any that disappear. The &lt;code&gt;calico-node&lt;/code&gt; pod is managed differently, by a DaemonSet, a controller that runs exactly one copy of a pod on every node, because each node needs its own networking agent. Finally, a Service, such as &lt;code&gt;service/kube-dns&lt;/code&gt;, gives a stable address to a group of pods, so that clients do not need to know which pods currently exist or where they run.&lt;/p&gt;

&lt;h2&gt;
  
  
  A first workload
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Starting it
&lt;/h3&gt;

&lt;p&gt;The best way to see the control loop at work is to give it something to do. The commands in this section are not part of the permanent environment, and the section ends by removing everything they create, but they show in a few minutes what Kubernetes does with a workload, using the public &lt;code&gt;nginx&lt;/code&gt; web server image so that no code of our own is involved yet.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;These commands were not captured in the module's screenshots, so the output you see may differ slightly in names and numbers from the descriptions below. [TODO: verify on VM]&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The first command creates a Deployment named &lt;code&gt;hello&lt;/code&gt; that runs one pod with the &lt;code&gt;nginx&lt;/code&gt; image, which Kubernetes downloads from Docker Hub. The second waits until that pod is ready, and the third creates a Service that makes the pod reachable from outside the cluster.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl create deployment hello &lt;span class="nt"&gt;--image&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;nginx
kubectl rollout status deployment/hello
kubectl expose deployment hello &lt;span class="nt"&gt;--type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;NodePort &lt;span class="nt"&gt;--port&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;80
kubectl get pods,svc &lt;span class="nt"&gt;-l&lt;/span&gt; &lt;span class="nv"&gt;app&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;hello
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The type &lt;code&gt;NodePort&lt;/code&gt; asks Kubernetes to open a port in the range 30000 to 32767 on every node and to forward traffic arriving there to the pods behind the Service. Because we did not choose a port, Kubernetes picks one at random, and the last command shows which. Its listing contains a line for the Service whose &lt;code&gt;PORT(S)&lt;/code&gt; column reads something like &lt;code&gt;80:31234/TCP&lt;/code&gt;, where the number after the colon is the node port. The option &lt;code&gt;-l app=hello&lt;/code&gt; filters the listing by label, a key and value that &lt;code&gt;kubectl create deployment&lt;/code&gt; attached to the pods and the Service for us, and labels are also how the Service knows which pods belong to it.&lt;/p&gt;

&lt;h3&gt;
  
  
  5.2 Calling it, breaking it, and watching it heal
&lt;/h3&gt;

&lt;p&gt;With the node port in hand, call the web server, replacing &lt;code&gt;31234&lt;/code&gt; with the number your own cluster chose.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; http://localhost:31234 | &lt;span class="nb"&gt;grep &lt;/span&gt;title
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The answer should be the line &lt;code&gt;&amp;lt;title&amp;gt;Welcome to nginx!&amp;lt;/title&amp;gt;&lt;/code&gt;, taken from the server's default page, which shows that a request from the VM reached a container inside the cluster through the Service. Now comes the instructive part. Delete the pod, as if it had crashed, and list the pods again immediately.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl delete pod &lt;span class="nt"&gt;-l&lt;/span&gt; &lt;span class="nv"&gt;app&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;hello
kubectl get pods &lt;span class="nt"&gt;-l&lt;/span&gt; &lt;span class="nv"&gt;app&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;hello
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A pod is still listed, but its name ends in a different random suffix and its age is a few seconds, because the ReplicaSet noticed that the number of running pods had fallen from one to zero and created a replacement at once. Nobody told it to do so. The desired state still said one pod, the actual state said none, and the controller closed the gap. Changing the desired state works the same way in the other direction, as the next command shows by asking for three copies instead of one.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl scale deployment hello &lt;span class="nt"&gt;--replicas&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;3
kubectl get pods &lt;span class="nt"&gt;-l&lt;/span&gt; &lt;span class="nv"&gt;app&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;hello
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Within seconds three pods are listed, and the Service, which selects pods by label rather than by name, spreads requests across all of them without any further change.&lt;/p&gt;

&lt;p&gt;The commands so far have been imperative, each one telling Kubernetes to perform an action, which is convenient for experiments but leaves no record of the intended state other than the cluster itself. In Part 3 we write the desired state down in a YAML file instead, the declarative style that real projects use, because a file can be reviewed, versioned, and applied again on any cluster. For now, remove the experiment so that the cluster is clean.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl delete deployment,service hello
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  A registry for our own images
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;nginx&lt;/code&gt; image came from Docker Hub, a public registry, which is a server that stores container images so that Docker and Kubernetes can pull them by name. Our own application, which we build in Part 3, has no place there, and publishing student code to a public registry would be neither necessary nor wise. A cluster can only run images it can pull from some registry, however, so we need a registry of our own, and MicroK8s provides one as an add-on.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;microk8s &lt;span class="nb"&gt;enable &lt;/span&gt;registry      &lt;span class="c"&gt;# image registry at localhost:32000&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Enabling the add-on asks for your password, because it changes system settings, and then reports what it creates, as Figure 4 shows. The registry needs somewhere to keep its images, so MicroK8s first enables the &lt;code&gt;hostpath-storage&lt;/code&gt; add-on, which stores data in a directory on the node, and warns that this kind of storage is not suitable for production, a warning that is true and harmless for a lab. It then creates a namespace called &lt;code&gt;container-registry&lt;/code&gt;, a 20 GiB storage claim, and the registry itself, reachable on port 32000.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsiz77m005sixi6gdguzm.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsiz77m005sixi6gdguzm.png" alt="Enabling the registry add-on" width="800" height="500"&gt;&lt;/a&gt;&lt;em&gt;Figure 4. Enabling the registry add-on, which asks for the sudo password, enables hostpath storage, and creates a 20 GiB registry in the container-registry namespace.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;From now on, an image whose name begins with &lt;code&gt;localhost:32000/&lt;/code&gt; refers to this registry. Docker, when we install it in Part 3, will push images there, and Kubernetes will pull them from the same address. Neither program asks for the TLS certificate that a public registry would need, because Docker treats any registry on &lt;code&gt;localhost&lt;/code&gt; as trusted by default and MicroK8s configures its own container runtime to trust this one address, an arrangement that is acceptable inside a private VM and would not be anywhere else.&lt;/p&gt;

&lt;h2&gt;
  
  
  Seeing the cluster with Headlamp
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Why a graphical view
&lt;/h3&gt;

&lt;p&gt;The command line shows the cluster one object type at a time, and that is how experienced engineers usually work, but a beginner learns faster when the relationships between objects are drawn on a screen. Earlier versions of this course used the Kubernetes Dashboard for that purpose. The Dashboard project was archived in 2026, and the Kubernetes project now points users to Headlamp, an open-source, extensible web interface that is its official successor (&lt;a href="https://kubernetes.io/blog/2026/06/01/dashboard-to-headlamp/" rel="noopener noreferrer"&gt;Kubernetes, 2026&lt;/a&gt;).&lt;/p&gt;

&lt;h3&gt;
  
  
  Installing and opening Headlamp
&lt;/h3&gt;

&lt;p&gt;Headlamp runs inside the cluster as an ordinary Deployment, so installing it means applying its manifest, the YAML file that describes its objects, straight from the project's repository. Logging in requires an identity, and Kubernetes gives programs and tools an identity called a service account. The commands below create one, grant it full rights over the cluster, issue a login token for it, and finally forward a port of the VM to Headlamp so that a browser can reach it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl apply &lt;span class="nt"&gt;-f&lt;/span&gt; https://raw.githubusercontent.com/&lt;span class="se"&gt;\&lt;/span&gt;
kubernetes-sigs/headlamp/main/kubernetes-headlamp.yaml
&lt;span class="c"&gt;# a service account to log in with (lab use only!)&lt;/span&gt;
kubectl &lt;span class="nt"&gt;-n&lt;/span&gt; kube-system create serviceaccount headlamp-admin
kubectl create clusterrolebinding headlamp-admin &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--clusterrole&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;cluster-admin &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--serviceaccount&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;kube-system:headlamp-admin
kubectl &lt;span class="nt"&gt;-n&lt;/span&gt; kube-system create token headlamp-admin
kubectl &lt;span class="nt"&gt;-n&lt;/span&gt; kube-system port-forward svc/headlamp 8888:80
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A backslash at the end of a line tells the shell that the command continues on the next line, which is how the long URL and the long &lt;code&gt;clusterrolebinding&lt;/code&gt; command fit on the page. The option &lt;code&gt;-n kube-system&lt;/code&gt; places the service account in the same namespace as Headlamp, and the cluster role binding attaches the predefined role &lt;code&gt;cluster-admin&lt;/code&gt;, which permits every action on every object, to that account. The &lt;code&gt;create token&lt;/code&gt; command prints a long string, the token, which is valid only for a limited time, so when Headlamp later refuses it you run the command again for a new one. The last command keeps running and occupies its terminal for as long as the forward is needed, carrying connections from port 8888 of the VM to port 80 of the Headlamp Service inside the cluster.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffz47m3artjoru5lkr1cx.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffz47m3artjoru5lkr1cx.png" alt="Deploying Headlamp and creating a service account" width="800" height="500"&gt;&lt;/a&gt;&lt;em&gt;Figure 5. Deploying Headlamp and creating its service account and cluster role binding, captured later in the series, which is why the prompt shows the project folder from Part 3 and the IDE appears in the dock, although the commands work the same from any folder.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhl17k1454uctilddh4oj.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhl17k1454uctilddh4oj.png" alt="Creating the token and forwarding the port" width="800" height="500"&gt;&lt;/a&gt;&lt;em&gt;Figure 6. A variant of the token and port-forward commands that saves the token to a file and runs the port forward in the background, so that the same terminal stays free for further commands.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Figure 6 shows a variation that some students find more convenient. It writes the token to a file, prints only its first 60 characters as a check, and starts the port forward in the background with &lt;code&gt;&amp;amp;&lt;/code&gt;, so that it does not occupy the terminal. Either form works, and the plain commands above are easier to read when you meet them for the first time.&lt;/p&gt;

&lt;p&gt;With the port forward running, open Firefox in the VM, go to &lt;code&gt;http://localhost:8888&lt;/code&gt;, and paste the token into the login page shown in Figure 7.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8ldn09ma54swxacfca9k.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8ldn09ma54swxacfca9k.png" alt="Logging in to Headlamp with a service-account token" width="800" height="500"&gt;&lt;/a&gt;&lt;em&gt;Figure 7. The Headlamp login page, which accepts the service-account token printed by kubectl create token.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;After logging in you see the cluster overview, with the health of the single node and of the pods listed from the command line, and a navigation bar for workloads, network objects, storage, and the cluster map. At this stage the picture is quiet, with only the system pods, the registry, and Headlamp itself, and Part 3 returns to it once our own application is running, so the screenshots of those pages appear there.&lt;/p&gt;

&lt;h3&gt;
  
  
  A warning about the cluster-admin binding
&lt;/h3&gt;

&lt;p&gt;The binding created above gives anyone holding the token complete control of the cluster, including the ability to read every secret and delete every object. On a personal lab cluster inside a VM that nobody else can reach, that convenience is reasonable. On a shared or production cluster it would be a serious security hole, and such clusters grant each person and tool only the permissions their work requires.&lt;/p&gt;

&lt;h2&gt;
  
  
  When the cluster misbehaves
&lt;/h2&gt;

&lt;p&gt;Two problems account for most of the trouble at this stage. The first, the message "Insufficient permissions to access MicroK8s", appears in every terminal opened before you logged out and back in after the &lt;code&gt;usermod&lt;/code&gt; command, and it disappears in a new session. The second is pods that remain in the Pending state for a long time, which means that Kubernetes cannot find a node with enough free memory or processor time to run them. On a single-node cluster that node is the VM itself, so the remedy is the one given in &lt;a href="https://dev.to/eugene-zimin/a-computer-inside-your-computer-1203-temp-slug-9280101"&gt;Part 1&lt;/a&gt;, to power off the VM, give it more memory or processors, and start it again, or, when an application is involved, to run fewer replicas of it.&lt;/p&gt;

&lt;p&gt;A third problem looks alarming but is entirely expected. When the VM restarts, the port forward to Headlamp ends with it, and the browser reports that the connection to &lt;code&gt;localhost:8888&lt;/code&gt; was refused. Headlamp is still installed and running in the cluster, so the remedy is to run the &lt;code&gt;port-forward&lt;/code&gt; command again and, if the token has expired, to create a new one.&lt;/p&gt;

&lt;h2&gt;
  
  
  What comes next
&lt;/h2&gt;

&lt;p&gt;The VM from &lt;a href="https://dev.to/eugene-zimin/a-computer-inside-your-computer-1203-temp-slug-9280101"&gt;Part 1&lt;/a&gt; now holds a working Kubernetes cluster. It runs a network layer and a name service, can host workloads that heal themselves and scale on request, keeps a private registry ready to receive images, and can be inspected through a browser as easily as from the command line. Every image the cluster has run so far, however, was written by somebody else. &lt;a href="//part-3-from-source-code-to-a-running-pod.md"&gt;Part 3&lt;/a&gt; closes that gap by installing a Java toolchain, Docker, and an IDE, writing a small Spring Boot service, packaging it as an image, pushing that image to the registry we have just created, and deploying it with a YAML file, so that the self-healing we watched with &lt;code&gt;nginx&lt;/code&gt; happens to code of our own.&lt;/p&gt;

</description>
      <category>devops</category>
      <category>kubernetes</category>
      <category>software</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>A Computer Inside Your Computer</title>
      <dc:creator>Eugene Zimin</dc:creator>
      <pubDate>Sun, 25 Oct 2026 09:37:31 +0000</pubDate>
      <link>https://dev.to/eugene-zimin/a-computer-inside-your-computer-7dk</link>
      <guid>https://dev.to/eugene-zimin/a-computer-inside-your-computer-7dk</guid>
      <description>&lt;h2&gt;
  
  
  Why we begin with an empty machine
&lt;/h2&gt;

&lt;p&gt;In the second or third week of almost every programming course that asks students to run real infrastructure, the room divides in two. Half the class has a working setup and has already moved on to the exercise, while the other half is staring at an error that mentions a port already in use, a library of the wrong version, or a permission that some earlier, half-remembered installation quietly changed. The code on both sides of the room is usually identical, which means the difference lies underneath it, in the layers of software that each laptop has accumulated over the years and that nobody in the room can fully describe.&lt;/p&gt;

&lt;p&gt;The remedy this series adopts is to stop installing course software on your own computer at all and to build, instead, a second computer that lives inside the first. That second computer, a virtual machine, starts completely empty, receives exactly the tools the course needs and nothing else, and behaves the same way on a Windows laptop, a MacBook, and a Linux desktop. If it is ever damaged beyond repair, it can be deleted and rebuilt in under an hour without touching a single file of your own, a freedom that makes it much easier to experiment, to break things on purpose, and to learn from what breaks.&lt;/p&gt;

&lt;p&gt;Figure 1 shows the whole environment that the three articles of this series assemble. The outer frame is your own computer, which from now on we call the host. Inside it runs VirtualBox, and inside VirtualBox runs a virtual machine with Ubuntu Linux, which will in turn hold a Java toolchain, Docker, an IDE, a browser, and a small Kubernetes cluster that runs our application. This first article builds the two outer layers, the hypervisor and the operating system, and leaves the rest of the picture for the parts that follow.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbe2fe1u3obu5xxejxcr0.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbe2fe1u3obu5xxejxcr0.png" alt="The development environment built in this series" width="800" height="401"&gt;&lt;/a&gt;&lt;em&gt;Figure 1. The complete lab environment, in which VirtualBox on your own computer hosts a single Ubuntu virtual machine that contains every tool and the Kubernetes cluster used for the rest of the course.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The work is divided into eight steps, which Figure 2 lays out in order. This article covers the first two, installing VirtualBox and creating the Ubuntu virtual machine, &lt;a href="https://dev.to/eugene-zimin/a-kubernetes-cluster-of-your-own-4lbh-temp-slug-9893045"&gt;Part 2&lt;/a&gt; covers the third, and &lt;a href="https://dev.to/eugene-zimin/from-source-code-to-a-running-pod-31kk-temp-slug-7278762"&gt;Part 3&lt;/a&gt; covers the remaining five. Each step ends with a small check, such as a version number, a login screen, or an HTTP response, so that a problem is caught in the step that caused it rather than three steps later, when its cause has become much harder to find.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fby3dututsdngs3hnuxrw.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fby3dututsdngs3hnuxrw.png" alt="The eight steps of the series" width="800" height="174"&gt;&lt;/a&gt;&lt;em&gt;Figure 2. The eight steps of the series, of which this article covers the first two.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The environment is not a throwaway exercise. Every later module of the course, from security to debugging, continuous delivery, and observability, reuses this machine, so the time spent building it carefully now is repaid many times over.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a virtual machine is
&lt;/h2&gt;

&lt;h3&gt;
  
  
  A computer simulated in software
&lt;/h3&gt;

&lt;p&gt;A virtual machine, usually shortened to VM, is a computer whose processor, memory, disk, network card, and screen are provided by a program rather than by physical hardware. The operating system installed inside it, which is called the guest, believes it is running on an ordinary computer, boots from what it takes to be a DVD, writes to what it takes to be a hard disk, and talks to what it takes to be a network. All of those devices are in fact files and processes managed by a program on the host, and that program is called a hypervisor.&lt;/p&gt;

&lt;p&gt;Hypervisors come in two broad kinds. A type 1 hypervisor runs directly on the hardware in place of an ordinary operating system, as happens in the data centers of cloud providers, whereas a type 2 hypervisor runs as an application on top of the operating system you already use, next to your browser and your editor. VirtualBox, a free and open-source hypervisor maintained by Oracle, belongs to the second kind, which is exactly what a student laptop needs, because it can be installed and removed like any other program.&lt;/p&gt;

&lt;p&gt;The image of a computer inside a computer is accurate in one important respect and misleading in another. It is accurate because the guest is genuinely isolated, so that nothing you install or delete inside Ubuntu can damage the files of your host. It is misleading if it suggests that the guest has resources of its own, because every virtual processor and every gigabyte of virtual memory is borrowed from the host for as long as the VM runs. A VM given too much starves the host, and a VM given too little crawls, which is why the next section is about choosing those numbers carefully.&lt;/p&gt;

&lt;h3&gt;
  
  
  How much of your computer to lend
&lt;/h3&gt;

&lt;p&gt;A good rule is to give the VM about half of your computer's resources. The setup used throughout this series assigns 4 processor cores, 8 GB of memory, and 60 GB of disk space, which leaves enough room for Kubernetes, Docker, and an IDE to run at the same time without long pauses. The environment also works, though noticeably more slowly, with 2 cores, 6 GB of memory, and 40 GB of disk, which is the practical minimum. The disk figure is less frightening than it looks, because, as we shall see, the virtual disk occupies only as much real space as the guest has actually written.&lt;/p&gt;

&lt;h3&gt;
  
  
  Two kinds of processor, two editions of Ubuntu
&lt;/h3&gt;

&lt;p&gt;The last decision to make before downloading anything concerns the processor in your computer. VirtualBox 7.2 supports both the Intel and AMD processors found in most Windows and Linux computers and the Apple silicon chips found in recent Macs, but these two families speak different machine languages, and a guest operating system must be built for the language of the host. On an Apple silicon Mac you therefore need the ARM64 edition of Ubuntu, while on every other computer you need the AMD64 edition. The screenshots in this series were taken on an Apple silicon Mac and so show ARM64 file names, but every command and every screen of the installer is identical on AMD64.&lt;/p&gt;

&lt;h2&gt;
  
  
  Creating the virtual machine
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Two downloads
&lt;/h3&gt;

&lt;p&gt;Two files are needed before VirtualBox can create anything. The first is VirtualBox itself, which you download for your operating system from &lt;a href="https://www.virtualbox.org/wiki/Downloads" rel="noopener noreferrer"&gt;virtualbox.org&lt;/a&gt; and install with the default options, since it is free for personal and educational use. The second is the Ubuntu 26.04 LTS desktop installer, distributed as an ISO image, a single file that contains a complete copy of an installation DVD, which you download from &lt;a href="https://ubuntu.com/download/desktop" rel="noopener noreferrer"&gt;ubuntu.com/download/desktop&lt;/a&gt;. LTS stands for Long Term Support, a promise that Canonical, the company behind Ubuntu, will publish security updates for this release for at least five years, which is why LTS releases are the usual choice for servers and for a course that should not change under its students' feet.&lt;/p&gt;

&lt;p&gt;VirtualBox is not the only possible host for the VM. VMware Workstation Pro and VMware Fusion Pro are also free for personal use, and Parallels Desktop is a common choice on Macs, so if anyone who already uses one of them can keep it, since the Ubuntu installation that follows is the same in all of them. The screenshots and settings in this article, however, are those of VirtualBox.&lt;/p&gt;

&lt;h3&gt;
  
  
  Naming the machine and choosing the image
&lt;/h3&gt;

&lt;p&gt;With both files downloaded, start VirtualBox and click &lt;em&gt;New&lt;/em&gt;. The dialog that opens, shown in Figure 3, asks first for a name, which we set to Ubuntu, and then for the ISO image, which you select from your downloads folder. VirtualBox reads the image and fills in the operating system fields by itself. Recent VirtualBox versions may not list Ubuntu 26.04 yet, in which case they show the closest version they know, here Ubuntu 25.04, which is harmless because the field only selects sensible default settings.&lt;/p&gt;

&lt;p&gt;One checkbox on this page needs your attention. VirtualBox offers to &lt;em&gt;Proceed with Unattended Installation&lt;/em&gt;, which would install Ubuntu silently with settings of its own choosing, and the box is checked by default, as it still is in the screenshot. Clear it, so that you follow the ordinary Ubuntu installer yourself, because the choices that installer asks you to make are exactly the ones you need to understand when something goes wrong later.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fszdyagjb2bd0e2cwkx2m.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fszdyagjb2bd0e2cwkx2m.png" alt="Creating a new VM with a name, an ISO image, and an operating system" width="800" height="565"&gt;&lt;/a&gt;&lt;em&gt;Figure 3. The first page of the New Virtual Machine dialog, where the VM is named Ubuntu and the ARM64 ISO is selected, and where the Proceed with Unattended Installation box, still checked here, must be cleared before continuing.&lt;/em&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Processors, memory, and disk
&lt;/h3&gt;

&lt;p&gt;Open the section &lt;em&gt;Specify virtual hardware&lt;/em&gt; and enter the resources chosen earlier, which means 8192 MB of base memory and 4 CPUs, as in Figure 4. Keep &lt;em&gt;Use EFI&lt;/em&gt; enabled. EFI is the modern firmware that a computer runs before its operating system starts, the successor of the older BIOS, and Ubuntu on ARM64 in particular expects to find it.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyxb0y4opfoaaoqk35qb1.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyxb0y4opfoaaoqk35qb1.png" alt="Assigning 8 GB of memory and 4 CPUs" width="800" height="565"&gt;&lt;/a&gt;&lt;em&gt;Figure 4. The virtual hardware section, with 8192 MB of memory, 4 processors, and EFI enabled.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Next, open &lt;em&gt;Specify virtual hard disk&lt;/em&gt; and set the size to 60 GB, as in Figure 5. The disk is dynamically allocated, which means that VirtualBox creates a file on your computer that starts small and grows only as Ubuntu actually writes data, up to the 60 GB limit, so a freshly installed VM occupies far less of your real disk than its nominal size suggests.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fil7alk3p3n3fi78gki0m.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fil7alk3p3n3fi78gki0m.png" alt="Creating a 60 GB virtual hard disk" width="800" height="565"&gt;&lt;/a&gt;&lt;em&gt;Figure 5. The virtual hard disk section, where a dynamically allocated 60 GB disk is created for the VM.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Click &lt;em&gt;Finish&lt;/em&gt;, and the new machine appears in the VirtualBox Manager, as Figure 6 shows. The details pane is worth reading once, because it confirms each decision made so far, with 8192 MB of memory, four processors, EFI enabled, a 60 GB disk file named &lt;code&gt;Ubuntu.vdi&lt;/code&gt;, the Ubuntu ISO inserted into a virtual optical drive, and a single network adapter attached to NAT, a setting we return later on.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fuck35qq1u0w3r7qqqzkh.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fuck35qq1u0w3r7qqqzkh.png" alt="The new VM in the VirtualBox Manager" width="800" height="500"&gt;&lt;/a&gt;&lt;em&gt;Figure 6. The new Ubuntu VM in the VirtualBox Manager, powered off, with its memory, processors, disk, installation ISO, and NAT network adapter listed in the details pane.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Select the VM and click &lt;em&gt;Start&lt;/em&gt;. VirtualBox opens a window that plays the role of the VM's screen, and the machine boots from the ISO image just as a physical computer would boot from an installation DVD.&lt;/p&gt;

&lt;h2&gt;
  
  
  Installing Ubuntu 26.04 LTS
&lt;/h2&gt;

&lt;h3&gt;
  
  
  From the boot menu to the installer
&lt;/h3&gt;

&lt;p&gt;The first thing the VM displays is the GRUB menu of the installation image, shown in Figure 7. GRUB is the boot loader that Ubuntu uses to choose what to start, and here it offers a few ways to start the installation medium. Choose &lt;em&gt;Try or Install Ubuntu&lt;/em&gt; and press Enter.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4xvrxwokqufvo5hy9w32.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4xvrxwokqufvo5hy9w32.png" alt="The GRUB boot menu of the Ubuntu ISO" width="800" height="600"&gt;&lt;/a&gt;&lt;em&gt;Figure 7. The GRUB boot menu of the Ubuntu installation image, where Try or Install Ubuntu starts the installer.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;After a short wait the graphical installer appears and guides you through a series of pages. The first four ask for little thought, and Figures 8 to 11 show them in order. You choose the language of the installer and the keyboard layout that matches your physical keyboard, and then the installer reports a wired network connection. That connection is not a cable at all, but the virtual network card VirtualBox provides, which the guest experiences as an Ethernet port with internet access through the host. On the fourth page, choose &lt;em&gt;Install Ubuntu&lt;/em&gt; rather than &lt;em&gt;Try Ubuntu&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fj8ob2v0c6z678utdo5sh.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fj8ob2v0c6z678utdo5sh.png" alt="Choosing the installer language" width="800" height="500"&gt;&lt;/a&gt;&lt;em&gt;Figure 8. The language page of the installer.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fj8tdyhlxirmyo5y40waw.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fj8tdyhlxirmyo5y40waw.png" alt="Selecting the keyboard layout" width="800" height="500"&gt;&lt;/a&gt;&lt;em&gt;Figure 9. The keyboard page, where the layout should match the keyboard you actually type on.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4vyija6tre3jwq817kpl.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4vyija6tre3jwq817kpl.png" alt="The VM uses VirtualBox's NAT network as a wired connection" width="800" height="500"&gt;&lt;/a&gt;&lt;em&gt;Figure 10. The network page, which reports a wired connection because the guest sees VirtualBox's virtual network card as an Ethernet port.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fuf1mp6meew7gfreaiwzc.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fuf1mp6meew7gfreaiwzc.png" alt="Choosing to install Ubuntu" width="800" height="500"&gt;&lt;/a&gt;&lt;em&gt;Figure 11. The page that chooses between trying Ubuntu from the image and installing it, where Install Ubuntu is the correct choice.&lt;/em&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  What to install
&lt;/h3&gt;

&lt;p&gt;The next three pages decide what goes onto the disk. Choose &lt;em&gt;Interactive installation&lt;/em&gt;, shown in Figure 12, which walks through the remaining questions one by one, and then the &lt;em&gt;Default selection&lt;/em&gt; of applications in Figure 13, which installs the desktop, a browser, and basic utilities without the office tools of the &lt;em&gt;Extended selection&lt;/em&gt;. The page in Figure 14, titled &lt;em&gt;Optimise your computer&lt;/em&gt;, offers third-party graphics and Wi-Fi drivers and support for additional media formats. Leave both boxes empty, because the VM has no physical graphics card or Wi-Fi adapter of its own, so such drivers would have nothing to drive.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbo6xcxdh36lghq18qfjf.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbo6xcxdh36lghq18qfjf.png" alt="Interactive installation" width="800" height="500"&gt;&lt;/a&gt;&lt;em&gt;Figure 12. The installation type page, where Interactive installation lets you make each remaining choice yourself.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftug0ymrd7x7svvgj8db5.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftug0ymrd7x7svvgj8db5.png" alt="Default selection of applications" width="800" height="500"&gt;&lt;/a&gt;&lt;em&gt;Figure 13. The applications page, where the Default selection installs a lean desktop.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fycdgtfk0dzouyzazgnho.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fycdgtfk0dzouyzazgnho.png" alt="Proprietary software is not needed in a VM" width="800" height="500"&gt;&lt;/a&gt;&lt;em&gt;Figure 14. The page offering proprietary drivers and media codecs, both left unchecked because a VM has no physical graphics or Wi-Fi hardware.&lt;/em&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  The disk page and why it is safe
&lt;/h3&gt;

&lt;p&gt;The page in Figure 15 is the one that makes students hesitate, because it offers to &lt;em&gt;Erase disk and install Ubuntu&lt;/em&gt;, and on a physical computer that sentence would mean losing everything on it. Inside a VM the meaning is different. The only disk the guest can see is the empty 60 GB virtual disk, which is a file on your host, so erasing it erases only that empty file and cannot reach a single byte of your own data. Choose this option with a clear conscience.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvhqajmi77rwygtgz3zsa.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvhqajmi77rwygtgz3zsa.png" alt="Erasing the virtual disk" width="800" height="500"&gt;&lt;/a&gt;&lt;em&gt;Figure 15. The disk setup page, where Erase disk and install Ubuntu refers only to the empty virtual disk created for this VM.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The installer then offers to encrypt the disk, as Figure 16 shows. Encryption protects a laptop that might be stolen, but a lab VM holds no personal data and would ask for an extra passphrase on every boot, so choose no encryption.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxszix1spm0c255rhzkip.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxszix1spm0c255rhzkip.png" alt="No disk encryption for the lab VM" width="800" height="500"&gt;&lt;/a&gt;&lt;em&gt;Figure 16. The encryption page, where no encryption is the sensible choice for a lab VM.&lt;/em&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Account, time zone, and review
&lt;/h3&gt;

&lt;p&gt;On the page in Figure 17, create your user account. The examples in this series use the user name &lt;code&gt;student&lt;/code&gt; and the computer name &lt;code&gt;cst8277-vm&lt;/code&gt;, and you will see both in every terminal prompt of the screenshots that follow, in the form &lt;code&gt;student@cst8277-vm&lt;/code&gt;. You may choose other names, but using the same ones makes it easier to compare your screen with the figures. Choose a password you will remember, since Ubuntu will ask for it whenever a command needs administrator rights.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fb6er0vzwef44hmx17qya.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fb6er0vzwef44hmx17qya.png" alt="Creating the user account" width="800" height="500"&gt;&lt;/a&gt;&lt;em&gt;Figure 17. The account page, where the user student and the computer name cst8277-vm are created.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Choose your time zone next, as in Figure 18, where Toronto has been selected. The installer then summarizes every choice on a review page, shown in Figure 19, which is the last moment at which anything can be changed without starting over. Read it, and click &lt;em&gt;Install&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fybg8zrgcfizu8rtwpov0.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fybg8zrgcfizu8rtwpov0.png" alt="Selecting the time zone" width="800" height="500"&gt;&lt;/a&gt;&lt;em&gt;Figure 18. The time zone page, with Toronto and the America/Toronto zone selected.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3kop21dt3iq4hfx0kqzw.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3kop21dt3iq4hfx0kqzw.png" alt="Reviewing the installation choices" width="800" height="500"&gt;&lt;/a&gt;&lt;em&gt;Figure 19. The review page, which lists every choice made so far before the installation begins.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The installation takes about ten minutes, during which the installer shows a progress page like the one in Figure 20. When it finishes, it announces that the installation is complete and offers &lt;em&gt;Restart now&lt;/em&gt;, as in Figure 21.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fns6clkw2jlcsy2mtywgh.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fns6clkw2jlcsy2mtywgh.png" alt="Installation in progress" width="800" height="500"&gt;&lt;/a&gt;&lt;em&gt;Figure 20. The installation in progress.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F39ihudr76w65wwezpyy8.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F39ihudr76w65wwezpyy8.png" alt="Installation complete" width="800" height="500"&gt;&lt;/a&gt;&lt;em&gt;Figure 21. The installer reporting that the installation is complete, with the Restart now button.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The first boot
&lt;/h2&gt;

&lt;p&gt;When you click &lt;em&gt;Restart now&lt;/em&gt;, the VM shuts down the installer and, before starting again, asks you to remove the installation medium and press Enter, as Figure 22 shows. On a physical computer you would take the DVD out of the drive, which is necessary because otherwise the machine would boot into the installer once more. In VirtualBox there is nothing to remove by hand, because pressing Enter is enough and VirtualBox ejects the virtual DVD by itself.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8rn4poyfm9cvwtwks22u.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8rn4poyfm9cvwtwks22u.png" alt="Removing the installation medium before the first boot" width="800" height="500"&gt;&lt;/a&gt;&lt;em&gt;Figure 22. The request to remove the installation medium, which in VirtualBox only requires pressing Enter.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Ubuntu now boots from its own virtual disk for the first time and shows the login screen in Figure 23. Log in with the password you chose on the account page.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpf80nlxviuv3ypaccye7.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpf80nlxviuv3ypaccye7.png" alt="Logging in to Ubuntu" width="800" height="500"&gt;&lt;/a&gt;&lt;em&gt;Figure 23. The Ubuntu login screen with the student account.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;A welcome assistant greets you after the first login, as in Figure 24, and walks you through a few optional settings, which you may accept or skip as you like. In the screenshot a notification has already appeared that 74 updates are available, a reminder that an ISO image is a snapshot of the day it was built and that the packages it installed have been revised many times since.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4vgel7bpd4fxnyb8h7ag.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4vgel7bpd4fxnyb8h7ag.png" alt="The Ubuntu welcome screen" width="800" height="500"&gt;&lt;/a&gt;&lt;em&gt;Figure 24. The welcome assistant of Ubuntu 26.04.1 LTS, with a notification that software updates are already available.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Once the assistant closes, you have a working Ubuntu desktop, shown in Figure 25, with the dock of applications on the left and, at the bottom of the dock, the button that shows every installed application.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyterapbibvyywx559gz6.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyterapbibvyywx559gz6.png" alt="The Ubuntu 26.04 LTS desktop" width="800" height="500"&gt;&lt;/a&gt;&lt;em&gt;Figure 25. The Ubuntu 26.04 LTS desktop, ready for use.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The most important application on that desktop, for the rest of this series, is the terminal, which you open by pressing Ctrl+Alt+T. Its first job is to bring the system up to date. You can accept the updates offered by the Software Updater, or run the two commands below, which first refresh the list of available packages and then install the newer versions of everything already installed.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;apt-get update &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;sudo &lt;/span&gt;apt-get upgrade
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The word &lt;code&gt;sudo&lt;/code&gt; in front of each command runs it with administrator rights, which is why Ubuntu asks for your password the first time. The &lt;code&gt;&amp;amp;&amp;amp;&lt;/code&gt; between the two commands means that the second runs only if the first succeeds, a small habit that prevents a failed update from being followed by an upgrade based on stale information. If the upgrade replaces the kernel, Ubuntu will suggest a restart, and it is worth accepting before you continue.&lt;/p&gt;

&lt;h2&gt;
  
  
  Optional work from your own terminal
&lt;/h2&gt;

&lt;h3&gt;
  
  
  How the VM reaches the network
&lt;/h3&gt;

&lt;p&gt;The VM window is a perfectly good place to work, but typing long commands into it can be tedious, and copying and pasting between the host and the guest does not always work smoothly. Many students therefore prefer to work from a terminal on their own computer and connect to the VM over SSH, the standard protocol for logging in to a remote Linux machine. Doing so requires one small change to how the VM is connected to the network, and understanding that change also explains how the VM reached the internet during the installation.&lt;/p&gt;

&lt;p&gt;The network adapter we saw in Figure 6 is attached to NAT, which stands for Network Address Translation. In this mode VirtualBox acts like the router in a home network, giving the guest a private address of its own, usually 10.0.2.15, and forwarding the guest's outgoing connections to the internet under the host's address. That is why &lt;code&gt;apt-get&lt;/code&gt;, the snap store, and Docker Hub can all be reached from inside the VM without any configuration. The same arrangement has a consequence that surprises many students, which is that connections cannot be opened in the other direction, from the host to the guest, unless we tell VirtualBox explicitly which port on the host should lead to which port in the guest. Figure 26 shows such a rule, called a port forward, for SSH.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8vgyrks3vcvazkndpp29.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8vgyrks3vcvazkndpp29.png" alt="VirtualBox NAT networking and port forwarding" width="798" height="197"&gt;&lt;/a&gt;&lt;em&gt;Figure 26. VirtualBox NAT networking, in which the guest reaches the internet through the host, and a port-forwarding rule from 127.0.0.1:2222 on the host to port 22 in the guest makes SSH possible in the opposite direction.&lt;/em&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Setting up the port forward
&lt;/h3&gt;

&lt;p&gt;There are two equivalent ways to create the rule. In the VirtualBox Manager, open the VM's settings, go to &lt;em&gt;Network&lt;/em&gt;, then to &lt;em&gt;Adapter 1 (NAT)&lt;/em&gt;, then to &lt;em&gt;Port Forwarding&lt;/em&gt;, and add a rule from host address 127.0.0.1 and host port 2222 to guest port 22. Alternatively, while the VM is powered off, run the following command in a terminal on your host.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;VBoxManage modifyvm Ubuntu &lt;span class="nt"&gt;--natpf1&lt;/span&gt; &lt;span class="s2"&gt;"ssh,tcp,127.0.0.1,2222,,22"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The quoted argument lists, in order, a name for the rule, the protocol, the host address and port, an empty guest address that VirtualBox fills in itself, and the guest port. Binding the rule to 127.0.0.1 rather than to every address of the host means that only programs on your own computer can use the forward, and not other machines on the same Wi-Fi network.&lt;/p&gt;

&lt;p&gt;The rule is useless until something inside the guest is listening on port 22, and the default Ubuntu desktop does not include an SSH server. Start the VM, install the server from its terminal, and then connect from a terminal on the host.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# inside the VM&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;apt-get &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; openssh-server

&lt;span class="c"&gt;# on your own computer&lt;/span&gt;
ssh &lt;span class="nt"&gt;-p&lt;/span&gt; 2222 student@127.0.0.1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;-p 2222&lt;/code&gt; option tells the SSH client to use the forwarded port rather than the standard port 22, and VirtualBox carries the connection through to the guest. From here on, every command in this series can be typed either in the VM's own terminal or in this SSH session, whichever you find more comfortable, with the exception of graphical programs such as the browser and the IDE, which must run on the VM's desktop.&lt;/p&gt;

&lt;h2&gt;
  
  
  When the VM misbehaves
&lt;/h2&gt;

&lt;p&gt;A VM fails in ways a physical computer does not, and three problems account for most of the trouble students report at this stage. The most common is a VM that is very slow, which almost always means it has too little memory or too few processors for what it is being asked to do. Power the VM off, since VirtualBox refuses to change these settings while it runs, raise the values toward the 4 cores and 8 GB, and start it again. Later in the series, when Kubernetes runs inside the VM, the same shortage shows up as application pods that stay in the Pending state, and the remedy is the same.&lt;/p&gt;

&lt;p&gt;The second problem is a VirtualBox window that freezes or a VM that crashes, which is most often caused by an outdated version of VirtualBox on a recently updated host. Updating VirtualBox usually cures it. If the window itself is what freezes, saving the VM's state, or starting the VM headless from the VirtualBox Manager and working entirely over the SSH connection sidesteps the problem.&lt;/p&gt;

&lt;p&gt;The third problem is the hardest to diagnose, because the VM fails to boot from the installation image, without any message that names the cause. The cause is nearly always an ISO of the wrong edition, AMD64 on an Apple silicon Mac or ARM64 on an Intel or AMD computer, and the remedy is to download the edition that matches the processor and to select it in the VM's storage settings.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you have now
&lt;/h2&gt;

&lt;p&gt;You now have a complete Ubuntu 26.04 LTS computer that runs inside your own, isolated from your files, sized for the work ahead, and reachable both from its own desktop and, if you set up the port forward, from your own terminal. Before moving on, it is worth confirming two things from a terminal in the VM. The first command below prints the name and version of the operating system, which should report Ubuntu 26.04 LTS, and the second shows the size and free space of the root file system, which should be close to the 60 GB you assigned, minus the space the installation already uses.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;lsb_release &lt;span class="nt"&gt;-a&lt;/span&gt;
&lt;span class="nb"&gt;df&lt;/span&gt; &lt;span class="nt"&gt;-h&lt;/span&gt; /
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The machine is still, in a sense, empty, since it runs a desktop and a browser and nothing that resembles an enterprise application platform. The earlier modules of the course described such a platform in theory, with containers grouped into pods, pods managed by an orchestrator that restarts them when they fail, and services that route traffic to whichever pods happen to be alive. &lt;a href="https://dev.to/eugene-zimin/a-kubernetes-cluster-of-your-own-4lbh-temp-slug-9893045"&gt;Part 2&lt;/a&gt; installs that orchestrator, Kubernetes, inside the VM we have just built, so that from then on the theory can be watched at work.&lt;/p&gt;

</description>
      <category>virtualmachine</category>
      <category>software</category>
      <category>linux</category>
      <category>development</category>
    </item>
    <item>
      <title>A Clean and Fast macOS Terminal with Ghostty, Starship, and Antidote</title>
      <dc:creator>Eugene Zimin</dc:creator>
      <pubDate>Sat, 24 Oct 2026 20:10:36 +0000</pubDate>
      <link>https://dev.to/eugene-zimin/a-clean-and-fast-macos-terminal-with-ghostty-starship-and-antidote-5ai</link>
      <guid>https://dev.to/eugene-zimin/a-clean-and-fast-macos-terminal-with-ghostty-starship-and-antidote-5ai</guid>
      <description>&lt;p&gt;My terminal is where I spend most of my working day, yet for years my setup was one long &lt;code&gt;.zshrc&lt;/code&gt;, a full framework I only used a fraction of, and a home folder full of hidden files I couldn't explain. In the last few weeks, I sat down and rebuilt it from the ground up around three small tools: Ghostty, starship, and antidote.&lt;/p&gt;

&lt;p&gt;So, below, you will find a short walkthrough of that setup, along with a few suggestions for keeping your home folder clean without breaking anything. All config files are in my &lt;a href="https://github.com/eugenezimin/development-utilities/tree/main/initial-install" rel="noopener noreferrer"&gt;development-utilities&lt;/a&gt; repository. In my experience, reading through a few small config files is still the best way to understand how a shell really starts up.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Ghostty, zsh, antidote, and starship fit together
&lt;/h2&gt;

&lt;p&gt;As a small side note, and since these names often get mixed up, here's how the pieces relate.&lt;/p&gt;

&lt;p&gt;If we think of the setup as a car, &lt;strong&gt;zsh&lt;/strong&gt; is the engine. &lt;strong&gt;antidote&lt;/strong&gt; is the parts manager that bolts plugins onto the engine (completion, syntax highlighting, git aliases). &lt;strong&gt;starship&lt;/strong&gt; is the dashboard that shows where you are and what state your repo is in. And &lt;strong&gt;Ghostty&lt;/strong&gt; is the body around it: the terminal emulator that draws everything quickly on the GPU.&lt;/p&gt;

&lt;p&gt;Each of these can be swapped out on its own, which is the main reason I like this combination over an all-in-one framework.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnjt5fcxxsmzaev1v1ukd.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnjt5fcxxsmzaev1v1ukd.png" alt="Terminal greeting and prompt in Ghostty" width="800" height="503"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Figure 1: The end result in a new Ghostty tab. A one-table system summary, followed by a two-line starship prompt.&lt;/em&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Scope and prerequisites
&lt;/h2&gt;

&lt;p&gt;This setup targets macOS (Apple Silicon or Intel) with &lt;a href="https://brew.sh" rel="noopener noreferrer"&gt;Homebrew&lt;/a&gt;. Installing the tools takes one line each:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;brew &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;--cask&lt;/span&gt; ghostty
brew &lt;span class="nb"&gt;install &lt;/span&gt;antidote starship
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There's no need to install a patched font for the icons, since Ghostty bundles the Nerd Font symbols. (In iTerm2 or Terminal.app you would need a Nerd Font, otherwise the icons show up as boxes.)&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Ghostty: the terminal
&lt;/h2&gt;

&lt;p&gt;Ghostty is fast, native on macOS, and configured through a single plain-text file. Instead of the default &lt;code&gt;~/Library/Application Support/com.mitchellh.ghostty/&lt;/code&gt; location, I keep that file at &lt;code&gt;~/.config/ghostty/config.ghostty&lt;/code&gt;, next to everything else. Here is a trimmed version of mine:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="py"&gt;font-family&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;"Nordwand Mono"&lt;/span&gt;
&lt;span class="py"&gt;font-size&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;13&lt;/span&gt;
&lt;span class="py"&gt;background-opacity&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;0.92&lt;/span&gt;
&lt;span class="py"&gt;background-blur-radius&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;15&lt;/span&gt;
&lt;span class="py"&gt;cursor-style&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;underline&lt;/span&gt;
&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="py"&gt;keybind&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;global:cmd+grave_accent=toggle_quick_terminal&lt;/span&gt;
&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="py"&gt;shell-integration&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;zsh&lt;/span&gt;
&lt;span class="py"&gt;shell-integration-features&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;no-title,ssh-terminfo&lt;/span&gt;
&lt;span class="py"&gt;copy-on-select&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;clipboard&lt;/span&gt;
&lt;span class="py"&gt;mouse-hide-while-typing&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Most of these lines are a matter of taste, but two of them changed how I work. The first is the &lt;code&gt;keybind&lt;/code&gt; line, which turns Ghostty into a drop-down "quick terminal": wherever I am, whether in the browser, the editor, or a PDF, &lt;code&gt;Cmd+Backtick&lt;/code&gt; slides a terminal down from the top of the screen, and the same key sends it away again. After a week, I stopped opening terminal windows for quick commands altogether.&lt;/p&gt;

&lt;p&gt;The second is the shell integration line. I keep Ghostty's integration on, but switch off its tab titles with &lt;code&gt;no-title&lt;/code&gt;, because I like my tabs to tell me a little more. A small zsh hook in &lt;code&gt;conf.d/60-ghostty-title.zsh&lt;/code&gt; names each tab after the folder, the git branch, and the command that's running, so a tab reads, for example, &lt;code&gt;acme-api - main - cargo&lt;/code&gt;. With five tabs open, that's the difference between finding the right one at a glance and clicking through all of them. The &lt;code&gt;ssh-terminfo&lt;/code&gt; feature, finally, quietly teaches remote hosts about Ghostty's terminal type, so colours and keys keep working when I SSH into a server.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Keeping the home folder clean
&lt;/h2&gt;

&lt;p&gt;By default, zsh reads &lt;code&gt;~/.zshrc&lt;/code&gt;, &lt;code&gt;~/.zprofile&lt;/code&gt;, &lt;code&gt;~/.zsh_history&lt;/code&gt;, and friends, all straight from your home folder. The trick is a single variable, &lt;code&gt;ZDOTDIR&lt;/code&gt;, which tells zsh where to look instead.&lt;/p&gt;

&lt;p&gt;The only zsh file left in &lt;code&gt;~&lt;/code&gt; is a three-line &lt;code&gt;~/.zshenv&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# zsh reads this file before ZDOTDIR is known; everything else lives in ~/.config/zsh.&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;ZDOTDIR&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$HOME&lt;/span&gt;&lt;span class="s2"&gt;/.config/zsh"&lt;/span&gt;
&lt;span class="o"&gt;[[&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$ZDOTDIR&lt;/span&gt;&lt;span class="s2"&gt;/.zshenv"&lt;/span&gt; &lt;span class="o"&gt;]]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;source&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$ZDOTDIR&lt;/span&gt;&lt;span class="s2"&gt;/.zshenv"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The nice part is that macOS plays along. &lt;code&gt;/etc/zshrc&lt;/code&gt; sets &lt;code&gt;HISTFILE=${ZDOTDIR:-$HOME}/.zsh_history&lt;/code&gt;, and Terminal's session files go to &lt;code&gt;${ZDOTDIR:-$HOME}/.zsh_sessions&lt;/code&gt;, so both follow you into &lt;code&gt;~/.config/zsh&lt;/code&gt; with no extra config.&lt;/p&gt;

&lt;p&gt;Everything else now lives together in &lt;code&gt;~/.config/zsh&lt;/code&gt;: the &lt;code&gt;.zshrc&lt;/code&gt; and &lt;code&gt;.zprofile&lt;/code&gt;, the plugin list, and a &lt;code&gt;conf.d/&lt;/code&gt; folder with small numbered files for options, environment variables, aliases, and functions. Instead of scrolling through one long file, I open the one file that's about the thing I want to change. API keys and tokens get a file of their own, &lt;code&gt;.zsh_secrets&lt;/code&gt;, readable only by me (chmod 600) and never copied into the repository, so I can share my dotfiles without worrying about leaking anything. Caches stay out of sight as well: starship and the greeting write to &lt;code&gt;~/.cache&lt;/code&gt;, and antidote keeps its plugin clones in &lt;code&gt;~/Library/Caches/antidote&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;There's one catch I ran into myself. After the switch, the old &lt;code&gt;~/.zsh_history&lt;/code&gt; and &lt;code&gt;~/.zsh_sessions&lt;/code&gt; are still sitting in your home folder, but nothing reads them anymore, so your shell suddenly seems to have forgotten every command you ever typed. The fix is to move the history over &lt;em&gt;before&lt;/em&gt; you open your first new shell (&lt;code&gt;mv ~/.zsh_history ~/.config/zsh/&lt;/code&gt;), and then delete &lt;code&gt;~/.zsh_sessions&lt;/code&gt;. In the same spirit, keep in mind that an old &lt;code&gt;~/.zshrc&lt;/code&gt; is silently ignored once &lt;code&gt;ZDOTDIR&lt;/code&gt; is set, which can be confusing if you forget it's there.&lt;/p&gt;

&lt;p&gt;And if you'd like to go further, many other tools can be convinced to move out, too. Rust, for example, reads &lt;code&gt;CARGO_HOME&lt;/code&gt; and &lt;code&gt;RUSTUP_HOME&lt;/code&gt;, and npm reads &lt;code&gt;NPM_CONFIG_CACHE&lt;/code&gt;. The trick is to set these in &lt;code&gt;$ZDOTDIR/.zshenv&lt;/code&gt; &lt;em&gt;before&lt;/em&gt; installing the tool. Otherwise, you'll need to move the existing folders yourself.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnvwses61gh4opvzp62oo.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnvwses61gh4opvzp62oo.png" alt="A clean home folder" width="800" height="394"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Figure 2: The home folder before (Oh My Zsh and Powerlevel10k, everything in &lt;code&gt;~&lt;/code&gt;) and after the &lt;code&gt;ZDOTDIR&lt;/code&gt; switch, where a single &lt;code&gt;.zshenv&lt;/code&gt; is left. The listings are demo values.&lt;/em&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  3. Antidote: plugins without a framework
&lt;/h2&gt;

&lt;p&gt;I used Oh My Zsh for years, but I only needed a handful of its files. antidote reads a plain list of plugins, builds a static bundle from it, and only rebuilds that bundle when the list changes, so startup stays fast.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;getantidote/use-omz                      &lt;span class="c"&gt;# Oh My Zsh pieces, without the framework&lt;/span&gt;
ohmyzsh/ohmyzsh path:lib/history.zsh
ohmyzsh/ohmyzsh path:plugins/git
marlonrichert/zsh-autocomplete           &lt;span class="c"&gt;# completion menu as you type&lt;/span&gt;
zsh-users/zsh-syntax-highlighting        &lt;span class="c"&gt;# must stay last&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first line, &lt;code&gt;use-omz&lt;/code&gt;, is what makes this work. It lets me pick individual Oh My Zsh files, like the history settings or the git aliases I've had in my fingers for years, without loading the framework around them.&lt;/p&gt;

&lt;p&gt;The order of the list matters more than it looks, though. &lt;code&gt;zsh-autocomplete&lt;/code&gt; sets up completion on its own, so it needs to come after the Oh My Zsh pieces, and &lt;code&gt;zsh-syntax-highlighting&lt;/code&gt; wants to see every other plugin first, so it always goes last. I learned both of these the hard way, so I left myself comments in the file.&lt;/p&gt;

&lt;p&gt;Day to day, I hardly think about antidote at all. When I add a line to the list, the next shell picks it up, and every few weeks I run &lt;code&gt;antidote update&lt;/code&gt; to pull the latest version of everything.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Starship: the prompt
&lt;/h2&gt;

&lt;p&gt;Finally, starship draws a two-line prompt modelled on Powerlevel10k's "lean" style. Line one shows the path and git state. Line two holds the &lt;code&gt;❯&lt;/code&gt; input marker, with status and project information on the right.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdx5tvfw3u4ntjn2t4cry.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdx5tvfw3u4ntjn2t4cry.png" alt="Prompt in different folders and git states" width="800" height="366"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Figure 3: The prompt in different repos: local changes, a deep subfolder, a detached tag, a rebase in progress, and a read-only folder.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The first line is where the git information lives, and it's the part I'd miss most. In the top row of the figure, &lt;code&gt;⇡2 +1 !2 ?1 *1&lt;/code&gt; tells me, without typing &lt;code&gt;git status&lt;/code&gt;, that I have two commits I haven't pushed, one staged file, two modified files, an untracked file, and something stashed. A clean repo shows nothing but the branch name, so when the line is short, I know I'm done.&lt;/p&gt;

&lt;p&gt;The path took the most work to get right. Long paths are hard to read, but cutting them down too far loses track of which project I'm in. So parent folders are shortened to a single letter, while the repo root and the current folder stay whole: deep inside a project, the prompt reads &lt;code&gt;~/D/acme-api/s/h/auth&lt;/code&gt;. Starship can't do this on its own, so a small pure-zsh function in &lt;code&gt;prompt.zsh&lt;/code&gt; works out the pieces before each prompt.&lt;/p&gt;

&lt;p&gt;The right side stays empty most of the time and only fills in when there's something worth saying, like a failed command's exit code, a command that ran longer than three seconds, or the package and language versions in a project folder. If you ever wonder what a symbol means or why the prompt feels slow, &lt;code&gt;starship explain&lt;/code&gt; and &lt;code&gt;starship timings&lt;/code&gt; will tell you.&lt;/p&gt;

&lt;h2&gt;
  
  
  Final words
&lt;/h2&gt;

&lt;p&gt;You don't have to adopt all of this at once. In fact, I'd suggest starting with the &lt;code&gt;ZDOTDIR&lt;/code&gt; change, since it's three lines and immediately tidies up your home folder. Then add antidote and starship one at a time and open a new tab after each step.&lt;/p&gt;

&lt;p&gt;If you'd rather not copy files by hand, the installer in the same repo can restore just this part: run &lt;code&gt;./install-all-macos.sh --dry-run&lt;/code&gt; first, and select only the &lt;code&gt;shell&lt;/code&gt; step.&lt;/p&gt;

&lt;p&gt;Thanks for reading, and I hope you enjoy your new terminal!&lt;/p&gt;

</description>
      <category>software</category>
      <category>terminal</category>
      <category>cli</category>
      <category>rust</category>
    </item>
    <item>
      <title>Building Pace Maker or how I did load testing</title>
      <dc:creator>Eugene Zimin</dc:creator>
      <pubDate>Mon, 19 Oct 2026 18:49:07 +0000</pubDate>
      <link>https://dev.to/eugene-zimin/building-pace-maker-or-how-i-did-load-testing-21p9</link>
      <guid>https://dev.to/eugene-zimin/building-pace-maker-or-how-i-did-load-testing-21p9</guid>
      <description>&lt;p&gt;Most of the load tests I have run over the years were not grand capacity-planning exercises but small and slightly anxious questions asked late in the afternoon, such as whether a freshly deployed endpoint would survive a few hundred requests per second or whether a caching change had actually made a service faster, and almost every time the tooling around those questions felt heavier than the questions themselves. Just imagine the JMeter - nothing wrong, but I'd rather use a curl with a loop, than spend time on setting it up, even though it's powerful. Writing a scenario file, pulling a container image, or keeping a browser tab open on a hosted dashboard is perfectly reasonable for a scheduled performance suite, yet it is a lot of ceremony when all you want is to point something at a URL, decide how hard to push, and watch what happens. Brrr, no. I stopped and decided to make something which is free and much comfortable for me. &lt;/p&gt;

&lt;p&gt;That's how Pace Maker, a small native macOS app written in Swift and SwiftUI, was appeared.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8uvbu34ocj76oo0ryf5t.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8uvbu34ocj76oo0ryf5t.png" alt="Pace Maker running a load test, with the live chart in the main area and the settings inspector on the right" width="800" height="553"&gt;&lt;/a&gt;&lt;/p&gt;

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

&lt;p&gt;Thinking about who would actually reach for such a tool shaped almost every later decision, because the typical user is a backend developer who already knows the endpoint, has a rough idea of the expected traffic, and mostly needs a fast and trustworthy feedback loop rather than a report generator, which means the window should be organized around the one thing that person keeps looking at while the test runs. And that's exactly what I needed.&lt;/p&gt;

&lt;p&gt;The application is simple yet powerful - its live chart owns the main area of the window, the settings live in a native inspector that can be hidden once a test is configured, and the controls that change the state of a run, together with the elapsed time and the number of requests sent, sit in the toolbar and the window subtitle, where they stay visible without competing with the data. Name it "focus mode", but that's definitely not a cockpit.&lt;/p&gt;

&lt;h2&gt;
  
  
  Separating pacing from concurrency
&lt;/h2&gt;

&lt;p&gt;The most important architectural idea in the app is that the rate at which requests are released and the number of requests allowed to be in flight are two different concerns, and conflating them is the reason so many simple load generators quietly misreport what they measured. If a tool sends the next request only after a previous one returns, a slow server automatically slows the tool down, and the chart ends up showing whatever pace the server could tolerate instead of the pace you asked for, which hides precisely the degradation you were trying to observe.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fy1pquq9x7geunrcf5wve.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fy1pquq9x7geunrcf5wve.png" alt="The request pipeline, from the rate pacer through the backlog and the adaptive connection gate to the worker pool, with outcomes flowing back through counters and the sampler" width="800" height="286"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Pace Maker therefore releases requests on a schedule derived from the target rate alone, and those requests enter a bounded backlog that is served by a fixed pool of workers, so nothing is created per request thus memory stays flat regardless of how long a test runs or how high the rate goes. At the same time between the backlog and the workers sits an adaptive connection gate, which decides how many requests may be in flight at any moment by reasoning roughly along the lines of Little's law, since the concurrency needed to sustain a rate is approximately that rate multiplied by the current latency, and which backs off when errors climb while throughput stalls, because that combination usually signals a saturated server rather than one that merely needs more connections. When the backlog fills up, the excess is shed and counted instead of being buffered without limit, and this choice is what keeps the backpressure honest, because the user sees the achieved rate fall below the target exactly when the endpoint can no longer keep up, which is the very signal a load test exists to produce.&lt;/p&gt;

&lt;h2&gt;
  
  
  Measuring as if someone will check
&lt;/h2&gt;

&lt;p&gt;A chart is only as useful as the arithmetic behind it, and several of the more instructive bugs in the project came from numbers that looked plausible while being subtly wrong, such as failures being counted per sampling window while successes were normalized per second, which quietly halved the apparent error rate whenever the sampling interval was shorter than a second. Every series is now normalized to requests per second, the error rate is defined as the share of finished requests that failed rather than the share of requests sent, and the final partial window of a run is included in the totals but deliberately left out of the chart, because a rate computed over a few milliseconds is noise that would otherwise draw a dramatic spike at the very end of an otherwise calm test. The same concern for honesty extends to pausing, since resuming a test continues with whatever time and request budget remain, so a sixty-second test stays a sixty-second test no matter how often it is interrupted.&lt;/p&gt;

&lt;h2&gt;
  
  
  Composing real requests
&lt;/h2&gt;

&lt;p&gt;Read endpoints are only half of most APIs, so the app sends every common HTTP method, from GET and HEAD through POST, PUT, PATCH, and DELETE to OPTIONS, and the methods that carry a body get a dedicated Load tab in which the body is written, typed as JSON, form data, plain text, or XML, and highlighted as you type.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhjr907wy0oy42s0zyd9c.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhjr907wy0oy42s0zyd9c.png" alt="The Load tab with a JSON request body and its live size and validity check" width="800" height="553"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Building that editor surfaced a small lesson that is easy to miss, because the standard text editor on macOS follows the system setting for smart quotes and silently turns straight quotation marks into typographic ones, which produces JSON that looks correct on screen and fails on the server, so the body editor disables every automatic substitution and shows a live check of the body size and of whether the JSON is valid. The Texts tab then closes the loop by showing the request exactly as it is sent, together with the most recent distinct responses, which makes it easy to confirm that the server answered with the expected status and payload before trusting anything the chart says.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwkb22netfl6seeonl259.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwkb22netfl6seeonl259.png" alt="The Texts tab showing the request as sent and the most recent responses" width="800" height="553"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Architecture
&lt;/h2&gt;

&lt;p&gt;Underneath the interface the structure is deliberately plain, with SwiftUI views observing a single session model, the model talking to an engine facade that owns the lifecycle of a run, and the facade delegating the actual traffic to a transport adapter, so that the views never know how requests are sent and the adapters never know how anything is drawn.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F35wazlnofooh0ixlh5qt.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F35wazlnofooh0ixlh5qt.png" alt="The layers of the app, from the SwiftUI interface through the observable session model and the engine facade down to the transport adapters" width="800" height="703"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Because the model is observable at the level of individual properties, a tick from the engine redraws only the views that display tick data, and editing a setting no longer redraws the chart, which mattered more than expected once tests ran long enough to accumulate thousands of points. The adapter boundary is also where the roadmap lives, since HTTP is the only real transport today while gRPC, WebSocket, and raw TCP are planned as further adapters behind the same contract, and keeping the pure parts of the system, such as the pacing arithmetic, the chart windowing, and the response bookkeeping, free of interface code is what allows most of the behavior to be covered by fast unit tests that run on every pull request.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it goes from here
&lt;/h2&gt;

&lt;p&gt;Pace Maker is still a small tool, and that is very much the point, because the value of a quick load test lies in how little stands between the question and the answer. The next steps are the remaining transports and a few refinements to how the connection gate tells random errors apart from genuine saturation, and if you would like to try the current release, the signed builds and a full feature overview are available in the &lt;a href="https://github.com/eugenezimin/Pace-Maker" rel="noopener noreferrer"&gt;public repository&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>softwaredevelopment</category>
      <category>softwareengineering</category>
      <category>tooling</category>
      <category>testing</category>
    </item>
    <item>
      <title>The Concurrency Cost in Different Programming Languages</title>
      <dc:creator>Eugene Zimin</dc:creator>
      <pubDate>Sun, 21 Jun 2026 21:09:40 +0000</pubDate>
      <link>https://dev.to/eugene-zimin/five-languages-one-loop-a-cross-language-concurrency-benchmark-the-beginning-403a</link>
      <guid>https://dev.to/eugene-zimin/five-languages-one-loop-a-cross-language-concurrency-benchmark-the-beginning-403a</guid>
      <description>&lt;p&gt;Many of the developers and engineers, especially younger generation used to use concurrency in their projects as is - just throw a thread, join it at the end, then destroy. More experiences guys use threadpool, which doesn't kill threads and doesn't waste time on their re-initializing. Seniors and staff guys probably knows that it's not that obvious under the hood, and that's what we would like to examine to reveal for making more pragmatic choice while using multithreading, concurrency and parallelism in general.&lt;/p&gt;

&lt;p&gt;If we take a look around, most of the benchmarks use prepared workflows which usually are well enough to be parallelized, meaning that data is isolated, algorithms are never changed and there is no direct impact on datasets in different threads. This is what is called ideal conditions - we all know them and definitely use threading as we can see there real impact on our efficiency and it is positive. Under such circumstances the problem is almost invisible and negligible. &lt;/p&gt;

&lt;p&gt;However the data is not always prepared and in many variations it became so small that spawning the thread, queuing a job, waking a worker, cost of making application working with threading becomes notable enough to be considered and surprisingly may lead to worse performance with enabled concurrency than without it.&lt;/p&gt;

&lt;p&gt;I prepared few test cases to find out all of this machinery and measure its impact.&lt;/p&gt;

&lt;p&gt;My goal is to measure and quantify the pure overhead of different concurrency approaches - raw thread creation and join, a simple thread pool with work-stealing and lock-based queues, as well as some higher-level abstractions like &lt;code&gt;async&lt;/code&gt;. In this case, by isolating the costs of thread startup, context switches, cache bouncing and memory barriers with the cost of synchronizing primitives, I can draw a simple decision boundary - at which point concurrency stops giving benefits and brings more problems. Results should help to decide whether to stay sequential, go async, use a thread pool, or invest in more sophisticated technics.&lt;/p&gt;

&lt;h2&gt;
  
  
  The First Workload - CPU only
&lt;/h2&gt;

&lt;p&gt;The work itself is always the same and it's very CPU intense (for the first round - later I'll make other ops tested, like IO, mixed load, etc.). I have ten thousand terms of the Leibniz series for π, which is a perfect pure computation with a tight floating-point loop. Usually it finishes in a couple of microseconds, but that's the point - it’s small enough to understand whether it worths to start a job sequentially or throw it concurrently, because the compiler have to do some extra job for that. This is exactly the regime where all the dispatch machinery suddenly becomes visible.&lt;/p&gt;

&lt;p&gt;However don't read this as a CPU benchmark. The π loop is only a good known fixed payload which has minimal size of the side effects. I'm measuring the only cost of spawning a thread, pushing a job onto a queue, then getting work from another core thus waking another worker, and to make it more clean I need to minimize any size effects which may give unpredictable and unreproducible results. In this picture the arithmetic becomes just the minimal work without any side effects and nothing more - pure calculations without going back and forth with IO, networking or something else.&lt;/p&gt;

&lt;p&gt;Getting such a workload, small enough, run in microseconds, but repeated multiple times one after another by pulling each one from the queue, I set the goal to check - how fast overall queue would be cleaned up from those jobs (to calculate π).&lt;/p&gt;

&lt;p&gt;Each measurement fires a hundred of these tasks through one concurrency primitive and waits until all hundred are done. That full fan-out counts as one sample. Every variant gets a single warm-up fan-out first - to warm the instruction cache, the branch predictors, and, when a pool is involved, to make sure the workers are actually created before the clock starts - and then a thousand timed samples.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to Read the Numbers
&lt;/h2&gt;

&lt;p&gt;A mean value would be definitely wrong here. Dispatch cost isn't a single value, it's a distribution with a tail, and the tail is actually where I can catch some interesting failures. There might be a run that hit a scheduler hiccup, or another one which caught a collector mid-sweep, or the one that was thrown onto a slow core. Every variant is reported as three points on its distribution:&amp;nbsp;&lt;strong&gt;p50&lt;/strong&gt;&amp;nbsp;(the median - the typical run),&amp;nbsp;&lt;strong&gt;p99&lt;/strong&gt;(the bad-but-not-rare run), and&amp;nbsp;&lt;strong&gt;p999&lt;/strong&gt;&amp;nbsp;(the worst run in roughly a thousand). With a thousand samples, p999 gets landing on rank ~998 - but it's a genuine one-in-a-thousand observation, not a synonym for "the slowest sample." When p999 pulls far away from p50 then something intermittent fell into the measurement and naming that something - whether it was a GC pause, or a clock transition, or a cold pool - is most of the work of the later articles.&lt;/p&gt;

&lt;p&gt;One methodological note that matters. A loop whose result is never used, is a loop which a good optimizer is entitled to delete, and with a flag &lt;code&gt;--release&lt;/code&gt;&amp;nbsp;Rust will absolutely do that as part of its optimization, as leaving you a workload for just an empty fan-out is a glorious nonsense. Thus each of the variant here wraps each task's result into a single global accumulator (simple XOR of the raw float bits) at the very end. At the same time that accumulator does a second job as it keeps every&amp;nbsp;&lt;code&gt;print&lt;/code&gt;&amp;nbsp;out of the timed region to minimize side effects on IO operations to console - I don't want to make a benchmark of&amp;nbsp;&lt;code&gt;stdout&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Can't Control For
&lt;/h2&gt;

&lt;p&gt;My working machine is Apple M5 Pro, and Apple silicon don't use a CPU governor, so I can't pin the clocks, I can't disable boost, and it's not possible to stop the scheduler from migrating a task between performance cores and super cores. That's another reason for p99 and p999 measurements - as a task that lands on an  performance core, shows up as a p99 or p999 tail that came from the machine, not from the runtime.&lt;/p&gt;

&lt;p&gt;Unfortunately I don't have a Linux box under my desk, so I can't run those tests on Linux and the same is true for Windows (I happily breathed out when I switched on Mac in 2008 - sorry Bill), but I share all the code in my GitHub repository so anyone may examine it, repeat tests and make his own measurement. Pure experiment, promising to become exciting and brings the same exciting results.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test Environment
&lt;/h2&gt;

&lt;p&gt;All measurements were taken on a single machine, cold-start: one warm-up run, then 1000 timed fan-outs per variant. No warm/cold matrix, no cache manipulation.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Keys&lt;/th&gt;
&lt;th&gt;Values&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Machine&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;MacBook Pro (Mac17,9), Apple M5 Pro&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Cores&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;15 (5 Super and 10 Performance)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Memory&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;24 GB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;OS&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;macOS 26.6.2 (25G83) (Darwin 25.6.0 kernel)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Runtimes (exact, pinned)
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Language&lt;/th&gt;
&lt;th&gt;Version&lt;/th&gt;
&lt;th&gt;Build / flags&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Rust&lt;/td&gt;
&lt;td&gt;rustc 1.98.1 (48a229cea 2026-09-01)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;--release&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Go&lt;/td&gt;
&lt;td&gt;go version go1.27.1 darwin/arm64&lt;/td&gt;
&lt;td&gt;default toolchain&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Node.js&lt;/td&gt;
&lt;td&gt;v24.20.0 (npm v11.19.0)&lt;/td&gt;
&lt;td&gt;worker_threads&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Python&lt;/td&gt;
&lt;td&gt;Python 3.14.6 free-threading [Clang 22.1.3 ]&lt;br&gt;Python 3.14.6 [Clang 22.1.3 ]&lt;/td&gt;
&lt;td&gt;3.14t&lt;br&gt;3.14&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Java&lt;/td&gt;
&lt;td&gt;Oracle JDK 25.0.3 (25.0.3+9-LTS-195)&lt;/td&gt;
&lt;td&gt;G1 GC,&amp;nbsp;&lt;code&gt;-Xms2g -Xmx2g&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Next Step
&lt;/h2&gt;

&lt;p&gt;Rust comes first because it gives me the cleanest baseline (and that's what I know better than C). It has such features like ahead-of-time compiled, no garbage collector, no interpreter loop - there's nothing between the code and the scheduler. The numbers I measure here are purely dispatch cost, but nothing else. All of these makes Rust the best starting point - once I know the cost a hundred tiny tasks through raw threads, through rayon, through a thread pool, and through an async runtime, I can do the same and compare with Go's scheduler, Java's JIT and its GC tail, Python's interpreter, and Node's runtime.&lt;/p&gt;

</description>
      <category>performance</category>
      <category>development</category>
      <category>python</category>
      <category>ai</category>
    </item>
    <item>
      <title>A native macOS load tester app — and backpressure made it honest</title>
      <dc:creator>Eugene Zimin</dc:creator>
      <pubDate>Sat, 06 Jun 2026 22:16:45 +0000</pubDate>
      <link>https://dev.to/eugene-zimin/a-native-macos-load-tester-app-and-backpressure-made-it-honest-38o9</link>
      <guid>https://dev.to/eugene-zimin/a-native-macos-load-tester-app-and-backpressure-made-it-honest-38o9</guid>
      <description>&lt;p&gt;&lt;em&gt;Why I built &lt;a href="https://github.com/eugenezimin/requester-public/releases" rel="noopener noreferrer"&gt;Requester&lt;/a&gt;, a real-time HTTP load testing app for macOS, and what Swift structured concurrency taught me about telling the truth under load.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;I wanted to hammer an HTTP endpoint and&amp;nbsp;&lt;em&gt;see&lt;/em&gt;&amp;nbsp;what happened. Not read a summary report three minutes later — watch it, live, the way you watch a profiler.&lt;/p&gt;

&lt;p&gt;The existing options are great but they all live in the terminal:&amp;nbsp;&lt;code&gt;wrk&lt;/code&gt;,&amp;nbsp;&lt;code&gt;hey&lt;/code&gt;,&amp;nbsp;&lt;code&gt;k6&lt;/code&gt;. I love them, but I kept wishing for a native window with a chart that moved. So I built one for macOS, in Swift and SwiftUI, and called it&amp;nbsp;&lt;strong&gt;Requester&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;This post is less "here are the features" and more "here are the three things I made building it." The most interesting one: making the tool&amp;nbsp;&lt;em&gt;honest&lt;/em&gt;&amp;nbsp;about backpressure turned out to be a design decision, not an accident.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fij84li1dcg5uv6qhao0q.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fij84li1dcg5uv6qhao0q.png" alt="Main Window" width="800" height="562"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The model: RPS per channel × channels
&lt;/h2&gt;

&lt;p&gt;Before any code, I needed a mental model the user could reason about. I landed on two knobs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Channels&lt;/strong&gt;&amp;nbsp;— how many requests can be in flight at once to emulate multiuser load (even though from a single host).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;RPS per channel&lt;/strong&gt;&amp;nbsp;— the rate I&amp;nbsp;&lt;em&gt;want&lt;/em&gt;&amp;nbsp;each channel to sustain.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So the total target rate is just&amp;nbsp;&lt;code&gt;channels × rpsPerChannel&lt;/code&gt;. No hidden division, no surprises. The dispatcher converts that into a fixed firing interval:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;interval&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;Duration&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;seconds&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="mf"&gt;1.0&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="kt"&gt;Double&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;throughputPerSec&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;concurrency&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The win here is conceptual, not technical: the number you type is the number you get (when the endpoint can keep up). Which brings me to the part where it&amp;nbsp;&lt;em&gt;can't&lt;/em&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  A load tester should lie down when the server does
&lt;/h2&gt;

&lt;p&gt;Here's the temptation. You set a target of 500 RPS. The endpoint can only serve 200. What do you do with the other 300 requests every second?&lt;/p&gt;

&lt;p&gt;The naive answer is "buffer them." But then your queue grows without bound, memory balloons, and — worse — your tool reports that it's happily firing 500 RPS while reality is nowhere close. That's a lie, and it's the most dangerous kind because it looks like success.&lt;/p&gt;

&lt;p&gt;So Requester does the opposite. The dispatcher fires requests as independent child tasks and never awaits a response. A single actor gates how many can be in flight:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;actor&lt;/span&gt; &lt;span class="kt"&gt;InFlightCounter&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Int&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;maxQueueDepth&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Int&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;current&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
    &lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;waiters&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;CheckedContinuation&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;Void&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;Never&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;

    &lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;acquireSlot&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="kt"&gt;Bool&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// Queue is full — shed this request, don't buffer forever.&lt;/span&gt;
        &lt;span class="k"&gt;guard&lt;/span&gt; &lt;span class="n"&gt;waiters&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;count&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;maxQueueDepth&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;current&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;limit&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;current&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="c1"&gt;// At the limit: suspend until a response frees a slot.&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;withCheckedContinuation&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;waiters&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="n"&gt;current&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;release&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;current&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;current&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;waiters&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;isEmpty&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;waiters&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;removeFirst&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;resume&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two behaviors fall out of this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The dispatcher suspends when every channel is busy.&lt;/strong&gt;&amp;nbsp;It can't fire faster than responses come back. So the&amp;nbsp;&lt;em&gt;achieved&lt;/em&gt;&amp;nbsp;RPS visibly drops below your target — and that dip is exactly the signal you came for. The chart tells you the truth.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Past a configurable queue depth, requests are shed&lt;/strong&gt;, not buffered. Bounded memory, bounded lag, no fantasy throughput.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;When the green "Received" line peels away from the blue "Sent" line on the chart, that's the endpoint saying&amp;nbsp;&lt;em&gt;uncle&lt;/em&gt;. I didn't have to build a special "the server is struggling" indicator. The honest plumbing surfaces it for free.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fig9zh5ssremd49vp6ugc.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fig9zh5ssremd49vp6ugc.png" alt="Stats View" width="800" height="816"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Structured concurrency made the whole thing boring (in the best way)
&lt;/h2&gt;

&lt;p&gt;The entire engine is&amp;nbsp;&lt;code&gt;async&lt;/code&gt;/&lt;code&gt;await&lt;/code&gt;,&amp;nbsp;&lt;code&gt;TaskGroup&lt;/code&gt;, and actors — no GCD queues, no completion-handler pyramids, no manual locks.&lt;/p&gt;

&lt;p&gt;The dispatcher and a snapshot timer run as siblings in one&amp;nbsp;&lt;code&gt;TaskGroup&lt;/code&gt;. The dispatcher records per-request outcomes into a&amp;nbsp;&lt;code&gt;RunCounter&lt;/code&gt;&amp;nbsp;&lt;strong&gt;actor&lt;/strong&gt;; the timer reads a delta from that same actor once per sampling interval and emits a&amp;nbsp;&lt;code&gt;.tick&lt;/code&gt;&amp;nbsp;event. Because the counter is actor-isolated, the N writers and the one reader never race — and I never wrote a lock.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="kd"&gt;actor&lt;/span&gt; &lt;span class="kt"&gt;RunCounter&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;private(set)&lt;/span&gt; &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;totalCompleted&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
    &lt;span class="kd"&gt;private(set)&lt;/span&gt; &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="nv"&gt;totalFailed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;

    &lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;record&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="nv"&gt;outcome&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;RequestOutcome&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;switch&lt;/span&gt; &lt;span class="n"&gt;outcome&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;success&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;totalCompleted&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
        &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;failure&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;totalFailed&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;/// Delta since the last read, then advance the baseline.&lt;/span&gt;
    &lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;snapshot&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;completedDelta&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="cm"&gt;/* … */&lt;/span&gt; &lt;span class="nv"&gt;totalCompleted&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;cd&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;totalCompleted&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;lastCompleted&lt;/span&gt;
        &lt;span class="n"&gt;lastCompleted&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;totalCompleted&lt;/span&gt;
        &lt;span class="nf"&gt;return&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cd&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="cm"&gt;/* … */&lt;/span&gt; &lt;span class="n"&gt;totalCompleted&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Events flow up through an&amp;nbsp;&lt;code&gt;AsyncThrowingStream&lt;/code&gt;&amp;nbsp;from the transport adapter, into a facade, and finally onto&amp;nbsp;&lt;code&gt;@Published&lt;/code&gt;properties in a&amp;nbsp;&lt;code&gt;@MainActor&lt;/code&gt;&amp;nbsp;view model. SwiftUI redraws. The chart moves. Each layer has exactly one job and they communicate through&amp;nbsp;&lt;code&gt;Sendable&lt;/code&gt;&amp;nbsp;values crossing isolation boundaries safely.&lt;/p&gt;

&lt;p&gt;The payoff: the scary part of a load tester — concurrent state — became the calmest part of the codebase.&lt;/p&gt;

&lt;h2&gt;
  
  
  URLSession will silently eat your headers
&lt;/h2&gt;

&lt;p&gt;This one cost me an embarrassing amount of time, so you get it for free.&lt;/p&gt;

&lt;p&gt;I set a custom&amp;nbsp;&lt;code&gt;User-Agent&lt;/code&gt;&amp;nbsp;on my&amp;nbsp;&lt;code&gt;URLRequest&lt;/code&gt;&amp;nbsp;like any reasonable person:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setValue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;userAgent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;forHTTPHeaderField&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"User-Agent"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;// 😶 silently dropped&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It never showed up on the wire. Turns out&amp;nbsp;&lt;code&gt;URLSession&lt;/code&gt;&amp;nbsp;treats&amp;nbsp;&lt;code&gt;User-Agent&lt;/code&gt;&amp;nbsp;(and other reserved headers) as off-limits at the&amp;nbsp;&lt;em&gt;request&lt;/em&gt;&amp;nbsp;level and quietly strips them. The fix is to set them on the&amp;nbsp;&lt;strong&gt;configuration&lt;/strong&gt;&amp;nbsp;instead:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;config&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;URLSessionConfiguration&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ephemeral&lt;/span&gt;
&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;httpAdditionalHeaders&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"User-Agent"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;userAgent&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="c1"&gt;// ✅ actually sent&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No error, no warning — it just works when you move it. If you've ever sworn your custom User-Agent "isn't applying," this is probably why. Requester ships with realistic browser presets (Safari, Chrome, Firefox, mobile variants,&amp;nbsp;&lt;code&gt;curl&lt;/code&gt;, Googlebot) precisely so you can test how an endpoint responds to different clients.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fesymxzo7jea43ghv0y0z.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fesymxzo7jea43ghv0y0z.png" alt="Headers" width="800" height="580"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Seeing the actual bytes
&lt;/h2&gt;

&lt;p&gt;A throughput chart tells you&amp;nbsp;&lt;em&gt;how much&lt;/em&gt;; sometimes you need&amp;nbsp;&lt;em&gt;what&lt;/em&gt;. Requester has a request/response inspector that shows the exact request being fired and the live responses coming back — status line, headers, body — with a sliding-window history and an optional truncation toggle for noisy bodies.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fkssfxbo0xh6dngsq83dq.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fkssfxbo0xh6dngsq83dq.png" alt="Loads View" width="800" height="875"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;p&gt;HTTP is what ships today. The transport layer is already abstracted behind a&amp;nbsp;&lt;code&gt;TransportAdapter&lt;/code&gt;&amp;nbsp;protocol (which also made testing trivial — every transport has a mock), so the architecture is ready for more:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;gRPC&lt;/strong&gt;&amp;nbsp;endpoint testing&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;WebSocket&lt;/strong&gt;&amp;nbsp;load&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Raw TCP&lt;/strong&gt;&amp;nbsp;load&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Requester is a native macOS app built entirely in Swift and SwiftUI, targeting macOS 14+.&lt;/p&gt;

&lt;p&gt;Built by Eugene Zimin and available on —&amp;nbsp;&lt;a href="https://github.com/eugenezimin/requester-public/releases" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;If you've built load-testing tooling, I'd love to hear how you handled the buffer-vs-shed question — drop a comment.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>swift</category>
      <category>swiftui</category>
      <category>macos</category>
      <category>concurrency</category>
    </item>
    <item>
      <title>Drawing the Blueprint: Flowchart, Functional Diagram, and Sequence Diagram</title>
      <dc:creator>Eugene Zimin</dc:creator>
      <pubDate>Sat, 23 May 2026 20:45:57 +0000</pubDate>
      <link>https://dev.to/eugene-zimin/drawing-the-blueprint-flowchart-functional-diagram-and-sequence-diagram-537c</link>
      <guid>https://dev.to/eugene-zimin/drawing-the-blueprint-flowchart-functional-diagram-and-sequence-diagram-537c</guid>
      <description>&lt;h1&gt;
  
  
  Lesson 3 of&amp;nbsp;&lt;em&gt;Build a Twitter Clone&lt;/em&gt;&amp;nbsp;- A Practical Guide to Software Modelling
&lt;/h1&gt;

&lt;p&gt;In &lt;a href="https://dev.to/eugene-zimin/the-blueprint-beneath-the-blueprint-designing-data-model-and-choosing-its-database-3bhl"&gt;Lesson 2&lt;/a&gt;, we defined what&amp;nbsp;&lt;code&gt;Bird&lt;/code&gt;&amp;nbsp;must&amp;nbsp;&lt;em&gt;remember&lt;/em&gt;: two databases, tables within, every field traced back to a use case. Now it's time to draw what&amp;nbsp;&lt;code&gt;Bird&lt;/code&gt;&amp;nbsp;must&amp;nbsp;&lt;em&gt;do&lt;/em&gt;. Using&amp;nbsp;&lt;code&gt;"Post a message"&lt;/code&gt;&amp;nbsp;as our single worked example, we construct all three diagrams in sequence - first a flowchart that traces the user action step by step, then a functional diagram that reads the required components directly off that flowchart, then a sequence diagram that shows exactly how those components talk. By the end, the use case that started as a sentence on a sticky note has a complete, traceable blueprint.&lt;/p&gt;

&lt;h2&gt;
  
  
  Introduction - The Foundation Is Ready
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://dev.to/eugene-zimin/the-blueprint-beneath-the-blueprint-designing-data-model-and-choosing-its-database-3bhl"&gt;Lesson 2&lt;/a&gt; ended with a promise, and it's worth quoting precisely:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;"That's where Lesson 3 picks up: with the data model as the foundation, we draw all three diagrams."&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That moment has arrived. But it's worth pausing for a second to notice what's different now compared to where we started.&lt;/p&gt;

&lt;p&gt;In &lt;a href="https://dev.to/eugene-zimin/from-idea-to-blueprint-turning-a-vague-app-concept-into-something-you-can-actually-build-1a60"&gt;Lesson 1&lt;/a&gt;, we introduced the idea of three diagrams - a flowchart, a functional diagram, and a sequence diagram - as three lenses on a single system. We explained the mental model, argued for the order, and left it there. No diagrams were actually drawn, and that was deliberate: a diagram drawn before the project is bounded and the data understood is a diagram you'll redraw.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fafgc5ff3q079abfk9ild.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fafgc5ff3q079abfk9ild.png" alt="Figure 1. The Foundation Is Ready" width="799" height="384"&gt;&lt;/a&gt;Figure 1. The Foundation Is Ready&lt;/p&gt;

&lt;p&gt;In &lt;a href="https://dev.to/eugene-zimin/the-blueprint-beneath-the-blueprint-designing-data-model-and-choosing-its-database-3bhl"&gt;Lesson 2&lt;/a&gt;, we did the bounding. We read&amp;nbsp;&lt;code&gt;Bird&lt;/code&gt;'s three use cases for data clues, named two entities (&lt;code&gt;User&lt;/code&gt;&amp;nbsp;and&amp;nbsp;&lt;code&gt;Message&lt;/code&gt;), defined five tables across two databases (&lt;code&gt;ums&lt;/code&gt;&amp;nbsp;and&amp;nbsp;&lt;code&gt;twitter&lt;/code&gt;), and established the relationships that tie them together. The schema is real now: a&amp;nbsp;&lt;code&gt;messages&lt;/code&gt;&amp;nbsp;row has a specific shape - an&amp;nbsp;&lt;code&gt;id&lt;/code&gt;, a&amp;nbsp;&lt;code&gt;content&lt;/code&gt;&amp;nbsp;field capped at 280 characters, an&amp;nbsp;&lt;code&gt;author_id&lt;/code&gt;pointing to a row in&amp;nbsp;&lt;code&gt;ums.users&lt;/code&gt;, and a&amp;nbsp;&lt;code&gt;created_at&lt;/code&gt;&amp;nbsp;timestamp. The&amp;nbsp;&lt;code&gt;users&lt;/code&gt;&amp;nbsp;row has a shape too. Every "save the message" step in every diagram we draw will refer to something concrete.&lt;/p&gt;

&lt;p&gt;That concreteness is what changes everything. A diagram drawn on top of a real data model isn't a sketch - it's a specification. The boxes mean something. The arrows carry named fields. When a step says&amp;nbsp;&lt;em&gt;"validate the message,"&lt;/em&gt;&amp;nbsp;we already know what the validation is checking (which is called validation criteria): a non-empty&amp;nbsp;&lt;code&gt;content&lt;/code&gt;&amp;nbsp;string, no longer than 280 characters, attached to an authenticated&amp;nbsp;&lt;code&gt;author_id&lt;/code&gt;. Nothing is left as a placeholder.&lt;/p&gt;

&lt;p&gt;This lesson draws all three diagrams. We'll work in order - behaviour first, then the structure that behaviour implies, then the detailed conversation inside that structure - for the same reason we always work in that order: each diagram is the input to the next, and the input has to exist before the output can be correct.&lt;/p&gt;

&lt;h2&gt;
  
  
  A Quick Reminder: The Three-Lens Progression
&lt;/h2&gt;

&lt;p&gt;Before we pick up the pen, one short bridge back to &lt;a href="https://dev.to/eugene-zimin/from-idea-to-blueprint-turning-a-vague-app-concept-into-something-you-can-actually-build-1a60"&gt;Lesson 1&lt;/a&gt; - not a full re-teach, just enough to make sure the scaffold is standing.&lt;/p&gt;

&lt;p&gt;The core idea is this: any software system can be understood from three distinct angles, and each angle demands a different kind of diagram.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Lens&lt;/th&gt;
&lt;th&gt;The question it answers&lt;/th&gt;
&lt;th&gt;The diagram&lt;/th&gt;
&lt;th&gt;What you see&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Behaviour&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;What should the app&amp;nbsp;&lt;em&gt;do&lt;/em&gt;?&lt;/td&gt;
&lt;td&gt;Flowchart&lt;/td&gt;
&lt;td&gt;Steps, decisions, and outcomes - the system as a&amp;nbsp;&lt;em&gt;process&lt;/em&gt;&amp;nbsp;unfolding in time&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Structure&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;What&amp;nbsp;&lt;em&gt;parts&lt;/em&gt;&amp;nbsp;must exist to make that behaviour happen?&lt;/td&gt;
&lt;td&gt;Functional diagram&lt;/td&gt;
&lt;td&gt;Components and their connections - the system as a&amp;nbsp;&lt;em&gt;thing made of pieces&lt;/em&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Interaction&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;How do those parts&amp;nbsp;&lt;em&gt;talk&lt;/em&gt;&amp;nbsp;to each other at runtime?&lt;/td&gt;
&lt;td&gt;Sequence diagram&lt;/td&gt;
&lt;td&gt;Messages exchanged between components - the system as a&amp;nbsp;&lt;em&gt;conversation&lt;/em&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;None of these is more correct than the others. A flowchart can't tell you what components to build. A functional diagram can't tell you what happens when a user submits an empty message. A sequence diagram can't tell you whether you've correctly scoped the feature. They are&amp;nbsp;&lt;strong&gt;complementary&lt;/strong&gt;, not competing - and you need all three because no single one is complete.&lt;/p&gt;

&lt;p&gt;The critical habit is the&amp;nbsp;&lt;strong&gt;order&lt;/strong&gt;. You move behaviour → structure → interaction, and each step is derived from the one before:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fj1cqn5nl8mqxij3nzfje.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fj1cqn5nl8mqxij3nzfje.png" alt="Figure 2. The three-lens progression - each diagram derives from the one before" width="800" height="126"&gt;&lt;/a&gt;Figure 2. The three-lens progression - each diagram derives from the one before&lt;/p&gt;

&lt;p&gt;The reason the order is fixed: you can't know what components to build until you know what the system must do. And you can't describe the conversation between components until you know which components exist. Behaviour is the requirement; structure serves it; interaction lives inside structure.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;An analogy for the progression.&lt;/strong&gt;&amp;nbsp;Imagine designing a kitchen. First you decide what the kitchen must&amp;nbsp;&lt;em&gt;do&lt;/em&gt;- prepare meals, store food, clean dishes. That's behaviour. Then you ask what&amp;nbsp;&lt;em&gt;parts&lt;/em&gt;&amp;nbsp;must exist to support each of those tasks: a cooktop, a refrigerator, a sink. That's structure, falling out of behaviour. Finally, you trace the specific workflow - the chef moves from the refrigerator to the prep surface to the cooktop, in that order, passing ingredients along. That's interaction, living inside the structure. You couldn't have designed the workflow without knowing the layout. You couldn't have designed the layout without knowing the tasks. The order is the method.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Lens 1: Behaviour - Drawing the Flowchart
&lt;/h2&gt;

&lt;p&gt;A flowchart answers one question:&amp;nbsp;&lt;strong&gt;what does the system do, step by step?&lt;/strong&gt;&amp;nbsp;Not what parts it has. Not which component calls which. Just the sequence of actions and decisions that carry a user from "I want to post a message" to "it's done" - or to an error, if something goes wrong along the way.&lt;/p&gt;

&lt;p&gt;That makes the flowchart the right place to start. It captures the requirement in its purest form, before any implementation choices have been made.&lt;/p&gt;

&lt;h3&gt;
  
  
  Building it step by step
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Step 1 - Name the start and end points.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The flow begins the moment the user presses "Post" - that is, when the system receives a submission. It ends either with a new row in&amp;nbsp;&lt;code&gt;twitter.messages&lt;/code&gt;&amp;nbsp;and a confirmation to the user, or with an error message and no row written. Name those endpoints first, before filling in anything in between.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 2 - List the actions in order.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Walk the happy path first - what happens when everything goes right? For "Post a message," that sequence is:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Submit a message&lt;/li&gt;
&lt;li&gt;Pass the authentication check - is the user logged in?&lt;/li&gt;
&lt;li&gt;Pass the permissions check - is the user allowed to post?&lt;/li&gt;
&lt;li&gt;Pass content validation - is the message valid?&lt;/li&gt;
&lt;li&gt;Save the message to the database&lt;/li&gt;
&lt;li&gt;Confirm success to the user&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Six steps. Two are actions, three are decision gates, one is a confirmation. That's a healthy breakdown - the logic is explicit without becoming an implementation manual.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 3 - Add the decision points.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Three decision diamonds gate the happy path:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Is the user logged in?&lt;/strong&gt;&amp;nbsp;If no → redirect to the login page. After a successful login the user is returned to the submission step - a loop, not a dead end.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Is the user allowed to post?&lt;/strong&gt;&amp;nbsp;Not every account has posting rights - a read-only or suspended role is blocked here. If no → shared Error node → END.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Is the message valid?&lt;/strong&gt;&amp;nbsp;(Non-empty? Within 280 characters?) If invalid → shared Error node → END.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Notice that the two error branches converge on a single&amp;nbsp;&lt;code&gt;Error&lt;/code&gt;&amp;nbsp;node before reaching&amp;nbsp;&lt;code&gt;END&lt;/code&gt;. This is an intentional design choice in the diagram: the system's response to a permissions failure and a content failure is the same kind of thing - an error returned to the user. Merging them keeps the diagram clean and makes that equivalence visible.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 4 - Connect and label.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Arrange the steps top to bottom, connect them with arrows, and label every arrow leaving a decision diamond. The labels on the branches ("yes / no", or more descriptively "valid / invalid") are what make the diagram readable at a glance.&lt;/p&gt;

&lt;h3&gt;
  
  
  The flowchart
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fndj15078ai1yo0txid9i.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fndj15078ai1yo0txid9i.png" alt="Figure 3. Flowchart for " width="800" height="1225"&gt;&lt;/a&gt; Figure 3. Flowchart for "Post a message"&lt;/p&gt;

&lt;h3&gt;
  
  
  What this diagram tells us - and what it doesn't
&lt;/h3&gt;

&lt;p&gt;The flowchart is intentionally silent on&amp;nbsp;&lt;em&gt;how&lt;/em&gt;&amp;nbsp;each step is implemented. It doesn't say which component checks authentication, or what library validates the string length, or whether the database write is synchronous. Those details don't belong here. What the flowchart does - and does well - is make the&amp;nbsp;&lt;strong&gt;logic&lt;/strong&gt;&amp;nbsp;visible: the decisions, the branches, the outcomes. That's the entire job of Lens 1.&lt;/p&gt;

&lt;p&gt;Notice that the data model already sharpens the language. The "Save message" step doesn't just say&amp;nbsp;&lt;em&gt;"save the message"&lt;/em&gt;- it names the fields:&amp;nbsp;&lt;code&gt;author_id&lt;/code&gt;,&amp;nbsp;&lt;code&gt;content&lt;/code&gt;,&amp;nbsp;&lt;code&gt;created_at&lt;/code&gt;, writing into&amp;nbsp;&lt;code&gt;twitter.messages&lt;/code&gt;. That specificity comes directly from &lt;a href="https://dev.to/eugene-zimin/the-blueprint-beneath-the-blueprint-designing-data-model-and-choosing-its-database-3bhl"&gt;Lesson 2&lt;/a&gt;. The flowchart is still high-level, but it's no longer vague.&lt;/p&gt;

&lt;p&gt;With the behaviour fully drawn, the next question asks itself:&amp;nbsp;&lt;strong&gt;what parts must exist to make each of these steps happen?&lt;/strong&gt;&amp;nbsp;That's Lens 2 - and the answer comes directly from reading this flowchart.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lens 2: Structure - Reading the Functional Diagram off the Flowchart
&lt;/h2&gt;

&lt;p&gt;The flowchart is done. We know what "Post a message" must do - the steps, the decisions, the branches. Now a different question takes over:&amp;nbsp;&lt;strong&gt;what parts must exist inside the system to make each of those steps happen?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This is where most beginners get into trouble. They open a new diagram and start inventing components - a generic&amp;nbsp;&lt;code&gt;AuthService&lt;/code&gt;, an&amp;nbsp;&lt;code&gt;APIGateway&lt;/code&gt;, a&amp;nbsp;&lt;code&gt;DatabaseLayer&lt;/code&gt;&amp;nbsp;- based on instinct or prior experience. The result is a diagram that reflects what the developer already knew, not what the use case actually requires.&lt;/p&gt;

&lt;p&gt;There is a better method. The functional diagram doesn't need to be invented. It can be&amp;nbsp;&lt;strong&gt;read directly off the flowchart&lt;/strong&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  The method: scan every step, ask one question
&lt;/h3&gt;

&lt;p&gt;For each step in the flowchart, ask:&amp;nbsp;&lt;em&gt;what component must own this responsibility?&lt;/em&gt;&amp;nbsp;The answer names a block. Do that for every step, merge the ones that belong together, and the structure falls out of the behaviour.&lt;/p&gt;

&lt;p&gt;Let's walk through the flowchart step by step.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Flowchart step&lt;/th&gt;
&lt;th&gt;Responsibility&lt;/th&gt;
&lt;th&gt;Component&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;User submits message&lt;/td&gt;
&lt;td&gt;Sends the HTTP request&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Frontend Client&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Is the user logged in?&lt;/td&gt;
&lt;td&gt;Identifies who is making the request&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Twitter Service&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Redirect to login&lt;/td&gt;
&lt;td&gt;Returns the login page&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Frontend Client&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Is the user allowed to post?&lt;/td&gt;
&lt;td&gt;Fetches the user record and checks for the&amp;nbsp;&lt;code&gt;PRODUCER&lt;/code&gt;role&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Twitter Service&lt;/strong&gt;&amp;nbsp;→&amp;nbsp;&lt;strong&gt;UMS Service&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Is the message valid?&lt;/td&gt;
&lt;td&gt;Enforces content rules (non-empty, ≤ 280 chars)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Twitter Service&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Save message&lt;/td&gt;
&lt;td&gt;Writes the row to&amp;nbsp;&lt;code&gt;twitter.messages&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Twitter Service&lt;/strong&gt;&amp;nbsp;→&amp;nbsp;&lt;strong&gt;MySQL Database&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Confirm success&lt;/td&gt;
&lt;td&gt;Returns&amp;nbsp;&lt;code&gt;201&lt;/code&gt;&amp;nbsp;with the created message&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Twitter Service&lt;/strong&gt;&amp;nbsp;→&amp;nbsp;&lt;strong&gt;Frontend Client&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Return error&lt;/td&gt;
&lt;td&gt;Returns&amp;nbsp;&lt;code&gt;403 Forbidden&lt;/code&gt;&amp;nbsp;to the client&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Twitter Service&lt;/strong&gt;&amp;nbsp;→&amp;nbsp;&lt;strong&gt;Frontend Client&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two things stand out immediately. First, the&amp;nbsp;&lt;strong&gt;Twitter Service&lt;/strong&gt;&amp;nbsp;carries the most weight: it receives the request, performs content validation, and orchestrates the database write. But it doesn't hold user data - that lives in UMS. So for the permission check, the Twitter Service calls the&amp;nbsp;&lt;strong&gt;UMS Service&lt;/strong&gt;&amp;nbsp;via an internal HTTP request, retrieves the user's roles, and decides whether to proceed. This cross-service call is the architectural heart of the feature.&lt;/p&gt;

&lt;p&gt;Second, there is no separate&amp;nbsp;&lt;code&gt;API Gateway&lt;/code&gt;&amp;nbsp;or&amp;nbsp;&lt;code&gt;Auth Service&lt;/code&gt;&amp;nbsp;in&amp;nbsp;&lt;code&gt;Bird&lt;/code&gt;. The Frontend Client talks directly to the Twitter Service on port&amp;nbsp;&lt;code&gt;9001&lt;/code&gt;. Role checking is a responsibility of the Twitter Service itself, delegated to UMS on demand - not a standalone component sitting in between.&lt;/p&gt;

&lt;p&gt;This gives us four components: the&amp;nbsp;&lt;strong&gt;Frontend Client&lt;/strong&gt;, the&amp;nbsp;&lt;strong&gt;Twitter Service&lt;/strong&gt;, the&amp;nbsp;&lt;strong&gt;UMS Service&lt;/strong&gt;, and&amp;nbsp;&lt;strong&gt;MySQL Database&lt;/strong&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  The functional diagram
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fl813m8f32djv1op3akk5.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fl813m8f32djv1op3akk5.png" alt="Figure 4. Functional diagram for&amp;nbsp; raw `Bird` endraw " width="800" height="1012"&gt;&lt;/a&gt; Figure 4. Functional diagram for&amp;nbsp;&lt;code&gt;Bird&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;The diagram reads as a set of named blocks connected by labelled arrows. The arrows carry the nature of the relationship - not the step-by-step logic (that's the flowchart's job) but the structural dependency: the Frontend Client sends requests to the Twitter Service; the Twitter Service calls UMS to validate the user; the Twitter Service reads and writes MySQL directly. Each arrow represents a real, named communication channel in the codebase - HTTP on&amp;nbsp;&lt;code&gt;:9001&lt;/code&gt;&amp;nbsp;for the UI-to-service call,&amp;nbsp;&lt;code&gt;WebClient&lt;/code&gt;&amp;nbsp;for the service-to-service call, JDBC for the database connection.&lt;/p&gt;

&lt;h3&gt;
  
  
  What this diagram is - and isn't
&lt;/h3&gt;

&lt;p&gt;A functional diagram is a&amp;nbsp;&lt;strong&gt;map of components&lt;/strong&gt;, not a map of steps. It answers "what pieces exist and how are they connected?" - not "what happens first?" The flowchart and the functional diagram are not redundant; they answer different questions and each is incomplete without the other.&lt;/p&gt;

&lt;p&gt;Notice also what the functional diagram deliberately omits: there are no arrows labelled with field names, no decision branches, no error paths, no sequence numbers. Those details live in the sequence diagram. Here, the goal is structural clarity - a reader should be able to see at a glance what&amp;nbsp;&lt;code&gt;Bird&lt;/code&gt;&amp;nbsp;is made of and how the pieces relate.&lt;/p&gt;

&lt;p&gt;With the components named and connected, one question remains:&amp;nbsp;&lt;strong&gt;how exactly do they talk to each other during "Post a message"?&lt;/strong&gt;&amp;nbsp;That's the sequence diagram - and now that we know which components exist, we can finally draw it precisely.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lens 3: Interaction - The Sequence Diagram
&lt;/h2&gt;

&lt;p&gt;The flowchart told us&amp;nbsp;&lt;em&gt;what&lt;/em&gt;&amp;nbsp;"Post a message" does. The functional diagram told us&amp;nbsp;&lt;em&gt;what components&lt;/em&gt;&amp;nbsp;exist to do it. The sequence diagram answers the last question:&amp;nbsp;&lt;strong&gt;what do those components say to each other, in what order, when the feature actually runs?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This is where the previous two diagrams earn their investment. Every component named in the functional diagram becomes a lifeline. Every step in the flowchart becomes a message on one of those lifelines. Nothing is invented - it's assembled from what we already know.&lt;/p&gt;

&lt;h3&gt;
  
  
  From components to lifelines
&lt;/h3&gt;

&lt;p&gt;A sequence diagram is built around&amp;nbsp;&lt;strong&gt;lifelines&lt;/strong&gt;&amp;nbsp;- vertical columns, one per component, representing each participant in the interaction. Reading the functional diagram directly, our lifelines are:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Frontend Client&lt;/strong&gt;&amp;nbsp;- the browser, initiating the request&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;MessageController&lt;/strong&gt;&amp;nbsp;- the entry point inside the Twitter Service, receiving the HTTP call&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;MessagesService&lt;/strong&gt;&amp;nbsp;- the orchestrator: calls UMS, checks roles, triggers the database write&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;UMSConnector&lt;/strong&gt;&amp;nbsp;- the thin HTTP client that talks to UMS via&amp;nbsp;&lt;code&gt;WebClient&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;UMS Service&lt;/strong&gt;&amp;nbsp;- the external service that owns user identity and roles&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;JdbcMessageRepository&lt;/strong&gt;&amp;nbsp;- the DAO that executes the SQL insert&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;MySQL&lt;/strong&gt;&amp;nbsp;- the database that persists the row&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Reading the flow
&lt;/h3&gt;

&lt;p&gt;The messages between lifelines follow the exact path the codebase executes. Let's trace it:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The&amp;nbsp;&lt;strong&gt;Frontend Client&lt;/strong&gt;&amp;nbsp;sends&amp;nbsp;&lt;code&gt;POST /messages/message&lt;/code&gt;&amp;nbsp;with&amp;nbsp;&lt;code&gt;{ author, content }&lt;/code&gt;&amp;nbsp;to the&amp;nbsp;&lt;strong&gt;MessageController&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;MessageController&lt;/strong&gt;&amp;nbsp;deserializes the body into a&amp;nbsp;&lt;code&gt;Message&lt;/code&gt;&amp;nbsp;DTO and delegates to&amp;nbsp;&lt;strong&gt;MessagesService&lt;/strong&gt;&amp;nbsp;via&amp;nbsp;&lt;code&gt;createMessage(message)&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;MessagesService&lt;/strong&gt;&amp;nbsp;needs to verify the author - it doesn't hold user data, so it calls&amp;nbsp;&lt;strong&gt;UMSConnector&lt;/strong&gt;&amp;nbsp;with&amp;nbsp;&lt;code&gt;retrieveUmsData(/users/user/{id})&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;UMSConnector&lt;/strong&gt;&amp;nbsp;fires a non-blocking&amp;nbsp;&lt;code&gt;WebClient GET&lt;/code&gt;&amp;nbsp;to the&amp;nbsp;&lt;strong&gt;UMS Service&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;UMS Service&lt;/strong&gt;&amp;nbsp;returns a&amp;nbsp;&lt;code&gt;UserDto&lt;/code&gt;&amp;nbsp;containing the user's roles.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;MessagesService&lt;/strong&gt;&amp;nbsp;unpacks the response via&amp;nbsp;&lt;code&gt;HttpResponseExtractor&lt;/code&gt;&amp;nbsp;and inspects the&amp;nbsp;&lt;code&gt;Roles&lt;/code&gt;. If the user is not a&amp;nbsp;&lt;code&gt;PRODUCER&lt;/code&gt;&amp;nbsp;- the flow terminates here with a&amp;nbsp;&lt;code&gt;403 Forbidden&lt;/code&gt;&amp;nbsp;back to the client.&lt;/li&gt;
&lt;li&gt;If the role check passes,&amp;nbsp;&lt;strong&gt;MessagesService&lt;/strong&gt;&amp;nbsp;calls&amp;nbsp;&lt;code&gt;createMessage(message)&lt;/code&gt;&amp;nbsp;on&amp;nbsp;&lt;strong&gt;JdbcMessageRepository&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;JdbcMessageRepository&lt;/strong&gt;&amp;nbsp;executes&amp;nbsp;&lt;code&gt;INSERT INTO messages&lt;/code&gt;&amp;nbsp;against&amp;nbsp;&lt;strong&gt;MySQL&lt;/strong&gt;&amp;nbsp;(converting the UUID via&amp;nbsp;&lt;code&gt;DaoHelper&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;MySQL&lt;/strong&gt;&amp;nbsp;returns&amp;nbsp;&lt;code&gt;ok&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The saved&amp;nbsp;&lt;code&gt;Message&lt;/code&gt;&amp;nbsp;travels back up:&amp;nbsp;&lt;strong&gt;JdbcMessageRepository&lt;/strong&gt;&amp;nbsp;→&amp;nbsp;&lt;strong&gt;MessagesService&lt;/strong&gt;&amp;nbsp;→&amp;nbsp;&lt;strong&gt;MessageController&lt;/strong&gt;&amp;nbsp;→&amp;nbsp;&lt;strong&gt;Frontend Client&lt;/strong&gt;&amp;nbsp;as&amp;nbsp;&lt;code&gt;201 { message }&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  The sequence diagram
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fjikmyhmwdhsjvjjroq8x.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fjikmyhmwdhsjvjjroq8x.png" alt="Figure 5. Sequence diagram for " width="800" height="393"&gt;&lt;/a&gt;Figure 5. Sequence diagram for "Post a message"&lt;/p&gt;

&lt;h3&gt;
  
  
  What this diagram shows that the others couldn't
&lt;/h3&gt;

&lt;p&gt;Look at the&amp;nbsp;&lt;code&gt;alt&lt;/code&gt;&amp;nbsp;block in the middle of the diagram. That's the permission check - the same diamond in the flowchart, the same role-checking responsibility assigned to&amp;nbsp;&lt;code&gt;MessagesService&lt;/code&gt;&amp;nbsp;in the functional diagram. But now it has an exact form: a comparison against the&amp;nbsp;&lt;code&gt;PRODUCER&lt;/code&gt;&amp;nbsp;role, returning&amp;nbsp;&lt;code&gt;403 Forbidden&lt;/code&gt;&amp;nbsp;if it fails, with the response passing back through&amp;nbsp;&lt;code&gt;MessageController&lt;/code&gt;&amp;nbsp;to the client.&lt;/p&gt;

&lt;p&gt;This is&amp;nbsp;&lt;strong&gt;traceability&lt;/strong&gt;&amp;nbsp;working in practice. The validation step in the flowchart → the&amp;nbsp;&lt;code&gt;MessagesService&lt;/code&gt;&amp;nbsp;block in the functional diagram → the&amp;nbsp;&lt;code&gt;HttpResponseExtractor → User → Roles&lt;/code&gt;&amp;nbsp;call chain in the sequence diagram. One idea, three levels of resolution.&lt;/p&gt;

&lt;p&gt;Notice also what the sequence diagram adds that neither previous diagram could:&amp;nbsp;&lt;strong&gt;the reactive chain&lt;/strong&gt;. The&amp;nbsp;&lt;code&gt;UMSConnector&lt;/code&gt;call is non-blocking -&amp;nbsp;&lt;code&gt;MessagesService&lt;/code&gt;&amp;nbsp;issues the&amp;nbsp;&lt;code&gt;WebClient&lt;/code&gt;&amp;nbsp;request and the rest of the chain executes inside a&amp;nbsp;&lt;code&gt;.flatMap()&lt;/code&gt;. The sequence diagram makes this visible through the activation boxes:&amp;nbsp;&lt;code&gt;MS&lt;/code&gt;&amp;nbsp;stays active while&amp;nbsp;&lt;code&gt;UC&lt;/code&gt;&amp;nbsp;and&amp;nbsp;&lt;code&gt;UMS&lt;/code&gt;&amp;nbsp;do their work, then resumes. That concurrency detail is invisible in both the flowchart and the functional diagram.&lt;/p&gt;

&lt;p&gt;With all three diagrams drawn, one use case - "Post a message" - now has a complete, layered blueprint. The next section puts all three side by side and shows what traceability looks like end to end.&lt;/p&gt;

&lt;p&gt;Let's pause before moving on and do something the three diagrams individually don't do: put them side by side and trace a single idea across all of them.&lt;/p&gt;

&lt;p&gt;Take the permission check - the moment the system decides whether the author is allowed to post. Here is where it lives in each diagram:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Diagram&lt;/th&gt;
&lt;th&gt;Where the permission check appears&lt;/th&gt;
&lt;th&gt;What you can see&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Flowchart&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;The diamond&amp;nbsp;&lt;em&gt;"Is user allowed to post?"&lt;/em&gt;&amp;nbsp;branching to&amp;nbsp;&lt;code&gt;Error&lt;/code&gt;or continuing to validation&lt;/td&gt;
&lt;td&gt;The&amp;nbsp;&lt;em&gt;logic&lt;/em&gt;&amp;nbsp;- two outcomes, one decision&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Functional diagram&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;The arrow from&amp;nbsp;&lt;code&gt;MessagesService&lt;/code&gt;&amp;nbsp;labelled&amp;nbsp;&lt;em&gt;"check roles contains PRODUCER"&lt;/em&gt;&amp;nbsp;pointing to&amp;nbsp;&lt;code&gt;Roles&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;The&amp;nbsp;&lt;em&gt;ownership&lt;/em&gt;&amp;nbsp;- which component holds this responsibility&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Sequence diagram&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;The&amp;nbsp;&lt;code&gt;alt&lt;/code&gt;&amp;nbsp;block:&amp;nbsp;&lt;code&gt;HttpResponseExtractor → User → Roles&lt;/code&gt;, branching to&amp;nbsp;&lt;code&gt;403 Forbidden&lt;/code&gt;&amp;nbsp;or&amp;nbsp;&lt;code&gt;INSERT&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;The&amp;nbsp;&lt;em&gt;mechanics&lt;/em&gt;&amp;nbsp;- what data moves, in what order, producing what result&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Same check. Three levels of resolution. You could point at the diamond in the flowchart, follow it to the&amp;nbsp;&lt;code&gt;MessagesService&lt;/code&gt;arrow in the functional diagram, and land on the&amp;nbsp;&lt;code&gt;alt&lt;/code&gt;&amp;nbsp;block in the sequence diagram - and at each step you'd learn something the previous diagram couldn't tell you.&lt;/p&gt;

&lt;p&gt;That property is&amp;nbsp;&lt;strong&gt;traceability&lt;/strong&gt;: the ability to follow a single requirement from its highest-level expression down to its lowest-level implementation without losing the thread. It's what separates a set of diagrams that decorate a document from a set that actually functions as a blueprint.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ffk1k8s8gfv65tivh8u7v.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ffk1k8s8gfv65tivh8u7v.jpg" alt="Figure 6. One feature, three views - the permission check traced across all three diagrams" width="800" height="402"&gt;&lt;/a&gt;Figure 6. One feature, three views - the permission check traced across all three diagrams&lt;/p&gt;

&lt;p&gt;Notice also what traceability reveals in the other direction. The sequence diagram shows that the&amp;nbsp;&lt;code&gt;403 Forbidden&lt;/code&gt;&amp;nbsp;response travels back through&amp;nbsp;&lt;code&gt;MessageController&lt;/code&gt;&amp;nbsp;before reaching the client - a detail invisible in both the flowchart (which just says "Error → END") and the functional diagram (which shows&amp;nbsp;&lt;code&gt;MessageController&lt;/code&gt;&amp;nbsp;connected to&amp;nbsp;&lt;code&gt;GlobalExceptionHandler&lt;/code&gt;&amp;nbsp;without explaining when). The diagrams don't just repeat each other. Each one completes the picture in a direction the others leave open.&lt;/p&gt;

&lt;h2&gt;
  
  
  Limitations and Open Questions
&lt;/h2&gt;

&lt;p&gt;Three diagrams drawn, one use case traced. Before closing, it's worth being clear about what this process is - and what it isn't.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Diagrams go stale.&lt;/strong&gt;&amp;nbsp;Every diagram in this lesson reflects the codebase as it stands today. The moment a new field is added to&amp;nbsp;&lt;code&gt;messages&lt;/code&gt;, or the permission model changes, or&amp;nbsp;&lt;code&gt;UMSConnector&lt;/code&gt;&amp;nbsp;is replaced by a shared auth library - the diagrams need updating. A diagram that isn't maintained becomes misleading faster than no diagram at all. The discipline of drawing diagrams is only half the work; the other half is keeping them honest.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;This is a snapshot, not a specification.&lt;/strong&gt;&amp;nbsp;The diagrams here describe what&amp;nbsp;&lt;code&gt;Bird&lt;/code&gt;&amp;nbsp;does, not what it must do. A specification would go further - stating invariants, error contracts, performance bounds, and edge cases. These diagrams are the foundation of a spec, not the spec itself. They answer "how does it work?" not "how must it always work?"&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Over-modelling is a real risk.&lt;/strong&gt;&amp;nbsp;Drawing all three diagrams for every feature in a large system would consume more time than it saves. The three-lens approach is most valuable at decision points: when designing a new feature from scratch, when onboarding someone to an unfamiliar part of the system, or when debugging behaviour that contradicts the mental model. Applied indiscriminately, it becomes ceremony rather than engineering.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;These diagrams are not UML.&lt;/strong&gt;&amp;nbsp;Readers familiar with the Unified Modelling Language (UML) will notice that the diagrams in this lesson borrow UML conventions - sequence diagram lifelines, flowchart decision diamonds - without following the full UML standard. That's deliberate. UML is precise and complete, and its precision comes at the cost of accessibility. However when you move to production systems with formal specification requirements, UML or C4 may be the right choice.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's Next
&lt;/h2&gt;

&lt;p&gt;Three lessons in, and&amp;nbsp;&lt;code&gt;Bird&lt;/code&gt;&amp;nbsp;has a bounded scope, a real data model, and a complete three-lens blueprint for its core feature.&lt;/p&gt;

&lt;p&gt;What it doesn't have yet is a working API. The diagrams show that a&amp;nbsp;&lt;code&gt;POST /messages/message&lt;/code&gt;&amp;nbsp;endpoint should exist, that it must accept&amp;nbsp;&lt;code&gt;{ author, content }&lt;/code&gt;, that it must call UMS before writing to the database - but none of that is running code yet.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lesson 4&lt;/strong&gt;&amp;nbsp;is where the diagrams meet the wire. We'll define the API contracts and endpoints that connect&amp;nbsp;&lt;code&gt;Bird&lt;/code&gt;'s components - what each service exposes, what it expects, and what it returns. The sequence diagram already told us that&amp;nbsp;&lt;code&gt;POST /messages/message&lt;/code&gt;&amp;nbsp;must exist, that it calls&amp;nbsp;&lt;code&gt;GET /users/user/{id}&lt;/code&gt;&amp;nbsp;on UMS, and that both exchanges carry specific payloads. Lesson 4 makes those contracts explicit and formal: request shapes, response shapes, status codes, and the communication protocol between the Twitter Service and UMS.&lt;/p&gt;

&lt;p&gt;The diagrams were never the destination. They were the clearest possible way to know what to build before building it.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>development</category>
      <category>softwareengineering</category>
    </item>
    <item>
      <title>The Blueprint Beneath the Blueprint: Designing Data Model and Choosing Its Database</title>
      <dc:creator>Eugene Zimin</dc:creator>
      <pubDate>Sat, 23 May 2026 06:36:22 +0000</pubDate>
      <link>https://dev.to/eugene-zimin/the-blueprint-beneath-the-blueprint-designing-data-model-and-choosing-its-database-3bhl</link>
      <guid>https://dev.to/eugene-zimin/the-blueprint-beneath-the-blueprint-designing-data-model-and-choosing-its-database-3bhl</guid>
      <description>&lt;h1&gt;
  
  
  Lesson 2 of&amp;nbsp;&lt;em&gt;Build a Twitter Clone&lt;/em&gt;&amp;nbsp;- A Practical Guide to Software Modelling
&lt;/h1&gt;

&lt;p&gt;A diagram shows you&amp;nbsp;&lt;em&gt;what&lt;/em&gt;&amp;nbsp;a system does; a data model tells you&amp;nbsp;&lt;em&gt;what it remembers&lt;/em&gt;. Before drawing a single flowchart, you need to know what information&amp;nbsp;&lt;code&gt;Bird&lt;/code&gt;&amp;nbsp;must store - and how that information is shaped. In this lesson we read our three use cases for data clues, name the entities the system must track, define their fields and relationships, and translate all of it into a real MySQL schema split across two purposefully separated databases. &lt;/p&gt;

&lt;h2&gt;
  
  
  Introduction - Data Before Diagrams
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://dev.to/eugene-zimin/from-idea-to-blueprint-turning-a-vague-app-concept-into-something-you-can-actually-build-1a60"&gt;Lesson 1&lt;/a&gt; was closed with a promise: in Lesson 2, we draw the diagrams. Flowchart, functional diagram, sequence diagram - the works. At the moment we're going to defer it for a while. The reason is quite simple - because of something that becomes obvious the moment you try to draw the flowchart for&amp;nbsp;&lt;em&gt;"Post a message"&lt;/em&gt;&amp;nbsp;without it:&amp;nbsp;&lt;strong&gt;a diagram that doesn't know what data it's moving is vague in exactly the wrong places.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;You need to consider the following - the flowchart for posting a message will eventually have a step called something like&amp;nbsp;&lt;em&gt;"save the message."&lt;/em&gt; Straightforward enough on paper. But the moment you try to build it, questions pile up fast:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Save&amp;nbsp;&lt;em&gt;what&lt;/em&gt;, exactly? The text? The author? A timestamp? All three?&lt;/li&gt;
&lt;li&gt;Where does the author's identity come from - a name typed into a field, or a reference to a stored account?&lt;/li&gt;
&lt;li&gt;What does "the author" even mean to the system - a row in a table somewhere, or just a string?&lt;/li&gt;
&lt;li&gt;If the same author posts twice, how does the system know both messages belong to the same person?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fle03a2dorqy1rx3xb0p2.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fle03a2dorqy1rx3xb0p2.jpg" alt="Figure 1. What is the Message?" width="800" height="585"&gt;&lt;/a&gt;Figure 1. What is the Message?&lt;/p&gt;

&lt;p&gt;A diagram that leaves those questions open isn't a blueprint. It's a sketch - useful for thinking, but not yet useful for building. The answers live in the&amp;nbsp;&lt;strong&gt;data model&lt;/strong&gt;: the formal description of what the system stores, and how the pieces of stored information relate to one another.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Think of the data model as the system's long-term memory. The diagrams describe what the system&amp;nbsp;&lt;em&gt;does&lt;/em&gt;- its behaviour, its structure, its conversations. The data model describes what it&amp;nbsp;&lt;em&gt;remembers&lt;/em&gt;&amp;nbsp;between those moments of action. Without memory, each request starts from nothing. With it, a message posted today is still there tomorrow, and the author who posted it can be found.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Getting the data model right before drawing the diagrams isn't pedantry. It's the thing that turns vague boxes into precise components. When you know that a&amp;nbsp;&lt;code&gt;message&lt;/code&gt;&amp;nbsp;is a row with an&amp;nbsp;&lt;code&gt;id&lt;/code&gt;, a&amp;nbsp;&lt;code&gt;content&lt;/code&gt;&amp;nbsp;field capped at 280 characters, and an&amp;nbsp;&lt;code&gt;author_id&lt;/code&gt;&amp;nbsp;that points to a specific user - then the "save message" step in your flowchart stops being a placeholder and starts being an instruction. The functional block that does the saving has a clear contract. The sequence diagram can name what passes between components.&lt;/p&gt;

&lt;h2&gt;
  
  
  One System, Two Databases - and Why That's the Right Call
&lt;/h2&gt;

&lt;p&gt;Before we write a single table definition, there's a structural question to answer: should all of&amp;nbsp;&lt;code&gt;Bird&lt;/code&gt;'s data live in one database, or more than one?&lt;/p&gt;

&lt;p&gt;The instinct is usually to start with one. It's simpler, it requires less setup, and for a small project it feels like the obvious default. We're going to make a different call - and the reasoning behind it is worth understanding clearly, because the same question will come up in every non-trivial system you ever build.&lt;/p&gt;

&lt;h3&gt;
  
  
  The problem with putting everything in one place
&lt;/h3&gt;

&lt;p&gt;Start with the simpler option: one database called&amp;nbsp;&lt;code&gt;bird&lt;/code&gt;, with all tables inside it - users, messages, sessions, roles, everything. Will it work?&lt;/p&gt;

&lt;p&gt;The answer is clear - it would work. In fact, it's how most beginner projects start, and there's nothing wrong with it as a starting point. But consider what happens as the system grows:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A security audit requires changes to how passwords are stored. To make that change safely, you need to understand every table that touches user data - except now it's tangled up with message tables, timeline queries, and subscription records. The scope of "change one thing" has quietly expanded.&lt;/li&gt;
&lt;li&gt;Message volume spikes. You want to move the&amp;nbsp;&lt;code&gt;messages&lt;/code&gt;&amp;nbsp;table to faster storage, or archive old records. But the table is in the same database as your user credentials, which have entirely different performance and retention requirements. You can't move one without the other.&lt;/li&gt;
&lt;li&gt;A colleague is working on authentication while you're working on the timeline feature. Both of you are making schema changes in the same database, to tables that are conceptually unrelated. You're in each other's way.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fon0zs0hz2huf7j033goq.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fon0zs0hz2huf7j033goq.jpg" alt=" " width="800" height="586"&gt;&lt;/a&gt; Figure 2. All in one Database - Will it Work?&lt;/p&gt;

&lt;p&gt;The deeper problem is this: a single database couples concerns that have no real reason to be coupled. They are in the same place not because they belong together, but because it was convenient to put them there. Convenience now, complexity later.&lt;/p&gt;

&lt;h3&gt;
  
  
  The principle: each domain owns its data
&lt;/h3&gt;

&lt;p&gt;The solution is to give each distinct area of the problem its own data store - its own database that it, and only it, is responsible for. Nothing else writes directly to that data; anything that needs information from another domain has to go through the interface that domain exposes.&lt;/p&gt;

&lt;p&gt;This is a well-established pattern in software design called&amp;nbsp;&lt;strong&gt;&lt;a href="https://dev.to/eugene-zimin/database-per-service-as-a-design-pattern-44gi"&gt;Database per Service&lt;/a&gt;&lt;/strong&gt;: each logical service or domain owns its storage, and the boundary between services is enforced at the data layer, not just at the code layer. The pattern makes the separation real and durable - you can't accidentally reach across a boundary you'd have to explicitly cross.&lt;/p&gt;

&lt;p&gt;![[Database per Service Design Pattern.jpg]]Figure 3. Database per Service Design Pattern&lt;/p&gt;

&lt;p&gt;For&amp;nbsp;&lt;code&gt;Bird&lt;/code&gt;, two domains emerge clearly once you ask the question:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Identity and access&lt;/strong&gt;&amp;nbsp;- who people are, how they prove it, what permissions they hold, which sessions are currently active. This is security-critical data, relatively stable, and completely self-contained. It has nothing to do with messages.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Content and social graph&lt;/strong&gt;&amp;nbsp;- the messages people post, and the follow relationships between them. This data is high-volume and fast-moving, with entirely different performance and retention characteristics.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These two concerns have different owners, different change rates, and different security requirements. They will be given separate databases:&amp;nbsp;&lt;code&gt;ums&lt;/code&gt;&amp;nbsp;(User Management System) for identity and access, and&amp;nbsp;&lt;code&gt;twitter&lt;/code&gt;&amp;nbsp;for content and social graph.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Database&lt;/th&gt;
&lt;th&gt;Domain&lt;/th&gt;
&lt;th&gt;What it owns&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ums&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Identity &amp;amp; access&lt;/td&gt;
&lt;td&gt;Who people are, how they authenticate, what permissions they hold, which sessions are active&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;twitter&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Content &amp;amp; social graph&lt;/td&gt;
&lt;td&gt;The messages people post, and who follows whom&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Separating them means each can be optimized, scaled, or secured independently - a schema change in one won't touch the other.&lt;/p&gt;

&lt;h3&gt;
  
  
  The idea behind the split: bounded contexts
&lt;/h3&gt;

&lt;p&gt;The deeper principle at work here comes from&amp;nbsp;&lt;strong&gt;Domain-Driven Design&lt;/strong&gt;&amp;nbsp;(DDD) - a way of thinking about how to organize software around the real-world problems it solves, rather than around technical convenience.&lt;/p&gt;

&lt;p&gt;DDD would describe&amp;nbsp;&lt;code&gt;ums&lt;/code&gt;&amp;nbsp;and&amp;nbsp;&lt;code&gt;twitter&lt;/code&gt;&amp;nbsp;as separate&amp;nbsp;&lt;strong&gt;bounded contexts&lt;/strong&gt;: distinct areas of the problem, each with its own vocabulary, rules, and data. The word&amp;nbsp;&lt;em&gt;user&lt;/em&gt;&amp;nbsp;in the identity context means something specific - an account with credentials, roles, and a session history. The word&amp;nbsp;&lt;em&gt;author&lt;/em&gt;&amp;nbsp;in the messaging context means something different - a source of content, identified by an ID, whose full identity details live elsewhere. These concepts correspond to the same human being, but they are different models of that person, serving different purposes. Keeping them in separate databases makes that distinction visible and enforces it.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F0zuxzd8pblp65lipqulm.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F0zuxzd8pblp65lipqulm.jpg" alt=" " width="800" height="586"&gt;&lt;/a&gt; Figure 4. How DDD Helps and Works&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;An analogy.&lt;/strong&gt;&amp;nbsp;Think of a hospital. The billing department and the medical records department both deal with the same patients - but they maintain completely separate files. The billing system doesn't need to know a patient's diagnosis; the medical records system doesn't need to know their payment history. Each department owns its data. Information is shared only when explicitly requested, through defined channels. The separation isn't bureaucracy - it's what keeps sensitive data appropriately contained, and what lets each department evolve independently.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;If you want to go deeper on Domain-Driven Design,&amp;nbsp;&lt;a href="https://dev.to/eugene-zimin/leveraging-domain-driven-design-for-application-design-58e2"&gt;this article&lt;/a&gt;&amp;nbsp;covers the core ideas without requiring a computer science background.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reading the Use Cases for Data Clues
&lt;/h2&gt;

&lt;p&gt;A use case describes what a user accomplishes. But read carefully, and it also tells you what the system must&amp;nbsp;&lt;em&gt;remember&lt;/em&gt; in order to make that possible. Each of&amp;nbsp;&lt;code&gt;Bird&lt;/code&gt;'s three use cases leaves a trail of data requirements - we just have to follow it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Post a message
&lt;/h3&gt;

&lt;p&gt;A user writes some text and publishes it. For this to work, the system must store the text itself, know&amp;nbsp;&lt;em&gt;who&lt;/em&gt;&amp;nbsp;posted it, and record&amp;nbsp;&lt;em&gt;when&lt;/em&gt;&amp;nbsp;it was posted so the timeline can be ordered. That's three pieces of information: content, author, timestamp.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Scope&lt;/th&gt;
&lt;th&gt;Field&lt;/th&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;th&gt;Why it's needed&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;messages&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;id&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;UUID&lt;/td&gt;
&lt;td&gt;A stable, unique identifier for this message&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;messages&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;author_id&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;UUID&lt;/td&gt;
&lt;td&gt;The&amp;nbsp;&lt;code&gt;id&lt;/code&gt;&amp;nbsp;of the user who posted this message - links back to&amp;nbsp;&lt;code&gt;users.id&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;messages&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;content&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;String (max 280 chars)&lt;/td&gt;
&lt;td&gt;The text of the message, capped at 280 characters&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;messages&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;created_at&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Timestamp&lt;/td&gt;
&lt;td&gt;When the message was posted - used to order the timeline&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  View the timeline
&lt;/h3&gt;

&lt;p&gt;This use case produces no new fields. It reads existing&amp;nbsp;&lt;code&gt;messages&lt;/code&gt;&amp;nbsp;rows, ordered by&amp;nbsp;&lt;code&gt;created_at&lt;/code&gt;&amp;nbsp;descending, and joins to&amp;nbsp;&lt;code&gt;users&lt;/code&gt;&amp;nbsp;on&amp;nbsp;&lt;code&gt;author_id&lt;/code&gt;&amp;nbsp;to display the author's name. Every field it depends on was already required by&amp;nbsp;&lt;em&gt;Post a message&lt;/em&gt;. Here we have to introduce what it is called - &lt;em&gt;subscriptions&lt;/em&gt; and set a relation between author and and consumer of the message, i.e. - subscriber.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Scope&lt;/th&gt;
&lt;th&gt;Field&lt;/th&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;th&gt;Why it's needed&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;subscriptions&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;subscriber_id&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;UUID&lt;/td&gt;
&lt;td&gt;The user doing the following - logical FK to&amp;nbsp;&lt;code&gt;users.id&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;subscriptions&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;producer_id&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;UUID&lt;/td&gt;
&lt;td&gt;The user being followed - logical FK to&amp;nbsp;&lt;code&gt;users.id&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;subscriptions&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;created_at&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Timestamp&lt;/td&gt;
&lt;td&gt;When the follow relationship was created&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Register an account
&lt;/h3&gt;

&lt;p&gt;A user creates an identity. The system must store a name to display, credentials to authenticate with (stored as a hashed password, never plain text), and again a timestamp. It also needs a way to distinguish one account from every other - a unique identifier that never changes even if the username does.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Scope&lt;/th&gt;
&lt;th&gt;Field&lt;/th&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;th&gt;Why it's needed&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;users&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;id&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;UUID&lt;/td&gt;
&lt;td&gt;A stable, unique identifier for this user - never changes, even if name or email does&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;users&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;name&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;String&lt;/td&gt;
&lt;td&gt;The display name shown alongside every post&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;users&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;email&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;String&lt;/td&gt;
&lt;td&gt;Login credential; unique across all users - no two accounts can share an address&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;users&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;password_hash&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;String&lt;/td&gt;
&lt;td&gt;The user's password after a one-way hashing function - never the plain-text password&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;users&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;created_at&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Timestamp&lt;/td&gt;
&lt;td&gt;When the account was registered&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;users&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;updated_at&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Timestamp&lt;/td&gt;
&lt;td&gt;When any field on this record last changed; refreshed automatically on every write&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Scope&lt;/th&gt;
&lt;th&gt;Field&lt;/th&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;th&gt;Why it's needed&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;roles&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;id&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;UUID&lt;/td&gt;
&lt;td&gt;Unique identifier for this role&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;roles&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;name&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;String&lt;/td&gt;
&lt;td&gt;The role label (e.g.&amp;nbsp;&lt;code&gt;admin&lt;/code&gt;,&amp;nbsp;&lt;code&gt;member&lt;/code&gt;) - must be unique&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;roles&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;description&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;String&lt;/td&gt;
&lt;td&gt;Optional human-readable explanation of what this role permits&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Scope&lt;/th&gt;
&lt;th&gt;Field&lt;/th&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;th&gt;Why it's needed&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;sessions&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;id&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;UUID&lt;/td&gt;
&lt;td&gt;Unique identifier for this login session&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;sessions&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;user_id&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;UUID&lt;/td&gt;
&lt;td&gt;References&amp;nbsp;&lt;code&gt;users.id&lt;/code&gt;&amp;nbsp;- whose session this is&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;sessions&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;logged_in_at&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Timestamp&lt;/td&gt;
&lt;td&gt;When the session started&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;sessions&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;logged_out_at&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Timestamp&lt;/td&gt;
&lt;td&gt;When the session ended;&amp;nbsp;&lt;code&gt;NULL&lt;/code&gt;&amp;nbsp;if the user is still logged in&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Across all three use cases, two distinct categories of information emerge: things the system needs to know about&amp;nbsp;&lt;em&gt;people&lt;/em&gt;, and things it needs to know about&amp;nbsp;&lt;em&gt;messages&lt;/em&gt;. That observation is the foundation of the data model.&lt;/p&gt;

&lt;h2&gt;
  
  
  Naming the Entities
&lt;/h2&gt;

&lt;p&gt;An&amp;nbsp;&lt;strong&gt;entity&lt;/strong&gt;&amp;nbsp;is a category of information the system tracks as a distinct thing - something that has its own identity, its own set of properties, and its own lifetime. Naming entities is the first act of data modelling: you're deciding what the system considers a&amp;nbsp;&lt;em&gt;noun&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;From the use cases, two entities name themselves:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;User&lt;/code&gt;&lt;/strong&gt;&amp;nbsp;- a registered account. Every message is posted by a user; the timeline attributes each post to one. Users exist independently of any message they've posted, and they persist even if all their messages were deleted. They are a distinct thing in their own right.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;Message&lt;/code&gt;&lt;/strong&gt;&amp;nbsp;- a piece of content posted by a user. Messages depend on users (a message with no author makes no sense), but they are not&amp;nbsp;&lt;em&gt;part of&lt;/em&gt;&amp;nbsp;a user - they are their own thing, with their own timestamp and their own text.&lt;/p&gt;

&lt;p&gt;Two entities. That matches the two databases we decided to create:&amp;nbsp;&lt;code&gt;ums&lt;/code&gt;&amp;nbsp;owns&amp;nbsp;&lt;code&gt;User&lt;/code&gt;, and&amp;nbsp;&lt;code&gt;twitter&lt;/code&gt;&amp;nbsp;owns&amp;nbsp;&lt;code&gt;Message&lt;/code&gt;. The database boundary and the entity boundary are the same line drawn twice.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;You'll notice there's no&amp;nbsp;&lt;code&gt;Timeline&lt;/code&gt;&amp;nbsp;entity, no&amp;nbsp;&lt;code&gt;Feed&lt;/code&gt;&amp;nbsp;entity, no&amp;nbsp;&lt;code&gt;Notification&lt;/code&gt;. The timeline is not a thing the system stores - it's a&amp;nbsp;&lt;em&gt;query&lt;/em&gt; - give me all messages, ordered by&amp;nbsp;&lt;code&gt;created_at&lt;/code&gt;, descending. It exists at runtime, not at rest. This is a useful distinction to internalise: not everything the user&amp;nbsp;&lt;em&gt;sees&lt;/em&gt;&amp;nbsp;needs to be&amp;nbsp;&lt;em&gt;stored&lt;/em&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;In the next section we'll define the fields each entity carries - and introduce the mechanism that connects a&amp;nbsp;&lt;code&gt;Message&lt;/code&gt;back to the&amp;nbsp;&lt;code&gt;User&lt;/code&gt;&amp;nbsp;who wrote it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Defining the Fields
&lt;/h2&gt;

&lt;p&gt;An entity is just a name until you give it fields - the individual pieces of data it holds. This is where the model gets concrete. For each field, we'll state what it is, what type of value it holds, and&amp;nbsp;&lt;em&gt;why it exists&lt;/em&gt;, tracing every decision back to a use case or a design constraint.&lt;/p&gt;

&lt;h3&gt;
  
  
  A note on identifiers
&lt;/h3&gt;

&lt;p&gt;Every entity needs a&amp;nbsp;&lt;strong&gt;primary key&lt;/strong&gt;&amp;nbsp;- a field whose sole job is to uniquely identify one row among all others, forever. A common choice is an auto-incrementing integer (1, 2, 3...), but&amp;nbsp;&lt;code&gt;Bird&lt;/code&gt;&amp;nbsp;uses something different: a&amp;nbsp;&lt;strong&gt;UUID&lt;/strong&gt;&amp;nbsp;(Universally Unique Identifier), stored as 16 bytes (or 128 bits) of binary data.&lt;/p&gt;

&lt;p&gt;A UUID looks like&amp;nbsp;&lt;code&gt;550e8400-e29b-41d4-a716-446655440000&lt;/code&gt;&amp;nbsp;- a 128-bit value generated in a way that makes collisions statistically impossible, even across separate systems. The binary storage (&lt;code&gt;BINARY(16)&lt;/code&gt;) keeps it compact and fast to index. The reason to prefer UUIDs over integers here is forward-looking: if&amp;nbsp;&lt;code&gt;Bird&lt;/code&gt;&amp;nbsp;ever scales to multiple servers generating records simultaneously, each can produce its own IDs without coordinating with the others. Integers can't do that safely.&lt;/p&gt;

&lt;p&gt;Every table uses this same pattern for its primary key: a&amp;nbsp;&lt;code&gt;BINARY(16)&lt;/code&gt;&amp;nbsp;column called&amp;nbsp;&lt;code&gt;id&lt;/code&gt;, defaulting to a freshly generated UUID.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;users&lt;/code&gt;&amp;nbsp;- the identity record
&lt;/h3&gt;

&lt;p&gt;The&amp;nbsp;&lt;code&gt;users&lt;/code&gt;&amp;nbsp;table lives in the&amp;nbsp;&lt;code&gt;ums&lt;/code&gt;&amp;nbsp;database. It is the authoritative record of everyone who has registered an account.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Field&lt;/th&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;th&gt;Purpose&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;id&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;BINARY(16)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Primary key - uniquely identifies this user across the entire system&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;name&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;VARCHAR(100)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The display name shown on posts&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;email&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;VARCHAR(255)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Login credential and contact address; must be unique across all users&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;password_hash&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;VARCHAR(255)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The result of running the user's password through a one-way hashing function - never the password itself&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;created_at&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;DATETIME&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;When the account was registered&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;updated_at&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;DATETIME&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;When any field on this record was last changed; updated automatically&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two fields deserve a word of explanation.&amp;nbsp;&lt;code&gt;password_hash&lt;/code&gt;&amp;nbsp;stores a hashed password, not a plain-text one. A&amp;nbsp;&lt;strong&gt;hash function&lt;/strong&gt;is a one-way transformation: you can turn a password into a hash, but you cannot reverse the process. When a user logs in, the system hashes what they typed and compares the result to the stored hash - the original password never needs to be stored or retrieved. This is standard practice; storing plain passwords is a serious security failure.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;updated_at&lt;/code&gt;&amp;nbsp;tracks the last modification time and refreshes itself automatically on every write. It's low-cost to store and invaluable for debugging, auditing, and cache invalidation later.&lt;/p&gt;

&lt;h3&gt;
  
  
  Supporting the&amp;nbsp;&lt;code&gt;User&lt;/code&gt;: roles and sessions
&lt;/h3&gt;

&lt;p&gt;The use cases named registration - but a real identity system has two more concerns lurking just beneath the surface:&amp;nbsp;&lt;em&gt;what is this user allowed to do&lt;/em&gt;, and&amp;nbsp;&lt;em&gt;is this user currently logged in&lt;/em&gt;? These concerns get their own tables.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;roles&lt;/code&gt;&lt;/strong&gt;&amp;nbsp;is a lookup table - a simple list of named permission levels (for example,&amp;nbsp;&lt;code&gt;admin&lt;/code&gt;,&amp;nbsp;&lt;code&gt;moderator&lt;/code&gt;,&amp;nbsp;&lt;code&gt;member&lt;/code&gt;). Roles don't come from the use cases directly; they come from the reality that not all users have the same permissions.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Field&lt;/th&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;th&gt;Purpose&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;id&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;BINARY(16)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Primary key — uniquely identifies this role&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;name&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;VARCHAR(50)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The role label (e.g.&amp;nbsp;&lt;code&gt;admin&lt;/code&gt;,&amp;nbsp;&lt;code&gt;member&lt;/code&gt;) — must be unique&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;description&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;VARCHAR(255)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Optional human-readable explanation of what this role permits&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;users_roles&lt;/code&gt;&lt;/strong&gt;&amp;nbsp;links users to roles. Because one user can hold multiple roles and one role can be held by many users, this is a&amp;nbsp;&lt;strong&gt;many-to-many relationship&lt;/strong&gt;&amp;nbsp;- and the standard way to model that in a relational database is a join table: a table with two columns, each a reference to one side of the relationship.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;sessions&lt;/code&gt;&lt;/strong&gt;&amp;nbsp;records active login sessions. When a user authenticates, a session row is created with a&amp;nbsp;&lt;code&gt;logged_in_at&lt;/code&gt;&amp;nbsp;timestamp. When they log out,&amp;nbsp;&lt;code&gt;logged_out_at&lt;/code&gt;&amp;nbsp;is filled in. This gives the system a full audit trail of who was logged in and when - and lets it invalidate specific sessions without forcing a global logout.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Field&lt;/th&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;th&gt;Purpose&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;id&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;BINARY(16)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Primary key — uniquely identifies this session&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;user_id&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;BINARY(16)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;References&amp;nbsp;&lt;code&gt;ums.users.id&lt;/code&gt;&amp;nbsp;— whose session this is&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;logged_in_at&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;DATETIME&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;When the session started&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;logged_out_at&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;DATETIME&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;When the session ended;&amp;nbsp;&lt;code&gt;NULL&lt;/code&gt;&amp;nbsp;if the user is still logged in&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;None of these tables store messages or content. They belong entirely to the&amp;nbsp;&lt;code&gt;ums&lt;/code&gt;&amp;nbsp;database and the identity domain.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;messages&lt;/code&gt;&amp;nbsp;- the content record
&lt;/h3&gt;

&lt;p&gt;The&amp;nbsp;&lt;code&gt;messages&lt;/code&gt;&amp;nbsp;table lives in the&amp;nbsp;&lt;code&gt;twitter&lt;/code&gt;&amp;nbsp;database. It is the record of everything posted.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Field&lt;/th&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;th&gt;Purpose&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;id&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;BINARY(16)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Primary key - uniquely identifies this message&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;author_id&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;BINARY(16)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The&amp;nbsp;&lt;code&gt;id&lt;/code&gt;&amp;nbsp;of the user who posted this message, from&amp;nbsp;&lt;code&gt;ums.users&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;content&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;VARCHAR(280)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The text of the message - capped at 280 characters&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;created_at&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;DATETIME&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;When the message was posted&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;content&lt;/code&gt;&amp;nbsp;is capped at 280 characters - the same limit Twitter uses, and a deliberate product constraint, not a technical one. The database enforces it with&amp;nbsp;&lt;code&gt;VARCHAR(280)&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;author_id&lt;/code&gt;&amp;nbsp;is the field that links a message to its author. It holds the&amp;nbsp;&lt;code&gt;id&lt;/code&gt;&amp;nbsp;value of a row in&amp;nbsp;&lt;code&gt;ums.users&lt;/code&gt;. Because the two tables live in separate databases, MySQL cannot enforce this link with a formal foreign key constraint - but the relationship is real. The application layer is responsible for ensuring that no message is ever written with an&amp;nbsp;&lt;code&gt;author_id&lt;/code&gt;&amp;nbsp;that doesn't correspond to a real user.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;subscriptions&lt;/code&gt;&amp;nbsp;- the social graph
&lt;/h3&gt;

&lt;p&gt;There is one more table in the&amp;nbsp;&lt;code&gt;twitter&lt;/code&gt;&amp;nbsp;database:&amp;nbsp;&lt;code&gt;subscriptions&lt;/code&gt;. It didn't appear in the original three use cases, but it's present in the schema for a good reason - it's the data that would power a personalised timeline.&lt;/p&gt;

&lt;p&gt;A subscription is a directional relationship between two users: one&amp;nbsp;&lt;strong&gt;subscriber&lt;/strong&gt;&amp;nbsp;who follows one&amp;nbsp;&lt;strong&gt;producer&lt;/strong&gt;. The table has just three fields:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Field&lt;/th&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;th&gt;Purpose&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;subscriber_id&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;BINARY(16)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The user doing the following&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;producer_id&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;BINARY(16)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The user being followed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;created_at&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;DATETIME&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;When the follow happened&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The combination of&amp;nbsp;&lt;code&gt;subscriber_id&lt;/code&gt;&amp;nbsp;and&amp;nbsp;&lt;code&gt;producer_id&lt;/code&gt;&amp;nbsp;is the primary key - you can only follow someone once. Both columns are logical foreign keys to&amp;nbsp;&lt;code&gt;ums.users.id&lt;/code&gt;, subject to the same cross-database constraint limitation as&amp;nbsp;&lt;code&gt;author_id&lt;/code&gt;&amp;nbsp;in&amp;nbsp;&lt;code&gt;messages&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;subscriptions&lt;/code&gt;&amp;nbsp;is infrastructure for a feature -&amp;nbsp;&lt;em&gt;"Follow a user"&lt;/em&gt;&amp;nbsp;- that isn't in scope for the current lesson's use cases but is correct to model now, because adding it later would require a migration. Modelling it upfront costs nothing; omitting it and adding it later costs a schema change and a deployment. This is the kind of forward-thinking that separates a considered data model from a reactive one.&lt;/p&gt;

&lt;h2&gt;
  
  
  How the Entities Relate
&lt;/h2&gt;

&lt;p&gt;Individual entities are only half the picture. A data model also defines how entities connect to one another - their&amp;nbsp;&lt;strong&gt;relationships&lt;/strong&gt;. For&amp;nbsp;&lt;code&gt;Bird&lt;/code&gt;, there are two:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A User has many Messages&lt;/strong&gt;&amp;nbsp;(one-to-many). One user can post any number of messages; each message belongs to exactly one user. This relationship is expressed through&amp;nbsp;&lt;code&gt;author_id&lt;/code&gt;&amp;nbsp;in the&amp;nbsp;&lt;code&gt;messages&lt;/code&gt;&amp;nbsp;table - a field that holds the&amp;nbsp;&lt;code&gt;id&lt;/code&gt;&amp;nbsp;of the user who owns that row. Following the&amp;nbsp;&lt;code&gt;author_id&lt;/code&gt;&amp;nbsp;from a message leads you to its author.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A User can follow many Users, and be followed by many Users&lt;/strong&gt;&amp;nbsp;(many-to-many). This is the social graph, modelled through the&amp;nbsp;&lt;code&gt;subscriptions&lt;/code&gt;&amp;nbsp;table. To find everyone a user follows, query&amp;nbsp;&lt;code&gt;subscriptions&lt;/code&gt;&amp;nbsp;where&amp;nbsp;&lt;code&gt;subscriber_id&lt;/code&gt;&amp;nbsp;matches. To find everyone following a user, query where&amp;nbsp;&lt;code&gt;producer_id&lt;/code&gt;&amp;nbsp;matches.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Flpbs4aqnxlbfitwczp17.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Flpbs4aqnxlbfitwczp17.jpg" alt=" " width="800" height="539"&gt;&lt;/a&gt;Figure 5. Types of Relationships&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The pattern to remember.&lt;/strong&gt;&amp;nbsp;A one-to-many relationship is expressed by putting the "one" side's&amp;nbsp;&lt;code&gt;id&lt;/code&gt;&amp;nbsp;as a field on the "many" side - the foreign key lives in the child table. A many-to-many relationship requires its own table, with one column for each side. Both patterns appear in&amp;nbsp;&lt;code&gt;Bird&lt;/code&gt;, and together they cover the vast majority of real-world data relationships you'll encounter.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;With the entities named, the fields defined, and the relationships mapped, the data model is complete. What remains is to choose a database engine and write the schema.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the Diagrams Will Now Be Built On
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://dev.to/eugene-zimin/from-idea-to-blueprint-turning-a-vague-app-concept-into-something-you-can-actually-build-1a60"&gt;Lesson 1&lt;/a&gt; introduced three lenses for looking at a system: functional structure, behaviour, and component interaction. &lt;a href="https://dev.to/eugene-zimin/from-idea-to-blueprint-turning-a-vague-app-concept-into-something-you-can-actually-build-1a60"&gt;Lesson 1&lt;/a&gt; also made a promise — that diagrams would follow.&lt;/p&gt;

&lt;p&gt;They will. But notice what's changed since then.&lt;/p&gt;

&lt;p&gt;Before the data model existed, a flowchart for&amp;nbsp;&lt;em&gt;"Post a message"&lt;/em&gt;&amp;nbsp;would have had a step labelled something like&amp;nbsp;&lt;em&gt;"save the message"&lt;/em&gt;&amp;nbsp;— a placeholder that gestures at an action without defining it. Now that step has a precise meaning: create a row in&amp;nbsp;&lt;code&gt;twitter.messages&lt;/code&gt;&amp;nbsp;with a&amp;nbsp;&lt;code&gt;content&lt;/code&gt;&amp;nbsp;value, an&amp;nbsp;&lt;code&gt;author_id&lt;/code&gt;&amp;nbsp;pointing to the authenticated user in&amp;nbsp;&lt;code&gt;ums.users&lt;/code&gt;, and a&amp;nbsp;&lt;code&gt;created_at&lt;/code&gt;timestamp set to now.&lt;/p&gt;

&lt;p&gt;The data model doesn't just inform the diagrams — it creates clear mechanics how they work. Every box that reads or writes data now has a contract: specific fields, specific tables, specific relationships. The sequence diagram will name what passes between components. The functional diagram will know what each block owns. The flowchart steps will correspond to real operations.&lt;/p&gt;

&lt;p&gt;That's where &lt;a href="https://dev.to/eugene-zimin/drawing-the-blueprint-flowchart-functional-diagram-and-sequence-diagram-537c"&gt;Lesson 3&lt;/a&gt; picks up: with the data model as the foundation, we draw all three diagrams.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;A data model is the contract the rest of the system is written against.&lt;/p&gt;

&lt;p&gt;We started with three use cases and asked a simple question: what must the system remember for each of these to work? That question led us to two entities —&amp;nbsp;&lt;code&gt;User&lt;/code&gt;&amp;nbsp;and&amp;nbsp;&lt;code&gt;Message&lt;/code&gt;&amp;nbsp;— and from there to five tables across two purposefully separated databases.&lt;/p&gt;

&lt;p&gt;The separation itself was a design decision, not a convenience. Identity and access belong to one domain; content and social graph belong to another. Keeping them apart makes each easier to change, scale, and reason about independently. The trade-off — enforcing cross-database relationships in application code rather than at the database level — is a known and manageable cost.&lt;/p&gt;

&lt;p&gt;Every field in the schema exists for a reason traceable back to a use case. Every relationship reflects a real dependency between things the system tracks. Nothing was added for completeness or anticipation — except&amp;nbsp;&lt;code&gt;subscriptions&lt;/code&gt;, which earns its place by being cheaper to model now than to migrate in later.&lt;/p&gt;

&lt;p&gt;The diagrams come next. They'll have something real to say.&lt;/p&gt;

</description>
      <category>database</category>
      <category>web</category>
      <category>architecture</category>
      <category>data</category>
    </item>
    <item>
      <title>From Idea to Blueprint: Turning a Vague App Concept into Something You Can Actually Build</title>
      <dc:creator>Eugene Zimin</dc:creator>
      <pubDate>Sun, 17 May 2026 22:03:47 +0000</pubDate>
      <link>https://dev.to/eugene-zimin/from-idea-to-blueprint-turning-a-vague-app-concept-into-something-you-can-actually-build-1a60</link>
      <guid>https://dev.to/eugene-zimin/from-idea-to-blueprint-turning-a-vague-app-concept-into-something-you-can-actually-build-1a60</guid>
      <description>&lt;h1&gt;
  
  
  Lesson 1 of&amp;nbsp;&lt;em&gt;Build a Twitter Clone&lt;/em&gt;&amp;nbsp;— A Practical Guide to Software Modelling
&lt;/h1&gt;

&lt;p&gt;"Build a Twitter clone" is a wish, not a plan — you can't write code from a single sentence. This lesson shows you how to think about turning a fuzzy app idea into a concrete blueprint: we scope a deliberately small messaging app called&amp;nbsp;&lt;code&gt;Bird&lt;/code&gt;, then introduce the three-lens model —&amp;nbsp;&lt;em&gt;what should it do?&lt;/em&gt;,&amp;nbsp;&lt;em&gt;what parts must exist?&lt;/em&gt;, and&amp;nbsp;&lt;em&gt;how do those parts talk to each other?&lt;/em&gt;&amp;nbsp;— that the rest of the series uses to actually draw it. By the end you'll have a bounded project and a clear mental model, the foundation &lt;a href="https://dev.to/eugene-zimin/the-blueprint-beneath-the-blueprint-designing-data-model-and-choosing-its-database-3bhl"&gt;Lesson 2&lt;/a&gt; builds on.&lt;/p&gt;

&lt;h2&gt;
  
  
  Introduction — From Idea to Blueprint
&lt;/h2&gt;

&lt;p&gt;Imagine someone hands you a sticky note that says&amp;nbsp;&lt;strong&gt;"build a Twitter clone"&lt;/strong&gt;&amp;nbsp;and asks you to start coding.&lt;/p&gt;

&lt;p&gt;Where, exactly, would you put your cursor?&lt;/p&gt;

&lt;p&gt;That question is harder than it looks, and the difficulty isn't about programming skill. It's that the instruction isn't actually buildable. "Build a Twitter clone" is a&amp;nbsp;&lt;em&gt;wish&lt;/em&gt;. It tells you the destination but none of the road. Before a single line of code makes sense, that wish has to be transformed into something with edges, parts, and order — a&amp;nbsp;&lt;strong&gt;blueprint&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fn6g4ur1za11qelsqrz75.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fn6g4ur1za11qelsqrz75.png" alt="Figure 1. From Idea to Blueprint" width="800" height="597"&gt;&lt;/a&gt;Figure 1. From Idea to Blueprint&lt;/p&gt;

&lt;p&gt;This lesson is about making that transformation, and doing it with a tool most people underrate: the humble diagram.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why an idea isn't a plan
&lt;/h3&gt;

&lt;p&gt;A sentence like "build a Twitter clone" hides an enormous number of decisions. Consider just a few:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Can users edit a post after publishing it, or is it permanent?&lt;/li&gt;
&lt;li&gt;What happens when someone submits an empty message? An error? Silence?&lt;/li&gt;
&lt;li&gt;Does a post appear instantly, or is there a moment of "sending..."?&lt;/li&gt;
&lt;li&gt;Where does the message&amp;nbsp;&lt;em&gt;go&lt;/em&gt;&amp;nbsp;once it's submitted — and who is responsible for keeping it?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of these answers live in the original sentence. They have to be&amp;nbsp;&lt;em&gt;decided&lt;/em&gt;, and a working app is really just the sum of hundreds of such decisions made consistent with one another. The gap between "an idea" and "a plan" is precisely the gap of unmade decisions — and code is a terrible place to discover you forgot one.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fl2ug5mvfc1qn39lbdosf.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fl2ug5mvfc1qn39lbdosf.png" alt="Figure 2. Why Idea is not Enough" width="799" height="381"&gt;&lt;/a&gt; Figure 2. Why Idea is not Enough&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A blueprint isn't bureaucracy. It's the cheapest place to be wrong. Moving a wall on paper costs a pencil eraser; moving it after the concrete is poured costs a demolition crew.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The same logic applies to software. A confused diagram can be fixed in thirty seconds. The same confusion, discovered three weeks into coding, can cost days of rework. Thinking before building isn't slower — it's the thing that makes building fast.&lt;/p&gt;

&lt;h3&gt;
  
  
  Diagrams: thinking you can point at
&lt;/h3&gt;

&lt;p&gt;When we say&amp;nbsp;&lt;strong&gt;diagram&lt;/strong&gt;, we don't mean decorative boxes-and-arrows that get drawn after the work is done and quietly go stale. We mean something more useful: a diagram is&amp;nbsp;&lt;em&gt;a way of thinking that you can point at&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;Plain prose is bad at certain kinds of thought. A paragraph describing how six components pass data around is exhausting to read and easy to get wrong, because language forces ideas into a single line, one word after another. But a system isn't a line — it has shape. It branches. It loops. It has parts that exist&amp;nbsp;&lt;em&gt;alongside&lt;/em&gt;&amp;nbsp;each other. A diagram lets that shape sit still on the page so you can inspect it, find the gap, and point a teammate straight at the problem.&lt;/p&gt;

&lt;p&gt;That's the real job of the diagrams in this series. They are not documentation. They are&amp;nbsp;&lt;strong&gt;the instrument you use to turn the wish into the plan&lt;/strong&gt;&amp;nbsp;— and, just as importantly, a shared picture a whole team can argue over and agree on.&lt;/p&gt;

&lt;h3&gt;
  
  
  What this series is — and what this lesson does
&lt;/h3&gt;

&lt;p&gt;This is the first lesson in a hands-on series with one practical goal:&amp;nbsp;&lt;strong&gt;build a working Twitter-style application, from blank page to running software.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;We won't begin with code, a database, or a framework. We'll begin where every real project begins — with the awkward question&amp;nbsp;&lt;em&gt;"what should this thing even do?"&lt;/em&gt;&amp;nbsp;— and we'll answer it the way experienced engineers do: by modelling the system before constructing it.&lt;/p&gt;

&lt;p&gt;Across this lesson you'll meet&amp;nbsp;&lt;strong&gt;three kinds of diagrams&lt;/strong&gt;, and the order matters, because each one answers a question the next one depends on:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Behaviour&lt;/strong&gt;&amp;nbsp;—&amp;nbsp;&lt;em&gt;What should the app do?&lt;/em&gt;&amp;nbsp;We'll capture this with a&amp;nbsp;&lt;strong&gt;flowchart&lt;/strong&gt;: the step-by-step flow of a single user action, including its decision points and outcomes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Structure&lt;/strong&gt;&amp;nbsp;—&amp;nbsp;&lt;em&gt;What parts must exist to make that behaviour possible?&lt;/em&gt;&amp;nbsp;We'll capture this with a&amp;nbsp;&lt;strong&gt;functional diagram&lt;/strong&gt;&amp;nbsp;(also called a block diagram): the system broken into its major components.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Interaction&lt;/strong&gt;&amp;nbsp;—&amp;nbsp;&lt;em&gt;How do those parts actually talk to each other at runtime?&lt;/em&gt;&amp;nbsp;We'll capture this with a&amp;nbsp;&lt;strong&gt;sequence diagram&lt;/strong&gt;: the precise back-and-forth of messages between components.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F9f7bcjzivvf05sfvrsv0.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F9f7bcjzivvf05sfvrsv0.png" alt="Figure 3. The Cube" width="800" height="800"&gt;&lt;/a&gt; Figure 3. The Cube&lt;/p&gt;

&lt;p&gt;Behaviour first, then the structure that delivers it, then the detailed conversation inside that structure. Each diagram is derived from the one before — you'll watch structure literally&amp;nbsp;&lt;em&gt;fall out of&lt;/em&gt;&amp;nbsp;behaviour rather than being invented from thin air.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Don't worry if those three terms mean nothing to you yet.&lt;/strong&gt;&amp;nbsp;Each gets a plain-language introduction, a worked example, and a step-by-step recipe in its own section. Right now, the only thing to hold onto is the progression:&amp;nbsp;&lt;strong&gt;what it does → what it's made of → how the parts interact.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  A deliberately small project
&lt;/h3&gt;

&lt;p&gt;There's one honest catch we should name immediately. The real Twitter is gigantic — feeds, ranking algorithms, notifications, direct messages, media uploads, advertising, moderation at planetary scale. We are&amp;nbsp;&lt;em&gt;not&lt;/em&gt;&amp;nbsp;building that. Trying to would teach you nothing except how to feel overwhelmed.&lt;/p&gt;

&lt;p&gt;Instead, we'll build the&amp;nbsp;&lt;strong&gt;smallest thing that is still recognisably a short-messaging app&lt;/strong&gt;&amp;nbsp;— a deliberately tiny slice we'll call&amp;nbsp;&lt;strong&gt;&lt;code&gt;Bird&lt;/code&gt;&lt;/strong&gt;. Choosing that slice on purpose is not a shortcut around the engineering. It&amp;nbsp;&lt;em&gt;is&lt;/em&gt;&amp;nbsp;the engineering. Knowing what to leave out — scoping a&amp;nbsp;&lt;em&gt;minimum viable product&lt;/em&gt;, or&amp;nbsp;&lt;strong&gt;MVP&lt;/strong&gt;, the leanest version that still does the core job — is one of the most valuable skills in software, and it's the first thing the next section will practise.&lt;/p&gt;

&lt;p&gt;So let's pick up the sticky note that says&amp;nbsp;&lt;em&gt;"build a Twitter clone"&lt;/em&gt;&amp;nbsp;— and turn it into something you can actually build.&lt;/p&gt;

&lt;h2&gt;
  
  
  Meet the Project:&amp;nbsp;&lt;code&gt;Bird&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Before we can draw a single diagram, we need something to draw&amp;nbsp;&lt;em&gt;about&lt;/em&gt;. So let's give the vague sticky note a concrete shape.&lt;/p&gt;

&lt;p&gt;We're building&amp;nbsp;&lt;strong&gt;&lt;code&gt;Bird&lt;/code&gt;&lt;/strong&gt;: a small short-messaging app where people post brief public updates and read other people's. That's the whole pitch. If Twitter is a city,&amp;nbsp;&lt;code&gt;Bird&lt;/code&gt;&amp;nbsp;is a single well-built room in it.&lt;/p&gt;

&lt;h3&gt;
  
  
  The hardest decision is what to leave out
&lt;/h3&gt;

&lt;p&gt;It's tempting to start listing everything&amp;nbsp;&lt;code&gt;Bird&lt;/code&gt;&amp;nbsp;&lt;em&gt;could&lt;/em&gt;&amp;nbsp;do — likes, replies, hashtags, profile photos, search, notifications, a ranked feed. Resist that. A feature list that grows without limit is the single most common reason beginner projects never ship.&lt;/p&gt;

&lt;p&gt;The discipline here has a name: scoping a&amp;nbsp;&lt;strong&gt;minimum viable product&lt;/strong&gt;, or&amp;nbsp;&lt;strong&gt;MVP&lt;/strong&gt;&amp;nbsp;— the&amp;nbsp;&lt;em&gt;leanest&lt;/em&gt;&amp;nbsp;version of an app that still does its core job and nothing more. The goal isn't a crippled product. It's a&amp;nbsp;&lt;em&gt;complete&lt;/em&gt;&amp;nbsp;one with a deliberately narrow definition of "complete."&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;A useful test for the MVP line:&lt;/strong&gt;&amp;nbsp;for each candidate feature, ask&amp;nbsp;&lt;em&gt;"If this were missing, would the app still be recognisably a short-messaging app?"&lt;/em&gt;&amp;nbsp;If the answer is yes, the feature is&amp;nbsp;&lt;strong&gt;out&lt;/strong&gt;&amp;nbsp;of Lesson 1 — not deleted forever, just postponed.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Run that test and most of the obvious features fall away. Likes? The app still works without them. Hashtags? Still works. A ranked feed? Still works — you'd just see posts newest-first. But remove the ability to&amp;nbsp;&lt;em&gt;write&lt;/em&gt;&amp;nbsp;a post, or to&amp;nbsp;&lt;em&gt;see_posts&lt;/em&gt;, and there's no app left at all. Those survive the test. Almost nothing else does.&lt;/p&gt;

&lt;p&gt;This gives us our scope. We'll describe it as a short list of&amp;nbsp;&lt;strong&gt;use cases&lt;/strong&gt;&amp;nbsp;— a use case is simply&amp;nbsp;&lt;em&gt;one complete thing a user can accomplish with the app&lt;/em&gt;, named from the user's point of view. Use cases are the raw material everything else in this lesson is derived from, so naming them precisely matters.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fz85n36c5cln5cohxf7x5.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fz85n36c5cln5cohxf7x5.png" alt="Figure 4. Defining the MVP - Minimal Viable Product" width="799" height="381"&gt;&lt;/a&gt; Figure 4. Defining the MVP - Minimal Viable Product&lt;/p&gt;

&lt;p&gt;For now,&amp;nbsp;&lt;code&gt;Bird&lt;/code&gt;&amp;nbsp;has exactly three use cases:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;#&lt;/th&gt;
&lt;th&gt;Use case&lt;/th&gt;
&lt;th&gt;What the user accomplishes&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;strong&gt;Post a message&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Write a short message (a "bird") and publish it so others can see it.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;View the timeline&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;See a list of recently posted messages, newest first.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Register an account&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Create an identity so posts can be attributed to a person.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Three use cases. That's the entire behavioural scope of the app — and it's&amp;nbsp;&lt;em&gt;meant&lt;/em&gt;&amp;nbsp;to feel small.&lt;/p&gt;

&lt;h3&gt;
  
  
  One use case becomes our worked example
&lt;/h3&gt;

&lt;p&gt;Three use cases is a small scope, but it's still more than we need to&amp;nbsp;&lt;em&gt;teach&lt;/em&gt;&amp;nbsp;diagramming. Drawing all three through all three diagram types would be repetitive — you'd learn the technique once and then watch it twice more.&lt;/p&gt;

&lt;p&gt;So for the rest of this lesson, we'll follow&amp;nbsp;&lt;strong&gt;a single use case&lt;/strong&gt;&amp;nbsp;end to end:&amp;nbsp;&lt;strong&gt;"Post a message."&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;We chose it deliberately. Of the three, it's the one with real&amp;nbsp;&lt;em&gt;internal richness&lt;/em&gt;&amp;nbsp;— it involves the user typing, the app checking the input, something deciding whether to accept or reject it, and the message being stored somewhere. That makes it the ideal teaching subject:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;it has a clear&amp;nbsp;&lt;strong&gt;start and end&lt;/strong&gt;, so it suits a flowchart;&lt;/li&gt;
&lt;li&gt;it requires&amp;nbsp;&lt;strong&gt;several distinct components&lt;/strong&gt;, so it suits a functional diagram;&lt;/li&gt;
&lt;li&gt;it involves a genuine&amp;nbsp;&lt;strong&gt;back-and-forth between those components&lt;/strong&gt;, so it suits a sequence diagram.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;"View the timeline" and "Register an account" are real and necessary, but we'll leave them as exercises. Once you've watched "Post a message" travel through all three diagram types, you'll have the pattern — and applying it to the other two becomes straightforward practice rather than new material.&lt;/p&gt;

&lt;p&gt;Eventually, we've turned a vague wish ("build a Twitter clone") into a&amp;nbsp;&lt;em&gt;bounded&lt;/em&gt;&amp;nbsp;one: an app called&amp;nbsp;&lt;code&gt;Bird&lt;/code&gt;, three named use cases, an explicit out-of-scope list, and one chosen worked example. That bounded definition is the first real artifact of the series. The next section introduces the three lenses we'll view it through — and explains why their order is not arbitrary.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three Lenses, One Progression
&lt;/h2&gt;

&lt;p&gt;We now have a bounded project: the&amp;nbsp;&lt;code&gt;Bird&lt;/code&gt;&amp;nbsp;app, three use cases, and one worked example —&amp;nbsp;&lt;em&gt;"Post a message."&lt;/em&gt;&amp;nbsp;The next move is to look at that example through three different&amp;nbsp;&lt;strong&gt;lenses&lt;/strong&gt;, each a kind of diagram.&lt;/p&gt;

&lt;p&gt;The word&amp;nbsp;&lt;em&gt;lens&lt;/em&gt;&amp;nbsp;is the right one. A lens doesn't change the thing you're looking at — it changes what you can&amp;nbsp;&lt;em&gt;see&lt;/em&gt;&amp;nbsp;clearly. Point a microscope and a telescope at the same night sky and you learn completely different truths. Our three diagrams work the same way: one system, three views, three distinct questions answered.&lt;/p&gt;

&lt;h3&gt;
  
  
  The three questions
&lt;/h3&gt;

&lt;p&gt;Every software system can be interrogated from three angles. Each diagram type is just a tool purpose-built to answer one of them.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Lens&lt;/th&gt;
&lt;th&gt;The question it answers&lt;/th&gt;
&lt;th&gt;The diagram&lt;/th&gt;
&lt;th&gt;What you see&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Behaviour&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;What should the app&amp;nbsp;&lt;em&gt;do&lt;/em&gt;?&lt;/td&gt;
&lt;td&gt;Flowchart&lt;/td&gt;
&lt;td&gt;Steps, decisions, and outcomes — the system as a&amp;nbsp;_process_unfolding in time.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Structure&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;What&amp;nbsp;&lt;em&gt;parts&lt;/em&gt;&amp;nbsp;must exist?&lt;/td&gt;
&lt;td&gt;Functional diagram&lt;/td&gt;
&lt;td&gt;Components and their connections — the system as a&amp;nbsp;&lt;em&gt;thing made of pieces&lt;/em&gt;.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Interaction&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;How do the parts&amp;nbsp;&lt;em&gt;talk&lt;/em&gt;?&lt;/td&gt;
&lt;td&gt;Sequence diagram&lt;/td&gt;
&lt;td&gt;Messages exchanged between components — the system as a&amp;nbsp;&lt;em&gt;conversation&lt;/em&gt;.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Notice that none of these is "more correct" than the others. A flowchart can't tell you what components to build; a functional diagram can't tell you what happens when input is invalid. They are not competing descriptions — they are&amp;nbsp;&lt;strong&gt;complementary&lt;/strong&gt;&amp;nbsp;ones. You need all three because no single one is complete.&lt;/p&gt;

&lt;p&gt;![[Behaviour, Structure, Interaction - 3 Diagram Types for Multi-Dimensional Software Modelling.png]]&lt;br&gt;
Figure 4. Behaviour, Structure, Interaction - 3 Diagram Types for Multi-Dimensional Software Modelling&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;An analogy.&lt;/strong&gt;&amp;nbsp;Think of how you'd describe a car.&amp;nbsp;&lt;em&gt;Behaviour:&lt;/em&gt;&amp;nbsp;"you press the pedal, fuel ignites, the wheels turn."&amp;nbsp;&lt;em&gt;Structure:&lt;/em&gt;&amp;nbsp;"it has an engine, a transmission, four wheels, a fuel tank."&amp;nbsp;&lt;em&gt;Interaction:&lt;/em&gt;&amp;nbsp;"the engine sends torque to the transmission, which sends it to the axle." Each description is true. Each is useless on its own. Together, they let you actually understand — or build — the car.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Why the order is not arbitrary
&lt;/h3&gt;

&lt;p&gt;Here is the part that matters most, and the habit this whole series is built on:&amp;nbsp;&lt;strong&gt;the three lenses form a progression, and the progression has a direction.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fn1m7qzqpxlisdkpqg13l.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fn1m7qzqpxlisdkpqg13l.png" alt="Figure 5. The order is important" width="800" height="133"&gt;&lt;/a&gt;Figure 5. The order is important&lt;/p&gt;

&lt;p&gt;You move&amp;nbsp;&lt;strong&gt;behaviour → structure → interaction&lt;/strong&gt;, and each step is&amp;nbsp;&lt;em&gt;derived from&lt;/em&gt;&amp;nbsp;the one before it. This is the single most important idea in the lesson, so it's worth saying slowly:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Start with behaviour.&lt;/strong&gt;&amp;nbsp;Before you can know what parts a system needs, you must know what it has to&amp;nbsp;&lt;em&gt;do&lt;/em&gt;. Behaviour is the requirement; everything else serves it. So we draw the flowchart first.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Let structure fall out of behaviour.&lt;/strong&gt;&amp;nbsp;Once the flowchart exists, the components almost name themselves. The flowchart has a step&amp;nbsp;&lt;em&gt;"check the message is valid"&lt;/em&gt;&amp;nbsp;— so there must be a part that does validation. It has a step&amp;nbsp;&lt;em&gt;"save the message"&lt;/em&gt;&amp;nbsp;— so there must be a part that handles storage. You don't&amp;nbsp;&lt;em&gt;invent&lt;/em&gt;&amp;nbsp;the structure; you&amp;nbsp;&lt;strong&gt;read it off&lt;/strong&gt;the behaviour. Each thing the app must do implies a part that does it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Detail the interaction inside the structure.&lt;/strong&gt;&amp;nbsp;Now that you know the parts, you can zoom into one slice of the action and show the precise back-and-forth between them — which component sends what message to which other component, and in what order. The sequence diagram lives&amp;nbsp;&lt;em&gt;inside&lt;/em&gt;&amp;nbsp;the structure the previous step produced.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Each diagram is the&amp;nbsp;&lt;em&gt;input&lt;/em&gt;&amp;nbsp;to the next. That's why the order can't be shuffled. Trying to design components before knowing the required behaviour means guessing — and guessed structure is the kind of mistake that's expensive to discover later. Behaviour-first isn't a stylistic preference; it's how you keep every later decision&amp;nbsp;&lt;em&gt;grounded in something real&lt;/em&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  The thread you'll be able to trace
&lt;/h3&gt;

&lt;p&gt;Because each diagram derives from the last, something valuable happens: you can follow a single feature&amp;nbsp;&lt;strong&gt;all the way through&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The use case&amp;nbsp;&lt;em&gt;"Post a message"&lt;/em&gt;&amp;nbsp;will appear three times in this lesson — once as a flow of steps, once as a set of components, once as a conversation between them. And crucially, you'll be able to point at any element in any diagram and trace it back.&amp;nbsp;&lt;em&gt;"This validation step in the flowchart"&lt;/em&gt;&amp;nbsp;becomes&amp;nbsp;&lt;em&gt;"this validator block in the functional diagram"_becomes&amp;nbsp;&lt;/em&gt;"this&amp;nbsp;&lt;code&gt;validate()&lt;/code&gt;&amp;nbsp;message in the sequence diagram."_&amp;nbsp;Same idea, three depths of focus.&lt;/p&gt;

&lt;p&gt;That property has a name —&amp;nbsp;&lt;strong&gt;traceability&lt;/strong&gt;&amp;nbsp;— and it is the quiet engine of good engineering. When you can trace a feature from "what the user wanted" down to "the specific parts that deliver it," nothing falls through the cracks. When you can't, things do.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The takeaway.&lt;/strong&gt;&amp;nbsp;Three diagrams, one progression:&amp;nbsp;&lt;strong&gt;what it does → what it's made of → how the parts interact.&lt;/strong&gt;&amp;nbsp;Each is a different lens on the same&amp;nbsp;&lt;code&gt;Bird&lt;/code&gt;&amp;nbsp;app, and each is derived from the one before. Hold onto that shape — the rest of the lesson simply walks it, one lens at a time.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Closing this lesson's opening half
&lt;/h3&gt;

&lt;p&gt;That completes the&amp;nbsp;&lt;strong&gt;framing half of Lesson 1&lt;/strong&gt;. You now have everything you need before the hands-on diagramming begins:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a vague wish turned into a&amp;nbsp;&lt;strong&gt;bounded project&lt;/strong&gt;&amp;nbsp;— the&amp;nbsp;&lt;code&gt;Bird&lt;/code&gt;&amp;nbsp;app, three use cases, one worked example;&lt;/li&gt;
&lt;li&gt;a clear understanding of&amp;nbsp;&lt;strong&gt;why blueprints matter&lt;/strong&gt;&amp;nbsp;— they are the cheapest place to be wrong;&lt;/li&gt;
&lt;li&gt;the&amp;nbsp;&lt;strong&gt;three-lens mental model&lt;/strong&gt;&amp;nbsp;— behaviour, structure, interaction — and the reason its order is fixed.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;What comes next.&lt;/strong&gt;&amp;nbsp;That brings&amp;nbsp;&lt;strong&gt;Lesson 1&lt;/strong&gt;&amp;nbsp;to a close. You haven't drawn anything yet — and that's exactly right. A diagram drawn before the project is bounded and the lenses are understood is a diagram you'll redraw.&amp;nbsp;&lt;strong&gt;&lt;a href="https://dev.to/eugene-zimin/the-blueprint-beneath-the-blueprint-designing-data-model-and-choosing-its-database-3bhl"&gt;Lesson 2&lt;/a&gt;&lt;/strong&gt;&amp;nbsp;is where the building starts: first we define the&amp;nbsp;&lt;strong&gt;data model&lt;/strong&gt;&amp;nbsp;— the precise structure of the information&amp;nbsp;&lt;code&gt;Bird&lt;/code&gt;&amp;nbsp;must store and pass between its components — and then we return to the three lenses, drawing the flowchart, functional diagram, and sequence diagram for&amp;nbsp;&lt;em&gt;"Post a message"&lt;/em&gt;&amp;nbsp;on top of that data model. The framing you've just finished is what makes those diagrams quick to draw and hard to get wrong.&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>tutorial</category>
      <category>development</category>
      <category>webdev</category>
    </item>
    <item>
      <title>API Authentication: Part III. JWT Tokens</title>
      <dc:creator>Eugene Zimin</dc:creator>
      <pubDate>Sun, 17 May 2026 02:55:46 +0000</pubDate>
      <link>https://dev.to/eugene-zimin/api-authentication-part-iii-jwt-tokens-3c6a</link>
      <guid>https://dev.to/eugene-zimin/api-authentication-part-iii-jwt-tokens-3c6a</guid>
      <description>&lt;h2&gt;
  
  
  Why API Keys Aren't Always Enough
&lt;/h2&gt;

&lt;p&gt;In Part II we saw that an API key is essentially a long, secret password your software shows to a server. It works, but it has a hidden cost: every time the key is used, the server must look it up in a database to find out what the key is allowed to do, whether it has expired, and whether it has been switched off. A&amp;nbsp;&lt;strong&gt;JSON Web Token (JWT)&lt;/strong&gt;&amp;nbsp;removes that lookup by carrying all of that information&amp;nbsp;&lt;em&gt;inside the token itself&lt;/em&gt;. This article explains the problem JWT solves and shows where it sits in the larger story of web authentication.&lt;/p&gt;

&lt;p&gt;Part I covered Basic Authentication — sending a username and password with every request. Part II covered API keys — replacing that reusable password with a single opaque secret string that identifies an application rather than a person.&lt;/p&gt;

&lt;p&gt;Both approaches share a quiet assumption:&amp;nbsp;&lt;strong&gt;the server already knows things about the credential, and it has to go and remember them.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Think of an API key as a numbered coat-check ticket. The ticket itself tells you nothing — it's just a number. To find out what the ticket entitles you to, the attendant has to walk into the back room, find the matching record, and read it. The ticket is meaningless without the back room.&lt;/p&gt;

&lt;p&gt;That "back room" is a database, and consulting it is called a&amp;nbsp;&lt;strong&gt;database roundtrip&lt;/strong&gt;&amp;nbsp;— the server pauses, sends a question to the database, and waits for an answer before it can continue.&lt;/p&gt;

&lt;h3&gt;
  
  
  The hidden cost of a roundtrip
&lt;/h3&gt;

&lt;p&gt;For most applications, one database lookup per request is perfectly fine. But consider what the server actually has to check each time an API key arrives:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Permissions&lt;/strong&gt;&amp;nbsp;— what is this key allowed to do?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Expiry&lt;/strong&gt;&amp;nbsp;— is this key still valid, or has it passed its end date?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Status&lt;/strong&gt;&amp;nbsp;— has someone revoked or suspended this key?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;All three answers live in the database, not in the key. So&amp;nbsp;&lt;em&gt;every single request&lt;/em&gt;&amp;nbsp;triggers a roundtrip before any real work happens.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fhs97n2viujir5cv68ouf.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fhs97n2viujir5cv68ouf.png" alt="Figure 1. API Keys and Database Roundtrips" width="800" height="383"&gt;&lt;/a&gt; Figure 1. API Keys and Database Roundtrips&lt;/p&gt;

&lt;p&gt;This becomes a problem at scale in two specific situations:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;High request volume.&lt;/strong&gt;&amp;nbsp;A popular API might handle thousands of requests per second. Thousands of identity lookups per second is real, measurable load — and load that does nothing for the user except verify who they are.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Distributed systems.&lt;/strong&gt;&amp;nbsp;Modern applications are often split into many small, independent services (commonly called&amp;nbsp;&lt;strong&gt;microservices&lt;/strong&gt;&amp;nbsp;— a single application built as a collection of small, separately running programs that talk to each other). If a request passes through five services, and each one independently re-checks the caller's identity against the database, that's five roundtrips for one user action. The database becomes a bottleneck that every service depends on.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fpu9j54ebe03dhjpg5qzd.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fpu9j54ebe03dhjpg5qzd.png" alt="Figure 2. API Key Validation Scaling Problem" width="800" height="453"&gt;&lt;/a&gt; Figure 2. API Key Validation Scaling Problem&lt;/p&gt;

&lt;p&gt;The deeper issue is&amp;nbsp;&lt;strong&gt;state&lt;/strong&gt;. A server that must consult a database to understand a credential is a&amp;nbsp;&lt;strong&gt;stateful&lt;/strong&gt;&amp;nbsp;server — it cannot make a decision on its own. It always needs the back room.&lt;/p&gt;

&lt;h3&gt;
  
  
  The core idea behind JWT
&lt;/h3&gt;

&lt;p&gt;JWT flips the model. Instead of handing the server a meaningless ticket and forcing it to look up the details, JWT hands the server a ticket that&amp;nbsp;&lt;em&gt;has the details printed on it&lt;/em&gt;&amp;nbsp;— and stamped in a way that proves the printing wasn't forged.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fxpyq7ezp1jhj6s4sxrp6.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fxpyq7ezp1jhj6s4sxrp6.png" alt="Figure 3. JWT and Stateless Validation" width="800" height="456"&gt;&lt;/a&gt; Figure 3. JWT and Stateless Validation&lt;/p&gt;

&lt;p&gt;To stay with the analogy: a JWT is less like a coat-check number and more like a&amp;nbsp;&lt;strong&gt;boarding pass&lt;/strong&gt;. A boarding pass already states your name, your flight, your seat, and your boarding time, right there on the paper. The gate agent doesn't phone headquarters to look you up — they read the pass and check that it's genuine. The information travels&amp;nbsp;&lt;em&gt;with&lt;/em&gt;&amp;nbsp;the traveler.&lt;/p&gt;

&lt;p&gt;This property has a precise name. A server that can validate a token using only the token itself (plus a verification key it already holds) is&amp;nbsp;&lt;strong&gt;stateless&lt;/strong&gt;&amp;nbsp;— it holds no per-request memory and needs no back room. We will unpack exactly&amp;nbsp;&lt;em&gt;how&lt;/em&gt;&amp;nbsp;a JWT proves it hasn't been forged later, when we open up its three-part structure. For now, the one idea to carry forward is the trade:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;An API key keeps the metadata in the database and gives you a pointer to it. A JWT puts the metadata in the token and gives you a way to trust it.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fl7cb5fqfnxqp9br7fu04.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fl7cb5fqfnxqp9br7fu04.png" alt="Figure 4. API Keys vs. JWT comparison" width="799" height="402"&gt;&lt;/a&gt; Figure 4. API Keys vs. JWT comparison&lt;/p&gt;

&lt;p&gt;That trade is powerful, but — as Part III will explore honestly — it is not free. Information printed on a token cannot be un-printed, which creates a genuine difficulty when you need to cancel a token early.&lt;/p&gt;

&lt;h2&gt;
  
  
  A Few Words of History
&lt;/h2&gt;

&lt;p&gt;Every technology arrives at a particular moment for a particular reason. JWT is no exception — it appeared just as the web was shifting away from a model that had quietly dominated for over a decade.&lt;/p&gt;

&lt;h3&gt;
  
  
  The world before: the session cookie
&lt;/h3&gt;

&lt;p&gt;For most of the 2000s, web authentication worked through&amp;nbsp;&lt;strong&gt;server-side sessions&lt;/strong&gt;. When you logged in, the server created a record of your session in its own memory or database and handed your browser a small identifier — a&amp;nbsp;&lt;strong&gt;session cookie&lt;/strong&gt;. On every later request, your browser sent that cookie back, and the server looked it up to remember who you were.&lt;/p&gt;

&lt;p&gt;This is the same coat-check pattern from the previous session above: the cookie is a meaningless number, and the server holds all the real information. It worked well when a website was a single server. But it tied every logged-in user to a specific machine's memory. Spread your traffic across many servers, and you hit a problem — a user logged in on &lt;strong&gt;Server A&lt;/strong&gt; is a stranger to &lt;strong&gt;Server B&lt;/strong&gt;, because &lt;strong&gt;Server B&lt;/strong&gt; never created that session record.&lt;/p&gt;

&lt;h3&gt;
  
  
  The pressure that created JWT
&lt;/h3&gt;

&lt;p&gt;Two trends in the early 2010s made server-side sessions increasingly awkward.&lt;/p&gt;

&lt;p&gt;First, applications stopped being single servers. They were spread across fleets of machines, and increasingly split into&amp;nbsp;&lt;strong&gt;microservices&lt;/strong&gt;&amp;nbsp;— many small programs, each handling one job. A shared, central session store became a bottleneck that every service had to consult.&lt;/p&gt;

&lt;p&gt;Second, the&amp;nbsp;&lt;em&gt;clients&lt;/em&gt;&amp;nbsp;changed. The web was no longer just browsers loading full pages. It was single-page applications (SPAs) — websites that load once and then behave like desktop apps — and native mobile apps, often talking to APIs hosted on entirely different domains. Cookies, which are tightly bound to a single domain by design, fit this new world poorly.&lt;/p&gt;

&lt;p&gt;The industry needed a credential that any server could verify on its own, without a shared session store, and that wasn't anchored to one domain. The answer was to make the token&amp;nbsp;&lt;em&gt;carry its own proof of identity&lt;/em&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  RFC 7519: the standard
&lt;/h3&gt;

&lt;p&gt;The work happened inside the&amp;nbsp;&lt;strong&gt;IETF&lt;/strong&gt;&amp;nbsp;(the Internet Engineering Task Force, the body that standardizes the protocols underlying the internet) as part of its OAuth working group — the same group designing the next generation of authorization standards. They needed a compact, secure token format to pass identity information around, and JWT was built to fill that gap.&lt;/p&gt;

&lt;p&gt;The result was published in&amp;nbsp;&lt;strong&gt;May 2015 as &lt;a href="https://datatracker.ietf.org/doc/html/rfc7519" rel="noopener noreferrer"&gt;RFC 7519&lt;/a&gt;&lt;/strong&gt;&amp;nbsp;— the formal specification that defines what a JSON Web Token is. An&amp;nbsp;&lt;strong&gt;RFC&lt;/strong&gt;&amp;nbsp;("Request for Comments") is the document format the IETF uses for internet standards; despite the tentative-sounding name, a published RFC is the authoritative definition.&lt;/p&gt;

&lt;p&gt;A point worth noting: JWT was never a lone invention. It is the centerpiece of a small family of related standards — collectively called&amp;nbsp;&lt;strong&gt;JOSE&lt;/strong&gt;&amp;nbsp;(JavaScript Object Signing and Encryption) — that also define how to sign tokens (JWS), encrypt them (JWE), and represent cryptographic keys (JWK). &lt;/p&gt;

&lt;p&gt;For most practical purposes, though, "JWT" is the term everyone uses, and it's the one we'll use here.&lt;/p&gt;

&lt;h3&gt;
  
  
  From standard to ubiquity
&lt;/h3&gt;

&lt;p&gt;A specification only matters if people adopt it, and JWT's adoption was unusually fast. The identity company&amp;nbsp;&lt;strong&gt;&lt;a href="https://auth0.com" rel="noopener noreferrer"&gt;Auth0&lt;/a&gt;&lt;/strong&gt;, founded in 2013, built much of its product and developer education around JWT and promoted the format heavily — including&amp;nbsp;&lt;code&gt;jwt.io&lt;/code&gt;, a free online debugger that let developers paste in a token and see its decoded contents instantly. For a great many engineers, that tool was their first hands-on encounter with the format.&lt;/p&gt;

&lt;p&gt;From there, JWT spread through the ecosystem. It became the default token format for&amp;nbsp;&lt;strong&gt;&lt;a href="https://openid.net" rel="noopener noreferrer"&gt;OpenID Connect&lt;/a&gt;&lt;/strong&gt;&amp;nbsp;— the identity layer built on top of &lt;a href="https://datatracker.ietf.org/doc/html/rfc6749" rel="noopener noreferrer"&gt;OAuth 2.0&lt;/a&gt;, and the subject we'll reach later in this series. Cloud platforms adopted it for service-to-service authentication. Web frameworks in nearly every programming language shipped libraries to create and verify it. Within a few years of RFC 7519, JWT had moved from a new proposal to a default assumption.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why the history matters
&lt;/h3&gt;

&lt;p&gt;This background isn't trivia — it explains the&amp;nbsp;&lt;em&gt;shape&lt;/em&gt;&amp;nbsp;of the technology you're about to take apart. JWT looks the way it does because it was designed for a specific world: distributed systems with no shared memory, and clients scattered across domains and devices.&lt;/p&gt;

&lt;p&gt;That origin also explains its central trade-off. JWT was built to let a server make a decision&amp;nbsp;&lt;em&gt;on its own&lt;/em&gt;, without calling back to a central store. That independence is exactly the feature that makes JWT fast and scalable — and, as we'll see, exactly the feature that makes a token hard to cancel once it's been issued. The strength and the weakness are the same design decision, viewed from two sides.&lt;/p&gt;

&lt;p&gt;With that context in place, we can now open up an actual token and see what those three dot-separated parts really contain.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Anatomy of a JWT
&lt;/h2&gt;

&lt;p&gt;This is the heart of subject. Once you can see what a JWT actually&amp;nbsp;&lt;strong&gt;&lt;em&gt;is&lt;/em&gt;&lt;/strong&gt;, every later topic — claims, signatures, validation, security — becomes far easier to follow. So we'll take a real token apart, piece by piece.&lt;/p&gt;

&lt;h3&gt;
  
  
  Three parts, two dots
&lt;/h3&gt;

&lt;p&gt;A JWT, when written out, looks like a long, slightly intimidating string of gibberish:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;eyJhbGciOiJIUzI&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="err"&gt;NiIsInR&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="err"&gt;cCI&lt;/span&gt;&lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="err"&gt;IkpXVCJ&lt;/span&gt;&lt;span class="mi"&gt;9&lt;/span&gt;&lt;span class="err"&gt;.eyJzdWIiOiIxMjM&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="err"&gt;NSIsIm&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="err"&gt;hbWUiOiJBZGEifQ.dBjftJeZ&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="err"&gt;CVP-mB&lt;/span&gt;&lt;span class="mi"&gt;92&lt;/span&gt;&lt;span class="err"&gt;K&lt;/span&gt;&lt;span class="mi"&gt;27&lt;/span&gt;&lt;span class="err"&gt;uhbUJU&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="err"&gt;p&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="err"&gt;r_wW&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="err"&gt;gFWFOEjXk&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It looks random. It isn't. Look closely and you'll spot&amp;nbsp;&lt;strong&gt;two dots&lt;/strong&gt;&amp;nbsp;dividing it into&amp;nbsp;&lt;strong&gt;three parts&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;header  .  payload  .  signature
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That structure never changes. Every JWT, everywhere, is exactly three parts separated by dots:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The&amp;nbsp;&lt;strong&gt;header&lt;/strong&gt;&amp;nbsp;— describes the token itself.&lt;/li&gt;
&lt;li&gt;The&amp;nbsp;&lt;strong&gt;payload&lt;/strong&gt;&amp;nbsp;— carries the actual information.&lt;/li&gt;
&lt;li&gt;The&amp;nbsp;&lt;strong&gt;signature&lt;/strong&gt;&amp;nbsp;— proves the first two parts haven't been tampered with.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The boarding-pass analogy from the first section holds up neatly here. The header is the small print at the top that says what kind of document this is. The payload is the part you actually care about — your name, your flight, your seat. The signature is the official stamp that proves the pass is genuine and not something printed at home.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why it looks like gibberish: Base64URL
&lt;/h3&gt;

&lt;p&gt;The reason a JWT looks unreadable is not encryption. It is&amp;nbsp;&lt;strong&gt;encoding&lt;/strong&gt;&amp;nbsp;— and the distinction matters.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Encryption&lt;/strong&gt;&amp;nbsp;scrambles data so that&amp;nbsp;&lt;em&gt;no one&lt;/em&gt;&amp;nbsp;can read it without a secret key.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Encoding&lt;/strong&gt;&amp;nbsp;simply rewrites data into a different alphabet so it can travel safely. Anyone can reverse it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A JWT's header and payload are merely&amp;nbsp;&lt;strong&gt;encoded&lt;/strong&gt;, not encrypted. They use a scheme called&amp;nbsp;&lt;strong&gt;Base64URL&lt;/strong&gt;&amp;nbsp;— a way of representing data using only letters, digits, and a couple of symbols that are safe to put inside a web address (a URL).&lt;/p&gt;

&lt;p&gt;This leads to the single most important — and most misunderstood — fact about JWTs:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The contents of a JWT are not secret.&lt;/strong&gt;&amp;nbsp;Anyone who holds the token can decode the header and payload and read everything inside. The signature stops people from&amp;nbsp;&lt;em&gt;changing&lt;/em&gt;&amp;nbsp;a token; it does nothing to&amp;nbsp;&lt;em&gt;hide&lt;/em&gt;&amp;nbsp;it.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F1zuqexgjjztg0wdepb4t.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F1zuqexgjjztg0wdepb4t.png" alt="Figure 5. JWT Decoding vs. Hiding Secrets" width="800" height="457"&gt;&lt;/a&gt; Figure 5. JWT Decoding vs. Hiding Secrets&lt;/p&gt;

&lt;p&gt;We'll return to the security consequences of this later. For now, just hold onto it: a JWT protects against forgery, not against reading. Never put a password, a credit-card number, or any other secret inside it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Part 1: The header
&lt;/h3&gt;

&lt;p&gt;Take the first segment of our example token and reverse the Base64URL encoding, and you get a small block of&amp;nbsp;&lt;strong&gt;JSON&lt;/strong&gt;— JavaScript Object Notation, the simple, human-readable&amp;nbsp;&lt;code&gt;key: value&lt;/code&gt;&amp;nbsp;format used everywhere on the modern web:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"alg"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"HS256"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"typ"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"JWT"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The header is metadata&amp;nbsp;&lt;em&gt;about the token&lt;/em&gt;. It answers two questions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;alg&lt;/code&gt;&lt;/strong&gt;&amp;nbsp;("algorithm") — how the signature was created. Here,&amp;nbsp;&lt;code&gt;HS256&lt;/code&gt;. We'll examine the algorithm choices in detail in part of the article; for now, treat it as a label naming the method used to make the stamp.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;typ&lt;/code&gt;&lt;/strong&gt;&amp;nbsp;("type") — what kind of object this is. For a JWT, this is simply&amp;nbsp;&lt;code&gt;"JWT"&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The header is short, and it's mostly there so the receiving server knows&amp;nbsp;&lt;em&gt;how&lt;/em&gt;&amp;nbsp;to check the signature later.&lt;/p&gt;

&lt;h3&gt;
  
  
  Part 2: The payload
&lt;/h3&gt;

&lt;p&gt;Decode the second segment and you get another block of JSON — and this is the part that does the real work:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"sub"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"12345"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Ada"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The payload carries the&amp;nbsp;&lt;strong&gt;claims&lt;/strong&gt;&amp;nbsp;— the individual statements the token is making. The word is well chosen: each entry is a&amp;nbsp;&lt;em&gt;claim&lt;/em&gt;, an assertion that "this is true." Here the token claims two things: the subject (&lt;code&gt;sub&lt;/code&gt;) of this token is user&amp;nbsp;&lt;code&gt;12345&lt;/code&gt;, and that user's name is&amp;nbsp;&lt;code&gt;Ada&lt;/code&gt;. The payload is where identity, permissions, expiry times, and your own custom data live. &lt;/p&gt;

&lt;h3&gt;
  
  
  Part 3: The signature
&lt;/h3&gt;

&lt;p&gt;The third segment is the only part that is genuinely cryptographic — and it's the part that makes a JWT trustworthy.&lt;/p&gt;

&lt;p&gt;Here is the problem the signature solves. We just established that anyone holding a token can read the payload. But could they also&amp;nbsp;&lt;em&gt;change&lt;/em&gt;&amp;nbsp;it — swap&amp;nbsp;&lt;code&gt;"sub": "12345"&lt;/code&gt;&amp;nbsp;for&amp;nbsp;&lt;code&gt;"sub": "99999"&lt;/code&gt;&amp;nbsp;and impersonate another user? The answer is &lt;strong&gt;"YES"&lt;/strong&gt;. Does it stay hidden? And the most important answer is here - &lt;strong&gt;"NO"&lt;/strong&gt;, as the signature is what makes that attack fail because it becomes easily recognizable and server knows how to react on it.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fhymigr4td0c8azd061vq.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fhymigr4td0c8azd061vq.png" alt="Figure 6. JWT Forgery Protection - The Signature" width="800" height="457"&gt;&lt;/a&gt;Figure 6. JWT Forgery Protection - The Signature&lt;/p&gt;

&lt;p&gt;When the server first creates the token, it performs a calculation. It takes the encoded header and the encoded payload, joins them with a dot, and feeds that — together with a&amp;nbsp;&lt;strong&gt;secret key that only the server knows&lt;/strong&gt;&amp;nbsp;— into a one-way mathematical function. For our&amp;nbsp;&lt;code&gt;HS256&lt;/code&gt;&amp;nbsp;example, that function is&amp;nbsp;&lt;strong&gt;HMAC-SHA256&lt;/strong&gt;. The output is the signature:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;signature = HMAC-SHA256(
    base64url(header) + "." + base64url(payload),
    secret
)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two properties of this function are what make the whole scheme work:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;It depends on every input.&lt;/strong&gt;&amp;nbsp;Change a single character of the header or payload, and the output is completely different.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It can't be reversed or faked without the secret.&lt;/strong&gt;&amp;nbsp;Knowing the inputs but not the secret, there is no practical way to compute the correct signature.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;So when an attacker edits the payload to say&amp;nbsp;&lt;code&gt;"sub": "99999"&lt;/code&gt;, the signature attached to the token no longer matches the modified content. When the server recomputes the signature from the tampered payload and compares it to the one in the token, the two don't match — and the token is rejected. Because the attacker doesn't have the secret key, they can't produce a fresh, valid signature for their forged payload either.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fnbcx81judko6uvdi8xjy.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fnbcx81judko6uvdi8xjy.png" alt="Figure 7. JWT Signature- The Key to Integrity and Honesty" width="" height=""&gt;&lt;/a&gt;Figure 7. JWT Signature- The Key to Integrity and Honesty&lt;/p&gt;

&lt;p&gt;And this is the core idea worth carrying out of this section:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The signature doesn't keep a JWT&amp;nbsp;&lt;em&gt;private&lt;/em&gt;. It keeps a JWT&amp;nbsp;&lt;em&gt;honest&lt;/em&gt;. It guarantees that the header and payload are exactly what the issuing server wrote, untouched since.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Building one by hand
&lt;/h3&gt;

&lt;p&gt;The best way to dispel the sense that this is magic is to build a token with no library at all — just the standard tools any programming language provides. Here it is in Python:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;base64&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;hmac&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;hashlib&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;base64url_encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;# Standard Base64, then made URL-safe: trailing '=' padding removed.
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;base64&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;urlsafe_b64encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;rstrip&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;b&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;create_jwt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;secret&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;# 1. The header: declare the algorithm and type.
&lt;/span&gt;    &lt;span class="n"&gt;header&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;alg&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;HS256&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;typ&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;JWT&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;# 2. Encode the header and payload as Base64URL.
&lt;/span&gt;    &lt;span class="n"&gt;h&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;base64url_encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;header&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;base64url_encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;

    &lt;span class="c1"&gt;# 3. Sign "header.payload" with the secret using HMAC-SHA256.
&lt;/span&gt;    &lt;span class="n"&gt;sig&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;hmac&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;secret&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;.&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;hashlib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sha256&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="c1"&gt;# 4. Join all three parts with dots.
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;.&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;.&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;base64url_encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sig&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;


&lt;span class="n"&gt;token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;create_jwt&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sub&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;12345&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Ada&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="n"&gt;secret&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;my-secret-key&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the&amp;nbsp;&lt;em&gt;entire&lt;/em&gt;&amp;nbsp;mechanism. There is nothing hidden. A JWT is two pieces of JSON, encoded for safe transport, plus a signature computed from them. The four numbered steps in the code are the whole story.&lt;/p&gt;

&lt;h3&gt;
  
  
  The production path: use a library
&lt;/h3&gt;

&lt;p&gt;Building a token by hand is the right way to&amp;nbsp;&lt;em&gt;understand&lt;/em&gt;&amp;nbsp;JWTs. It is the wrong way to&amp;nbsp;&lt;em&gt;use&lt;/em&gt;&amp;nbsp;them in real software.&lt;/p&gt;

&lt;p&gt;In production, always reach for a well-maintained, audited library —&amp;nbsp;&lt;code&gt;PyJWT&lt;/code&gt;&amp;nbsp;or&amp;nbsp;&lt;code&gt;python-jose&lt;/code&gt;&amp;nbsp;in Python, and direct equivalents in every other major language. The same task becomes a single call:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;jwt&lt;/span&gt;  &lt;span class="c1"&gt;# the PyJWT library
&lt;/span&gt;
&lt;span class="n"&gt;token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;jwt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sub&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;12345&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Ada&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;my-secret-key&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;algorithm&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;HS256&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This isn't laziness — it's safety. The hand-written version above is correct for&amp;nbsp;&lt;em&gt;creating&lt;/em&gt;&amp;nbsp;tokens, but&amp;nbsp;&lt;em&gt;verifying&lt;/em&gt;&amp;nbsp;them safely is full of subtle traps. A naive verifier can be tricked into accepting forged tokens, expired tokens, or tokens it should never have trusted. Mature libraries have already absorbed those lessons; your own code, written from scratch, has not.&lt;/p&gt;

&lt;p&gt;So build one by hand once, to see that the magic is just arithmetic. Then never do it again in production.&lt;/p&gt;

&lt;p&gt;With the structure of a token clear, we can turn to its most important part in detail — the payload, and the claims it carries.&lt;/p&gt;

&lt;h2&gt;
  
  
  Claims: What a Token Actually Says
&lt;/h2&gt;

&lt;p&gt;In previous section we opened up a JWT and found the payload — the middle segment carrying the token's real content. Each piece of information in that payload is called a&amp;nbsp;&lt;strong&gt;claim&lt;/strong&gt;. This section is about what those claims are, which ones the standard defines for you, and which ones you create yourself.&lt;/p&gt;

&lt;h3&gt;
  
  
  A claim is just a statement
&lt;/h3&gt;

&lt;p&gt;The terminology sounds formal, but the idea is plain. A&amp;nbsp;&lt;strong&gt;claim&lt;/strong&gt;&amp;nbsp;is a single statement the token makes about its subject — one&amp;nbsp;&lt;code&gt;key: value&lt;/code&gt;&amp;nbsp;pair in the payload's JSON. The token "claims" these things are true, and the signature is what makes that claim trustworthy.&lt;/p&gt;

&lt;p&gt;A payload is simply a collection of these statements:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"iss"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"auth.myapp.com"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"sub"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"12345"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"aud"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"api.myapp.com"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"iat"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1716120000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"exp"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1716123600&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"role"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"editor"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"tenant_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"acme-corp"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Some of those keys —&amp;nbsp;&lt;code&gt;iss&lt;/code&gt;,&amp;nbsp;&lt;code&gt;sub&lt;/code&gt;,&amp;nbsp;&lt;code&gt;aud&lt;/code&gt;,&amp;nbsp;&lt;code&gt;iat&lt;/code&gt;,&amp;nbsp;&lt;code&gt;exp&lt;/code&gt;&amp;nbsp;— are part of the JWT standard and mean the same thing everywhere. Others —&amp;nbsp;&lt;code&gt;role&lt;/code&gt;,&amp;nbsp;&lt;code&gt;tenant_id&lt;/code&gt;&amp;nbsp;— are invented by the application. That split is the central idea of this section.&lt;/p&gt;

&lt;h3&gt;
  
  
  Registered claims: the standard vocabulary
&lt;/h3&gt;

&lt;p&gt;RFC 7519 defines a small set of&amp;nbsp;&lt;strong&gt;registered claims&lt;/strong&gt;. These are not required — a JWT is free to omit any of them — but if you&amp;nbsp;&lt;em&gt;do&lt;/em&gt;&amp;nbsp;use them, you must use them with the meaning the standard assigns. They have short, three-letter names to keep tokens compact.&lt;/p&gt;

&lt;p&gt;There are seven worth knowing:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Claim&lt;/th&gt;
&lt;th&gt;Name&lt;/th&gt;
&lt;th&gt;What it means&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;iss&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Issuer&lt;/td&gt;
&lt;td&gt;Who created and signed this token.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;sub&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Subject&lt;/td&gt;
&lt;td&gt;Who or what the token is about — typically the user ID.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;aud&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Audience&lt;/td&gt;
&lt;td&gt;Who the token is&amp;nbsp;&lt;em&gt;intended for&lt;/em&gt;&amp;nbsp;— which service should accept it.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;exp&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Expiration Time&lt;/td&gt;
&lt;td&gt;The moment after which the token must be rejected.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;nbf&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Not Before&lt;/td&gt;
&lt;td&gt;The moment before which the token is not yet valid.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;iat&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Issued At&lt;/td&gt;
&lt;td&gt;The moment the token was created.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;jti&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;JWT ID&lt;/td&gt;
&lt;td&gt;A unique identifier for this specific token.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A few of these deserve a closer look, because they do more than they first appear to.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;iss&lt;/code&gt;&amp;nbsp;(Issuer)&lt;/strong&gt;&amp;nbsp;identifies the authority that minted the token — usually an authentication server like&amp;nbsp;&lt;code&gt;auth.myapp.com&lt;/code&gt;. A receiving service checks this so it only trusts tokens from a source it recognizes, and ignores tokens minted by anyone else.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;aud&lt;/code&gt;&amp;nbsp;(Audience)&lt;/strong&gt;&amp;nbsp;is the one most often misunderstood, and it matters for security. It names the intended&amp;nbsp;&lt;em&gt;recipient&lt;/em&gt;. If your authentication server issues tokens for several different services, the&amp;nbsp;&lt;code&gt;aud&lt;/code&gt;&amp;nbsp;claim lets each service confirm "this token was meant for&amp;nbsp;&lt;em&gt;me&lt;/em&gt;." A token minted for the billing service should be refused by the email service, even though both trust the same issuer. Skipping this check is a real vulnerability.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;jti&lt;/code&gt;&amp;nbsp;(JWT ID)&lt;/strong&gt;&amp;nbsp;is a unique serial number for one individual token. On its own it does little. Its importance shows up later: when you need to&amp;nbsp;&lt;em&gt;cancel&lt;/em&gt;&amp;nbsp;a specific token before it expires, the&amp;nbsp;&lt;code&gt;jti&lt;/code&gt;&amp;nbsp;is the handle you use to do it. That's the revocation problem, and&amp;nbsp;&lt;code&gt;jti&lt;/code&gt;&amp;nbsp;is the thread that connects to it.&lt;/p&gt;

&lt;h3&gt;
  
  
  The temporal claims:&amp;nbsp;&lt;code&gt;iat&lt;/code&gt;,&amp;nbsp;&lt;code&gt;nbf&lt;/code&gt;, and&amp;nbsp;&lt;code&gt;exp&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;Three of the registered claims —&amp;nbsp;&lt;code&gt;iat&lt;/code&gt;,&amp;nbsp;&lt;code&gt;nbf&lt;/code&gt;, and&amp;nbsp;&lt;code&gt;exp&lt;/code&gt;&amp;nbsp;— are about&amp;nbsp;&lt;em&gt;time&lt;/em&gt;, and together they give a token a lifespan. They are the mechanism behind one of JWT's most important properties: a token that automatically stops working.&lt;/p&gt;

&lt;p&gt;Laid out on a timeline, they mark out a window of validity:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;   iat            nbf              now                      exp
    │              │                │                        │
    ●──────────────●════════════════●════════════════════════●─────────►  time
  issued       valid from      this moment              expires after
                 │◄───────── token is VALID here ───────────►│
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Figure 8. Timeline for JWT claims&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;iat&lt;/code&gt;&amp;nbsp;(Issued At)&lt;/strong&gt;&amp;nbsp;is the token's birth timestamp — when it was created.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;nbf&lt;/code&gt;&amp;nbsp;(Not Before)&lt;/strong&gt;&amp;nbsp;is the moment the token&amp;nbsp;&lt;em&gt;starts&lt;/em&gt;&amp;nbsp;being valid. Usually this is the same as&amp;nbsp;&lt;code&gt;iat&lt;/code&gt;, but it can be set in the future to issue a token now that only "switches on" later.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;exp&lt;/code&gt;&amp;nbsp;(Expiration Time)&lt;/strong&gt;&amp;nbsp;is the moment the token&amp;nbsp;&lt;em&gt;stops&lt;/em&gt;&amp;nbsp;being valid. After this instant, every correct server must reject it, no matter what else the token says.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The&amp;nbsp;&lt;code&gt;exp&lt;/code&gt;&amp;nbsp;claim is what gives a JWT its self-limiting nature. An API key, by contrast, tends to live until a human remembers to revoke it. A JWT carries its own deadline. Set&amp;nbsp;&lt;code&gt;exp&lt;/code&gt;&amp;nbsp;to 15 minutes after&amp;nbsp;&lt;code&gt;iat&lt;/code&gt;, and the token simply expires 15 minutes later — no database, no cleanup job, no human action. This is central to managing the risk of a stolen token.&lt;/p&gt;

&lt;p&gt;One technical detail: these timestamps are stored as&amp;nbsp;&lt;strong&gt;Unix time&lt;/strong&gt;&amp;nbsp;— the number of seconds since midnight UTC on January 1, 1970. That's why&amp;nbsp;&lt;code&gt;exp&lt;/code&gt;&amp;nbsp;appears as a large integer like&amp;nbsp;&lt;code&gt;1716123600&lt;/code&gt;&amp;nbsp;rather than a human-readable date. It's a universal, timezone-free way to pin down an exact instant, and every JWT library converts it for you.&lt;/p&gt;

&lt;h3&gt;
  
  
  Custom claims: your own data
&lt;/h3&gt;

&lt;p&gt;The registered claims cover identity and timing. They say nothing about what a user is&amp;nbsp;&lt;em&gt;allowed to do&lt;/em&gt;, because that is specific to your application. For everything else, you add&amp;nbsp;&lt;strong&gt;custom claims&lt;/strong&gt;&amp;nbsp;—&amp;nbsp;&lt;code&gt;key: value&lt;/code&gt;&amp;nbsp;pairs you define yourself, with whatever names and meanings you choose.&lt;/p&gt;

&lt;p&gt;Custom claims are where JWT delivers on the promise from the section above — moving metadata&amp;nbsp;&lt;em&gt;into the token&lt;/em&gt;&amp;nbsp;so the server doesn't need a database lookup. Typical examples:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;role&lt;/code&gt;&lt;/strong&gt;&amp;nbsp;or&amp;nbsp;&lt;strong&gt;&lt;code&gt;permissions&lt;/code&gt;&lt;/strong&gt;&amp;nbsp;— what the user is authorized to do (&lt;code&gt;"editor"&lt;/code&gt;,&amp;nbsp;&lt;code&gt;"admin"&lt;/code&gt;, or a list like&amp;nbsp;&lt;code&gt;["read:invoices", "write:invoices"]&lt;/code&gt;). The receiving service reads this straight from the token and decides what to allow — no roundtrip required.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;tenant_id&lt;/code&gt;&lt;/strong&gt;&amp;nbsp;— in applications that serve many separate organizations from one system, this identifies which organization the user belongs to.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;email&lt;/code&gt;&lt;/strong&gt;,&amp;nbsp;&lt;strong&gt;&lt;code&gt;name&lt;/code&gt;&lt;/strong&gt;&amp;nbsp;— basic profile fields, included to save a lookup when displaying the user's identity.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is exactly the design that makes a server&amp;nbsp;&lt;strong&gt;stateless&lt;/strong&gt;: the answer to "who is this, and what may they do?" travels inside the request itself.&lt;/p&gt;

&lt;p&gt;Two cautions, both flowing directly from facts already established.&lt;/p&gt;

&lt;p&gt;First — and this bears repeating because it is the most common JWT mistake —&amp;nbsp;&lt;strong&gt;the payload is not secret.&lt;/strong&gt;&amp;nbsp;Anyone holding the token can decode and read it. So custom claims may contain a user's role; they must never contain anything sensitive — no passwords, no API secrets, no private personal data.&lt;/p&gt;

&lt;p&gt;Second,&amp;nbsp;&lt;strong&gt;keep the payload small.&lt;/strong&gt;&amp;nbsp;A JWT travels with&amp;nbsp;&lt;em&gt;every single request&lt;/em&gt;&amp;nbsp;to your API. Every claim you add makes every request slightly larger. Stick to what the receiving service genuinely needs to make a decision; resist the temptation to pack a user's entire profile into the token.&lt;/p&gt;

&lt;h3&gt;
  
  
  Avoiding name collisions
&lt;/h3&gt;

&lt;p&gt;If your application invents a custom claim called&amp;nbsp;&lt;code&gt;role&lt;/code&gt;, and some other system your token passes through also uses&amp;nbsp;&lt;code&gt;role&lt;/code&gt;&amp;nbsp;to mean something different, the two meanings collide. The JWT standard's recommended fix is to&amp;nbsp;&lt;strong&gt;namespace&lt;/strong&gt;&amp;nbsp;custom claims — prefix them with a URL you control, so the name is globally unique:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"sub"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"12345"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"https://myapp.com/role"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"editor"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The URL doesn't have to point anywhere real; it's used purely as a unique prefix. For a closed system where you control every service, plain names like&amp;nbsp;&lt;code&gt;role&lt;/code&gt;&amp;nbsp;are common and perfectly workable. For tokens that travel between organizations, namespacing prevents a class of subtle, hard-to-diagnose bugs.&lt;/p&gt;

&lt;h3&gt;
  
  
  The payload, in summary
&lt;/h3&gt;

&lt;p&gt;A JWT's payload is a set of claims — statements the token asserts and the signature makes trustworthy. Some claims are&amp;nbsp;&lt;strong&gt;registered&lt;/strong&gt;&amp;nbsp;by the standard:&amp;nbsp;&lt;code&gt;iss&lt;/code&gt;,&amp;nbsp;&lt;code&gt;sub&lt;/code&gt;, and&amp;nbsp;&lt;code&gt;aud&lt;/code&gt;&amp;nbsp;answer&amp;nbsp;&lt;em&gt;who&lt;/em&gt;;&amp;nbsp;&lt;code&gt;iat&lt;/code&gt;,&amp;nbsp;&lt;code&gt;nbf&lt;/code&gt;, and&amp;nbsp;&lt;code&gt;exp&lt;/code&gt;&amp;nbsp;answer&amp;nbsp;&lt;em&gt;when&lt;/em&gt;;&amp;nbsp;&lt;code&gt;jti&lt;/code&gt;&amp;nbsp;gives the token a unique name. The rest are&amp;nbsp;&lt;strong&gt;custom claims&lt;/strong&gt;&amp;nbsp;you define, and they are how identity and permissions ride along inside the request, sparing the server a database lookup.&lt;/p&gt;

&lt;p&gt;We now know what a token&amp;nbsp;&lt;em&gt;says&lt;/em&gt;. The next question is&amp;nbsp;&lt;em&gt;how&lt;/em&gt;&amp;nbsp;it proves it — which means looking closely at the signature, and the different algorithms used to create it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Signature Algorithms: HS256, RS256, and ES256
&lt;/h2&gt;

&lt;p&gt;Above we established what the signature&amp;nbsp;&lt;em&gt;does&lt;/em&gt;: it makes a token honest, guaranteeing the header and payload haven't been altered since the issuer wrote them. We used&amp;nbsp;&lt;code&gt;HS256&lt;/code&gt;&amp;nbsp;as the example and treated it as a black box. Now we open the box — because the choice of signing algorithm is one of the most consequential decisions you'll make when building a real system, and it turns entirely on a single question:&amp;nbsp;&lt;strong&gt;who is allowed to verify the token?&lt;/strong&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Two families: symmetric and asymmetric
&lt;/h3&gt;

&lt;p&gt;Every JWT signing algorithm belongs to one of two families. The difference between them is the difference between a shared password and a wax seal.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fyjjy1odpu2fbjc4kzx7x.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fyjjy1odpu2fbjc4kzx7x.png" alt="Figure 9. JWT Signing - Symmetric vs. Asymmetric" width="800" height="454"&gt;&lt;/a&gt; Figure 9. JWT Signing - Symmetric vs. Asymmetric&lt;/p&gt;

&lt;p&gt;A&amp;nbsp;&lt;strong&gt;symmetric&lt;/strong&gt;&amp;nbsp;algorithm uses&amp;nbsp;&lt;strong&gt;one secret key for both jobs&lt;/strong&gt;&amp;nbsp;— creating the signature and checking it. The same key signs and verifies. It's like a password shared between two parties: whoever knows it can both lock and unlock.&lt;/p&gt;

&lt;p&gt;An&amp;nbsp;&lt;strong&gt;asymmetric&lt;/strong&gt;&amp;nbsp;algorithm uses&amp;nbsp;&lt;strong&gt;a pair of mathematically linked keys&lt;/strong&gt;: a&amp;nbsp;&lt;strong&gt;private key&lt;/strong&gt;&amp;nbsp;and a&amp;nbsp;&lt;strong&gt;public key&lt;/strong&gt;. The private key creates signatures and is guarded closely. The public key only&amp;nbsp;&lt;em&gt;verifies&lt;/em&gt;&amp;nbsp;signatures and can be shared freely — handed to anyone, posted publicly — without weakening anything. It's like a wax seal: the issuer owns the unique stamp (the private key), but anyone with a picture of the seal (the public key) can recognize a genuine one. Holding the public key lets you&amp;nbsp;&lt;em&gt;check&lt;/em&gt;&amp;nbsp;a seal but never&amp;nbsp;&lt;em&gt;forge&lt;/em&gt;&amp;nbsp;one.&lt;/p&gt;

&lt;p&gt;That single distinction — one shared key versus a public/private pair — drives everything that follows.&lt;/p&gt;

&lt;h3&gt;
  
  
  HS256: the symmetric option
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;HS256&lt;/strong&gt;&amp;nbsp;stands for&amp;nbsp;&lt;strong&gt;HMAC using SHA-256&lt;/strong&gt;. It is the symmetric choice, and it's the algorithm in every example so far.&lt;/p&gt;

&lt;p&gt;With HS256, one secret string both signs and verifies tokens. The server that issues a token uses the secret to compute the signature; the server that receives a token uses&amp;nbsp;&lt;em&gt;the same secret&lt;/em&gt;&amp;nbsp;to recompute the signature and compare. If they match, the token is genuine.&lt;/p&gt;

&lt;p&gt;This is simple, fast, and produces compact signatures. But it carries one unavoidable consequence:&amp;nbsp;&lt;strong&gt;every party that needs to verify a token must hold the secret key.&lt;/strong&gt;&amp;nbsp;And here is the catch — the verifying key and the signing key are the&amp;nbsp;&lt;em&gt;same key&lt;/em&gt;. Any service that can&amp;nbsp;&lt;em&gt;check&lt;/em&gt;&amp;nbsp;a token can therefore also&amp;nbsp;&lt;em&gt;mint&lt;/em&gt;&amp;nbsp;a brand-new token that everyone else will trust.&lt;/p&gt;

&lt;p&gt;In a single, self-contained application, that's fine. The same server issues tokens and checks them. There's one secret, in one place, and no one else needs it.&lt;/p&gt;

&lt;p&gt;The trouble begins when the system grows.&lt;/p&gt;

&lt;h3&gt;
  
  
  RS256: the asymmetric option
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;RS256&lt;/strong&gt;&amp;nbsp;stands for&amp;nbsp;&lt;strong&gt;RSA Signature using SHA-256&lt;/strong&gt;. RSA is a long-established public-key cryptosystem; what matters here is that RS256 is&amp;nbsp;&lt;strong&gt;asymmetric&lt;/strong&gt;&amp;nbsp;— it uses the private/public key pair.&lt;/p&gt;

&lt;p&gt;With RS256, the authentication server holds a&amp;nbsp;&lt;strong&gt;private key&lt;/strong&gt;&amp;nbsp;and uses it — and only it — to sign tokens. It then publishes the matching&amp;nbsp;&lt;strong&gt;public key&lt;/strong&gt;&amp;nbsp;for the world to see. Any other service can take that public key and verify a token's signature. But the public key&amp;nbsp;&lt;em&gt;cannot sign anything&lt;/em&gt;. A service holding only the public key can confirm a token is genuine, yet has no power to forge one.&lt;/p&gt;

&lt;p&gt;That asymmetry solves the exact problem HS256 created.&lt;/p&gt;

&lt;h3&gt;
  
  
  The "aha": why this matters for microservices
&lt;/h3&gt;

&lt;p&gt;Recall the microservices picture from Figure 1 — an application split into many small, independent services. Suppose one of them, the authentication service, issues tokens, and a dozen others need to verify them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;With HS256&lt;/strong&gt;, every one of those dozen services must hold the shared secret. That's a real problem on two fronts:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A wider attack surface.&lt;/strong&gt;&amp;nbsp;The secret now lives in twelve places instead of one. A breach of&amp;nbsp;_any single service_leaks the key — and with it, the power to forge tokens that all twelve will trust.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Misplaced trust.&lt;/strong&gt;&amp;nbsp;Every service that can&amp;nbsp;&lt;em&gt;verify&lt;/em&gt;&amp;nbsp;can also&amp;nbsp;&lt;em&gt;issue&lt;/em&gt;. A minor, low-privilege service now holds the keys to impersonate anyone. That violates a basic security principle: a component should hold only the power it actually needs.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;With RS256&lt;/strong&gt;, the picture changes completely:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The&amp;nbsp;&lt;strong&gt;private key lives in exactly one place&lt;/strong&gt;&amp;nbsp;— the authentication service. Only it can mint tokens.&lt;/li&gt;
&lt;li&gt;The&amp;nbsp;&lt;strong&gt;public key is distributed&lt;/strong&gt;&amp;nbsp;to all twelve verifying services. It's not a secret; if one leaks, nothing is compromised, because a public key cannot forge anything.&lt;/li&gt;
&lt;li&gt;Verifying services can do their job — confirming a token is genuine — without ever holding the power to create one.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F9rxdkphjshwg1arb52qk.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F9rxdkphjshwg1arb52qk.png" alt="Figure 10. Token Validation in Microservices - Securing the Keys" width="800" height="393"&gt;&lt;/a&gt; Figure 10. Token Validation in Microservices - Securing the Keys&lt;/p&gt;

&lt;p&gt;This is the central insight of the section, and it's worth stating plainly:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;In a distributed system, use an&amp;nbsp;&lt;strong&gt;asymmetric&lt;/strong&gt;&amp;nbsp;algorithm. It lets you separate the power to&amp;nbsp;&lt;em&gt;issue&lt;/em&gt;&amp;nbsp;tokens from the power to&amp;nbsp;&lt;em&gt;verify&lt;/em&gt;&amp;nbsp;them — concentrating the dangerous capability in one guarded place while distributing the harmless one freely.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  ES256: asymmetric, but leaner
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;ES256&lt;/strong&gt;&amp;nbsp;stands for&amp;nbsp;&lt;strong&gt;ECDSA using SHA-256&lt;/strong&gt;, where ECDSA is the Elliptic Curve Digital Signature Algorithm.&lt;/p&gt;

&lt;p&gt;For decision-making purposes, ES256 belongs in the same category as RS256: it is&amp;nbsp;&lt;strong&gt;asymmetric&lt;/strong&gt;, with a private key for signing and a public key for verifying, and it solves the microservices problem in exactly the same way. The difference is mechanical. ES256 is built on&amp;nbsp;&lt;strong&gt;elliptic-curve cryptography&lt;/strong&gt;, a more modern branch of public-key cryptography that achieves the same security strength with much smaller keys and signatures.&lt;/p&gt;

&lt;p&gt;The practical payoff is&amp;nbsp;&lt;strong&gt;size&lt;/strong&gt;. An RS256 signature is large; an ES256 signature, at comparable security, is substantially smaller. Since a JWT travels with every request, a smaller signature means a smaller token on every call. For a high-traffic API, that adds up.&lt;/p&gt;

&lt;p&gt;The trade-off is maturity and ubiquity. RSA has been in use for decades and is supported absolutely everywhere; elliptic-curve support, while now excellent, is marginally less universal in older systems. In practice, both are solid choices — RS256 is the safe, conventional default, and ES256 is the leaner, more modern alternative.&lt;/p&gt;

&lt;h3&gt;
  
  
  Putting it side by side
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;&lt;strong&gt;HS256&lt;/strong&gt;&lt;/th&gt;
&lt;th&gt;&lt;strong&gt;RS256&lt;/strong&gt;&lt;/th&gt;
&lt;th&gt;&lt;strong&gt;ES256&lt;/strong&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Family&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Symmetric&lt;/td&gt;
&lt;td&gt;Asymmetric&lt;/td&gt;
&lt;td&gt;Asymmetric&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Key(s)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;One shared secret&lt;/td&gt;
&lt;td&gt;RSA private/public pair&lt;/td&gt;
&lt;td&gt;EC private/public pair&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Who can verify?&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Only holders of the secret&lt;/td&gt;
&lt;td&gt;Anyone with the public key&lt;/td&gt;
&lt;td&gt;Anyone with the public key&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Can a verifier also forge tokens?&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Yes&lt;/strong&gt;&amp;nbsp;— same key signs and verifies&lt;/td&gt;
&lt;td&gt;No — public key can't sign&lt;/td&gt;
&lt;td&gt;No — public key can't sign&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Signature size&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Small&lt;/td&gt;
&lt;td&gt;Large&lt;/td&gt;
&lt;td&gt;Small&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Best fit&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;A single, self-contained service&lt;/td&gt;
&lt;td&gt;Distributed systems / microservices&lt;/td&gt;
&lt;td&gt;Distributed systems where token size matters&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The decision rule is short. If your application is a&amp;nbsp;&lt;strong&gt;single service&lt;/strong&gt;&amp;nbsp;that both issues and verifies its own tokens,&amp;nbsp;&lt;strong&gt;HS256&lt;/strong&gt;&amp;nbsp;is simple, fast, and entirely appropriate — there's no second party, so a shared secret shares nothing. The moment&amp;nbsp;&lt;strong&gt;more than one independent service must verify tokens&lt;/strong&gt;, switch to an asymmetric algorithm:&amp;nbsp;&lt;strong&gt;RS256&lt;/strong&gt;&amp;nbsp;as the well-supported default,&amp;nbsp;&lt;strong&gt;ES256&lt;/strong&gt;&amp;nbsp;when you want smaller tokens and your platform supports it. The question is never "which algorithm is best" in the abstract — it is "who, in my system, needs to verify a token, and should those same parties be able to create one?"&lt;/p&gt;

&lt;p&gt;We now know how a token is signed. But verifying a token correctly involves much more than checking that one signature. The next section lays out the full validation checklist — and shows why skipping any step on it opens a door.&lt;/p&gt;

&lt;h2&gt;
  
  
  Validation: The Full Checklist
&lt;/h2&gt;

&lt;p&gt;Here is a mistake that has shipped to production in countless real systems: a developer wires up JWT authentication, confirms the signature checks out, and considers the job done. The signature is valid — so the token is trustworthy. Right?&lt;/p&gt;

&lt;p&gt;Not quite.&amp;nbsp;&lt;strong&gt;A valid signature answers only one question: was this token altered since it was issued?&lt;/strong&gt;&amp;nbsp;It says nothing about&amp;nbsp;&lt;em&gt;when&lt;/em&gt;&amp;nbsp;the token was issued,&amp;nbsp;&lt;em&gt;who&lt;/em&gt;&amp;nbsp;issued it,&amp;nbsp;&lt;em&gt;who&lt;/em&gt;&amp;nbsp;it was meant for, or whether it has been&amp;nbsp;&lt;em&gt;cancelled&lt;/em&gt;&amp;nbsp;in the meantime. A token can have a perfect signature and still be one your server must refuse.&lt;/p&gt;

&lt;p&gt;Proper validation is therefore a&amp;nbsp;&lt;strong&gt;checklist&lt;/strong&gt;, run in order. This section walks through all six steps, mirroring the security rigor of the earlier parts of this series.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why the order matters
&lt;/h3&gt;

&lt;p&gt;The steps are sequenced deliberately, and the first one comes first for a reason:&amp;nbsp;&lt;strong&gt;until the signature is fully verified, you cannot trust a single byte of the payload.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Every later check — expiry, issuer, audience — reads a value&amp;nbsp;&lt;em&gt;from the payload&lt;/em&gt;. But if the signature hasn't been confirmed, the payload might be an attacker's forgery, and an attacker writes their&amp;nbsp;&lt;code&gt;exp&lt;/code&gt;&amp;nbsp;claim to say whatever they like. Checking expiry on an unverified payload is theatre. So the signature is verified first; only once it passes does the payload become trustworthy enough to inspect.&lt;/p&gt;

&lt;h3&gt;
  
  
  The six-step checklist
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Step 1 — Verify the signature.&lt;/strong&gt;&amp;nbsp;Recompute the signature from the token's header and payload (using the secret for HS256, or the public key for RS256/ES256) and confirm it matches the signature attached to the token. If it doesn't, the token is forged or corrupted — reject it immediately and run no further checks. If it matches, the payload is now proven authentic, and the remaining steps can rely on it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 2 — Check&amp;nbsp;&lt;code&gt;exp&lt;/code&gt;&amp;nbsp;(not expired).&lt;/strong&gt;&amp;nbsp;Read the expiration timestamp and confirm the current time is before it. An expired token is rejected even though its signature is flawless. This is the check that makes a stolen token stop working on its own — and the entire reason short expiry times are worth setting.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 3 — Check&amp;nbsp;&lt;code&gt;nbf&lt;/code&gt;&amp;nbsp;(not used before valid).&lt;/strong&gt;&amp;nbsp;If the token carries a "not before" claim, confirm the current time is past it. A token presented before its activation moment is not yet valid and must be refused. In practice this rarely fires, but a complete validator checks it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 4 — Check&amp;nbsp;&lt;code&gt;iss&lt;/code&gt;&amp;nbsp;(expected issuer).&lt;/strong&gt;&amp;nbsp;Confirm the&amp;nbsp;&lt;code&gt;iss&lt;/code&gt;&amp;nbsp;claim names an authentication server you actually trust. A token can be perfectly signed by&amp;nbsp;&lt;em&gt;some&lt;/em&gt;&amp;nbsp;issuer, but if it isn't&amp;nbsp;&lt;em&gt;your&lt;/em&gt;&amp;nbsp;issuer, it's irrelevant — reject it. This stops your service from honouring tokens minted by an unrelated, untrusted source.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 5 — Check&amp;nbsp;&lt;code&gt;aud&lt;/code&gt;&amp;nbsp;(intended audience).&lt;/strong&gt;&amp;nbsp;Confirm the&amp;nbsp;&lt;code&gt;aud&lt;/code&gt;&amp;nbsp;claim names&amp;nbsp;&lt;em&gt;your&lt;/em&gt;&amp;nbsp;service. This is the check which is most often skipped — and skipping it is a genuine vulnerability. Without it, a token issued for the low-privilege analytics service would be accepted by the high-privilege billing service, simply because both trust the same issuer. The&amp;nbsp;&lt;code&gt;aud&lt;/code&gt;&amp;nbsp;check is what keeps a token usable&amp;nbsp;&lt;em&gt;only&lt;/em&gt;&amp;nbsp;where it was meant to be used.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 6 — Check&amp;nbsp;&lt;code&gt;jti&lt;/code&gt;&amp;nbsp;against a revocation list (if needed).&lt;/strong&gt;&amp;nbsp;For most stateless setups this step is deliberately omitted — checking a token against a list means consulting a database, which sacrifices the very statelessness JWT was chosen for. But when you genuinely need the ability to cancel an individual token before it expires, this is where it happens: look up the token's&amp;nbsp;&lt;code&gt;jti&lt;/code&gt;&amp;nbsp;and reject it if it appears on a blocklist. This step is the bridge revocation problem, and the reason it's marked "if needed" is itself the subject of that section.&lt;/p&gt;

&lt;h3&gt;
  
  
  What this looks like in code
&lt;/h3&gt;

&lt;p&gt;Previously we said: build a token by hand once to understand it, then use a library in production. Validation is precisely where that advice earns its keep. A good library performs the entire checklist for you —&amp;nbsp;&lt;em&gt;if&lt;/em&gt;&amp;nbsp;you tell it what to expect:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;jwt&lt;/span&gt;  &lt;span class="c1"&gt;# the PyJWT library
&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;validate_jwt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;secret&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;expected_audience&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;jwt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;secret&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;algorithms&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;HS256&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;          &lt;span class="c1"&gt;# Step 1: which algorithm(s) to trust
&lt;/span&gt;            &lt;span class="n"&gt;audience&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;expected_audience&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;    &lt;span class="c1"&gt;# Step 5: the aud claim must match this
&lt;/span&gt;        &lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="c1"&gt;# Steps 2 and 3 — exp and nbf — are checked automatically by decode().
&lt;/span&gt;        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;

    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="n"&gt;jwt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ExpiredSignatureError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;AuthError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Token has expired&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;          &lt;span class="c1"&gt;# Step 2 failed
&lt;/span&gt;    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="n"&gt;jwt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;InvalidAudienceError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;AuthError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Invalid audience&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;           &lt;span class="c1"&gt;# Step 5 failed
&lt;/span&gt;    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="n"&gt;jwt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;InvalidSignatureError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;AuthError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Signature verification failed&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# Step 1 failed
&lt;/span&gt;    &lt;span class="c1"&gt;# ... and so on for the other failure cases
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three details in that small function carry the section's whole lesson.&lt;/p&gt;

&lt;p&gt;First,&amp;nbsp;&lt;strong&gt;&lt;code&gt;jwt.decode()&lt;/code&gt;&amp;nbsp;does several checks at once.&lt;/strong&gt;&amp;nbsp;A single call verifies the signature, the expiry, and the "not before" time. The library runs the checklist — but only the parts you've configured.&lt;/p&gt;

&lt;p&gt;Second,&amp;nbsp;&lt;strong&gt;a check you don't ask for doesn't happen.&lt;/strong&gt;&amp;nbsp;Notice&amp;nbsp;&lt;code&gt;audience=expected_audience&lt;/code&gt;&amp;nbsp;is passed explicitly. Leave that argument out, and many libraries simply&amp;nbsp;&lt;em&gt;skip the audience check entirely&lt;/em&gt;&amp;nbsp;— Step 5 silently vanishes, and no error is ever raised. The token validates "successfully," and a real vulnerability sits quietly in your code. The same applies to the issuer. Safe validation means actively telling the library what to expect; its defaults are not your security policy.&lt;/p&gt;

&lt;p&gt;Third — and this is the most important line in the whole snippet —&amp;nbsp;&lt;strong&gt;&lt;code&gt;algorithms=["HS256"]&lt;/code&gt;&amp;nbsp;is not optional.&lt;/strong&gt;&amp;nbsp;It tells the library exactly which signing algorithm(s) to accept. Omitting it, or filling it in carelessly, opens the single most infamous JWT vulnerability of all. We've now mentioned it twice as a forward pointer; later we finally show the attack itself.&lt;/p&gt;

&lt;h3&gt;
  
  
  The takeaway
&lt;/h3&gt;

&lt;p&gt;A valid signature is the&amp;nbsp;&lt;em&gt;first&lt;/em&gt;&amp;nbsp;line of a six-line checklist, not the whole of it. Full validation confirms a token is&amp;nbsp;&lt;strong&gt;authentic&lt;/strong&gt;(signature),&amp;nbsp;&lt;strong&gt;current&lt;/strong&gt;&amp;nbsp;(&lt;code&gt;exp&lt;/code&gt;,&amp;nbsp;&lt;code&gt;nbf&lt;/code&gt;),&amp;nbsp;&lt;strong&gt;from a source you trust&lt;/strong&gt;&amp;nbsp;(&lt;code&gt;iss&lt;/code&gt;),&amp;nbsp;&lt;strong&gt;meant for you&lt;/strong&gt;&amp;nbsp;(&lt;code&gt;aud&lt;/code&gt;), and&amp;nbsp;&lt;strong&gt;not revoked&lt;/strong&gt;&amp;nbsp;(&lt;code&gt;jti&lt;/code&gt;, when required). A production-grade library will run every one of those checks — but only the ones you explicitly configure. The unsafe defaults are silent, and silence, in security, is exactly the danger.&lt;/p&gt;

&lt;p&gt;That sixth step — revocation — has been flagged twice now as something deferred. It's deferred because it isn't a simple checklist item; it's a genuine tension at the heart of the whole JWT design. The next section confronts it directly.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Stateless Advantage — and the Revocation Problem
&lt;/h2&gt;

&lt;p&gt;This is the most important section in the article, and the most honest. Everything so far has built toward a single payoff — and that same payoff comes bundled with a single, genuine flaw. A clear-eyed engineer needs both halves. This section gives you both.&lt;/p&gt;

&lt;h3&gt;
  
  
  The win: a server that needs no memory
&lt;/h3&gt;

&lt;p&gt;Return to the very first idea of this series. An API key is a meaningless ticket; to learn what it permits, the server must make a&amp;nbsp;&lt;strong&gt;database roundtrip&lt;/strong&gt;&amp;nbsp;to the back room. A JWT prints the details on the ticket itself and stamps them so they can't be forged.&lt;/p&gt;

&lt;p&gt;Now we can state precisely what that buys you. When a JWT arrives, the receiving server checks it using&amp;nbsp;&lt;strong&gt;only the token and a verification key the server already holds&lt;/strong&gt;&amp;nbsp;— the shared secret for HS256, or the public key for RS256/ES256. The signature confirms the token is authentic; the claims inside answer&amp;nbsp;&lt;em&gt;who the user is&lt;/em&gt;&amp;nbsp;and&amp;nbsp;&lt;em&gt;what they may do&lt;/em&gt;. Nothing else is consulted.&lt;/p&gt;

&lt;p&gt;That property is&amp;nbsp;&lt;strong&gt;statelessness&lt;/strong&gt;: the server keeps no per-user, per-session memory. It needs no shared session store, no identity database lookup on the request path. And the benefits compound:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Speed.&lt;/strong&gt;&amp;nbsp;No network trip to a database before real work begins. Verification is local arithmetic.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scale.&lt;/strong&gt;&amp;nbsp;Add a hundred new servers and not one of them needs a connection to a session store. Each verifies tokens entirely on its own.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Resilience.&lt;/strong&gt;&amp;nbsp;Recall the microservices picture from figure 2 — a dozen services each verifying tokens. With JWT, none of them shares a session-store bottleneck. If that store would have existed, it would have been a single point of failure every service depended on. JWT removes it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is the killer feature. It is the reason JWT became the default token of the distributed-systems era.&lt;/p&gt;

&lt;h3&gt;
  
  
  The problem: you can't un-issue a token
&lt;/h3&gt;

&lt;p&gt;Now the honest half.&lt;/p&gt;

&lt;p&gt;The very thing that makes a JWT powerful — it is&amp;nbsp;&lt;strong&gt;self-contained&lt;/strong&gt;, valid purely on its own evidence — is the thing that makes it dangerous. A stateless server's logic is simply:&amp;nbsp;&lt;em&gt;"the signature is good and the token hasn't expired, therefore I trust it."&lt;/em&gt;&amp;nbsp;That logic has no step that asks anyone's permission. It never phones home.&lt;/p&gt;

&lt;p&gt;So consider: a token is&amp;nbsp;&lt;strong&gt;stolen&lt;/strong&gt;. An attacker copies a user's valid, unexpired JWT — through a compromised device, an intercepted request, a leaked log file.&lt;/p&gt;

&lt;p&gt;With an old-style server-side session, the fix is instant. You delete the session record from the server. The next request carrying that session is a stranger; access is gone forever.&lt;/p&gt;

&lt;p&gt;With a JWT, there is&amp;nbsp;&lt;strong&gt;no record to delete.&lt;/strong&gt;&amp;nbsp;The token's authority lives&amp;nbsp;&lt;em&gt;inside the token&lt;/em&gt;, in the attacker's possession. Every server that sees it performs the same local check, gets the same answer —&amp;nbsp;&lt;em&gt;signature good, not expired&lt;/em&gt;&amp;nbsp;— and grants access. The token keeps working, in the attacker's hands,&amp;nbsp;&lt;strong&gt;until its&amp;nbsp;&lt;code&gt;exp&lt;/code&gt;&amp;nbsp;timestamp passes.&lt;/strong&gt;&amp;nbsp;And nothing you do can hurry that moment along.&lt;/p&gt;

&lt;p&gt;State this plainly, because it is the crux of the entire JWT trade-off:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A JWT is&amp;nbsp;&lt;strong&gt;self-contained by design&lt;/strong&gt;, which means it is&amp;nbsp;&lt;strong&gt;non-revocable by design.&lt;/strong&gt;&amp;nbsp;The server gave up its memory in exchange for speed — and a server with no memory has no way to remember that one particular token should now be refused.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This is not a bug to be patched. It is the direct, unavoidable shadow of the feature. Statelessness and instant revocation are two ends of the same stick: you cannot pick up one end without the other coming with it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Living with the trade-off
&lt;/h3&gt;

&lt;p&gt;You can't eliminate the problem, but you can&amp;nbsp;&lt;em&gt;manage&lt;/em&gt;&amp;nbsp;it. Three patterns are used in practice, and they sit on a deliberate spectrum — from "fully stateless, accept some risk" to "give back some statelessness, regain control."&lt;/p&gt;

&lt;h4&gt;
  
  
  1. Short expiry times
&lt;/h4&gt;

&lt;p&gt;The simplest response: if a stolen token is valid until&amp;nbsp;&lt;code&gt;exp&lt;/code&gt;, then&amp;nbsp;&lt;strong&gt;make&amp;nbsp;&lt;code&gt;exp&lt;/code&gt;&amp;nbsp;soon.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Set tokens to expire in fifteen minutes rather than twenty-four hours. A stolen token is still unstoppable — but only for a short window. The damage is time-boxed. This keeps the server fully stateless; you simply accept a small, bounded exposure instead of trying to abolish it.&lt;/p&gt;

&lt;p&gt;The objection is obvious, and it's a usability one: a token that expires every fifteen minutes would log the user out every fifteen minutes. Forcing someone to re-enter their password that often is unworkable. Which is exactly why short expiry never travels alone — it travels with the next pattern.&lt;/p&gt;

&lt;h4&gt;
  
  
  2. The access token + refresh token pattern
&lt;/h4&gt;

&lt;p&gt;This is the pattern most production systems actually implement, and it resolves the usability objection cleanly by splitting one token into&amp;nbsp;&lt;strong&gt;two tokens with two different jobs.&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The access token&lt;/strong&gt;&amp;nbsp;is a JWT, short-lived (say, 15 minutes). It rides along with every API request and does the real authorization work. Being a JWT, it is verified statelessly — fast, local, no database. If stolen, it's dangerous for only 15 minutes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The refresh token&lt;/strong&gt;&amp;nbsp;is long-lived (days or weeks), but it is&amp;nbsp;&lt;strong&gt;not&lt;/strong&gt;&amp;nbsp;used for ordinary API calls. Its&amp;nbsp;&lt;em&gt;only&lt;/em&gt;&amp;nbsp;power is to ask the authentication server for a fresh access token. It is sent rarely, to one endpoint only, and it is typically a stored,&amp;nbsp;&lt;strong&gt;revocable&lt;/strong&gt;&amp;nbsp;credential — the auth server&amp;nbsp;&lt;em&gt;does&lt;/em&gt;&amp;nbsp;keep a record of it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F6sl391xcshyvqjd7fuir.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F6sl391xcshyvqjd7fuir.png" alt="Figure 11. Access &amp;amp; Refresh Token Split Pattern" width="799" height="383"&gt;&lt;/a&gt; Figure 11. Access &amp;amp; Refresh Token Split Pattern&lt;/p&gt;

&lt;p&gt;The whole lifecycle is as follows:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1. User logs in.
   → Auth server issues:  a short-lived ACCESS token  +  a long-lived REFRESH token.

2. For each API request:
   → Client sends the ACCESS token.  Service verifies it statelessly. Fast.

3. After ~15 minutes:
   → The ACCESS token expires. The next API call is rejected.

4. Client quietly sends the REFRESH token to the auth server.
   → Auth server checks the refresh token is still valid (and not revoked),
     then issues a brand-new short-lived ACCESS token.

5. Loop back to step 2. The user notices nothing.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The elegance is in the division of labour. The token used&amp;nbsp;&lt;em&gt;constantly&lt;/em&gt;&amp;nbsp;(the access token) is stateless and fast, exactly where speed matters — but barely dangerous, because it expires almost immediately. The token that is&amp;nbsp;&lt;em&gt;long-lived and genuinely dangerous&lt;/em&gt;&amp;nbsp;(the refresh token) is used&amp;nbsp;&lt;em&gt;rarely&lt;/em&gt;, hits only one endpoint, and&amp;nbsp;&lt;strong&gt;can be revoked at that one chokepoint.&lt;/strong&gt;&amp;nbsp;To cut off a compromised user, you invalidate their refresh token: within fifteen minutes their access token expires, the refresh fails, and they're locked out.&lt;/p&gt;

&lt;p&gt;You haven't achieved truly instant revocation — there's still that ≤15-minute tail — but you've shrunk the unstoppable window to something tolerable while keeping the request path stateless. For most systems, that is the right balance.&lt;/p&gt;

&lt;h4&gt;
  
  
  3. A revocation blocklist
&lt;/h4&gt;

&lt;p&gt;When even a fifteen-minute window is unacceptable — banking, healthcare, anything where a compromised session must die&amp;nbsp;&lt;em&gt;now&lt;/em&gt;&amp;nbsp;— you need true immediate revocation. And here you must pay the honest price.&lt;/p&gt;

&lt;p&gt;The pattern: recall the&amp;nbsp;&lt;strong&gt;&lt;code&gt;jti&lt;/code&gt;&lt;/strong&gt;&amp;nbsp;claim, the token's unique serial number. To revoke a token immediately, add its&amp;nbsp;&lt;code&gt;jti&lt;/code&gt;&amp;nbsp;to a&amp;nbsp;&lt;strong&gt;blocklist&lt;/strong&gt;&amp;nbsp;— a list of "tokens that must now be refused" — and have every service check incoming tokens against it. To keep that check fast, the list lives in a high-speed in-memory store such as&amp;nbsp;&lt;strong&gt;Redis&lt;/strong&gt;, not a slow disk database.&lt;/p&gt;

&lt;p&gt;Be clear-eyed about what this costs. Checking a blocklist means&amp;nbsp;&lt;strong&gt;consulting a shared store on every request&lt;/strong&gt;&amp;nbsp;— which is precisely the database roundtrip that JWT was chosen to eliminate. You have, deliberately,&amp;nbsp;&lt;strong&gt;given back the stateless advantage.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;But the surrender is partial, and that nuance matters:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A blocklist holds only&amp;nbsp;&lt;em&gt;revoked&lt;/em&gt;&amp;nbsp;tokens — a tiny set — not&amp;nbsp;&lt;em&gt;every&lt;/em&gt;&amp;nbsp;session. The store stays small.&lt;/li&gt;
&lt;li&gt;An in-memory store like Redis is far faster than the relational-database lookup the original API-key model implied.&lt;/li&gt;
&lt;li&gt;Entries can be evicted the moment a token's natural&amp;nbsp;&lt;code&gt;exp&lt;/code&gt;&amp;nbsp;passes — a revoked token already rejected by the expiry check needs no blocklist entry. The list stays small on its own.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So this isn't a wholesale return to stateful sessions. It's a precise, conscious concession: trade a little speed, on a small fast lookup, to buy back the immediate-revocation capability — but only when the application's risk profile genuinely demands it.&lt;/p&gt;

&lt;h3&gt;
  
  
  The honest summary
&lt;/h3&gt;

&lt;p&gt;JWT's defining feature and JWT's defining flaw are&amp;nbsp;&lt;strong&gt;the same fact&lt;/strong&gt;, seen from two sides. A self-contained token lets a server decide&amp;nbsp;&lt;em&gt;on its own&lt;/em&gt;&amp;nbsp;— fast, scalable, memoryless. A self-contained token also cannot be&amp;nbsp;&lt;em&gt;un-decided&lt;/em&gt;&amp;nbsp;— once issued, it is authoritative until it expires.&lt;/p&gt;

&lt;p&gt;There is no pattern that makes that flaw vanish. There are only positions on a spectrum:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Approach&lt;/th&gt;
&lt;th&gt;Statelessness&lt;/th&gt;
&lt;th&gt;Revocation speed&lt;/th&gt;
&lt;th&gt;Typical use&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Short expiry only&lt;/td&gt;
&lt;td&gt;Fully stateless&lt;/td&gt;
&lt;td&gt;None — wait for&amp;nbsp;&lt;code&gt;exp&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Low-risk, simple systems&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Access + refresh tokens&lt;/td&gt;
&lt;td&gt;Stateless request path&lt;/td&gt;
&lt;td&gt;Bounded — within the access token's lifetime&lt;/td&gt;
&lt;td&gt;The mainstream default&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;jti&lt;/code&gt;&amp;nbsp;blocklist&lt;/td&gt;
&lt;td&gt;Partly stateful again&lt;/td&gt;
&lt;td&gt;Immediate&lt;/td&gt;
&lt;td&gt;High-stakes systems only&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The engineering question is never "how do I make JWT revocable?" — it can't be made so without giving something up. The question is:&amp;nbsp;&lt;strong&gt;how much immediate-revocation power does this system genuinely need, and how much statelessness am I willing to trade for it?&lt;/strong&gt;&amp;nbsp;Answer that honestly, pick your point on the spectrum, and you are using JWT well.&lt;/p&gt;

&lt;p&gt;We've now covered what a JWT is, what it carries, how it's signed, how it's validated, and its central trade-off. One thing remains: the specific ways JWT implementations get attacked — including the single most infamous JWT vulnerability of all.&lt;/p&gt;

&lt;h2&gt;
  
  
  Security Considerations
&lt;/h2&gt;

&lt;p&gt;A JWT is a security mechanism, which makes its own failure modes worth studying with care. Most JWT disasters are not failures of the cryptography — the math is sound. They are failures of&amp;nbsp;&lt;em&gt;implementation&lt;/em&gt;: a check skipped, a default trusted, a token stored carelessly. This section covers the four that matter most, beginning with the most famous of them all.&lt;/p&gt;

&lt;h3&gt;
  
  
  The algorithm confusion attack:&amp;nbsp;&lt;code&gt;alg: none&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;We have pointed at this vulnerability twice and promised to show it here. It is the most infamous JWT bug ever, and what makes it memorable is that it turns the token's own honesty mechanism&amp;nbsp;&lt;em&gt;against itself&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;Recall the structure of the token. The&amp;nbsp;&lt;strong&gt;header&lt;/strong&gt;&amp;nbsp;declares which algorithm was used to sign the token, in its&amp;nbsp;&lt;code&gt;alg&lt;/code&gt;&amp;nbsp;field —&amp;nbsp;&lt;code&gt;"alg": "HS256"&lt;/code&gt;. The&amp;nbsp;&lt;strong&gt;signature&lt;/strong&gt;&amp;nbsp;is the stamp that proves the token wasn't altered.&lt;/p&gt;

&lt;p&gt;Now recall a detail almost no one expects: the JWT standard defines a&amp;nbsp;&lt;em&gt;legitimate&lt;/em&gt;&amp;nbsp;algorithm value called&amp;nbsp;&lt;strong&gt;&lt;code&gt;none&lt;/code&gt;&lt;/strong&gt;. It means "this token is unsigned." It exists for narrow cases where a token's integrity is already guaranteed by some other layer. It also creates a trapdoor.&lt;/p&gt;

&lt;p&gt;Here is the attack, step by step:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1. The attacker takes a real, valid token and decodes it
   (remember - the payload is NOT secret, anyone can read it).

2. The attacker EDITS the payload freely:
        "sub": "regular-user"   →   "sub": "admin"

3. The attacker changes the header:
        "alg": "HS256"          →   "alg": "none"

4. The attacker DELETES the signature entirely,
   leaving a token that ends in a final dot and nothing after it:
        header.payload.

5. The token is sent to the server.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now everything depends on one line of the server's code. A&amp;nbsp;&lt;strong&gt;naively written verifier&lt;/strong&gt;&amp;nbsp;reads the header, sees&amp;nbsp;&lt;code&gt;"alg": "none"&lt;/code&gt;, and reasons:&amp;nbsp;&lt;em&gt;"The header says this token is unsigned, so there is no signature to check. No signature to check means nothing fails. Token accepted."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fj667g6roi388lf9chprf.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fj667g6roi388lf9chprf.png" alt="Figure 12. JWT Attack with " width="800" height="382"&gt;&lt;/a&gt; Figure 12. JWT Attack with "alg" set to "none"&lt;/p&gt;

&lt;p&gt;The forged token sails through. The attacker is now&amp;nbsp;&lt;code&gt;admin&lt;/code&gt;. They never needed the secret key, never broke any cryptography — they simply&amp;nbsp;&lt;strong&gt;let the token tell the server how to verify it&lt;/strong&gt;, and the token, under attacker control, said "don't bother."&lt;/p&gt;

&lt;p&gt;That is the heart of the trap: a naive verifier trusts the&amp;nbsp;&lt;em&gt;token's own header&lt;/em&gt;&amp;nbsp;to decide how the token should be checked. But the header is attacker-controlled. You have let the thing being inspected dictate the terms of its own inspection. (If this sounds like the embedded-public-key problem discussed earlier — a token vouching for its own trust — it is exactly the same mistake, in a different costume.)&lt;/p&gt;

&lt;p&gt;*&lt;em&gt;The fix is one line, and you have already seen it:&lt;/em&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;jwt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;secret&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;algorithms&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;HS256&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That&amp;nbsp;&lt;code&gt;algorithms=["HS256"]&lt;/code&gt;&amp;nbsp;argument is the entire defense. It instructs the library:&amp;nbsp;&lt;em&gt;"I will accept HS256 and nothing else. I don't care what the token's header claims."&lt;/em&gt;&amp;nbsp;A token arriving with&amp;nbsp;&lt;code&gt;alg: none&lt;/code&gt;&amp;nbsp;is now rejected before any verification logic runs, because&amp;nbsp;&lt;code&gt;none&lt;/code&gt;&amp;nbsp;is not on the list the&amp;nbsp;&lt;em&gt;server&lt;/em&gt;&amp;nbsp;chose.&lt;/p&gt;

&lt;p&gt;The principle, stated generally:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Never let the token decide how it should be verified.&lt;/strong&gt;&amp;nbsp;The set of acceptable algorithms is a decision the server makes in advance and enforces — not a value it reads out of the untrusted header.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Modern, well-maintained libraries now refuse&amp;nbsp;&lt;code&gt;alg: none&lt;/code&gt;&amp;nbsp;by default and require you to specify allowed algorithms — which is exactly why we insisted on using a real library rather than hand-rolling verification. But the lesson outlives this one bug: a verifier must hold its own policy, never inherit it from the token.&lt;/p&gt;

&lt;h3&gt;
  
  
  A second face of algorithm confusion: HS256 vs RS256
&lt;/h3&gt;

&lt;p&gt;There is a subtler cousin of the same attack.&lt;/p&gt;

&lt;p&gt;Recall the asymmetric model: with RS256, the server signs with a&amp;nbsp;&lt;strong&gt;private key&lt;/strong&gt;&amp;nbsp;and publishes the&amp;nbsp;&lt;strong&gt;public key&lt;/strong&gt;&amp;nbsp;for anyone to verify with. The public key is, by design,&amp;nbsp;&lt;em&gt;not secret&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;Now picture a server configured for RS256 but with a careless verifier that accepts whatever algorithm the header names. An attacker takes the&amp;nbsp;&lt;strong&gt;public key&lt;/strong&gt;&amp;nbsp;— which is freely available — and uses it as if it were an&amp;nbsp;&lt;strong&gt;HS256 shared secret&lt;/strong&gt;. They forge a token, set the header to&amp;nbsp;&lt;code&gt;"alg": "HS256"&lt;/code&gt;, and sign it using the public key as the HMAC secret.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fa54js759hu26oilae780.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fa54js759hu26oilae780.png" alt="Figure 13. RS256/HS256 Key Confusion Attack Path" width="800" height="385"&gt;&lt;/a&gt; Figure 13. RS256/HS256 Key Confusion Attack Path&lt;/p&gt;

&lt;p&gt;When the token arrives, the confused server sees&amp;nbsp;&lt;code&gt;alg: HS256&lt;/code&gt;, fetches its RS256 public key as the "secret," runs the HS256 check — and it passes. The attacker has forged a valid token using only public information.&lt;/p&gt;

&lt;p&gt;The fix is the same fix: pin the accepted algorithm. A server expecting RS256 must specify&amp;nbsp;&lt;code&gt;algorithms=["RS256"]&lt;/code&gt;&amp;nbsp;and refuse everything else, so a token claiming&amp;nbsp;&lt;code&gt;HS256&lt;/code&gt;&amp;nbsp;is rejected outright. Once again — the server decides, not the token.&lt;/p&gt;

&lt;h3&gt;
  
  
  Secret strength for HS256
&lt;/h3&gt;

&lt;p&gt;This one is simple and easily neglected. With HS256, the security of every token rests entirely on one shared secret. The signature is only as strong as that secret is hard to guess.&lt;/p&gt;

&lt;p&gt;A secret like&amp;nbsp;&lt;code&gt;"secret"&lt;/code&gt;,&amp;nbsp;&lt;code&gt;"password123"&lt;/code&gt;, or your application's name is not a secret — it is an invitation. An attacker who suspects HS256 can run an&amp;nbsp;&lt;strong&gt;offline brute-force attack&lt;/strong&gt;: take any real token they've captured, and rapidly try millions of candidate secrets, checking each by recomputing the signature. A weak secret falls in seconds. Once it's found, the attacker can mint unlimited valid tokens for anyone.&lt;/p&gt;

&lt;p&gt;The defense is unglamorous but absolute:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Use a&amp;nbsp;&lt;strong&gt;long, high-entropy, randomly generated&lt;/strong&gt;&amp;nbsp;secret — at least 256 bits (32 random bytes), produced by a cryptographic random generator, never typed by a human.&lt;/li&gt;
&lt;li&gt;Store it as a&amp;nbsp;&lt;strong&gt;secret&lt;/strong&gt;&amp;nbsp;— in a secrets manager or environment configuration, never hard-coded in source, never committed to version control.&lt;/li&gt;
&lt;li&gt;This concern is HS256-specific. With RS256/ES256 there is no shared secret to guess; the private key is a different kind of object, guarded differently.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Token storage in the browser
&lt;/h3&gt;

&lt;p&gt;When a JWT is used to authenticate a user in a web browser, a practical question arises:&amp;nbsp;&lt;em&gt;where does the browser keep the token between requests?&lt;/em&gt;&amp;nbsp;This debate is mostly relevant to browser-based apps rather than the pure API-to-API use that is this series' main focus, so we'll keep it brief — but it's worth knowing the shape of it.&lt;/p&gt;

&lt;p&gt;There are two common choices, each with a different weakness:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;&lt;code&gt;localStorage&lt;/code&gt;&lt;/strong&gt;&amp;nbsp;is a simple in-browser storage area. It's easy to use, but any JavaScript running on the page can read it. If an attacker manages to inject a malicious script into your site — a&amp;nbsp;&lt;strong&gt;cross-site scripting (XSS)&lt;/strong&gt;&amp;nbsp;attack — that script can read the token straight out of&amp;nbsp;&lt;code&gt;localStorage&lt;/code&gt;&amp;nbsp;and steal it.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;An&amp;nbsp;&lt;code&gt;httpOnly&lt;/code&gt;&amp;nbsp;cookie&lt;/strong&gt;&amp;nbsp;is a cookie the browser marks as off-limits to JavaScript. Even a successful XSS attack cannot read it directly, which closes the theft route above. The trade-off: cookies are sent by the browser automatically, which can expose the app to a different attack class —&amp;nbsp;&lt;strong&gt;cross-site request forgery (CSRF)&lt;/strong&gt;&amp;nbsp;— that needs its own separate defenses.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;There is no universally "correct" answer; each option trades one risk for another. The genuinely important point sits underneath both:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A JWT is a&amp;nbsp;&lt;strong&gt;bearer token&lt;/strong&gt;&amp;nbsp;— like cash, whoever holds it can spend it. The token itself contains no proof that the&amp;nbsp;&lt;em&gt;right&lt;/em&gt;&amp;nbsp;person is presenting it. So the security of any browser-based JWT system depends heavily on simply&amp;nbsp;&lt;em&gt;not letting the token get stolen&lt;/em&gt;&amp;nbsp;— and that depends on the surrounding application being free of injection flaws.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Short expiry as a discipline
&lt;/h3&gt;

&lt;p&gt;The final consideration is not a new attack but a habit that blunts all of them — and we've already met it.&lt;/p&gt;

&lt;p&gt;Every threat in this section ends the same way: an attacker obtains a token they shouldn't have. And we already know JWT's hard truth — a stolen token cannot be recalled; it is valid until its&amp;nbsp;&lt;code&gt;exp&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;This makes the expiry time itself a security control. A long-lived token turns any single theft into a long-lived breach. A short-lived one — minutes, not days — means a stolen token is a&amp;nbsp;&lt;em&gt;briefly&lt;/em&gt;&amp;nbsp;useful prize, paired with the refresh-token pattern so users never feel the churn.&lt;/p&gt;

&lt;p&gt;Short expiry doesn't&amp;nbsp;&lt;em&gt;prevent&lt;/em&gt;&amp;nbsp;theft. It&amp;nbsp;&lt;strong&gt;caps the cost&lt;/strong&gt;&amp;nbsp;of theft. It is the safety net beneath every other mistake, and it should be treated as non-negotiable discipline rather than a tuning knob.&lt;/p&gt;

&lt;h3&gt;
  
  
  The thread connecting all four
&lt;/h3&gt;

&lt;p&gt;Step back and the four considerations rhyme. The algorithm confusion attack is&amp;nbsp;&lt;em&gt;trusting the token to define its own verification&lt;/em&gt;. Weak HS256 secrets are&amp;nbsp;&lt;em&gt;trusting an easily guessed value to be hard&lt;/em&gt;. Careless token storage is&amp;nbsp;&lt;em&gt;trusting a hostile environment to keep a bearer token safe&lt;/em&gt;. And the antidote running through all of them is the same instinct:&amp;nbsp;&lt;strong&gt;trust must be anchored in decisions the server makes and controls — never in something the token, the attacker, or the environment supplies.&lt;/strong&gt;&amp;nbsp;The cryptography in JWT is rarely what fails. The trust assumptions around it are.&lt;/p&gt;

&lt;p&gt;With the failure modes mapped, only the practical verdict remains: when JWT is the right tool for the job — and when it isn't.&lt;/p&gt;

&lt;h2&gt;
  
  
  When to Use JWT — and When Not To
&lt;/h2&gt;

&lt;p&gt;We've taken JWT apart completely: its structure, its claims, its signatures, its validation, its central trade-off, and its failure modes. Knowing how a tool works is not the same as knowing when to reach for it. This section is the practical verdict — a decision guide for an engineer building a real system.&lt;/p&gt;

&lt;p&gt;The honest framing is this: JWT is not a&amp;nbsp;&lt;em&gt;better&lt;/em&gt;&amp;nbsp;credential than the API key from Part II. It is a&amp;nbsp;&lt;em&gt;different&lt;/em&gt;&amp;nbsp;credential, built for a different job. Picking the right one means matching the tool to the situation.&lt;/p&gt;

&lt;h3&gt;
  
  
  When JWT is the right choice
&lt;/h3&gt;

&lt;p&gt;JWT earns its place whenever its defining feature — a&amp;nbsp;&lt;strong&gt;self-contained, statelessly verifiable token&lt;/strong&gt;&amp;nbsp;— solves a problem you actually have.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Stateless microservices — the killer use case.&lt;/strong&gt;&amp;nbsp;This is the scenario JWT was built for, and the one where it has no real rival. When an application is split into many small services, and a request may pass through several of them, a JWT lets&amp;nbsp;&lt;em&gt;each&lt;/em&gt;&amp;nbsp;service verify the caller independently — with only a public key, no shared session store, no database roundtrip. The token carries identity and permissions with it. If there is one situation where JWT is unambiguously the answer, this is it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cross-domain authentication — SPAs and mobile apps.&lt;/strong&gt;&amp;nbsp;Recall that JWT emerged partly because session cookies are bound to a single domain and fit modern clients poorly. A JWT is just a string. It travels comfortably from a single-page app or a native mobile application to an API hosted on an entirely different domain, with none of the domain-binding friction cookies impose. When your client and your API don't share an origin, JWT removes a real obstacle.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Short-lived service-to-service tokens.&lt;/strong&gt;&amp;nbsp;When one internal service needs to call another and prove "this request is genuinely from me," a short-lived JWT is an excellent fit. Its built-in expiry means these machine-to-machine tokens clean up after themselves — no long-lived credential left lying around, no revocation process to run. They are minted, used, and expire, all within minutes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;As a session-scoped layer on top of a long-term API key.&lt;/strong&gt;&amp;nbsp;This is the subtlest fit, and it shows JWT and the API key working&amp;nbsp;&lt;em&gt;together&lt;/em&gt;&amp;nbsp;rather than competing. An API key (Part II) is excellent for stable, long-term&amp;nbsp;&lt;em&gt;identity&lt;/em&gt;&amp;nbsp;— "this is the Acme Corp account." A JWT is excellent for short-lived, fine-grained&amp;nbsp;&lt;em&gt;authorization&lt;/em&gt;&amp;nbsp;— "this particular request, in this 15-minute window, may write to invoices." A common production pattern: a client authenticates&amp;nbsp;&lt;em&gt;once&lt;/em&gt;&amp;nbsp;with its long-lived API key, and in exchange receives a series of short-lived JWTs that carry the actual per-request permissions. The API key answers&amp;nbsp;&lt;em&gt;who you are over time&lt;/em&gt;; the JWT answers&amp;nbsp;&lt;em&gt;what you may do right now&lt;/em&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  When to reach for something else
&lt;/h3&gt;

&lt;p&gt;JWT's weaknesses are not hidden and we stated them plainly. Two situations expose them directly, and in both, a different tool is the honest choice.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When you need immediate, reliable revocation.&lt;/strong&gt;&amp;nbsp;This is JWT's defining flaw, and it is worth refusing to paper over. A standard JWT&amp;nbsp;&lt;em&gt;cannot be un-issued&lt;/em&gt;&amp;nbsp;— it is valid until&amp;nbsp;&lt;code&gt;exp&lt;/code&gt;, full stop. If your system has a hard requirement that a compromised credential must die&amp;nbsp;&lt;em&gt;the instant&lt;/em&gt;&amp;nbsp;you say so — with no fifteen-minute tail, no acceptable window — then a self-contained token is fighting your requirement.&lt;/p&gt;

&lt;p&gt;The alternative here is an&amp;nbsp;&lt;strong&gt;opaque token&lt;/strong&gt;&amp;nbsp;checked by&amp;nbsp;&lt;strong&gt;introspection&lt;/strong&gt;: the token is a meaningless reference string (like the API key of Part II — a coat-check ticket), and the receiving service asks a central authority, on every request, "is this token still valid?" That&amp;nbsp;&lt;em&gt;is&lt;/em&gt;&amp;nbsp;a database roundtrip. You are deliberately giving up statelessness — but in exchange you get exactly the instant, authoritative revocation that statelessness cost you. For high-stakes systems, that is the right trade.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When the token is genuinely long-lived.&lt;/strong&gt;&amp;nbsp;If a credential is meant to last for months — a stable identifier for a server, a long-running integration — then JWT's machinery is working against you. Its central feature, the self-contained expiring claim set, brings no benefit when nothing is meant to expire soon, and its central flaw, non-revocability, becomes a serious liability over a long lifespan. For durable, long-term identity, a plain&amp;nbsp;&lt;strong&gt;API key&lt;/strong&gt;&amp;nbsp;is simpler, and — because it's checked against a database anyway — it can be revoked the moment you need to. Don't reach for a JWT's complexity to do an API key's job.&lt;/p&gt;

&lt;h3&gt;
  
  
  The decision in one table
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Your situation&lt;/th&gt;
&lt;th&gt;Reach for&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Many services must verify a caller independently&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;JWT&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Stateless verification, no shared session store&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Client and API live on different domains&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;JWT&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;A plain string travels anywhere; no cookie domain-binding&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Short-lived, self-expiring service-to-service calls&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;JWT&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Built-in expiry; no revocation process needed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Stable, long-term identity for an account or server&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;API key&lt;/strong&gt;&amp;nbsp;(Part II)&lt;/td&gt;
&lt;td&gt;Simpler; revocable; nothing needs to expire soon&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A compromised credential must be killable&amp;nbsp;&lt;em&gt;instantly&lt;/em&gt;
&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Opaque token + introspection&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;True immediate revocation, at the cost of statelessness&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  The underlying judgment
&lt;/h3&gt;

&lt;p&gt;Reduce all of the above to a single question and it becomes easy to remember:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Does this system need a server to decide&amp;nbsp;&lt;em&gt;on its own&lt;/em&gt;, fast and at scale — or does it need a central authority to stay&amp;nbsp;&lt;em&gt;in control&lt;/em&gt;, able to revoke instantly?&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;JWT is the tool for the first. It trades central control for local, stateless speed — a brilliant trade when speed and scale are what you need, a poor one when control is. Opaque tokens and API keys are the tools for the second.&lt;/p&gt;

&lt;p&gt;There is no "best" credential, only a credential that fits. An engineer who can name&amp;nbsp;&lt;em&gt;why&lt;/em&gt;&amp;nbsp;they chose JWT — and name what they gave up to get it — is using it well. One who reaches for it by default, because it is the modern-sounding option, has skipped the only question that matters.&lt;/p&gt;

&lt;p&gt;That leaves one piece of this series unfinished. JWT answers, beautifully, the question&amp;nbsp;&lt;em&gt;who are you?&lt;/em&gt;&amp;nbsp;It does not answer a different and harder one:&amp;nbsp;&lt;em&gt;how do you let a third party act on your behalf — without ever handing them your password?&lt;/em&gt;&amp;nbsp;That is the problem the final part was built to solve.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion: Three Tools, Three Jobs
&lt;/h2&gt;

&lt;p&gt;We began this article with a coat-check ticket and we end it with a boarding pass. That shift — from a meaningless token the server must look up, to a self-describing token the server can read and trust on sight — is the whole of what JWT contributes. It's worth gathering the journey into a single picture before we close.&lt;/p&gt;

&lt;h3&gt;
  
  
  What JWT actually gave us
&lt;/h3&gt;

&lt;p&gt;Strip away the Base64URL, the claims tables, the algorithm names, and one idea remains:&amp;nbsp;&lt;strong&gt;a JWT moves metadata out of the database and into the token itself, stamped so it cannot be forged.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That single move is the source of everything else in this article. It is why a server can be&amp;nbsp;&lt;strong&gt;stateless&lt;/strong&gt;&amp;nbsp;— verifying a request with only a key it already holds, no roundtrip, no shared session store. It is why JWT scales so gracefully across microservices and travels so freely across domains. And it is — unavoidably — why a JWT cannot be un-issued: a server that gave up its memory for speed has no memory in which to record that one token should now be refused.&lt;/p&gt;

&lt;p&gt;The strength and the flaw are the same fact seen from two sides. An engineer who holds both halves in mind at once understands JWT. One who remembers only the strength will, sooner or later, be surprised by the flaw.&lt;/p&gt;

&lt;h3&gt;
  
  
  Three methods, side by side
&lt;/h3&gt;

&lt;p&gt;This series has now equipped us with three distinct credentials. They are not a ranking — not worse, better, best. They are three tools for three different jobs:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;
&lt;strong&gt;Basic Auth&lt;/strong&gt;&amp;nbsp;(Part I)&lt;/th&gt;
&lt;th&gt;
&lt;strong&gt;API Key&lt;/strong&gt;&amp;nbsp;(Part II)&lt;/th&gt;
&lt;th&gt;
&lt;strong&gt;JWT&lt;/strong&gt;&amp;nbsp;(Part III)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;What it is&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Username + password sent on every request&lt;/td&gt;
&lt;td&gt;A single opaque secret string&lt;/td&gt;
&lt;td&gt;A signed, self-describing token&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Carries its own data?&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No — it's just a reference&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Yes&lt;/strong&gt;&amp;nbsp;— identity, permissions, expiry, all inside&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Server needs a lookup?&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Yes, every request&lt;/td&gt;
&lt;td&gt;Yes, every request&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;No&lt;/strong&gt;&amp;nbsp;— verified statelessly with a key&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Built-in expiry?&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Yes&lt;/strong&gt;&amp;nbsp;— the&amp;nbsp;&lt;code&gt;exp&lt;/code&gt;&amp;nbsp;claim&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Revocable immediately?&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Yes (change the password)&lt;/td&gt;
&lt;td&gt;Yes (delete the key)&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;No&lt;/strong&gt;&amp;nbsp;— valid until it expires&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Best at&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Simple, internal, low-stakes access&lt;/td&gt;
&lt;td&gt;Stable, long-term identity&lt;/td&gt;
&lt;td&gt;Stateless, distributed, short-lived authorization&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Read the table top to bottom and the trade-off snaps into focus. Basic Auth and the API key keep their information in the database — which makes them slower per request, but instantly revocable. JWT carries its information in the token — which makes it fast and stateless, but stubbornly non-revocable.&amp;nbsp;&lt;strong&gt;You cannot have both.&lt;/strong&gt;&amp;nbsp;Every authentication design is, at heart, a choice about where the trust lives: in a central store the server must consult, or in the token the server can read on its own.&lt;/p&gt;

&lt;p&gt;Choosing well means naming that trade-off out loud — not reaching for the most modern-sounding option by reflex, but asking what&amp;nbsp;&lt;em&gt;this&lt;/em&gt;&amp;nbsp;system actually needs, and what it can afford to give up.&lt;/p&gt;

&lt;h3&gt;
  
  
  The one question that remains
&lt;/h3&gt;

&lt;p&gt;And yet, for all three of these methods, notice what they have in common. Basic Auth, the API key, and the JWT all answer the same single question:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;"Who are you?"&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;They are mechanisms of&amp;nbsp;&lt;em&gt;authentication&lt;/em&gt;&amp;nbsp;— proving identity. Each one assumes the party presenting the credential is the party it belongs to, acting on its own behalf.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ffya2cr2sssedz2ls8yvq.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ffya2cr2sssedz2ls8yvq.png" alt="Figure 14. Authentication vs. Authorization" width="800" height="456"&gt;&lt;/a&gt; Figure 14. Authentication vs. Authorization&lt;/p&gt;

&lt;p&gt;But a great deal of the modern web does not work that way. When you let a photo-printing service reach into your cloud storage, or allow an analytics dashboard to read your calendar, something more delicate is happening. You are granting&amp;nbsp;&lt;em&gt;a third party&lt;/em&gt;&amp;nbsp;access to&amp;nbsp;&lt;em&gt;your&lt;/em&gt;&amp;nbsp;resources — and you would never dream of handing them your password to do it.&lt;/p&gt;

&lt;p&gt;None of the three tools in this series solves that. A JWT can prove&amp;nbsp;&lt;em&gt;who you are&lt;/em&gt;&amp;nbsp;beautifully; it has nothing to say about how you safely&amp;nbsp;&lt;em&gt;delegate&lt;/em&gt;&amp;nbsp;a sliver of your access to someone else.&lt;/p&gt;

&lt;p&gt;That problem —&amp;nbsp;&lt;strong&gt;how to grant a third party limited access to your resources without ever sharing your credentials&lt;/strong&gt;&amp;nbsp;— is precisely the problem the next and final part of this series was built around. It is the problem of&amp;nbsp;&lt;em&gt;authorization&lt;/em&gt;&amp;nbsp;and&amp;nbsp;&lt;em&gt;delegation&lt;/em&gt;, and the answer is the framework that quietly underpins nearly every "Sign in with…" button and connected app on the internet:&amp;nbsp;&lt;strong&gt;OAuth 2.0&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;We have spent three articles learning, in ever-greater depth, how to answer&amp;nbsp;&lt;em&gt;who are you?&lt;/em&gt;&amp;nbsp;It is time to ask the harder question.&lt;/p&gt;

</description>
      <category>api</category>
      <category>backend</category>
      <category>security</category>
      <category>tutorial</category>
    </item>
  </channel>
</rss>
