<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>posts · wleeaf field notes</title><link>https://articles.wleeaf.dev/posts/</link><description>Hands-on guides and build logs from the wleeaf studio: self-hosting, Linux, networking, Docker, the command line and reverse engineering.</description><language>en-us</language><lastBuildDate>Thu, 07 May 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://articles.wleeaf.dev/posts/feed.xml" rel="self" type="application/rss+xml"/><item><title>Live Editing Suzerain: Walking an IL2CPP Heap to Find a Hidden Lua VM</title><link>https://articles.wleeaf.dev/posts/reverse-engineering-suzerain/</link><pubDate>Thu, 07 May 2026 00:00:00 +0000</pubDate><guid>https://articles.wleeaf.dev/posts/reverse-engineering-suzerain/</guid><description>How I built WolknerProtocol, a real-time state editor for Suzerain, by reverse engineering its Unity 6 IL2CPP build to find the in-process Lua variable table without any static knowledge of class layouts.</description><category>reverse-engineering</category><category>il2cpp</category><category>unity</category><category>lua</category><category>cpp</category><category>qt</category><content:encoded><![CDATA[<p>I wanted to replay specific late-game branches in <a href="https://torporgames.com/">Suzerain</a> without grinding for the right resources every time. The game has a save editor scene built in, but it requires a restart, and high values trigger debuffs that gate the cheat itself.</p>
<p>So I built a runtime state editor instead. It attaches to a running <code>Suzerain.exe</code>, finds the live in-game Lua variables, and lets me read or rewrite any of them without restarting the game. The result is <a href="https://github.com/ts-solidarity/WolknerProtocol">WolknerProtocol</a>, and the path to it ran through three dead ends and one good idea. Worth writing up, because the good idea is more general than a single game.</p>
<h2 id="what-i-was-working-with">What I was working with<a class="anchor" href="#what-i-was-working-with" aria-label="Link to “What I was working with”">#</a></h2>
<p>Suzerain is a Unity 6 game shipped with IL2CPP. The build I had was version 3.1.0.1.153, running through Proton on Linux. IL2CPP compiles C# to C++ at build time, so there is no Mono runtime metadata to query and no easy way to enumerate types from outside the process. The only obvious entry points were <code>GameAssembly.dll</code> (the compiled native code) and <code>il2cpp_data/Metadata/global-metadata.dat</code> (the game&rsquo;s own type information).</p>
<p>The game state lives in C# heap memory once the game is running. The save file confirmed it: a single JSON document with a giant <code>variables</code> field that turned out to be a Lua-table-style serialization of around 800,000 characters. Things like <code>[&quot;BaseGame.GovernmentBudget&quot;]=9, [&quot;BaseGame.PersonalWealth&quot;]=4, ...</code> going on forever.</p>
<h2 id="dead-end-one-classic-memory-scanning">Dead end one: classic memory scanning<a class="anchor" href="#dead-end-one-classic-memory-scanning" aria-label="Link to “Dead end one: classic memory scanning”">#</a></h2>
<p>The first instinct is always the same. Find the value 9 in the running process, change it in-game, scan again, narrow. Standard scanmem workflow, no metadata required.</p>
<p>This works for finding <em>a</em> value of 9 in memory. It does not work for finding <em>the</em> value of 9 that the game uses, because Suzerain has hundreds of int-valued things. Even after several rounds of narrowing you end up with thousands of candidates, and even when you find one and write to it, the game ignores you. The address you found is a buffer, a render cache, an undo snapshot, anything but the live state.</p>
<p>I confirmed this by overwriting the literal save-file string <code>[&quot;BaseGame.GovernmentBudget&quot;]=9</code> to <code>=5</code> in memory. The change held, but the game UI kept showing 9, and at the next autosave my edit was gone. Whatever I was editing, it was not what the game read.</p>
<h2 id="dead-end-two-save-editing">Dead end two: save editing<a class="anchor" href="#dead-end-two-save-editing" aria-label="Link to “Dead end two: save editing”">#</a></h2>
<p>The save file is JSON. In principle you can edit it in a text editor, set budget to whatever you want, and reload the save. The community has guides for this.</p>
<p>Two reasons it is not enough. First, you need to restart the game on every change. Second, the variables <code>BaseGame.Sordland_HUDStat_GovernmentBudget_Max = 30</code> and similar set hard caps. Editing budget to 999 triggers a debuff cascade because the value is out of bounds. You can edit the cap variables too, but now you are doing two restarts to make one change.</p>
<p>I wanted to edit values mid-play, freely, with no consequences other than the game state itself.</p>
<h2 id="the-dump-that-changed-everything">The dump that changed everything<a class="anchor" href="#the-dump-that-changed-everything" aria-label="Link to “The dump that changed everything”">#</a></h2>
<p>I gave up on blind scanning and dumped the IL2CPP metadata. The right tool for Unity 6 (metadata version 31) is <a href="https://github.com/SamboyCoding/Cpp2IL">Cpp2IL</a>. Run it against the game directory, ask for <code>diffable-cs</code> output, and you get a directory full of decompiled C# pseudocode showing every class, every field, every offset.</p>
<p>Two things jumped out:</p>
<p>First, there is a <code>Language.Lua</code> namespace with classes like <code>LuaTable</code>, <code>LuaString</code>, <code>LuaNumber</code>, <code>LuaBoolean</code>, <code>LuaInterpreter</code>. Suzerain has an embedded Lua VM written in C#.</p>
<p>Second, there is <code>PixelCrushers.DialogueSystem.DialogueLua</code> with methods like <code>GetVariable(string)</code> and <code>SetVariable(string, object)</code>. So the game uses <a href="https://www.pixelcrushers.com/dialogue-system/">PixelCrushers Dialogue System</a>, a popular Unity asset for narrative games, which embeds its own Lua interpreter for variable storage.</p>
<p>The data model became clear. The game has a global Lua table called <code>Variable</code>. Every game variable is stored in it as a key-value pair where the key is a <code>LuaString</code> (or sometimes a plain <code>System.String</code>) and the value is a <code>LuaNumber</code>, <code>LuaBoolean</code>, or <code>LuaString</code>. The save file is just this table serialized.</p>
<p>The dump also gave me the field layouts. <code>LuaNumber</code> has a <code>double Number</code> field at offset 16 in the object. <code>LuaTable</code> has its main hash dictionary at offset 24. Every C# <code>Dictionary&lt;TKey, TValue&gt;</code> has a contiguous <code>entries</code> array with each entry shaped like <code>[hashCode(4), next(4), key(8), value(8)]</code> for reference types.</p>
<h2 id="finding-the-luanumber-class-at-runtime">Finding the LuaNumber class at runtime<a class="anchor" href="#finding-the-luanumber-class-at-runtime" aria-label="Link to “Finding the LuaNumber class at runtime”">#</a></h2>
<p>The metadata told me what to look for. The next problem was actually finding it without hardcoding addresses. Class pointers in IL2CPP are at fixed offsets within <code>GameAssembly.dll</code> once it is loaded, but the loaded base address varies between launches due to ASLR. I needed runtime detection.</p>
<p>My approach was a heuristic that worked the first time and has been reliable since. I sample 8-byte aligned positions in writable memory. For each position whose 8 bytes interpret as a finite, non-zero, integer-valued double in a sane range, I look at the 16 preceding bytes (the type pointer for IL2CPP objects always sits at offset 0). I cluster all the type pointers I see and pick the most common one. That is the LuaNumber class.</p>
<div class="code is-plain">
<div class="code-head"><button type="button" class="code-copy" data-copy-code hidden><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2.5"/><path d="M16 8V6.5A2.5 2.5 0 0 0 13.5 4h-7A2.5 2.5 0 0 0 4 6.5v7A2.5 2.5 0 0 0 6.5 16H8"/></svg><span>Copy</span></button></div>
<pre tabindex="0"><code>for (size_t off = 16; off + 8 &lt;= toRead; off += 8) {
    double d;
    std::memcpy(&amp;d, buf.data() + off, 8);
    if (!std::isfinite(d) || d == 0.0) continue;
    if (std::fabs(d) &gt; 1e6) continue;
    if (d != std::trunc(d)) continue;
    uint64_t typePtr;
    std::memcpy(&amp;typePtr, buf.data() + off - 16, 8);
    if (typePtr &lt; 0x100000 || typePtr &gt; 0x0000800000000000ULL) continue;
    counts[typePtr]++;
}</code></pre>
</div>
<p>Suzerain has hundreds of LuaNumber instances representing live game variables, each one preceded by the same type pointer. Other types of objects with double values are much rarer. The cluster wins clearly every time.</p>
<h2 id="the-dict-entries">The dict entries<a class="anchor" href="#the-dict-entries" aria-label="Link to “The dict entries”">#</a></h2>
<p>Once you have the LuaNumber class pointer, finding dict entries is straightforward in two passes.</p>
<p>Pass one: scan writable memory for any 8-byte position whose value equals the LuaNumber class pointer. Each hit is a LuaNumber object&rsquo;s start address. Collect all of these into a hash set.</p>
<p>Pass two: scan writable memory again for 8-byte positions whose value is in that set. Each hit is a place that points to a LuaNumber. The 8 bytes immediately before the hit are usually the key pointer for that dict entry. Dereference the key pointer as either a <code>System.String</code> or a <code>LuaString</code> (which itself wraps a <code>System.String</code>), and you have a name for the value.</p>
<p>I filter for keys that start with known prefixes like <code>BaseGame.</code>, <code>RiziaDLC.</code>, or <code>GameCondition.</code>. After the second pass I have a complete catalog of every numeric in-game variable, keyed by its real name. About 428 of them in a typical save.</p>
<p>Total runtime, parallelized across CPUs, around 20 seconds for a 4 GB process. The bottleneck is the kernel serializing reads from <code>/proc/&lt;pid&gt;/mem</code>, not the scanning logic.</p>
<h2 id="the-actual-hard-part">The actual hard part<a class="anchor" href="#the-actual-hard-part" aria-label="Link to “The actual hard part”">#</a></h2>
<p>This is where I almost gave up.</p>
<p>After locating a dict entry pointing to the LuaNumber for <code>BaseGame.GovernmentBudget</code>, I wrote <code>11.0</code> to that LuaNumber&rsquo;s <code>Number</code> field. The game UI showed 11. I high-fived myself. Then I played for a minute, the value changed naturally to 10, and the next time I read from that same address I got <code>2.42092e-322</code>.</p>
<p>A denormalized double is the smell of pointer-sized garbage being interpreted as a float. The address still existed, the read succeeded, but whatever was at that address was no longer the budget LuaNumber.</p>
<p>Suzerain&rsquo;s Lua treats values as immutable. When <code>Variable[&quot;BaseGame.GovernmentBudget&quot;]</code> changes from 9 to 10, the runtime allocates a new <code>LuaNumber(10)</code> and updates the dict entry&rsquo;s value pointer to point at the new instance. The old <code>LuaNumber(9)</code> becomes garbage, eventually collected, eventually overwritten by something else.</p>
<p>So caching LuaNumber addresses is hopeless. They go stale on every game tick.</p>
<p>What is stable is the dict entry itself. The dict&rsquo;s <code>entries</code> array is allocated once and lives at a fixed address for the process lifetime. IL2CPP uses Boehm GC, which is non-moving for non-managed types, so allocations do not relocate. The 8 bytes inside the entry that hold the LuaNumber pointer (the value slot) sit at a stable address. The pointer they hold changes; the slot does not.</p>
<p>The fix is one extra dereference. Instead of caching <code>valueLuaNumberAddr</code>, cache <code>valueSlotAddr</code>. Reads become a two-hop operation:</p>
<div class="code is-plain">
<div class="code-head"><button type="button" class="code-copy" data-copy-code hidden><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2.5"/><path d="M16 8V6.5A2.5 2.5 0 0 0 13.5 4h-7A2.5 2.5 0 0 0 4 6.5v7A2.5 2.5 0 0 0 6.5 16H8"/></svg><span>Copy</span></button></div>
<pre tabindex="0"><code>double readValue(uint64_t valueSlotAddr) {
    uint64_t luaPtr;
    mem.readInto(valueSlotAddr, &amp;luaPtr, 8);
    double v;
    mem.readInto(luaPtr + 16, &amp;v, 8);
    return v;
}</code></pre>
</div>
<p>Writes work the same way: dereference the slot to get the <em>current</em> LuaNumber, then write to its <code>Number</code> field. If the game later allocates a fresh LuaNumber for that variable, our next read picks up the new address automatically through the slot.</p>
<p>This single insight made the rest of the project work. Live polling at 1 Hz, manual edits that stick across game events, autosaves that do not break tracking, all of it falls out of the value-slot indirection.</p>
<h2 id="the-build">The build<a class="anchor" href="#the-build" aria-label="Link to “The build”">#</a></h2>
<p>Once the data model was working I wrapped it in a Qt6 GUI. C++20, frameless window, dark slate theme, sliding mode toggle for the Sordland and Rizia campaigns, sortable table of all 428 variables, search box that supports name substrings and value comparisons (<code>&gt;10</code>, <code>=5</code>, etc.), 1 Hz live polling with the editing cell exempted so typed input is not clobbered.</p>
<p>The platform abstraction is small. One <code>ProcessMem</code> class with an <code>#ifdef _WIN32</code> split. On Linux it uses <code>pread</code> and <code>pwrite</code> against <code>/proc/&lt;pid&gt;/mem</code> after parsing <code>/proc/&lt;pid&gt;/maps</code> to find writable regions. On Windows it uses <code>OpenProcess</code>, <code>VirtualQueryEx</code> to enumerate regions, and <code>ReadProcessMemory</code> and <code>WriteProcessMemory</code> for I/O. About fifty lines of platform-specific code total.</p>
<p>For distribution I cross compile from Linux to Windows. <code>mingw-w64</code> for the toolchain, <a href="https://github.com/miurahr/aqtinstall">aqtinstall</a> to fetch Qt6 binaries for both targets, a small CMake toolchain file, and <code>windres</code> to embed the icon and version info into the PE. The Windows build is a self-contained 28 MB folder with the executable and the necessary Qt DLLs. Defender flags it as a HackTool on first run, which is standard for memory-editing binaries; whitelist the folder and move on.</p>
<p>The repo has the toolchain file and full instructions if you want to learn how to ship a Qt6 desktop app for Windows from a Linux machine. That path was less documented than I expected.</p>
<h2 id="what-i-learned">What I learned<a class="anchor" href="#what-i-learned" aria-label="Link to “What I learned”">#</a></h2>
<p>Three things, in order of usefulness for future projects.</p>
<p><strong>Do not fight the game&rsquo;s data model.</strong> Classic memory scanning works against simple games where values are stored as primitives in stable C structs. Modern Unity games are not those games. Once you accept that there is a managed runtime with its own object lifecycle, the right move is to reverse engineer the runtime, not to scan around it.</p>
<p><strong>Cache slots, not values.</strong> Anywhere a runtime might replace objects (Lua tables, immutable strings, GC-managed values), pointers to those objects are inherently fragile. Pointers to the slots that hold those pointers are stable. One extra dereference per access is a tiny cost for full correctness across every game state mutation.</p>
<p><strong>IL2CPP is more legible than its reputation.</strong> Cpp2IL produces readable pseudocode. The runtime layout is consistent and predictable. Boehm GC is non-moving. Compared to JIT-based Mono, where addresses can move on you, IL2CPP is actually friendly to outside-process introspection once you accept that you are working at the heap level.</p>
<p>If you have ever wanted to mess with the runtime state of a single-player Unity game without touching save files, the playbook above generalizes. Find the scripting layer (Lua, custom DSL, or just C# itself), dump the metadata, locate live values through one stable indirection, and stop fighting the GC.</p>
<p>The repo is at <a href="https://github.com/ts-solidarity/WolknerProtocol">github.com/ts-solidarity/WolknerProtocol</a>. Linux and Windows binaries on the releases page, MIT licensed, single-player only, please be kind to the small studio that made the game.</p>
]]></content:encoded></item><item><title>DNS For People Who Just Want Their Site to Work</title><link>https://articles.wleeaf.dev/posts/dns-explained-for-developers/</link><pubDate>Mon, 06 Apr 2026 00:00:00 +0000</pubDate><guid>https://articles.wleeaf.dev/posts/dns-explained-for-developers/</guid><description>A, AAAA, CNAME, MX, TXT, NS, SOA. What they do, when to use them, and how to debug when things break.</description><category>dns</category><category>networking</category><category>web</category><content:encoded><![CDATA[<p>DNS is the thing that turns <code>example.com</code> into <code>93.184.216.34</code>. It sounds simple until you need to actually configure it. Then you are staring at a dashboard full of record types, TTL values, and propagation delays wondering why your site still points to the old server.</p>
<h2 id="the-record-types-that-matter">The Record Types That Matter<a class="anchor" href="#the-record-types-that-matter" aria-label="Link to “The Record Types That Matter”">#</a></h2>
<h3 id="a-and-aaaa">A and AAAA<a class="anchor" href="#a-and-aaaa" aria-label="Link to “A and AAAA”">#</a></h3>
<p>The most basic. Maps a domain to an IP address:</p>
<div class="code is-plain">
<div class="code-head"><button type="button" class="code-copy" data-copy-code hidden><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2.5"/><path d="M16 8V6.5A2.5 2.5 0 0 0 13.5 4h-7A2.5 2.5 0 0 0 4 6.5v7A2.5 2.5 0 0 0 6.5 16H8"/></svg><span>Copy</span></button></div>
<pre tabindex="0"><code>example.dev.    A      203.0.113.50
example.dev.    AAAA   2001:db8::1</code></pre>
</div>
<p>A is IPv4. AAAA is IPv6. You want both if your server supports v6.</p>
<h3 id="cname">CNAME<a class="anchor" href="#cname" aria-label="Link to “CNAME”">#</a></h3>
<p>An alias. Points one name to another:</p>
<div class="code is-plain">
<div class="code-head"><button type="button" class="code-copy" data-copy-code hidden><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2.5"/><path d="M16 8V6.5A2.5 2.5 0 0 0 13.5 4h-7A2.5 2.5 0 0 0 4 6.5v7A2.5 2.5 0 0 0 6.5 16H8"/></svg><span>Copy</span></button></div>
<pre tabindex="0"><code>www.example.dev.    CNAME    example.dev.</code></pre>
</div>
<p>This says &ldquo;www is the same as the bare domain.&rdquo; The DNS resolver follows the chain and ends up at the A record for <code>example.dev</code>.</p>
<p>The critical rule: <strong>you cannot have a CNAME alongside other records for the same name.</strong> This means the zone apex (bare domain) usually cannot be a CNAME. Use an A record there instead.</p>
<h3 id="mx">MX<a class="anchor" href="#mx" aria-label="Link to “MX”">#</a></h3>
<p>Where to deliver email:</p>
<div class="code is-plain">
<div class="code-head"><button type="button" class="code-copy" data-copy-code hidden><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2.5"/><path d="M16 8V6.5A2.5 2.5 0 0 0 13.5 4h-7A2.5 2.5 0 0 0 4 6.5v7A2.5 2.5 0 0 0 6.5 16H8"/></svg><span>Copy</span></button></div>
<pre tabindex="0"><code>example.dev.    MX    10 mail.example.dev.</code></pre>
</div>
<p>The number is priority. Lower means preferred. If you have a backup mail server:</p>
<div class="code is-plain">
<div class="code-head"><button type="button" class="code-copy" data-copy-code hidden><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2.5"/><path d="M16 8V6.5A2.5 2.5 0 0 0 13.5 4h-7A2.5 2.5 0 0 0 4 6.5v7A2.5 2.5 0 0 0 6.5 16H8"/></svg><span>Copy</span></button></div>
<pre tabindex="0"><code>example.dev.    MX    10 mail.example.dev.
example.dev.    MX    20 backup-mail.example.dev.</code></pre>
</div>
<p>Senders try priority 10 first, fall back to 20 if it is down.</p>
<h3 id="txt">TXT<a class="anchor" href="#txt" aria-label="Link to “TXT”">#</a></h3>
<p>Arbitrary text. Used for verification and email authentication:</p>
<div class="code is-plain">
<div class="code-head"><button type="button" class="code-copy" data-copy-code hidden><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2.5"/><path d="M16 8V6.5A2.5 2.5 0 0 0 13.5 4h-7A2.5 2.5 0 0 0 4 6.5v7A2.5 2.5 0 0 0 6.5 16H8"/></svg><span>Copy</span></button></div>
<pre tabindex="0"><code>example.dev.    TXT    &#34;v=spf1 a mx ip4:203.0.113.50 ~all&#34;</code></pre>
</div>
<p>SPF tells receiving mail servers which IPs are allowed to send email for your domain. DKIM and DMARC are also TXT records. Google and other services use TXT for domain ownership verification too.</p>
<h3 id="ns">NS<a class="anchor" href="#ns" aria-label="Link to “NS”">#</a></h3>
<p>Nameserver delegation. Usually set by your registrar:</p>
<div class="code is-plain">
<div class="code-head"><button type="button" class="code-copy" data-copy-code hidden><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2.5"/><path d="M16 8V6.5A2.5 2.5 0 0 0 13.5 4h-7A2.5 2.5 0 0 0 4 6.5v7A2.5 2.5 0 0 0 6.5 16H8"/></svg><span>Copy</span></button></div>
<pre tabindex="0"><code>example.dev.    NS    ada.ns.cloudflare.com.
example.dev.    NS    greg.ns.cloudflare.com.</code></pre>
</div>
<p>This tells the internet &ldquo;Cloudflare knows the DNS records for example.dev.&rdquo; You rarely touch these directly.</p>
<h2 id="debugging-with-dig">Debugging With dig<a class="anchor" href="#debugging-with-dig" aria-label="Link to “Debugging With dig”">#</a></h2>
<p><code>dig</code> is the standard DNS debugging tool:</p>
<div class="code">
<div class="code-head"><span class="code-lang">bash</span><button type="button" class="code-copy" data-copy-code hidden><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2.5"/><path d="M16 8V6.5A2.5 2.5 0 0 0 13.5 4h-7A2.5 2.5 0 0 0 4 6.5v7A2.5 2.5 0 0 0 6.5 16H8"/></svg><span>Copy</span></button></div>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="c1"># Basic lookup</span>
</span></span><span class="line"><span class="cl">dig example.dev
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Specific record type</span>
</span></span><span class="line"><span class="cl">dig MX example.dev
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Short output</span>
</span></span><span class="line"><span class="cl">dig +short example.dev
</span></span><span class="line"><span class="cl"><span class="c1"># 203.0.113.50</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Use a specific DNS server</span>
</span></span><span class="line"><span class="cl">dig @8.8.8.8 example.dev
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Trace the full resolution path</span>
</span></span><span class="line"><span class="cl">dig +trace example.dev</span></span></code></pre></div>
</div>
<p>The <code>+trace</code> option follows the entire DNS chain from the root servers down. This is invaluable when debugging propagation issues.</p>
<h2 id="ttl">TTL<a class="anchor" href="#ttl" aria-label="Link to “TTL”">#</a></h2>
<p>Time To Live. How long DNS resolvers cache a record:</p>
<div class="code is-plain">
<div class="code-head"><button type="button" class="code-copy" data-copy-code hidden><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2.5"/><path d="M16 8V6.5A2.5 2.5 0 0 0 13.5 4h-7A2.5 2.5 0 0 0 4 6.5v7A2.5 2.5 0 0 0 6.5 16H8"/></svg><span>Copy</span></button></div>
<pre tabindex="0"><code>example.dev.    300    IN    A    203.0.113.50</code></pre>
</div>
<p>300 means 5 minutes. After that, resolvers ask again. Lower TTL means faster propagation when you change records, but more DNS queries hitting your nameservers.</p>
<p>Before a migration:</p>
<ol>
<li>Lower TTL to 60 seconds, 24 hours ahead</li>
<li>Wait for the old TTL to expire</li>
<li>Change the record</li>
<li>Verify</li>
<li>Raise TTL back to 3600 or higher</li>
</ol>
<p>This way, when you change the IP, everyone picks up the new address within a minute.</p>
<h2 id="common-patterns">Common Patterns<a class="anchor" href="#common-patterns" aria-label="Link to “Common Patterns”">#</a></h2>
<h3 id="subdomain-to-a-different-server">Subdomain to a different server<a class="anchor" href="#subdomain-to-a-different-server" aria-label="Link to “Subdomain to a different server”">#</a></h3>
<div class="code is-plain">
<div class="code-head"><button type="button" class="code-copy" data-copy-code hidden><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2.5"/><path d="M16 8V6.5A2.5 2.5 0 0 0 13.5 4h-7A2.5 2.5 0 0 0 4 6.5v7A2.5 2.5 0 0 0 6.5 16H8"/></svg><span>Copy</span></button></div>
<pre tabindex="0"><code>api.example.com.    A    203.0.113.50</code></pre>
</div>
<h3 id="wildcard">Wildcard<a class="anchor" href="#wildcard" aria-label="Link to “Wildcard”">#</a></h3>
<div class="code is-plain">
<div class="code-head"><button type="button" class="code-copy" data-copy-code hidden><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2.5"/><path d="M16 8V6.5A2.5 2.5 0 0 0 13.5 4h-7A2.5 2.5 0 0 0 4 6.5v7A2.5 2.5 0 0 0 6.5 16H8"/></svg><span>Copy</span></button></div>
<pre tabindex="0"><code>*.example.com.    A    93.184.216.34</code></pre>
</div>
<p>Matches any subdomain that does not have a more specific record. Useful for catch-all routing.</p>
<h3 id="email-setup">Email setup<a class="anchor" href="#email-setup" aria-label="Link to “Email setup”">#</a></h3>
<p>Minimum viable email DNS:</p>
<div class="code is-plain">
<div class="code-head"><button type="button" class="code-copy" data-copy-code hidden><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2.5"/><path d="M16 8V6.5A2.5 2.5 0 0 0 13.5 4h-7A2.5 2.5 0 0 0 4 6.5v7A2.5 2.5 0 0 0 6.5 16H8"/></svg><span>Copy</span></button></div>
<pre tabindex="0"><code>example.com.         MX   10 mail.example.com.
mail.example.com.    A    93.184.216.34
example.com.         TXT  &#34;v=spf1 a mx ~all&#34;
_dmarc.example.com.  TXT  &#34;v=DMARC1; p=quarantine&#34;
sel._domainkey.example.com.  TXT  &#34;v=DKIM1; k=rsa; p=MIIBIj...&#34;</code></pre>
</div>
<h3 id="cloudflare-proxy-vs-dns-only">Cloudflare proxy vs DNS-only<a class="anchor" href="#cloudflare-proxy-vs-dns-only" aria-label="Link to “Cloudflare proxy vs DNS-only”">#</a></h3>
<p>If you use Cloudflare:</p>
<div class="code is-plain">
<div class="code-head"><button type="button" class="code-copy" data-copy-code hidden><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2.5"/><path d="M16 8V6.5A2.5 2.5 0 0 0 13.5 4h-7A2.5 2.5 0 0 0 4 6.5v7A2.5 2.5 0 0 0 6.5 16H8"/></svg><span>Copy</span></button></div>
<pre tabindex="0"><code># Proxied (orange cloud) - traffic goes through Cloudflare CDN
example.dev.    A    203.0.113.50    (proxied)

