<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
  <title>Dustin VanKrimpen — Undefined Behavior</title>
  <subtitle>Undefined Behavior. A dev blog about the parts of software that aren&#39;t in the docs — full stack development, homelab experiments, working notes, and war stories.</subtitle>
  <link href="https://dustinvk.com/feed.xml" rel="self"/>
  <link href="https://dustinvk.com/"/>
  <updated>2026-08-02T00:00:00Z</updated>
  <id>https://dustinvk.com/</id>
  <author>
    <name>Dustin VanKrimpen</name>
  </author>
  
  
  <entry>
    <title>I Approved the Lockfile That Broke Every Deploy</title>
    <link href="https://dustinvk.com/blog/approved-the-lockfile/"/>
    <updated>2026-08-02T00:00:00Z</updated>
    <id>https://dustinvk.com/blog/approved-the-lockfile/</id>
    <summary>A dependency-pinning change that read as obviously safe. Why hash-locking turned a harmless gap into a total failure, and the question I should have asked in review.</summary>
    <content type="html"><![CDATA[<p>A colleague opened a small PR to harden dependency pinning on a Lambda. It was a real fix for a real bug. I read it, thought &quot;good, this is more rigorous than what we had,&quot; and approved it.</p>
<p>It broke every infrastructure apply in the workspace for two days.</p>
<p>Nobody noticed, because the one thing that would have exercised the change was the one thing our pipeline never ran.</p>
<h2 id="the-bug-it-was-fixing-was-real">The bug it was fixing was real</h2>
<p>The Lambda gets packaged and deployed by Terraform. Its rebuild trigger was a hash of the requirements file:</p>
<pre class="language-hcl"><code class="language-hcl"><span class="token property">triggers_replace</span> <span class="token punctuation">=</span> <span class="token punctuation">{</span>
  <span class="token property">requirements</span> <span class="token punctuation">=</span> filemd5(<span class="token string">"<span class="token interpolation"><span class="token punctuation">$</span><span class="token punctuation">{</span><span class="token keyword">path</span><span class="token punctuation">.</span><span class="token type variable">module</span><span class="token punctuation">}</span></span>/src/requirements.txt"</span>)
<span class="token punctuation">}</span></code></pre>
<p>That looks reasonable until you notice what the requirements file actually pinned. One direct dependency, pinned exactly. Everything underneath it floated.</p>
<p>So the transitive tree could move without the file changing a single byte. A new release of a downstream crypto library would land, the packaged artifact would happily pick it up on the next rebuild, and the trigger would see the same hash it saw last week. No rebuild, no change to <code>source_code_hash</code>, no signal that anything had moved. The trigger was watching a file that could not see the thing it was supposed to be watching.</p>
<p>That is a legitimate problem and worth fixing.</p>
<h2 id="the-fix-that-arrived">The fix that arrived</h2>
<p>The PR did the textbook thing.</p>
<p>It introduced a human-edited source of truth with one line in it, the direct dependency. It compiled that into a fully hash-locked requirements file with <code>uv</code>:</p>
<pre><code>uv pip compile requirements.in \
  --generate-hashes \
  --python-version 3.12 \
  --python-platform x86_64-manylinux2014
</code></pre>
<p>Out came a dozen pinned transitive dependencies, each with its hashes. Then it added <code>--require-hashes</code> to the install step in the build, so nothing could sneak in unpinned.</p>
<p>The PR body made the case cleanly: the trigger now fingerprints the entire dependency tree, so any transitive bump becomes a visible, reviewed diff that forces a rebuild.</p>
<p>Every part of that is correct reasoning. It is also the exact reasoning I would have written myself.</p>
<h2 id="the-failure">The failure</h2>
<p>The build installs with <code>pip</code>, not <code>uv</code>:</p>
<pre><code>pip3 install \
  --platform manylinux2014_x86_64 \
  --only-binary=:all: \
  --python-version 3.12 \
  --require-hashes \
  -r requirements.txt \
  -t ./build
</code></pre>
<p>Here is what it does now, every time:</p>
<pre><code>ERROR: In --require-hashes mode, all requirements must have their
versions pinned with ==. These do not:
    typing-extensions&gt;=4.13.2 (from cryptography==49.0.0)
</code></pre>
<p>The lockfile pins <code>cryptography</code>. <code>cryptography</code> requires <code>typing-extensions</code>. <code>typing-extensions</code> is not in the lockfile. It never was. It does not appear anywhere in the file, and <code>git blame</code> confirms the pin that requires it was there in the original lockfile commit, so this was not a later dependency bump knocking things loose. The lockfile was incomplete from birth.</p>
<p><code>--require-hashes</code> refuses to fetch anything that was not pre-pinned and pre-hashed. So the install dies, the zip never gets built, <code>source_code_hash</code> never moves, and the apply exits non-zero. Not just for that Lambda. The whole workspace apply fails.</p>
<p>Read that against the goal one more time. The PR set out to stop transitive drift from silently skipping rebuilds. What it shipped was a build that loudly fails and <em>still</em> never rebuilds. Same stale artifact, worse blast radius.</p>
<h2 id="root-cause-one-lockfile-two-resolvers">Root cause: one lockfile, two resolvers</h2>
<p>The lockfile was generated by <code>uv</code> and installed by <code>pip</code>.</p>
<p>For the same target, Python 3.12 on manylinux2014, <code>uv</code>'s computed transitive closure excluded <code>typing-extensions</code>. <code>pip</code>'s install-time resolution for that same target includes it. Two tools, same inputs, different answers about what the dependency tree contains. Why they diverge at that level, I didn't reproduce, and it doesn't change what broke next.</p>
<p>Under normal conditions that disagreement is invisible and harmless. <code>pip</code> would notice <code>typing-extensions</code> was missing, download a compatible version, and move on. Slightly unpinned, completely working, nobody ever finds out.</p>
<p><code>--require-hashes</code> is what turned that into a wall.</p>
<p>This is the part I keep chewing on. The strictness was the whole point. It is what guarantees you get the bytes you reviewed and nothing else. And it is precisely what converted a benign resolver disagreement into a total, unrecoverable build failure. The hardening did not fail to protect us. The hardening is the mechanism by which we got hurt.</p>
<p>A lockfile is only complete relative to the tool that produced it. &quot;Fingerprints the entire dependency tree&quot; quietly assumed <code>uv</code>'s tree and <code>pip</code>'s install-time tree were the same tree. They were not, and <code>--require-hashes</code> made that assumption load-bearing.</p>
<h2 id="why-it-survived-review">Why it survived review</h2>
<p>Three misses, and I own the middle one.</p>
<p><strong>CI never ran it.</strong> The pipeline builds the application, tests it, and deploys it. It does not run <code>terraform apply</code>, and it does not run the Lambda's packaging step either. Infrastructure is applied by hand. So a lockfile that cannot install is completely invisible to CI. The one artifact that would have caught this in thirty seconds is not in the pipeline at all. If the only thing that exercises a build step is a manual command someone runs locally, that build step has no test. It has a habit.</p>
<p><strong>I approved it.</strong> The honest version is not that I skimmed it. I read it. The problem is that the diff looked <em>more</em> rigorous than what it replaced. Hashes. Exact pins. A dedicated source-of-truth file. An added strictness flag. Every visible property of that change signaled care, and I graded the change on the properties I could see instead of the one that mattered: does the resulting file install?</p>
<p>That is the trap. Hardening changes come pre-loaded with the appearance of correctness. A diff that adds <code>--require-hashes</code> reads as unambiguously safer than one that removes it, and the reviewer's instinct is to nod. I never asked whether the artifact had been run.</p>
<p><strong>The AI reviewer caught the cosmetics.</strong> Copilot did several passes on this PR. It flagged a comment mentioning the wrong library and got it corrected. It suggested adding the source file to the trigger list, which we declined for reasonable cause. It noticed that <code>uv</code> spells the platform <code>x86_64-manylinux2014</code> while <code>pip</code> spells it <code>manylinux2014_x86_64</code>, called that easy to mix up, and its autofix committed a note about the spelling.</p>
<p>On the lockfile itself: reviewed three of three files, generated no new comments.</p>
<p>So it observed, in writing, that <code>uv</code> and <code>pip</code> are two different tools with two different spellings for the same target. Then it fixed the spelling and shipped. It flagged the surface symptom of the exact bug class that was sitting three lines below in the same diff. I do not think that is a dunk on the tool so much as a good illustration of what it is currently good at: it reads diffs, not artifacts. Neither did I.</p>
<h2 id="what-actually-fixes-it">What actually fixes it</h2>
<p><strong>Immediate.</strong> Regenerate the lockfile and then prove it installs before merging. The gap here was never &quot;forgot to regenerate.&quot; It was &quot;never verified the generated file installs.&quot; Those are different failures and only one of them is caught by regenerating more carefully.</p>
<p><strong>Structural.</strong> Do not generate with one resolver and install with another under <code>--require-hashes</code>. Either install with <code>uv</code> as well, or compile with <code>pip-tools</code> so that generation and installation share <code>pip</code>'s resolver. One resolver, end to end. The moment you have two, the lockfile is a claim about a tree that the installer may not agree exists.</p>
<p><strong>CI.</strong> Run the packaging step in the pipeline. Not the apply, just the install into a throwaway directory:</p>
<pre><code>pip install --require-hashes -r requirements.txt -t /tmp/verify
</code></pre>
<p>Ten seconds. It converts &quot;a colleague's apply fails two days from now&quot; into &quot;this PR is red.&quot; More broadly, if apply is manual, the parts of the infrastructure that build things should still be exercised automatically.</p>
<h2 id="the-thing-i-took-away">The thing I took away</h2>
<p>A lockfile that does not install is worse than a floating dependency. A floating dependency is a known, bounded risk that you can reason about. A broken lockfile is a landmine dressed as rigor, and it gets waved through review precisely because it is wearing the costume.</p>
<p><code>--require-hashes</code> is only as safe as the lockfile is complete. And &quot;it compiled&quot; is not &quot;it installs.&quot;</p>
<p>I still think the PR was the right idea. I would approve the same intent again. What I would do differently is ask one question I did not ask, the same question that turns out to matter for most build tooling: <em><strong>has anyone actually run this, or does it just look correct?</strong></em></p>
]]></content>
  </entry>
  
  
  <entry>
    <title>zram Ignored My Config, and systemd Said It Worked</title>
    <link href="https://dustinvk.com/blog/zram-ignored-my-config/"/>
    <updated>2026-07-26T00:00:00Z</updated>
    <id>https://dustinvk.com/blog/zram-ignored-my-config/</id>
    <summary>A vendor default, an already-active device, and why start is not restart.</summary>
    <content type="html"><![CDATA[<p>I configured an 8 GiB zstd zram device on a 16 GB box. Wrote the config, reloaded the daemon, started the service. No errors. Exit code 0.</p>
<p>Then I checked what was actually running:</p>
<pre><code>$ zramctl
NAME       ALGORITHM DISKSIZE DATA COMPR TOTAL STREAMS MOUNTPOINT
/dev/zram0 lzo-rle         4G   4K   64B   12K       6 [SWAP]
</code></pre>
<p>Four gigabytes. lzo-rle. Neither of those is in my config file.</p>
<h2 id="what-zram-is-briefly">What zram is, briefly</h2>
<p>zram is a compressed block device that lives in RAM. Point swap at it and the kernel compresses pages instead of writing them to disk, which gets you meaningfully more usable memory for a small CPU cost. On a machine where adding physical RAM means paying 2026 DDR4 prices, it is cheap insurance.</p>
<p>My box runs a local model on the GPU, and the weights live in VRAM. System RAM is not the biggest concern here, but the OS plus whatever orchestration I stack on top can spike. The server is meant to run unattended, so I wanted headroom.</p>
<h2 id="the-setup">The setup</h2>
<p>On Ubuntu 26.04 the right tool is <code>systemd-zram-generator</code>. Not <code>zram-tools</code>, not <code>zram-config</code>. Those are older packages with different config paths, and having more than one of them installed is a good way to spend an hour editing a file that nothing reads.</p>
<pre class="language-bash"><code class="language-bash"><span class="token function">sudo</span> <span class="token function">apt</span> <span class="token function">install</span> <span class="token parameter variable">-y</span> systemd-zram-generator

<span class="token function">sudo</span> <span class="token function">tee</span> /etc/systemd/zram-generator.conf <span class="token operator">></span>/dev/null <span class="token operator">&lt;&lt;</span><span class="token string">'EOF'
[zram0]
zram-size = min(ram, 8192)
compression-algorithm = zstd
EOF</span>

<span class="token function">sudo</span> <span class="token function">tee</span> /etc/sysctl.d/99-zram.conf <span class="token operator">></span>/dev/null <span class="token operator">&lt;&lt;</span><span class="token string">'EOF'
vm.swappiness = 180
EOF</span>
<span class="token function">sudo</span> <span class="token function">sysctl</span> <span class="token parameter variable">--system</span> <span class="token operator">></span>/dev/null</code></pre>
<p><code>zram-size</code> is evaluated in MiB, so <code>min(ram, 8192)</code> gives an 8 GiB device on a 16 GB machine. <code>zramctl</code> prints that as <code>8G</code>.</p>
<p><code>vm.swappiness = 180</code> looks wrong if you remember 100 being the ceiling. Kernels since 5.8 cap it at 200. Since zram swap is RAM speed, biasing the kernel toward evicting pages into compressed RAM instead of reclaiming page cache is a good trade. 150 works about as well. The difference is noise.</p>
<h2 id="the-gotcha">The gotcha</h2>
<p>The package ships a vendor default that activates a 4 GB lzo-rle device at install time. That happens the moment <code>apt</code> finishes, which is before your config file exists.</p>
<p>So by the time you write <code>/etc/systemd/zram-generator.conf</code>, there is already a live zram0. And <code>systemctl start</code> on an already-active unit does nothing at all. It does not re-read your config, it does not rebuild the device, and it does not complain. It returns success, because from systemd's point of view the unit is running and that is exactly what you asked for.</p>
<p>The fix is one word:</p>
<pre class="language-bash"><code class="language-bash"><span class="token function">sudo</span> systemctl daemon-reload
<span class="token function">sudo</span> systemctl restart systemd-zram-setup@zram0.service</code></pre>
<p><code>restart</code> tears the device down and rebuilds it from <code>/etc</code>. Then verify:</p>
<pre class="language-bash"><code class="language-bash">zramctl                        <span class="token comment"># want: /dev/zram0  zstd  8G  [SWAP]</span>
<span class="token function">swapon</span> <span class="token parameter variable">--show</span>                  <span class="token comment"># zram0 at priority 100, disk swapfile as low-priority tail</span>
<span class="token function">cat</span> /proc/sys/vm/swappiness    <span class="token comment"># 180</span></code></pre>
<p>If your install did not come with an on-disk swapfile, <code>swapon --show</code> will list zram0 alone. That works, but see the caveat below before you leave it that way.</p>
<h2 id="one-caveat">One caveat</h2>
<p>zram is not an OOM guarantee. A single runaway process can blow through RAM plus zram and still meet the OOM killer. Keep an on-disk swapfile at priority -1 as the tail that catches overflow, and add one if your install did not create it. zram sits at priority 100 and fills first, disk only takes what spills past it. For a box running unattended workloads, that layering is worth the five minutes.</p>
<p>Also worth knowing: compressed pages still occupy real RAM. An 8 GiB zram device on a 16 GB machine is not 24 GB of memory. It is 16 GB where the swapped-out portion holds 2 to 3 times what it would uncompressed, depending on how compressible your pages are. Treat the ratio as a bonus, not a budget.</p>
<h2 id="the-part-that-generalizes">The part that generalizes</h2>
<p>The reason this cost me time is not that <code>restart</code> is obscure. It is that <code>start</code> reported success.</p>
<p>Every signal I had said the configuration was applied. The unit was active. The exit code was zero. Nothing in the logs suggested a conflict. The only way to find out the truth was to ask the device what it actually was, rather than asking systemd whether the command had worked.</p>
<p>This keeps happening to me in different costumes. A model that loads into VRAM but quietly runs inference on the CPU. A build that installs cleanly but produces the wrong artifact. In each case there's a green checkmark sitting on top of a wrong answer. The fix is the same every time: go look at the running state.</p>
<p><code>systemctl start</code> answers a question about systemd. <code>zramctl</code> answers a question about your machine. Ask the second one.</p>
]]></content>
  </entry>
  
  
  <entry>
    <title>Using My Local Coding Model From Anywhere</title>
    <link href="https://dustinvk.com/blog/reaching-my-local-llm-from-anywhere/"/>
    <updated>2026-07-25T00:00:00Z</updated>
    <id>https://dustinvk.com/blog/reaching-my-local-llm-from-anywhere/</id>
    <summary>Reaching a headless GPU box from my laptop or my phone, from anywhere, with nothing exposed to the public internet.</summary>
    <content type="html"><![CDATA[<p>In the <a href="/blog/self-hosting-my-own-llm/">last post</a> I got a coding model (<strong>qwen2.5-coder:14b</strong>) running on a repurposed GPU (<strong>Radeon RX 6750 XT 12GB</strong>) that AMD's compute stack officially won't support. Which is great, except for one thing: the box it runs on sits in a corner in my basement. A local model you can only use while physically sitting at the machine it runs on is just inconvenient.</p>
<p>The whole point of a local tier is that it is there when I am. At my laptop on the couch, on my phone waiting in line somewhere, from a coffee shop. This post is the connective tissue, detailing how I reach that headless box from anywhere without punching a single hole in my router.</p>
<h2 id="why-the-obvious-answer-is-the-wrong-one">Why the obvious answer is the wrong one</h2>
<p>Two things I wanted, and they pull against each other:</p>
<ol>
<li>Reach the machine, and its model endpoint, from any of my devices, wherever I am.</li>
<li>Expose nothing to the public internet.</li>
</ol>
<p>The naive way to do (1) is port forwarding: tell your router to send traffic on some port straight to the box. It works, and it flatly violates (2). And it is especially reckless here, because the model endpoint has <strong>no authentication</strong>. Ollama does not ask for a password. Anyone who can reach it can use it. Forwarding that to the open internet means giving your GPU to the first scanner that wanders by.</p>
<p>So port forwarding is out. What I wanted was a way for my own devices to reach each other as if they were on the same local network, no matter what physical network each one is actually on, without anything being visible to anyone else. Keeping everything private, with nothing exposed, is the whole point.</p>
<h2 id="tailscale-a-private-network-that-follows-your-devices-around">Tailscale: a private network that follows your devices around</h2>
<p>The tool I settled on is Tailscale. It is a mesh VPN built on WireGuard. It's not the kind of VPN that hides your traffic from your ISP. Instead, it's the kind that connects <em>your own devices</em> to each other. You install it on each machine, they all join one private network (your &quot;tailnet&quot;), and every device gets a stable address in the <code>100.x</code> range that is reachable from any other device on the tailnet, wherever they physically are.</p>
<p>The clever part is how the connection gets made. A coordination server helps your devices find each other and then gets out of the way. The actual traffic is a direct, end-to-end encrypted, peer-to-peer link. There is no port forwarding, and nothing is exposed to the public internet. The same mechanism carries everything: SSH on port 22, the model endpoint on <code>:11434</code>, and whatever else I run, all over the tailnet. Nothing public.</p>
<p>A few practical notes:</p>
<ul>
<li><strong>The free Personal tier is plenty.</strong> Unlimited devices of your own, up to 6 users. A handful of personal machines is nowhere near any limit, and it costs nothing.</li>
<li><strong>Install it on everything, one account.</strong> The GPU box, the laptop, the phone, all on the same tailnet.</li>
<li><strong>MagicDNS</strong> gives your devices names instead of making you memorize <code>100.x</code> addresses... in theory. See the gotchas.</li>
</ul>
<blockquote>
<p><strong>Note:</strong> For those who would rather not trust Tailscale's coordination server at all, there is an open-source, self-hostable replacement for it called Headscale. I plan on trying it out at some point and documenting my verdict here in the future, so stay tuned!</p>
</blockquote>
<h2 id="two-gotchas">Two gotchas</h2>
<p>This is the part worth documenting, because these are the things that will make someone bounce off Tailscale thinking it's broken when it isn't.</p>
<p><strong>The Mac app can get stuck, and the fix is the CLI build.</strong> The standalone Tailscale Mac app leans on a macOS system extension, and that extension can wedge itself: no login prompt, the VPN toggle throws errors, and reinstalling does nothing. It is a known issue, more likely if you ever had an old App Store install or you're on a newer macOS. This happened to me, unfortunately. The fix that worked was installing the Homebrew CLI build instead (<code>brew install tailscale</code>), which uses the <code>utun</code> interface and sidesteps the system extension entirely. The tradeoff is you lose the menu-bar GUI and drive it from the terminal. Really no problem at all for me. It connects reliably, which is the part that matters.</p>
<p><strong>MagicDNS short names are flaky on that CLI build.</strong> Once you are on the Homebrew CLI client, MagicDNS does not wire into the Mac's resolver as cleanly as the GUI app does. The name you set up may just not resolve. The fix is to use the full MagicDNS name or the raw <code>100.x</code> IP. Slightly annoying, entirely livable once you know it. It's worth noting the MagicDNS names resolved fine on the Android client.</p>
<h2 id="ssh-and-making-it-survive-real-life">SSH, and making it survive real life</h2>
<p>With the tailnet up, SSH into the box is just <!--email_off--><code>ssh you@100.x.x.x</code><!--email_on--> from any of your devices. A few things make it pleasant instead of fiddly:</p>
<ul>
<li><strong>Key auth, no passwords.</strong> Standard <code>ssh-copy-id</code>. If you import your keys from GitHub during the Ubuntu install, this is basically already done.</li>
<li><strong>An SSH config alias</strong> so you can type <code>ssh yourMachine</code> and never think about IPs again. In <code>~/.ssh/config</code>, point <code>Host yourMachine</code> at the raw <code>100.x</code> address. This has a nice side effect: because you hardcoded the IP, the alias works even when MagicDNS is being flaky. It routes around the gotcha above entirely.</li>
<li><strong>tmux for anything long-running.</strong> An SSH session dies when your laptop sleeps or your WiFi drops, and anything running directly in that session dies with it. tmux runs the session <em>on the box</em>, decoupled from your connection, so you start something, detach, close the lid, and reattach later from a different device exactly where you left off. This is load-bearing once you are kicking off unattended work.</li>
<li><strong>mosh for the phone.</strong> Plain SSH drops when your phone roams between WiFi and cellular. mosh survives that, so a mobile session does not die every time you walk out of the building. mosh plus tmux is the mobile combo.</li>
</ul>
<h2 id="exposing-the-model-endpoint-only-to-the-tailnet">Exposing the model endpoint (only to the tailnet)</h2>
<p>Here is the piece specific to the model. By default Ollama binds to <code>127.0.0.1</code>, meaning it only listens to the box itself. To let other devices reach it, you tell it to listen on all interfaces with a systemd drop-in that sets <code>OLLAMA_HOST=0.0.0.0</code>.</p>
<p>That sounds alarming, and it would be, except for what actually holds the boundary. The bind is broad, but <strong>the firewall is the fence.</strong> <code>ufw</code> is set to default-deny incoming, with a single allow rule scoped to the tailnet interface (<code>ufw allow in on tailscale0</code>). So the endpoint listens broadly, but it's only <em>reachable</em> over the tailnet. Nothing on the physical LAN or the public internet can touch it. This matters enormously precisely because the endpoint is unauthenticated: the firewall scoping is the only thing standing between &quot;my private model&quot; and &quot;a GPU for anyone on the internet.&quot; It is not optional.</p>
<h2 id="the-one-command-that-proves-the-whole-chain">The one command that proves the whole chain</h2>
<p>There are three moving parts stacked here: the broad bind, the ufw rule, and Tailscale itself. When something doesn't connect, it is tempting to guess which layer is at fault. Don't guess. Prove it from a real client with one request:</p>
<pre><code>curl http://100.x.x.x:11434/api/tags
</code></pre>
<p>Run that from the laptop, not from the box. If it returns JSON listing your models, the entire chain is proven end to end. The bind is listening, the firewall is allowing tailnet traffic, and Tailscale is carrying it. If it hangs or refuses, you've narrowed it to the network path rather than the model. This is the fastest &quot;is it actually working&quot; check, and I run it before wiring up anything else.</p>
<h2 id="actually-using-it-from-the-laptop">Actually using it from the laptop</h2>
<p>Now the payoff. Anything that speaks to Ollama can be pointed at the box over the tailnet. The <code>ollama</code> CLI and most Ollama-aware tools respect an <code>OLLAMA_HOST</code> environment variable, so on the laptop:</p>
<pre><code>export OLLAMA_HOST=http://100.x.x.x:11434
</code></pre>
<p>and the CLI is now a remote control for the GPU box. Nothing runs locally on the laptop; it is purely a client. For tools that only speak the OpenAI API shape, Ollama also exposes an OpenAI-compatible endpoint at the <code>/v1</code> path, which you point the tool at with any dummy API key.</p>
<p>The reason this works so cleanly is a small architectural point worth internalizing: the model is stateless per request, and &quot;workspace context&quot; is just files you put into the request. So you can gather context on the machine that has your files (the laptop) and send only the model call to the remote GPU. The code and the compute do not have to live in the same place.</p>
<p>In a future post, I will dive deeper into the tooling that you can connect to your local LLM for development. Currently, I've been experimenting with using JetBrains Gateway and Cline.</p>
<h2 id="the-honest-tradeoff-plan-a-way-back-in">The honest tradeoff: plan a way back in</h2>
<p>As configured, SSH into this box rides <em>only</em> the tailnet firewall rule. So if Tailscale fails to come up on boot, the machine is unreachable over the tailnet, and right now that is the main door.</p>
<p>The fix is cheap and does not compromise the posture: add a second <code>ufw</code> rule that allows SSH from your local network, scoped to your subnet (<code>ufw allow from 192.168.x.0/24 to any port 22</code>). That is still not public. Your LAN is not the internet, and you are not touching the router or forwarding any ports. You are just widening the way in from &quot;tailnet only&quot; to &quot;tailnet, or the same physical network.&quot; When Tailscale is down and you are home, you can still walk over to your own network and get in.</p>
<p>The LAN rule only saves you when you are physically on the LAN. If the tunnel dies while you are out, you are still waiting until you get home to console in or reboot. But for a headless box sitting in my house, &quot;I can always reach it from my own network&quot; covers nearly every lockout worth worrying about, and it is a rule that only needs to be set once. The thing to actually avoid is having <em>neither</em> path, tailnet-only with no LAN fallback, on a box you have made a dependency.</p>
<h2 id="where-this-leaves-the-project">Where this leaves the project</h2>
<p>So now the model from post 1 is not stuck in a corner. I can reach it from my laptop or my phone, from anywhere, and point tools at it as if it were local, with nothing exposed to the internet and one curl to prove the whole path. That is the local tier.</p>
<p>The next question is the one that decides whether any of this was worth it: <strong>is the model actually good enough to trust with real software engineering work?</strong> A quantized 14B on a consumer card is capable, but capable enough to hand real tasks to and route around a frontier model? That is a benchmarking post, and it has a twist, because the first numbers I got were badly wrong for reasons that had nothing to do with the model. I will dive deeper into this with my next post.</p>
<p>The bigger goal underneath all of it is the same: a coding agent that does the cheap work locally, for free and off any rate limit, and only spends frontier-model credits when the problem actually earns one. A free tier LLM reachable from anywhere is most of the setup for that. Now I need to know how much of my real work it can actually carry.</p>
<p>That is all for today. Go make the machine in your closet reachable from anywhere.</p>
]]></content>
  </entry>
  
  
  <entry>
    <title>I Gave My Coding Agent A Free Tier</title>
    <link href="https://dustinvk.com/blog/self-hosting-my-own-llm/"/>
    <updated>2026-07-11T00:00:00Z</updated>
    <id>https://dustinvk.com/blog/self-hosting-my-own-llm/</id>
    <summary>Self-hosting a local model on a GPU AMD told me not to use.</summary>
    <content type="html"><![CDATA[<p><em><a href="#tldr">Jump to the TL;DR</a> if you just want the summary of setting it all up minus the story.</em></p>
<h2 id="why-host-a-model">Why host a model?</h2>
<p>I like using the latest frontier class models for the hard stuff. I do not love spending their rate limits on &quot;rename this variable&quot; and &quot;write the obvious boilerplate.&quot; So I decided to host a large language model (LLM) on my own hardware, free and off of rate limits: soak up the easy work locally, save the frontier credits for the work that needs them, and keep my data off some AI corporation's servers while I'm at it. This post is step #1: setting up the hardware.</p>
<h2 id="sourcing-hardware-during-the-rampocalypse">Sourcing hardware during the RAMpocalypse</h2>
<p>2026 is a brutal market for PC components. There's a massive AI-driven shortage of DRAM and GDDR which has made prices skyrocket. As of this post, a 32 GB DDR4 kit that used to cost about $50-$70 is sitting at roughly $200. Rather than get fleeced in a seller's market, I decided to just use hardware that I had lying around.</p>
<p>Enter my gaming PC. I built it back in 2020 during the pandemic restrictions. Back then I had all the free time in the world for gaming. It's mostly been collecting dust since my first son was born in 2022. The perfect candidate for my AI server.</p>
<h3 id="the-specs">The Specs</h3>
<p>Originally the gaming PC had a GTX 1080 carried over from an even earlier build. Around 3 years ago I impulse bought a RX 6750 XT 12GB on a good sale and swapped it in. The RX 6750 XT is a fine mid-level card for 1080p and some 1440p gaming today. However, it is generally not a card that you'll see recommended for running LLMs locally.</p>
<p>That said, it's not a bad card for it either (with some caveats I will get to later). For inference, the most important factor is VRAM, because that's what determines what size of model you can hold. The 12 GB in my RX 6750 XT is enough to hold a real coding model, with extra room for context. I didn't set out to choose AMD over NVIDIA here. It's just what I had.</p>
<p>The rest of the box is nothing special. The CPU is a genuinely dated Ryzen 5 2600, but that's ok because it doesn't need to do much during GPU inference. It has 16 GB of DDR4 RAM. A bit tight since I also want to run orchestration processes on this machine, but it's fine given the sheer redonkulousness of RAM prices in 2026.</p>
<p>I did a fresh install of Ubuntu 26.04 since it's free, lean, fits the tooling, and I plan on mostly using it headless via SSH anyway.</p>
<h3 id="full-spec-table-for-the-curious">Full spec table for the curious:</h3>
<table>
<thead>
<tr>
<th>Part</th>
<th>What I Have</th>
<th>Notes</th>
</tr>
</thead>
<tbody>
<tr>
<td>CPU</td>
<td>Ryzen 5 2600 (Zen+, 6c/12t, 2018)</td>
<td>Adequate for now</td>
</tr>
<tr>
<td>GPU</td>
<td>Radeon RX 6750 XT 12GB (RDNA2, gfx1031)</td>
<td>Fine for now, but I'm keeping an eye out for deals on something like an RX 7900 XTX or RTX 3090 to upgrade</td>
</tr>
<tr>
<td>RAM</td>
<td>16 GB DDR4</td>
<td>Will upgrade when RAM prices come down</td>
</tr>
</tbody>
</table>
<h2 id="the-unsupported-card-problem">The unsupported card problem</h2>
<p>Here is where the caveat of my GPU comes into play. AMD's compute stack is called ROCm. This is their answer to NVIDIA's CUDA. There is a hardcoded list of supported GPU targets for ROCm, and your card has to be on it. My RX 6750 XT reports its chip target as <code>gfx1031</code>, which is not supported. The very similar chip in the 6800 and 6900 series cards is <code>gfx1030</code>, and that one is supported. <code>gfx1031</code>, the 6700 series, is not. Same architecture, one digit off, left off the list.</p>
<blockquote>
<p><strong>Note:</strong>
ROCm's official supported-GPU list changes between releases, and gfx1031 support has been a moving target. Best to check for yourself since things may have changed since the time of this posting.</p>
</blockquote>
<h3 id="the-usual-workaround">The usual workaround</h3>
<p>There is a very simple workaround that a lot of people have reported success with on Linux. It basically tricks ROCm into thinking your card is supported. You install the GPU drivers and ROCm runtime as usual, add your user account to the video and render groups, and then spoof the GFX version to <code>10.3.0</code> like so:</p>
<pre class="language-bash"><code class="language-bash"><span class="token builtin class-name">export</span> <span class="token assign-left variable">HSA_OVERRIDE_GFX_VERSION</span><span class="token operator">=</span><span class="token number">10.3</span>.0</code></pre>
<h3 id="vulkan-the-alternative">Vulkan: the alternative</h3>
<p>Vulkan is a graphics and compute API that these cards support natively with no ROCm needed. I am using Ollama to serve models, which runs inference on the Vulkan backend no problem. The main downside is it's inference-only, meaning you can't fine-tune or train models. Since I'm only trying to serve an existing model for now, it's a non-issue for me.</p>
<blockquote>
<p><strong>Note:</strong> It's not that Vulkan is inherently incapable of training/tuning models. It's just that the typical training stack targets CUDA/ROCm.</p>
</blockquote>
<h3 id="comparison-rocm-workaround-vs-vulkan-rx-6750-xt-gfx1031">Comparison: ROCm workaround vs. Vulkan (RX 6750 XT / gfx1031)</h3>
<table>
<thead>
<tr>
<th>Factor</th>
<th>ROCm (via <code>HSA_OVERRIDE_GFX_VERSION=10.3.0</code>)</th>
<th>Vulkan</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>Setup</strong></td>
<td>Needs the override env var to spoof the GPU target</td>
<td>Natively supported, no workaround</td>
</tr>
<tr>
<td><strong>Reliability</strong></td>
<td>Fragile: can fail to init; updates can silently break it</td>
<td>Stable; part of Mesa, survives kernel updates</td>
</tr>
<tr>
<td><strong>Fine-tuning / training</strong></td>
<td>Supported; the ML-native path (PyTorch-ROCm, vLLM)</td>
<td>Unsupported; inference only</td>
</tr>
<tr>
<td><strong>Tooling</strong></td>
<td>Richer (<code>rocm-smi</code> for monitoring)</td>
<td>Lighter (<code>radeontop</code>)</td>
</tr>
</tbody>
</table>
<h3 id="verdict">Verdict</h3>
<p>For my use case (a mostly-headless inference box), Vulkan wins, and I'm not giving up much for it. The old assumption that ROCm is meaningfully faster has largely eroded: recent benchmarks put ROCm anywhere from ~10–20% ahead (mostly on prompt processing) to level with or behind Vulkan. On RDNA2 in particular, the generation the Vulkan backend was first tuned on, the two are close enough that speed isn't the deciding factor.</p>
<p>The goal of this project is to make AI-assisted workflows easier for me. I want to spend my time building cool things, not debugging why the latest update broke my hacky workaround. Vulkan just works, and keeps working across driver updates, which is what I want for an always-on server.</p>
<h2 id="setup">Setup</h2>
<p>I started with a clean slate. A fresh install of Ubuntu 26.04 LTS Desktop. I went with desktop because I do have a monitor attached (set up at a desk in my basement), and will occasionally work from it directly using IDEs and such. But most of the time it will be connected to and used headless via my MacBook Air.</p>
<h3 id="step-1-the-vulkan-graphics-stack">Step 1: The Vulkan graphics stack</h3>
<p>Update the system and install the Vulkan userspace drivers and a couple of
diagnostic tools:</p>
<pre class="language-bash"><code class="language-bash"><span class="token function">sudo</span> <span class="token function">apt</span> update <span class="token operator">&amp;&amp;</span> <span class="token function">sudo</span> <span class="token function">apt</span> full-upgrade <span class="token parameter variable">-y</span>
<span class="token function">sudo</span> <span class="token function">apt</span> <span class="token function">install</span> <span class="token parameter variable">-y</span> mesa-vulkan-drivers vulkan-tools radeontop</code></pre>
<p>Confirm the card is visible to Vulkan:</p>
<pre class="language-bash"><code class="language-bash">vulkaninfo <span class="token operator">|</span> <span class="token function">grep</span> <span class="token parameter variable">-i</span> deviceName
<span class="token comment"># should print "AMD Radeon RX 6750 XT" (mine shows RADV NAVI22)</span></code></pre>
<p><code>radeontop</code> is the tool we'll use later to prove the GPU is actually doing the
math, so it's worth installing now.</p>
<h3 id="step-2-install-ollama-and-pull-a-model">Step 2: Install Ollama and pull a model</h3>
<p>Ollama is the easiest way to serve a local model. Install it with one line:</p>
<pre class="language-bash"><code class="language-bash"><span class="token function">curl</span> <span class="token parameter variable">-fsSL</span> https://ollama.com/install.sh <span class="token operator">|</span> <span class="token function">sh</span></code></pre>
<p>On this hardware Ollama installs as a systemd service. During the first run, it
resolves the Vulkan backend, drops the unsupported ROCm device, and
loads onto the discrete RX 6750 XT with ~11.6 GiB of usable VRAM.</p>
<blockquote>
<p><strong>Note:</strong> When Ollama starts, it will show a warning that it dropped the ROCm device. This is expected on a <code>gfx1031</code> card. It realized that the GPU doesn't support ROCm and fell back to Vulkan. That's what you want.</p>
</blockquote>
<h4 id="picking-a-model-for-12gb-of-vram">Picking a model for 12GB of VRAM</h4>
<p>Now that Ollama is set up, it's time to choose a model for it to serve. This is where the VRAM budgeting gets real.</p>
<p>My VRAM limitation rules out the current flagships: qwen3-coder:30b (~19GB), Codestral 22B (~13GB), and anything 24B+ dense. What's left is still a solid field of 7–16B coders.</p>
<table>
<thead>
<tr>
<th>Model</th>
<th>Params</th>
<th>~Q4 size</th>
<th>Context</th>
<th>Trade-off</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>qwen2.5-coder:14b</strong> <em>(chosen)</em></td>
<td>14.8B dense</td>
<td>~9GB</td>
<td>32K</td>
<td>Best dense code quality in the tier; native tool-calling + fill-in-the-middle. Non-reasoning, so it's fast.</td>
</tr>
<tr>
<td>qwen2.5-coder:7b</td>
<td>7B dense</td>
<td>~4.7GB</td>
<td>32K</td>
<td>Same family, more headroom/speed, lower ceiling.</td>
</tr>
<tr>
<td>qwen3:14b</td>
<td>14.8B dense</td>
<td>~9GB</td>
<td>32K+</td>
<td>Strong generalist that codes well, but not code-specialized.</td>
</tr>
<tr>
<td>deepseek-coder-v2:16b</td>
<td>16B MoE (2.4B active)</td>
<td>~8.9GB</td>
<td>160K</td>
<td>Fastest option (MoE) and by far the largest context. Weaker at multi-step tool use.</td>
</tr>
<tr>
<td>deepseek-r1-distill-qwen:14b</td>
<td>14B dense (reasoning)</td>
<td>~9GB</td>
<td>32K</td>
<td>Thinks step-by-step. Great for hard debugging, slower for everyday generation.</td>
</tr>
</tbody>
</table>
<p>I picked it because this box is intended to be the <em>worker tier</em> of a two-tier setup: easy tasks run locally, hard ones escalate to externally hosted models. I wanted the fastest, most reliable, tool-capable code specialist that fits. If I need a higher-reasoning model, I reach for an externally hosted 'frontier-class' one.</p>
<h4 id="pulling">Pulling</h4>
<p>With the model chosen, pulling it is one line:</p>
<pre class="language-bash"><code class="language-bash">ollama pull qwen2.5-coder:14b-instruct-q4_K_M</code></pre>
<p>Quick smoke test:</p>
<pre class="language-bash"><code class="language-bash">ollama run qwen2.5-coder:14b-instruct-q4_K_M <span class="token string">"write a python function that reverses a string"</span></code></pre>
<p>If it answers, the model runs. However, that does <strong>not</strong> prove it's using the
GPU. That part is next.</p>
<h3 id="step-3-prove-its-actually-on-the-gpu-not-silently-on-the-cpu">Step 3: Prove it's actually on the GPU (not silently on the CPU)</h3>
<p>It's possible for Ollama to discover your GPU, load the weights into VRAM, and still run the compute on CPU.
The logs will look normal, VRAM will show occupied, and the tokens will trickle while your CPU melts.</p>
<p>To verify the GPU compute, I used <code>radeontop</code>. Open it in one terminal like so:</p>
<pre class="language-bash"><code class="language-bash">radeontop</code></pre>
<p>Then, in a second terminal, fire off a prompt long enough to sustain load for several seconds:</p>
<pre class="language-bash"><code class="language-bash">ollama run qwen2.5-coder:14b-instruct-q4_K_M <span class="token punctuation">\</span>
  <span class="token string">"write a detailed, heavily commented python implementation of a red-black tree with insert, delete, and search"</span></code></pre>
<blockquote>
<p>A tmux tip: split the terminal (<code>Ctrl-b %</code>), run <code>radeontop</code> on the left and
your <code>ollama run</code> on the right, and you can literally watch the GPU spike as it
&quot;thinks.&quot; It's oddly satisfying.</p>
</blockquote>
<p>Now watch <code>radeontop</code> while tokens are streaming. What you're looking for:</p>
<table>
<thead>
<tr>
<th>Metric</th>
<th>Idle (between generations)</th>
<th>Active (mid-generation)</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>Graphics pipe</strong></td>
<td>~1–2%</td>
<td>jumps high (<strong>~93%</strong> on mine)</td>
</tr>
<tr>
<td><strong>Shader Clock</strong></td>
<td>~0.4%</td>
<td>climbs toward max (<strong>~90%</strong>)</td>
</tr>
<tr>
<td><strong>VRAM</strong></td>
<td>~10.3 / 12.2 GB</td>
<td>same (model stays resident)</td>
</tr>
</tbody>
</table>
<p>The signal is the <strong>transition</strong>: idle → active → idle, synced to token output.</p>
<blockquote>
<p><strong>Note:</strong> Watch Graphics pipe and Shader Clock, not VRAM. The weights can be in VRAM while the math runs on the CPU. You have to watch during streaming. The compute drops to zero as soon as generation ends.</p>
</blockquote>
<blockquote>
<p><strong>On the VRAM numbers:</strong> Ollama reports usable VRAM in <strong>GiB</strong> (it logged ~11.6 GiB available), while <code>radeontop</code> reads out in <strong>GB</strong> using its own accounting. The two don't line up exactly.</p>
</blockquote>
<p>On my box, Graphics pipe hit ~93% and Shader Clock ~90% during generation, then fell to 0% and ~0.11% after. That confirms GPU inference
over Vulkan. If your compute stays flat while tokens are streaming, that indicates CPU fallback, so you'll need to do some debugging.</p>
<figure>
<img src="/files/posts/hosting-my-own-llm/gpu-compute-validation.png"
        alt="Verifying GPU compute" loading="lazy" width="800" height="718">
<figcaption>GPU compute verified.</figcaption>
</figure>
<h3 id="step-4-the-context-window-decision-8k-on-purpose">Step 4: The context-window decision (8K, on purpose)</h3>
<p>Ollama picks a default context length for you, but I wanted to set it explicitly
because context costs VRAM. A bigger context window means a bigger KV cache
sitting in memory alongside the weights. On a 12 GB card, that budget is tight, so
I capped the default served context at 8K, which gives ~10.3 GB resident
with headroom to spare (even though Qwen2.5-Coder's native context is 32K).</p>
<p>I set it with a systemd drop-in, so I'm not editing Ollama's packaged unit file:</p>
<pre class="language-bash"><code class="language-bash"><span class="token function">sudo</span> <span class="token function">mkdir</span> <span class="token parameter variable">-p</span> /etc/systemd/system/ollama.service.d
<span class="token function">sudo</span> <span class="token function">tee</span> /etc/systemd/system/ollama.service.d/override.conf <span class="token operator">></span>/dev/null <span class="token operator">&lt;&lt;</span><span class="token string">'EOF'
[Service]
Environment="OLLAMA_CONTEXT_LENGTH=8192"
EOF</span>

<span class="token function">sudo</span> systemctl daemon-reload
<span class="token function">sudo</span> systemctl restart ollama</code></pre>
<p>Verify it took:</p>
<pre class="language-bash"><code class="language-bash">systemctl show ollama <span class="token parameter variable">-p</span> Environment --no-pager   <span class="token comment"># echoes OLLAMA_CONTEXT_LENGTH=8192</span></code></pre>
<p>8K is a fine default for most coding prompts. If a specific task needs
more, you don't have to change the service. You can override context per
request:</p>
<pre class="language-bash"><code class="language-bash"><span class="token function">curl</span> http://localhost:11434/api/generate <span class="token parameter variable">-d</span> <span class="token string">'{
  "model": "qwen2.5-coder:14b-instruct-q4_K_M",
  "prompt": "...",
  "stream": false,
  "options": { "num_ctx": 16384, "temperature": 0.2 }
}'</span></code></pre>
<p>Raise <code>num_ctx</code> for the one long-context call that needs it. Don't leave it high
globally or you'll pay the VRAM tax on every request.</p>
<p>A couple of other handy per-request knobs:</p>
<ul>
<li><code>temperature</code> 0.1–0.3 for deterministic code, higher for brainstorming</li>
<li><code>keep_alive</code> pass <code>-1</code> to pin the model resident when you're about to hammer it (by default it unloads after 5 idle minutes and the next request eats a few-second reload).</li>
</ul>
<p>Ollama also exposes an OpenAI-compatible API at <code>http://localhost:11434/v1</code>.
Any tool compatible with &quot;OpenAI API, custom base
URL&quot; can point at it locally with a dummy key. The server is stateless. It takes
the full message history on every call, so your client holds the conversation.</p>
<p>I also set up <a href="/blog/zram-ignored-my-config/">zram</a> as a compressed-RAM cushion for the orchestration I plan to stack on top, since 16 GB is tight.</p>
<h2 id="tldr">TL;DR</h2>
<p>The cliffnotes on setting up your own self-hosted LLM server:</p>
<ol>
<li><strong>Install an LTS Linux</strong> (Ubuntu 24.04/26.04) with a recent kernel for your GPU.</li>
<li><strong>Check your GPU's compute support.</strong> AMD: is your <code>gfx</code> target on ROCm's list?
If not (e.g. RX 6700-series gfx1031), plan on <strong>Vulkan</strong>:
<code>sudo apt install mesa-vulkan-drivers vulkan-tools radeontop</code>.</li>
<li><strong>Install Ollama:</strong> <code>curl -fsSL https://ollama.com/install.sh | sh</code>.</li>
<li><strong>Pull a model that fits your VRAM.</strong> 12 GB → a 14B at Q4_K_M (~10 GB). Don't
try to cram a 30B into 12 GB.</li>
<li><strong>Verify GPU compute</strong> with <code>radeontop</code> <em>during</em> generation. Watch Graphics
pipe / Shader Clock, not VRAM. Discovery ≠ compute.</li>
<li><strong>Set the context ceiling</strong> with a systemd drop-in
(<code>OLLAMA_CONTEXT_LENGTH=8192</code>) to keep the KV cache inside your VRAM budget;
override per request with <code>num_ctx</code> when needed.</li>
</ol>
<h2 id="next-steps">Next steps</h2>
<p>This box is the worker tier of a two-tier setup: the easy work runs here, the hard work escalates to a frontier model. <a href="/blog/reaching-my-local-llm-from-anywhere/">Reaching it from anywhere without exposing anything to the internet</a> is the next post. Whether a quantized 14B is actually good enough to trust is the one after.</p>
<h2 id="final-notes">Final notes</h2>
<p>For now, I am happy to have my own free model running on a mid-level card that I didn't have to pay 2026 prices for. You don't need the latest, most expensive hardware to run a real and useful model at home. All you need is something with enough VRAM. It doesn't even have to be on AMD's supported list.</p>
<p>Go and check out what your idle GPU could be doing instead.</p>
]]></content>
  </entry>
  
  
  <entry>
    <title>Keeping a Record of Why</title>
    <link href="https://dustinvk.com/blog/keeping-a-record-of-why/"/>
    <updated>2026-06-23T00:00:00Z</updated>
    <id>https://dustinvk.com/blog/keeping-a-record-of-why/</id>
    <summary>A Claude Code skill for capturing technical decisions during working sessions and querying them later, so the reasoning behind a codebase does not live only in people&#39;s heads.</summary>
    <content type="html"><![CDATA[<p><em>Updated 2026-06-25: the plugin was renamed to wherefore and gained the resolve and supersede skills since this was first published.</em></p>
<p>Three people hashed out the API structure in a meeting back in March. The conclusion is in their heads. Nowhere else.</p>
<p>This happens constantly in software teams. Some decisions make it into a Jira ticket, a PR description, or an ADR that someone
spent an hour writing and that nobody will read again. Most don't. A few months later, someone asks why the thing is built the way
it is, and the honest answer is that you weren't in the room.</p>
<p>The meetings aren't the problem. The reasoning disappearing afterward is.</p>
<p>I built a Claude Code skill to capture this: a minimal decision log that lives in the project repository, gets populated during
working sessions with Claude, and can be queried later when someone needs to know why. Open and self-hosted at <a href="http://github.com/DustinVK/wherefore">github.com/DustinVK/wherefore</a>.</p>
<p style="text-align:center;margin:2.5rem 0;"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 520 84" style="width:380px;max-width:88%;height:auto;display:inline-block;" role="img" aria-label="wherefore">
  <defs>
    <style>
      @import url('https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@600&amp;display=swap');
      .wf-text { font-family: 'Space Grotesk', sans-serif; font-size: 64px; font-weight: 600; letter-spacing: -1.28px; fill: currentColor; }
      .wf-dot  { fill: #4ab5b7; }
      [data-theme="light"] .wf-dot { fill: #258a84; }
    </style>
  </defs>
  <circle class="wf-dot" cx="19" cy="33" r="7"/>
  <circle class="wf-dot" cx="37" cy="33" r="7"/>
  <circle class="wf-dot" cx="28" cy="51" r="7"/>
  <text class="wf-text" x="60" y="60">wherefore</text>
</svg></p>
<h2 id="four-skills-and-a-command">Four skills and a command</h2>
<p>The plugin ships four skills and a setup command.</p>
<p><strong>capture</strong> logs a decision from a discussion. <strong>ask</strong> retrieves prior context: invoke it when you're about to touch a system and want to know what was decided there before. <strong>resolve</strong> closes an open question left hanging in an earlier entry, recording the answer and rationale. <strong>supersede</strong> retires a past decision after the fact, marking it obsolete or forwarding to a replacement, without needing a full new session.</p>
<p>Capture and ask have different triggers: you invoke capture at the end of a session where something got decided, ask when you're about to touch a system and want prior context. Neither tries to do the other's job.</p>
<p>If you use a <code>/grill-me</code> style workflow to stress-test a decision before committing to it, <code>capture</code> is a natural next step. The grilling surfaces what was considered and rejected; the log preserves that output in a form you can actually find later.</p>
<h2 id="what-an-entry-looks-like">What an entry looks like</h2>
<p>The goal is not a transcript. A transcript of a technical discussion is 4,000 words of context-switching and tangents that resolves, somewhere near the end, into a paragraph of actual decisions. The entry should be that paragraph.</p>
<p>Each log file captures four things:</p>
<ul>
<li><strong>What was decided</strong> and the outcome.</li>
<li><strong>Why</strong> that choice was made.</li>
<li><strong>What was rejected</strong> and why each option was ruled out.</li>
<li><strong>Open questions</strong> left unresolved, if any.</li>
</ul>
<p>A typical entry is under 40 lines. Frontmatter carries the metadata; freeform markdown carries the content. Here is the shape of one:</p>
<pre class="language-markdown"><code class="language-markdown"><span class="token front-matter-block"><span class="token punctuation">---</span>
<span class="token front-matter yaml language-yaml">date: 2026-06-22
title: OAuth token encryption — KMS-direct, SSM for app credentials
areas: [ingestion, credentials]
topics: [security, kms, oauth]
status: active</span>
<span class="token punctuation">---</span></span>

<span class="token title important"><span class="token punctuation">##</span> Summary</span>
Per-location GBP refresh tokens are stored as KMS-encrypted ciphertext in the
<span class="token code-snippet code keyword">`locations`</span> table. App-level OAuth credentials (client ID + secret) live in SSM
Parameter Store. No envelope encryption; no cross-invocation credential caching.

<span class="token title important"><span class="token punctuation">##</span> Decisions</span>
<span class="token list punctuation">-</span> <span class="token bold"><span class="token punctuation">**</span><span class="token content">Per-location refresh tokens:</span><span class="token punctuation">**</span></span> using <span class="token code-snippet code keyword">`KMS.Encrypt`</span> / <span class="token code-snippet code keyword">`KMS.Decrypt`</span> with encryption context <span class="token code-snippet code keyword">`{"location_id": id}`</span>.
  One KMS decrypt per job invocation; no data-key caching.
<span class="token list punctuation">-</span> <span class="token bold"><span class="token punctuation">**</span><span class="token content">App credentials (client ID/secret):</span><span class="token punctuation">**</span></span> SSM Parameter Store SecureString, fetched
  at cold start. Free, KMS-backed at rest, no per-secret monthly fee.
<span class="token list punctuation">-</span> <span class="token bold"><span class="token punctuation">**</span><span class="token content">Access-token caching:</span><span class="token punctuation">**</span></span> cache the short-lived access token in <span class="token code-snippet code keyword">`sync.Map`</span>; never
  cache the decrypted refresh token. Cache hits transitively skip the KMS call.

<span class="token title important"><span class="token punctuation">##</span> Why</span>
Postgres disk encryption protects a stolen disk, not anyone who can read the table —
a refresh token is effectively a long-lived password and must be encrypted at rest
behind a real key boundary. Co-locating the ciphertext with the location row is the
correct data model. Secrets Manager would charge $0.40/location/month for rotation
we don't use. KMS-direct costs ~$1–3/month at hundreds of locations.

Access-token caching (not refresh-token caching) gives the best risk/reward profile:
a ~1h self-expiring credential sits in process memory; the refresh token is decrypted
transiently per job and discarded. Caching the refresh token would hold the long-lived
credential for the container's entire lifetime — maximum blast radius — to save cents.

<span class="token title important"><span class="token punctuation">##</span> Alternatives considered</span>
<span class="token bold"><span class="token punctuation">**</span><span class="token content">Envelope encryption (AWS Encryption SDK or hand-rolled AES-GCM):</span><span class="token punctuation">**</span></span> envelope helps
when payloads exceed KMS's 4KB limit or when one data key is amortized across a batch.
Neither applies: a refresh token is a few hundred bytes; the worker processes exactly
one location per invocation. Envelope would make the same single KMS call plus an
AES-GCM nonce-management layer on top — strictly more code, zero savings.

<span class="token bold"><span class="token punctuation">**</span><span class="token content">Secrets Manager per location:</span><span class="token punctuation">**</span></span> correct security model, but $0.40/secret/month scales
linearly with location count. Rejected in favor of encrypted column.

<span class="token bold"><span class="token punctuation">**</span><span class="token content">Cached data key across warm invocations:</span><span class="token punctuation">**</span></span> holds a plaintext key that unlocks every
location's token in memory for the container's lifetime. Rejected — maximum blast radius
to save a sub-$3/month KMS bill.

<span class="token title important"><span class="token punctuation">##</span> Open questions</span>
None. The KMS CMK is wired via <span class="token code-snippet code keyword">`sst.Linkable`</span> (couples ARN injection with the IAM
grant so they can't drift); the Go implementation of the decrypt path is pending <span class="token code-snippet code keyword">`gbp.go`</span>.</code></pre>
<p>The skill instructs Claude to extract decisions and reasoning and drop everything else. If nothing was actually decided in a session, open questions go under that heading rather than manufacturing a conclusion that was not there.</p>
<h2 id="the-tagging-system">The tagging system</h2>
<p>Entries are tagged along two dimensions: <strong>areas</strong> and <strong>topics</strong>.</p>
<p>Areas are feature slices or product domains. The what. Something like order-process or user-auth or reporting. Topics are
cross-cutting technical concerns. The how. Something like performance or caching or third-party-integration.</p>
<p>The distinction matters for retrieval. If someone asks what decisions were made about caching, they want everything tagged with
the topic caching, regardless of which feature it touches. &quot;What about the checkout flow?&quot; lands differently: everything in the
area checkout, whatever the technical concern. The two dimensions let you slice it either way.</p>
<p>The controlled vocabulary is the part that actually makes this work. Early in a project, you populate a <a href="http://topics.md">topics.md</a> file with the
tags that matter for your codebase. From that point on, the skill reuses existing tags rather than inventing new ones. This sounds
like a minor detail. It is not. Uncontrolled tagging fragments search results across synonyms and near-duplicates within a few
months. The plugin includes a <code>/wherefore:seed</code> command that bootstraps the vocabulary from your codebase structure, so you're not
starting from a blank page.</p>
<h2 id="when-a-decision-changes">When a decision changes</h2>
<p>The status system handles supersession. When a later discussion reverses an earlier decision, the new entry records <code>supersedes: YYYY-MM-DD-old-slug</code> and the old entry gets updated with <code>status: superseded</code> and a pointer forward.</p>
<p>Both entries stay in the log. The old decision still happened, and the reasoning behind it is still worth keeping. Sometimes you need to know not just what the current approach is, but that the original one was tried and why it was abandoned. The chain gives you that.</p>
<p>The <code>supersede</code> skill handles this directly: point it at a past entry and it marks the entry superseded or obsolete without requiring a new discussion session. Obsolete differs from superseded: obsolete means the decision no longer applies and there is no replacement, just a change in direction.</p>
<h2 id="installing-and-using-it">Installing and using it</h2>
<p>Two commands, then restart Claude Code:</p>
<!--email_off-->
<pre><code>/plugin marketplace add DustinVK/wherefore
/plugin install wherefore@dustinvk
</code></pre>
<!--email_on-->
<p>The plugin ships its own tooling. The log itself lives in each project repository, created on first use.</p>
<h3 id="first-time-setup">First-time setup</h3>
<p><strong>Bootstrap your tag vocabulary.</strong> Rather than inventing area and topic tags from scratch, run:</p>
<pre><code>/wherefore:seed
</code></pre>
<p>Claude inspects your codebase, module layout, routes, dependency manifests, migrations, and proposes a starter set with a short justification for each tag. Confirm, edit, or trim the list and it writes <code>wherefore/topics.md</code>. Good tags are coarse and stable: <code>checkout</code>, <code>billing</code>, <code>auth</code>, <code>postgres</code>. Not <code>price-rounding-edge-case</code>.</p>
<p><strong>Wire up the trigger.</strong> Paste the snippet from <code>CLAUDE.snippet.md</code> into your project's <code>CLAUDE.md</code>. This makes Claude offer to log a decision when a session reaches one. Capturing becomes a yes instead of something someone has to remember to initiate.</p>
<p>Both steps are optional. The log works without them; the trigger just becomes manual and the vocabulary grows organically.</p>
<h3 id="logging-a-discussion">Logging a discussion</h3>
<p>At the end of a huddle or design conversation, paste the summary and say &quot;log this discussion.&quot; Raw transcript, Slack export, AI-generated recap: any of these work.</p>
<p>Claude distills it into a compact entry: what was decided, why, and what alternatives were rejected. Not a transcript. The useful residue. It tags the entry from your controlled vocabulary, checks whether it reverses any earlier decision and links the two entries if so, and shows you the result before writing anything. You get a chance to correct the tags or title before anything is committed.</p>
<p>The entry lands in <code>wherefore/log/YYYY-MM-DD-short-slug.md</code> and a one-line summary is appended to <code>wherefore/INDEX.md</code>. Both files go into the repo like any other code: PR-reviewable, blame-traceable, and searchable with standard tools.</p>
<h3 id="querying-the-log">Querying the log</h3>
<p>Ask in plain English:</p>
<ul>
<li>&quot;Why did we implement the price calculator the way we did?&quot;</li>
<li>&quot;What did we decide about PROJ-1234?&quot;</li>
<li>&quot;How are we handling tenant isolation?&quot;</li>
</ul>
<p>Claude reads <code>INDEX.md</code> to shortlist relevant entries without opening every file, then pulls the decisions and rationale from the matching ones. If a decision was later reversed, it follows the supersession link and answers from the current entry, and tells you the decision changed so you are not acting on something that was revisited. If nothing matches, it says so plainly.</p>
<h2 id="what-it-does-not-do">What it does not do</h2>
<p>A few honest limitations.</p>
<p>It only captures what you bring to it. If your team makes a significant call in a meeting that does not involve a Claude session, that decision will not end up in the log unless someone takes time afterward to summarize it and run the skill. It is not passive. It requires a habit.</p>
<p>It is also not a replacement for formal decision records on things that affect the whole organization. An ADR process with review and stakeholder sign-off is the right tool for that. This covers the smaller daily calls that are not worth a formal process but are still worth more than a Slack thread that scrolls away.</p>
<h2 id="final-notes">Final notes</h2>
<p>The thing I wanted most from this was the ability to ask &quot;why does this work this way?&quot; and get a real answer instead of a shrug or a two-hour archaeology project through old messages. That is what it does. The log is not comprehensive, but it has been useful enough to pull up more than once already, which is more than I can say for most documentation I have written.</p>
<p>That is all for today.</p>
]]></content>
  </entry>
  
  
  <entry>
    <title>How This Site Got Its Look.</title>
    <link href="https://dustinvk.com/blog/how-this-site-got-its-look/"/>
    <updated>2026-06-19T00:00:00Z</updated>
    <id>https://dustinvk.com/blog/how-this-site-got-its-look/</id>
    <summary>A walkthrough of my design process, the iterations I explored, style elements I cut, and how a flannel shirt saved my design.</summary>
    <content type="html"><![CDATA[

<style>
@import url('https://fonts.googleapis.com/css2?family=Bebas+Neue&family=Barlow:wght@400;500;600&family=Barlow+Condensed:wght@500;600;700&family=Fraunces:ital,wght@0,400..600;1,400..500&family=Inter:wght@500;700&family=JetBrains+Mono:wght@400;500&family=Space+Grotesk:wght@500;600&display=swap');

.design-post { font-family: 'Barlow', sans-serif; color: var(--ink-soft); line-height: 1.7; background: var(--charcoal); }

.design-post * { margin: 0; padding: 0; box-sizing: border-box; }
.design-post {
    --purple-bg: #221f3d;
    --glow-purple: 0 0 10px rgba(138,114,196,0.4);
    --max-w: 760px;
  }
[data-theme="light"] .design-post {
    --purple-bg: #ece4f5;
    --glow-purple: 0 0 0 transparent;
  }
@keyframes pulse-dot { 0%, 100% { opacity: 1; } 50% { opacity: 0.55; } }
.design-post .post-body { padding: 0; max-width: none; }
.design-post .post-content { max-width: var(--max-w); margin: 0 auto; }
.design-post .post-content h2 { font-family: 'Bebas Neue', sans-serif; font-size: 2rem; letter-spacing: 0.04em; color: var(--ink); line-height: 1; margin: 3.5rem 0 0.5rem; }
.design-post .post-content h2 em { font-style: normal; color: var(--teal); text-shadow: var(--glow-teal-soft); }
.design-post .post-content h2:first-child { margin-top: 0; }
.design-post .section-label { font-family: 'Barlow Condensed', sans-serif; font-size: 0.72rem; font-weight: 700; letter-spacing: 0.28em; text-transform: uppercase; color: var(--teal); display: flex; align-items: center; gap: 0.7rem; margin-bottom: 1rem; text-shadow: var(--glow-teal-soft); }
.design-post .section-label::before { content: ''; display: block; width: 24px; height: 1.5px; background: var(--teal); box-shadow: var(--glow-teal-soft); }
.design-post .post-content p { font-size: 0.98rem; line-height: 1.8; color: var(--ink-soft); margin-bottom: 1.25rem; }
.design-post .post-content p strong { color: var(--ink); font-weight: 600; }
.design-post .post-content p em { color: var(--teal); font-style: italic; }
.design-post .post-content code { font-family: 'JetBrains Mono', monospace; font-size: 0.88em; color: var(--teal); background: var(--charcoal-3); padding: 0.1em 0.4em; border-radius: 2px; }
.design-post .post-content ul { list-style: none; margin: 0 0 1.5rem; padding: 0; }
.design-post .post-content ul li { padding: 0.4rem 0 0.4rem 1.5rem; position: relative; font-size: 0.95rem; color: var(--ink-soft); line-height: 1.7; }
.design-post .post-content ul li::before { content: '→'; position: absolute; left: 0; color: var(--teal); text-shadow: var(--glow-teal-soft); }
.design-post .palette-strip { display: grid; grid-template-columns: repeat(4, 1fr); gap: 0; margin: 1.5rem 0 2.5rem; border: 1px solid var(--border); }
.design-post .swatch { padding: 1.25rem 1rem; display: flex; flex-direction: column; gap: 0.2rem; min-height: 110px; justify-content: flex-end; }
.design-post .swatch-name { font-family: 'Barlow Condensed', sans-serif; font-size: 0.75rem; font-weight: 700; letter-spacing: 0.1em; text-transform: uppercase; }
.design-post .swatch-hex { font-family: 'JetBrains Mono', monospace; font-size: 0.7rem; opacity: 0.7; }
.design-post .type-stack { background: var(--charcoal-2); border-left: 2px solid var(--teal); padding: 1.5rem 1.75rem; margin: 1.5rem 0 2.5rem; }
.design-post .type-stack-row { padding: 0.85rem 0; border-bottom: 1px solid var(--border); }
.design-post .type-stack-row:last-child { border-bottom: none; }
.design-post .type-stack-label { font-family: 'JetBrains Mono', monospace; font-size: 0.7rem; color: var(--ink-mute); letter-spacing: 0.05em; margin-bottom: 0.35rem; }
.design-post .type-sample-display { font-family: 'Bebas Neue', sans-serif; font-size: 2.2rem; letter-spacing: 0.04em; color: var(--ink); line-height: 1; }
.design-post .type-sample-display em { font-style: normal; color: var(--teal); text-shadow: var(--glow-teal-soft); }
.design-post .type-sample-body { font-family: 'Barlow', sans-serif; font-size: 0.95rem; color: var(--ink-soft); line-height: 1.65; }
.design-post .type-sample-label { font-family: 'Barlow Condensed', sans-serif; font-size: 0.8rem; font-weight: 700; letter-spacing: 0.2em; text-transform: uppercase; color: var(--teal); text-shadow: var(--glow-teal-soft); }
.design-post .type-sample-mono { font-family: 'JetBrains Mono', monospace; font-size: 0.85rem; color: var(--ink-soft); letter-spacing: 0.04em; }
.design-post .type-sample-mono .marker { color: var(--teal); text-shadow: var(--glow-teal-soft); }
.design-post .demo-card { background: var(--charcoal-2); border: 1px solid var(--border); padding: 1.75rem; margin: 1.5rem 0 2.5rem; }
.design-post .demo-label { font-family: 'JetBrains Mono', monospace; font-size: 0.7rem; color: var(--ink-mute); letter-spacing: 0.06em; margin-bottom: 1.25rem; }
.design-post .demo-label .marker { color: var(--teal); }
.design-post .demo-content { display: flex; align-items: center; justify-content: center; padding: 1.5rem 0; min-height: 80px; }
.design-post .demo-logo { font-family: 'Bebas Neue', sans-serif; font-size: 1.6rem; letter-spacing: 0.08em; color: var(--ink); }
.design-post .demo-logo .slash { color: var(--teal); margin: 0 0.3rem; text-shadow: var(--glow-teal); }
.design-post .demo-logo span { color: var(--purple); }
.design-post .demo-status { display: flex; align-items: center; gap: 0.6rem; font-family: 'JetBrains Mono', monospace; font-size: 0.78rem; color: var(--ink-mute); letter-spacing: 0.05em; }
.design-post .demo-status-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--teal); box-shadow: var(--glow-teal); animation: pulse-dot 2.5s ease-in-out infinite; }
.design-post .demo-eyebrow { font-family: 'JetBrains Mono', monospace; font-size: 0.88rem; color: var(--ink-soft); letter-spacing: 0.06em; }
.design-post .demo-eyebrow .marker { color: var(--teal); margin-right: 0.4rem; text-shadow: var(--glow-teal-soft); }
.design-post .comparison { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; margin: 1.5rem 0 2.5rem; }
.design-post .comparison-card { padding: 0; min-height: 0; display: flex; flex-direction: column; overflow: hidden; }
.design-post .comparison-card.dont { background: var(--charcoal-3); border-top: 2px solid var(--spark); }
.design-post .comparison-card.do { background: var(--charcoal-3); border-top: 2px solid var(--teal); }
.design-post .comparison-tag { font-family: 'Barlow Condensed', sans-serif; font-size: 0.72rem; font-weight: 700; letter-spacing: 0.2em; text-transform: uppercase; padding: 1.25rem 1.5rem 0; }
.design-post .comparison-card.dont .comparison-tag { color: var(--spark); }
.design-post .comparison-card.do .comparison-tag { color: var(--teal); text-shadow: var(--glow-teal-soft); }
.design-post .comparison-card .text-block { padding: 0 1.5rem 1.5rem; }
.design-post .comparison-card .what { font-family: 'Bebas Neue', sans-serif; font-size: 1.1rem; letter-spacing: 0.04em; color: var(--ink); line-height: 1.15; margin-bottom: 0.5rem; margin-top: 1rem; }
.design-post .comparison-card .why { font-size: 0.85rem; color: var(--ink-soft); line-height: 1.6; }
.design-post .mockup { height: 110px; width: 100%; display: flex; align-items: center; justify-content: center; overflow: hidden; position: relative; border-bottom: 1px solid var(--border); margin-top: 0.85rem; }
.design-post .mockup-black { background: #000000; flex-direction: column; gap: 0.35rem; padding: 0.8rem; }
.design-post .mockup-black .h { font-family: 'Bebas Neue', sans-serif; font-size: 1.15rem; letter-spacing: 0.04em; color: #00e5ff; }
.design-post .mockup-black .sub { font-family: 'Barlow', sans-serif; font-size: 0.68rem; color: #6e6e7a; letter-spacing: 0.04em; }
.design-post .mockup-acid { background: #000000; flex-direction: column; align-items: flex-start; justify-content: center; gap: 0.2rem; padding: 0.7rem 1rem; font-family: 'JetBrains Mono', monospace; font-size: 0.72rem; line-height: 1.5; }
.design-post .mockup-acid .line { color: #00ff41; text-shadow: 0 0 6px rgba(0,255,65,0.7); }
.design-post .mockup-acid .prompt { color: #00ff41; opacity: 0.7; }
.design-post .mockup-cursor { background: #0a0a14; font-family: 'JetBrains Mono', monospace; font-size: 0.85rem; }
.design-post .mockup-cursor .text { color: var(--teal); }
.design-post .mockup-cursor .blink { display: inline-block; width: 7px; height: 1rem; background: var(--teal-bright); vertical-align: middle; margin-left: 3px; box-shadow: var(--glow-teal); animation: blink-hard 0.7s step-end infinite; }
@keyframes blink-hard { 0%, 49% { opacity: 1; } 50%, 100% { opacity: 0; } }
.design-post .mockup-glow { background: var(--charcoal); flex-direction: column; gap: 0.4rem; padding: 0.7rem; }
.design-post .mockup-glow .h { font-family: 'Bebas Neue', sans-serif; font-size: 1.1rem; letter-spacing: 0.04em; color: var(--teal-bright); text-shadow: 0 0 12px var(--teal-bright), 0 0 4px var(--teal-bright); }
.design-post .mockup-glow .body { font-size: 0.7rem; color: var(--teal); text-shadow: 0 0 6px var(--teal); }
.design-post .mockup-glow .btn { font-family: 'Barlow Condensed', sans-serif; font-size: 0.65rem; font-weight: 700; letter-spacing: 0.12em; text-transform: uppercase; color: var(--spark); border: 1px solid var(--spark); padding: 0.2rem 0.7rem; text-shadow: 0 0 6px var(--spark); box-shadow: 0 0 8px var(--spark); margin-top: 0.2rem; }
.design-post .mockup-scanlines { background: var(--charcoal); flex-direction: column; gap: 0.3rem; padding: 0.8rem; }
.design-post .mockup-scanlines::after { content: ''; position: absolute; inset: 0; background: repeating-linear-gradient(to bottom, transparent 0, transparent 2px, rgba(74,181,183,0.18) 2px, rgba(74,181,183,0.18) 3px); pointer-events: none; }
.design-post .mockup-scanlines .h { font-family: 'Bebas Neue', sans-serif; font-size: 1.15rem; letter-spacing: 0.04em; color: var(--ink); position: relative; }
.design-post .mockup-scanlines .body { font-size: 0.68rem; color: var(--ink-soft); position: relative; }
.design-post .mockup-brackets { background: var(--charcoal); flex-direction: column; align-items: flex-start; justify-content: center; gap: 0.35rem; padding: 0.8rem 1.5rem; }
.design-post .mockup-bracket-row { display: flex; gap: 0.55rem; font-family: 'JetBrains Mono', monospace; font-size: 0.78rem; align-items: center; }
.design-post .mockup-bracket-row .num { color: var(--teal); text-shadow: var(--glow-teal-soft); }
.design-post .mockup-bracket-row .label { color: var(--ink-soft); font-family: 'Barlow Condensed', sans-serif; font-size: 0.8rem; font-weight: 600; letter-spacing: 0.1em; text-transform: uppercase; }
.design-post .mockup-sliding { background: var(--charcoal); gap: 0.4rem; padding: 0.6rem; }
.design-post .mockup-sliding .card { background: var(--charcoal-3); padding: 0.3rem 0.55rem; font-family: 'Barlow Condensed', sans-serif; font-size: 0.65rem; font-weight: 700; letter-spacing: 0.1em; text-transform: uppercase; color: var(--ink-soft); border: 1px solid var(--border); flex-shrink: 0; opacity: 0; transform: translateX(-15px); animation: slide-in-loop 3s ease-out infinite; }
.design-post .mockup-sliding .card.s2 { animation-delay: 0.3s; }
.design-post .mockup-sliding .card.s3 { animation-delay: 0.6s; }
@keyframes slide-in-loop {
    0% { opacity: 0; transform: translateX(-15px); }
    20%, 80% { opacity: 1; transform: translateX(0); }
    100% { opacity: 0; transform: translateX(-15px); }
  }
.design-post .mockup-wobble { background: var(--charcoal); padding: 0.8rem; }
.design-post .mockup-wobble .card { background: var(--charcoal-3); padding: 0.55rem 1rem; font-family: 'Barlow Condensed', sans-serif; font-size: 0.75rem; font-weight: 700; letter-spacing: 0.12em; text-transform: uppercase; color: var(--ink-soft); border: 1px solid var(--border); animation: wobble-loop 2.2s ease-in-out infinite; }
@keyframes wobble-loop {
    0%, 100% { transform: rotate(0deg) scale(1); }
    25% { transform: rotate(-3deg) scale(1.04); }
    75% { transform: rotate(3deg) scale(1.04); }
  }
.design-post .destination { background: var(--purple-bg); padding: 4rem 2.5rem; border-top: 1px solid var(--border); border-bottom: 1px solid var(--border); margin: 3.5rem 0; position: relative; }
.design-post .destination::before { content: ''; position: absolute; left: 0; right: 0; top: 0; height: 1px; background: var(--spark); opacity: 0.6; box-shadow: 0 0 10px var(--spark); }
.design-post .destination-inner { max-width: var(--max-w); margin: 0 auto; }
.design-post .destination-label { font-family: 'Barlow Condensed', sans-serif; font-size: 0.72rem; font-weight: 700; letter-spacing: 0.28em; text-transform: uppercase; color: var(--purple); margin-bottom: 1rem; text-shadow: var(--glow-purple); display: flex; align-items: center; gap: 0.7rem; }
.design-post .destination-label::before { content: ''; display: block; width: 24px; height: 1.5px; background: var(--purple); box-shadow: var(--glow-purple); }
.design-post .destination-title { font-family: 'Bebas Neue', sans-serif; font-size: 2rem; letter-spacing: 0.04em; color: var(--ink); line-height: 1; margin-bottom: 1.25rem; }
.design-post .destination-title em { font-style: normal; color: var(--purple); text-shadow: var(--glow-purple); }
.design-post .destination-body { font-size: 0.98rem; color: var(--ink-soft); line-height: 1.8; }
.design-post .pull-quote { font-family: 'Bebas Neue', sans-serif; font-size: 1.85rem; letter-spacing: 0.04em; color: var(--ink); line-height: 1.15; margin: 2.5rem 0; padding-left: 1.5rem; border-left: 2px solid var(--teal); }
.design-post .pull-quote em { font-style: normal; color: var(--teal); text-shadow: var(--glow-teal); }
.design-post .iteration-card { background: var(--charcoal-2); border: 1px solid var(--border); margin: 2rem 0 2.5rem; overflow: hidden; }
.design-post .iteration-card-final { border-color: rgba(74,181,183,0.25); box-shadow: 0 0 24px rgba(74,181,183,0.06); }
.design-post .iter-head { padding: 1.5rem 1.5rem 1rem; }
.design-post .iter-label { font-family: 'JetBrains Mono', monospace; font-size: 0.7rem; color: var(--ink-mute); letter-spacing: 0.05em; margin-bottom: 0.5rem; }
.design-post .iter-label .marker { color: var(--teal); margin-right: 0.5rem; text-shadow: var(--glow-teal-soft); }
.design-post .iter-label.kept .marker { color: var(--teal-bright); }
.design-post .iter-title { font-family: 'Bebas Neue', sans-serif; font-size: 1.5rem; letter-spacing: 0.04em; color: var(--ink); line-height: 1; margin-bottom: 0.5rem; }
.design-post .iter-tagline { font-size: 0.85rem; color: var(--ink-soft); font-style: italic; line-height: 1.5; }
.design-post .iter-snippet { width: 100%; min-height: 200px; padding: 2rem 2rem; display: flex; align-items: center; }
.design-post .snippet-plain { background: #ffffff; }
.design-post .snippet-plain .label { font-family: 'Inter', sans-serif; font-size: 0.78rem; color: #6b7280; margin-bottom: 0.7rem; font-weight: 400; }
.design-post .snippet-plain .h { font-family: 'Inter', sans-serif; font-size: 1.5rem; font-weight: 600; color: #000000; margin-bottom: 0.55rem; line-height: 1.25; letter-spacing: -0.01em; }
.design-post .snippet-plain .body { font-family: 'Inter', sans-serif; font-size: 0.88rem; color: #4b5563; line-height: 1.55; margin-bottom: 1.1rem; max-width: 360px; font-weight: 400; }
.design-post .snippet-plain .btn { background: #000000; color: #ffffff; border: none; padding: 0.55rem 1.2rem; font-family: 'Inter', sans-serif; font-size: 0.85rem; font-weight: 500; cursor: pointer; border-radius: 4px; }
.design-post .snippet-modern { background: #faf6ee; }
.design-post .snippet-modern .label { font-family: 'Barlow', sans-serif; font-size: 0.65rem; font-weight: 600; letter-spacing: 0.18em; text-transform: uppercase; color: #5d7a6b; margin-bottom: 0.7rem; }
.design-post .snippet-modern .h { font-family: 'Fraunces', serif; font-size: 1.55rem; line-height: 1.15; color: #1a2536; font-weight: 500; margin-bottom: 0.55rem; letter-spacing: -0.01em; }
.design-post .snippet-modern .h em { font-style: italic; color: #5d7a6b; font-weight: 400; }
.design-post .snippet-modern .body { font-family: 'Barlow', sans-serif; font-size: 0.88rem; color: #4a5562; line-height: 1.6; margin-bottom: 1.1rem; max-width: 360px; }
.design-post .snippet-modern .btn { background: #c2624a; color: #faf6ee; border: none; padding: 0.6rem 1.4rem; border-radius: 100px; font-family: 'Barlow', sans-serif; font-size: 0.82rem; font-weight: 500; cursor: pointer; }
.design-post .snippet-neon { background: #050510; position: relative; }
.design-post .snippet-neon::before { content: ''; position: absolute; inset: 0; background: radial-gradient(circle at 30% 40%, rgba(179,0,255,0.15) 0%, transparent 50%), radial-gradient(circle at 70% 60%, rgba(0,255,128,0.1) 0%, transparent 50%); pointer-events: none; }
.design-post .snippet-neon .content { position: relative; z-index: 1; }
.design-post .snippet-neon .label { font-family: 'JetBrains Mono', monospace; font-size: 0.7rem; color: #b300ff; text-shadow: 0 0 10px rgba(179,0,255,0.8); margin-bottom: 0.7rem; letter-spacing: 0.08em; }
.design-post .snippet-neon .h { font-family: 'JetBrains Mono', monospace; font-size: 1.55rem; color: #00ff80; text-shadow: 0 0 14px rgba(0,255,128,0.9), 0 0 4px rgba(0,255,128,0.6); font-weight: 700; letter-spacing: 0.04em; margin-bottom: 0.6rem; }
.design-post .snippet-neon .body { font-family: 'JetBrains Mono', monospace; font-size: 0.78rem; color: #00ff80; text-shadow: 0 0 6px rgba(0,255,128,0.5); margin-bottom: 1.1rem; opacity: 0.85; }
.design-post .snippet-neon .btn { background: transparent; color: #b300ff; border: 1px solid #b300ff; padding: 0.55rem 1.4rem; font-family: 'JetBrains Mono', monospace; font-size: 0.75rem; font-weight: 600; letter-spacing: 0.1em; text-shadow: 0 0 8px rgba(179,0,255,0.8); box-shadow: 0 0 12px rgba(179,0,255,0.5), inset 0 0 8px rgba(179,0,255,0.15); cursor: pointer; }
.design-post .snippet-flannel { background: var(--charcoal); position: relative; }
.design-post .snippet-flannel::before { content: ''; position: absolute; left: 0; right: 0; top: 0; height: 1px; background: var(--spark); opacity: 0.5; box-shadow: 0 0 8px var(--spark); }
.design-post .snippet-flannel .label { font-family: 'JetBrains Mono', monospace; font-size: 0.72rem; color: var(--ink-soft); letter-spacing: 0.05em; margin-bottom: 0.7rem; }
.design-post .snippet-flannel .label .m { color: var(--teal); margin-right: 0.4rem; text-shadow: var(--glow-teal-soft); }
.design-post .snippet-flannel .h { font-family: 'Bebas Neue', sans-serif; font-size: 1.6rem; letter-spacing: 0.04em; color: var(--ink); line-height: 1.05; margin-bottom: 0.6rem; }
.design-post .snippet-flannel .h em { font-style: normal; color: var(--teal); text-shadow: var(--glow-teal); }
.design-post .snippet-flannel .body { font-family: 'Barlow', sans-serif; font-size: 0.85rem; color: var(--ink-soft); line-height: 1.6; margin-bottom: 1.1rem; max-width: 360px; }
.design-post .snippet-flannel .btn { background: var(--spark); color: var(--ink); border: none; padding: 0.6rem 1.4rem; font-family: 'Barlow Condensed', sans-serif; font-size: 0.78rem; font-weight: 700; letter-spacing: 0.13em; text-transform: uppercase; cursor: pointer; }
.design-post .iter-palette { display: grid; grid-template-columns: repeat(4, 1fr); border-top: 1px solid var(--border); border-bottom: 1px solid var(--border); }
.design-post .iter-swatch { padding: 0.85rem 0.9rem; display: flex; flex-direction: column; gap: 0.15rem; min-height: 70px; justify-content: center; }
.design-post .iter-swatch-name { font-family: 'Barlow Condensed', sans-serif; font-size: 0.66rem; font-weight: 700; letter-spacing: 0.1em; text-transform: uppercase; }
.design-post .iter-swatch-hex { font-family: 'JetBrains Mono', monospace; font-size: 0.62rem; opacity: 0.7; letter-spacing: 0.02em; }
.design-post .iter-body { padding: 1.5rem 1.5rem 1.5rem; }
.design-post .iter-body p { font-size: 0.92rem; line-height: 1.7; color: var(--ink-soft); margin-bottom: 0.9rem; }
.design-post .iter-body p:last-of-type { margin-bottom: 0; }
.design-post .iter-body p strong { color: var(--ink); font-weight: 600; }
.design-post .iter-body p em { color: var(--teal); font-style: italic; }
.design-post .iter-body code { font-family: 'JetBrains Mono', monospace; font-size: 0.85em; color: var(--teal); background: var(--charcoal-3); padding: 0.1em 0.4em; border-radius: 2px; }
.design-post .verdict { margin-top: 1.25rem; padding: 1rem 1.25rem; background: var(--charcoal-3); border-left: 2px solid var(--spark); }
.design-post .verdict.kept { border-left-color: var(--teal); box-shadow: var(--glow-teal-soft); }
.design-post .verdict-tag { font-family: 'Barlow Condensed', sans-serif; font-size: 0.7rem; font-weight: 700; letter-spacing: 0.2em; text-transform: uppercase; color: var(--spark); margin-bottom: 0.45rem; }
.design-post .verdict.kept .verdict-tag { color: var(--teal); text-shadow: var(--glow-teal-soft); }
.design-post .verdict-body { font-size: 0.88rem; color: var(--ink-soft); line-height: 1.65; margin: 0 !important; }
.design-post .type-card { background: var(--charcoal-2); border: 1px solid var(--border); margin: 1.25rem 0; overflow: hidden; }
.design-post .type-card.kept { border-color: rgba(74,181,183,0.25); }
.design-post .type-sample-area { padding: 2.25rem 2rem; background: var(--charcoal); border-bottom: 1px solid var(--border); min-height: 110px; display: flex; align-items: center; position: relative; }
.design-post .type-card.kept .type-sample-area { background: var(--charcoal-3); }
.design-post .type-sample-area::before { content: ''; position: absolute; left: 0; top: 0; bottom: 0; width: 3px; background: var(--spark); }
.design-post .type-card.kept .type-sample-area::before { background: var(--teal); box-shadow: 0 0 12px rgba(74,181,183,0.5); }
.design-post .type-sample { color: var(--ink); width: 100%; }
.design-post .type-sample-fraunces { font-family: 'Fraunces', serif; font-style: italic; font-weight: 500; font-size: 2.1rem; letter-spacing: -0.01em; line-height: 1.05; }
.design-post .type-sample-inter { font-family: 'Inter', sans-serif; font-weight: 700; font-size: 1.9rem; letter-spacing: -0.02em; line-height: 1.1; }
.design-post .type-sample-space { font-family: 'Space Grotesk', sans-serif; font-weight: 500; font-size: 1.95rem; letter-spacing: -0.01em; line-height: 1.1; }
.design-post .type-sample-bebas { font-family: 'Bebas Neue', sans-serif; font-size: 2.5rem; letter-spacing: 0.04em; line-height: 1; }
.design-post .type-sample-barlow { font-family: 'Barlow', sans-serif; font-weight: 400; font-size: 1.15rem; line-height: 1.55; color: var(--ink-soft); }
.design-post .type-sample-barlow strong { color: var(--ink); font-weight: 600; }
.design-post .type-sample-mono { font-family: 'JetBrains Mono', monospace; font-weight: 500; font-size: 1.4rem; }
.design-post .type-sample-mono .marker { color: var(--teal); text-shadow: var(--glow-teal-soft); margin-right: 0.4rem; }
.design-post .type-meta { padding: 1.25rem 1.5rem 1.4rem; }
.design-post .type-name-row { display: flex; align-items: baseline; gap: 0.85rem; margin-bottom: 0.95rem; flex-wrap: wrap; }
.design-post .type-name { font-family: 'Barlow Condensed', sans-serif; font-size: 0.95rem; font-weight: 700; letter-spacing: 0.18em; text-transform: uppercase; color: var(--ink); }
.design-post .type-card.kept .type-name { color: var(--teal); text-shadow: var(--glow-teal-soft); }
.design-post .type-classification { font-family: 'JetBrains Mono', monospace; font-size: 0.68rem; color: var(--ink-mute); letter-spacing: 0.04em; }
.design-post .type-verdict { display: flex; gap: 0.85rem; align-items: flex-start; }
.design-post .type-verdict-tag { font-family: 'Barlow Condensed', sans-serif; font-size: 0.65rem; font-weight: 700; letter-spacing: 0.18em; text-transform: uppercase; color: var(--spark); flex-shrink: 0; padding: 0.25rem 0.65rem; border: 1px solid var(--spark); margin-top: 0.1rem; }
.design-post .type-card.kept .type-verdict-tag { color: var(--teal); border-color: rgba(74,181,183,0.45); text-shadow: var(--glow-teal-soft); }
.design-post .type-why { font-size: 0.88rem; color: var(--ink-soft); line-height: 1.65; }
.design-post .subsection-heading { font-family: 'Barlow Condensed', sans-serif; font-size: 0.85rem; font-weight: 700; letter-spacing: 0.2em; text-transform: uppercase; color: var(--ink); margin: 2.5rem 0 1.25rem; padding-bottom: 0.6rem; border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 0.75rem; }
.design-post .subsection-heading::before { content: ''; display: block; width: 16px; height: 1px; background: var(--teal); box-shadow: var(--glow-teal-soft); }
@media (max-width: 700px) {
.design-post .palette-strip { grid-template-columns: repeat(2, 1fr); }
.design-post .comparison { grid-template-columns: 1fr; }
.design-post .destination { margin: 3rem 0; }
}
</style>

<div class="design-post">

<main class="post-body">
  <article class="post-content">

    <p>Design works like software. You sketch something, mock it up, keep what lands, and throw out what doesn't. Then you go again, and again, until it stops feeling wrong.</p>

    <p>When I started on my portfolio, I looked around at what other developers had built. Plenty of good templates out there. None of them felt like me. I wanted something personal, closer to a craft workshop than a landing page. Somewhere that looks like a person makes things there, on purpose: sophisticated, jewel-toned, terminal energy used as <em>punctuation</em> instead of the whole sentence.</p>

    <p>Here's how I got there.</p>


    <h2>The Palette <em>Journey.</em></h2>

    <div class="section-label">Iteration 01 — finding the right colors</div>

    <p>I tried four directions before one of them stuck. The first was the default you land on when you don't make any choices at all. The next two were fine designs that just weren't mine, and weren't right for this. 
      
    </p>

    <!-- DIRECTION 1: PLAIN DEFAULT -->
    <div class="iteration-card">
      <div class="iter-head">
        <div class="iter-label"><span class="marker">//</span>Direction 1</div>
        <h3 class="iter-title">Plain Default.</h3>
        <p class="iter-tagline">No fuss, no decisions. Black text on white, system-ish sans, default everything.</p>
      </div>

      <div class="iter-snippet snippet-plain">
        <div>
          <div class="label">About</div>
          <h4 class="h">Dustin VanKrimpen</h4>
          <p class="body">Software engineer building things in Michigan. View my projects or get in touch.</p>
          <button class="btn">Contact</button>
        </div>
      </div>

      <div class="iter-palette">
        <div class="iter-swatch" style="background: #ffffff; color: #000000; border-right: 1px solid #eeeeee;">
          <div class="iter-swatch-name">White</div>
          <div class="iter-swatch-hex">#FFFFFF</div>
        </div>
        <div class="iter-swatch" style="background: #000000; color: #ffffff;">
          <div class="iter-swatch-name">Black</div>
          <div class="iter-swatch-hex">#000000</div>
        </div>
        <div class="iter-swatch" style="background: #6b7280; color: #ffffff;">
          <div class="iter-swatch-name">Mid Gray</div>
          <div class="iter-swatch-hex">#6B7280</div>
        </div>
        <div class="iter-swatch" style="background: #eeeeee; color: #000000;">
          <div class="iter-swatch-name">Light Gray</div>
          <div class="iter-swatch-hex">#EEEEEE</div>
        </div>
      </div>

      <div class="iter-body">
        <p>The first instinct was to just not make decisions. White background, black text, gray secondary text, default sans-serif. The "I'm not a designer, I'm a developer who happens to need a website" approach. The thinking: don't pretend, don't perform, let the content speak for itself.</p>

        <p>It's the safest possible direction. Nobody could call it tacky or overdesigned. It would fit any context, any device, any decade.</p>

        <div class="verdict">
          <div class="verdict-tag">Verdict — moved on</div>
          <p class="verdict-body">Plain. Fine. Said nothing. On a portfolio it reads as "I couldn't be bothered," which isn't what I want a hiring manager walking away with.</p>
        </div>
      </div>
    </div>

        <!-- DIRECTION 1: PLAIN DEFAULT -->

     <div class="iteration-card">
      <div class="iter-head">
        <div class="iter-label"><span class="marker">//</span>Direction 2</div>
        <h3 class="iter-title">Modern Refined.</h3>
        <p class="iter-tagline">The "thoughtful SaaS" direction. Safe, modern, approachable.</p>
      </div>
      <div class="iter-snippet snippet-modern">
        <div>
          <div class="label">The Developer</div>
          <h4 class="h">Dustin VanKrimpen, <em>software engineer.</em></h4>
          <p class="body">Me as a SaaS platform. Your software, delivered with care. Join now, free for the first 30 days.</p>
          <button class="btn">Get in touch</button>
        </div>
      </div>

      <div class="iter-palette">
        <div class="iter-swatch" style="background: #faf6ee; color: #1a2536;">
          <div class="iter-swatch-name">Warm Cream</div>
          <div class="iter-swatch-hex">#FAF6EE</div>
        </div>
        <div class="iter-swatch" style="background: #1a2536; color: #faf6ee;">
          <div class="iter-swatch-name">Soft Navy</div>
          <div class="iter-swatch-hex">#1A2536</div>
        </div>
        <div class="iter-swatch" style="background: #c2624a; color: #faf6ee;">
          <div class="iter-swatch-name">Terracotta</div>
          <div class="iter-swatch-hex">#C2624A</div>
        </div>
        <div class="iter-swatch" style="background: #5d7a6b; color: #faf6ee;">
          <div class="iter-swatch-name">Sage</div>
          <div class="iter-swatch-hex">#5D7A6B</div>
        </div>
      </div>

      <div class="iter-body">
        <p>I wanted color, so I built around a muted pairing of clay red and sage green. Terracotta (<code>#c2624a</code>) on the primary CTA carries the same energy as red but reads as craft and earth instead of civic or political. Sage (<code>#5d7a6b</code>) is the earthy grounding accent, used for labels and italics. Together they drop the page straight into "modern software" territory. I warmed the background to a buttery <code>#faf6ee</code> and softened the navy to <code>#1a2536</code>, a notch less stark than full navy.</p>

        <p><strong>The typography shifts.</strong> Headings switched to Fraunces, a variable serif, with italics for emphasis. Fraunces reads as <em>thoughtful modern software</em>. Sentence case on buttons and labels read as conversational rather than commanding. Pill-shaped buttons instead of hard rectangles are softer, more contemporary.</p>

        <div class="verdict">
          <div class="verdict-tag">Verdict — moved on</div>
          <p class="verdict-body">Good, but not <em>me</em>. I'd built something you couldn't tell apart from a startup's marketing site. The craft was there. The <em>personality</em> wasn't. It felt like cosplaying another company's brand.
          </p>
        </div>
      </div>
    </div>

    <!-- DIRECTION 3: NEON CYBERPUNK -->
    <div class="iteration-card">
      <div class="iter-head">
        <div class="iter-label"><span class="marker">//</span>Direction 3</div>
        <h3 class="iter-title">Neon Cyberpunk.</h3>
        <p class="iter-tagline">The opposite swing. Electric purple, neon green, heavy glow, all monospace.</p>
      </div>

      <div class="iter-snippet snippet-neon">
        <div class="content">
          <div class="label">// SYS_ONLINE</div>
          <h4 class="h">_DUSTIN_VANKRIMPEN</h4>
          <p class="body">&gt; init l33t haXX0r skillz...</p>
          <button class="btn">EXECUTE</button>
        </div>
      </div>

      <div class="iter-palette">
        <div class="iter-swatch" style="background: #050510; color: #00ff80;">
          <div class="iter-swatch-name">Void Black</div>
          <div class="iter-swatch-hex">#050510</div>
        </div>
        <div class="iter-swatch" style="background: #00ff80; color: #050510;">
          <div class="iter-swatch-name">Neon Green</div>
          <div class="iter-swatch-hex">#00FF80</div>
        </div>
        <div class="iter-swatch" style="background: #b300ff; color: #f0eadc;">
          <div class="iter-swatch-name">Electric Purple</div>
          <div class="iter-swatch-hex">#B300FF</div>
        </div>
        <div class="iter-swatch" style="background: #ff00cc; color: #050510;">
          <div class="iter-swatch-name">Hot Magenta</div>
          <div class="iter-swatch-hex">#FF00CC</div>
        </div>
      </div>

      <div class="iter-body">
        <p>If the refined direction was too safe, this was the overcorrection. Pure cyberpunk. Electric purple (<code>#b300ff</code>) and neon green (<code>#00ff80</code>) on void-black, heavy glow on every element, monospace all the way down.</p>

        <p>Borrowed energy from cyberpunk movie posters, Defcon talks, hacker forum aesthetics. Buttons had glow on top of glow. Headlines had double text-shadows. The whole thing felt like a launch sequence.</p>

        <div class="verdict">
          <div class="verdict-tag">Verdict — moved on</div>
          <p class="verdict-body">All volume, no quiet. Nowhere to rest your eyes. Fun as a vibe. Still not <em>me</em>.</p>
        </div>
      </div>
    </div>

    <!-- DIRECTION 4: THE FLANNEL — the keeper -->
    <div class="iteration-card iteration-card-final">
      <div class="iter-head">
        <div class="iter-label kept"><span class="marker">//</span>Direction 4 — the keeper</div>
        <h3 class="iter-title">The Flannel Epiphany.</h3>
        <p class="iter-tagline">Unexpected inspiration from real life.</p>
      </div>

      <div class="iter-snippet snippet-flannel">
        <div>
          <div class="label"><span class="m">//</span>Grand Rapids, MI</div>
          <h4 class="h">Dustin <em>VanKrimpen.</em></h4>
          <p class="body">Just a chill software dev in Michigan. Build logs, projects, and the occasional thought.</p>
          <button class="btn">View Projects →</button>
        </div>
      </div>

      <div class="iter-palette">
        <div class="iter-swatch" style="background: #1a1a22; color: #f0eadc;">
          <div class="iter-swatch-name">Charcoal</div>
          <div class="iter-swatch-hex">#1A1A22</div>
        </div>
        <div class="iter-swatch" style="background: #221f3d; color: #f0eadc;">
          <div class="iter-swatch-name">Purple</div>
          <div class="iter-swatch-hex">#221F3D</div>
        </div>
        <div class="iter-swatch" style="background: #3a9b9d; color: #1a1a22;">
          <div class="iter-swatch-name">Teal</div>
          <div class="iter-swatch-hex">#3A9B9D</div>
        </div>
        <div class="iter-swatch" style="background: #c2392e; color: #f0eadc;">
          <div class="iter-swatch-name">Spark</div>
          <div class="iter-swatch-hex">#C2392E</div>
        </div>
      </div>

      <div class="iter-body">
        <p>I ran through several more rounds trying to split the difference between safe-and-mature and fun-and-techy. Nothing clicked. I wanted something grounded and humble that still carried color and a character of its own.</p>
        <p>Then I looked down at the shirt I was wearing. A flannel I wear constantly: deep navy-purple base, charcoal, a jewel-tone teal, thin red stripes through it. I thought, <em>this is the palette</em>.</p>

        <p>Not the plaid itself, but what the colors were doing together. The dark base wasn't pure black; it had warmth and depth. The teal sat muted and low, nothing like neon. The red was one thin stripe, used sparingly. And purple wasn't a background at all. It was a destination.</p>

        <p>The charcoal (<code>#1a1a22</code>) carries a faint purple-warm undertone. That little bit of warmth keeps the whole thing from going cold and sterile. It reads as low-light workshop, not data center. The teal (<code>#3a9b9d</code>) is muted and shifted slightly green, away from bright cyan. It looks more like an old phosphor display than a neon sign. The spark red (<code>#C2392E</code>) shows up only for emphasis: a thin line above the hero, the primary button, and that's it.</p>

        <div class="verdict kept">
          <div class="verdict-tag">Verdict — shipped</div>
          <p class="verdict-body">It worked because textile designers had already proven these colors can live together, for decades. Real-world objects come with color harmonies built in. It worked because every color had a job too: teal is the workhorse accent, purple the destination, spark red the action signal, charcoal the foundation.</p>
        </div>
      </div>
    </div>


    <h2>Style Elements I <em>Cut.</em></h2>

    <div class="section-label">Iteration 02 — choices that didn't make it</div>

    <p>Beyond color, there were a dozen smaller choices I tried and cut. Some were generic anti-patterns I knew to dodge from the start. Others almost made the final cut before I caught them doing more harm than good.</p>

    <div class="subsection-heading">Generic anti-patterns</div>

    <p>My first attempts looked exactly like the thing I was trying to avoid. None of them said anything about <strong>me</strong>. They were templates I'd dropped my information into.</p>

    <div class="comparison">
      <div class="comparison-card dont">
        <div class="comparison-tag">Skipped</div>
        <div class="mockup mockup-black">
          <div class="h">DEV PORTFOLIO</div>
          <div class="sub">Building things since 2020</div>
        </div>
        <div class="text-block">
          <div class="what">Pure Black Background</div>
          <div class="why">Goes too dark. Feels cold and sterile, like a server room dashboard.</div>
        </div>
      </div>
      <div class="comparison-card dont">
        <div class="comparison-tag">Skipped</div>
        <div class="mockup mockup-acid">
          <div class="line"><span class="prompt">$</span> whoami</div>
          <div class="line"><span class="prompt">&gt;</span> dev_xtreme</div>
          <div class="line"><span class="prompt">$</span> cat skills.txt</div>
        </div>
        <div class="text-block">
          <div class="what">Acid/Lime Green Text</div>
          <div class="why">Too cartoonish. Reads as Defcon costume, not craftsperson.</div>
        </div>
      </div>
      <div class="comparison-card dont">
        <div class="comparison-tag">Skipped</div>
        <div class="mockup mockup-cursor">
          <div class="text">$ whoami<span class="blink"></span></div>
        </div>
        <div class="text-block">
          <div class="what">Blinking Cursor Animation</div>
          <div class="why">Theatrical. The static <code>&gt;</code> and <code>//</code> markers convey terminal energy without being a costume.</div>
        </div>
      </div>
      <div class="comparison-card dont">
        <div class="comparison-tag">Skipped</div>
        <div class="mockup mockup-glow">
          <div class="h">EVERYTHING GLOWS</div>
          <div class="body">even the body text glows softly</div>
          <div class="btn">Glowing Button</div>
        </div>
        <div class="text-block">
          <div class="what">Glow On Everything</div>
          <div class="why">If glow is everywhere, it stops meaning anything. Make it earn its place.</div>
        </div>
      </div>
    </div>

    <div class="pull-quote">When everything glows, <em>nothing does.</em></div>

    <div class="subsection-heading">Almost made the cut</div>

    <p>These four got closer. Each one ended up clarifying what the site is actually for.</p>

    <div class="comparison">
      <div class="comparison-card dont">
        <div class="comparison-tag">Cut</div>
        <div class="mockup mockup-scanlines">
          <div class="h">HEADLINE TEXT</div>
          <div class="body">Body text underneath the heading</div>
        </div>
        <div class="text-block">
          <div class="what">CRT Scanline Overlay</div>
          <div class="why">Tried it across the whole site. At full opacity it felt like a costume. Kept a faint version at 2.5%, felt more than seen.</div>
        </div>
      </div>
      <div class="comparison-card dont">
        <div class="comparison-tag">Cut</div>
        <div class="mockup mockup-brackets">
          <div class="mockup-bracket-row"><span class="num">[01]</span><span class="label">Cut the Unnecessary</span></div>
          <div class="mockup-bracket-row"><span class="num">[02]</span><span class="label">Priced for Small Biz</span></div>
          <div class="mockup-bracket-row"><span class="num">[03]</span><span class="label">Built to Work</span></div>
        </div>
        <div class="text-block">
          <div class="what">Bracketed Numbers <code>[01]</code></div>
          <div class="why">Too "look at me, I'm a terminal." Just <code>01</code> in mono is cleaner.</div>
        </div>
      </div>
      <div class="comparison-card dont">
        <div class="comparison-tag">Cut</div>
        <div class="mockup mockup-sliding">
          <div class="card">Card 1</div>
          <div class="card s2">Card 2</div>
          <div class="card s3">Card 3</div>
        </div>
        <div class="text-block">
          <div class="what">Sliding-In Cards on Scroll</div>
          <div class="why">Theatrical and slows the reader down. Subtle fades are fine. Nothing more.</div>
        </div>
      </div>
      <div class="comparison-card dont">
        <div class="comparison-tag">Cut</div>
        <div class="mockup mockup-wobble">
          <div class="card">Hover Me</div>
        </div>
        <div class="text-block">
          <div class="what">Wobbling Cards on Hover</div>
          <div class="why">Adds movement that doesn't communicate anything. Animations should be functional.</div>
        </div>
      </div>
    </div>

    <p>The pattern I noticed: every time I cut something, it was because the element was <em>performing</em> rather than <em>communicating</em>. Decorative motion that didn't tell you anything. Costume-coded type that screamed "look how terminal I am" rather than just being terminal-flavored. The good moves are the ones that get out of the way once you've absorbed them.</p>

    <h2>Typefaces <em>Considered.</em></h2>

    <div class="section-label">Iteration 03 — picking the right voices</div>

    <p>With the palette settled, I had to pick type. Four faces, each with one job, no overlap. Before I locked the display face, I tried three that didn't work. Each showed me a way the site shouldn't go.</p>

    <div class="type-card">
      <div class="type-sample-area">
        <div class="type-sample type-sample-fraunces">Crafting <em>quiet tools.</em></div>
      </div>
      <div class="type-meta">
        <div class="type-name-row">
          <div class="type-name">Fraunces</div>
          <div class="type-classification">Variable Serif · Display</div>
        </div>
        <div class="type-verdict">
          <div class="type-verdict-tag">Cut</div>
          <div class="type-why">Beautiful and considered, but it's turned into the house serif for a lot of well-funded software. It made the site read like someone else's marketing. Borrowed authority.</div>
        </div>
      </div>
    </div>

    <div class="type-card">
      <div class="type-sample-area">
        <div class="type-sample type-sample-inter">Crafting quiet tools.</div>
      </div>
      <div class="type-meta">
        <div class="type-name-row">
          <div class="type-name">Inter</div>
          <div class="type-classification">Modern Sans · Internet default</div>
        </div>
        <div class="type-verdict">
          <div class="type-verdict-tag">Cut</div>
          <div class="type-why">A default on a lot of dev portfolios. Genuinely good, genuinely characterless. Wanted something more distinct.</div>
        </div>
      </div>
    </div>

    <div class="type-card">
      <div class="type-sample-area">
        <div class="type-sample type-sample-space">Crafting quiet tools.</div>
      </div>
      <div class="type-meta">
        <div class="type-name-row">
          <div class="type-name">Space Grotesk</div>
          <div class="type-classification">Geometric Sans · Tech-launch energy</div>
        </div>
        <div class="type-verdict">
          <div class="type-verdict-tag">Cut</div>
          <div class="type-why">Has the modern startup-launch feel. Felt cliché, but I do like it.</div>
        </div>
      </div>
    </div>

    <div class="type-card kept">
      <div class="type-sample-area">
        <div class="type-sample type-sample-bebas">CRAFTING QUIET TOOLS.</div>
      </div>
      <div class="type-meta">
        <div class="type-name-row">
          <div class="type-name">Bebas Neue</div>
          <div class="type-classification">Condensed Display · Industrial signage</div>
        </div>
        <div class="type-verdict">
          <div class="type-verdict-tag">Kept — display</div>
          <div class="type-why">Reads as workshop signage, news headlines, industrial labels. No decoration, no nostalgia. It gets out of the way and lets the words do the work. Free, one weight, hard to misuse.</div>
        </div>
      </div>
    </div>

    <div class="type-card kept">
      <div class="type-sample-area">
        <div class="type-sample type-sample-barlow"><strong>Crafting quiet tools</strong> — a workshop in Michigan, since 2026.</div>
      </div>
      <div class="type-meta">
        <div class="type-name-row">
          <div class="type-name">Barlow + Condensed</div>
          <div class="type-classification">Humanist Sans · Body and labels</div>
        </div>
        <div class="type-verdict">
          <div class="type-verdict-tag">Kept — body</div>
          <div class="type-why">Pairs with Bebas Neue without competing. Quiet enough at body sizes that you forget it's there. Shares a family with Barlow Condensed for labels and UI, so the system unifies cleanly.</div>
        </div>
      </div>
    </div>

    <div class="type-card kept">
      <div class="type-sample-area">
        <div class="type-sample type-sample-mono"><span class="marker">//</span>crafting quiet tools</div>
      </div>
      <div class="type-meta">
        <div class="type-name-row">
          <div class="type-name">JetBrains Mono</div>
          <div class="type-classification">Coder Monospace · Terminal accents only</div>
        </div>
        <div class="type-verdict">
          <div class="type-verdict-tag">Kept — terminal</div>
          <div class="type-why">The <code>//</code> comment-style eyebrows depend on this one. Real ligature support, drawn by people who actually write code. Used only as punctuation, never for body.</div>
        </div>
      </div>
    </div>

    <p>The rule that emerged: <strong>JetBrains Mono only for terminal-energy moments</strong>. Comment-style eyebrow text. Metadata. The numbers in numbered lists. Never body copy. The moment you put mono on long-form text, it goes from "this is from the terminal" to "this is uncomfortable to read."</p>

    <h2>Signature <em>Moves.</em></h2>

    <div class="section-label">Iteration 04 — making it feel like mine</div>

    <p>A palette and a type system get you 80% of the way there. The last 20% is the small recurring elements that make a site feel like it belongs to a specific person, not a Figma template.</p>

    <p>I built three of them. Each one earns its place.</p>

    <div class="demo-card">
      <div class="demo-label"><span class="marker">//</span> The logo lockup</div>
      <div class="demo-content">
        <div class="demo-logo">DUSTIN<span class="slash">/</span><span>VANKRIMPEN</span></div>
      </div>
    </div>

    <p>The wordmark sets a teal forward-slash between my first and last name, last name in purple. The slash glows, just barely. It's the only place that slash-and-purple combo shows up anywhere on the site, which is what makes it work. It's <em>my</em> signature, not a motif I reuse until it turns into wallpaper.</p>

    <div class="demo-card">
      <div class="demo-label"><span class="marker">//</span> The status dot</div>
      <div class="demo-content">
        <div class="demo-status"><div class="demo-status-dot"></div>AVAILABLE FOR WORK</div>
      </div>
    </div>

    <p>A small pulsing teal dot in the nav, with a "STATUS" label in mono. It says "alive and present" without spelling anything out. The pulse is slow, a full 2.5 seconds, so it reads as ambient instead of anxious.</p>

    <div class="demo-card">
      <div class="demo-label"><span class="marker">//</span> Comment-marker eyebrows</div>
      <div class="demo-content">
        <div class="demo-eyebrow"><span class="marker">//</span>Grand Rapids, MI · Est. 2026</div>
      </div>
    </div>

    <p>For the eyebrows above section headlines, I use a teal <code>//</code> marker followed by the label in mono. It frames the section like a code comment: "this is metadata about the thing below." Once you notice it, the whole site starts reading a little more like a code editor.</p>

    <div class="destination">
      <div class="destination-inner">
        <div class="destination-label">The Destination Rule</div>
        <h3 class="destination-title">Purple Only Shows Up <em>Once Per Page.</em></h3>
        <p class="destination-body">Purple is the destination color. One section per page gets it: the headline feature, the spot I most want a visitor to land. When purple finally shows up, it should feel like stepping into a velvet room in a slate-floored studio. Spread it everywhere and the hierarchy collapses, nothing feels special. The restraint is what makes the rare thing read as rare.</p>
      </div>
    </div>


    <h2>What I <em>Learned.</em></h2>

    <div class="section-label">Iteration 05 — the takeaways</div>

    <p>Three things I came out of this with that I'll take into the next design:</p>

    <ul>
      <li><strong>Restraint is a feature.</strong> Every accent color, every glow, every animation has to earn its place. If everything is loud, nothing is loud.</li>
      <li><strong>Borrow from physical objects, not other websites.</strong> That flannel taught me more about a palette than an hour of scrolling Dribbble ever did. Things that already exist in the real world have survived the test of looking good together.</li>
      <li><strong>Rules beat decisions.</strong> "Purple only once per page" is easier to follow than "use purple thoughtfully." Codified constraints free you from having to make every choice from scratch.</li>
    </ul>

    <p>The site you're reading this on isn't finished. It'll keep changing as I learn more. But the foundation feels right: it looks like somewhere a person actually makes things, not a template with my name dropped in. That was the entire goal.</p>

    <p><em>I keep a reference doc for the whole design system and update it as I go. Drop me a line if you'd like a copy.</em></p>

  </article>
</main>

</div>
]]></content>
  </entry>
  
  
  <entry>
    <title>Migrating Password Hashes Without Anyone Noticing</title>
    <link href="https://dustinvk.com/blog/lazy-password-migration/"/>
    <updated>2026-06-16T00:00:00Z</updated>
    <id>https://dustinvk.com/blog/lazy-password-migration/</id>
    <summary>A walkthrough of seamlessly upgrading user password hashing algorithms with no forced resets, no batch jobs, and no downtime in a Spring Boot application.</summary>
    <content type="html"><![CDATA[<p>Most security upgrades come with a tax. You change something under the hood and your users feel it. A password-hash upgrade is the rare one where that tax is optional, but most teams pay it anyway by firing off a &quot;please reset your password&quot; email to the entire user base. That email tanks engagement and fills the Monday support queue with <em>&quot;why did you change my password???&quot;</em></p>
<p>When I took over an existing Spring Boot codebase, one of the first things I did was a security review of what I was inheriting. One item I flagged as a top priority was password storage. The app hashed passwords with salted <code>SHA-256</code>, a solid choice years ago that has aged like milk. The hard part was never picking a replacement. It was rolling one out without forcing every user to reset their password.</p>
<p>I moved password-hashing to Argon2id without sending a single one of those emails. Every user kept their existing password, nothing ran as a batch job, and the app never went into a maintenance window. As far as our users could tell, nothing happened at all. Here is how the pattern works and why it flows naturally from the way password hashing is handled.</p>
<blockquote>
<p><strong>Note:</strong> the examples below are in Spring and Kotlin, but the pattern holds up whatever framework or language you're using. They're simplified to show the shape of the approach, not lifted out of a production codebase. <strong>Always</strong> do your own research and validation when performing critical security updates rather than blindly following what is presented here.</p>
</blockquote>
<h2 id="why-salted-sha-256-is-the-wrong-tool-for-passwords">Why salted SHA-256 is the wrong tool for passwords</h2>
<p>SHA-256's fatal flaw as a password hash is that it's fast. A modern GPU churns through something like 10 billion SHA-256 hashes a second, so a budget box with four consumer cards can grind an enormous candidate list in an afternoon. Salting doesn't fix that. A per-user salt stops one precomputed rainbow table from unlocking everyone at once, but it does nothing to slow a brute force against a single account, and each user is still cheap to crack if their password is weak.</p>
<p>The real fix is a hash that's deliberately slow and memory-hungry, 100 to 500 ms per attempt: practically invisible during an interactive login, brutal for an attacker who needs billions of guesses. The technique is called <em><strong>adaptive hashing</strong></em>, and the &quot;adaptive&quot; part means you can crank the cost up as hardware gets faster.</p>
<h2 id="picking-a-secure-password-hash">Picking a secure password hash</h2>
<p>Three candidates were worth taking seriously. <strong>bcrypt</strong> (1999) is mature, battle-tested, and built into Spring Security, but it is not memory-hard and it silently truncates anything past a 72-byte input limit. <strong>scrypt</strong> (2009) is memory-hard and genuinely solid, but under-supported by libraries. <strong>Argon2id</strong> (2015) is the hybrid Argon2 variant that won the Password Hashing Competition: memory-hard, three tunable knobs, OWASP's current pick for new applications, and exactly what Spring Security's <code>Argon2PasswordEncoder</code> gives you.</p>
<table>
<thead>
<tr>
<th></th>
<th>bcrypt</th>
<th>Argon2id</th>
</tr>
</thead>
<tbody>
<tr>
<td>Cryptographic strength</td>
<td>Solid</td>
<td>Solid, plus memory-hard</td>
</tr>
<tr>
<td>GPU/ASIC resistance</td>
<td>Modest</td>
<td>Strong</td>
</tr>
<tr>
<td>Maturity (years deployed)</td>
<td>25+</td>
<td>~10</td>
</tr>
<tr>
<td>Spring Security support</td>
<td>Built-in, default</td>
<td>Built-in (needs BouncyCastle)</td>
</tr>
<tr>
<td>Tunable parameters</td>
<td>1 (cost)</td>
<td>3 (memory, iterations, parallelism)</td>
</tr>
<tr>
<td>Hash output</td>
<td>Self-contained, ~60 chars</td>
<td>Self-contained, ~100 chars</td>
</tr>
<tr>
<td>OWASP recommendation</td>
<td>Acceptable</td>
<td>Recommended</td>
</tr>
<tr>
<td>Operational complexity</td>
<td>Low</td>
<td>Slightly higher</td>
</tr>
</tbody>
</table>
<p>I went with <strong>Argon2id</strong>, not because bcrypt would have been wrong, but because the marginal cost of stepping up to the OWASP-recommended option was tiny and we already had the BouncyCastle dependency it needs registered for another integration. Otherwise, bcrypt at cost 12 is a defensible answer. scrypt got the least thought: same memory-hardness, older and less-recommended, no upside I could name for a fresh start.</p>
<h2 id="you-cant-rehash-what-you-cant-read">You can't rehash what you can't read</h2>
<p>My first instinct was the obvious one: write a migration script, loop over every user, rehash each password with Argon2id, done by lunch. That plan survived about thirty seconds. <em><strong>You cannot rehash a password without the plaintext</strong></em>, and the plaintext is the one thing a password table never stores. That is the whole point of a hash, and it makes the tidy batch job impossible, not just impractical. There is nothing to feed the new algorithm.</p>
<p>The only moment you ever hold a user's plaintext is the instant they type it to log in, so that is when the migration has to happen. You do not migrate users in a batch. You migrate each one on their next login, riding along on a verification step that was going to run anyway.</p>
<p>At any point during the rollout you have three groups:</p>
<ol>
<li><strong>Active users</strong> log in regularly and migrate within a week or so.</li>
<li><strong>Occasional users</strong> migrate whenever they next show up, maybe a month or three out.</li>
<li><strong>Dormant users</strong> never come back and stay on the old hash indefinitely. That is fine. Their accounts are no more exposed than before, the hash is still valid, and if they ever return they migrate on the spot.</li>
</ol>
<p>The dormant tail is not a security problem, it is at most a future UX decision. You can always force a reset of it sometime later if you prefer to leave no threads hanging.</p>
<h3 id="a-caveat-on-dormant-legacy-hashes">A caveat on dormant legacy hashes</h3>
<p>On calling the dormant tail a UX decision rather than a security risk: That holds for salted SHA-256, which still forces an attacker to grind each hash on its own, so the residual exposure on a dormant account is low enough. If your legacy hash is genuinely weak (unsalted, MD5, bare SHA-1), or you are in a high-stakes context, those dormant hashes are a real risk, since they can be cracked in bulk whether or not the user ever comes back. OWASP's <a href="https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html#upgrading-legacy-hashes">Upgrading Legacy Hashes</a> guidance covers this: expire the old hashes and force those users to reset, or layer the strong algorithm over the weak one. Layering sidesteps the no-plaintext problem from earlier, since you feed Argon2id the hash you already have instead of the password, storing <code>argon2id(md5($password))</code> for every account in one pass and protecting even the dormant ones immediately. A layered hash is a bit weaker than a clean one, so treat it as a stopgap and still swap in a real <code>argon2id($password)</code> on the user's next login.</p>
<h2 id="the-lazy-migration">The lazy migration</h2>
<p>The core idea in pseudocode:</p>
<pre><code>for each successful login:
    if the user already has a modern hash:
        verify normally, return the result
    else:
        verify against the legacy algorithm
        if it matches:
            hash the plaintext with the modern algorithm
            store the new hash and clear the legacy one
        return the result
</code></pre>
<p>Two properties matter. First, failed logins migrate nobody: a wrong password behaves exactly as before, with no database writes, so brute-force attempts never migrate themselves into anything. Second, the user sees nothing. They typed a password, they got logged in, same as always, with no &quot;we have updated our security, please confirm&quot; prompt.</p>
<h2 id="the-code">The code</h2>
<p>Spring Security setup is one bean:</p>
<pre class="language-kotlin"><code class="language-kotlin"><span class="token annotation builtin">@Configuration</span>
<span class="token keyword">open</span> <span class="token keyword">class</span> <span class="token function">PasswordHashingConfig</span><span class="token punctuation">(</span>
    <span class="token annotation builtin">@Value</span><span class="token punctuation">(</span><span class="token string-literal singleline"><span class="token string">"\${security.argon2.memory-kib:19456}"</span></span><span class="token punctuation">)</span> <span class="token keyword">private</span> <span class="token keyword">val</span> memoryKib<span class="token operator">:</span> Int<span class="token punctuation">,</span>
    <span class="token annotation builtin">@Value</span><span class="token punctuation">(</span><span class="token string-literal singleline"><span class="token string">"\${security.argon2.iterations:2}"</span></span><span class="token punctuation">)</span> <span class="token keyword">private</span> <span class="token keyword">val</span> iterations<span class="token operator">:</span> Int<span class="token punctuation">,</span>
    <span class="token annotation builtin">@Value</span><span class="token punctuation">(</span><span class="token string-literal singleline"><span class="token string">"\${security.argon2.parallelism:1}"</span></span><span class="token punctuation">)</span> <span class="token keyword">private</span> <span class="token keyword">val</span> parallelism<span class="token operator">:</span> Int<span class="token punctuation">,</span>
<span class="token punctuation">)</span> <span class="token punctuation">{</span>
    <span class="token annotation builtin">@Bean</span>
    <span class="token keyword">open</span> <span class="token keyword">fun</span> <span class="token function">passwordEncoder</span><span class="token punctuation">(</span><span class="token punctuation">)</span><span class="token operator">:</span> PasswordEncoder <span class="token operator">=</span>
        <span class="token function">Argon2PasswordEncoder</span><span class="token punctuation">(</span><span class="token number">16</span><span class="token punctuation">,</span> <span class="token number">32</span><span class="token punctuation">,</span> parallelism<span class="token punctuation">,</span> memoryKib<span class="token punctuation">,</span> iterations<span class="token punctuation">)</span>
<span class="token punctuation">}</span></code></pre>
<blockquote>
<p>The first two arguments are easy to skim past: 16 is the salt length in bytes (a 128-bit salt) and 32 is the length of the hash output in bytes (256 bits). Both are Spring's defaults. The three named values after them are the ones that set the actual work factor.</p>
</blockquote>
<p>That same encoder both creates and verifies Argon2id hashes. Argon2 hashes are self-describing, so on verification the encoder reads the parameters straight out of the stored string.</p>
<p>One thing the constructor does not show is which Argon2 variant you get. There is no argument for it, because <code>Argon2PasswordEncoder</code> is hardwired to Argon2id and gives you no way to pick Argon2i or Argon2d. If you ever want to confirm it, the variant is the first field of that self-describing string, so any stored hash beginning with $argon2id$ settles it.</p>
<p>Worth a quick aside: Spring's <code>DelegatingPasswordEncoder</code> has out-of-the-box scheme-switching for hashes stored in its prefixed <code>{id}</code> format. Our legacy hashes were a bare SHA-256 scheme with no prefix, so a small custom dispatcher was simpler than retrofitting the prefix onto every old row.</p>
<p>The data model has two homes during the migration:</p>
<ul>
<li>The <strong>legacy</strong> columns on the <code>user_account</code> table: <code>salt</code> and <code>password</code> (the SHA-256 output), both made nullable in a migration.</li>
<li>A new <code>user_verification</code> table, one row per migrated user: <code>user_id</code>, <code>token</code> (the Argon2id hash), <code>date_updated</code>.</li>
</ul>
<p>The presence of a <code>user_verification</code> row <em><strong>is</strong></em> the migration flag. No <code>is_migrated</code> boolean, no nullable enum. Either the row exists and you are on Argon2id, or it does not and you are still on SHA-256.</p>
<p>You do not need a second table for any of this. Migrating in place works fine. Widen the existing password column for the longer Argon2id hash and overwrite it on a successful login. That is simpler, and it is what most write-ups do.</p>
<p>I split the new hash into its own table for application-level separation. It keeps credentials off the main user entity (no leaking through a stray <code>SELECT *</code>, an API response, or a log line), lets me scope tighter database grants, and allows checking &quot;who is migrated?&quot; by a simple row query instead of matching hash prefixes. To be clear, it buys nothing against a security breach where both tables fall together. It is defense against accidental exposure, not a wall against an attacker who already has your database.</p>
<p>The dispatcher:</p>
<pre class="language-kotlin"><code class="language-kotlin"><span class="token annotation builtin">@Service</span>
<span class="token keyword">class</span> <span class="token function">PasswordVerificationService</span><span class="token punctuation">(</span>
    <span class="token keyword">private</span> <span class="token keyword">val</span> passwordEncoder<span class="token operator">:</span> PasswordEncoder<span class="token punctuation">,</span>
    <span class="token keyword">private</span> <span class="token keyword">val</span> verificationRepo<span class="token operator">:</span> PasswordVerificationRepository<span class="token punctuation">,</span>
<span class="token punctuation">)</span> <span class="token punctuation">{</span>

    <span class="token comment">/**
     * Verifies that [plaintext] matches the stored password for [user].
     *
     * As a side effect, lazily migrates users still on the legacy hash to
     * Argon2id on successful verification. The migration is invisible to
     * the caller. They just see a Boolean.
     */</span>
    <span class="token keyword">fun</span> <span class="token function">verifyPassword</span><span class="token punctuation">(</span>plaintext<span class="token operator">:</span> String<span class="token punctuation">,</span> user<span class="token operator">:</span> User<span class="token punctuation">)</span><span class="token operator">:</span> Boolean <span class="token punctuation">{</span>
        <span class="token keyword">val</span> stored <span class="token operator">=</span> verificationRepo<span class="token punctuation">.</span><span class="token function">findByUserId</span><span class="token punctuation">(</span>user<span class="token punctuation">.</span>id<span class="token punctuation">)</span>

        <span class="token keyword">if</span> <span class="token punctuation">(</span>stored <span class="token operator">!=</span> <span class="token keyword">null</span><span class="token punctuation">)</span> <span class="token punctuation">{</span>
            <span class="token comment">// User has already been migrated to Argon2id.</span>
            <span class="token keyword">return</span> passwordEncoder<span class="token punctuation">.</span><span class="token function">matches</span><span class="token punctuation">(</span>plaintext<span class="token punctuation">,</span> stored<span class="token punctuation">.</span>token<span class="token punctuation">)</span>
        <span class="token punctuation">}</span>

        <span class="token comment">// No verification row, so the user is still on legacy SHA-256.</span>
        <span class="token keyword">if</span> <span class="token punctuation">(</span><span class="token function">sha256Hex</span><span class="token punctuation">(</span>user<span class="token punctuation">.</span>salt <span class="token operator">+</span> plaintext<span class="token punctuation">)</span> <span class="token operator">!=</span> user<span class="token punctuation">.</span>password<span class="token punctuation">)</span> <span class="token punctuation">{</span>
            <span class="token keyword">return</span> <span class="token boolean">false</span>
        <span class="token punctuation">}</span>

        <span class="token comment">// Plaintext matches the legacy hash. Migrate to Argon2id while we have it.</span>
        verificationRepo<span class="token punctuation">.</span><span class="token function">insertAndClearLegacy</span><span class="token punctuation">(</span>
            userId <span class="token operator">=</span> user<span class="token punctuation">.</span>id<span class="token punctuation">,</span>
            token <span class="token operator">=</span> passwordEncoder<span class="token punctuation">.</span><span class="token function">encode</span><span class="token punctuation">(</span>plaintext<span class="token punctuation">)</span>
        <span class="token punctuation">)</span>
        <span class="token keyword">return</span> <span class="token boolean">true</span>
    <span class="token punctuation">}</span>
<span class="token punctuation">}</span></code></pre>
<p>That is the whole engine. Two branches, no flag columns, no scheme-detection guesswork. Here is what happens per request:</p>
<table>
<thead>
<tr>
<th>User state</th>
<th>Wrong password</th>
<th>Correct password</th>
</tr>
</thead>
<tbody>
<tr>
<td>Already migrated (Argon2id row exists)</td>
<td><code>matches()</code> returns false. No writes.</td>
<td><code>matches()</code> returns true. No writes.</td>
</tr>
<tr>
<td>Not yet migrated (legacy SHA-256)</td>
<td>SHA-256 check fails. No writes.</td>
<td>SHA-256 check passes, then hash with Argon2id and write the new row plus clear the legacy columns, in one transaction.</td>
</tr>
</tbody>
</table>
<p><strong>One honest weakness:</strong> the two failure paths do not cost the same. A wrong password for a migrated user runs the full Argon2id verify, while a wrong password for someone still on legacy fails on a fast SHA-256 compare. That timing gap leaks whether a given account has migrated yet, which is low value to an attacker but not nothing. If you care, run a throwaway Argon2id hash on that fast-fail path so both failures take about the same time. The path where the user is not found at all has the same shape and deserves the same treatment; that is out of scope here since <code>verifyPassword</code> already receives a <code>User</code>.</p>
<p>The repository layer is the boring part, an insert plus an atomic clear of the legacy columns:</p>
<pre class="language-kotlin"><code class="language-kotlin"><span class="token annotation builtin">@Repository</span>
<span class="token keyword">class</span> <span class="token function">PasswordVerificationRepository</span><span class="token punctuation">(</span><span class="token keyword">private</span> <span class="token keyword">val</span> em<span class="token operator">:</span> EntityManager<span class="token punctuation">)</span> <span class="token punctuation">{</span>

    <span class="token keyword">fun</span> <span class="token function">findByUserId</span><span class="token punctuation">(</span>userId<span class="token operator">:</span> Long<span class="token punctuation">)</span><span class="token operator">:</span> PasswordVerification<span class="token operator">?</span> <span class="token operator">=</span> <span class="token operator">..</span><span class="token punctuation">.</span>

    <span class="token annotation builtin">@Transactional</span>
    <span class="token keyword">fun</span> <span class="token function">insertAndClearLegacy</span><span class="token punctuation">(</span>userId<span class="token operator">:</span> Long<span class="token punctuation">,</span> token<span class="token operator">:</span> String<span class="token punctuation">)</span> <span class="token punctuation">{</span>
        em<span class="token punctuation">.</span><span class="token function">createNativeQuery</span><span class="token punctuation">(</span><span class="token string-literal multiline"><span class="token string">"""
            INSERT INTO user_verification (user_id, token, date_updated)
            VALUES (:userId, :token, NOW())
            ON CONFLICT (user_id) DO NOTHING
        """</span></span><span class="token punctuation">)</span><span class="token punctuation">.</span><span class="token function">setParameter</span><span class="token punctuation">(</span><span class="token string-literal singleline"><span class="token string">"userId"</span></span><span class="token punctuation">,</span> userId<span class="token punctuation">)</span>
           <span class="token punctuation">.</span><span class="token function">setParameter</span><span class="token punctuation">(</span><span class="token string-literal singleline"><span class="token string">"token"</span></span><span class="token punctuation">,</span> token<span class="token punctuation">)</span>
           <span class="token punctuation">.</span><span class="token function">executeUpdate</span><span class="token punctuation">(</span><span class="token punctuation">)</span>

        <span class="token comment">// Idempotent: a concurrent login may have already nulled these.</span>
        em<span class="token punctuation">.</span><span class="token function">createQuery</span><span class="token punctuation">(</span><span class="token string-literal multiline"><span class="token string">"""
            UPDATE User u
               SET u.salt = NULL, u.password = NULL
             WHERE u.id = :userId
        """</span></span><span class="token punctuation">)</span><span class="token punctuation">.</span><span class="token function">setParameter</span><span class="token punctuation">(</span><span class="token string-literal singleline"><span class="token string">"userId"</span></span><span class="token punctuation">,</span> userId<span class="token punctuation">)</span><span class="token punctuation">.</span><span class="token function">executeUpdate</span><span class="token punctuation">(</span><span class="token punctuation">)</span>
    <span class="token punctuation">}</span>
<span class="token punctuation">}</span></code></pre>
<p>Both writes live in one transaction, so they commit together or roll back together. There is never a window where a user has both an Argon2id hash and a populated legacy hash. The dispatcher would behave correctly even if there were, since it checks for the verification row first, but clearing the old columns keeps the data clean and the &quot;is this user migrated?&quot; question crisp. If you're updating the hashes in place, then this happens automatically.</p>
<p>One concurrency edge: two simultaneous logins for the same un-migrated user can both read no verification row, both pass the SHA-256 check, and both attempt the insert. Put a unique constraint on <code>user_verification.user_id</code> and make the insert idempotent (<code>ON CONFLICT (user_id) DO NOTHING</code> as above, or catch the constraint violation and fall through to verifying against the now-present Argon2id row) so a duplicate is a no-op rather than an error.</p>
<p>This lets me track the progress in two simple queries.</p>
<pre class="language-sql"><code class="language-sql"><span class="token comment">-- migrated users</span>
<span class="token keyword">SELECT</span> <span class="token function">COUNT</span><span class="token punctuation">(</span><span class="token operator">*</span><span class="token punctuation">)</span> <span class="token keyword">FROM</span> user_verification<span class="token punctuation">;</span>

<span class="token comment">-- users still on legacy SHA-256</span>
<span class="token keyword">SELECT</span> <span class="token function">COUNT</span><span class="token punctuation">(</span><span class="token operator">*</span><span class="token punctuation">)</span> <span class="token keyword">FROM</span> user_account <span class="token keyword">WHERE</span> password <span class="token operator">IS</span> <span class="token operator">NOT</span> <span class="token boolean">NULL</span><span class="token punctuation">;</span></code></pre>
<p>Watch those two numbers converge over the weeks after deploy.</p>
<h2 id="tuning-argon2id">Tuning Argon2id</h2>
<p>Argon2 has three knobs:</p>
<ul>
<li><strong><code>memory</code></strong>: KiB allocated per hash. More memory, more resistance to parallel attacks.</li>
<li><strong><code>iterations</code></strong>: passes over that memory. More passes, more time per hash.</li>
<li><strong><code>parallelism</code></strong>: threads per hash. More threads is faster wall-clock but costs more CPU per login.</li>
</ul>
<p>The <a href="https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html">OWASP Password Storage Cheat Sheet</a> currently recommends a baseline of <code>m=19456 KiB (19 MiB), t=2, p=1</code>, which lands around 150 to 300 ms on commodity hardware. I ran the defaults through our test and staging environments to confirm logins still felt fast. This was more of a sanity check than rigorous latency profiling. If login latency is critical, measure it properly on production-like hardware and tune it yourself instead of relying on a default or a number from someone's blog (including this one). The same goes for the recommended parameters themselves. They move as hardware gets faster, so treat the numbers here as a snapshot and check the cheat sheet for the current guidance before you ship.</p>
<p>One note on <code>parallelism</code>: every concurrent login burns <code>p</code> threads. On a small, busy instance, leave <code>p=1</code>. The rough test for raising it is <code>(spare cores at peak) ÷ (peak concurrent logins) ≥ p</code>. For a typical low-login-throughput B2B app, <code>p=1</code> is almost always right.</p>
<h2 id="a-free-bonus-self-invalidating-reset-tokens">A free bonus: self-invalidating reset tokens</h2>
<p>I got one nice side effect for free. Password-reset tokens are signed with a server secret concatenated with the user's current password hash:</p>
<pre class="language-kotlin"><code class="language-kotlin"><span class="token comment">// A signed token is just a payload plus an HMAC over it, keyed by some secret.</span>
<span class="token keyword">fun</span> <span class="token function">createToken</span><span class="token punctuation">(</span>payload<span class="token operator">:</span> String<span class="token punctuation">,</span> secretKey<span class="token operator">:</span> String<span class="token punctuation">)</span><span class="token operator">:</span> String <span class="token punctuation">{</span>
    <span class="token keyword">val</span> body <span class="token operator">=</span> <span class="token function">base64UrlEncode</span><span class="token punctuation">(</span>payload<span class="token punctuation">.</span><span class="token function">toByteArray</span><span class="token punctuation">(</span><span class="token punctuation">)</span><span class="token punctuation">)</span>
    <span class="token keyword">val</span> signature <span class="token operator">=</span> <span class="token function">base64UrlEncode</span><span class="token punctuation">(</span><span class="token function">hmacSha256</span><span class="token punctuation">(</span>body<span class="token punctuation">,</span> secretKey<span class="token punctuation">)</span><span class="token punctuation">)</span>
    <span class="token keyword">return</span> <span class="token string-literal singleline"><span class="token string">"</span><span class="token interpolation"><span class="token interpolation-punctuation punctuation">$</span><span class="token expression">body</span></span><span class="token string">.</span><span class="token interpolation"><span class="token interpolation-punctuation punctuation">$</span><span class="token expression">signature</span></span><span class="token string">"</span></span>
<span class="token punctuation">}</span>

<span class="token comment">// Reset token: key the signature with the server secret PLUS the user's current hash.</span>
<span class="token keyword">fun</span> <span class="token function">createResetToken</span><span class="token punctuation">(</span>user<span class="token operator">:</span> User<span class="token punctuation">)</span><span class="token operator">:</span> String <span class="token punctuation">{</span>
    <span class="token keyword">val</span> payload <span class="token operator">=</span> <span class="token string-literal multiline"><span class="token string">"""{"sub":"</span><span class="token interpolation"><span class="token interpolation-punctuation punctuation">${</span><span class="token expression">user<span class="token punctuation">.</span>email</span><span class="token interpolation-punctuation punctuation">}</span></span><span class="token string">","exp":</span><span class="token interpolation"><span class="token interpolation-punctuation punctuation">$</span><span class="token expression">expiresAt</span></span><span class="token string">}"""</span></span>
    <span class="token keyword">return</span> <span class="token function">createToken</span><span class="token punctuation">(</span>payload<span class="token punctuation">,</span> secretKey <span class="token operator">=</span> serverSecret <span class="token operator">+</span> <span class="token function">currentPasswordHash</span><span class="token punctuation">(</span>user<span class="token punctuation">)</span><span class="token punctuation">)</span>
<span class="token punctuation">}</span></code></pre>
<p>The important piece is <code>currentPasswordHash(user)</code>, which returns whatever the hash is right now, in whichever scheme:</p>
<pre class="language-kotlin"><code class="language-kotlin"><span class="token keyword">fun</span> <span class="token function">currentPasswordHash</span><span class="token punctuation">(</span>user<span class="token operator">:</span> User<span class="token punctuation">)</span><span class="token operator">:</span> String <span class="token operator">=</span>
    verificationRepo<span class="token punctuation">.</span><span class="token function">findByUserId</span><span class="token punctuation">(</span>user<span class="token punctuation">.</span>id<span class="token punctuation">)</span><span class="token operator">?</span><span class="token punctuation">.</span>token  <span class="token comment">// Argon2id (post-migration)</span>
        <span class="token operator">?:</span> user<span class="token punctuation">.</span>password                            <span class="token comment">// SHA-256 (pre-migration)</span></code></pre>
<p>Because the signature is keyed by the current hash, anything that changes that hash invalidates every outstanding reset token for that user. Two things change it. A completed reset or password change rotates the hash, so the token that was just used stops verifying the instant the new password lands, which means it cannot be replayed. The migration counts too: when a user logs in and flips from SHA-256 to Argon2id, any reset link issued beforehand quietly dies along with the old hash. You end up with single-use reset tokens for free, with no consumed flag, no per-token rows, and no server-side state to track. The <code>exp</code> field in the payload handles expiry; the signing scheme handles the rest on its own. The one flip side: a reset link issued before migration silently dies if the user logs in and migrates before clicking it. That is fine; if the user could log in, the reset was not needed.</p>
<blockquote>
<p><strong>Aside:</strong> the real reset token also carries an <code>action</code> field, but that's
orthogonal to the self-invalidation trick.</p>
</blockquote>
<h2 id="what-about-new-users">What about new users?</h2>
<p>Nothing special. New signups run through the normal flow and get an Argon2id hash from day one, because the <code>passwordEncoder.encode(...)</code> call in the registration path writes straight to <code>user_verification</code>. They never touch the legacy branch. The &quot;no verification row means SHA-256&quot; path exists only for accounts that predate the migration.</p>
<h2 id="final-notes">Final notes</h2>
<p>If you are sitting on fast legacy hashes, SHA-256, MD5, anything quick, lazy-on-login is the standard way out. It works, users never notice, and the same scaffold stretches to cover the next hash upgrade too. The hard parts are picking parameters and writing the dispatcher (which really isn't hard at all). The rest is config and patience.</p>
<p>The one thing to hold onto: a hash you cannot reverse can only be replaced when its owner hands you the plaintext again. Build around that constraint instead of against it, and the migration runs itself.</p>
<p>That is all for today. Go find out what your old hashes are actually made of.</p>
]]></content>
  </entry>
  
  
  <entry>
    <title>Hello World!</title>
    <link href="https://dustinvk.com/blog/starting-the-build-log/"/>
    <updated>2026-06-15T00:00:00Z</updated>
    <id>https://dustinvk.com/blog/starting-the-build-log/</id>
    <summary>Why I am keeping a public dev blog, what goes in it, and how to easily manage posts with Eleventy.</summary>
    <content type="html"><![CDATA[<p><em>Editor's note (2026-07-10): This blog was originally called the &quot;Build Log.&quot; It has since been renamed to &quot;Undefined Behavior.&quot; The original name is left intact below, where it appears as it was first written.</em></p>
<h2 id="intro">Intro</h2>
<p>Welcome to the first entry in the <strong>Build Log</strong>, my personal software engineering journal. It exists mainly to serve as my personal knowledge-repository of challenges faced, topics researched, discussions had, and decisions made in my day-to-day job building software. But if anyone else gets some value out of it, that's a great side-effect to have!</p>
<h2 id="why-write-a-dev-blog">Why write a dev blog?</h2>
<p>One of my favorite things about being a software engineer is the constant array of new challenges I get to research and solve for. Often, it's a pattern or concept I've seen before, but occasionally I get to tackle something that is brand new to me too. As the years go by, I find myself losing the finer details of what I learned and researched. I often remember the broad details of <em><strong>what</strong></em> I did, but lose the details of the <em><strong>whys</strong></em> and <em><strong>hows</strong></em>. In the process of documenting my work, I hope to lock in a deeper understanding of these challenges and solutions. And if not, the Build Log will serve as my personal decision audit-table where I can go in and see the specifics.</p>
<p>Putting my reasoning in public is also partly self-interested. The quickest way to find the flaw in your own thinking is to write it down and let someone correct you. So please feel free to reach out if you disagree with anything I write here, or have a better solution for a problem than what I came up with. I welcome any and all feedback I can get!</p>
<h2 id="what-goes-in-here">What goes in here</h2>
<p>Expect more working notes and decision logging than hot takes and juicy gossip. Posts will mostly be short to medium length and almost always pulled from real work.</p>
<p>My current work is primarily in web application development, so expect topics like:</p>
<ul>
<li>Multi-tenant data isolation patterns, especially when Postgres <code>RLS</code> is on the table.</li>
<li>Serverless trade-offs on AWS, mostly Go on Lambda with SST.</li>
<li>Auth flows you have to live with after you ship them.</li>
</ul>
<p>If a post does not pass the &quot;would I send this to a teammate&quot; test, I will not publish it.</p>
<h2 id="how-it-is-built">How it is built</h2>
<p>The blog is Markdown plus <a href="https://www.11ty.dev/">Eleventy v3</a> on top of my otherwise hand-coded site. The rest of <a href="http://dustinvk.com">dustinvk.com</a> is plain HTML/CSS/JS. To publish a post, I drop a <code>.md</code> file into <code>posts/</code>, push to <code>main</code>, and Cloudflare Pages rebuilds. No CMS, no component framework, no JavaScript at runtime beyond the existing theme switcher.</p>
<h3 id="why-eleventy">Why Eleventy</h3>
<p>I wanted a static site generator with as little ceremony as possible. I'd been writing posts straight into HTML, which works exactly once. I didn't want a CMS either, since I'm the only one publishing here.</p>
<p>Eleventy was the simple middle ground: I write in Markdown, keep my own hand-coded design, and a new post is just a file I drop in and push. As a bonus, it's generic enough to reskin for any client sites I build.</p>
<p>Here are some more things I liked about Eleventy:</p>
<ul>
<li>It ships <em><strong>zero</strong></em> client-side JavaScript by default. Whatever JS ends up in the build is JS I wrote or added on purpose.</li>
<li>It does not assume a frontend framework. Templates can be Nunjucks, Liquid, Markdown, or plain HTML, and they mix freely.</li>
<li>The config is one JavaScript file. No magic, no plugin ecosystem you have to learn before you can ship.</li>
<li>It only processes the file types I opt into and copies everything else across with passthrough copy, so my existing hand-coded pages ship exactly as I wrote them.</li>
</ul>
<p>Eleventy is the simplest tool that gets the job done.</p>
<h3 id="a-short-setup-tour">A short setup tour</h3>
<p>If you want to do the same, the four moving parts are: a config file, a folder of Markdown posts, a layout that wraps each post, and a build command. The whole thing fits on one screen.</p>
<p><strong>1. Install it.</strong></p>
<pre class="language-sh"><code class="language-sh"><span class="token function">npm</span> init <span class="token parameter variable">-y</span>
<span class="token function">npm</span> <span class="token function">install</span> --save-dev @11ty/eleventy</code></pre>
<p><strong>2. Write a minimal <code>eleventy.config.js</code>.</strong> This tells Eleventy where to read from, where to write to, and which file types to treat as templates.</p>
<pre class="language-js"><code class="language-js"><span class="token keyword">export</span> <span class="token keyword">default</span> <span class="token keyword">function</span> <span class="token punctuation">(</span><span class="token parameter">eleventyConfig</span><span class="token punctuation">)</span> <span class="token punctuation">{</span>
  <span class="token keyword">return</span> <span class="token punctuation">{</span>
    <span class="token literal-property property">dir</span><span class="token operator">:</span> <span class="token punctuation">{</span> <span class="token literal-property property">input</span><span class="token operator">:</span> <span class="token string">"."</span><span class="token punctuation">,</span> <span class="token literal-property property">output</span><span class="token operator">:</span> <span class="token string">"_site"</span><span class="token punctuation">,</span> <span class="token literal-property property">includes</span><span class="token operator">:</span> <span class="token string">"_includes"</span> <span class="token punctuation">}</span><span class="token punctuation">,</span>
    <span class="token literal-property property">templateFormats</span><span class="token operator">:</span> <span class="token punctuation">[</span><span class="token string">"njk"</span><span class="token punctuation">,</span> <span class="token string">"md"</span><span class="token punctuation">]</span><span class="token punctuation">,</span>
    <span class="token literal-property property">markdownTemplateEngine</span><span class="token operator">:</span> <span class="token string">"njk"</span><span class="token punctuation">,</span>
  <span class="token punctuation">}</span><span class="token punctuation">;</span>
<span class="token punctuation">}</span></code></pre>
<p>Set <code>&quot;type&quot;: &quot;module&quot;</code> in your <code>package.json</code> if you want the ESM syntax above. Anything that is not one of those template formats (CSS, images, my existing hand-coded HTML) stays out of the template pipeline. I copy those across with <code>eleventyConfig.addPassthroughCopy(...)</code>, which is how the rest of the site comes through the build untouched.</p>
<p><strong>3. Make a layout</strong> at <code>_includes/layouts/post.njk</code>. It wraps every post. Nunjucks is just HTML with <code>{{ variables }}</code> and <code>{% blocks %}</code>:</p>
<pre class="language-html"><code class="language-html"><span class="token doctype"><span class="token punctuation">&lt;!</span><span class="token doctype-tag">doctype</span> <span class="token name">html</span><span class="token punctuation">></span></span>
<span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span>html</span> <span class="token attr-name">lang</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span>en<span class="token punctuation">"</span></span><span class="token punctuation">></span></span>
<span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span>head</span><span class="token punctuation">></span></span><span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span>title</span><span class="token punctuation">></span></span>{{ title }}<span class="token tag"><span class="token tag"><span class="token punctuation">&lt;/</span>title</span><span class="token punctuation">></span></span><span class="token tag"><span class="token tag"><span class="token punctuation">&lt;/</span>head</span><span class="token punctuation">></span></span>
<span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span>body</span><span class="token punctuation">></span></span>
  <span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span>h1</span><span class="token punctuation">></span></span>{{ title }}<span class="token tag"><span class="token tag"><span class="token punctuation">&lt;/</span>h1</span><span class="token punctuation">></span></span>
  <span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span>article</span><span class="token punctuation">></span></span>{{ content | safe }}<span class="token tag"><span class="token tag"><span class="token punctuation">&lt;/</span>article</span><span class="token punctuation">></span></span>
<span class="token tag"><span class="token tag"><span class="token punctuation">&lt;/</span>body</span><span class="token punctuation">></span></span>
<span class="token tag"><span class="token tag"><span class="token punctuation">&lt;/</span>html</span><span class="token punctuation">></span></span></code></pre>
<p><strong>4. Write a post</strong> at <code>posts/hello.md</code>. The frontmatter tells Eleventy which layout to use and provides the variables that layout reads.</p>
<pre class="language-markdown"><code class="language-markdown"><span class="token front-matter-block"><span class="token punctuation">---</span>
<span class="token front-matter yaml language-yaml">title: Hello
layout: layouts/post.njk</span>
<span class="token punctuation">---</span></span>

Hi, this is my first post.</code></pre>
<p><strong>5. Build and preview.</strong></p>
<pre class="language-sh"><code class="language-sh">npx @11ty/eleventy           <span class="token comment"># one-shot build to _site/</span>
npx @11ty/eleventy <span class="token parameter variable">--serve</span>   <span class="token comment"># local dev server at http://localhost:8080</span></code></pre>
<p>That is the entire happy path. The <a href="https://www.11ty.dev/docs/">Eleventy docs</a> are genuinely good. The <a href="https://www.11ty.dev/docs/get-started/">Get Started</a> page covers everything above in more depth, and <a href="https://www.11ty.dev/docs/collections/">Collections</a> is where things start to get interesting.</p>
<h3 id="where-it-goes-from-here">Where it goes from here</h3>
<p>The next thing you probably want is a <em><strong>collection</strong></em> to power a post list. Here is a trimmed version of the one driving this page. It grabs every Markdown file under <code>posts/</code>, drops anything marked <code>draft: true</code>, and sorts newest first:</p>
<pre class="language-js"><code class="language-js">eleventyConfig<span class="token punctuation">.</span><span class="token function">addCollection</span><span class="token punctuation">(</span><span class="token string">"posts"</span><span class="token punctuation">,</span> <span class="token punctuation">(</span><span class="token parameter">api</span><span class="token punctuation">)</span> <span class="token operator">=></span>
  api
    <span class="token punctuation">.</span><span class="token function">getFilteredByGlob</span><span class="token punctuation">(</span><span class="token string">"posts/*.md"</span><span class="token punctuation">)</span>
    <span class="token punctuation">.</span><span class="token function">filter</span><span class="token punctuation">(</span><span class="token punctuation">(</span><span class="token parameter">p</span><span class="token punctuation">)</span> <span class="token operator">=></span> <span class="token operator">!</span>p<span class="token punctuation">.</span>data<span class="token punctuation">.</span>draft<span class="token punctuation">)</span>
    <span class="token punctuation">.</span><span class="token function">sort</span><span class="token punctuation">(</span><span class="token punctuation">(</span><span class="token parameter">a<span class="token punctuation">,</span> b</span><span class="token punctuation">)</span> <span class="token operator">=></span> b<span class="token punctuation">.</span>date <span class="token operator">-</span> a<span class="token punctuation">.</span>date<span class="token punctuation">)</span>
<span class="token punctuation">)</span><span class="token punctuation">;</span></code></pre>
<p>After that, the usual additions are an RSS feed via <a href="https://github.com/11ty/eleventy-plugin-rss"><code>@11ty/eleventy-plugin-rss</code></a> and code syntax highlighting via <a href="https://github.com/11ty/eleventy-plugin-syntaxhighlight"><code>@11ty/eleventy-plugin-syntaxhighlight</code></a>. Both are one-line additions in your config.</p>
<p>I also plan on using <a href="https://giscus.app/">giscus</a> to power a comment system in my blog posts via Github Discussions. But I will save that for another day.</p>
<h3 id="configuring-eleventy-on-cloudflare-pages">Configuring Eleventy on Cloudflare Pages</h3>
<p>Wiring this up to Cloudflare Pages took about 30 seconds. In the build
configuration you pick <strong>Eleventy</strong> as the framework preset, which fills in the
build command (<code>npx @11ty/eleventy</code>) and the output directory (<code>_site</code>) for you.
The only thing I added by hand was a <code>NODE_VERSION</code> environment variable set to
<code>20</code>, so the build runs on a current Node. That is the whole setup, and every
push to <code>main</code> rebuilds and deploys on its own. Almost too easy.</p>
<h2 id="final-notes">Final notes</h2>
<p>There you have it. My thoughts on developer blogging, choosing the right tool, and a very quick rundown of how to setup Eleventy to power your own personal blog through basic .MD files.</p>
<p>Next up, I'll be diving into a practical security migration: How to seamlessly upgrade user password hashing algorithms in a Spring Boot web app without disrupting the user experience. I'll cover how to audit the existing setup, implement a rolling migration strategy that upgrades hashes dynamically upon user login, and handle the database schema updates without downtime.</p>
<p>That's all for today. Let's build! 🚀</p>
]]></content>
  </entry>
  
</feed>