# DNS-only (gray cloud) - direct connection to your server
mail.example.dev.    A    203.0.113.50    (DNS-only)</code></pre>
</div>
<p>Proxied records hide your real IP and add DDoS protection. But they break non-HTTP protocols. Mail servers, game servers, and anything that is not HTTP/HTTPS needs DNS-only.</p>
<h2 id="why-is-my-dns-not-working">Why Is My DNS Not Working?<a class="anchor" href="#why-is-my-dns-not-working" aria-label="Link to “Why Is My DNS Not Working?”">#</a></h2>
<p>In order of likelihood:</p>
<ol>
<li>
<p><strong>Propagation delay.</strong> You changed the record 2 minutes ago. Wait for the TTL to expire. Check with <code>dig @8.8.8.8</code> to see what Google&rsquo;s DNS returns.</p>
</li>
<li>
<p><strong>Browser cache.</strong> Chrome caches DNS aggressively. Try <code>chrome://net-internals/#dns</code> and clear it, or just use a different browser.</p>
</li>
<li>
<p><strong>Wrong record type.</strong> You added a CNAME where you needed an A record, or vice versa.</p>
</li>
<li>
<p><strong>Cloudflare proxy interfering.</strong> Your service needs direct access but the record is proxied. Switch to DNS-only.</p>
</li>
<li>
<p><strong>Missing trailing dot.</strong> In some DNS configurations, <code>mail.example.com</code> and <code>mail.example.com.</code> are different. The dot makes it fully qualified. Most providers handle this for you, but raw zone files need it.</p>
</li>
</ol>
<div class="code">
<div class="code-head"><span class="code-lang">bash</span><button type="button" class="code-copy" data-copy-code hidden><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2.5"/><path d="M16 8V6.5A2.5 2.5 0 0 0 13.5 4h-7A2.5 2.5 0 0 0 4 6.5v7A2.5 2.5 0 0 0 6.5 16H8"/></svg><span>Copy</span></button></div>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="c1"># Quick sanity check script</span>
</span></span><span class="line"><span class="cl"><span class="k">for</span> record in A AAAA MX TXT CNAME<span class="p">;</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">  <span class="nb">echo</span> <span class="s2">&#34;=== </span><span class="nv">$record</span><span class="s2"> ===&#34;</span>
</span></span><span class="line"><span class="cl">  dig +short <span class="nv">$record</span> example.com
</span></span><span class="line"><span class="cl"><span class="k">done</span></span></span></code></pre></div>
</div>
<p>DNS is not complicated once you understand the record types. The frustrating part is always the caching and propagation delays. Lower your TTLs before making changes, use <code>dig</code> to verify, and be patient.</p>
]]></content:encoded></item><item><title>Building a WebSocket Server From Scratch in Node.js</title><link>https://articles.wleeaf.dev/posts/websocket-from-scratch/</link><pubDate>Sun, 05 Apr 2026 00:00:00 +0000</pubDate><guid>https://articles.wleeaf.dev/posts/websocket-from-scratch/</guid><description>The WebSocket protocol is simple enough to implement by hand. Here is what happens under the hood.</description><category>node.js</category><category>websocket</category><category>networking</category><content:encoded><![CDATA[<p>Libraries like Socket.IO and ws abstract away the WebSocket protocol. That is usually fine. But understanding what happens at the protocol level makes debugging easier and helps you make better architecture decisions.</p>
<p>Let us build a WebSocket server from scratch using only the Node.js standard library.</p>
<h2 id="the-handshake">The Handshake<a class="anchor" href="#the-handshake" aria-label="Link to “The Handshake”">#</a></h2>
<p>WebSocket starts as an HTTP upgrade request. The client sends:</p>
<div class="code is-plain">
<div class="code-head"><button type="button" class="code-copy" data-copy-code hidden><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2.5"/><path d="M16 8V6.5A2.5 2.5 0 0 0 13.5 4h-7A2.5 2.5 0 0 0 4 6.5v7A2.5 2.5 0 0 0 6.5 16H8"/></svg><span>Copy</span></button></div>
<pre tabindex="0"><code>GET / HTTP/1.1
Host: localhost:8080
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13</code></pre>
</div>
<p>The server responds:</p>
<div class="code is-plain">
<div class="code-head"><button type="button" class="code-copy" data-copy-code hidden><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2.5"/><path d="M16 8V6.5A2.5 2.5 0 0 0 13.5 4h-7A2.5 2.5 0 0 0 4 6.5v7A2.5 2.5 0 0 0 6.5 16H8"/></svg><span>Copy</span></button></div>
<pre tabindex="0"><code>HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=</code></pre>
</div>
<p>The <code>Sec-WebSocket-Accept</code> value is computed from the client&rsquo;s key:</p>
<div class="code">
<div class="code-head"><span class="code-lang">javascript</span><button type="button" class="code-copy" data-copy-code hidden><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2.5"/><path d="M16 8V6.5A2.5 2.5 0 0 0 13.5 4h-7A2.5 2.5 0 0 0 4 6.5v7A2.5 2.5 0 0 0 6.5 16H8"/></svg><span>Copy</span></button></div>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-javascript" data-lang="javascript"><span class="line"><span class="cl"><span class="kr">const</span> <span class="nx">crypto</span> <span class="o">=</span> <span class="nx">require</span><span class="p">(</span><span class="s1">&#39;crypto&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="kd">function</span> <span class="nx">computeAcceptKey</span><span class="p">(</span><span class="nx">clientKey</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="kr">const</span> <span class="nx">MAGIC</span> <span class="o">=</span> <span class="s1">&#39;258EAFA5-E914-47DA-95CA-C5AB0DC85B11&#39;</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">  <span class="k">return</span> <span class="nx">crypto</span>
</span></span><span class="line"><span class="cl">    <span class="p">.</span><span class="nx">createHash</span><span class="p">(</span><span class="s1">&#39;sha1&#39;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="p">.</span><span class="nx">update</span><span class="p">(</span><span class="nx">clientKey</span> <span class="o">+</span> <span class="nx">MAGIC</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="p">.</span><span class="nx">digest</span><span class="p">(</span><span class="s1">&#39;base64&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div>
</div>
<p>The magic string is literally part of the RFC. Every WebSocket implementation uses it.</p>
<h2 id="the-server">The Server<a class="anchor" href="#the-server" aria-label="Link to “The Server”">#</a></h2>
<div class="code">
<div class="code-head"><span class="code-lang">javascript</span><button type="button" class="code-copy" data-copy-code hidden><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2.5"/><path d="M16 8V6.5A2.5 2.5 0 0 0 13.5 4h-7A2.5 2.5 0 0 0 4 6.5v7A2.5 2.5 0 0 0 6.5 16H8"/></svg><span>Copy</span></button></div>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-javascript" data-lang="javascript"><span class="line"><span class="cl"><span class="kr">const</span> <span class="nx">http</span> <span class="o">=</span> <span class="nx">require</span><span class="p">(</span><span class="s1">&#39;http&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="cl"><span class="kr">const</span> <span class="nx">crypto</span> <span class="o">=</span> <span class="nx">require</span><span class="p">(</span><span class="s1">&#39;crypto&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="kr">const</span> <span class="nx">MAGIC</span> <span class="o">=</span> <span class="s1">&#39;258EAFA5-E914-47DA-95CA-C5AB0DC85B11&#39;</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="kr">const</span> <span class="nx">server</span> <span class="o">=</span> <span class="nx">http</span><span class="p">.</span><span class="nx">createServer</span><span class="p">();</span>
</span></span><span class="line"><span class="cl"><span class="kr">const</span> <span class="nx">clients</span> <span class="o">=</span> <span class="k">new</span> <span class="nx">Set</span><span class="p">();</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nx">server</span><span class="p">.</span><span class="nx">on</span><span class="p">(</span><span class="s1">&#39;upgrade&#39;</span><span class="p">,</span> <span class="p">(</span><span class="nx">req</span><span class="p">,</span> <span class="nx">socket</span><span class="p">)</span> <span class="p">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="kr">const</span> <span class="nx">clientKey</span> <span class="o">=</span> <span class="nx">req</span><span class="p">.</span><span class="nx">headers</span><span class="p">[</span><span class="s1">&#39;sec-websocket-key&#39;</span><span class="p">];</span>
</span></span><span class="line"><span class="cl">  <span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="nx">clientKey</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="nx">socket</span><span class="p">.</span><span class="nx">destroy</span><span class="p">();</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">  <span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="kr">const</span> <span class="nx">acceptKey</span> <span class="o">=</span> <span class="nx">crypto</span>
</span></span><span class="line"><span class="cl">    <span class="p">.</span><span class="nx">createHash</span><span class="p">(</span><span class="s1">&#39;sha1&#39;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="p">.</span><span class="nx">update</span><span class="p">(</span><span class="nx">clientKey</span> <span class="o">+</span> <span class="nx">MAGIC</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="p">.</span><span class="nx">digest</span><span class="p">(</span><span class="s1">&#39;base64&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="nx">socket</span><span class="p">.</span><span class="nx">write</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">    <span class="s1">&#39;HTTP/1.1 101 Switching Protocols\r\n&#39;</span> <span class="o">+</span>
</span></span><span class="line"><span class="cl">    <span class="s1">&#39;Upgrade: websocket\r\n&#39;</span> <span class="o">+</span>
</span></span><span class="line"><span class="cl">    <span class="s1">&#39;Connection: Upgrade\r\n&#39;</span> <span class="o">+</span>
</span></span><span class="line"><span class="cl">    <span class="sb">`Sec-WebSocket-Accept: </span><span class="si">${</span><span class="nx">acceptKey</span><span class="si">}</span><span class="sb">\r\n`</span> <span class="o">+</span>
</span></span><span class="line"><span class="cl">    <span class="s1">&#39;\r\n&#39;</span>
</span></span><span class="line"><span class="cl">  <span class="p">);</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="nx">clients</span><span class="p">.</span><span class="nx">add</span><span class="p">(</span><span class="nx">socket</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">  <span class="nx">console</span><span class="p">.</span><span class="nx">log</span><span class="p">(</span><span class="sb">`Client connected (</span><span class="si">${</span><span class="nx">clients</span><span class="p">.</span><span class="nx">size</span><span class="si">}</span><span class="sb"> total)`</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="nx">socket</span><span class="p">.</span><span class="nx">on</span><span class="p">(</span><span class="s1">&#39;data&#39;</span><span class="p">,</span> <span class="p">(</span><span class="nx">buffer</span><span class="p">)</span> <span class="p">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="kr">const</span> <span class="nx">message</span> <span class="o">=</span> <span class="nx">decodeFrame</span><span class="p">(</span><span class="nx">buffer</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="p">(</span><span class="nx">message</span> <span class="o">===</span> <span class="kc">null</span><span class="p">)</span> <span class="k">return</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="nx">console</span><span class="p">.</span><span class="nx">log</span><span class="p">(</span><span class="s1">&#39;Received:&#39;</span><span class="p">,</span> <span class="nx">message</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="c1">// Echo to all clients
</span></span></span><span class="line"><span class="cl"><span class="c1"></span>    <span class="k">for</span> <span class="p">(</span><span class="kr">const</span> <span class="nx">client</span> <span class="k">of</span> <span class="nx">clients</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">      <span class="nx">client</span><span class="p">.</span><span class="nx">write</span><span class="p">(</span><span class="nx">encodeFrame</span><span class="p">(</span><span class="nx">message</span><span class="p">));</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl">  <span class="p">});</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="nx">socket</span><span class="p">.</span><span class="nx">on</span><span class="p">(</span><span class="s1">&#39;close&#39;</span><span class="p">,</span> <span class="p">()</span> <span class="p">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="nx">clients</span><span class="p">.</span><span class="k">delete</span><span class="p">(</span><span class="nx">socket</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">    <span class="nx">console</span><span class="p">.</span><span class="nx">log</span><span class="p">(</span><span class="sb">`Client disconnected (</span><span class="si">${</span><span class="nx">clients</span><span class="p">.</span><span class="nx">size</span><span class="si">}</span><span class="sb"> total)`</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">  <span class="p">});</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="nx">socket</span><span class="p">.</span><span class="nx">on</span><span class="p">(</span><span class="s1">&#39;error&#39;</span><span class="p">,</span> <span class="p">()</span> <span class="p">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="nx">clients</span><span class="p">.</span><span class="k">delete</span><span class="p">(</span><span class="nx">socket</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">  <span class="p">});</span>
</span></span><span class="line"><span class="cl"><span class="p">});</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nx">server</span><span class="p">.</span><span class="nx">listen</span><span class="p">(</span><span class="mi">8080</span><span class="p">,</span> <span class="p">()</span> <span class="p">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="nx">console</span><span class="p">.</span><span class="nx">log</span><span class="p">(</span><span class="s1">&#39;WebSocket server on ws://localhost:8080&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="cl"><span class="p">});</span></span></span></code></pre></div>
</div>
<h2 id="frame-decoding">Frame Decoding<a class="anchor" href="#frame-decoding" aria-label="Link to “Frame Decoding”">#</a></h2>
<p>WebSocket messages are wrapped in frames. The frame format:</p>
<div class="code is-plain">
<div class="code-head"><button type="button" class="code-copy" data-copy-code hidden><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2.5"/><path d="M16 8V6.5A2.5 2.5 0 0 0 13.5 4h-7A2.5 2.5 0 0 0 4 6.5v7A2.5 2.5 0 0 0 6.5 16H8"/></svg><span>Copy</span></button></div>
<pre tabindex="0"><code> 0               1               2               3
 0 1 2 3 4 5 6 7 0 1 2 3 4 5 6 7 0 1 2 3 4 5 6 7 0 1 2 3 4 5 6 7
+-+-+-+-+-------+-+-------------+-------------------------------+
|F|R|R|R| opcode|M| Payload len |    Extended payload length    |
|I|S|S|S|  (4)  |A|     (7)     |            (16/64)            |
|N|V|V|V|       |S|             |                               |
| |1|2|3|       |K|             |                               |
+-+-+-+-+-------+-+-------------+-------------------------------+
|     Masking key (if MASK set)                                 |
+-------------------------------+-------------------------------+
|     Payload Data                                              |
+---------------------------------------------------------------+</code></pre>
</div>
<p>Client-to-server frames are always masked. Server-to-client frames are never masked. Here is the decoder:</p>
<div class="code">
<div class="code-head"><span class="code-lang">javascript</span><button type="button" class="code-copy" data-copy-code hidden><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2.5"/><path d="M16 8V6.5A2.5 2.5 0 0 0 13.5 4h-7A2.5 2.5 0 0 0 4 6.5v7A2.5 2.5 0 0 0 6.5 16H8"/></svg><span>Copy</span></button></div>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-javascript" data-lang="javascript"><span class="line"><span class="cl"><span class="kd">function</span> <span class="nx">decodeFrame</span><span class="p">(</span><span class="nx">buffer</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="kr">const</span> <span class="nx">firstByte</span> <span class="o">=</span> <span class="nx">buffer</span><span class="p">[</span><span class="mi">0</span><span class="p">];</span>
</span></span><span class="line"><span class="cl">  <span class="kr">const</span> <span class="nx">opcode</span> <span class="o">=</span> <span class="nx">firstByte</span> <span class="o">&amp;</span> <span class="mh">0x0f</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="c1">// 0x1 = text, 0x8 = close, 0x9 = ping, 0xA = pong
</span></span></span><span class="line"><span class="cl"><span class="c1"></span>  <span class="k">if</span> <span class="p">(</span><span class="nx">opcode</span> <span class="o">===</span> <span class="mh">0x8</span><span class="p">)</span> <span class="k">return</span> <span class="kc">null</span><span class="p">;</span> <span class="c1">// close frame
</span></span></span><span class="line"><span class="cl"><span class="c1"></span>
</span></span><span class="line"><span class="cl">  <span class="kr">const</span> <span class="nx">secondByte</span> <span class="o">=</span> <span class="nx">buffer</span><span class="p">[</span><span class="mi">1</span><span class="p">];</span>
</span></span><span class="line"><span class="cl">  <span class="kr">const</span> <span class="nx">isMasked</span> <span class="o">=</span> <span class="p">(</span><span class="nx">secondByte</span> <span class="o">&amp;</span> <span class="mh">0x80</span><span class="p">)</span> <span class="o">!==</span> <span class="mi">0</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">  <span class="kd">let</span> <span class="nx">payloadLength</span> <span class="o">=</span> <span class="nx">secondByte</span> <span class="o">&amp;</span> <span class="mh">0x7f</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">  <span class="kd">let</span> <span class="nx">offset</span> <span class="o">=</span> <span class="mi">2</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="k">if</span> <span class="p">(</span><span class="nx">payloadLength</span> <span class="o">===</span> <span class="mi">126</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="nx">payloadLength</span> <span class="o">=</span> <span class="nx">buffer</span><span class="p">.</span><span class="nx">readUInt16BE</span><span class="p">(</span><span class="mi">2</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">    <span class="nx">offset</span> <span class="o">=</span> <span class="mi">4</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">  <span class="p">}</span> <span class="k">else</span> <span class="k">if</span> <span class="p">(</span><span class="nx">payloadLength</span> <span class="o">===</span> <span class="mi">127</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="nx">payloadLength</span> <span class="o">=</span> <span class="nb">Number</span><span class="p">(</span><span class="nx">buffer</span><span class="p">.</span><span class="nx">readBigUInt64BE</span><span class="p">(</span><span class="mi">2</span><span class="p">));</span>
</span></span><span class="line"><span class="cl">    <span class="nx">offset</span> <span class="o">=</span> <span class="mi">10</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">  <span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="kd">let</span> <span class="nx">maskingKey</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">  <span class="k">if</span> <span class="p">(</span><span class="nx">isMasked</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="nx">maskingKey</span> <span class="o">=</span> <span class="nx">buffer</span><span class="p">.</span><span class="nx">slice</span><span class="p">(</span><span class="nx">offset</span><span class="p">,</span> <span class="nx">offset</span> <span class="o">+</span> <span class="mi">4</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">    <span class="nx">offset</span> <span class="o">+=</span> <span class="mi">4</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">  <span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="kr">const</span> <span class="nx">payload</span> <span class="o">=</span> <span class="nx">buffer</span><span class="p">.</span><span class="nx">slice</span><span class="p">(</span><span class="nx">offset</span><span class="p">,</span> <span class="nx">offset</span> <span class="o">+</span> <span class="nx">payloadLength</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="k">if</span> <span class="p">(</span><span class="nx">isMasked</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="k">for</span> <span class="p">(</span><span class="kd">let</span> <span class="nx">i</span> <span class="o">=</span> <span class="mi">0</span><span class="p">;</span> <span class="nx">i</span> <span class="o">&lt;</span> <span class="nx">payload</span><span class="p">.</span><span class="nx">length</span><span class="p">;</span> <span class="nx">i</span><span class="o">++</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">      <span class="nx">payload</span><span class="p">[</span><span class="nx">i</span><span class="p">]</span> <span class="o">^=</span> <span class="nx">maskingKey</span><span class="p">[</span><span class="nx">i</span> <span class="o">%</span> <span class="mi">4</span><span class="p">];</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl">  <span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="k">return</span> <span class="nx">payload</span><span class="p">.</span><span class="nx">toString</span><span class="p">(</span><span class="s1">&#39;utf-8&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div>
</div>
<h2 id="frame-encoding">Frame Encoding<a class="anchor" href="#frame-encoding" aria-label="Link to “Frame Encoding”">#</a></h2>
<p>Sending from server to client is simpler because we do not mask:</p>
<div class="code">
<div class="code-head"><span class="code-lang">javascript</span><button type="button" class="code-copy" data-copy-code hidden><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2.5"/><path d="M16 8V6.5A2.5 2.5 0 0 0 13.5 4h-7A2.5 2.5 0 0 0 4 6.5v7A2.5 2.5 0 0 0 6.5 16H8"/></svg><span>Copy</span></button></div>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-javascript" data-lang="javascript"><span class="line"><span class="cl"><span class="kd">function</span> <span class="nx">encodeFrame</span><span class="p">(</span><span class="nx">message</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="kr">const</span> <span class="nx">payload</span> <span class="o">=</span> <span class="nx">Buffer</span><span class="p">.</span><span class="nx">from</span><span class="p">(</span><span class="nx">message</span><span class="p">,</span> <span class="s1">&#39;utf-8&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">  <span class="kr">const</span> <span class="nx">length</span> <span class="o">=</span> <span class="nx">payload</span><span class="p">.</span><span class="nx">length</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="kd">let</span> <span class="nx">header</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">  <span class="k">if</span> <span class="p">(</span><span class="nx">length</span> <span class="o">&lt;</span> <span class="mi">126</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="nx">header</span> <span class="o">=</span> <span class="nx">Buffer</span><span class="p">.</span><span class="nx">alloc</span><span class="p">(</span><span class="mi">2</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">    <span class="nx">header</span><span class="p">[</span><span class="mi">0</span><span class="p">]</span> <span class="o">=</span> <span class="mh">0x81</span><span class="p">;</span> <span class="c1">// FIN + text opcode
</span></span></span><span class="line"><span class="cl"><span class="c1"></span>    <span class="nx">header</span><span class="p">[</span><span class="mi">1</span><span class="p">]</span> <span class="o">=</span> <span class="nx">length</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">  <span class="p">}</span> <span class="k">else</span> <span class="k">if</span> <span class="p">(</span><span class="nx">length</span> <span class="o">&lt;</span> <span class="mi">65536</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="nx">header</span> <span class="o">=</span> <span class="nx">Buffer</span><span class="p">.</span><span class="nx">alloc</span><span class="p">(</span><span class="mi">4</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">    <span class="nx">header</span><span class="p">[</span><span class="mi">0</span><span class="p">]</span> <span class="o">=</span> <span class="mh">0x81</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">    <span class="nx">header</span><span class="p">[</span><span class="mi">1</span><span class="p">]</span> <span class="o">=</span> <span class="mi">126</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">    <span class="nx">header</span><span class="p">.</span><span class="nx">writeUInt16BE</span><span class="p">(</span><span class="nx">length</span><span class="p">,</span> <span class="mi">2</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">  <span class="p">}</span> <span class="k">else</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="nx">header</span> <span class="o">=</span> <span class="nx">Buffer</span><span class="p">.</span><span class="nx">alloc</span><span class="p">(</span><span class="mi">10</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">    <span class="nx">header</span><span class="p">[</span><span class="mi">0</span><span class="p">]</span> <span class="o">=</span> <span class="mh">0x81</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">    <span class="nx">header</span><span class="p">[</span><span class="mi">1</span><span class="p">]</span> <span class="o">=</span> <span class="mi">127</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">    <span class="nx">header</span><span class="p">.</span><span class="nx">writeBigUInt64BE</span><span class="p">(</span><span class="nx">BigInt</span><span class="p">(</span><span class="nx">length</span><span class="p">),</span> <span class="mi">2</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">  <span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="k">return</span> <span class="nx">Buffer</span><span class="p">.</span><span class="nx">concat</span><span class="p">([</span><span class="nx">header</span><span class="p">,</span> <span class="nx">payload</span><span class="p">]);</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div>
</div>
<h2 id="testing-it">Testing It<a class="anchor" href="#testing-it" aria-label="Link to “Testing It”">#</a></h2>
<p>Open a browser console:</p>
<div class="code">
<div class="code-head"><span class="code-lang">javascript</span><button type="button" class="code-copy" data-copy-code hidden><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2.5"/><path d="M16 8V6.5A2.5 2.5 0 0 0 13.5 4h-7A2.5 2.5 0 0 0 4 6.5v7A2.5 2.5 0 0 0 6.5 16H8"/></svg><span>Copy</span></button></div>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-javascript" data-lang="javascript"><span class="line"><span class="cl"><span class="kr">const</span> <span class="nx">ws</span> <span class="o">=</span> <span class="k">new</span> <span class="nx">WebSocket</span><span class="p">(</span><span class="s1">&#39;ws://localhost:8080&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="cl"><span class="nx">ws</span><span class="p">.</span><span class="nx">onmessage</span> <span class="o">=</span> <span class="p">(</span><span class="nx">e</span><span class="p">)</span> <span class="p">=&gt;</span> <span class="nx">console</span><span class="p">.</span><span class="nx">log</span><span class="p">(</span><span class="s1">&#39;Got:&#39;</span><span class="p">,</span> <span class="nx">e</span><span class="p">.</span><span class="nx">data</span><span class="p">);</span>
</span></span><span class="line"><span class="cl"><span class="nx">ws</span><span class="p">.</span><span class="nx">onopen</span> <span class="o">=</span> <span class="p">()</span> <span class="p">=&gt;</span> <span class="nx">ws</span><span class="p">.</span><span class="nx">send</span><span class="p">(</span><span class="s1">&#39;hello from browser&#39;</span><span class="p">);</span></span></span></code></pre></div>
</div>
<p>Or use a Node.js client:</p>
<div class="code">
<div class="code-head"><span class="code-lang">javascript</span><button type="button" class="code-copy" data-copy-code hidden><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2.5"/><path d="M16 8V6.5A2.5 2.5 0 0 0 13.5 4h-7A2.5 2.5 0 0 0 4 6.5v7A2.5 2.5 0 0 0 6.5 16H8"/></svg><span>Copy</span></button></div>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-javascript" data-lang="javascript"><span class="line"><span class="cl"><span class="kr">const</span> <span class="nx">net</span> <span class="o">=</span> <span class="nx">require</span><span class="p">(</span><span class="s1">&#39;net&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="cl"><span class="kr">const</span> <span class="nx">crypto</span> <span class="o">=</span> <span class="nx">require</span><span class="p">(</span><span class="s1">&#39;crypto&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="kr">const</span> <span class="nx">key</span> <span class="o">=</span> <span class="nx">crypto</span><span class="p">.</span><span class="nx">randomBytes</span><span class="p">(</span><span class="mi">16</span><span class="p">).</span><span class="nx">toString</span><span class="p">(</span><span class="s1">&#39;base64&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="cl"><span class="kr">const</span> <span class="nx">socket</span> <span class="o">=</span> <span class="nx">net</span><span class="p">.</span><span class="nx">createConnection</span><span class="p">(</span><span class="mi">8080</span><span class="p">,</span> <span class="s1">&#39;localhost&#39;</span><span class="p">,</span> <span class="p">()</span> <span class="p">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="nx">socket</span><span class="p">.</span><span class="nx">write</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">    <span class="s1">&#39;GET / HTTP/1.1\r\n&#39;</span> <span class="o">+</span>
</span></span><span class="line"><span class="cl">    <span class="s1">&#39;Host: localhost:8080\r\n&#39;</span> <span class="o">+</span>
</span></span><span class="line"><span class="cl">    <span class="s1">&#39;Upgrade: websocket\r\n&#39;</span> <span class="o">+</span>
</span></span><span class="line"><span class="cl">    <span class="s1">&#39;Connection: Upgrade\r\n&#39;</span> <span class="o">+</span>
</span></span><span class="line"><span class="cl">    <span class="sb">`Sec-WebSocket-Key: </span><span class="si">${</span><span class="nx">key</span><span class="si">}</span><span class="sb">\r\n`</span> <span class="o">+</span>
</span></span><span class="line"><span class="cl">    <span class="s1">&#39;Sec-WebSocket-Version: 13\r\n&#39;</span> <span class="o">+</span>
</span></span><span class="line"><span class="cl">    <span class="s1">&#39;\r\n&#39;</span>
</span></span><span class="line"><span class="cl">  <span class="p">);</span>
</span></span><span class="line"><span class="cl"><span class="p">});</span></span></span></code></pre></div>
</div>
<h2 id="what-this-does-not-handle">What This Does Not Handle<a class="anchor" href="#what-this-does-not-handle" aria-label="Link to “What This Does Not Handle”">#</a></h2>
<p>This implementation is deliberately minimal. A production WebSocket server also needs:</p>
<ul>
<li><strong>Ping/pong frames</strong> for connection keepalive</li>
<li><strong>Fragmented messages</strong> where large payloads span multiple frames</li>
<li><strong>Close handshake</strong> with proper status codes</li>
<li><strong>Per-message compression</strong> (the permessage-deflate extension)</li>
<li><strong>Backpressure</strong> when clients read slower than you write</li>
</ul>
<p>That is why libraries like ws exist. But knowing the protocol means you understand what those libraries are doing, and you can debug problems at the frame level instead of guessing.</p>
<p>The entire WebSocket protocol fits in RFC 6455, which is surprisingly readable as RFCs go. The core framing logic is maybe 100 lines of code. Everything else is just handling edge cases.</p>
]]></content:encoded></item><item><title>awk in 10 Minutes With Practical Examples</title><link>https://articles.wleeaf.dev/posts/awk-practical-examples/</link><pubDate>Sat, 04 Apr 2026 00:00:00 +0000</pubDate><guid>https://articles.wleeaf.dev/posts/awk-practical-examples/</guid><description>Nobody reads the awk manual. Here are the patterns that cover 90% of real-world use.</description><category>awk</category><category>cli</category><category>linux</category><content:encoded><![CDATA[<p>awk is one of those tools that looks intimidating until you learn five patterns. Then you use it every day. I am not going to explain the full language. Here are the patterns that actually matter.</p>
<h2 id="pattern-1-print-a-column">Pattern 1: Print a Column<a class="anchor" href="#pattern-1-print-a-column" aria-label="Link to “Pattern 1: Print a Column”">#</a></h2>
<p>awk splits each line into fields. <code>$1</code> is the first, <code>$2</code> is the second, <code>$0</code> is the whole line:</p>
<div class="code">
<div class="code-head"><span class="code-lang">bash</span><button type="button" class="code-copy" data-copy-code hidden><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2.5"/><path d="M16 8V6.5A2.5 2.5 0 0 0 13.5 4h-7A2.5 2.5 0 0 0 4 6.5v7A2.5 2.5 0 0 0 6.5 16H8"/></svg><span>Copy</span></button></div>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="c1"># Print the first column of ps output</span>
</span></span><span class="line"><span class="cl">ps aux <span class="p">|</span> awk <span class="s1">&#39;{print $1}&#39;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Print columns 1 and 11 (user and command)</span>
</span></span><span class="line"><span class="cl">ps aux <span class="p">|</span> awk <span class="s1">&#39;{print $1, $11}&#39;</span></span></span></code></pre></div>
</div>
<p>The default field separator is whitespace. Change it with <code>-F</code>:</p>
<div class="code">
<div class="code-head"><span class="code-lang">bash</span><button type="button" class="code-copy" data-copy-code hidden><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2.5"/><path d="M16 8V6.5A2.5 2.5 0 0 0 13.5 4h-7A2.5 2.5 0 0 0 4 6.5v7A2.5 2.5 0 0 0 6.5 16H8"/></svg><span>Copy</span></button></div>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="c1"># Parse /etc/passwd (colon-separated)</span>
</span></span><span class="line"><span class="cl">awk -F: <span class="s1">&#39;{print $1, $7}&#39;</span> /etc/passwd
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Parse CSV</span>
</span></span><span class="line"><span class="cl">awk -F, <span class="s1">&#39;{print $2}&#39;</span> data.csv</span></span></code></pre></div>
</div>
<h2 id="pattern-2-filter-rows">Pattern 2: Filter Rows<a class="anchor" href="#pattern-2-filter-rows" aria-label="Link to “Pattern 2: Filter Rows”">#</a></h2>
<p>Put a condition before the action:</p>
<div class="code">
<div class="code-head"><span class="code-lang">bash</span><button type="button" class="code-copy" data-copy-code hidden><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2.5"/><path d="M16 8V6.5A2.5 2.5 0 0 0 13.5 4h-7A2.5 2.5 0 0 0 4 6.5v7A2.5 2.5 0 0 0 6.5 16H8"/></svg><span>Copy</span></button></div>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="c1"># Lines where column 3 is greater than 100</span>
</span></span><span class="line"><span class="cl">awk <span class="s1">&#39;$3 &gt; 100&#39;</span> data.txt
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Lines containing &#34;ERROR&#34;</span>
</span></span><span class="line"><span class="cl">awk <span class="s1">&#39;/ERROR/&#39;</span> app.log
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Lines where the first field matches a pattern</span>
</span></span><span class="line"><span class="cl">awk <span class="s1">&#39;$1 ~ /^192\.168/&#39;</span> access.log</span></span></code></pre></div>
</div>
<p>Combine conditions:</p>
<div class="code">
<div class="code-head"><span class="code-lang">bash</span><button type="button" class="code-copy" data-copy-code hidden><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2.5"/><path d="M16 8V6.5A2.5 2.5 0 0 0 13.5 4h-7A2.5 2.5 0 0 0 4 6.5v7A2.5 2.5 0 0 0 6.5 16H8"/></svg><span>Copy</span></button></div>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="c1"># Memory usage over 50% AND the process is not root</span>
</span></span><span class="line"><span class="cl">ps aux <span class="p">|</span> awk <span class="s1">&#39;$4 &gt; 50.0 &amp;&amp; $1 != &#34;root&#34; {print $1, $4, $11}&#39;</span></span></span></code></pre></div>
</div>
<h2 id="pattern-3-sum-a-column">Pattern 3: Sum a Column<a class="anchor" href="#pattern-3-sum-a-column" aria-label="Link to “Pattern 3: Sum a Column”">#</a></h2>
<div class="code">
<div class="code-head"><span class="code-lang">bash</span><button type="button" class="code-copy" data-copy-code hidden><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2.5"/><path d="M16 8V6.5A2.5 2.5 0 0 0 13.5 4h-7A2.5 2.5 0 0 0 4 6.5v7A2.5 2.5 0 0 0 6.5 16H8"/></svg><span>Copy</span></button></div>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="c1"># Total bytes transferred (column 10 in access logs)</span>
</span></span><span class="line"><span class="cl">awk <span class="s1">&#39;{sum += $10} END {print sum}&#39;</span> access.log
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Average response time</span>
</span></span><span class="line"><span class="cl">awk <span class="s1">&#39;{sum += $NF; n++} END {print sum/n}&#39;</span> response_times.log</span></span></code></pre></div>
</div>
<p><code>END</code> runs after all lines are processed. <code>NF</code> is the number of fields on the current line, so <code>$NF</code> is the last field.</p>
<h2 id="pattern-4-count-and-group">Pattern 4: Count and Group<a class="anchor" href="#pattern-4-count-and-group" aria-label="Link to “Pattern 4: Count and Group”">#</a></h2>
<div class="code">
<div class="code-head"><span class="code-lang">bash</span><button type="button" class="code-copy" data-copy-code hidden><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2.5"/><path d="M16 8V6.5A2.5 2.5 0 0 0 13.5 4h-7A2.5 2.5 0 0 0 4 6.5v7A2.5 2.5 0 0 0 6.5 16H8"/></svg><span>Copy</span></button></div>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="c1"># Count HTTP status codes</span>
</span></span><span class="line"><span class="cl">awk <span class="s1">&#39;{count[$9]++} END {for (c in count) print c, count[c]}&#39;</span> access.log <span class="p">|</span> sort -rn -k2
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Output:</span>
</span></span><span class="line"><span class="cl"><span class="c1"># 200 45231</span>
</span></span><span class="line"><span class="cl"><span class="c1"># 304 12044</span>
</span></span><span class="line"><span class="cl"><span class="c1"># 404 891</span>
</span></span><span class="line"><span class="cl"><span class="c1"># 500 23</span></span></span></code></pre></div>
</div>
<p>This uses an associative array. <code>count[$9]++</code> increments the count for each unique value in column 9.</p>
<p>Top 10 IP addresses by request count:</p>
<div class="code">
<div class="code-head"><span class="code-lang">bash</span><button type="button" class="code-copy" data-copy-code hidden><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2.5"/><path d="M16 8V6.5A2.5 2.5 0 0 0 13.5 4h-7A2.5 2.5 0 0 0 4 6.5v7A2.5 2.5 0 0 0 6.5 16H8"/></svg><span>Copy</span></button></div>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">awk <span class="s1">&#39;{count[$1]++} END {for (ip in count) print count[ip], ip}&#39;</span> access.log <span class="p">|</span> sort -rn <span class="p">|</span> head -10</span></span></code></pre></div>
</div>
<h2 id="pattern-5-transform-fields">Pattern 5: Transform Fields<a class="anchor" href="#pattern-5-transform-fields" aria-label="Link to “Pattern 5: Transform Fields”">#</a></h2>
<div class="code">
<div class="code-head"><span class="code-lang">bash</span><button type="button" class="code-copy" data-copy-code hidden><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2.5"/><path d="M16 8V6.5A2.5 2.5 0 0 0 13.5 4h-7A2.5 2.5 0 0 0 4 6.5v7A2.5 2.5 0 0 0 6.5 16H8"/></svg><span>Copy</span></button></div>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="c1"># Convert bytes to megabytes</span>
</span></span><span class="line"><span class="cl">awk <span class="s1">&#39;{printf &#34;%s %.1fMB\n&#34;, $1, $2/1048576}&#39;</span> sizes.txt
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Add a prefix to every line</span>
</span></span><span class="line"><span class="cl">awk <span class="s1">&#39;{print &#34;[INFO]&#34;, $0}&#39;</span> app.log
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Swap two columns</span>
</span></span><span class="line"><span class="cl">awk <span class="s1">&#39;{temp=$1; $1=$2; $2=temp; print}&#39;</span> data.txt</span></span></code></pre></div>
</div>
<h2 id="real-world-examples">Real-World Examples<a class="anchor" href="#real-world-examples" aria-label="Link to “Real-World Examples”">#</a></h2>
<h3 id="parse-docker-ps-output">Parse Docker ps output<a class="anchor" href="#parse-docker-ps-output" aria-label="Link to “Parse Docker ps output”">#</a></h3>
<div class="code">
<div class="code-head"><span class="code-lang">bash</span><button type="button" class="code-copy" data-copy-code hidden><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2.5"/><path d="M16 8V6.5A2.5 2.5 0 0 0 13.5 4h-7A2.5 2.5 0 0 0 4 6.5v7A2.5 2.5 0 0 0 6.5 16H8"/></svg><span>Copy</span></button></div>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">docker ps --format <span class="s1">&#39;{{.Names}}\t{{.Status}}\t{{.Ports}}&#39;</span> <span class="p">|</span> <span class="se">\
</span></span></span><span class="line"><span class="cl"><span class="se"></span>  awk -F<span class="s1">&#39;\t&#39;</span> <span class="s1">&#39;{printf &#34;%-20s %-15s %s\n&#34;, $1, $2, $3}&#39;</span></span></span></code></pre></div>
</div>
<h3 id="find-the-largest-docker-images">Find the largest Docker images<a class="anchor" href="#find-the-largest-docker-images" aria-label="Link to “Find the largest Docker images”">#</a></h3>
<div class="code">
<div class="code-head"><span class="code-lang">bash</span><button type="button" class="code-copy" data-copy-code hidden><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2.5"/><path d="M16 8V6.5A2.5 2.5 0 0 0 13.5 4h-7A2.5 2.5 0 0 0 4 6.5v7A2.5 2.5 0 0 0 6.5 16H8"/></svg><span>Copy</span></button></div>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">docker images --format <span class="s1">&#39;{{.Repository}}:{{.Tag}}\t{{.Size}}&#39;</span> <span class="p">|</span> <span class="se">\
</span></span></span><span class="line"><span class="cl"><span class="se"></span>  awk -F<span class="s1">&#39;\t&#39;</span> <span class="s1">&#39;{
</span></span></span><span class="line"><span class="cl"><span class="s1">    size = $2
</span></span></span><span class="line"><span class="cl"><span class="s1">    if (size ~ /GB/) {
</span></span></span><span class="line"><span class="cl"><span class="s1">      gsub(/GB/, &#34;&#34;, size)
</span></span></span><span class="line"><span class="cl"><span class="s1">      mb = size * 1024
</span></span></span><span class="line"><span class="cl"><span class="s1">    } else if (size ~ /MB/) {
</span></span></span><span class="line"><span class="cl"><span class="s1">      gsub(/MB/, &#34;&#34;, size)
</span></span></span><span class="line"><span class="cl"><span class="s1">      mb = size
</span></span></span><span class="line"><span class="cl"><span class="s1">    } else {
</span></span></span><span class="line"><span class="cl"><span class="s1">      mb = 0
</span></span></span><span class="line"><span class="cl"><span class="s1">    }
</span></span></span><span class="line"><span class="cl"><span class="s1">    printf &#34;%8.0fMB  %s\n&#34;, mb, $1
</span></span></span><span class="line"><span class="cl"><span class="s1">  }&#39;</span> <span class="p">|</span> sort -rn <span class="p">|</span> head -10</span></span></code></pre></div>
</div>
<h3 id="calculate-uptime-percentage">Calculate uptime percentage<a class="anchor" href="#calculate-uptime-percentage" aria-label="Link to “Calculate uptime percentage”">#</a></h3>
<p>Given a log with timestamps and status:</p>
<div class="code">
<div class="code-head"><span class="code-lang">bash</span><button type="button" class="code-copy" data-copy-code hidden><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2.5"/><path d="M16 8V6.5A2.5 2.5 0 0 0 13.5 4h-7A2.5 2.5 0 0 0 4 6.5v7A2.5 2.5 0 0 0 6.5 16H8"/></svg><span>Copy</span></button></div>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">awk <span class="s1">&#39;
</span></span></span><span class="line"><span class="cl"><span class="s1">  /UP/   { up++ }
</span></span></span><span class="line"><span class="cl"><span class="s1">  /DOWN/ { down++ }
</span></span></span><span class="line"><span class="cl"><span class="s1">  END {
</span></span></span><span class="line"><span class="cl"><span class="s1">    total = up + down
</span></span></span><span class="line"><span class="cl"><span class="s1">    if (total &gt; 0)
</span></span></span><span class="line"><span class="cl"><span class="s1">      printf &#34;Uptime: %.2f%% (%d/%d checks)\n&#34;, (up/total)*100, up, total
</span></span></span><span class="line"><span class="cl"><span class="s1">  }
</span></span></span><span class="line"><span class="cl"><span class="s1">&#39;</span> monitoring.log</span></span></code></pre></div>
</div>
<h3 id="extract-unique-error-messages">Extract unique error messages<a class="anchor" href="#extract-unique-error-messages" aria-label="Link to “Extract unique error messages”">#</a></h3>
<div class="code">
<div class="code-head"><span class="code-lang">bash</span><button type="button" class="code-copy" data-copy-code hidden><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2.5"/><path d="M16 8V6.5A2.5 2.5 0 0 0 13.5 4h-7A2.5 2.5 0 0 0 4 6.5v7A2.5 2.5 0 0 0 6.5 16H8"/></svg><span>Copy</span></button></div>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">awk <span class="s1">&#39;/ERROR/ {
</span></span></span><span class="line"><span class="cl"><span class="s1">  msg = &#34;&#34;
</span></span></span><span class="line"><span class="cl"><span class="s1">  for (i=4; i&lt;=NF; i++) msg = msg &#34; &#34; $i
</span></span></span><span class="line"><span class="cl"><span class="s1">  errors[msg]++
</span></span></span><span class="line"><span class="cl"><span class="s1">}
</span></span></span><span class="line"><span class="cl"><span class="s1">END {
</span></span></span><span class="line"><span class="cl"><span class="s1">  for (e in errors) printf &#34;%5d %s\n&#34;, errors[e], e
</span></span></span><span class="line"><span class="cl"><span class="s1">}&#39;</span> app.log <span class="p">|</span> sort -rn <span class="p">|</span> head -20</span></span></code></pre></div>
</div>
<h2 id="built-in-variables">Built-in Variables<a class="anchor" href="#built-in-variables" aria-label="Link to “Built-in Variables”">#</a></h2>
<table>
  <thead>
      <tr>
          <th>Variable</th>
          <th>Meaning</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>NR</code></td>
          <td>Current line number</td>
      </tr>
      <tr>
          <td><code>NF</code></td>
          <td>Number of fields on current line</td>
      </tr>
      <tr>
          <td><code>FS</code></td>
          <td>Field separator</td>
      </tr>
      <tr>
          <td><code>OFS</code></td>
          <td>Output field separator</td>
      </tr>
      <tr>
          <td><code>$0</code></td>
          <td>Entire line</td>
      </tr>
      <tr>
          <td><code>$1..$n</code></td>
          <td>Individual fields</td>
      </tr>
      <tr>
          <td><code>FILENAME</code></td>
          <td>Current filename</td>
      </tr>
  </tbody>
</table>
<h2 id="when-to-use-awk-vs-other-tools">When to Use awk vs. Other Tools<a class="anchor" href="#when-to-use-awk-vs-other-tools" aria-label="Link to “When to Use awk vs. Other Tools”">#</a></h2>
<ul>
<li><strong>cut</strong>: When you just need a column and nothing else. <code>cut -d: -f1 /etc/passwd</code> is simpler than awk for trivial extraction.</li>
<li><strong>grep</strong>: When you just need to filter lines by a pattern.</li>
<li><strong>sed</strong>: When you need to do find-and-replace on text.</li>
<li><strong>awk</strong>: When you need to do math, grouping, conditional logic, or work with multiple fields at once.</li>
</ul>
<p>The mental model is simple. awk reads lines, splits them into fields, and lets you write rules about what to do with each line. Once that clicks, you start seeing awk solutions everywhere.</p>
]]></content:encoded></item><item><title>Makefiles Are Not Just for C</title><link>https://articles.wleeaf.dev/posts/makefiles-beyond-c/</link><pubDate>Fri, 03 Apr 2026 00:00:00 +0000</pubDate><guid>https://articles.wleeaf.dev/posts/makefiles-beyond-c/</guid><description>Using Make as a project task runner when npm scripts and shell aliases fall short.</description><category>make</category><category>automation</category><category>workflow</category><content:encoded><![CDATA[<p>Every project accumulates a collection of commands. Build this, test that, deploy here, clean up there. Most people throw these into npm scripts, a shell script, or a README that nobody reads. I put them in a Makefile.</p>
<p>Not because I write C. Because Make is a task runner that is already installed on every machine.</p>
<h2 id="the-basics">The Basics<a class="anchor" href="#the-basics" aria-label="Link to “The Basics”">#</a></h2>
<div class="code">
<div class="code-head"><span class="code-lang">makefile</span><button type="button" class="code-copy" data-copy-code hidden><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2.5"/><path d="M16 8V6.5A2.5 2.5 0 0 0 13.5 4h-7A2.5 2.5 0 0 0 4 6.5v7A2.5 2.5 0 0 0 6.5 16H8"/></svg><span>Copy</span></button></div>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-makefile" data-lang="makefile"><span class="line"><span class="cl"><span class="nf">.PHONY</span><span class="o">:</span> <span class="n">build</span> <span class="n">test</span> <span class="n">deploy</span> <span class="n">clean</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nf">build</span><span class="o">:</span>
</span></span><span class="line"><span class="cl">	docker compose build
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nf">test</span><span class="o">:</span>
</span></span><span class="line"><span class="cl">	docker compose run --rm app npm <span class="nb">test</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nf">deploy</span><span class="o">:</span> <span class="n">build</span>
</span></span><span class="line"><span class="cl">	rsync -a --delete ./dist/ server:/opt/app/
</span></span><span class="line"><span class="cl">	ssh server <span class="s2">&#34;cd /opt/app &amp;&amp; docker compose up -d&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nf">clean</span><span class="o">:</span>
</span></span><span class="line"><span class="cl">	docker compose down -v
</span></span><span class="line"><span class="cl">	rm -rf dist node_modules
</span></span></code></pre></div>
</div>
<p>Now:</p>
<div class="code">
<div class="code-head"><span class="code-lang">bash</span><button type="button" class="code-copy" data-copy-code hidden><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2.5"/><path d="M16 8V6.5A2.5 2.5 0 0 0 13.5 4h-7A2.5 2.5 0 0 0 4 6.5v7A2.5 2.5 0 0 0 6.5 16H8"/></svg><span>Copy</span></button></div>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">make build
</span></span><span class="line"><span class="cl">make <span class="nb">test</span>
</span></span><span class="line"><span class="cl">make deploy
</span></span><span class="line"><span class="cl">make clean</span></span></code></pre></div>
</div>
<p><code>.PHONY</code> tells Make these targets are commands, not files. Without it, if you have a directory called <code>build</code>, Make would say &ldquo;build is up to date&rdquo; and do nothing.</p>
<h2 id="dependencies">Dependencies<a class="anchor" href="#dependencies" aria-label="Link to “Dependencies”">#</a></h2>
<p>The power of Make is in the dependency graph. <code>deploy</code> depends on <code>build</code>:</p>
<div class="code">
<div class="code-head"><span class="code-lang">makefile</span><button type="button" class="code-copy" data-copy-code hidden><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2.5"/><path d="M16 8V6.5A2.5 2.5 0 0 0 13.5 4h-7A2.5 2.5 0 0 0 4 6.5v7A2.5 2.5 0 0 0 6.5 16H8"/></svg><span>Copy</span></button></div>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-makefile" data-lang="makefile"><span class="line"><span class="cl"><span class="nf">deploy</span><span class="o">:</span> <span class="n">build</span>
</span></span><span class="line"><span class="cl">	rsync -a --delete ./dist/ server:/opt/app/
</span></span></code></pre></div>
</div>
<p>When you run <code>make deploy</code>, it runs <code>build</code> first. If <code>build</code> fails, <code>deploy</code> never runs. This is what npm scripts cannot do cleanly.</p>
<h2 id="variables">Variables<a class="anchor" href="#variables" aria-label="Link to “Variables”">#</a></h2>
<div class="code">
<div class="code-head"><span class="code-lang">makefile</span><button type="button" class="code-copy" data-copy-code hidden><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2.5"/><path d="M16 8V6.5A2.5 2.5 0 0 0 13.5 4h-7A2.5 2.5 0 0 0 4 6.5v7A2.5 2.5 0 0 0 6.5 16H8"/></svg><span>Copy</span></button></div>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-makefile" data-lang="makefile"><span class="line"><span class="cl"><span class="nv">SERVER</span> <span class="o">:=</span> myserver
</span></span><span class="line"><span class="cl"><span class="nv">REMOTE_DIR</span> <span class="o">:=</span> /opt/app
</span></span><span class="line"><span class="cl"><span class="nv">IMAGE</span> <span class="o">:=</span> myapp:latest
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nf">build</span><span class="o">:</span>
</span></span><span class="line"><span class="cl">	docker build -t <span class="k">$(</span>IMAGE<span class="k">)</span> .
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nf">push</span><span class="o">:</span> <span class="n">build</span>
</span></span><span class="line"><span class="cl">	docker push <span class="k">$(</span>IMAGE<span class="k">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nf">deploy</span><span class="o">:</span> <span class="n">push</span>
</span></span><span class="line"><span class="cl">	ssh <span class="k">$(</span>SERVER<span class="k">)</span> <span class="s2">&#34;cd </span><span class="k">$(</span>REMOTE_DIR<span class="k">)</span><span class="s2"> &amp;&amp; docker pull </span><span class="k">$(</span>IMAGE<span class="k">)</span><span class="s2"> &amp;&amp; docker compose up -d&#34;</span>
</span></span></code></pre></div>
</div>
<p>Override from the command line:</p>
<div class="code">
<div class="code-head"><span class="code-lang">bash</span><button type="button" class="code-copy" data-copy-code hidden><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2.5"/><path d="M16 8V6.5A2.5 2.5 0 0 0 13.5 4h-7A2.5 2.5 0 0 0 4 6.5v7A2.5 2.5 0 0 0 6.5 16H8"/></svg><span>Copy</span></button></div>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">make deploy <span class="nv">SERVER</span><span class="o">=</span>staging-server</span></span></code></pre></div>
</div>
<h2 id="multi-stage-builds-with-file-targets">Multi-Stage Builds With File Targets<a class="anchor" href="#multi-stage-builds-with-file-targets" aria-label="Link to “Multi-Stage Builds With File Targets”">#</a></h2>
<p>Make tracks file timestamps. This is useful for caching:</p>
<div class="code">
<div class="code-head"><span class="code-lang">makefile</span><button type="button" class="code-copy" data-copy-code hidden><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2.5"/><path d="M16 8V6.5A2.5 2.5 0 0 0 13.5 4h-7A2.5 2.5 0 0 0 4 6.5v7A2.5 2.5 0 0 0 6.5 16H8"/></svg><span>Copy</span></button></div>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-makefile" data-lang="makefile"><span class="line"><span class="cl"><span class="nf">node_modules</span><span class="o">:</span> <span class="n">package</span>.<span class="n">json</span> <span class="n">package</span>-<span class="n">lock</span>.<span class="n">json</span>
</span></span><span class="line"><span class="cl">	npm ci
</span></span><span class="line"><span class="cl">	touch node_modules
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nf">dist</span><span class="o">:</span> <span class="n">node_modules</span> <span class="k">$(</span><span class="nv">shell</span> <span class="nv">find</span> <span class="nv">src</span> -<span class="nv">type</span> <span class="nv">f</span><span class="k">)</span>
</span></span><span class="line"><span class="cl">	npm run build
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nf">.PHONY</span><span class="o">:</span> <span class="n">serve</span>
</span></span><span class="line"><span class="cl"><span class="nf">serve</span><span class="o">:</span> <span class="n">dist</span>
</span></span><span class="line"><span class="cl">	python3 -m http.server <span class="m">8080</span> -d dist
</span></span></code></pre></div>
</div>
<p><code>node_modules</code> only rebuilds if <code>package.json</code> or <code>package-lock.json</code> changed. <code>dist</code> only rebuilds if source files changed. Make compares file timestamps automatically.</p>
<h2 id="help-target">Help Target<a class="anchor" href="#help-target" aria-label="Link to “Help Target”">#</a></h2>
<p>Add a self-documenting help target:</p>
<div class="code">
<div class="code-head"><span class="code-lang">makefile</span><button type="button" class="code-copy" data-copy-code hidden><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2.5"/><path d="M16 8V6.5A2.5 2.5 0 0 0 13.5 4h-7A2.5 2.5 0 0 0 4 6.5v7A2.5 2.5 0 0 0 6.5 16H8"/></svg><span>Copy</span></button></div>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-makefile" data-lang="makefile"><span class="line"><span class="cl"><span class="nv">.DEFAULT_GOAL</span> <span class="o">:=</span> <span class="nb">help</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nf">help</span><span class="o">:</span> <span class="c">## Show this help
</span></span></span><span class="line"><span class="cl"><span class="c"></span>	@grep -E <span class="s1">&#39;^[a-zA-Z_-]+:.*?## .*$$&#39;</span> <span class="k">$(</span>MAKEFILE_LIST<span class="k">)</span> <span class="p">|</span> <span class="se">\
</span></span></span><span class="line"><span class="cl"><span class="se"></span>		awk <span class="s1">&#39;BEGIN {FS = &#34;:.*?## &#34;}; {printf &#34;  \033[36m%-15s\033[0m %s\n&#34;, $$1, $$2}&#39;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nf">build</span><span class="o">:</span> <span class="c">## Build the Docker image
</span></span></span><span class="line"><span class="cl"><span class="c"></span>	docker compose build
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nf">test</span><span class="o">:</span> <span class="c">## Run the test suite
</span></span></span><span class="line"><span class="cl"><span class="c"></span>	docker compose run --rm app npm <span class="nb">test</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nf">deploy</span><span class="o">:</span> <span class="c">## Deploy to production
</span></span></span><span class="line"><span class="cl"><span class="c"></span>	./deploy.sh
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nf">clean</span><span class="o">:</span> <span class="c">## Remove all build artifacts
</span></span></span><span class="line"><span class="cl"><span class="c"></span>	rm -rf dist node_modules
</span></span></code></pre></div>
</div>
<p>Now <code>make</code> with no arguments prints:</p>
<div class="code is-plain">
<div class="code-head"><button type="button" class="code-copy" data-copy-code hidden><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2.5"/><path d="M16 8V6.5A2.5 2.5 0 0 0 13.5 4h-7A2.5 2.5 0 0 0 4 6.5v7A2.5 2.5 0 0 0 6.5 16H8"/></svg><span>Copy</span></button></div>
<pre tabindex="0"><code>  build           Build the Docker image
  test            Run the test suite
  deploy          Deploy to production
  clean           Remove all build artifacts</code></pre>
</div>
<h2 id="environment-specific-configs">Environment-Specific Configs<a class="anchor" href="#environment-specific-configs" aria-label="Link to “Environment-Specific Configs”">#</a></h2>
<div class="code">
<div class="code-head"><span class="code-lang">makefile</span><button type="button" class="code-copy" data-copy-code hidden><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2.5"/><path d="M16 8V6.5A2.5 2.5 0 0 0 13.5 4h-7A2.5 2.5 0 0 0 4 6.5v7A2.5 2.5 0 0 0 6.5 16H8"/></svg><span>Copy</span></button></div>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-makefile" data-lang="makefile"><span class="line"><span class="cl"><span class="nv">ENV</span> <span class="o">?=</span> development
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="err">ifeq</span> <span class="err">(</span><span class="k">$(</span><span class="nv">ENV</span><span class="k">)</span><span class="err">,production)</span>
</span></span><span class="line"><span class="cl">  <span class="nv">COMPOSE_FILE</span> <span class="o">:=</span> docker-compose.yml -f docker-compose.prod.yml
</span></span><span class="line"><span class="cl"><span class="err">else</span>
</span></span><span class="line"><span class="cl">  <span class="nv">COMPOSE_FILE</span> <span class="o">:=</span> docker-compose.yml
</span></span><span class="line"><span class="cl"><span class="err">endif</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nf">up</span><span class="o">:</span>
</span></span><span class="line"><span class="cl">	docker compose <span class="k">$(</span>addprefix -f ,<span class="k">$(</span>COMPOSE_FILE<span class="k">))</span> up -d
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c"># Usage:
</span></span></span><span class="line"><span class="cl"><span class="c"># make up           → development
</span></span></span><span class="line"><span class="cl"><span class="c"># make up ENV=production  → production
</span></span></span></code></pre></div>
</div>
<h2 id="parallel-execution">Parallel Execution<a class="anchor" href="#parallel-execution" aria-label="Link to “Parallel Execution”">#</a></h2>
<p>Make can run independent targets in parallel:</p>
<div class="code">
<div class="code-head"><span class="code-lang">bash</span><button type="button" class="code-copy" data-copy-code hidden><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2.5"/><path d="M16 8V6.5A2.5 2.5 0 0 0 13.5 4h-7A2.5 2.5 0 0 0 4 6.5v7A2.5 2.5 0 0 0 6.5 16H8"/></svg><span>Copy</span></button></div>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">make -j4 lint <span class="nb">test</span> typecheck</span></span></code></pre></div>
</div>
<p>As long as these targets do not depend on each other, Make runs all four simultaneously. This is free parallelism with no extra scripting.</p>
<h2 id="a-real-project-makefile">A Real Project Makefile<a class="anchor" href="#a-real-project-makefile" aria-label="Link to “A Real Project Makefile”">#</a></h2>
<div class="code">
<div class="code-head"><span class="code-lang">makefile</span><button type="button" class="code-copy" data-copy-code hidden><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2.5"/><path d="M16 8V6.5A2.5 2.5 0 0 0 13.5 4h-7A2.5 2.5 0 0 0 4 6.5v7A2.5 2.5 0 0 0 6.5 16H8"/></svg><span>Copy</span></button></div>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-makefile" data-lang="makefile"><span class="line"><span class="cl"><span class="nf">.PHONY</span><span class="o">:</span> <span class="n">help</span> <span class="n">dev</span> <span class="n">build</span> <span class="n">test</span> <span class="n">lint</span> <span class="n">deploy</span> <span class="n">clean</span>
</span></span><span class="line"><span class="cl"><span class="nv">.DEFAULT_GOAL</span> <span class="o">:=</span> <span class="nb">help</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nv">IMAGE</span> <span class="o">:=</span> myapp
</span></span><span class="line"><span class="cl"><span class="nv">TAG</span> <span class="o">:=</span> <span class="k">$(</span>shell git rev-parse --short HEAD<span class="k">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nf">help</span><span class="o">:</span>
</span></span><span class="line"><span class="cl">	@grep -E <span class="s1">&#39;^[a-zA-Z_-]+:.*?## .*$$&#39;</span> <span class="k">$(</span>MAKEFILE_LIST<span class="k">)</span> <span class="p">|</span> <span class="se">\
</span></span></span><span class="line"><span class="cl"><span class="se"></span>		awk <span class="s1">&#39;BEGIN {FS = &#34;:.*?## &#34;}; {printf &#34;  \033[36m%-15s\033[0m %s\n&#34;, $$1, $$2}&#39;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nf">dev</span><span class="o">:</span> <span class="c">## Start development server
</span></span></span><span class="line"><span class="cl"><span class="c"></span>	docker compose up -d
</span></span><span class="line"><span class="cl">	docker compose logs -f app
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nf">build</span><span class="o">:</span> <span class="c">## Build production image
</span></span></span><span class="line"><span class="cl"><span class="c"></span>	docker build -t <span class="k">$(</span>IMAGE<span class="k">)</span>:<span class="k">$(</span>TAG<span class="k">)</span> -t <span class="k">$(</span>IMAGE<span class="k">)</span>:latest .
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nf">test</span><span class="o">:</span> <span class="c">## Run tests
</span></span></span><span class="line"><span class="cl"><span class="c"></span>	docker compose run --rm app npm <span class="nb">test</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nf">lint</span><span class="o">:</span> <span class="c">## Run linter
</span></span></span><span class="line"><span class="cl"><span class="c"></span>	docker compose run --rm app npm run lint
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nf">deploy</span><span class="o">:</span> <span class="n">build</span> <span class="c">## Deploy to production
</span></span></span><span class="line"><span class="cl"><span class="c"></span>	docker push <span class="k">$(</span>IMAGE<span class="k">)</span>:<span class="k">$(</span>TAG<span class="k">)</span>
</span></span><span class="line"><span class="cl">	ssh server <span class="s2">&#34;docker pull </span><span class="k">$(</span>IMAGE<span class="k">)</span><span class="s2">:</span><span class="k">$(</span>TAG<span class="k">)</span><span class="s2"> &amp;&amp; docker compose up -d&#34;</span>
</span></span><span class="line"><span class="cl">	@echo <span class="s2">&#34;Deployed </span><span class="k">$(</span>TAG<span class="k">)</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nf">clean</span><span class="o">:</span> <span class="c">## Clean up everything
</span></span></span><span class="line"><span class="cl"><span class="c"></span>	docker compose down -v
</span></span><span class="line"><span class="cl">	docker image prune -f
</span></span></code></pre></div>
</div>
<p>The Makefile is not trendy. It does not have a package ecosystem or a plugin system. But it works everywhere, it handles dependencies, it supports parallelism, and it does not require installing anything. For project automation, that is hard to beat.</p>
]]></content:encoded></item><item><title>Python One-Liners for Server Troubleshooting</title><link>https://articles.wleeaf.dev/posts/python-one-liners-sysadmin/</link><pubDate>Thu, 02 Apr 2026 00:00:00 +0000</pubDate><guid>https://articles.wleeaf.dev/posts/python-one-liners-sysadmin/</guid><description>When awk gets ugly and bash gets painful, Python -c is right there in your PATH.</description><category>python</category><category>linux</category><category>sysadmin</category><content:encoded><![CDATA[<p>Sometimes awk is not enough and a full script is too much. Python sits in the middle. Every Linux server has it installed, and <code>python3 -c</code> lets you run code inline.</p>
<h2 id="quick-http-server">Quick HTTP Server<a class="anchor" href="#quick-http-server" aria-label="Link to “Quick HTTP Server”">#</a></h2>
<p>The classic. Serve the current directory:</p>
<div class="code">
<div class="code-head"><span class="code-lang">bash</span><button type="button" class="code-copy" data-copy-code hidden><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2.5"/><path d="M16 8V6.5A2.5 2.5 0 0 0 13.5 4h-7A2.5 2.5 0 0 0 4 6.5v7A2.5 2.5 0 0 0 6.5 16H8"/></svg><span>Copy</span></button></div>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">python3 -m http.server <span class="m">8080</span></span></span></code></pre></div>
</div>
<p>Bind to a specific interface:</p>
<div class="code">
<div class="code-head"><span class="code-lang">bash</span><button type="button" class="code-copy" data-copy-code hidden><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2.5"/><path d="M16 8V6.5A2.5 2.5 0 0 0 13.5 4h-7A2.5 2.5 0 0 0 4 6.5v7A2.5 2.5 0 0 0 6.5 16H8"/></svg><span>Copy</span></button></div>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">python3 -m http.server <span class="m">8080</span> --bind 127.0.0.1</span></span></code></pre></div>
</div>
<p>This is genuinely useful for transferring files between machines, testing static sites, or sharing a directory with a coworker.</p>
<h2 id="json-pretty-printing">JSON Pretty Printing<a class="anchor" href="#json-pretty-printing" aria-label="Link to “JSON Pretty Printing”">#</a></h2>
<p>When you do not have jq installed:</p>
<div class="code">
<div class="code-head"><span class="code-lang">bash</span><button type="button" class="code-copy" data-copy-code hidden><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2.5"/><path d="M16 8V6.5A2.5 2.5 0 0 0 13.5 4h-7A2.5 2.5 0 0 0 4 6.5v7A2.5 2.5 0 0 0 6.5 16H8"/></svg><span>Copy</span></button></div>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">curl -s https://api.example.com/data <span class="p">|</span> python3 -m json.tool</span></span></code></pre></div>
</div>
<p>Or with color:</p>
<div class="code">
<div class="code-head"><span class="code-lang">bash</span><button type="button" class="code-copy" data-copy-code hidden><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2.5"/><path d="M16 8V6.5A2.5 2.5 0 0 0 13.5 4h-7A2.5 2.5 0 0 0 4 6.5v7A2.5 2.5 0 0 0 6.5 16H8"/></svg><span>Copy</span></button></div>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="nb">echo</span> <span class="s1">&#39;{&#34;a&#34;:1,&#34;b&#34;:[2,3]}&#39;</span> <span class="p">|</span> python3 -c <span class="s2">&#34;
</span></span></span><span class="line"><span class="cl"><span class="s2">import json, sys
</span></span></span><span class="line"><span class="cl"><span class="s2">data = json.load(sys.stdin)
</span></span></span><span class="line"><span class="cl"><span class="s2">print(json.dumps(data, indent=2, sort_keys=True))
</span></span></span><span class="line"><span class="cl"><span class="s2">&#34;</span></span></span></code></pre></div>
</div>
<h2 id="parse-timestamps">Parse Timestamps<a class="anchor" href="#parse-timestamps" aria-label="Link to “Parse Timestamps”">#</a></h2>
<p>Convert epoch timestamps that show up in logs:</p>
<div class="code">
<div class="code-head"><span class="code-lang">bash</span><button type="button" class="code-copy" data-copy-code hidden><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2.5"/><path d="M16 8V6.5A2.5 2.5 0 0 0 13.5 4h-7A2.5 2.5 0 0 0 4 6.5v7A2.5 2.5 0 0 0 6.5 16H8"/></svg><span>Copy</span></button></div>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">python3 -c <span class="s2">&#34;
</span></span></span><span class="line"><span class="cl"><span class="s2">from datetime import datetime
</span></span></span><span class="line"><span class="cl"><span class="s2">import sys
</span></span></span><span class="line"><span class="cl"><span class="s2">for line in sys.stdin:
</span></span></span><span class="line"><span class="cl"><span class="s2">    ts = int(line.strip())
</span></span></span><span class="line"><span class="cl"><span class="s2">    print(datetime.fromtimestamp(ts).isoformat())
</span></span></span><span class="line"><span class="cl"><span class="s2">&#34;</span> <span class="o">&lt;&lt;&lt;</span> <span class="s2">&#34;1711708800&#34;</span>
</span></span><span class="line"><span class="cl"><span class="c1"># 2024-03-29T12:00:00</span></span></span></code></pre></div>
</div>
<h2 id="find-large-files">Find Large Files<a class="anchor" href="#find-large-files" aria-label="Link to “Find Large Files”">#</a></h2>
<p><code>find</code> works, but if you want more control over the output:</p>
<div class="code">
<div class="code-head"><span class="code-lang">bash</span><button type="button" class="code-copy" data-copy-code hidden><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2.5"/><path d="M16 8V6.5A2.5 2.5 0 0 0 13.5 4h-7A2.5 2.5 0 0 0 4 6.5v7A2.5 2.5 0 0 0 6.5 16H8"/></svg><span>Copy</span></button></div>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">python3 -c <span class="s2">&#34;
</span></span></span><span class="line"><span class="cl"><span class="s2">import os
</span></span></span><span class="line"><span class="cl"><span class="s2">files = []
</span></span></span><span class="line"><span class="cl"><span class="s2">for root, dirs, fnames in os.walk(&#39;/var/log&#39;):
</span></span></span><span class="line"><span class="cl"><span class="s2">    for f in fnames:
</span></span></span><span class="line"><span class="cl"><span class="s2">        path = os.path.join(root, f)
</span></span></span><span class="line"><span class="cl"><span class="s2">        try:
</span></span></span><span class="line"><span class="cl"><span class="s2">            size = os.path.getsize(path)
</span></span></span><span class="line"><span class="cl"><span class="s2">            if size &gt; 100_000_000:  # 100MB
</span></span></span><span class="line"><span class="cl"><span class="s2">                files.append((size, path))
</span></span></span><span class="line"><span class="cl"><span class="s2">        except OSError:
</span></span></span><span class="line"><span class="cl"><span class="s2">            pass
</span></span></span><span class="line"><span class="cl"><span class="s2">for size, path in sorted(files, reverse=True):
</span></span></span><span class="line"><span class="cl"><span class="s2">    print(f&#39;{size / 1e9:.1f}G  {path}&#39;)
</span></span></span><span class="line"><span class="cl"><span class="s2">&#34;</span></span></span></code></pre></div>
</div>
<h2 id="base64-decode">Base64 Decode<a class="anchor" href="#base64-decode" aria-label="Link to “Base64 Decode”">#</a></h2>
<p>Decode those opaque values in Kubernetes secrets or JWTs:</p>
<div class="code">
<div class="code-head"><span class="code-lang">bash</span><button type="button" class="code-copy" data-copy-code hidden><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2.5"/><path d="M16 8V6.5A2.5 2.5 0 0 0 13.5 4h-7A2.5 2.5 0 0 0 4 6.5v7A2.5 2.5 0 0 0 6.5 16H8"/></svg><span>Copy</span></button></div>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">python3 -c <span class="s2">&#34;
</span></span></span><span class="line"><span class="cl"><span class="s2">import base64, json, sys
</span></span></span><span class="line"><span class="cl"><span class="s2">token = sys.argv[1]
</span></span></span><span class="line"><span class="cl"><span class="s2">parts = token.split(&#39;.&#39;)
</span></span></span><span class="line"><span class="cl"><span class="s2">for part in parts[:2]:
</span></span></span><span class="line"><span class="cl"><span class="s2">    padded = part + &#39;=&#39; * (4 - len(part) % 4)
</span></span></span><span class="line"><span class="cl"><span class="s2">    decoded = base64.urlsafe_b64decode(padded)
</span></span></span><span class="line"><span class="cl"><span class="s2">    print(json.dumps(json.loads(decoded), indent=2))
</span></span></span><span class="line"><span class="cl"><span class="s2">&#34;</span> <span class="s2">&#34;eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0In0.signature&#34;</span></span></span></code></pre></div>
</div>
<h2 id="port-scanner">Port Scanner<a class="anchor" href="#port-scanner" aria-label="Link to “Port Scanner”">#</a></h2>
<p>Quick check which ports are open without installing nmap:</p>
<div class="code">
<div class="code-head"><span class="code-lang">bash</span><button type="button" class="code-copy" data-copy-code hidden><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2.5"/><path d="M16 8V6.5A2.5 2.5 0 0 0 13.5 4h-7A2.5 2.5 0 0 0 4 6.5v7A2.5 2.5 0 0 0 6.5 16H8"/></svg><span>Copy</span></button></div>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">python3 -c <span class="s2">&#34;
</span></span></span><span class="line"><span class="cl"><span class="s2">import socket, sys
</span></span></span><span class="line"><span class="cl"><span class="s2">host = sys.argv[1]
</span></span></span><span class="line"><span class="cl"><span class="s2">for port in range(1, 1025):
</span></span></span><span class="line"><span class="cl"><span class="s2">    s = socket.socket()
</span></span></span><span class="line"><span class="cl"><span class="s2">    s.settimeout(0.1)
</span></span></span><span class="line"><span class="cl"><span class="s2">    if s.connect_ex((host, port)) == 0:
</span></span></span><span class="line"><span class="cl"><span class="s2">        print(f&#39;{port}/tcp open&#39;)
</span></span></span><span class="line"><span class="cl"><span class="s2">    s.close()
</span></span></span><span class="line"><span class="cl"><span class="s2">&#34;</span> 10.0.0.1</span></span></code></pre></div>
</div>
<h2 id="monitor-a-log-file-with-filtering">Monitor a Log File With Filtering<a class="anchor" href="#monitor-a-log-file-with-filtering" aria-label="Link to “Monitor a Log File With Filtering”">#</a></h2>
<p>Like <code>tail -f | grep</code> but with more logic:</p>
<div class="code">
<div class="code-head"><span class="code-lang">bash</span><button type="button" class="code-copy" data-copy-code hidden><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2.5"/><path d="M16 8V6.5A2.5 2.5 0 0 0 13.5 4h-7A2.5 2.5 0 0 0 4 6.5v7A2.5 2.5 0 0 0 6.5 16H8"/></svg><span>Copy</span></button></div>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">python3 -c <span class="s2">&#34;
</span></span></span><span class="line"><span class="cl"><span class="s2">import sys, re, time
</span></span></span><span class="line"><span class="cl"><span class="s2">
</span></span></span><span class="line"><span class="cl"><span class="s2">pattern = re.compile(r&#39;(ERROR|WARN|FATAL)&#39;, re.IGNORECASE)
</span></span></span><span class="line"><span class="cl"><span class="s2">seen = set()
</span></span></span><span class="line"><span class="cl"><span class="s2">
</span></span></span><span class="line"><span class="cl"><span class="s2">for line in sys.stdin:
</span></span></span><span class="line"><span class="cl"><span class="s2">    if pattern.search(line):
</span></span></span><span class="line"><span class="cl"><span class="s2">        key = line.strip()
</span></span></span><span class="line"><span class="cl"><span class="s2">        if key not in seen:
</span></span></span><span class="line"><span class="cl"><span class="s2">            seen.add(key)
</span></span></span><span class="line"><span class="cl"><span class="s2">            print(line, end=&#39;&#39;)
</span></span></span><span class="line"><span class="cl"><span class="s2">&#34;</span> &lt; /var/log/syslog</span></span></code></pre></div>
</div>
<p>This deduplicates repeated errors so you see each unique message only once.</p>
<h2 id="csv-to-json">CSV to JSON<a class="anchor" href="#csv-to-json" aria-label="Link to “CSV to JSON”">#</a></h2>
<p>Convert that spreadsheet export into something usable:</p>
<div class="code">
<div class="code-head"><span class="code-lang">bash</span><button type="button" class="code-copy" data-copy-code hidden><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2.5"/><path d="M16 8V6.5A2.5 2.5 0 0 0 13.5 4h-7A2.5 2.5 0 0 0 4 6.5v7A2.5 2.5 0 0 0 6.5 16H8"/></svg><span>Copy</span></button></div>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">python3 -c <span class="s2">&#34;
</span></span></span><span class="line"><span class="cl"><span class="s2">import csv, json, sys
</span></span></span><span class="line"><span class="cl"><span class="s2">reader = csv.DictReader(sys.stdin)
</span></span></span><span class="line"><span class="cl"><span class="s2">print(json.dumps(list(reader), indent=2))
</span></span></span><span class="line"><span class="cl"><span class="s2">&#34;</span> &lt; data.csv</span></span></code></pre></div>
</div>
<h2 id="url-encode-and-decode">URL Encode and Decode<a class="anchor" href="#url-encode-and-decode" aria-label="Link to “URL Encode and Decode”">#</a></h2>
<div class="code">
<div class="code-head"><span class="code-lang">bash</span><button type="button" class="code-copy" data-copy-code hidden><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2.5"/><path d="M16 8V6.5A2.5 2.5 0 0 0 13.5 4h-7A2.5 2.5 0 0 0 4 6.5v7A2.5 2.5 0 0 0 6.5 16H8"/></svg><span>Copy</span></button></div>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="c1"># Encode</span>
</span></span><span class="line"><span class="cl">python3 -c <span class="s2">&#34;from urllib.parse import quote; print(quote(&#39;hello world &amp; friends&#39;))&#34;</span>
</span></span><span class="line"><span class="cl"><span class="c1"># hello%20world%20%26%20friends</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Decode</span>
</span></span><span class="line"><span class="cl">python3 -c <span class="s2">&#34;from urllib.parse import unquote; print(unquote(&#39;hello%20world%20%26%20friends&#39;))&#34;</span>
</span></span><span class="line"><span class="cl"><span class="c1"># hello world &amp; friends</span></span></span></code></pre></div>
</div>
<h2 id="generate-random-passwords">Generate Random Passwords<a class="anchor" href="#generate-random-passwords" aria-label="Link to “Generate Random Passwords”">#</a></h2>
<div class="code">
<div class="code-head"><span class="code-lang">bash</span><button type="button" class="code-copy" data-copy-code hidden><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2.5"/><path d="M16 8V6.5A2.5 2.5 0 0 0 13.5 4h-7A2.5 2.5 0 0 0 4 6.5v7A2.5 2.5 0 0 0 6.5 16H8"/></svg><span>Copy</span></button></div>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">python3 -c <span class="s2">&#34;
</span></span></span><span class="line"><span class="cl"><span class="s2">import secrets, string
</span></span></span><span class="line"><span class="cl"><span class="s2">alphabet = string.ascii_letters + string.digits + &#39;!@#</span>$<span class="s2">%&#39;
</span></span></span><span class="line"><span class="cl"><span class="s2">password = &#39;&#39;.join(secrets.choice(alphabet) for _ in range(24))
</span></span></span><span class="line"><span class="cl"><span class="s2">print(password)
</span></span></span><span class="line"><span class="cl"><span class="s2">&#34;</span></span></span></code></pre></div>
</div>
<h2 id="ip-address-math">IP Address Math<a class="anchor" href="#ip-address-math" aria-label="Link to “IP Address Math”">#</a></h2>
<p>Check if an address is in a subnet:</p>
<div class="code">
<div class="code-head"><span class="code-lang">bash</span><button type="button" class="code-copy" data-copy-code hidden><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2.5"/><path d="M16 8V6.5A2.5 2.5 0 0 0 13.5 4h-7A2.5 2.5 0 0 0 4 6.5v7A2.5 2.5 0 0 0 6.5 16H8"/></svg><span>Copy</span></button></div>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">python3 -c <span class="s2">&#34;
</span></span></span><span class="line"><span class="cl"><span class="s2">from ipaddress import ip_address, ip_network
</span></span></span><span class="line"><span class="cl"><span class="s2">net = ip_network(&#39;10.0.0.0/24&#39;)
</span></span></span><span class="line"><span class="cl"><span class="s2">print(ip_address(&#39;10.0.0.42&#39;) in net)  # True
</span></span></span><span class="line"><span class="cl"><span class="s2">print(ip_address(&#39;10.14.0.1&#39;) in net)    # False
</span></span></span><span class="line"><span class="cl"><span class="s2">print(f&#39;Network: {net.network_address}&#39;)
</span></span></span><span class="line"><span class="cl"><span class="s2">print(f&#39;Broadcast: {net.broadcast_address}&#39;)
</span></span></span><span class="line"><span class="cl"><span class="s2">print(f&#39;Hosts: {net.num_addresses - 2}&#39;)
</span></span></span><span class="line"><span class="cl"><span class="s2">&#34;</span></span></span></code></pre></div>
</div>
<p>The standard library covers most sysadmin needs without pip. <code>json</code>, <code>csv</code>, <code>http.server</code>, <code>ipaddress</code>, <code>secrets</code>, <code>urllib</code>, <code>base64</code>, <code>socket</code>. It is all there. When the bash one-liner turns into a bash five-liner with nested quotes and escaped dollars signs, switch to Python. Your future self will thank you.</p>
]]></content:encoded></item><item><title>Writing systemd Services That Actually Work</title><link>https://articles.wleeaf.dev/posts/systemd-services-from-scratch/</link><pubDate>Wed, 01 Apr 2026 00:00:00 +0000</pubDate><guid>https://articles.wleeaf.dev/posts/systemd-services-from-scratch/</guid><description>Unit files, restart policies, environment variables, and journalctl. A practical guide to running your own daemons.</description><category>linux</category><category>systemd</category><category>infrastructure</category><content:encoded><![CDATA[<p>At some point you write a script or a server that needs to run forever. You could use screen, tmux, nohup, or a cron <code>@reboot</code> hack. Or you could write a systemd service and get automatic restarts, logging, resource limits, and dependency management for free.</p>
<h2 id="the-minimal-service">The Minimal Service<a class="anchor" href="#the-minimal-service" aria-label="Link to “The Minimal Service”">#</a></h2>
<p>Create <code>/etc/systemd/system/myapp.service</code>:</p>
<div class="code">
<div class="code-head"><span class="code-lang">ini</span><button type="button" class="code-copy" data-copy-code hidden><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2.5"/><path d="M16 8V6.5A2.5 2.5 0 0 0 13.5 4h-7A2.5 2.5 0 0 0 4 6.5v7A2.5 2.5 0 0 0 6.5 16H8"/></svg><span>Copy</span></button></div>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ini" data-lang="ini"><span class="line"><span class="cl"><span class="k">[Unit]</span>
</span></span><span class="line"><span class="cl"><span class="na">Description</span><span class="o">=</span><span class="s">My Application</span>
</span></span><span class="line"><span class="cl"><span class="na">After</span><span class="o">=</span><span class="s">network.target</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">[Service]</span>
</span></span><span class="line"><span class="cl"><span class="na">Type</span><span class="o">=</span><span class="s">simple</span>
</span></span><span class="line"><span class="cl"><span class="na">ExecStart</span><span class="o">=</span><span class="s">/usr/bin/node /opt/myapp/server.js</span>
</span></span><span class="line"><span class="cl"><span class="na">Restart</span><span class="o">=</span><span class="s">unless-stopped</span>
</span></span><span class="line"><span class="cl"><span class="na">RestartSec</span><span class="o">=</span><span class="s">5</span>
</span></span><span class="line"><span class="cl"><span class="na">WorkingDirectory</span><span class="o">=</span><span class="s">/opt/myapp</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">[Install]</span>
</span></span><span class="line"><span class="cl"><span class="na">WantedBy</span><span class="o">=</span><span class="s">multi-user.target</span></span></span></code></pre></div>
</div>
<p>Then:</p>
<div class="code">
<div class="code-head"><span class="code-lang">bash</span><button type="button" class="code-copy" data-copy-code hidden><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2.5"/><path d="M16 8V6.5A2.5 2.5 0 0 0 13.5 4h-7A2.5 2.5 0 0 0 4 6.5v7A2.5 2.5 0 0 0 6.5 16H8"/></svg><span>Copy</span></button></div>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">sudo systemctl daemon-reload
</span></span><span class="line"><span class="cl">sudo systemctl <span class="nb">enable</span> myapp
</span></span><span class="line"><span class="cl">sudo systemctl start myapp</span></span></code></pre></div>
</div>
<p>That is it. The app starts on boot, restarts on crash, and logs to journald.</p>
<h2 id="service-types">Service Types<a class="anchor" href="#service-types" aria-label="Link to “Service Types”">#</a></h2>
<p><code>Type=simple</code> is correct for most things. The process you start is the service. But there are others:</p>
<div class="code">
<div class="code-head"><span class="code-lang">ini</span><button type="button" class="code-copy" data-copy-code hidden><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2.5"/><path d="M16 8V6.5A2.5 2.5 0 0 0 13.5 4h-7A2.5 2.5 0 0 0 4 6.5v7A2.5 2.5 0 0 0 6.5 16H8"/></svg><span>Copy</span></button></div>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ini" data-lang="ini"><span class="line"><span class="cl"><span class="c1"># App forks into background (like old-school daemons)</span>
</span></span><span class="line"><span class="cl"><span class="na">Type</span><span class="o">=</span><span class="s">forking</span>
</span></span><span class="line"><span class="cl"><span class="na">PIDFile</span><span class="o">=</span><span class="s">/var/run/myapp.pid</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># App signals readiness via sd_notify</span>
</span></span><span class="line"><span class="cl"><span class="na">Type</span><span class="o">=</span><span class="s">notify</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># App is a one-shot script, not a long-running process</span>
</span></span><span class="line"><span class="cl"><span class="na">Type</span><span class="o">=</span><span class="s">oneshot</span>
</span></span><span class="line"><span class="cl"><span class="na">RemainAfterExit</span><span class="o">=</span><span class="s">yes</span></span></span></code></pre></div>
</div>
<p>Use <code>simple</code> unless you have a specific reason not to. <code>oneshot</code> is useful for startup scripts that need to run once.</p>
<h2 id="environment-variables">Environment Variables<a class="anchor" href="#environment-variables" aria-label="Link to “Environment Variables”">#</a></h2>
<p>Three ways to pass them:</p>
<div class="code">
<div class="code-head"><span class="code-lang">ini</span><button type="button" class="code-copy" data-copy-code hidden><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2.5"/><path d="M16 8V6.5A2.5 2.5 0 0 0 13.5 4h-7A2.5 2.5 0 0 0 4 6.5v7A2.5 2.5 0 0 0 6.5 16H8"/></svg><span>Copy</span></button></div>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ini" data-lang="ini"><span class="line"><span class="cl"><span class="c1"># Inline (good for a few vars)</span>
</span></span><span class="line"><span class="cl"><span class="k">[Service]</span>
</span></span><span class="line"><span class="cl"><span class="na">Environment</span><span class="o">=</span><span class="s">PORT=3000</span>
</span></span><span class="line"><span class="cl"><span class="na">Environment</span><span class="o">=</span><span class="s">NODE_ENV=production</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># From a file (good for secrets)</span>
</span></span><span class="line"><span class="cl"><span class="k">[Service]</span>
</span></span><span class="line"><span class="cl"><span class="na">EnvironmentFile</span><span class="o">=</span><span class="s">/opt/myapp/.env</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Multiple files</span>
</span></span><span class="line"><span class="cl"><span class="k">[Service]</span>
</span></span><span class="line"><span class="cl"><span class="na">EnvironmentFile</span><span class="o">=</span><span class="s">/opt/myapp/.env</span>
</span></span><span class="line"><span class="cl"><span class="na">EnvironmentFile</span><span class="o">=</span><span class="s">/opt/myapp/.env.local</span></span></span></code></pre></div>
</div>
<p>The <code>EnvironmentFile</code> approach keeps secrets out of the unit file. The unit file itself might be in version control or readable by any user. The <code>.env</code> file can be locked down with permissions.</p>
<h2 id="running-as-a-non-root-user">Running As a Non-Root User<a class="anchor" href="#running-as-a-non-root-user" aria-label="Link to “Running As a Non-Root User”">#</a></h2>
<div class="code">
<div class="code-head"><span class="code-lang">ini</span><button type="button" class="code-copy" data-copy-code hidden><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2.5"/><path d="M16 8V6.5A2.5 2.5 0 0 0 13.5 4h-7A2.5 2.5 0 0 0 4 6.5v7A2.5 2.5 0 0 0 6.5 16H8"/></svg><span>Copy</span></button></div>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ini" data-lang="ini"><span class="line"><span class="cl"><span class="k">[Service]</span>
</span></span><span class="line"><span class="cl"><span class="na">User</span><span class="o">=</span><span class="s">myapp</span>
</span></span><span class="line"><span class="cl"><span class="na">Group</span><span class="o">=</span><span class="s">myapp</span>
</span></span><span class="line"><span class="cl"><span class="na">ExecStart</span><span class="o">=</span><span class="s">/usr/bin/node /opt/myapp/server.js</span></span></span></code></pre></div>
</div>
<p>Create the user first:</p>
<div class="code">
<div class="code-head"><span class="code-lang">bash</span><button type="button" class="code-copy" data-copy-code hidden><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2.5"/><path d="M16 8V6.5A2.5 2.5 0 0 0 13.5 4h-7A2.5 2.5 0 0 0 4 6.5v7A2.5 2.5 0 0 0 6.5 16H8"/></svg><span>Copy</span></button></div>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">sudo useradd -r -s /usr/sbin/nologin -d /opt/myapp myapp
</span></span><span class="line"><span class="cl">sudo chown -R myapp:myapp /opt/myapp</span></span></code></pre></div>
</div>
<p>The <code>-r</code> flag creates a system user. <code>-s /usr/sbin/nologin</code> prevents interactive login.</p>
<h2 id="resource-limits">Resource Limits<a class="anchor" href="#resource-limits" aria-label="Link to “Resource Limits”">#</a></h2>
<p>Prevent a runaway process from eating all your RAM or CPU:</p>
<div class="code">
<div class="code-head"><span class="code-lang">ini</span><button type="button" class="code-copy" data-copy-code hidden><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2.5"/><path d="M16 8V6.5A2.5 2.5 0 0 0 13.5 4h-7A2.5 2.5 0 0 0 4 6.5v7A2.5 2.5 0 0 0 6.5 16H8"/></svg><span>Copy</span></button></div>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ini" data-lang="ini"><span class="line"><span class="cl"><span class="k">[Service]</span>
</span></span><span class="line"><span class="cl"><span class="na">MemoryMax</span><span class="o">=</span><span class="s">512M</span>
</span></span><span class="line"><span class="cl"><span class="na">CPUQuota</span><span class="o">=</span><span class="s">50%</span>
</span></span><span class="line"><span class="cl"><span class="na">TasksMax</span><span class="o">=</span><span class="s">100</span>
</span></span><span class="line"><span class="cl"><span class="na">LimitNOFILE</span><span class="o">=</span><span class="s">65535</span></span></span></code></pre></div>
</div>
<p><code>MemoryMax</code> kills the process if it exceeds the limit. <code>CPUQuota</code> throttles but does not kill. <code>TasksMax</code> limits the number of threads and child processes. <code>LimitNOFILE</code> sets the open file descriptor limit.</p>
<h2 id="restart-policies">Restart Policies<a class="anchor" href="#restart-policies" aria-label="Link to “Restart Policies”">#</a></h2>
<div class="code">
<div class="code-head"><span class="code-lang">ini</span><button type="button" class="code-copy" data-copy-code hidden><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2.5"/><path d="M16 8V6.5A2.5 2.5 0 0 0 13.5 4h-7A2.5 2.5 0 0 0 4 6.5v7A2.5 2.5 0 0 0 6.5 16H8"/></svg><span>Copy</span></button></div>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ini" data-lang="ini"><span class="line"><span class="cl"><span class="c1"># Restart on any exit (crash, signal, clean exit)</span>
</span></span><span class="line"><span class="cl"><span class="na">Restart</span><span class="o">=</span><span class="s">always</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Restart on failure only (non-zero exit, signal)</span>
</span></span><span class="line"><span class="cl"><span class="na">Restart</span><span class="o">=</span><span class="s">on-failure</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Restart unless you explicitly stopped it</span>
</span></span><span class="line"><span class="cl"><span class="na">Restart</span><span class="o">=</span><span class="s">unless-stopped</span></span></span></code></pre></div>
</div>
<p>I use <code>unless-stopped</code> for everything. It restarts on crashes but stays down when I manually stop the service for debugging.</p>
<p>Add rate limiting to prevent restart loops:</p>
<div class="code">
<div class="code-head"><span class="code-lang">ini</span><button type="button" class="code-copy" data-copy-code hidden><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2.5"/><path d="M16 8V6.5A2.5 2.5 0 0 0 13.5 4h-7A2.5 2.5 0 0 0 4 6.5v7A2.5 2.5 0 0 0 6.5 16H8"/></svg><span>Copy</span></button></div>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ini" data-lang="ini"><span class="line"><span class="cl"><span class="na">RestartSec</span><span class="o">=</span><span class="s">5</span>
</span></span><span class="line"><span class="cl"><span class="na">StartLimitIntervalSec</span><span class="o">=</span><span class="s">60</span>
</span></span><span class="line"><span class="cl"><span class="na">StartLimitBurst</span><span class="o">=</span><span class="s">5</span></span></span></code></pre></div>
</div>
<p>This allows 5 restarts within 60 seconds. After that, the service enters a failed state and stops trying. Check what happened with <code>journalctl -u myapp</code>.</p>
<h2 id="logging">Logging<a class="anchor" href="#logging" aria-label="Link to “Logging”">#</a></h2>
<p>systemd captures stdout and stderr automatically:</p>
<div class="code">
<div class="code-head"><span class="code-lang">bash</span><button type="button" class="code-copy" data-copy-code hidden><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2.5"/><path d="M16 8V6.5A2.5 2.5 0 0 0 13.5 4h-7A2.5 2.5 0 0 0 4 6.5v7A2.5 2.5 0 0 0 6.5 16H8"/></svg><span>Copy</span></button></div>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="c1"># View logs</span>
</span></span><span class="line"><span class="cl">journalctl -u myapp
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Follow in real time</span>
</span></span><span class="line"><span class="cl">journalctl -u myapp -f
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Last 50 lines</span>
</span></span><span class="line"><span class="cl">journalctl -u myapp -n <span class="m">50</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Since last boot</span>
</span></span><span class="line"><span class="cl">journalctl -u myapp -b
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># JSON output for parsing</span>
</span></span><span class="line"><span class="cl">journalctl -u myapp -o json-pretty</span></span></code></pre></div>
</div>
<p>No log rotation config needed. journald handles it automatically based on disk space.</p>
<h2 id="dependencies-and-ordering">Dependencies and Ordering<a class="anchor" href="#dependencies-and-ordering" aria-label="Link to “Dependencies and Ordering”">#</a></h2>
<div class="code">
<div class="code-head"><span class="code-lang">ini</span><button type="button" class="code-copy" data-copy-code hidden><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2.5"/><path d="M16 8V6.5A2.5 2.5 0 0 0 13.5 4h-7A2.5 2.5 0 0 0 4 6.5v7A2.5 2.5 0 0 0 6.5 16H8"/></svg><span>Copy</span></button></div>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ini" data-lang="ini"><span class="line"><span class="cl"><span class="k">[Unit]</span>
</span></span><span class="line"><span class="cl"><span class="na">Description</span><span class="o">=</span><span class="s">My App</span>
</span></span><span class="line"><span class="cl"><span class="na">After</span><span class="o">=</span><span class="s">network.target postgresql.service</span>
</span></span><span class="line"><span class="cl"><span class="na">Requires</span><span class="o">=</span><span class="s">postgresql.service</span></span></span></code></pre></div>
</div>
<p><code>After</code> controls startup order. <code>Requires</code> means if PostgreSQL stops, myapp stops too. Use <code>Wants</code> instead of <code>Requires</code> for soft dependencies where your app can handle the other service being down.</p>
<h2 id="pre-and-post-commands">Pre and Post Commands<a class="anchor" href="#pre-and-post-commands" aria-label="Link to “Pre and Post Commands”">#</a></h2>
<div class="code">
<div class="code-head"><span class="code-lang">ini</span><button type="button" class="code-copy" data-copy-code hidden><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2.5"/><path d="M16 8V6.5A2.5 2.5 0 0 0 13.5 4h-7A2.5 2.5 0 0 0 4 6.5v7A2.5 2.5 0 0 0 6.5 16H8"/></svg><span>Copy</span></button></div>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ini" data-lang="ini"><span class="line"><span class="cl"><span class="k">[Service]</span>
</span></span><span class="line"><span class="cl"><span class="na">ExecStartPre</span><span class="o">=</span><span class="s">/opt/myapp/migrate.sh</span>
</span></span><span class="line"><span class="cl"><span class="na">ExecStart</span><span class="o">=</span><span class="s">/usr/bin/node /opt/myapp/server.js</span>
</span></span><span class="line"><span class="cl"><span class="na">ExecStartPost</span><span class="o">=</span><span class="s">/usr/bin/curl -s http://localhost:3000/health</span>
</span></span><span class="line"><span class="cl"><span class="na">ExecStopPost</span><span class="o">=</span><span class="s">/opt/myapp/cleanup.sh</span></span></span></code></pre></div>
</div>
<p><code>ExecStartPre</code> runs before the main process. Good for database migrations or config validation. If it fails, the service does not start.</p>
<h2 id="debugging">Debugging<a class="anchor" href="#debugging" aria-label="Link to “Debugging”">#</a></h2>
<div class="code">
<div class="code-head"><span class="code-lang">bash</span><button type="button" class="code-copy" data-copy-code hidden><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2.5"/><path d="M16 8V6.5A2.5 2.5 0 0 0 13.5 4h-7A2.5 2.5 0 0 0 4 6.5v7A2.5 2.5 0 0 0 6.5 16H8"/></svg><span>Copy</span></button></div>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="c1"># Check service status</span>
</span></span><span class="line"><span class="cl">systemctl status myapp
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># View full logs on failure</span>
</span></span><span class="line"><span class="cl">journalctl -u myapp -e --no-pager
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Check the actual unit file being used</span>
</span></span><span class="line"><span class="cl">systemctl cat myapp
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Verify unit file syntax</span>
</span></span><span class="line"><span class="cl">systemd-analyze verify myapp.service</span></span></code></pre></div>
</div>
<p>The <code>systemd-analyze verify</code> command catches syntax errors without starting anything. Use it before reloading.</p>
<p>systemd is verbose and opinionated, but once you get past the initial learning curve, it handles all the edge cases that screen and nohup do not. Crashes, reboots, logging, resource limits, dependencies. Write the unit file once and forget about it.</p>
]]></content:encoded></item><item><title>Git Worktrees Changed How I Handle Branches</title><link>https://articles.wleeaf.dev/posts/git-worktrees/</link><pubDate>Mon, 30 Mar 2026 00:00:00 +0000</pubDate><guid>https://articles.wleeaf.dev/posts/git-worktrees/</guid><description>Switching branches without stashing, losing context, or waiting for node_modules to reinstall.</description><category>git</category><category>workflow</category><category>productivity</category><content:encoded><![CDATA[<p>The worst part of working on multiple branches is the context switching. You are deep in a feature branch, someone asks you to review a hotfix, and now you have to stash your changes, switch branches, wait for your build tools to catch up, do the review, switch back, pop the stash, and hope nothing got lost.</p>
<p>Git worktrees fix this entirely: they let you check out several branches at once, each in its own directory, all sharing one repository.</p>
<h2 id="what-is-a-worktree">What Is a Worktree?<a class="anchor" href="#what-is-a-worktree" aria-label="Link to “What Is a Worktree?”">#</a></h2>
<p>A worktree is a separate working directory that shares the same <code>.git</code> data. Each worktree can have a different branch checked out simultaneously:</p>
<div class="code">
<div class="code-head"><span class="code-lang">bash</span><button type="button" class="code-copy" data-copy-code hidden><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2.5"/><path d="M16 8V6.5A2.5 2.5 0 0 0 13.5 4h-7A2.5 2.5 0 0 0 4 6.5v7A2.5 2.5 0 0 0 6.5 16H8"/></svg><span>Copy</span></button></div>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="c1"># You are on main in /home/user/project</span>
</span></span><span class="line"><span class="cl">git worktree add ../project-hotfix hotfix/urgent-fix</span></span></code></pre></div>
</div>
<p>Now you have two directories:</p>
<div class="code is-plain">
<div class="code-head"><button type="button" class="code-copy" data-copy-code hidden><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2.5"/><path d="M16 8V6.5A2.5 2.5 0 0 0 13.5 4h-7A2.5 2.5 0 0 0 4 6.5v7A2.5 2.5 0 0 0 6.5 16H8"/></svg><span>Copy</span></button></div>
<pre tabindex="0"><code>/home/user/project          → main branch
/home/user/project-hotfix   → hotfix/urgent-fix branch</code></pre>
</div>
<p>Both share the same git history. Commits in one are visible in the other. But they have completely independent working directories.</p>
<h2 id="the-workflow">The Workflow<a class="anchor" href="#the-workflow" aria-label="Link to “The Workflow”">#</a></h2>
<p>My typical day looks like this:</p>
<div class="code">
<div class="code-head"><span class="code-lang">bash</span><button type="button" class="code-copy" data-copy-code hidden><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2.5"/><path d="M16 8V6.5A2.5 2.5 0 0 0 13.5 4h-7A2.5 2.5 0 0 0 4 6.5v7A2.5 2.5 0 0 0 6.5 16H8"/></svg><span>Copy</span></button></div>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="c1"># Main work</span>
</span></span><span class="line"><span class="cl"><span class="nb">cd</span> ~/projects/app
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Someone needs a review - create a worktree</span>
</span></span><span class="line"><span class="cl">git worktree add ~/reviews/pr-142 origin/feature/new-api
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Review in a completely separate directory</span>
</span></span><span class="line"><span class="cl"><span class="nb">cd</span> ~/reviews/pr-142
</span></span><span class="line"><span class="cl">npm install  <span class="c1"># only needed first time</span>
</span></span><span class="line"><span class="cl">npm <span class="nb">test</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Done reviewing, remove it</span>
</span></span><span class="line"><span class="cl"><span class="nb">cd</span> ~/projects/app
</span></span><span class="line"><span class="cl">git worktree remove ~/reviews/pr-142</span></span></code></pre></div>
</div>
<p>The key advantage: my main working directory is completely untouched. No stashing, no branch switching, no reinstalling dependencies.</p>
<h2 id="practical-patterns">Practical Patterns<a class="anchor" href="#practical-patterns" aria-label="Link to “Practical Patterns”">#</a></h2>
<h3 id="parallel-testing">Parallel testing<a class="anchor" href="#parallel-testing" aria-label="Link to “Parallel testing”">#</a></h3>
<p>Run tests on two branches at the same time:</p>
<div class="code">
<div class="code-head"><span class="code-lang">bash</span><button type="button" class="code-copy" data-copy-code hidden><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2.5"/><path d="M16 8V6.5A2.5 2.5 0 0 0 13.5 4h-7A2.5 2.5 0 0 0 4 6.5v7A2.5 2.5 0 0 0 6.5 16H8"/></svg><span>Copy</span></button></div>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">git worktree add /tmp/test-main main
</span></span><span class="line"><span class="cl">git worktree add /tmp/test-feature feature/new-thing
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># In two terminals</span>
</span></span><span class="line"><span class="cl"><span class="nb">cd</span> /tmp/test-main <span class="o">&amp;&amp;</span> npm <span class="nb">test</span>
</span></span><span class="line"><span class="cl"><span class="nb">cd</span> /tmp/test-feature <span class="o">&amp;&amp;</span> npm test</span></span></code></pre></div>
</div>
<h3 id="long-running-branches">Long-running branches<a class="anchor" href="#long-running-branches" aria-label="Link to “Long-running branches”">#</a></h3>
<p>If you maintain a staging branch alongside main:</p>
<div class="code">
<div class="code-head"><span class="code-lang">bash</span><button type="button" class="code-copy" data-copy-code hidden><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2.5"/><path d="M16 8V6.5A2.5 2.5 0 0 0 13.5 4h-7A2.5 2.5 0 0 0 4 6.5v7A2.5 2.5 0 0 0 6.5 16H8"/></svg><span>Copy</span></button></div>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">git worktree add ~/staging staging</span></span></code></pre></div>
</div>
<p>Keep it around permanently. Push to staging by working in that directory. No branch switching ever.</p>
<h3 id="ci-simulation">CI simulation<a class="anchor" href="#ci-simulation" aria-label="Link to “CI simulation”">#</a></h3>
<p>Test your CI pipeline locally against a clean checkout:</p>
<div class="code">
<div class="code-head"><span class="code-lang">bash</span><button type="button" class="code-copy" data-copy-code hidden><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2.5"/><path d="M16 8V6.5A2.5 2.5 0 0 0 13.5 4h-7A2.5 2.5 0 0 0 4 6.5v7A2.5 2.5 0 0 0 6.5 16H8"/></svg><span>Copy</span></button></div>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">git worktree add /tmp/ci-test HEAD
</span></span><span class="line"><span class="cl"><span class="nb">cd</span> /tmp/ci-test
</span></span><span class="line"><span class="cl">npm ci
</span></span><span class="line"><span class="cl">npm run lint
</span></span><span class="line"><span class="cl">npm <span class="nb">test</span>
</span></span><span class="line"><span class="cl">npm run build</span></span></code></pre></div>
</div>
<p>This catches the classic &ldquo;works on my machine&rdquo; issues where you have uncommitted files or stale build artifacts that mask problems.</p>
<h2 id="managing-worktrees">Managing Worktrees<a class="anchor" href="#managing-worktrees" aria-label="Link to “Managing Worktrees”">#</a></h2>
<div class="code">
<div class="code-head"><span class="code-lang">bash</span><button type="button" class="code-copy" data-copy-code hidden><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2.5"/><path d="M16 8V6.5A2.5 2.5 0 0 0 13.5 4h-7A2.5 2.5 0 0 0 4 6.5v7A2.5 2.5 0 0 0 6.5 16H8"/></svg><span>Copy</span></button></div>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="c1"># List all worktrees</span>
</span></span><span class="line"><span class="cl">git worktree list
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Output:</span>
</span></span><span class="line"><span class="cl"><span class="c1"># /home/user/project         abc1234 [main]</span>
</span></span><span class="line"><span class="cl"><span class="c1"># /home/user/project-hotfix  def5678 [hotfix/urgent-fix]</span>
</span></span><span class="line"><span class="cl"><span class="c1"># /tmp/ci-test               abc1234 (detached HEAD)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Remove a worktree</span>
</span></span><span class="line"><span class="cl">git worktree remove /home/user/project-hotfix
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Clean up stale worktree references</span>
</span></span><span class="line"><span class="cl">git worktree prune</span></span></code></pre></div>
</div>
<h2 id="gotchas">Gotchas<a class="anchor" href="#gotchas" aria-label="Link to “Gotchas”">#</a></h2>
<p>You cannot check out the same branch in two worktrees. Git prevents this because having the same branch in two places would make it ambiguous which working directory a commit applies to.</p>
<p>If you need to work on the same branch in two places, create a local tracking branch:</p>
<div class="code">
<div class="code-head"><span class="code-lang">bash</span><button type="button" class="code-copy" data-copy-code hidden><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2.5"/><path d="M16 8V6.5A2.5 2.5 0 0 0 13.5 4h-7A2.5 2.5 0 0 0 4 6.5v7A2.5 2.5 0 0 0 6.5 16H8"/></svg><span>Copy</span></button></div>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">git worktree add ../experiment experiment-copy
</span></span><span class="line"><span class="cl"><span class="nb">cd</span> ../experiment
</span></span><span class="line"><span class="cl">git checkout -b experiment-local origin/experiment</span></span></code></pre></div>
</div>
<p>The other thing to watch out for is node_modules. Each worktree needs its own <code>node_modules</code> because paths are different. The first <code>npm install</code> in a new worktree takes time. After that, it is fast because the npm cache is shared.</p>
<h2 id="vs-git-stash">vs. git stash<a class="anchor" href="#vs-git-stash" aria-label="Link to “vs. git stash”">#</a></h2>
<p>Stash is fine for quick interruptions. If someone asks you a question and you need to check something on another branch for thirty seconds, stash works.</p>
<p>But for anything that takes more than a minute, worktrees are better. They do not mess with your working directory, they let you have multiple contexts open in different editor windows, and they do not have the failure mode where you forget to pop the stash and lose track of what you were doing.</p>
<p>I keep a <code>~/worktrees/</code> directory for temporary ones and let them accumulate during the week. Friday afternoon I run <code>git worktree list</code> and clean up anything I am done with.</p>
]]></content:encoded></item><item><title>Debugging Running Docker Containers Without Restarting Them</title><link>https://articles.wleeaf.dev/posts/docker-debugging-running-containers/</link><pubDate>Sun, 29 Mar 2026 00:00:00 +0000</pubDate><guid>https://articles.wleeaf.dev/posts/docker-debugging-running-containers/</guid><description>exec, logs, inspect, and nsenter. Four tools for figuring out what is happening inside a container without touching it.</description><category>docker</category><category>debugging</category><category>linux</category><content:encoded><![CDATA[<p>Something is wrong with your container. It is running but not behaving. You could rebuild and redeploy, but that might hide the problem. Here is how to investigate a running container without restarting it, using <code>docker exec</code>, <code>docker logs</code>, <code>docker inspect</code>, <code>docker cp</code>, <code>nsenter</code> and <code>docker diff</code>.</p>
<h2 id="docker-exec">docker exec<a class="anchor" href="#docker-exec" aria-label="Link to “docker exec”">#</a></h2>
<p>The obvious one. Drop into a shell:</p>
<div class="code">
<div class="code-head"><span class="code-lang">bash</span><button type="button" class="code-copy" data-copy-code hidden><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2.5"/><path d="M16 8V6.5A2.5 2.5 0 0 0 13.5 4h-7A2.5 2.5 0 0 0 4 6.5v7A2.5 2.5 0 0 0 6.5 16H8"/></svg><span>Copy</span></button></div>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">docker <span class="nb">exec</span> -it mycontainer sh</span></span></code></pre></div>
</div>
<p>Use <code>sh</code> not <code>bash</code> because Alpine images do not have bash. Once inside, you can poke around:</p>
<div class="code">
<div class="code-head"><span class="code-lang">bash</span><button type="button" class="code-copy" data-copy-code hidden><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2.5"/><path d="M16 8V6.5A2.5 2.5 0 0 0 13.5 4h-7A2.5 2.5 0 0 0 4 6.5v7A2.5 2.5 0 0 0 6.5 16H8"/></svg><span>Copy</span></button></div>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="c1"># Check what processes are running</span>
</span></span><span class="line"><span class="cl">ps aux
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Look at environment variables</span>
</span></span><span class="line"><span class="cl">env <span class="p">|</span> sort
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Test network connectivity</span>
</span></span><span class="line"><span class="cl">wget -qO- http://other-service:3000/health
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Check disk usage</span>
</span></span><span class="line"><span class="cl">df -h</span></span></code></pre></div>
</div>
<p>But what if the container has no shell? Minimal images built <code>FROM scratch</code> or distroless images strip everything out.</p>
<h2 id="docker-logs">docker logs<a class="anchor" href="#docker-logs" aria-label="Link to “docker logs”">#</a></h2>
<p>Before exec-ing into anything, check the logs:</p>
<div class="code">
<div class="code-head"><span class="code-lang">bash</span><button type="button" class="code-copy" data-copy-code hidden><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2.5"/><path d="M16 8V6.5A2.5 2.5 0 0 0 13.5 4h-7A2.5 2.5 0 0 0 4 6.5v7A2.5 2.5 0 0 0 6.5 16H8"/></svg><span>Copy</span></button></div>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="c1"># Last 100 lines</span>
</span></span><span class="line"><span class="cl">docker logs --tail <span class="m">100</span> mycontainer
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Follow in real time</span>
</span></span><span class="line"><span class="cl">docker logs -f mycontainer
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Logs since a specific time</span>
</span></span><span class="line"><span class="cl">docker logs --since 2026-03-29T10:00:00 mycontainer
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># With timestamps</span>
</span></span><span class="line"><span class="cl">docker logs -t mycontainer</span></span></code></pre></div>
</div>
<p>The <code>--since</code> flag is underrated. When a container has been running for weeks, you do not want to scroll through megabytes of old logs.</p>
<h2 id="docker-inspect">docker inspect<a class="anchor" href="#docker-inspect" aria-label="Link to “docker inspect”">#</a></h2>
<p>This tells you everything about the container&rsquo;s configuration:</p>
<div class="code">
<div class="code-head"><span class="code-lang">bash</span><button type="button" class="code-copy" data-copy-code hidden><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2.5"/><path d="M16 8V6.5A2.5 2.5 0 0 0 13.5 4h-7A2.5 2.5 0 0 0 4 6.5v7A2.5 2.5 0 0 0 6.5 16H8"/></svg><span>Copy</span></button></div>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="c1"># Full JSON dump</span>
</span></span><span class="line"><span class="cl">docker inspect mycontainer
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Just the IP address</span>
</span></span><span class="line"><span class="cl">docker inspect -f <span class="s1">&#39;{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}&#39;</span> mycontainer
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Environment variables</span>
</span></span><span class="line"><span class="cl">docker inspect -f <span class="s1">&#39;{{json .Config.Env}}&#39;</span> mycontainer <span class="p">|</span> jq .
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Mount points</span>
</span></span><span class="line"><span class="cl">docker inspect -f <span class="s1">&#39;{{json .Mounts}}&#39;</span> mycontainer <span class="p">|</span> jq .
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Port mappings</span>
</span></span><span class="line"><span class="cl">docker inspect -f <span class="s1">&#39;{{json .NetworkSettings.Ports}}&#39;</span> mycontainer <span class="p">|</span> jq .
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Health check status</span>
</span></span><span class="line"><span class="cl">docker inspect -f <span class="s1">&#39;{{json .State.Health}}&#39;</span> mycontainer <span class="p">|</span> jq .</span></span></code></pre></div>
</div>
<h2 id="docker-cp">docker cp<a class="anchor" href="#docker-cp" aria-label="Link to “docker cp”">#</a></h2>
<p>Copy files in or out of a running container:</p>
<div class="code">
<div class="code-head"><span class="code-lang">bash</span><button type="button" class="code-copy" data-copy-code hidden><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2.5"/><path d="M16 8V6.5A2.5 2.5 0 0 0 13.5 4h-7A2.5 2.5 0 0 0 4 6.5v7A2.5 2.5 0 0 0 6.5 16H8"/></svg><span>Copy</span></button></div>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="c1"># Copy a config file out for inspection</span>
</span></span><span class="line"><span class="cl">docker cp mycontainer:/etc/nginx/nginx.conf ./nginx.conf
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Copy a fix in</span>
</span></span><span class="line"><span class="cl">docker cp ./fixed-config.json mycontainer:/app/config.json</span></span></code></pre></div>
</div>
<p>This works even if the container has no shell or text editor.</p>
<h2 id="nsenter-the-nuclear-option">nsenter: The Nuclear Option<a class="anchor" href="#nsenter-the-nuclear-option" aria-label="Link to “nsenter: The Nuclear Option”">#</a></h2>
<p>When <code>docker exec</code> is not enough or the container has no shell, <code>nsenter</code> lets you enter the container&rsquo;s namespaces from the host:</p>
<div class="code">
<div class="code-head"><span class="code-lang">bash</span><button type="button" class="code-copy" data-copy-code hidden><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2.5"/><path d="M16 8V6.5A2.5 2.5 0 0 0 13.5 4h-7A2.5 2.5 0 0 0 4 6.5v7A2.5 2.5 0 0 0 6.5 16H8"/></svg><span>Copy</span></button></div>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="c1"># Get the container&#39;s PID</span>
</span></span><span class="line"><span class="cl"><span class="nv">PID</span><span class="o">=</span><span class="k">$(</span>docker inspect -f <span class="s1">&#39;{{.State.Pid}}&#39;</span> mycontainer<span class="k">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Enter its network namespace</span>
</span></span><span class="line"><span class="cl">sudo nsenter -t <span class="nv">$PID</span> -n ss -tlnp
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Enter all namespaces (like exec but from the host)</span>
</span></span><span class="line"><span class="cl">sudo nsenter -t <span class="nv">$PID</span> -m -u -i -n -p -- /bin/sh</span></span></code></pre></div>
</div>
<p>This is useful for network debugging. You can run <code>tcpdump</code>, <code>ss</code>, or <code>ip</code> inside the container&rsquo;s network namespace even if those tools are not installed in the container.</p>
<h2 id="docker-diff">docker diff<a class="anchor" href="#docker-diff" aria-label="Link to “docker diff”">#</a></h2>
<p>See what files changed since the container started:</p>
<div class="code">
<div class="code-head"><span class="code-lang">bash</span><button type="button" class="code-copy" data-copy-code hidden><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2.5"/><path d="M16 8V6.5A2.5 2.5 0 0 0 13.5 4h-7A2.5 2.5 0 0 0 4 6.5v7A2.5 2.5 0 0 0 6.5 16H8"/></svg><span>Copy</span></button></div>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">docker diff mycontainer</span></span></code></pre></div>
</div>
<p>Output looks like:</p>
<div class="code is-plain">
<div class="code-head"><button type="button" class="code-copy" data-copy-code hidden><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2.5"/><path d="M16 8V6.5A2.5 2.5 0 0 0 13.5 4h-7A2.5 2.5 0 0 0 4 6.5v7A2.5 2.5 0 0 0 6.5 16H8"/></svg><span>Copy</span></button></div>
<pre tabindex="0"><code>C /app
A /app/data/cache.db
C /tmp
A /tmp/something-unexpected</code></pre>
</div>
<p><code>A</code> means added, <code>C</code> means changed, <code>D</code> means deleted. This is great for finding containers that are writing to unexpected places.</p>
<h2 id="debugging-network-issues">Debugging Network Issues<a class="anchor" href="#debugging-network-issues" aria-label="Link to “Debugging Network Issues”">#</a></h2>
<p>When containers cannot talk to each other:</p>
<div class="code">
<div class="code-head"><span class="code-lang">bash</span><button type="button" class="code-copy" data-copy-code hidden><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2.5"/><path d="M16 8V6.5A2.5 2.5 0 0 0 13.5 4h-7A2.5 2.5 0 0 0 4 6.5v7A2.5 2.5 0 0 0 6.5 16H8"/></svg><span>Copy</span></button></div>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="c1"># Check which networks a container is on</span>
</span></span><span class="line"><span class="cl">docker inspect mycontainer -f <span class="s1">&#39;{{json .NetworkSettings.Networks}}&#39;</span> <span class="p">|</span> jq <span class="s1">&#39;keys&#39;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Ping from one container to another</span>
</span></span><span class="line"><span class="cl">docker <span class="nb">exec</span> web ping -c <span class="m">3</span> api
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Check DNS resolution</span>
</span></span><span class="line"><span class="cl">docker <span class="nb">exec</span> web nslookup api
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># List all containers on a network</span>
</span></span><span class="line"><span class="cl">docker network inspect mynetwork -f <span class="s1">&#39;{{range .Containers}}{{.Name}} {{.IPv4Address}}{{end}}&#39;</span></span></span></code></pre></div>
</div>
<h2 id="one-liner-health-checks">One-Liner Health Checks<a class="anchor" href="#one-liner-health-checks" aria-label="Link to “One-Liner Health Checks”">#</a></h2>
<div class="code">
<div class="code-head"><span class="code-lang">bash</span><button type="button" class="code-copy" data-copy-code hidden><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2.5"/><path d="M16 8V6.5A2.5 2.5 0 0 0 13.5 4h-7A2.5 2.5 0 0 0 4 6.5v7A2.5 2.5 0 0 0 6.5 16H8"/></svg><span>Copy</span></button></div>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="c1"># Which containers are unhealthy?</span>
</span></span><span class="line"><span class="cl">docker ps --filter <span class="nv">health</span><span class="o">=</span>unhealthy --format <span class="s1">&#39;{{.Names}}: {{.Status}}&#39;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Which containers restarted recently?</span>
</span></span><span class="line"><span class="cl">docker ps --format <span class="s1">&#39;{{.Names}}: {{.Status}}&#39;</span> <span class="p">|</span> grep -i restarting
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Resource usage snapshot</span>
</span></span><span class="line"><span class="cl">docker stats --no-stream --format <span class="s1">&#39;table {{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}&#39;</span></span></span></code></pre></div>
</div>
<p>The instinct to restart a broken container is strong. But nine times out of ten, the information you need to fix the actual problem is sitting right there in the logs, the inspect output, or the filesystem diff. Restarting just resets the evidence.</p>
]]></content:encoded></item><item><title>Caddy Patterns That Replaced My Nginx Config</title><link>https://articles.wleeaf.dev/posts/caddy-reverse-proxy-patterns/</link><pubDate>Sat, 28 Mar 2026 00:00:00 +0000</pubDate><guid>https://articles.wleeaf.dev/posts/caddy-reverse-proxy-patterns/</guid><description>Wildcard certs, host matching, VPN-only routes, and SPA fallbacks in a single Caddyfile.</description><category>caddy</category><category>webserver</category><category>infrastructure</category><content:encoded><![CDATA[<p>I switched from Nginx to Caddy about a year ago and I am never going back. Not because Nginx is bad, but because Caddy eliminates an entire category of problems I used to spend time on.</p>
<h2 id="automatic-tls">Automatic TLS<a class="anchor" href="#automatic-tls" aria-label="Link to “Automatic TLS”">#</a></h2>
<p>This is the obvious one. Caddy gets certificates from Let&rsquo;s Encrypt automatically. But the real power is the DNS challenge with wildcard certs:</p>
<div class="code">
<div class="code-head"><span class="code-lang">caddy</span><button type="button" class="code-copy" data-copy-code hidden><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2.5"/><path d="M16 8V6.5A2.5 2.5 0 0 0 13.5 4h-7A2.5 2.5 0 0 0 4 6.5v7A2.5 2.5 0 0 0 6.5 16H8"/></svg><span>Copy</span></button></div>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-caddy" data-lang="caddy"><span class="line"><span class="cl"><span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="k">acme_dns</span> <span class="s">cloudflare</span> <span class="se">{env.CLOUDFLARE_API_TOKEN}</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="gh">*.example.dev</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="k">tls</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="k">dns</span> <span class="s">cloudflare</span> <span class="se">{env.CLOUDFLARE_API_TOKEN}</span>
</span></span><span class="line"><span class="cl">  <span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="nd">@app</span> <span class="k">host</span> <span class="s">app.example.dev</span>
</span></span><span class="line"><span class="cl">  <span class="k">handle</span> <span class="nd">@app</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="k">reverse_proxy</span> <span class="n">app</span><span class="p">:</span><span class="mi">3000</span>
</span></span><span class="line"><span class="cl">  <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div>
</div>
<p>One wildcard cert covers every subdomain. No per-service cert management. No Certificate Transparency log leaking your internal subdomain names. And the DNS challenge means you do not even need port 80 open.</p>
<h2 id="host-matching">Host Matching<a class="anchor" href="#host-matching" aria-label="Link to “Host Matching”">#</a></h2>
<p>Caddy lets you route by hostname inside a single server block:</p>
<div class="code">
<div class="code-head"><span class="code-lang">caddy</span><button type="button" class="code-copy" data-copy-code hidden><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2.5"/><path d="M16 8V6.5A2.5 2.5 0 0 0 13.5 4h-7A2.5 2.5 0 0 0 4 6.5v7A2.5 2.5 0 0 0 6.5 16H8"/></svg><span>Copy</span></button></div>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-caddy" data-lang="caddy"><span class="line"><span class="cl"><span class="gh">*.example.dev</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="nd">@git</span> <span class="k">host</span> <span class="s">git.example.dev</span>
</span></span><span class="line"><span class="cl">  <span class="k">handle</span> <span class="nd">@git</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="k">reverse_proxy</span> <span class="n">gitea</span><span class="p">:</span><span class="mi">3000</span>
</span></span><span class="line"><span class="cl">  <span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="nd">@chat</span> <span class="k">host</span> <span class="s">chat.example.dev</span>
</span></span><span class="line"><span class="cl">  <span class="k">handle</span> <span class="nd">@chat</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="k">reverse_proxy</span> <span class="n">element</span><span class="p">:</span><span class="mi">80</span>
</span></span><span class="line"><span class="cl">  <span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="k">handle</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="k">respond</span> <span class="s2">&#34;Not found&#34;</span> <span class="mi">404</span>
</span></span><span class="line"><span class="cl">  <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div>
</div>
<p>In Nginx, each of these would be a separate <code>server</code> block with its own SSL configuration. In Caddy, it is one block with named matchers.</p>
<h2 id="vpn-only-routes">VPN-Only Routes<a class="anchor" href="#vpn-only-routes" aria-label="Link to “VPN-Only Routes”">#</a></h2>
<p>I restrict admin services to my VPN subnet:</p>
<div class="code">
<div class="code-head"><span class="code-lang">caddy</span><button type="button" class="code-copy" data-copy-code hidden><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2.5"/><path d="M16 8V6.5A2.5 2.5 0 0 0 13.5 4h-7A2.5 2.5 0 0 0 4 6.5v7A2.5 2.5 0 0 0 6.5 16H8"/></svg><span>Copy</span></button></div>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-caddy" data-lang="caddy"><span class="line"><span class="cl"><span class="gh">@admin</span> <span class="gh">host</span> <span class="gh">admin.example.dev</span><span class="p">
</span></span></span><span class="line"><span class="cl"><span class="p"></span><span class="k">handle</span> <span class="nd">@admin</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="nd">@blocked</span> <span class="k">not</span> <span class="k">remote_ip</span> <span class="mi">10</span><span class="s">.0.0.0/24</span>
</span></span><span class="line"><span class="cl">  <span class="k">respond</span> <span class="nd">@blocked</span> <span class="s2">&#34;Access Denied&#34;</span> <span class="mi">403</span>
</span></span><span class="line"><span class="cl">  <span class="k">reverse_proxy</span> <span class="n">admin</span><span class="p">:</span><span class="mi">3001</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div>
</div>
<p>If the request does not come from the VPN, it gets a 403. Combined with DNS that only resolves through the VPN, this is two layers of protection without any auth middleware.</p>
<h2 id="spa-fallback-with-sub-paths">SPA Fallback With Sub-Paths<a class="anchor" href="#spa-fallback-with-sub-paths" aria-label="Link to “SPA Fallback With Sub-Paths”">#</a></h2>
<p>Serving a single-page app under a sub-path is surprisingly tricky. The app lives at <code>/app/</code> but the client-side router handles routes like <code>/app/settings</code>:</p>
<div class="code">
<div class="code-head"><span class="code-lang">caddy</span><button type="button" class="code-copy" data-copy-code hidden><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2.5"/><path d="M16 8V6.5A2.5 2.5 0 0 0 13.5 4h-7A2.5 2.5 0 0 0 4 6.5v7A2.5 2.5 0 0 0 6.5 16H8"/></svg><span>Copy</span></button></div>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-caddy" data-lang="caddy"><span class="line"><span class="cl"><span class="gh">handle_path</span> <span class="gh">/app*</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="k">reverse_proxy</span> <span class="n">spa</span><span class="p">:</span><span class="mi">3000</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="nd">@notfound</span> <span class="k">status</span> <span class="mi">404</span>
</span></span><span class="line"><span class="cl">    <span class="k">handle_response</span> <span class="nd">@notfound</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">      <span class="k">rewrite</span> <span class="nd">*</span> <span class="s">/index.html</span>
</span></span><span class="line"><span class="cl">      <span class="k">reverse_proxy</span> <span class="n">spa</span><span class="p">:</span><span class="mi">3000</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl">  <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div>
</div>
<p><code>handle_path</code> strips the <code>/app</code> prefix before forwarding. If the upstream returns 404, we rewrite to <code>index.html</code> and try again. The SPA router takes over from there.</p>
<h2 id="gzip-and-security-headers">Gzip and Security Headers<a class="anchor" href="#gzip-and-security-headers" aria-label="Link to “Gzip and Security Headers”">#</a></h2>
<div class="code">
<div class="code-head"><span class="code-lang">caddy</span><button type="button" class="code-copy" data-copy-code hidden><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2.5"/><path d="M16 8V6.5A2.5 2.5 0 0 0 13.5 4h-7A2.5 2.5 0 0 0 4 6.5v7A2.5 2.5 0 0 0 6.5 16H8"/></svg><span>Copy</span></button></div>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-caddy" data-lang="caddy"><span class="line"><span class="cl"><span class="gh">*.example.dev</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="k">encode</span> <span class="s">gzip</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="k">header</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="k">Strict-Transport-Security</span> <span class="s2">&#34;max-age=31536000; includeSubDomains&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="k">X-Content-Type-Options</span> <span class="s2">&#34;nosniff&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="k">X-Frame-Options</span> <span class="s2">&#34;DENY&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="k">Referrer-Policy</span> <span class="s2">&#34;strict-origin-when-cross-origin&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div>
</div>
<p>These apply to every subdomain automatically. No copying the same headers into fifteen different location blocks.</p>
<h2 id="static-files-with-custom-404">Static Files With Custom 404<a class="anchor" href="#static-files-with-custom-404" aria-label="Link to “Static Files With Custom 404”">#</a></h2>
<div class="code">
<div class="code-head"><span class="code-lang">caddy</span><button type="button" class="code-copy" data-copy-code hidden><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2.5"/><path d="M16 8V6.5A2.5 2.5 0 0 0 13.5 4h-7A2.5 2.5 0 0 0 4 6.5v7A2.5 2.5 0 0 0 6.5 16H8"/></svg><span>Copy</span></button></div>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-caddy" data-lang="caddy"><span class="line"><span class="cl"><span class="gh">@landing</span> <span class="gh">host</span> <span class="gh">example.dev</span><span class="p">
</span></span></span><span class="line"><span class="cl"><span class="p"></span><span class="k">handle</span> <span class="nd">@landing</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="k">root</span> <span class="nd">*</span> <span class="s">/srv/example.dev</span>
</span></span><span class="line"><span class="cl">  <span class="k">file_server</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">handle_errors</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="nd">@404</span> <span class="k">expression</span> <span class="se">{err.status_code}</span> <span class="s">==</span> <span class="mi">404</span>
</span></span><span class="line"><span class="cl">  <span class="k">handle</span> <span class="nd">@404</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="k">root</span> <span class="nd">*</span> <span class="s">/srv</span>
</span></span><span class="line"><span class="cl">    <span class="k">rewrite</span> <span class="nd">*</span> <span class="s">/404.html</span>
</span></span><span class="line"><span class="cl">    <span class="k">file_server</span>
</span></span><span class="line"><span class="cl">  <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div>
</div>
<p>The <code>handle_errors</code> block catches 404s across all routes and serves a custom page. One error handler for the entire server.</p>
<h2 id="websocket-proxying">WebSocket Proxying<a class="anchor" href="#websocket-proxying" aria-label="Link to “WebSocket Proxying”">#</a></h2>
<p>Caddy handles WebSocket upgrades automatically:</p>
<div class="code">
<div class="code-head"><span class="code-lang">caddy</span><button type="button" class="code-copy" data-copy-code hidden><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2.5"/><path d="M16 8V6.5A2.5 2.5 0 0 0 13.5 4h-7A2.5 2.5 0 0 0 4 6.5v7A2.5 2.5 0 0 0 6.5 16H8"/></svg><span>Copy</span></button></div>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-caddy" data-lang="caddy"><span class="line"><span class="cl"><span class="gh">reverse_proxy</span> <span class="gh">game:3000</span></span></span></code></pre></div>
</div>
<p>That is it. No <code>proxy_http_version 1.1</code>, no <code>proxy_set_header Upgrade</code>, no <code>proxy_set_header Connection</code>. Caddy detects the upgrade request and does the right thing.</p>
<h2 id="streaming-responses">Streaming Responses<a class="anchor" href="#streaming-responses" aria-label="Link to “Streaming Responses”">#</a></h2>
<p>For Server-Sent Events or long-running responses:</p>
<div class="code">
<div class="code-head"><span class="code-lang">caddy</span><button type="button" class="code-copy" data-copy-code hidden><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2.5"/><path d="M16 8V6.5A2.5 2.5 0 0 0 13.5 4h-7A2.5 2.5 0 0 0 4 6.5v7A2.5 2.5 0 0 0 6.5 16H8"/></svg><span>Copy</span></button></div>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-caddy" data-lang="caddy"><span class="line"><span class="cl"><span class="gh">reverse_proxy</span> <span class="gh">backend:3000</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="k">flush_interval</span> <span class="s">-1</span>
</span></span><span class="line"><span class="cl">  <span class="k">transport</span> <span class="s">http</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="k">read_timeout</span> <span class="mi">0</span>
</span></span><span class="line"><span class="cl">    <span class="k">write_timeout</span> <span class="mi">0</span>
</span></span><span class="line"><span class="cl">  <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div>
</div>
<p><code>flush_interval -1</code> disables response buffering. The timeout overrides prevent Caddy from killing long-lived connections.</p>
<p>The Caddyfile syntax is not perfect. The matcher system takes some getting used to, and the documentation could be better organized. But the amount of boilerplate it eliminates compared to Nginx is staggering. My entire server config is under 120 lines, and it handles TLS, routing, static files, reverse proxying, WebSockets, and error pages for a dozen services.</p>
]]></content:encoded></item><item><title>Bypassing ISP Deep Packet Inspection on Linux</title><link>https://articles.wleeaf.dev/posts/dpi-bypass-linux/</link><pubDate>Sat, 28 Mar 2026 00:00:00 +0000</pubDate><guid>https://articles.wleeaf.dev/posts/dpi-bypass-linux/</guid><description>When your ISP blocks websites at the packet level, here is how to get around it without a full VPN.</description><category>networking</category><category>privacy</category><category>linux</category><content:encoded><![CDATA[<p>My ISP does deep packet inspection. Not the basic DNS-level blocking that you can get around by switching to a different resolver. Actual packet inspection that looks at TLS handshakes and blocks connections to certain domains based on the SNI field in the ClientHello.</p>
<p>This means changing your DNS server does nothing. The ISP sees the domain name in the TLS handshake before the connection is encrypted and drops the packet. You need a different approach. Mine has two parts: zapret, which splits up the TLS ClientHello so the DPI system cannot read the SNI field in time, and DNS-over-TLS, so DNS queries stop travelling in plain text.</p>
<h2 id="how-dpi-blocking-works">How DPI Blocking Works<a class="anchor" href="#how-dpi-blocking-works" aria-label="Link to “How DPI Blocking Works”">#</a></h2>
<p>When you connect to a website over HTTPS, the first thing your browser sends is a TLS ClientHello message. This message is not encrypted. It contains the Server Name Indication field, which tells the server which domain you are trying to reach. This is necessary because multiple domains can share the same IP address, and the server needs to know which certificate to present.</p>
<p>Your ISP inspects this field. If the domain matches their blocklist, they inject a TCP RST packet or simply drop the connection. From your perspective, the site just times out or you get a connection reset.</p>
<h2 id="the-zapret-approach">The Zapret Approach<a class="anchor" href="#the-zapret-approach" aria-label="Link to “The Zapret Approach”">#</a></h2>
<p>There is a tool called zapret that manipulates outgoing packets to confuse DPI systems. It does not encrypt anything or route traffic through a proxy. Instead, it exploits the fact that DPI systems have to make real-time decisions about packets and often take shortcuts.</p>
<p>The basic techniques include fragmenting the ClientHello across multiple TCP segments, adding padding to push the SNI field into a second packet, or modifying TCP flags in ways that confuse stateful inspection engines but are tolerated by the destination server.</p>
<p>I run zapret as a systemd service. It hooks into the outgoing packet flow using nfqueue and modifies specific packets before they leave my machine. The destination server receives the same data, just split up differently, and handles it fine. The ISP DPI system sees fragments that it cannot reassemble quickly enough and lets them through.</p>
<h2 id="dns-over-tls-complement">DNS-over-TLS Complement<a class="anchor" href="#dns-over-tls-complement" aria-label="Link to “DNS-over-TLS Complement”">#</a></h2>
<p>Zapret handles the TLS handshake problem, but DNS queries can still leak information. Even if the ISP cannot block the connection, they can see which domains you are resolving and log that data.</p>
<p>I force all DNS through DNS-over-TLS using systemd-resolved. The configuration tells resolved to only use TLS-encrypted DNS and to require valid certificates from the upstream resolver. I use Cloudflare and Google as upstream servers since both support DNS-over-TLS.</p>
<p>The resolved config looks straightforward. You set the DNS servers and tell it to require encrypted transport. After restarting the service, all DNS queries from the machine go through TLS on port 853 instead of plaintext on port 53.</p>
<h2 id="the-vpn-conflict">The VPN Conflict<a class="anchor" href="#the-vpn-conflict" aria-label="Link to “The VPN Conflict”">#</a></h2>
<p>This setup conflicts with split-tunnel VPN configurations. When the VPN is active, DNS queries for private services need to go to the VPN DNS server over the tunnel. But the DPI bypass forces all DNS through TLS, which means queries to the VPN DNS server also try to use TLS.</p>
<p>The VPN DNS server is just dnsmasq. It does not speak TLS. So the queries fail and private domain names do not resolve.</p>
<p>The fix is a PostUp hook in the WireGuard configuration that disables DNS-over-TLS specifically on the VPN interface. The system still uses DoT for all other DNS queries, but queries routed through the VPN go as plain DNS over the encrypted tunnel. Since the tunnel itself is encrypted, this is fine from a privacy perspective.</p>
<p>Getting this right took some trial and error. The symptoms were confusing: public websites worked, VPN connected successfully, but private subdomains just would not resolve. Once I traced it to the DoT setting, the fix was one line.</p>
<h2 id="performance-impact">Performance Impact<a class="anchor" href="#performance-impact" aria-label="Link to “Performance Impact”">#</a></h2>
<p>Zapret adds essentially zero latency. The packet manipulation happens in kernel space and takes microseconds. DNS-over-TLS adds a small amount of latency to the first query for each domain, maybe 10 to 20 milliseconds. Subsequent queries are cached by resolved so you do not notice it.</p>
<p>I have been running this setup for months and have never noticed any performance impact in normal usage. Streaming, gaming, and large downloads all work at full speed. The only time I notice it is when I forget to check if the service is running and a blocked site fails to load.</p>
<h2 id="reliability">Reliability<a class="anchor" href="#reliability" aria-label="Link to “Reliability”">#</a></h2>
<p>The DPI bypass works about 99 percent of the time. Occasionally the ISP updates their inspection rules and a specific technique stops working. Zapret has multiple strategies and you can configure fallbacks. Usually updating to the latest version fixes any regressions within a day or two.</p>
<p>The DNS-over-TLS setup is rock solid. I have never had it fail. Systemd-resolved handles reconnections and failovers between upstream servers automatically.</p>
<h2 id="legal-and-ethical-notes">Legal and Ethical Notes<a class="anchor" href="#legal-and-ethical-notes" aria-label="Link to “Legal and Ethical Notes”">#</a></h2>
<p>This is not about accessing illegal content. My ISP blocks legitimate websites, often because a previous user of the domain or IP address did something that triggered a broad block. The blocking is opaque, there is no notification, and there is no process for getting a site unblocked.</p>
<p>Bypassing DPI to access legal content that your ISP has incorrectly or broadly blocked is a reasonable thing to do. The tools involved are all open source and widely used. Nobody has ever gotten in trouble for running zapret on their own machine.</p>
<p>If you are in a situation where your ISP does DPI blocking, this setup gives you back control over your own internet connection without the overhead and privacy implications of routing all your traffic through a third-party VPN.</p>
]]></content:encoded></item><item><title>jq Is the SQL of JSON</title><link>https://articles.wleeaf.dev/posts/jq-command-line-json/</link><pubDate>Fri, 27 Mar 2026 00:00:00 +0000</pubDate><guid>https://articles.wleeaf.dev/posts/jq-command-line-json/</guid><description>Every API returns JSON now. jq turns your terminal into a query engine for it.</description><category>cli</category><category>json</category><category>linux</category><content:encoded><![CDATA[<p>Every API you talk to returns JSON. Every config file is JSON. Every log pipeline emits JSON. And yet most people still pipe <code>curl</code> output through <code>grep</code> and pray.</p>
<p>jq is the tool that makes JSON usable from the command line. Once you learn the basics, you will never go back.</p>
<h2 id="basics">Basics<a class="anchor" href="#basics" aria-label="Link to “Basics”">#</a></h2>
<p>Pretty-print some JSON:</p>
<div class="code">
<div class="code-head"><span class="code-lang">bash</span><button type="button" class="code-copy" data-copy-code hidden><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2.5"/><path d="M16 8V6.5A2.5 2.5 0 0 0 13.5 4h-7A2.5 2.5 0 0 0 4 6.5v7A2.5 2.5 0 0 0 6.5 16H8"/></svg><span>Copy</span></button></div>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">curl -s https://api.github.com/users/torvalds <span class="p">|</span> jq .</span></span></code></pre></div>
</div>
<p>Extract a single field:</p>
<div class="code">
<div class="code-head"><span class="code-lang">bash</span><button type="button" class="code-copy" data-copy-code hidden><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2.5"/><path d="M16 8V6.5A2.5 2.5 0 0 0 13.5 4h-7A2.5 2.5 0 0 0 4 6.5v7A2.5 2.5 0 0 0 6.5 16H8"/></svg><span>Copy</span></button></div>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="nb">echo</span> <span class="s1">&#39;{&#34;name&#34;: &#34;Linus&#34;, &#34;repos&#34;: 5}&#39;</span> <span class="p">|</span> jq .name
</span></span><span class="line"><span class="cl"><span class="c1"># &#34;Linus&#34;</span></span></span></code></pre></div>
</div>
<p>Drop the quotes with <code>-r</code>:</p>
<div class="code">
<div class="code-head"><span class="code-lang">bash</span><button type="button" class="code-copy" data-copy-code hidden><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2.5"/><path d="M16 8V6.5A2.5 2.5 0 0 0 13.5 4h-7A2.5 2.5 0 0 0 4 6.5v7A2.5 2.5 0 0 0 6.5 16H8"/></svg><span>Copy</span></button></div>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="nb">echo</span> <span class="s1">&#39;{&#34;name&#34;: &#34;Linus&#34;}&#39;</span> <span class="p">|</span> jq -r .name
</span></span><span class="line"><span class="cl"><span class="c1"># Linus</span></span></span></code></pre></div>
</div>
<h2 id="arrays">Arrays<a class="anchor" href="#arrays" aria-label="Link to “Arrays”">#</a></h2>
<p>Get the first element:</p>
<div class="code">
<div class="code-head"><span class="code-lang">bash</span><button type="button" class="code-copy" data-copy-code hidden><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2.5"/><path d="M16 8V6.5A2.5 2.5 0 0 0 13.5 4h-7A2.5 2.5 0 0 0 4 6.5v7A2.5 2.5 0 0 0 6.5 16H8"/></svg><span>Copy</span></button></div>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="nb">echo</span> <span class="s1">&#39;[1, 2, 3]&#39;</span> <span class="p">|</span> jq <span class="s1">&#39;.[0]&#39;</span>
</span></span><span class="line"><span class="cl"><span class="c1"># 1</span></span></span></code></pre></div>
</div>
<p>Iterate over all elements:</p>
<div class="code">
<div class="code-head"><span class="code-lang">bash</span><button type="button" class="code-copy" data-copy-code hidden><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2.5"/><path d="M16 8V6.5A2.5 2.5 0 0 0 13.5 4h-7A2.5 2.5 0 0 0 4 6.5v7A2.5 2.5 0 0 0 6.5 16H8"/></svg><span>Copy</span></button></div>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="nb">echo</span> <span class="s1">&#39;[{&#34;name&#34;: &#34;a&#34;}, {&#34;name&#34;: &#34;b&#34;}]&#39;</span> <span class="p">|</span> jq <span class="s1">&#39;.[].name&#39;</span>
</span></span><span class="line"><span class="cl"><span class="c1"># &#34;a&#34;</span>
</span></span><span class="line"><span class="cl"><span class="c1"># &#34;b&#34;</span></span></span></code></pre></div>
</div>
<h2 id="filtering">Filtering<a class="anchor" href="#filtering" aria-label="Link to “Filtering”">#</a></h2>
<p>This is where it gets powerful. Say you have a list of GitHub repos:</p>
<div class="code">
<div class="code-head"><span class="code-lang">bash</span><button type="button" class="code-copy" data-copy-code hidden><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2.5"/><path d="M16 8V6.5A2.5 2.5 0 0 0 13.5 4h-7A2.5 2.5 0 0 0 4 6.5v7A2.5 2.5 0 0 0 6.5 16H8"/></svg><span>Copy</span></button></div>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">curl -s https://api.github.com/users/torvalds/repos <span class="p">|</span> <span class="se">\
</span></span></span><span class="line"><span class="cl"><span class="se"></span>  jq <span class="s1">&#39;[.[] | select(.stargazers_count &gt; 1000)] | length&#39;</span></span></span></code></pre></div>
</div>
<p>That filters repos with more than 1000 stars and counts them.</p>
<h2 id="constructing-objects">Constructing Objects<a class="anchor" href="#constructing-objects" aria-label="Link to “Constructing Objects”">#</a></h2>
<p>You can reshape JSON on the fly:</p>
<div class="code">
<div class="code-head"><span class="code-lang">bash</span><button type="button" class="code-copy" data-copy-code hidden><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2.5"/><path d="M16 8V6.5A2.5 2.5 0 0 0 13.5 4h-7A2.5 2.5 0 0 0 4 6.5v7A2.5 2.5 0 0 0 6.5 16H8"/></svg><span>Copy</span></button></div>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">curl -s https://api.github.com/users/torvalds/repos <span class="p">|</span> <span class="se">\
</span></span></span><span class="line"><span class="cl"><span class="se"></span>  jq <span class="s1">&#39;.[] | {name: .name, stars: .stargazers_count, lang: .language}&#39;</span></span></span></code></pre></div>
</div>
<p>This outputs a new object for each repo with only the fields you care about.</p>
<h2 id="real-world-parsing-docker">Real World: Parsing Docker<a class="anchor" href="#real-world-parsing-docker" aria-label="Link to “Real World: Parsing Docker”">#</a></h2>
<p>List container names and their memory usage:</p>
<div class="code">
<div class="code-head"><span class="code-lang">bash</span><button type="button" class="code-copy" data-copy-code hidden><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2.5"/><path d="M16 8V6.5A2.5 2.5 0 0 0 13.5 4h-7A2.5 2.5 0 0 0 4 6.5v7A2.5 2.5 0 0 0 6.5 16H8"/></svg><span>Copy</span></button></div>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">docker stats --no-stream --format <span class="s1">&#39;{{json .}}&#39;</span> <span class="p">|</span> <span class="se">\
</span></span></span><span class="line"><span class="cl"><span class="se"></span>  jq -r <span class="s1">&#39;[.Name, .MemUsage] | @tsv&#39;</span></span></span></code></pre></div>
</div>
<p>Find containers using more than 100MB:</p>
<div class="code">
<div class="code-head"><span class="code-lang">bash</span><button type="button" class="code-copy" data-copy-code hidden><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2.5"/><path d="M16 8V6.5A2.5 2.5 0 0 0 13.5 4h-7A2.5 2.5 0 0 0 4 6.5v7A2.5 2.5 0 0 0 6.5 16H8"/></svg><span>Copy</span></button></div>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">docker stats --no-stream --format <span class="s1">&#39;{{json .}}&#39;</span> <span class="p">|</span> <span class="se">\
</span></span></span><span class="line"><span class="cl"><span class="se"></span>  jq -r <span class="s1">&#39;select(.MemPerc | gsub(&#34;%&#34;; &#34;&#34;) | tonumber &gt; 1) | .Name&#39;</span></span></span></code></pre></div>
</div>
<h2 id="working-with-nested-data">Working With Nested Data<a class="anchor" href="#working-with-nested-data" aria-label="Link to “Working With Nested Data”">#</a></h2>
<p>Given deeply nested JSON:</p>
<div class="code">
<div class="code-head"><span class="code-lang">json</span><button type="button" class="code-copy" data-copy-code hidden><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2.5"/><path d="M16 8V6.5A2.5 2.5 0 0 0 13.5 4h-7A2.5 2.5 0 0 0 4 6.5v7A2.5 2.5 0 0 0 6.5 16H8"/></svg><span>Copy</span></button></div>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="cl"><span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;data&#34;</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;users&#34;</span><span class="p">:</span> <span class="p">[</span>
</span></span><span class="line"><span class="cl">      <span class="p">{</span><span class="nt">&#34;id&#34;</span><span class="p">:</span> <span class="mi">1</span><span class="p">,</span> <span class="nt">&#34;profile&#34;</span><span class="p">:</span> <span class="p">{</span><span class="nt">&#34;email&#34;</span><span class="p">:</span> <span class="s2">&#34;a@b.com&#34;</span><span class="p">}},</span>
</span></span><span class="line"><span class="cl">      <span class="p">{</span><span class="nt">&#34;id&#34;</span><span class="p">:</span> <span class="mi">2</span><span class="p">,</span> <span class="nt">&#34;profile&#34;</span><span class="p">:</span> <span class="p">{</span><span class="nt">&#34;email&#34;</span><span class="p">:</span> <span class="s2">&#34;c@d.com&#34;</span><span class="p">}}</span>
</span></span><span class="line"><span class="cl">    <span class="p">]</span>
</span></span><span class="line"><span class="cl">  <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div>
</div>
<p>Extract all emails:</p>
<div class="code">
<div class="code-head"><span class="code-lang">bash</span><button type="button" class="code-copy" data-copy-code hidden><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2.5"/><path d="M16 8V6.5A2.5 2.5 0 0 0 13.5 4h-7A2.5 2.5 0 0 0 4 6.5v7A2.5 2.5 0 0 0 6.5 16H8"/></svg><span>Copy</span></button></div>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">jq <span class="s1">&#39;[.data.users[].profile.email]&#39;</span> data.json
</span></span><span class="line"><span class="cl"><span class="c1"># [&#34;a@b.com&#34;, &#34;c@d.com&#34;]</span></span></span></code></pre></div>
</div>
<h2 id="combining-with-other-tools">Combining With Other Tools<a class="anchor" href="#combining-with-other-tools" aria-label="Link to “Combining With Other Tools”">#</a></h2>
<p>jq plays well with the rest of the Unix toolkit:</p>
<div class="code">
<div class="code-head"><span class="code-lang">bash</span><button type="button" class="code-copy" data-copy-code hidden><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2.5"/><path d="M16 8V6.5A2.5 2.5 0 0 0 13.5 4h-7A2.5 2.5 0 0 0 4 6.5v7A2.5 2.5 0 0 0 6.5 16H8"/></svg><span>Copy</span></button></div>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="c1"># Get all unique languages across repos</span>
</span></span><span class="line"><span class="cl">curl -s https://api.github.com/users/torvalds/repos <span class="p">|</span> <span class="se">\
</span></span></span><span class="line"><span class="cl"><span class="se"></span>  jq -r <span class="s1">&#39;.[].language // empty&#39;</span> <span class="p">|</span> sort -u
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Convert JSON array to lines for xargs</span>
</span></span><span class="line"><span class="cl"><span class="nb">echo</span> <span class="s1">&#39;[&#34;file1.txt&#34;, &#34;file2.txt&#34;]&#39;</span> <span class="p">|</span> jq -r <span class="s1">&#39;.[]&#39;</span> <span class="p">|</span> xargs rm
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Merge multiple JSON files</span>
</span></span><span class="line"><span class="cl">jq -s <span class="s1">&#39;add&#39;</span> file1.json file2.json &gt; merged.json</span></span></code></pre></div>
</div>
<h2 id="the-cheat-sheet">The Cheat Sheet<a class="anchor" href="#the-cheat-sheet" aria-label="Link to “The Cheat Sheet”">#</a></h2>
<table>
  <thead>
      <tr>
          <th>Pattern</th>
          <th>What it does</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>.field</code></td>
          <td>Get field</td>
      </tr>
      <tr>
          <td><code>.[]</code></td>
          <td>Iterate array</td>
      </tr>
      <tr>
          <td><code>select(cond)</code></td>
          <td>Filter</td>
      </tr>
      <tr>
          <td><code>map(expr)</code></td>
          <td>Transform array</td>
      </tr>
      <tr>
          <td><code>length</code></td>
          <td>Count</td>
      </tr>
      <tr>
          <td><code>keys</code></td>
          <td>Object keys</td>
      </tr>
      <tr>
          <td><code>to_entries</code></td>
          <td>Object to key-value pairs</td>
      </tr>
      <tr>
          <td><code>@csv</code></td>
          <td>Output as CSV</td>
      </tr>
      <tr>
          <td><code>@tsv</code></td>
          <td>Output as TSV</td>
      </tr>
      <tr>
          <td><code>//</code></td>
          <td>Default value</td>
      </tr>
  </tbody>
</table>
<p>jq has a learning curve, but it is one of those tools where the investment pays off within a week. Every time you find yourself writing a Python script just to parse some JSON, remember that jq probably does it in one line.</p>
]]></content:encoded></item><item><title>SSH Config Tricks I Use Every Day</title><link>https://articles.wleeaf.dev/posts/ssh-config-tricks/</link><pubDate>Thu, 26 Mar 2026 00:00:00 +0000</pubDate><guid>https://articles.wleeaf.dev/posts/ssh-config-tricks/</guid><description>Stop typing full hostnames and passwords. Your SSH config file is more powerful than you think.</description><category>ssh</category><category>linux</category><category>productivity</category><content:encoded><![CDATA[<p>I SSH into servers maybe thirty times a day. Without a proper SSH config, that would mean typing out usernames, hostnames, ports, and key paths every single time. Life is too short for that.</p>
<h2 id="the-basics">The Basics<a class="anchor" href="#the-basics" aria-label="Link to “The Basics”">#</a></h2>
<p>The SSH config lives at <code>~/.ssh/config</code>. Here is a minimal example:</p>
<div class="code is-plain">
<div class="code-head"><button type="button" class="code-copy" data-copy-code hidden><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2.5"/><path d="M16 8V6.5A2.5 2.5 0 0 0 13.5 4h-7A2.5 2.5 0 0 0 4 6.5v7A2.5 2.5 0 0 0 6.5 16H8"/></svg><span>Copy</span></button></div>
<pre tabindex="0"><code>Host myserver
  HostName 203.0.113.50
  User root
  IdentityFile ~/.ssh/id_ed25519</code></pre>
</div>
<p>Now <code>ssh myserver</code> is all you need. But it gets better.</p>
<h2 id="jump-hosts">Jump Hosts<a class="anchor" href="#jump-hosts" aria-label="Link to “Jump Hosts”">#</a></h2>
<p>If you need to reach a server through a bastion:</p>
<div class="code is-plain">
<div class="code-head"><button type="button" class="code-copy" data-copy-code hidden><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2.5"/><path d="M16 8V6.5A2.5 2.5 0 0 0 13.5 4h-7A2.5 2.5 0 0 0 4 6.5v7A2.5 2.5 0 0 0 6.5 16H8"/></svg><span>Copy</span></button></div>
<pre tabindex="0"><code>Host bastion
  HostName bastion.example.com
  User admin

Host internal
  HostName 10.0.1.50
  User deploy
  ProxyJump bastion</code></pre>
</div>
<p><code>ssh internal</code> automatically tunnels through the bastion. No manual port forwarding, no two-step login.</p>
<h2 id="wildcard-hosts">Wildcard Hosts<a class="anchor" href="#wildcard-hosts" aria-label="Link to “Wildcard Hosts”">#</a></h2>
<p>This is the one most people miss:</p>
<div class="code is-plain">
<div class="code-head"><button type="button" class="code-copy" data-copy-code hidden><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2.5"/><path d="M16 8V6.5A2.5 2.5 0 0 0 13.5 4h-7A2.5 2.5 0 0 0 4 6.5v7A2.5 2.5 0 0 0 6.5 16H8"/></svg><span>Copy</span></button></div>
<pre tabindex="0"><code>Host *.prod
  User deploy
  IdentityFile ~/.ssh/prod_key
  StrictHostKeyChecking accept-new

Host web.prod
  HostName 10.0.1.10

Host api.prod
  HostName 10.0.1.11

Host db.prod
  HostName 10.0.1.12</code></pre>
</div>
<p>The wildcard block sets defaults for anything ending in <code>.prod</code>. Individual host blocks just add the hostname. You get consistent configuration without repetition.</p>
<h2 id="keep-connections-alive">Keep Connections Alive<a class="anchor" href="#keep-connections-alive" aria-label="Link to “Keep Connections Alive”">#</a></h2>
<p>Add this to your <code>Host *</code> block:</p>
<div class="code is-plain">
<div class="code-head"><button type="button" class="code-copy" data-copy-code hidden><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2.5"/><path d="M16 8V6.5A2.5 2.5 0 0 0 13.5 4h-7A2.5 2.5 0 0 0 4 6.5v7A2.5 2.5 0 0 0 6.5 16H8"/></svg><span>Copy</span></button></div>
<pre tabindex="0"><code>Host *
  ServerAliveInterval 60
  ServerAliveCountMax 3
  AddKeysToAgent yes
  IdentitiesOnly yes</code></pre>
</div>
<p><code>ServerAliveInterval</code> sends a keepalive every 60 seconds. This stops firewalls and NATs from killing idle connections. <code>AddKeysToAgent</code> automatically loads keys into ssh-agent after first use.</p>
<h2 id="connection-multiplexing">Connection Multiplexing<a class="anchor" href="#connection-multiplexing" aria-label="Link to “Connection Multiplexing”">#</a></h2>
<p>This is the real speed trick:</p>
<div class="code is-plain">
<div class="code-head"><button type="button" class="code-copy" data-copy-code hidden><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2.5"/><path d="M16 8V6.5A2.5 2.5 0 0 0 13.5 4h-7A2.5 2.5 0 0 0 4 6.5v7A2.5 2.5 0 0 0 6.5 16H8"/></svg><span>Copy</span></button></div>
<pre tabindex="0"><code>Host *
  ControlMaster auto
  ControlPath ~/.ssh/sockets/%r@%h-%p
  ControlPersist 600</code></pre>
</div>
<p>The first SSH connection to a host creates a socket. Subsequent connections to the same host reuse it. No new TCP handshake, no new key exchange. The second <code>ssh myserver</code> connects instantly.</p>
<p><code>ControlPersist 600</code> keeps the socket alive for 10 minutes after you disconnect. So if you SSH in, run a command, disconnect, and SSH back in within 10 minutes, the second connection is instant.</p>
<p>Create the socket directory first:</p>
<div class="code">
<div class="code-head"><span class="code-lang">bash</span><button type="button" class="code-copy" data-copy-code hidden><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2.5"/><path d="M16 8V6.5A2.5 2.5 0 0 0 13.5 4h-7A2.5 2.5 0 0 0 4 6.5v7A2.5 2.5 0 0 0 6.5 16H8"/></svg><span>Copy</span></button></div>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">mkdir -p ~/.ssh/sockets</span></span></code></pre></div>
</div>
<h2 id="port-forwarding-shortcuts">Port Forwarding Shortcuts<a class="anchor" href="#port-forwarding-shortcuts" aria-label="Link to “Port Forwarding Shortcuts”">#</a></h2>
<div class="code is-plain">
<div class="code-head"><button type="button" class="code-copy" data-copy-code hidden><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2.5"/><path d="M16 8V6.5A2.5 2.5 0 0 0 13.5 4h-7A2.5 2.5 0 0 0 4 6.5v7A2.5 2.5 0 0 0 6.5 16H8"/></svg><span>Copy</span></button></div>
<pre tabindex="0"><code>Host db-tunnel
  HostName myserver.com
  User admin
  LocalForward 5432 localhost:5432
  RequestTTY no
  ExitOnForwardFailure yes</code></pre>
</div>
<p>Now <code>ssh db-tunnel</code> opens a tunnel to the remote Postgres. No remembering the <code>-L</code> syntax. <code>ExitOnForwardFailure</code> means if the port is already in use, SSH exits instead of connecting without the tunnel.</p>
<h2 id="sshfp-for-host-verification">SSHFP for Host Verification<a class="anchor" href="#sshfp-for-host-verification" aria-label="Link to “SSHFP for Host Verification”">#</a></h2>
<p>Tired of the &ldquo;Are you sure you want to continue connecting?&rdquo; messages? If you control the DNS, add SSHFP records:</p>
<div class="code">
<div class="code-head"><span class="code-lang">bash</span><button type="button" class="code-copy" data-copy-code hidden><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2.5"/><path d="M16 8V6.5A2.5 2.5 0 0 0 13.5 4h-7A2.5 2.5 0 0 0 4 6.5v7A2.5 2.5 0 0 0 6.5 16H8"/></svg><span>Copy</span></button></div>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">ssh-keygen -r myserver.com</span></span></code></pre></div>
</div>
<p>This outputs DNS records you can add to your zone. Then set:</p>
<div class="code is-plain">
<div class="code-head"><button type="button" class="code-copy" data-copy-code hidden><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2.5"/><path d="M16 8V6.5A2.5 2.5 0 0 0 13.5 4h-7A2.5 2.5 0 0 0 4 6.5v7A2.5 2.5 0 0 0 6.5 16H8"/></svg><span>Copy</span></button></div>
<pre tabindex="0"><code>Host *
  VerifyHostKeyDNS yes</code></pre>
</div>
<p>SSH verifies the host key against DNS automatically. No more blindly accepting fingerprints.</p>
<p>The SSH config file is probably the highest-leverage dotfile you can invest time in. Five minutes of setup saves thousands of keystrokes.</p>
]]></content:encoded></item><item><title>Self-Hosting Matrix: Federation, Calls, and What Nobody Tells You</title><link>https://articles.wleeaf.dev/posts/matrix-federation-self-hosted/</link><pubDate>Thu, 26 Mar 2026 00:00:00 +0000</pubDate><guid>https://articles.wleeaf.dev/posts/matrix-federation-self-hosted/</guid><description>Setting up a Matrix homeserver with Element Web, voice calls, and federation. The documentation leaves out some important details.</description><category>matrix</category><category>self-hosting</category><category>chat</category><content:encoded><![CDATA[<p>I wanted a self-hosted chat platform that my friends could use without installing anything weird. Matrix with Element Web checked every box: it works in the browser, supports voice and video calls, has end-to-end encryption, and federates with other Matrix servers. The setup was mostly straightforward, but there were a few things that cost me hours because the documentation glosses over them.</p>
<h2 id="server-name-is-permanent">Server Name Is Permanent<a class="anchor" href="#server-name-is-permanent" aria-label="Link to “Server Name Is Permanent”">#</a></h2>
<p>The most important decision you make is <code>server_name</code> in the Synapse config. This is the domain that appears in every user ID. If you set it to <code>matrix.example.com</code>, your users will be <code>@name:matrix.example.com</code>. If you set it to <code>example.com</code>, they will be <code>@name:example.com</code>.</p>
<p>You want the bare domain. Shorter IDs look better and are easier to share. But here is the catch: once you set the server name and create the first user, you cannot change it. Ever. The signing keys, room IDs, and event hashes are all tied to that name. Changing it means starting over.</p>
<p>I went with the bare domain and use well-known delegation to tell other servers that the actual homeserver lives at a subdomain.</p>
<h2 id="well-known-delegation">Well-Known Delegation<a class="anchor" href="#well-known-delegation" aria-label="Link to “Well-Known Delegation”">#</a></h2>
<p>When another Matrix server wants to talk to yours, it looks up your server name. If your server name is <code>example.com</code> but Synapse runs on <code>matrix.example.com</code>, the remote server needs a way to discover that.</p>
<p>You serve two JSON endpoints on the bare domain:</p>
<p>The server endpoint returns which host and port handles federation. The client endpoint returns where the client API lives. Both are tiny JSON responses that Caddy can serve inline with the <code>respond</code> directive. No files, no separate service.</p>
<p>The important detail that the docs barely mention: the client well-known response needs a CORS header. Specifically <code>Access-Control-Allow-Origin: *</code>. Without it, Element Web running in a browser cannot discover the homeserver. You will stare at a login screen that says it cannot reach the server and wonder what you did wrong.</p>
<h2 id="cloudflare-and-federation">Cloudflare and Federation<a class="anchor" href="#cloudflare-and-federation" aria-label="Link to “Cloudflare and Federation”">#</a></h2>
<p>If your domain is behind Cloudflare, federation works fine as long as you set up the well-known endpoints correctly. Federation traffic goes through port 443, which Cloudflare proxies without issues.</p>
<p>The one thing to watch: Cloudflare has a 100-second timeout on the free plan. For a small personal server this is irrelevant, but if you ever end up in a large federated room, the initial sync could theoretically hit that limit.</p>
<p>I have been in rooms with several hundred members and never hit the timeout. But it is worth knowing about.</p>
<h2 id="voice-and-video-calls">Voice and Video Calls<a class="anchor" href="#voice-and-video-calls" aria-label="Link to “Voice and Video Calls”">#</a></h2>
<p>Element supports one-to-one and group calls, but they need a TURN server to relay media when direct peer-to-peer connections fail. This happens more often than you would think. Corporate firewalls, symmetric NAT, and mobile carriers all block direct WebRTC connections.</p>
<p>I already had a coturn server running for another project. Adding Matrix support was just a matter of generating a shared secret and putting it in the Synapse config. Synapse generates time-limited TURN credentials for each call using HMAC, which is more secure than static credentials.</p>
<p>The config in Synapse points to the TURN server by IP address, not hostname. This avoids DNS resolution issues. You list both UDP and TCP transport options so clients behind restrictive firewalls can fall back to TCP.</p>
<p>Testing calls is annoying because you need two separate accounts on two separate devices. You cannot call yourself. I created a test account, logged in on my phone, and called from my laptop. The first call had one-way audio because the TURN server was binding relay ports on Docker internal IPs instead of the public IP. Adding <code>--relay-ip</code> and <code>--listening-ip</code> flags to coturn fixed it immediately.</p>
<h2 id="registration-and-invites">Registration and Invites<a class="anchor" href="#registration-and-invites" aria-label="Link to “Registration and Invites”">#</a></h2>
<p>I started with invite-only registration using tokens. Synapse has a built-in token system where you generate a token, give it to someone, and they use it during registration. Each token can be limited to a single use.</p>
<p>Then I realized I was the only one creating accounts, so I just opened registration entirely. Synapse will complain about open registration without verification, but you can silence that with a config flag. For a small server with friends, the spam risk is minimal.</p>
<p>If spam does become a problem, switching back to token-based registration is a one-line config change. No need to over-engineer it upfront.</p>
<h2 id="password-policy">Password Policy<a class="anchor" href="#password-policy" aria-label="Link to “Password Policy”">#</a></h2>
<p>Synapse has a built-in password strength checker that rejects most passwords. It is aggressive. Passwords that I considered perfectly fine were being rejected. The policy can be disabled in the config, which I did immediately.</p>
<p>Element Web also has its own client-side password strength check that is separate from the server policy. Even after disabling the server policy, Element still complained. That one I could not disable through config. It is more of an annoyance than a blocker since it warns but does not prevent you from proceeding.</p>
<h2 id="resource-usage">Resource Usage<a class="anchor" href="#resource-usage" aria-label="Link to “Resource Usage”">#</a></h2>
<p>Synapse is the heaviest thing on my server. At idle with a handful of users, it uses about 300 megabytes. During federation sync or when someone joins a large room, it can spike to a gigabyte.</p>
<p>The PostgreSQL database backing it uses another 70 megabytes. Element Web is negligible since it is just static files served by nginx in a container.</p>
<p>The config has a <code>caches.global_factor</code> setting that controls memory usage. The default is 0.5. Turning it down to 0.3 reduces memory at the cost of more database queries. For a small server the difference is not noticeable.</p>
<h2 id="was-it-worth-the-effort">Was It Worth the Effort<a class="anchor" href="#was-it-worth-the-effort" aria-label="Link to “Was It Worth the Effort”">#</a></h2>
<p>Absolutely. My friends and I have a chat platform that we fully control. Voice calls work, screen sharing works, file sharing works. The web client means nobody has to install anything. And because it federates, anyone on any Matrix server can reach us if they know our addresses.</p>
<p>The setup took about two hours. Most of that was the TURN server configuration and testing calls. If you skip voice and video, you could have it running in thirty minutes.</p>
]]></content:encoded></item><item><title>Split Tunnel WireGuard: Private Services Without Routing Everything</title><link>https://articles.wleeaf.dev/posts/wireguard-split-tunnel/</link><pubDate>Wed, 25 Mar 2026 00:00:00 +0000</pubDate><guid>https://articles.wleeaf.dev/posts/wireguard-split-tunnel/</guid><description>How to set up WireGuard so only traffic to your private services goes through the tunnel, while everything else uses your normal connection.</description><category>wireguard</category><category>vpn</category><category>networking</category><content:encoded><![CDATA[<p>Most WireGuard tutorials show you how to route all traffic through the tunnel. That is great for privacy but terrible for latency and bandwidth. If you just want to access a few private services on your server, there is a much better approach: put only your VPN subnet in the client&rsquo;s <code>AllowedIPs</code>, so traffic to those services goes through the tunnel and everything else takes your normal route.</p>
<h2 id="the-problem">The Problem<a class="anchor" href="#the-problem" aria-label="Link to “The Problem”">#</a></h2>
<p>I run several services on my VPS that should not be publicly accessible: a git server, an admin panel, a VPN management UI, and a mail server admin interface. These need to be reachable from my laptop and phone, but nobody else should be able to see them.</p>
<p>The obvious solution is a VPN. But I do not want all my traffic going through a server in Germany when I am watching a video or browsing the web. I want my normal internet connection for everything except the few private services.</p>
<h2 id="split-tunnel-configuration">Split Tunnel Configuration<a class="anchor" href="#split-tunnel-configuration" aria-label="Link to “Split Tunnel Configuration”">#</a></h2>
<p>The key is the <code>AllowedIPs</code> setting in the WireGuard client config. Instead of <code>0.0.0.0/0</code> which routes everything, you specify only the VPN subnet:</p>
<div class="code is-plain">
<div class="code-head"><button type="button" class="code-copy" data-copy-code hidden><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2.5"/><path d="M16 8V6.5A2.5 2.5 0 0 0 13.5 4h-7A2.5 2.5 0 0 0 4 6.5v7A2.5 2.5 0 0 0 6.5 16H8"/></svg><span>Copy</span></button></div>
<pre tabindex="0"><code>[Interface]
PrivateKey = &lt;client-private-key&gt;
Address = 10.0.0.2/24
DNS = 10.0.0.1

[Peer]
PublicKey = &lt;server-public-key&gt;
Endpoint = your-server-ip:51820
AllowedIPs = 10.0.0.0/24</code></pre>
</div>
<p>With this config, only traffic destined for <code>10.0.0.x</code> addresses goes through the tunnel. Everything else takes the normal route. Your browsing speed stays the same, your public IP does not change, and you still get access to your private services.</p>
<h2 id="dns-is-where-the-magic-happens">DNS Is Where the Magic Happens<a class="anchor" href="#dns-is-where-the-magic-happens" aria-label="Link to “DNS Is Where the Magic Happens”">#</a></h2>
<p>The split tunnel routes packets, but your browser needs to know that <code>admin.example.com</code> should resolve to a VPN IP instead of a public one. This is where a local DNS server comes in.</p>
<p>I run dnsmasq on the VPN server, listening only on the WireGuard interface. It has entries like:</p>
<div class="code is-plain">
<div class="code-head"><button type="button" class="code-copy" data-copy-code hidden><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2.5"/><path d="M16 8V6.5A2.5 2.5 0 0 0 13.5 4h-7A2.5 2.5 0 0 0 4 6.5v7A2.5 2.5 0 0 0 6.5 16H8"/></svg><span>Copy</span></button></div>
<pre tabindex="0"><code>address=/git.example.com/10.0.0.1
address=/admin.example.com/10.0.0.1
address=/vpn.example.com/10.0.0.1</code></pre>
</div>
<p>For any domain not in this list, dnsmasq forwards the query to Cloudflare or Google DNS. So private subdomains resolve to the VPN IP, and everything else resolves normally.</p>
<p>The client config sets <code>DNS = 10.0.0.1</code> which tells the system to use the VPN DNS server. Combined with the split tunnel routing, DNS queries for private domains go through the tunnel to dnsmasq, and all other DNS queries go through the tunnel too but get forwarded to public DNS by dnsmasq.</p>
<h2 id="the-dns-over-tls-conflict">The DNS-over-TLS Conflict<a class="anchor" href="#the-dns-over-tls-conflict" aria-label="Link to “The DNS-over-TLS Conflict”">#</a></h2>
<p>This one took me a while to figure out. I use a DPI bypass tool on my local machine that forces all DNS through DNS-over-TLS via systemd-resolved. When the VPN comes up, the DNS setting should override this, but it does not always work cleanly.</p>
<p>The fix was a PostUp script in the WireGuard config:</p>
<div class="code is-plain">
<div class="code-head"><button type="button" class="code-copy" data-copy-code hidden><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2.5"/><path d="M16 8V6.5A2.5 2.5 0 0 0 13.5 4h-7A2.5 2.5 0 0 0 4 6.5v7A2.5 2.5 0 0 0 6.5 16H8"/></svg><span>Copy</span></button></div>
<pre tabindex="0"><code>PostUp = resolvectl dnsovertls %i no</code></pre>
</div>
<p>This disables DNS-over-TLS specifically on the VPN interface so queries to 10.0.0.1 go through as plain DNS over the tunnel. Without this, the system tries to establish a TLS connection to the dnsmasq server, which obviously does not support TLS, and private domains fail to resolve.</p>
<h2 id="no-public-dns-leaks">No Public DNS Leaks<a class="anchor" href="#no-public-dns-leaks" aria-label="Link to “No Public DNS Leaks”">#</a></h2>
<p>One nice side effect of this setup: your private subdomains are completely invisible. There are no public DNS records for them. They do not show up in certificate transparency logs because the wildcard cert covers them. Someone scanning your domain will find the public subdomains but have no idea the private ones exist.</p>
<p>The only way to discover them is to be on the VPN, at which point dnsmasq resolves them. And even if someone knows the subdomain name and tries to access it from the public internet, Caddy returns 403 based on the source IP check.</p>
<h2 id="mobile-access">Mobile Access<a class="anchor" href="#mobile-access" aria-label="Link to “Mobile Access”">#</a></h2>
<p>WireGuard works great on phones. The official app for both iOS and Android supports split tunneling natively. I have the same config on my phone: only VPN traffic goes through the tunnel. This means I can check my git server from my phone without draining battery or slowing down everything else.</p>
<p>The one downside is that you have to manually toggle the VPN on when you need it. There is no automatic connection based on network or location. But honestly, it takes one tap and the connection establishes in under a second.</p>
<h2 id="resource-usage">Resource Usage<a class="anchor" href="#resource-usage" aria-label="Link to “Resource Usage”">#</a></h2>
<p>WireGuard is absurdly lightweight. The server side runs as a single container using about 30MB of RAM. The CPU usage is essentially zero unless you are pushing serious bandwidth. On the client side, the kernel module handles everything so there is no userspace overhead.</p>
<p>Compare this to OpenVPN which needs a full userspace daemon, TLS handshakes, and significantly more CPU per connection. For a personal VPN with one or two clients, WireGuard is comically overpowered.</p>
<h2 id="final-thoughts">Final Thoughts<a class="anchor" href="#final-thoughts" aria-label="Link to “Final Thoughts”">#</a></h2>
<p>Split tunnel WireGuard gives you the best of both worlds: fast, private access to your services without compromising your normal internet experience. The setup takes maybe 30 minutes and the result is rock solid. I have had zero downtime in months.</p>
<p>The only complexity is the DNS piece, and even that is just a few lines in a dnsmasq config file. If you are running private services on a VPS, this is the way to expose them.</p>
]]></content:encoded></item><item><title>Bash Traps and the Art of Not Leaving a Mess</title><link>https://articles.wleeaf.dev/posts/bash-traps-error-handling/</link><pubDate>Wed, 25 Mar 2026 00:00:00 +0000</pubDate><guid>https://articles.wleeaf.dev/posts/bash-traps-error-handling/</guid><description>How trap, set -e, and a cleanup function can save you from half-finished deployments.</description><category>bash</category><category>linux</category><category>scripting</category><content:encoded><![CDATA[<p>I once ran a deploy script that got killed halfway through an rsync. The remote server had half the files from the new version and half from the old one. The site was broken for twenty minutes while I figured out what happened.</p>
<p>That is when I started using traps. <code>trap cleanup EXIT</code> runs a cleanup function however the script ends: a normal exit, an error or a Ctrl-C.</p>
<h2 id="the-problem">The Problem<a class="anchor" href="#the-problem" aria-label="Link to “The Problem”">#</a></h2>
<p>Bash scripts are fragile by default. If a command fails, the script keeps going. If you hit Ctrl-C, whatever state you left things in is where they stay. If your SSH connection drops mid-script, same thing.</p>
<p>Most people know about <code>set -e</code> which exits on the first error. But that only covers one failure mode. What about cleanup?</p>
<h2 id="enter-trap">Enter trap<a class="anchor" href="#enter-trap" aria-label="Link to “Enter trap”">#</a></h2>
<p>The <code>trap</code> builtin lets you run a command when the script receives a signal or exits:</p>
<div class="code">
<div class="code-head"><span class="code-lang">bash</span><button type="button" class="code-copy" data-copy-code hidden><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2.5"/><path d="M16 8V6.5A2.5 2.5 0 0 0 13.5 4h-7A2.5 2.5 0 0 0 4 6.5v7A2.5 2.5 0 0 0 6.5 16H8"/></svg><span>Copy</span></button></div>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="cp">#!/bin/bash
</span></span></span><span class="line"><span class="cl"><span class="cp"></span><span class="nb">set</span> -euo pipefail
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nv">TMPDIR</span><span class="o">=</span><span class="k">$(</span>mktemp -d<span class="k">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">cleanup<span class="o">()</span> <span class="o">{</span>
</span></span><span class="line"><span class="cl">  <span class="nb">echo</span> <span class="s2">&#34;Cleaning up </span><span class="nv">$TMPDIR</span><span class="s2">...&#34;</span>
</span></span><span class="line"><span class="cl">  rm -rf <span class="s2">&#34;</span><span class="nv">$TMPDIR</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl"><span class="o">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nb">trap</span> cleanup EXIT
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Your actual work here</span>
</span></span><span class="line"><span class="cl">cp important-files <span class="s2">&#34;</span><span class="nv">$TMPDIR</span><span class="s2">/&#34;</span>
</span></span><span class="line"><span class="cl">process <span class="s2">&#34;</span><span class="nv">$TMPDIR</span><span class="s2">/important-files&#34;</span>
</span></span><span class="line"><span class="cl">mv <span class="s2">&#34;</span><span class="nv">$TMPDIR</span><span class="s2">/important-files&#34;</span> /final/destination/</span></span></code></pre></div>
</div>
<p>The <code>EXIT</code> trap fires no matter how the script ends. Normal exit, error, Ctrl-C, even <code>kill</code>. The temp directory always gets cleaned up.</p>
<h2 id="a-real-deploy-script">A Real Deploy Script<a class="anchor" href="#a-real-deploy-script" aria-label="Link to “A Real Deploy Script”">#</a></h2>
<p>Here is the pattern I use for deployments:</p>
<div class="code">
<div class="code-head"><span class="code-lang">bash</span><button type="button" class="code-copy" data-copy-code hidden><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2.5"/><path d="M16 8V6.5A2.5 2.5 0 0 0 13.5 4h-7A2.5 2.5 0 0 0 4 6.5v7A2.5 2.5 0 0 0 6.5 16H8"/></svg><span>Copy</span></button></div>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="cp">#!/bin/bash
</span></span></span><span class="line"><span class="cl"><span class="cp"></span><span class="nb">set</span> -euo pipefail
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nv">SERVER</span><span class="o">=</span><span class="s2">&#34;myserver&#34;</span>
</span></span><span class="line"><span class="cl"><span class="nv">REMOTE_DIR</span><span class="o">=</span><span class="s2">&#34;/opt/app&#34;</span>
</span></span><span class="line"><span class="cl"><span class="nv">BACKUP_DIR</span><span class="o">=</span><span class="s2">&#34;/opt/app-backup-</span><span class="k">$(</span>date +%s<span class="k">)</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl"><span class="nv">ROLLED_BACK</span><span class="o">=</span><span class="nb">false</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">rollback<span class="o">()</span> <span class="o">{</span>
</span></span><span class="line"><span class="cl">  <span class="k">if</span> <span class="o">[</span> <span class="s2">&#34;</span><span class="nv">$ROLLED_BACK</span><span class="s2">&#34;</span> <span class="o">=</span> <span class="nb">true</span> <span class="o">]</span><span class="p">;</span> <span class="k">then</span> <span class="k">return</span><span class="p">;</span> <span class="k">fi</span>
</span></span><span class="line"><span class="cl">  <span class="nv">ROLLED_BACK</span><span class="o">=</span><span class="nb">true</span>
</span></span><span class="line"><span class="cl">  <span class="nb">echo</span> <span class="s2">&#34;Deploy failed. Rolling back...&#34;</span>
</span></span><span class="line"><span class="cl">  ssh <span class="s2">&#34;</span><span class="nv">$SERVER</span><span class="s2">&#34;</span> <span class="s2">&#34;rm -rf </span><span class="nv">$REMOTE_DIR</span><span class="s2"> &amp;&amp; mv </span><span class="nv">$BACKUP_DIR</span><span class="s2"> </span><span class="nv">$REMOTE_DIR</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="nb">echo</span> <span class="s2">&#34;Rollback complete.&#34;</span>
</span></span><span class="line"><span class="cl"><span class="o">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nb">trap</span> rollback ERR
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Backup current version</span>
</span></span><span class="line"><span class="cl">ssh <span class="s2">&#34;</span><span class="nv">$SERVER</span><span class="s2">&#34;</span> <span class="s2">&#34;cp -r </span><span class="nv">$REMOTE_DIR</span><span class="s2"> </span><span class="nv">$BACKUP_DIR</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Deploy</span>
</span></span><span class="line"><span class="cl">rsync -a --delete ./dist/ <span class="s2">&#34;</span><span class="nv">$SERVER</span><span class="s2">:</span><span class="nv">$REMOTE_DIR</span><span class="s2">/&#34;</span>
</span></span><span class="line"><span class="cl">ssh <span class="s2">&#34;</span><span class="nv">$SERVER</span><span class="s2">&#34;</span> <span class="s2">&#34;cd </span><span class="nv">$REMOTE_DIR</span><span class="s2"> &amp;&amp; docker compose up -d&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Success - remove backup</span>
</span></span><span class="line"><span class="cl">ssh <span class="s2">&#34;</span><span class="nv">$SERVER</span><span class="s2">&#34;</span> <span class="s2">&#34;rm -rf </span><span class="nv">$BACKUP_DIR</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl"><span class="nb">echo</span> <span class="s2">&#34;Deploy complete.&#34;</span></span></span></code></pre></div>
</div>
<p>If anything after the backup fails, the old version gets restored automatically. No manual intervention, no half-broken state.</p>
<h2 id="set--euo-pipefail">set -euo pipefail<a class="anchor" href="#set--euo-pipefail" aria-label="Link to “set -euo pipefail”">#</a></h2>
<p>This trio should be at the top of every script you write:</p>
<ul>
<li><code>set -e</code> exits on any command failure</li>
<li><code>set -u</code> treats unset variables as errors instead of empty strings</li>
<li><code>set -o pipefail</code> makes pipes fail if any command in the chain fails, not just the last one</li>
</ul>
<p>Without <code>pipefail</code>, this silently succeeds even when <code>curl</code> fails:</p>
<div class="code">
<div class="code-head"><span class="code-lang">bash</span><button type="button" class="code-copy" data-copy-code hidden><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2.5"/><path d="M16 8V6.5A2.5 2.5 0 0 0 13.5 4h-7A2.5 2.5 0 0 0 4 6.5v7A2.5 2.5 0 0 0 6.5 16H8"/></svg><span>Copy</span></button></div>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">curl https://example.com/data <span class="p">|</span> jq .result
</span></span><span class="line"><span class="cl"><span class="nb">echo</span> <span class="s2">&#34;Done&#34;</span></span></span></code></pre></div>
</div>
<p><code>jq</code> gets an empty input, outputs null, and the script continues. With <code>pipefail</code>, the whole pipe fails and <code>set -e</code> kills the script.</p>
<h2 id="trapping-multiple-signals">Trapping Multiple Signals<a class="anchor" href="#trapping-multiple-signals" aria-label="Link to “Trapping Multiple Signals”">#</a></h2>
<p>You can trap specific signals separately:</p>
<div class="code">
<div class="code-head"><span class="code-lang">bash</span><button type="button" class="code-copy" data-copy-code hidden><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2.5"/><path d="M16 8V6.5A2.5 2.5 0 0 0 13.5 4h-7A2.5 2.5 0 0 0 4 6.5v7A2.5 2.5 0 0 0 6.5 16H8"/></svg><span>Copy</span></button></div>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="nb">trap</span> <span class="s2">&#34;echo Interrupted; cleanup; exit 130&#34;</span> INT
</span></span><span class="line"><span class="cl"><span class="nb">trap</span> <span class="s2">&#34;echo Terminated; cleanup; exit 143&#34;</span> TERM
</span></span><span class="line"><span class="cl"><span class="nb">trap</span> cleanup EXIT</span></span></code></pre></div>
</div>
<p>But usually just trapping <code>EXIT</code> is enough. It covers everything.</p>
<p>The key insight is that bash does not protect you from yourself. You have to opt into safety. But once you do, scripts become surprisingly reliable.</p>
]]></content:encoded></item><item><title>Running 15 Services on One VPS With Docker Compose</title><link>https://articles.wleeaf.dev/posts/docker-compose-single-server/</link><pubDate>Sun, 22 Mar 2026 00:00:00 +0000</pubDate><guid>https://articles.wleeaf.dev/posts/docker-compose-single-server/</guid><description>How a single compose file, two networks, and some naming discipline keeps everything manageable.</description><category>docker</category><category>infrastructure</category><category>self-hosting</category><content:encoded><![CDATA[<p>At some point I looked at my server and counted fifteen containers. A reverse proxy, a git server, a CI runner, a game server, a mail server, a chat platform, a webmail client, a database, a VPN, an auth portal, an admin panel, a music bot, a TURN server, and a couple of static site builders. All on one four-core VPS with eight gigs of RAM.</p>
<p>It sounds like a mess, but it is actually pretty clean. The trick is structure: one compose file, two networks and some naming discipline.</p>
<h2 id="one-compose-file-two-networks">One Compose File, Two Networks<a class="anchor" href="#one-compose-file-two-networks" aria-label="Link to “One Compose File, Two Networks”">#</a></h2>
<p>Every service lives in a single docker-compose.yml. I tried splitting it into multiple compose files early on and it was a nightmare. Services that need to talk to each other end up on different networks, you forget which file defines what, and debugging becomes archaeology.</p>
<p>One file means one place to look. The trade-off is that the file is about 200 lines long, but that is way better than hunting through five separate files.</p>
<p>The networking is simple: two Docker bridge networks. The first one connects Caddy to the internet. The second one connects Caddy to every backend service. Services that need to be reached by Caddy join the backend network. Services that need direct port access to the internet get explicit port mappings.</p>
<p>The WireGuard container is the exception. It runs with host networking because it needs raw access to the network stack for the VPN tunnel. Everything else uses the bridge networks.</p>
<h2 id="container-naming">Container Naming<a class="anchor" href="#container-naming" aria-label="Link to “Container Naming”">#</a></h2>
<p>I name every container explicitly with <code>container_name</code>. Docker Compose generates names like <code>wleeaf-caddy-1</code> by default, which is ugly in logs and hard to type in commands. Explicit names mean I can do <code>docker logs caddy</code> instead of <code>docker logs wleeaf-caddy-1</code>.</p>
<p>This also matters for DNS resolution inside Docker networks. When you give a container a name, other containers on the same network can reach it by that name. So my Caddy config just says <code>reverse_proxy gitea:3000</code> and Docker handles the resolution.</p>
<h2 id="resource-reality">Resource Reality<a class="anchor" href="#resource-reality" aria-label="Link to “Resource Reality”">#</a></h2>
<p>People overestimate how much RAM you need. Here is what my containers actually use:</p>
<p>The reverse proxy sits at 15 megabytes. The git server uses about 110. The mail server is the hungriest at 160. The Matrix homeserver varies between 100 and 500 depending on federation activity. The database backing it uses 70. Everything else is under 50 each.</p>
<p>Total memory usage hovers around 2.5 gigabytes. That leaves five gigs free for spikes, builds, and the occasional agent session eating a gig for a few minutes.</p>
<p>CPU is basically idle. The load average rarely goes above 0.1 unless someone is building a Docker image or running a CI job. Four cores is overkill for this workload.</p>
<h2 id="secrets-management">Secrets Management<a class="anchor" href="#secrets-management" aria-label="Link to “Secrets Management”">#</a></h2>
<p>I keep secrets in a .env file next to the compose file. Docker Compose reads it automatically. Database passwords, API tokens, and hashed passwords all live there. The compose file references them with the dollar-sign-curly-brace syntax.</p>
<p>The .env file is not in any git repo. I back it up manually. Some people use Docker secrets or Vault for this, but for a single-server personal setup, a .env file is fine.</p>
<h2 id="restart-policies">Restart Policies<a class="anchor" href="#restart-policies" aria-label="Link to “Restart Policies”">#</a></h2>
<p>Every service has <code>restart: unless-stopped</code>. This means containers come back after a reboot or a crash, but stay down if I explicitly stop them. I used to use <code>always</code> but that fights you when you are debugging, because the container keeps restarting while you are trying to read the logs.</p>
<p>The one exception is the CI runner. It has <code>restart: unless-stopped</code> too, but I sometimes need to stop it to free resources during heavy builds. With <code>unless-stopped</code>, it stays down until I bring it back up.</p>
<h2 id="volume-strategy">Volume Strategy<a class="anchor" href="#volume-strategy" aria-label="Link to “Volume Strategy”">#</a></h2>
<p>I mount named directories from the host filesystem, not Docker volumes. Every service gets a directory under the main project folder. Caddy gets <code>./caddy</code>, Gitea gets <code>./gitea</code>, the mail server gets <code>./stalwart</code>, and so on.</p>
<p>This makes backups trivial. I can tar the entire project directory and I have everything: configs, data, and the compose file itself. Docker volumes are fine, but they hide the data in a location you have to look up every time.</p>
<h2 id="the-upgrade-dance">The Upgrade Dance<a class="anchor" href="#the-upgrade-dance" aria-label="Link to “The Upgrade Dance”">#</a></h2>
<p>Upgrading a service usually means changing the image tag and running <code>docker compose up -d</code>. For services using <code>latest</code>, I pull first with <code>docker compose pull</code> and then bring them up.</p>
<p>I do not use Watchtower or any automatic update tool. I want to know exactly when things change. An automatic update at 3 AM that breaks the mail server is not a problem I want to have.</p>
<p>The whole setup has been running for months with essentially zero unplanned downtime. The only time services go down is when I am actively working on them. For a personal infrastructure project, that is more than good enough.</p>
]]></content:encoded></item><item><title>What I Learned Setting Up My Own Mail Server in 2026</title><link>https://articles.wleeaf.dev/posts/self-hosting-mail-server/</link><pubDate>Sun, 15 Mar 2026 00:00:00 +0000</pubDate><guid>https://articles.wleeaf.dev/posts/self-hosting-mail-server/</guid><description>Running your own SMTP/IMAP server is supposedly a nightmare. Here is what actually happened when I tried it.</description><category>self-hosting</category><category>email</category><category>infrastructure</category><content:encoded><![CDATA[<p>Everyone tells you not to run your own mail server. I did it anyway.</p>
<p>The usual advice goes something like this: spam filters will eat your messages, IP reputation is impossible to build, and you will spend your weekends debugging DKIM signatures instead of doing anything useful. After a couple weeks of running Stalwart on a cheap Hetzner VPS, I can say that some of those warnings are legitimate but most of them are outdated.</p>
<h2 id="the-setup">The Setup<a class="anchor" href="#the-setup" aria-label="Link to “The Setup”">#</a></h2>
<p>I went with Stalwart, which is a relatively new mail server written in Rust. It handles SMTP, IMAP, and JMAP out of the box, and the admin UI is surprisingly decent for a self-hosted project. The whole thing runs in a single Docker container with a RocksDB backend, which means no separate database to manage.</p>
<p>The stack looks like this:</p>
<ul>
<li>Stalwart for mail handling on ports 25, 465, 587, and 993</li>
<li>Caddy as the reverse proxy with a wildcard TLS cert</li>
<li>SnappyMail for webmail access</li>
<li>Cloudflare for DNS on the free plan</li>
</ul>
<p>The VPS itself is nothing special. 4 cores, 8 gigs of RAM, running Ubuntu 24.04. Stalwart barely touches the resources and sits at around 150MB of RAM even after a few thousand messages.</p>
<h2 id="dns-was-the-easy-part">DNS Was the Easy Part<a class="anchor" href="#dns-was-the-easy-part" aria-label="Link to “DNS Was the Easy Part”">#</a></h2>
<p>Setting up DNS records is the part that scares most people, but it is genuinely straightforward if you take it step by step. You need an A record for your mail subdomain, an MX record pointing to it, SPF to declare which IPs can send for your domain, DKIM for cryptographic signature verification, and DMARC to set a policy for failed checks.</p>
<p>The Cloudflare API makes all of this scriptable. I wrote a shell script that creates all five records in about 30 seconds. The DKIM key was the only tricky part because Stalwart generates it internally and you have to copy it out of the admin panel and paste it into DNS.</p>
<h2 id="the-reputation-problem">The Reputation Problem<a class="anchor" href="#the-reputation-problem" aria-label="Link to “The Reputation Problem”">#</a></h2>
<p>This is the real challenge. When you spin up a brand new IP and start sending mail, every major provider treats you with suspicion. Gmail sent my first few messages straight to spam even though SPF, DKIM, and DMARC all passed perfectly.</p>
<p>The fix was not technical, it was behavioral. I marked those messages as not spam in my Gmail account, added the addresses to my contacts, and started having normal conversations. Within a few days, Gmail stopped flagging my messages. Google Postmaster Tools confirmed that the domain reputation was climbing.</p>
<p>One thing that helped: my VPS provider has decent IP reputation to begin with. If you are on a provider known for abuse, you will have a much harder time. Also make sure your reverse DNS is set correctly because Gmail checks that the PTR record for your IP matches your mail hostname.</p>
<h2 id="tls-certificate-sharing">TLS Certificate Sharing<a class="anchor" href="#tls-certificate-sharing" aria-label="Link to “TLS Certificate Sharing”">#</a></h2>
<p>I did not want to manage separate certificates for the mail server. Caddy already handles TLS for all my subdomains using a wildcard cert from Lets Encrypt via DNS challenge. I just mounted the certificate directory read-only into the Stalwart container.</p>
<p>The only catch is that Caddy stores the cert in a specific directory structure and you need to figure out the exact path. Once you find that, a volume mount in Docker Compose is all you need.</p>
<h2 id="was-it-worth-it">Was It Worth It<a class="anchor" href="#was-it-worth-it" aria-label="Link to “Was It Worth It”">#</a></h2>
<p>Honestly, yes. The total ongoing cost is zero beyond the VPS I was already paying for. I have full control over my email, the data never touches a third party, and the whole thing took an afternoon to set up.</p>
<p>The one thing I would do differently: I would have registered my domain and set up the mail server on the same day. Domain age matters for reputation, and the sooner you start sending from a domain, the sooner it builds trust with the big providers.</p>
<p>If you are already running a VPS for other things, adding a mail server is a no-brainer. If you are starting from scratch just for email, you might want to think about whether the learning experience is worth the first week of dealing with spam folders.</p>
]]></content:encoded></item></channel></rss>