<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Prateek Rai</title>
    <description>The latest articles on DEV Community by Prateek Rai (@prateek11rai).</description>
    <link>https://dev.to/prateek11rai</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F756348%2Fcb097e96-a9d6-4ffc-8245-45626effeead.png</url>
      <title>DEV Community: Prateek Rai</title>
      <link>https://dev.to/prateek11rai</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/prateek11rai"/>
    <language>en</language>
    <item>
      <title>Building a Vitals Monitor for Claude Code</title>
      <dc:creator>Prateek Rai</dc:creator>
      <pubDate>Sat, 18 Jul 2026 23:23:02 +0000</pubDate>
      <link>https://dev.to/prateek11rai/building-a-vitals-monitor-for-claude-code-3ndh</link>
      <guid>https://dev.to/prateek11rai/building-a-vitals-monitor-for-claude-code-3ndh</guid>
      <description>&lt;p&gt;A Slack thread about our climbing AI bill, plus word that per-user usage limits were landing soon, sent me to check what I was actually spending on Claude Code. The answer was that I had no idea.&lt;/p&gt;

&lt;p&gt;The cost only showed up after the fact, in a dashboard I never opened. So I built a gauge instead. My terminal footer now reads out the session's vitals on every render, and the whole thing is one shell script.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fg5on6au5o922n4xnzh9o.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fg5on6au5o922n4xnzh9o.jpg" alt="Luffy raises his fist in the snow outside Drum Castle, inviting Tony Tony Chopper (the little reindeer doctor, at bottom right) to join the Straw Hat crew" width="800" height="600"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;In a hurry? Skip to what the line shows.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the status line gets to see
&lt;/h2&gt;

&lt;p&gt;Claude Code pipes a JSON blob to whatever you register as its &lt;code&gt;statusLine&lt;/code&gt; command, once on every render. My script reads that blob with a single &lt;code&gt;jq&lt;/code&gt; pass and prints one line back. Claude Code renders each stdout line as its own status row, so I get to decide exactly what sits under the prompt.&lt;/p&gt;

&lt;p&gt;Registering it is two lines in &lt;code&gt;~/.claude/settings.json&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"statusLine"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"bash ~/.claude/statusline-command.sh"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here's the field extraction, trimmed to what matters:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# One jq pass over the JSON Claude Code pipes in on every render.&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$input&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'[
  (.model.display_name                       // ""),
  (.context_window.used_percentage           // "" | tostring),
  (.context_window.total_input_tokens        // "" | tostring),
  (.context_window.context_window_size       // "" | tostring),
  (.cost.total_cost_usd                      // "" | tostring),
  (.rate_limits.five_hour.used_percentage    // "" | tostring),
  (.rate_limits.seven_day.used_percentage    // "" | tostring),
  (.exceeds_200k_tokens                      // false | tostring),
  (.effort.level                             // "" | tostring)
] | join("\u001f")'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;\u001f&lt;/code&gt; is a unit separator, not a tab. Tab is IFS whitespace, so &lt;code&gt;read&lt;/code&gt; would collapse the empty fields and shift every value one slot to the left. The rate-limit and usage fields are absent early in a session, so empty fields are the normal case, not the edge case. Learned that one the hard way.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I watch, and why each segment earns its place
&lt;/h2&gt;

&lt;p&gt;Every segment has to justify the pixels. Here is the full line and what each part answers.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Segment&lt;/th&gt;
&lt;th&gt;Reads from&lt;/th&gt;
&lt;th&gt;Why it's on screen&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Opus 4.8 (1M context)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;model.display_name&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Which model and window I'm on, without guessing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;xhigh&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;effort.level&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;High effort burns output tokens fast; seeing it nudges me to dial down for cheap tasks&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;▓▓░░░ 34% 343k/1M&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;context_window.*&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;How full the window is. Near the top means auto-compaction is close, and per-turn cost is climbing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;⚠&amp;gt;200K&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;exceeds_200k_tokens&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;This request's input crossed 200K tokens (see below)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;⚡99%&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;context_window.current_usage.*&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Share of input served from cheap cache reads instead of full-price fresh input&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;$18.20&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;cost.total_cost_usd&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Dollars this session&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;5h:25%↻2h33m 7d:10%↻3d13h&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;rate_limits.*&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Rolling rate-limit usage and time until each window resets&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The meter is a five-slot bar that goes green under 50%, yellow through 79%, red at 80% and up. The cache indicator is computed, not read: &lt;code&gt;cache_read / (input + cache_creation + cache_read)&lt;/code&gt;, colored the same way. A low number there means I'm paying full input price every turn, which is real money on a long session.&lt;/p&gt;

&lt;p&gt;In a narrow tmux split the line wraps to two rows and starts shedding segments in a fixed order: effort, then limits, then cache, then the token fraction, then cost. The &lt;code&gt;⚠&amp;gt;200K&lt;/code&gt; warning is never dropped. It's the one thing I never want to lose sight of, for reasons in the next section.&lt;/p&gt;

&lt;h2&gt;
  
  
  The two costs that surprise people
&lt;/h2&gt;

&lt;p&gt;There are two different cost mechanisms in play, and the line treats them as separate things because they are.&lt;/p&gt;

&lt;p&gt;The first is the long-context tier. The &lt;code&gt;⚠&amp;gt;200K&lt;/code&gt; flag comes straight from the &lt;code&gt;exceeds_200k_tokens&lt;/code&gt; boolean, and it lights up when a single request's input crosses 200K tokens. On the models that carry a long-context pricing tier, that's the point where input past 200K starts billing at a higher per-token rate. The honest caveat: at the time of writing, the current Opus (Opus 4.8, 1M context) bills the whole window at standard rates with no long-context premium. So treat the flag as "you're in long-context territory, go check what your model charges here," not a guaranteed price jump. &lt;a href="https://www.anthropic.com/pricing" rel="noopener noreferrer"&gt;Anthropic's pricing&lt;/a&gt; is the source of truth, and these tiers move.&lt;/p&gt;

&lt;p&gt;The second is plan-level "extra usage," and it's what the 5h/7d segments are really about. Those track rolling usage against the plan's rate limits. On Team plans with spillover enabled, usage past the included allotment bills at API rates, roughly 4–5× the effective included rate at the time of writing. The status line can't show a dollar figure for it, because there is no field for it in the JSON. The percentages and reset countdowns are the proxy: &lt;code&gt;5h:25%↻2h33m&lt;/code&gt; means I'm a quarter into the five-hour window with 2h33m until it resets.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Hot Take&lt;/strong&gt;&lt;br&gt;
Cost is the number everyone watches and it's the wrong one to watch alone. Crossing the 200K threshold and burning down a rate-limit window are leading indicators; the dollar figure is the lagging one that arrives after the decision that caused it. The reset countdown turned rate limits from an invisible ceiling into something I can pace against.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Reading the line at a glance
&lt;/h2&gt;

&lt;p&gt;Here it is in the wild: a minimal WezTerm window, tmux doing the multiplexing, and the footer carrying the whole story.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1zl25ute070o7ux3b0h7.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1zl25ute070o7ux3b0h7.png" alt="WezTerm running a tmux session with the custom Claude Code status line across the bottom, showing model, context meter, the 200K flag, cache-hit rate, session cost, and rate-limit resets" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Bottom row, left to right: identity and branch, then &lt;code&gt;Opus 4.8 (1M context)&lt;/code&gt;, &lt;code&gt;xhigh&lt;/code&gt;, the context meter at &lt;code&gt;34% 343k/1M&lt;/code&gt;, &lt;code&gt;⚠&amp;gt;200K&lt;/code&gt; lit (343k is well past the threshold), &lt;code&gt;⚡99%&lt;/code&gt; cache hits, &lt;code&gt;$18.20&lt;/code&gt; spent, and the two reset clocks. One glance and I know where the session stands before I fire off the next expensive turn.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I kept my own script
&lt;/h2&gt;

&lt;p&gt;Before committing to this, I compared it against &lt;code&gt;ccstatusline&lt;/code&gt;, the popular off-the-shelf option. The deciding factor was where the data lives.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;statusLine&lt;/code&gt; JSON hands you almost everything worth showing for free: model, effort, context, cost, cache, rate limits. The one thing it has no field for is an extra-usage or overage dollar figure — that lives behind a separate Usage API, which is exactly what &lt;code&gt;ccstatusline&lt;/code&gt; calls to fill the gap. I didn't want the dollar figure badly enough to take on a second data source and its auth, so I kept the script and just added the fields it was missing.&lt;/p&gt;

&lt;h2&gt;
  
  
  rtk, the token saver that briefly lied to me
&lt;/h2&gt;

&lt;p&gt;Separate from the status line, but part of the same "spend the context window on thinking, not scrollback" goal, I run &lt;code&gt;rtk&lt;/code&gt; (Rust Token Killer). It's an output-compression proxy wired in as a Claude Code PreToolUse Bash hook (&lt;code&gt;rtk hook claude&lt;/code&gt;). It squeezes the command output that flows back into the model's context — long &lt;code&gt;git log&lt;/code&gt;, directory listings, build dumps — so scrollback doesn't eat the window.&lt;/p&gt;

&lt;p&gt;It works. It also bit me this session. &lt;code&gt;rtk&lt;/code&gt; was aggressive enough to collapse my own diagnostic output: a &lt;code&gt;wc -l&lt;/code&gt; came back &lt;code&gt;0&lt;/code&gt;, an &lt;code&gt;ls -la&lt;/code&gt; came back &lt;code&gt;(empty)&lt;/code&gt;, and for a minute I was convinced real commands were failing. They weren't. The hook had compressed the output to nothing before I ever saw it. The fix was to redirect to a file and read the file directly, bypassing the hook entirely.&lt;/p&gt;

&lt;p&gt;You can catch it happening in the screenshot above, where a &lt;code&gt;cd dotfiles&lt;/code&gt; reads &lt;code&gt;Bash completed with no output&lt;/code&gt;. A token-saver that hides real output is a trade-off, and I'd rather say that plainly than pretend it's free.&lt;/p&gt;

&lt;h2&gt;
  
  
  How the pieces fit
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2Fpako%3AeNpFkMtKxEAQRX-lqJVCooPLILOJYUCjglEQjIsyXXlIP2I_xGFm_t2aCWhvGm7fc4rqHXZOMRY4eJpHqJ9aC3LK8q3FUlNSDKUUrj_85fqMv9lvwbNV7M9bfIc8X-9DpJhCPVmG2-bxYQ9NLeySaknzzhlDVl2EUZBF39Qn1Anz-QUzhSBYtTlyPBi2MRRgZKzOwHBkn53mX61Wd9BrGjLoqBtZLhdiBnoyUwz_8mpztMPz_curGKNJP9A7JxqpYIaGvaFJyc47eR3ZcIsFtGg5RU-6xYOU0qwocqWm6DwWPenAGVKKrtna7i9YWjcTye8ZLKJPfPgFKtRwJA" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2Fpako%3AeNpFkMtKxEAQRX-lqJVCooPLILOJYUCjglEQjIsyXXlIP2I_xGFm_t2aCWhvGm7fc4rqHXZOMRY4eJpHqJ9aC3LK8q3FUlNSDKUUrj_85fqMv9lvwbNV7M9bfIc8X-9DpJhCPVmG2-bxYQ9NLeySaknzzhlDVl2EUZBF39Qn1Anz-QUzhSBYtTlyPBi2MRRgZKzOwHBkn53mX61Wd9BrGjLoqBtZLhdiBnoyUwz_8mpztMPz_curGKNJP9A7JxqpYIaGvaFJyc47eR3ZcIsFtGg5RU-6xYOU0qwocqWm6DwWPenAGVKKrtna7i9YWjcTye8ZLKJPfPgFKtRwJA" alt="Architecture diagram" width="1153" height="118"&gt;&lt;/a&gt;&lt;br&gt;
Claude Code renders, pipes the JSON, the script parses it once and prints, tmux shows it. No daemon, no polling, no state. It recomputes from scratch on every render, so what you see is always the current session, with no cached numbers to go stale.&lt;/p&gt;

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

&lt;p&gt;The line changed how I work more than I expected. I match effort to the task now instead of leaving &lt;code&gt;xhigh&lt;/code&gt; on for a one-line edit. I keep the prompt prefix stable so the cache-hit rate stays high. I pace long runs against the reset clock instead of running into a wall I couldn't see. None of that is new information — it was all available before, in a dashboard, after the fact. Putting it under the cursor is what made me act on it.&lt;/p&gt;

&lt;p&gt;The script and its install live in my &lt;a href="https://github.com/prateek11rai/dotfiles" rel="noopener noreferrer"&gt;dotfiles&lt;/a&gt;, symlinked and set up by the bootstrap only when the &lt;code&gt;claude&lt;/code&gt; CLI is present.&lt;/p&gt;




&lt;p&gt;Chopper is the Straw Hats' doctor. He doesn't wait for a crewmate to drop mid-fight; he watches their vitals and calls it early. That's what this footer is — a vitals monitor for the session, taking its pulse on every render. And when the window gets tight there's a bit of Sanji in it too: rationing a limited pantry so nothing goes to waste, which is the entire job of &lt;code&gt;rtk&lt;/code&gt; and, fittingly, the name over the door of this blog.&lt;/p&gt;

&lt;p&gt;Chopper only became that doctor because Dr. Hiriluk, the quack everyone laughed at, believed in him first.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F63h3nowpoct2cc4whros.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F63h3nowpoct2cc4whros.jpg" alt="Dr. Hiriluk, tears streaming down his face, hugging a young Chopper on snowy Drum Island — the doctor who first believed in him" width="704" height="528"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;Intro and closing images are from the One Piece anime via the &lt;a href="https://onepiece.fandom.com/" rel="noopener noreferrer"&gt;One Piece Wiki&lt;/a&gt;: the &lt;a href="https://onepiece.fandom.com/wiki/File:Luffy_Tells_Chopper_to_Join.png" rel="noopener noreferrer"&gt;intro scene of Luffy inviting Chopper to join&lt;/a&gt; and the closing scene of &lt;a href="https://onepiece.fandom.com/wiki/File:Hiriluk_Hugs_Chopper.png" rel="noopener noreferrer"&gt;Dr. Hiriluk and Chopper&lt;/a&gt;. Attribution and credits — please &lt;a href="mailto:prateek11rai@protonmail.com"&gt;contact&lt;/a&gt; if anything needs updating.&lt;/p&gt;

</description>
      <category>claudecode</category>
      <category>terminal</category>
      <category>tokens</category>
      <category>monitoring</category>
    </item>
    <item>
      <title>How Python's rglob Silently Loses Files, and Why macOS Makes It Worse</title>
      <dc:creator>Prateek Rai</dc:creator>
      <pubDate>Sat, 04 Jul 2026 12:31:26 +0000</pubDate>
      <link>https://dev.to/prateek11rai/how-pythons-rglob-silently-loses-files-and-why-macos-makes-it-worse-58ap</link>
      <guid>https://dev.to/prateek11rai/how-pythons-rglob-silently-loses-files-and-why-macos-makes-it-worse-58ap</guid>
      <description>&lt;p&gt;A Python service I work on did something that shouldn't be possible. It wrote a directory full of files, turned around to upload them, and found nothing. The files were right there — &lt;code&gt;ls&lt;/code&gt; saw them from another terminal.&lt;/p&gt;

&lt;p&gt;The upload logged success. Zero files. No exception. Digging into why took me through three separate layers of the stack — none of which would tell me what actually happened — and a paper trail going back to 2015 that almost nobody has read.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzrq4cppu4kjtps4m7c95.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzrq4cppu4kjtps4m7c95.jpg" alt="Nico Robin standing before the massive red Road Poneglyph inside the Whale Tree of Zou, dwarfed by a wall of ancient glyphs" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The record of every one of these problems has been sitting in public bug trackers and mailing lists for years — some of it for two decades. This post is the story of the bug, the archaeology of who reported what and when, and the fix we shipped while the deeper layers stay unfixed.&lt;/p&gt;

&lt;p&gt;If this corner of the stack is new to you, here's the short version of each layer, in the order the post walks them:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Python's &lt;code&gt;rglob&lt;/code&gt; hides errors.&lt;/strong&gt; It walks a folder tree and returns every file it finds — and if the operating system refuses or fails anywhere along the walk, it silently skips instead of complaining. A folder it couldn't read looks exactly like a folder with nothing in it, and there is no way to find out which one you got.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The operating system never promised your listing was fresh.&lt;/strong&gt; A listing that races concurrent writes is allowed to miss the newest files — on every system — and on macOS the filesystem adds behaviour Apple has never documented in either direction.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The obvious repair tool doesn't do what its name says.&lt;/strong&gt; &lt;code&gt;fsync&lt;/code&gt;, the call that's supposed to force pending writes all the way down to disk, only goes halfway on macOS — and it was never designed to freshen directory listings in the first place.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Each section below expands one layer, with receipts. When the jargon gets thick, look for the collapsible "in plain English" boxes — they're there so you can keep up without leaving the page. And if you're here because you have this bug &lt;em&gt;right now&lt;/em&gt;, jump straight to the fix.&lt;/p&gt;

&lt;h2&gt;
  
  
  A Program That Succeeds at Doing Nothing
&lt;/h2&gt;

&lt;p&gt;The service writes a batch of parquet files (the columnar data files pipelines pass around) into a directory, then hands that directory to an uploader. The uploader does what every Python tutorial would tell you to do:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;discovered&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;src&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;rglob&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;*&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;is_file&lt;/span&gt;&lt;span class="p"&gt;()]&lt;/span&gt;
&lt;span class="n"&gt;results&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;upload&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;discovered&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="n"&gt;reason&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;uploaded&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;discovered&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;skipped:nothing_to_upload&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;pathlib&lt;/code&gt; is the modern, blessed way to walk a directory. On a quiet machine this always works. In production — Linux containers on ARM, like almost everyone's production — it intermittently returned an empty iterator for a directory that had files in it. An &lt;code&gt;os.listdir&lt;/code&gt; in the same process, microseconds later, saw them fine.&lt;/p&gt;

&lt;p&gt;The insidious part is what happens next: nothing. The caller sees an empty list and proceeds. Logs say the upload completed. The next pipeline stage treats the empty result as a legitimate zero-row dataset. A program that crashes gets fixed the same day; a program that silently does nothing can lie to you for months.&lt;/p&gt;

&lt;p&gt;So I did the obvious thing and tried to reproduce it locally — on an Apple Silicon MacBook, because that's what dev laptops are now. It reproduced. Then it kept reproducing in ways production had never shown: wider windows, weirder timings, listings that disagreed with each other from one call to the next. The repro environment was lying in more ways than the production one.&lt;/p&gt;

&lt;p&gt;It turned out three independently-true facts were involved. The first fires on every platform and is the heart of the production bug. The second is allowed everywhere but gets dramatically worse on a Mac. The third is purely a macOS problem — and it matters because a Mac is where you'll be standing when you try to debug this. None of the three is a bug in the villain sense. All of them are documented — if you know where to dig.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer One — pathlib Swallows Errors, and Has Since 2015
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;rglob&lt;/code&gt; works by walking into a folder, asking the operating system what's inside, and repeating that for every subfolder it finds. At any step of that walk, the OS can say no: you don't have permission, the thing it was reading vanished a moment ago, a read transiently failed. In Python, those refusals arrive as an &lt;code&gt;OSError&lt;/code&gt; exception.&lt;/p&gt;

&lt;p&gt;Here's the design decision at the heart of this post: when that happens inside &lt;code&gt;glob()&lt;/code&gt; or &lt;code&gt;rglob()&lt;/code&gt;, pathlib catches the error and just keeps going. No exception, no warning, no hook to find out it happened. Whatever it was scanning at that moment quietly drops out of the results. So the output of &lt;code&gt;rglob&lt;/code&gt; cannot distinguish "this directory is empty" from "this directory has a thousand files I wasn't allowed to see."&lt;/p&gt;

&lt;p&gt;&lt;/p&gt;
  In plain English — what 'swallowing an error' looks like
  &lt;p&gt;Deep inside pathlib's directory walk sits the moral equivalent of this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;entries&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;scandir&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;directory&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# ask the OS what's inside
&lt;/span&gt;&lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;OSError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;pass&lt;/span&gt;                              &lt;span class="c1"&gt;# couldn't read it? pretend it was empty
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the read fails, pretend it succeeded and found nothing. There's no log, no flag, no exception for the caller to catch — a failed read and a genuinely empty folder produce the &lt;em&gt;identical&lt;/em&gt; return value. That's the property this whole post hangs on: not just that errors are dropped, but that their absence is unobservable.&lt;/p&gt;



&lt;br&gt;
&lt;p&gt;&lt;/p&gt;

&lt;p&gt;The paper trail on this is remarkable, and nobody has ever written it up as a story. It starts in May 2015, when a bug tracker user named Gregorio filed &lt;a href="https://github.com/python/cpython/issues/68308" rel="noopener noreferrer"&gt;cpython#68308&lt;/a&gt; — asking, reasonably, that &lt;code&gt;rglob&lt;/code&gt; &lt;em&gt;stop crashing&lt;/em&gt; on unreadable directories and skip them the way &lt;code&gt;os.walk&lt;/code&gt; does. Core developer Serhiy Storchaka was the lone dissent, proposing a bash-&lt;code&gt;failglob&lt;/code&gt;-style option to surface errors instead. That idea was deflected to "a different issue" which was never filed.&lt;/p&gt;

&lt;p&gt;The suppression patch (by Ulrich Petri) sat unreviewed for months, until Guido van Rossum personally hit the bug in January 2016: "I'm just going to commit this." Two days later he noticed his own fix made results depend on directory scan order — "dependent on the ordering of the names returned by listdir(), which is just wrong" — re-patched it, and closed with a line that reads differently a decade later: "Should be fixed for real now."&lt;/p&gt;

&lt;p&gt;It wasn't. In 2019, Thierry Parmentelat filed &lt;a href="https://github.com/python/cpython/issues/83075" rel="noopener noreferrer"&gt;cpython#83075&lt;/a&gt; with &lt;code&gt;strace&lt;/code&gt; output (a tool that records every call a program makes to the OS) showing that a single symlink into an unreadable directory made &lt;code&gt;glob()&lt;/code&gt; silently drop &lt;em&gt;other, perfectly readable&lt;/em&gt; files. Matt Wozniski reduced it to a five-command repro, and Pablo Galindo root-caused it as a regression from a performance rewrite. Nobody had noticed — because the failure mode is silence, exactly as Guido's warning predicted.&lt;/p&gt;

&lt;p&gt;The endgame is the telling part. In 2021, &lt;a href="https://github.com/python/cpython/issues/90208" rel="noopener noreferrer"&gt;cpython#90208&lt;/a&gt; asked for an opt-in flag to surface the errors. It was resolved in 2023 by pathlib's maintainer Barney Gale going the &lt;em&gt;other&lt;/em&gt; way: suppressing &lt;strong&gt;all&lt;/strong&gt; &lt;code&gt;OSError&lt;/code&gt; below the top-level path. That shipped in Python 3.13, whose docs now state that errors are "suppressed in many cases, but not all." And in March 2026, Jakub Kuczys demonstrated that &lt;code&gt;list(Path('/root').glob('*'))&lt;/code&gt; returns &lt;code&gt;[]&lt;/code&gt; with zero indication anything went wrong — and filed &lt;a href="https://github.com/python/cpython/issues/146646" rel="noopener noreferrer"&gt;cpython#146646&lt;/a&gt; as a &lt;em&gt;docs-only&lt;/em&gt; issue, writing: "Based on #68308 (and just the fact that it has stayed unchanged for years), I assume this is intentional." Eleven years in, the behaviour's age had become the argument for keeping it.&lt;/p&gt;

&lt;p&gt;What makes this sting is that CPython knows how to do better — it just didn't do it here. &lt;code&gt;os.walk&lt;/code&gt; has taken an &lt;code&gt;onerror&lt;/code&gt; callback since forever. &lt;code&gt;Path.walk&lt;/code&gt;, added in 3.12, takes &lt;code&gt;on_error&lt;/code&gt;. POSIX &lt;code&gt;find&lt;/code&gt; prints to stderr and exits nonzero when it can't read a directory. &lt;code&gt;glob&lt;/code&gt; and &lt;code&gt;rglob&lt;/code&gt; give you nothing. "Errors should never pass silently. Unless explicitly silenced" is in the Zen of Python; there is no way to explicitly un-silence these.&lt;/p&gt;

&lt;p&gt;The most rigorous Python codebases already know. Black recurses with &lt;code&gt;iterdir&lt;/code&gt; and handles errors itself. pytest's internal &lt;code&gt;visit()&lt;/code&gt; helper walks with &lt;code&gt;os.scandir&lt;/code&gt;. mypy's file cache catches &lt;code&gt;OSError&lt;/code&gt; and &lt;em&gt;re-raises&lt;/em&gt; it. Ruff walks the tree in Rust.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Trivia&lt;/strong&gt;&lt;br&gt;
Ruff has a lint rule, &lt;a href="https://docs.astral.sh/ruff/rules/glob/" rel="noopener noreferrer"&gt;PTH207&lt;/a&gt;, that tells you to replace &lt;code&gt;glob.glob&lt;/code&gt; with &lt;code&gt;Path.glob&lt;/code&gt; because pathlib is more idiomatic. The migration it recommends moves you from an API that suppresses &lt;em&gt;most&lt;/em&gt; errors to one that, since 3.13, suppresses &lt;em&gt;all&lt;/em&gt; of them below the root. The linter whose own authors bypass pathlib for traversal nudges everyone else toward it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Hot Take&lt;/strong&gt;&lt;br&gt;
Documenting a footgun is not fixing a footgun. The 3.13 docs note and the 2026 glob-module note upgraded a decade-old surprise from "undocumented behaviour" to "intended behaviour" without adding a single way to opt out. That's the cheapest possible resolution each time, and it's now load-bearing: any future fix would be a documented-behaviour break.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Now the honest question, because you should ask it: did suppression actually &lt;em&gt;cause&lt;/em&gt; our production incident? I can't prove it — and that is precisely the indictment. If an error fired during those listings, &lt;code&gt;rglob&lt;/code&gt; ate it. If the kernel simply returned a stale view, &lt;code&gt;rglob&lt;/code&gt; passed it along. The return value is identical either way: &lt;code&gt;[]&lt;/code&gt;. The API doesn't just fail — it destroys the evidence of &lt;em&gt;how&lt;/em&gt; it failed, which means the first question of any incident review ("what actually happened?") is unanswerable by design.&lt;/p&gt;

&lt;p&gt;What I can offer is the one measurement we did get. While reproducing locally, &lt;code&gt;rglob&lt;/code&gt; would come back empty while &lt;code&gt;os.scandir&lt;/code&gt; — the lower-level directory-scanning call that pathlib itself is built on — returned the full set of files when called at essentially the same instant. Two readers of the same kernel state disagreeing is what turns pathlib from bystander into suspect. Though even here, honesty: the two calls are microseconds apart, and we could never fully separate "pathlib dropped entries" from "the view changed between the calls."&lt;/p&gt;

&lt;p&gt;Switching to &lt;code&gt;os.scandir&lt;/code&gt; closes the silence class entirely — errors propagate, so at minimum you learn &lt;em&gt;which&lt;/em&gt; failure you have. But it can't conjure files the operating system didn't report. That's the next layer down.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer Two — The Filesystem Never Promised You a Fresh Listing
&lt;/h2&gt;

&lt;p&gt;Here's the part I would have called a kernel bug, before I read the spec. Whenever any program lists a folder — &lt;code&gt;ls&lt;/code&gt;, Finder, Python's &lt;code&gt;os.listdir&lt;/code&gt;, pathlib's walk — it ultimately goes through one low-level operation: &lt;code&gt;readdir&lt;/code&gt;, the operating system's "tell me what's in this directory" call. POSIX, the standard that defines how that call must behave on Unix-like systems, says something remarkable in the &lt;a href="https://pubs.opengroup.org/onlinepubs/009695399/functions/readdir_r.html" rel="noopener noreferrer"&gt;readdir specification&lt;/a&gt;: "If a file is removed from or added to the directory after the most recent call to opendir() or rewinddir(), whether a subsequent call to readdir() returns an entry for that file is unspecified."&lt;/p&gt;

&lt;p&gt;Unspecified. A listing that races concurrent writes is allowed to miss files, &lt;em&gt;by design&lt;/em&gt;, on every POSIX system. But read the clause carefully, because it's easy to apply too broadly — I did at first: it covers files added &lt;em&gt;while the directory is being read&lt;/em&gt;. Our production pipeline was, as far as we could reconstruct, sequential — write, close, &lt;em&gt;then&lt;/em&gt; list. POSIX has no carve-out for that. A filesystem that returns a stale view after a completed write-and-close isn't exercising the spec's freedom; something else is going on.&lt;/p&gt;

&lt;p&gt;So here is the honest statement about production, on Linux: &lt;strong&gt;we never definitively identified the mechanism.&lt;/strong&gt; The candidates: the container filesystem (production runs in containers, and overlay filesystems have a documented history of readdir caching quirks); writes that weren't as complete as the code believed; or an error that fired and was eaten — and Layer One explains why that last one can never be ruled out. The listing was wrong, the API was silent about why, and the crime scene was already clean by the time anyone looked.&lt;/p&gt;

&lt;p&gt;The local repro on my Mac is where things got measurably stranger — and that's an APFS question. First, the mental model that makes any of this thinkable: creating a file is really &lt;em&gt;two&lt;/em&gt; writes — the file's bytes, and the folder's record that the name exists — and nothing guarantees they become visible at the same instant. APFS is the copy-on-write filesystem on every modern Mac, and the honest answer to what it does to directory listings under pressure is that nobody outside Apple knows.&lt;/p&gt;

&lt;p&gt;&lt;/p&gt;
  In plain English — a new file is two writes, not one
  &lt;p&gt;Creating a file changes two separate things: the file's contents (the bytes you wrote) and the folder's own records saying "a file with this name now exists here" — the &lt;em&gt;metadata&lt;/em&gt;. They are not committed at the same instant. APFS batches its metadata updates for speed using a copy-on-write structure: it builds a new version of the folder's records off to the side and atomically swaps a pointer when it commits. Until that swap happens, both of these can be true at once: the file's bytes are fully written and you can &lt;code&gt;open()&lt;/code&gt; it by name, &lt;em&gt;and&lt;/em&gt; a directory listing still reads the old records and doesn't show it. "The file exists" and "the folder doesn't list it" are not contradictions — they're two different questions asked of two different data structures.&lt;/p&gt;



&lt;p&gt;&lt;/p&gt;

&lt;p&gt;I want to be careful here, because this is the least-documented layer of the three. Apple's &lt;a href="https://developer.apple.com/support/downloads/Apple-File-System-Reference.pdf" rel="noopener noreferrer"&gt;APFS Reference&lt;/a&gt; describes the copy-on-write B-tree design across 181 pages and does not mention &lt;code&gt;readdir&lt;/code&gt; once. There is no published contract for when a freshly created file becomes visible to directory enumeration. What I saw while reproducing locally — files that &lt;code&gt;stat&lt;/code&gt; (the "does this exact path exist?" check) and &lt;code&gt;open&lt;/code&gt; could see by name while a concurrent recursive listing came back without them, under burst writes on an Apple Silicon laptop — is consistent with metadata commits lagging data writes, but I can't point you at an Apple document that says so, because there is no Apple document that says anything about it at all. The silence is the finding.&lt;/p&gt;

&lt;p&gt;What &lt;em&gt;is&lt;/em&gt; on the record is enough to stop giving APFS the benefit of the doubt — with precision about what it shows. In 2020, Giovanni Pizzi of the AiiDA project filed &lt;a href="https://bugs.python.org/issue41291" rel="noopener noreferrer"&gt;bpo-41291&lt;/a&gt;: a &lt;em&gt;delete-versus-open&lt;/em&gt; race, where a file being replaced while another process opens it yields empty reads and &lt;code&gt;st_ino == 0&lt;/code&gt; (an inode number of zero — the file's on-disk identity, reading back as "nothing"). That is &lt;strong&gt;not a listing bug&lt;/strong&gt;, and I'm not claiming it as one — but it is the closest thing on the public record to an Apple-acknowledged APFS race of any kind. CPython's macOS maintainer Ronald Oussoren reproduced it in pure C and bisected it by filesystem: fires on APFS, not on HFS+. His conclusion: "This appears to be a bug in the APFS filesystem implementation." Reported to Apple as FB8009608. No public resolution.&lt;/p&gt;

&lt;p&gt;Two years earlier, Gregory Szorc had &lt;a href="https://gregoryszorc.com/blog/2018/10/29/global-kernel-locks-in-apfs/" rel="noopener noreferrer"&gt;profiled Firefox builds on macOS&lt;/a&gt; and found &lt;code&gt;readdir&lt;/code&gt; on APFS serializing on a global kernel lock — the machine spending ~75% of its CPU time in the kernel just listing directories. A performance finding, not a correctness one — but the same neglected code path, and the same ending: a radar filed, a partial improvement shipped without comment, no acknowledgement.&lt;/p&gt;

&lt;p&gt;One precision note, because it matters for anyone debugging their own version of this: several distinct mechanisms produce the "listing lies" symptom, and they are not the same bug. A writer in another process racing your listing is POSIX-unspecified territory. Files changing while you're mid-walk is a TOCTOU problem — time-of-check to time-of-use, meaning the world changed between when you looked and when you acted. &lt;code&gt;bpo-41291&lt;/code&gt; is an apparent APFS defect. iCloud and Dropbox "dataless" files break globbing in their own special way. And a file you wrote but never flushed is a &lt;em&gt;you&lt;/em&gt; problem. The fix depends on which one you have — and this is where I have to practice what I'm preaching about evidence: production, on Linux, remains formally unsolved; the mechanism candidates above are indistinguishable after the fact. The local Mac repro showed listing inconsistency we could measure but never fully attribute. When your listing API is silent, every one of these mechanisms produces the identical observable: &lt;code&gt;[]&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;So the instinctive fix is: force the pending metadata down before listing — sync the directory. Hold that instinct, because it's about to get complicated twice over: the syncing tool doesn't mean what its name says on a Mac, and it was never a listing-freshness tool &lt;em&gt;anywhere&lt;/em&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer Three — fsync Is a Polite Request on macOS
&lt;/h2&gt;

&lt;p&gt;On Linux, &lt;code&gt;fsync(fd)&lt;/code&gt; — the &lt;code&gt;fd&lt;/code&gt; is a &lt;em&gt;file descriptor&lt;/em&gt;, the numeric handle the OS gives you for anything you've opened, folders included — means: this data is on durable storage before the call returns. On macOS, &lt;code&gt;fsync(fd)&lt;/code&gt; means: this data has been handed to the drive, which may keep it in a volatile cache and write it whenever it feels like it. Apple's own &lt;a href="https://developer.apple.com/library/archive/documentation/System/Conceptual/ManPages_iPhoneOS/man2/fsync.2.html" rel="noopener noreferrer"&gt;fsync man page&lt;/a&gt; has said so for over twenty years, and it's blunt about the consequences: "This is not a theoretical edge case."&lt;/p&gt;

&lt;p&gt;&lt;/p&gt;
  In plain English — the three stops between your program and the disk
  &lt;p&gt;When you write a file, the bytes make three stops: your operating system's memory (fast, lost if the OS crashes), the drive's own internal cache (fast, lost if power cuts out), and finally the durable flash storage (survives anything short of hardware failure). &lt;code&gt;fsync&lt;/code&gt; is the call that's supposed to mean "push my data all the way down, then tell me." On Linux it pushes through all three stops. On macOS it stops at the drive's cache — so data that &lt;code&gt;fsync&lt;/code&gt; just claimed was safe can still be eaten by a power cut. The macOS call that pushes all the way is &lt;code&gt;fcntl(fd, F_FULLFSYNC)&lt;/code&gt;, and it's dramatically slower — which is exactly why it isn't the default, and exactly why almost nothing uses it.&lt;/p&gt;



&lt;p&gt;&lt;/p&gt;

&lt;p&gt;The picture is worth having in your head — three stops, and the two systems disagree about which stop "safe" means:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2Fpako%3AeNpVkMFKAzEQhl9lmJNCi6i3RXpavVRQ3JMYD2ky7QaSTZgkLaX03R2ztmBu-ef_viE5oYmWsMMd6zTC64eaQM77l8LPWBkSR5mEpw3frQ7sCt3cKvyG5XIFa-kMJSa4b9O3AQKFyMd28zEXiBNIaljnUaDZvG5sf2EfWtuy2xMYbUb6R6d4IAZTyxXvG_5ywR9nvLLeeIKtl00tyZX3osx_BvFlUeACA3HQzsqDTwrLSIEUdqBwolpYe4VnKdVkdaFn60pk7LbaZ1qgriUOx8lcg7nVO_37QdgVrnT-AXo3bow" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2Fpako%3AeNpVkMFKAzEQhl9lmJNCi6i3RXpavVRQ3JMYD2ky7QaSTZgkLaX03R2ztmBu-ef_viE5oYmWsMMd6zTC64eaQM77l8LPWBkSR5mEpw3frQ7sCt3cKvyG5XIFa-kMJSa4b9O3AQKFyMd28zEXiBNIaljnUaDZvG5sf2EfWtuy2xMYbUb6R6d4IAZTyxXvG_5ywR9nvLLeeIKtl00tyZX3osx_BvFlUeACA3HQzsqDTwrLSIEUdqBwolpYe4VnKdVkdaFn60pk7LbaZ1qgriUOx8lcg7nVO_37QdgVrnT-AXo3bow" alt="Architecture diagram" width="881" height="118"&gt;&lt;/a&gt;&lt;br&gt;
&lt;code&gt;fsync&lt;/code&gt; on Linux pushes your data to stop 3. &lt;code&gt;fsync&lt;/code&gt; on macOS stops at 2 and reports success anyway. The real flush is a separate call, &lt;code&gt;fcntl(fd, F_FULLFSYNC)&lt;/code&gt;, which pushes to stop 3 and has existed since Mac OS X 10.3 in 2003. The canonical explanation came from Apple filesystem engineer Dominic Giampaolo on the &lt;a href="https://postgrespro.com/list/thread-id/1658794" rel="noopener noreferrer"&gt;PostgreSQL mailing list in 2005&lt;/a&gt; — he'd verified drive-cache behaviour with a logic analyzer on the ATA cable, and described the full flush as "a heavy handed operation... But in an app like a database, it is essential." The database world quietly listened: SQLite added &lt;code&gt;PRAGMA fullfsync&lt;/code&gt; in 2004, PostgreSQL grew &lt;code&gt;fsync_writethrough&lt;/code&gt;. Everyone who truly needed durability opted in twenty years ago, which is precisely why there was never enough pressure to change the default.&lt;/p&gt;

&lt;p&gt;The rest of us found out in February 2022, when Asahi Linux's Hector Martin measured an M1's SSD doing &lt;a href="https://mjtsai.com/blog/2022/02/17/apple-ssd-benchmarks-and-f_fullsync/" rel="noopener noreferrer"&gt;~46 write operations per second with real flushes versus ~40,000 without&lt;/a&gt; and called it benchmark cheating. Michael Tsai's roundup of the ensuing fight is the fairest record — including the counter-voices: most non-Apple consumer drives simply lie about flushes, and when Russ Bishop power-loss-tested four NVMe SSDs, half of them lost data they had claimed to flush. Alex Miller's &lt;a href="https://transactional.blog/blog/2022-darwins-deceptive-durability" rel="noopener noreferrer"&gt;"Darwin's Deceptive Durability"&lt;/a&gt; distilled it for database people into three deadpan headings: fsync does not fsync, O_DSYNC does not O_DSYNC, SQLite is not durable.&lt;/p&gt;

&lt;p&gt;This genre has a famous Linux chapter too — fsyncgate, 2018: PostgreSQL's Craig Ringer discovered that when Linux &lt;code&gt;fsync&lt;/code&gt; &lt;em&gt;fails&lt;/em&gt;, the kernel silently discards the data and the next call reports success. His report is dry understatement at its finest — "The write never made it to disk, but we completed the checkpoint, and merrily carried on our way. Whoops, data loss." &lt;a href="https://lwn.net/Articles/752063/" rel="noopener noreferrer"&gt;Jonathan Corbet's LWN writeup&lt;/a&gt; and &lt;a href="https://danluu.com/fsyncgate/" rel="noopener noreferrer"&gt;Dan Luu's verbatim archive&lt;/a&gt; tell it properly, and a later &lt;a href="https://research.cs.wisc.edu/adsl/Publications/cuttlefs-tos21.pdf" rel="noopener noreferrer"&gt;USENIX study&lt;/a&gt; found no major application handles fsync failure correctly even now. The takeaway for this post: durability primitives whose names promise more than they deliver are a genre, not a macOS quirk.&lt;/p&gt;

&lt;p&gt;Now the caveat this section has been building to — and it cuts against our own fix, so it stays in. &lt;code&gt;fsync&lt;/code&gt; is a &lt;em&gt;durability&lt;/em&gt; tool: it controls what survives a power cut. It is not, on any system I can find documentation for, a &lt;em&gt;visibility&lt;/em&gt; tool — nothing in POSIX or Apple's docs says that syncing a directory's file descriptor refreshes what the next &lt;code&gt;readdir&lt;/code&gt; returns on a running machine. On Linux it has no defined effect on our problem at all. On macOS, the theory that &lt;code&gt;F_FULLFSYNC&lt;/code&gt; forces APFS to commit the metadata transaction that feeds enumeration is exactly that — a theory, resting on the same undocumented behaviour as Layer Two. We put the barrier in our fix anyway: it costs 5–20ms on NVMe, and if the theory is right, it tightens the window. But I won't pretend we proved it does anything. Keep that skepticism handy for the next section.&lt;/p&gt;
&lt;h2&gt;
  
  
  How Three Half-Truths Compound
&lt;/h2&gt;

&lt;p&gt;Any one of these alone is trivia. Stacked, they turn the friendliest file API in Python into silent data loss. Read the diagram top to bottom — this is the order it happens in at runtime:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2Fpako%3AeNptkk9PAjEQxb_KpCeIENR42hhOamJINIEDB9fDbDtAQ7ddp1M2G8J3t8uCB7WXNvN-nff656h0MKQKFekrkdf0ZHHLWJce8miQxWrboBdYA0ZYsxXiv-KiFxfEnhzMYGMdxS4K_dNm2ZNLQkMMI966UI0HaD2dzxcFtL3DaAw3oF2I_apKHGVg3oIQhEPemkljmbQE7uBgqQWNHhxuITAQ82PFs_moJr1Db2MNB2RLEaoO3lcXw-VgyDlLbjXKIcnLRVtMs7gsgOpGur7l-Qjozla_s2QOvYHXlzx1vXnmNzmcgdqaaYtuPznHaVB2zlYQc8mFNutWoEz3t3cPkPzehxYrR9dwQwAmSewjfHxeSRfCPoKze4KYtKYY1UTVxDVak9_xWCrZUU2lKqBUnpIwulKdMpQag0LPxuZLU8UGXaSJwiRh1Xn9UxioyzdQhXCi0zfd17gA" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2Fpako%3AeNptkk9PAjEQxb_KpCeIENR42hhOamJINIEDB9fDbDtAQ7ddp1M2G8J3t8uCB7WXNvN-nff656h0MKQKFekrkdf0ZHHLWJce8miQxWrboBdYA0ZYsxXiv-KiFxfEnhzMYGMdxS4K_dNm2ZNLQkMMI966UI0HaD2dzxcFtL3DaAw3oF2I_apKHGVg3oIQhEPemkljmbQE7uBgqQWNHhxuITAQ82PFs_moJr1Db2MNB2RLEaoO3lcXw-VgyDlLbjXKIcnLRVtMs7gsgOpGur7l-Qjozla_s2QOvYHXlzx1vXnmNzmcgdqaaYtuPznHaVB2zlYQc8mFNutWoEz3t3cPkPzehxYrR9dwQwAmSewjfHxeSRfCPoKze4KYtKYY1UTVxDVak9_xWCrZUU2lKqBUnpIwulKdMpQag0LPxuZLU8UGXaSJwiRh1Xn9UxioyzdQhXCi0zfd17gA" alt="Architecture diagram" width="720" height="511"&gt;&lt;/a&gt;&lt;br&gt;
| Layer | What it's supposed to guarantee | What it actually guarantees |&lt;br&gt;
|---|---|---|&lt;br&gt;
| &lt;code&gt;Path.rglob&lt;/code&gt; | Returns every file in the tree | Returns whatever &lt;code&gt;readdir&lt;/code&gt; delivered, silently dropping anything that errored — and never telling you whether anything did |&lt;br&gt;
| &lt;code&gt;readdir&lt;/code&gt; | The current contents of the directory | Under concurrent writes: unspecified by POSIX. After sequential writes: should be current — but APFS's behaviour is undocumented and container filesystems have their own quirks |&lt;br&gt;
| &lt;code&gt;fsync(dir_fd)&lt;/code&gt; | Pending metadata committed to durable storage | Kernel buffers only on macOS (&lt;code&gt;F_FULLFSYNC&lt;/code&gt; for the drive) — and on no system is it a documented listing-freshness barrier |&lt;/p&gt;

&lt;p&gt;In production on Linux we can name the symptom but not the mechanism — the listing was wrong and the API was silent about why. On a Mac dev box, two more undocumented layers join in, which is why the bug was easier to reproduce at my desk than it ever was to catch in the logs. The window is tens of milliseconds. The probability per call is tiny. The probability across a multi-hour pipeline run writing thousands of files is not — and every miss looks exactly like a legitimately empty directory.&lt;/p&gt;

&lt;p&gt;The reframe that finally made the fix obvious: a directory listing is not a query. It's a measurement. It has noise, it has a sampling window, and if you need it to be right, you have to engineer for that the way you would for any other measurement.&lt;/p&gt;
&lt;h2&gt;
  
  
  What We Shipped
&lt;/h2&gt;

&lt;p&gt;First: do you even need this? Only if your code lists a directory &lt;em&gt;in the same breath&lt;/em&gt; as writing into it — write-then-immediately-list, or listing while writers may still be active. A static folder read at startup is fine with plain &lt;code&gt;rglob&lt;/code&gt;. If that's you, close this tab guilt-free.&lt;/p&gt;

&lt;p&gt;For everyone else, four moves, each honest about what it does and doesn't buy:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Replace &lt;code&gt;rglob&lt;/code&gt; with an &lt;code&gt;os.scandir&lt;/code&gt; recursion that raises on error.&lt;/strong&gt; The one &lt;em&gt;provable&lt;/em&gt; fix: silence becomes a loud failure, so you always know which kind of empty you got.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Retry briefly with backoff.&lt;/strong&gt; The unglamorous move that, in truth, probably does most of the work.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Nudge the directory metadata before each attempt&lt;/strong&gt; — &lt;code&gt;F_FULLFSYNC&lt;/code&gt; on macOS, &lt;code&gt;os.fsync&lt;/code&gt; elsewhere. This is the theory-not-proof part flagged above: cheap, harmless, unproven.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A tripwire and a loud log.&lt;/strong&gt; Two same-instant top-level listings that disagree mean the directory is visibly in flux; if it never settles, operators hear about it instead of nobody.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The block below is complete — imports, exception, logging — paste it and it runs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;fcntl&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;logging&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;sys&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pathlib&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Path&lt;/span&gt;

&lt;span class="n"&gt;logger&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;logging&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getLogger&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;__name__&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# In the stdlib on macOS builds of Python; the raw value elsewhere is inert.
&lt;/span&gt;&lt;span class="n"&gt;F_FULLFSYNC&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getattr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fcntl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;F_FULLFSYNC&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;51&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;


&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;StorageListingError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Exception&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;A directory listing failed — surfaced instead of swallowed.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_flush_directory_metadata&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Nudge the kernel to commit pending directory metadata.

    fsync is a durability primitive; treating it as a listing-freshness
    barrier is a theory, not a documented contract. Kept because it is
    cheap and cannot make things worse.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;fd&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;O_DIRECTORY&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;platform&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;darwin&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="n"&gt;fcntl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fcntl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fd&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;F_FULLFSYNC&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;OSError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fd&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# e.g. network/virtual filesystems
&lt;/span&gt;        &lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fd&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;finally&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fd&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_scandir_recursive&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Like Path.rglob(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;*&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;) for files, except errors raise instead of
    silently shrinking the result.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;scandir&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;it&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;entry&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;it&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;is_dir&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;follow_symlinks&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
                    &lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;extend&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;_scandir_recursive&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt;
                &lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="n"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;is_file&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;follow_symlinks&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
                    &lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;OSError&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;exc&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;StorageListingError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;could not list &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;exc&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;out&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;safe_list_directory&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;max_attempts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Recursive file listing, hardened for directories written moments ago.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;backoffs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.05&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.10&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;last&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;max_attempts&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;backoffs&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;attempt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;backoffs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)])&lt;/span&gt;
        &lt;span class="nf"&gt;_flush_directory_metadata&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;listing&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_scandir_recursive&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="c1"&gt;# Tripwire, not proof: two top-level listings taken back-to-back
&lt;/span&gt;        &lt;span class="c1"&gt;# that DISAGREE mean the directory is visibly in flux. Agreement
&lt;/span&gt;        &lt;span class="c1"&gt;# does not prove the recursive listing is complete.
&lt;/span&gt;        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;listdir&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;iterdir&lt;/span&gt;&lt;span class="p"&gt;()}:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;listing&lt;/span&gt;
        &lt;span class="n"&gt;last&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;listing&lt;/span&gt;
    &lt;span class="n"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;warning&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;directory listing never converged: path=%s attempts=%d&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;max_attempts&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;last&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It drops in at every &lt;code&gt;rglob&lt;/code&gt; call site with one deliberate behavioural change: where &lt;code&gt;rglob&lt;/code&gt; silently returned partial results, this &lt;em&gt;raises&lt;/em&gt;. That is the point — if your caller can't handle a &lt;code&gt;StorageListingError&lt;/code&gt;, it was already mishandling the invisible version of the same event.&lt;/p&gt;

&lt;p&gt;Which parts can I defend, and how hard? The scandir-and-raise part: fully — it provably closes the silence class. The retry: empirically — it's the standard answer to transient inconsistency, whatever the mechanism. The flush: it's the belt-and-braces theory flagged in Layer Three; we never ran the ablation that would separate its contribution from the retry's, and I'd genuinely love to see someone do it. The tripwire: it detects churn between two instants, not stable staleness — a steadily wrong view sails through it, which is why the warning log exists. The whole thing is probabilistic, not deterministic. We tightened the window; we did not close it. The Darwin branch exists because dev machines live on Macs even though production is Linux; the cost on a Mac is 5–20ms of &lt;code&gt;F_FULLFSYNC&lt;/code&gt; per attempt, far cheaper on Linux, and negligible against upload latencies either way.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Fixes That Don't Trust Listings
&lt;/h2&gt;

&lt;p&gt;Closing the window for real means restructuring so the race can't exist, and every mature system that has faced this converged on one of three shapes — none of which trust a directory listing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Sentinel markers.&lt;/strong&gt; The writer touches a marker file last, after everything is flushed; readers refuse to list until the marker exists. Hadoop's &lt;code&gt;_SUCCESS&lt;/code&gt; file, carried forward by Spark, Hive, and Iceberg.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Atomic rename.&lt;/strong&gt; Write into a staging directory, flush, then &lt;code&gt;rename&lt;/code&gt; it into place — POSIX guarantees observers see the old state or the new state, never a partial one. Maildir has done this since 1995; Git's &lt;code&gt;index.lock&lt;/code&gt; is the same idea.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Kernel events instead of listings.&lt;/strong&gt; Subscribe to FSEvents/inotify before writing and build the file set from events. Watchman, Buck2, and Mercurial's fsmonitor live here.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;There's an optimistic precedent for the "or the platform just fixes it" ending, too. S3's LIST calls were eventually consistent for fourteen years, and it bit production hard enough that Netflix built &lt;a href="https://netflixtechblog.com/s3mper-consistency-in-the-cloud-b6a1076aa4f8" rel="noopener noreferrer"&gt;s3mper&lt;/a&gt; — Daniel Weeks's 2014 writeup could be this post's abstract: "there is no indication that a problem occurred... if only a small portion of input is missing the results will appear convincing." Hadoop built S3Guard for the same reason. Then in December 2020, &lt;a href="https://aws.amazon.com/blogs/aws/amazon-s3-update-strong-read-after-write-consistency/" rel="noopener noreferrer"&gt;AWS made S3 strongly consistent&lt;/a&gt; for everyone, retroactively, for free — Werner Vogels's &lt;a href="https://www.allthingsdistributed.com/2021/04/s3-strong-consistency.html" rel="noopener noreferrer"&gt;engineering post-mortem&lt;/a&gt; notes the formal verification took more work than the implementation. The S3Guard docs now just say: delete the DynamoDB table.&lt;/p&gt;

&lt;p&gt;Sit with the irony for a second: an object store on the other side of the planet now gives you stronger listing guarantees than the filesystem on the laptop you're reading this on.&lt;/p&gt;

&lt;h2&gt;
  
  
  The People Who Dug First
&lt;/h2&gt;

&lt;p&gt;Almost nothing in this post is my discovery. The record was public the whole time — it just took a production incident to make me read it. Credit where it belongs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The pathlib thread&lt;/strong&gt;: Gregorio (the 2015 report), Serhiy Storchaka (the road not taken), Ulrich Petri (the patch), Guido van Rossum (the warning that came true), Thierry Parmentelat and Matt Wozniski (the strace proof), Pablo Galindo (the regression fix), Barney Gale (3.13's resolution), and Jakub Kuczys (the 2026 issue that finally got it documented).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The Darwin durability thread&lt;/strong&gt;: Dominic Giampaolo (the 2005 explanation that still holds), Hector Martin (the numbers), Michael Tsai (the fairest record of the fight), Alex Miller (the essay to read first), and Howard Oakley (the calm explainer).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The fsyncgate thread&lt;/strong&gt;: Craig Ringer (the discovery), Jonathan Corbet (the no-villains account), Dan Luu (the archive, and the whole "files are hard" genre), Thomas Munro and Andres Freund (the fix), and Ted Ts'o (the kernel's side of the story, which deserves to be heard).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The APFS thread&lt;/strong&gt;: Giovanni Pizzi and Ronald Oussoren (bpo-41291 — the one Apple-acknowledged APFS race, a delete-vs-open one rather than a listing one), and Gregory Szorc (the readdir profiling). This thread is the shortest because almost nobody has dug here. That should probably change.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you take one action from this post, make it structural: treat directory listings as measurements, surface your errors, and when correctness matters, publish atomically or use a marker. The standard library teaches you to trust the listing. Production says don't.&lt;/p&gt;




&lt;p&gt;The scholars of Ohara, with the Buster Call bearing down on them, threw their books into the lake so the knowledge would outlive the island. Twenty years later it was all still there, waiting for someone willing to dive.&lt;/p&gt;

&lt;p&gt;The knowledge about this bug was never hidden either. It's been sitting in open issue trackers, mailing list archives, and man pages since before some of the people hitting it learned Python — eleven years of it in one CPython thread alone. Nobody burned the library. We just stopped reading the poneglyphs.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxhckylq4lrzwztiin3kf.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxhckylq4lrzwztiin3kf.jpg" alt="The lake at Ohara filled with the books the scholars saved from the burning Tree of Knowledge" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;Intro and closing images are from the One Piece anime via the &lt;a href="https://onepiece.fandom.com/" rel="noopener noreferrer"&gt;One Piece Wiki&lt;/a&gt;. Attribution and credits — please &lt;a href="mailto:prateek11rai@protonmail.com"&gt;contact&lt;/a&gt; if anything needs updating.&lt;/p&gt;

</description>
      <category>python</category>
      <category>macos</category>
      <category>debugging</category>
      <category>filesystem</category>
    </item>
    <item>
      <title>Your Blog Deserves a Second Life: Syndicating to dev.to with Canonical URLs</title>
      <dc:creator>Prateek Rai</dc:creator>
      <pubDate>Sun, 28 Jun 2026 10:54:50 +0000</pubDate>
      <link>https://dev.to/prateek11rai/your-blog-deserves-a-second-life-syndicating-to-devto-with-canonical-urls-2ped</link>
      <guid>https://dev.to/prateek11rai/your-blog-deserves-a-second-life-syndicating-to-devto-with-canonical-urls-2ped</guid>
      <description>&lt;p&gt;I spent months writing posts for this blog. Each one went live on the site, got maybe three readers (one of them me checking the RSS feed), and sat there in peace.&lt;/p&gt;

&lt;p&gt;Then I started copy-pasting the same content to dev.to manually — fixing broken image paths, stripping out MkDocs-specific markdown, re-pasting. Every. Single. Post.&lt;/p&gt;

&lt;p&gt;The third time I did it, I wrote a Python script instead. That script became a CI pipeline. That pipeline became the subject of this post.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4soy3yyr7qgn8l94pnwg.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4soy3yyr7qgn8l94pnwg.png" alt="The Going Merry — the sending-ship that carried the Straw Hats from their private dock to the public ports of the world, over and over again" width="800" height="723"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The Canonical URL Problem
&lt;/h2&gt;

&lt;p&gt;Here is the thing that kept me up at night: dev.to has a domain authority of 89. Your personal blog has a domain authority of about 4 (if you are lucky). If you post the same content on both without telling Google which one is original, guess which one ranks first?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Hint: not yours.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This is where &lt;code&gt;canonical_url&lt;/code&gt; comes in. It is an HTML &lt;code&gt;&amp;lt;link&amp;gt;&lt;/code&gt; tag that tells search engines: "This article originally lives at this URL. Index that one, not me." dev.to supports it natively — if you include &lt;code&gt;canonical_url&lt;/code&gt; in your API payload, every dev.to article page renders a &lt;code&gt;&amp;lt;link rel="canonical" href="..."&amp;gt;&lt;/code&gt; in the head. Google sees it and credits the original source.&lt;/p&gt;

&lt;p&gt;Without canonical URLs, dev.to syndication actively harms your SEO. With them, it becomes a pure distribution channel — your site keeps the ranking, dev.to gets the traffic.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Hot Take&lt;/strong&gt;&lt;br&gt;
If you syndicate to dev.to without setting a canonical URL, you are actively cannibalizing your own SEO. dev.to's domain authority means Google will rank their copy above yours every time. Canonical URLs are not a nice-to-have — they are the difference between syndication helping you and hurting you.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The canonical URL for this very post? &lt;code&gt;https://prateek11rai.github.io/sanji/blog/2026/06/28/devto-syndication/&lt;/code&gt;. The script builds it from the front matter date and the slug.&lt;/p&gt;

&lt;h2&gt;
  
  
  The dev.to API
&lt;/h2&gt;

&lt;p&gt;The dev.to API is refreshingly simple. No OAuth flow, no Bearer tokens, no scopes to negotiate. You generate an API key from &lt;code&gt;dev.to → Settings → Account → API Keys&lt;/code&gt; and send it as a header.&lt;/p&gt;

&lt;h3&gt;
  
  
  Authentication
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;api-key: YOUR_DEVTO_API_KEY
Content-Type: application/json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is it. One header. No &lt;code&gt;Authorization: Bearer&lt;/code&gt;, no refresh tokens.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Payload
&lt;/h3&gt;

&lt;p&gt;The entire payload nests under a single &lt;code&gt;article&lt;/code&gt; key:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"article"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Your Blog Deserves a Second Life"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"body_markdown"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"# The full markdown content..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"published"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"canonical_url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://prateek11rai.github.io/sanji/blog/2026/06/28/devto-syndication/"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"description"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Short excerpt shown in previews"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"tags"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"tooling"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"python"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"meta"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"main_image"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://raw.githubusercontent.com/prateek11rai/sanji/main/docs/assets/images/blog/devto-syndication/devto-intro.png"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Endpoints
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Endpoint&lt;/th&gt;
&lt;th&gt;Method&lt;/th&gt;
&lt;th&gt;Purpose&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/api/articles&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;POST&lt;/td&gt;
&lt;td&gt;Create new article&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/api/articles/{id}&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;PUT&lt;/td&gt;
&lt;td&gt;Update existing article&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/api/articles/me/all?per_page=100&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;GET&lt;/td&gt;
&lt;td&gt;List all articles (including drafts)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/api/articles/me/published?per_page=100&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;GET&lt;/td&gt;
&lt;td&gt;List published articles only&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Rate Limits
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;10 POST&lt;/strong&gt; requests per 30 seconds&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;30 PUT&lt;/strong&gt; requests per 30 seconds&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;We hit the POST limit during the first bulk syndication. The API does not return rate-limit headers — you only learn you hit the wall when a &lt;code&gt;429&lt;/code&gt; comes back. The fix is a retry with backoff, which I added after the first batch failed halfway through.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Trivia&lt;/strong&gt;&lt;br&gt;
The dev.to API has no &lt;code&gt;DELETE /api/articles/{id}&lt;/code&gt; endpoint. If you need to remove a syndicated article, you must delete it manually through the web dashboard. This is surprisingly common among community-focused platforms — they want human oversight on deletions.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Tag Restrictions
&lt;/h3&gt;

&lt;p&gt;dev.to tags must be:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Purely alphanumeric (no hyphens, no spaces, no special characters)&lt;/li&gt;
&lt;li&gt;Max 4 tags per article&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This means &lt;code&gt;self-hosting&lt;/code&gt; (MkDocs category) becomes &lt;code&gt;selfhosting&lt;/code&gt; (dev.to tag). Our script lowercases categories and strips hyphens automatically.&lt;/p&gt;

&lt;h3&gt;
  
  
  Draft Quirk
&lt;/h3&gt;

&lt;p&gt;Setting &lt;code&gt;published: false&lt;/code&gt; creates an unpublished article — but &lt;code&gt;GET /api/articles/{id}&lt;/code&gt; returns a 404 for drafts. You must use &lt;code&gt;/me/all&lt;/code&gt; to find them. This caught us during development when we tried to verify a draft by fetching it back.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Transformation Pipeline
&lt;/h2&gt;

&lt;p&gt;MkDocs Material produces beautiful markdown — for MkDocs Material. dev.to uses its own renderer (based on Redcarpet/github-linguist), and the two speak completely different dialects.&lt;/p&gt;

&lt;p&gt;Here is every transform our &lt;code&gt;syndicate.py&lt;/code&gt; script applies, in order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Strip ``&lt;/strong&gt; — MkDocs excerpt marker, irrelevant on dev.to&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Strip &lt;code&gt;&amp;lt;style&amp;gt;&lt;/code&gt; blocks&lt;/strong&gt; — Material inline styles that break dev.to rendering&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Strip `` grid cards&lt;/strong&gt; — Material's HTML grid markup is stripped by dev.to anyway&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Render Mermaid diagrams as images&lt;/strong&gt; — &lt;code&gt;&lt;/code&gt;`&lt;code&gt;mermaid&lt;/code&gt; blocks become &lt;code&gt;![Architecture diagram](https://mermaid.ink/img/pako:{compressed})&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Strip ``&lt;/strong&gt; — MkDocs image attribute, dev.to does not support it&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Strip attr_list attributes&lt;/strong&gt; — `` annotations from the attr_list plugin&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Replace Material icons&lt;/strong&gt; — &lt;code&gt;✅&lt;/code&gt; becomes &lt;code&gt;✅&lt;/code&gt;, unknown icons are removed&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Convert admonitions to blockquotes&lt;/strong&gt; — &lt;code&gt;!!! note "Title"\n    content&lt;/code&gt; becomes &lt;code&gt;&amp;gt; **Title**\n&amp;gt; content&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rewrite image URLs&lt;/strong&gt; — &lt;code&gt;../../assets/images/foo.jpg&lt;/code&gt; → &lt;code&gt;https://raw.githubusercontent.com/...&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Unwrap footnote definitions&lt;/strong&gt; — &lt;code&gt;: text&lt;/code&gt; moved to bottom under a &lt;code&gt;---&lt;/code&gt; separator&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Strip footnote references&lt;/strong&gt; — `` inline refs removed&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Strip H1 title&lt;/strong&gt; — The &lt;code&gt;# Title&lt;/code&gt; is removed (dev.to uses its own title field from the payload)&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The most complex transform is the Mermaid rendering, which deserves its own section.&lt;/p&gt;

&lt;h2&gt;
  
  
  Mermaid Rendering
&lt;/h2&gt;

&lt;p&gt;MkDocs Material renders Mermaid diagrams natively in the browser using JavaScript. dev.to does not. The solution: pre-render every Mermaid block as an image using the free &lt;a href="https://mermaid.ink" rel="noopener noreferrer"&gt;mermaid.ink&lt;/a&gt; service.&lt;/p&gt;

&lt;p&gt;The trick is in how mermaid.ink accepts diagrams. It expects a URL-safe, zlib-compressed, base64-encoded JSON payload in the path. The payload structure:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"code"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"graph TB&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;    A[Post] --&amp;gt; B(syndicate.py)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"mermaid"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"{&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;theme&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;neutral&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;}"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"updateEditor"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"autoSync"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"updateDiagram"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Our script compresses this, encodes it, and produces a URL:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_render_mermaid&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_repl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Match&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;code&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;group&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;code&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;code&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;mermaid&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;theme&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;neutral&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}),&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;updateEditor&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;autoSync&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;updateDiagram&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="n"&gt;separators&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;,&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;:&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="n"&gt;compressed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;zlib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;compress&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;level&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;9&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;encoded&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;base64&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;urlsafe_b64encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;compressed&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;rstrip&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://mermaid.ink/img/pako:&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;encoded&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;![Architecture diagram](&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;)&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;```

mermaid\n(.*?)

```\s*\n*&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_repl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;flags&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DOTALL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The result is an inline image that renders the diagram exactly as it would on the site. The &lt;code&gt;level=9&lt;/code&gt; compression is crucial — Mermaid payloads can be large, and URLs have length limits.&lt;/p&gt;

&lt;p&gt;Here is the full flow visualized:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2Fpako%3AeNqFUk1LxDAQ_SshRz-2F_GwiLDb7sLCBkPbPaUe0mbcjaZJSVJBxf9u2lStKDiHdt6bN8nLMG-4MQLwEh8t705on1cahXB9HYkKF1w_SrRW5ljhWByCZIw8ZaZxiBrnb2qb3IqAkjroki5QLjlbtOL-u2NL2NYa7RHh3oONLdwDOkdN-B2NleAmPWgRk99uXrSQgx4VjZWdn3sqKHOf5UX3Mru7pKy0XLsHY1tEZQdKapjV04ylXBsdWhU65Hu07qUSYGcSkjMCtuVSoDzYAzs94VXJOjyh5g6ur_61L-B54c3c9IruWGSHdDwy4Z1MuPWyUV8TGYLeFSUbPihB9FDOKqu83KX7DaN9raQ7gUCr2P63IZKhy8vbMK8It2SEaRZhQUdYTtUyQjJtRhp7g9fprPwnDslIDD4jMzoeNdElvsBtnGRYu7cK-xO0UOFlGI-G3luuKvweRH03rMdGSG8sXj5w5eAC896bsAPNFxFVmeRhxC1eetvD-wdR7dfg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2Fpako%3AeNqFUk1LxDAQ_SshRz-2F_GwiLDb7sLCBkPbPaUe0mbcjaZJSVJBxf9u2lStKDiHdt6bN8nLMG-4MQLwEh8t705on1cahXB9HYkKF1w_SrRW5ljhWByCZIw8ZaZxiBrnb2qb3IqAkjroki5QLjlbtOL-u2NL2NYa7RHh3oONLdwDOkdN-B2NleAmPWgRk99uXrSQgx4VjZWdn3sqKHOf5UX3Mru7pKy0XLsHY1tEZQdKapjV04ylXBsdWhU65Hu07qUSYGcSkjMCtuVSoDzYAzs94VXJOjyh5g6ur_61L-B54c3c9IruWGSHdDwy4Z1MuPWyUV8TGYLeFSUbPihB9FDOKqu83KX7DaN9raQ7gUCr2P63IZKhy8vbMK8It2SEaRZhQUdYTtUyQjJtRhp7g9fprPwnDslIDD4jMzoeNdElvsBtnGRYu7cK-xO0UOFlGI-G3luuKvweRH03rMdGSG8sXj5w5eAC896bsAPNFxFVmeRhxC1eetvD-wdR7dfg" alt="Architecture diagram" width="1751" height="292"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The CI Workflow
&lt;/h2&gt;

&lt;p&gt;The workflow lives in &lt;code&gt;.github/workflows/syndicate.yml&lt;/code&gt; and runs on every push to &lt;code&gt;main&lt;/code&gt;. It detects which blog posts changed, runs the syndication script on each one, and publishes (or updates) them on dev.to.&lt;/p&gt;

&lt;p&gt;Key excerpt:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Syndicate blog to dev.to&lt;/span&gt;
&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;branches&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;main&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;syndicate&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;fetch-depth&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Detect changed blog posts&lt;/span&gt;
        &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;changed&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;BEFORE="${{ github.event.before }}"&lt;/span&gt;
          &lt;span class="s"&gt;if [ "$BEFORE" = "0000000000000000000000000000000000000000" ]; then&lt;/span&gt;
            &lt;span class="s"&gt;FILES=$(git diff-tree --no-commit-id -r "${{ github.sha }}" \&lt;/span&gt;
              &lt;span class="s"&gt;--name-only -- 'docs/blog/posts/*.md')&lt;/span&gt;
          &lt;span class="s"&gt;else&lt;/span&gt;
            &lt;span class="s"&gt;FILES=$(git diff --name-only "$BEFORE" "${{ github.sha }}" \&lt;/span&gt;
              &lt;span class="s"&gt;-- 'docs/blog/posts/*.md')&lt;/span&gt;
          &lt;span class="s"&gt;fi&lt;/span&gt;
          &lt;span class="s"&gt;FILES=$(echo "$FILES" | grep -v 'sanji\.md$' || true)&lt;/span&gt;
          &lt;span class="s"&gt;echo "changed&amp;lt;&amp;lt;DELIM" &amp;gt;&amp;gt; $GITHUB_OUTPUT&lt;/span&gt;
          &lt;span class="s"&gt;echo "$FILES" &amp;gt;&amp;gt; $GITHUB_OUTPUT&lt;/span&gt;
          &lt;span class="s"&gt;echo "DELIM" &amp;gt;&amp;gt; $GITHUB_OUTPUT&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Syndicate to dev.to&lt;/span&gt;
        &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;steps.changed.outputs.changed != ''&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;echo "$CHANGED_FILES" | while IFS= read -r file; do&lt;/span&gt;
            &lt;span class="s"&gt;[ -z "$file" ] &amp;amp;&amp;amp; continue&lt;/span&gt;
            &lt;span class="s"&gt;uv run python scripts/syndicate.py "$file" --publish&lt;/span&gt;
          &lt;span class="s"&gt;done&lt;/span&gt;
        &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;CHANGED_FILES&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ steps.changed.outputs.changed }}&lt;/span&gt;
          &lt;span class="na"&gt;DEVTO_API_KEY&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.DEVTO_API_KEY }}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few design decisions worth noting:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;fetch-depth: 0&lt;/code&gt;&lt;/strong&gt; is required because the workflow needs the full git history for &lt;code&gt;git diff&lt;/code&gt; to compare against the previous commit. A shallow clone would fail here.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The zero-SHA check&lt;/strong&gt; handles the first push to a branch, where &lt;code&gt;github.event.before&lt;/code&gt; is all zeros. In that case, &lt;code&gt;git diff-tree&lt;/code&gt; lists every file in the initial commit.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;sanji.md&lt;/code&gt; is filtered out&lt;/strong&gt; — that is the about page, not a blog post.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;uv run&lt;/code&gt;&lt;/strong&gt; is used instead of raw &lt;code&gt;python&lt;/code&gt; because the project uses uv for Python package management. Much faster than pip, lockfile for reproducibility.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The Canonical URL Builder
&lt;/h2&gt;

&lt;p&gt;The most important function in the script. Every post gets a deterministic URL based on its front matter date and title:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;build_payload&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;post_file&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;publish&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;content&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;post_file&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read_text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;encoding&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;utf-8&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;fm&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;parse_front_matter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;title&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;extract_title&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;slug&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;post_file&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stem&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt; &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;title&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;description&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;extract_description&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;slug&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;slug&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="nf"&gt;make_slug&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;tags&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;fm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;categories&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[])][:&lt;/span&gt;&lt;span class="n"&gt;MAX_TAGS&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;tags&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;tags&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;meta&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

    &lt;span class="n"&gt;date_str&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;fm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;date&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;date_parts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;date_str&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;date_parts&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;canonical_url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;SITE_BASE&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/blog/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;date_parts&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;date_parts&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;date_parts&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;slug&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;canonical_url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;SITE_BASE&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/blog/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;slug&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

    &lt;span class="c1"&gt;# ... payload assembly continues ...
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The slug is generated using &lt;code&gt;pymdownx.slugs.uslugify(title, "-")&lt;/code&gt; — the same function MkDocs Material uses internally. This ensures the dev.to canonical URL matches the exact URL the blog generates at build time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Idempotency &amp;amp; Dedup
&lt;/h2&gt;

&lt;p&gt;The worst thing a syndication script can do is create duplicates. Our script prevents this by checking the existing articles before creating:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;find_existing&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;api_key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;canonical_url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;headers&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;api-key&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;api_key&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;url&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;DEVTO_API&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/me/all?per_page=100&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;DEVTO_API&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/me/published?per_page=100&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;]:&lt;/span&gt;
        &lt;span class="n"&gt;resp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status_code&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;article&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;article&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;canonical_url&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;canonical_url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;article&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If an article with matching &lt;code&gt;canonical_url&lt;/code&gt; exists, it calls PUT instead of POST. The same post can be re-syndicated a hundred times — it creates one article, updated in place.&lt;/p&gt;

&lt;p&gt;The script also preserves the existing &lt;code&gt;published&lt;/code&gt; state on updates unless &lt;code&gt;--publish&lt;/code&gt; is explicitly passed. This means a draft post that gets edited stays a draft on dev.to.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why This Matters
&lt;/h2&gt;

&lt;p&gt;Syndication infrastructure is invisible when it works and very visible when it does not. The script runs on every push, transforms every block, and posts to dev.to without anyone thinking about it. That is the point.&lt;/p&gt;




&lt;p&gt;The Going Merry was not the biggest or strongest ship. It was a caravel — an obsolete design by Grand Line standards. But it carried the Straw Hats through East Blue, across Reverse Mountain, into the sky, and all the way to Enies Lobby. It was the sending-ship that connected their private world to the public one.&lt;/p&gt;

&lt;p&gt;This syndication pipeline is the same thing. A small, purpose-built tool that carries each post from my personal dock to the public port. The canonical URL is the flag in the mast — everyone knows where it came from.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ficnx1qf0c0bh0zh9jww9.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ficnx1qf0c0bh0zh9jww9.png" alt="Transponder Snail — the Den Den Mushi that broadcasts signals across islands, always keeping the original message intact" width="799" height="451"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;Image credit and attribution — One Piece artwork is the property of Eiichiro Oda and Shueisha. Please &lt;a href="mailto:prateek11rai@protonmail.com"&gt;contact&lt;/a&gt; if anything needs updating.&lt;/p&gt;

</description>
      <category>devto</category>
      <category>ci</category>
      <category>seo</category>
      <category>python</category>
    </item>
    <item>
      <title>Crafting Your Terminal Like a Chef Sharpens His Knives</title>
      <dc:creator>Prateek Rai</dc:creator>
      <pubDate>Sat, 27 Jun 2026 19:27:21 +0000</pubDate>
      <link>https://dev.to/prateek11rai/crafting-your-terminal-like-a-chef-sharpens-his-knives-2d8c</link>
      <guid>https://dev.to/prateek11rai/crafting-your-terminal-like-a-chef-sharpens-his-knives-2d8c</guid>
      <description>&lt;p&gt;I used the default macOS Terminal for years and thought that was just how terminals look.&lt;/p&gt;

&lt;p&gt;Then I saw someone else's — proper font, syntax highlighting, a prompt that actually tells you something — and realized mine looked like a hospital monitor from the 90s.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdwuknkyvx8rws8og7bs4.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdwuknkyvx8rws8og7bs4.png" alt="Young Sanji cooking at the Baratie — learning the craft early" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Not the Defaults
&lt;/h2&gt;

&lt;p&gt;macOS ships with Terminal.app and bash (well, zsh now, but the point stands). They are designed to be adequate, not good. Here is what I wanted instead:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Default&lt;/th&gt;
&lt;th&gt;Why It Falls Short&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Terminal.app&lt;/td&gt;
&lt;td&gt;No GPU acceleration, no Lua config, no cross-platform&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;bash&lt;/td&gt;
&lt;td&gt;No autosuggestions, weaker completion than zsh&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bare prompt&lt;/td&gt;
&lt;td&gt;No git status, no Python version, no command timing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;No multiplexer&lt;/td&gt;
&lt;td&gt;Lose your session when the window closes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Default font&lt;/td&gt;
&lt;td&gt;Menlo at 12px — designed for legibility, not for staring at 8 hours a day&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;I wanted a terminal that felt like my space — configured exactly how I like it, persistent across sessions, and beautiful enough that I do not mind spending hours in it. The result is a stack of four tools that each do one thing well: &lt;strong&gt;WezTerm&lt;/strong&gt; for rendering, &lt;strong&gt;Tmux&lt;/strong&gt; for session management, &lt;strong&gt;Zsh&lt;/strong&gt; for the shell, and &lt;strong&gt;Starship&lt;/strong&gt; for the prompt. All tied together with a single dotfiles repo and a bootstrap script that sets up a new Mac in one command.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Hot Take&lt;/strong&gt;&lt;br&gt;
iTerm2 is the default recommendation for anyone switching from Terminal.app, and I think it is overrated. It is heavy, its tab management has always felt bolted on, and its Python scripting API is powerful but unnecessary for 95% of users. WezTerm does everything iTerm2 does but with a config file you can version-control and a GPU-accelerated renderer that actually matters on high-DPI displays.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  WezTerm — The Terminal Itself
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://wezfurlong.org/wezterm/" rel="noopener noreferrer"&gt;WezTerm&lt;/a&gt; is a GPU-accelerated terminal emulator configured entirely through Lua. No GUI settings panel, no hidden preferences — just a &lt;code&gt;wezterm.lua&lt;/code&gt; file you check into git.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Config
&lt;/h3&gt;

&lt;p&gt;The main file lives at &lt;code&gt;~/.config/wezterm/wezterm.lua&lt;/code&gt;, split into modules loaded with &lt;code&gt;require()&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;~/.config/wezterm/
├── wezterm.lua           # Main config
├── startup/
│   └── init.lua          # Auto-attach tmux + maximize on launch
└── keybindings/
    └── init.lua          # Custom keybindings (C-S-f to fit screen)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Key settings:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Setting&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;enable_tab_bar = false&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Off&lt;/td&gt;
&lt;td&gt;Tmux handles tabs — WezTerm just renders&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;window_decorations = "RESIZE"&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Minimal&lt;/td&gt;
&lt;td&gt;No title bar, no close/minimize buttons&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;font = JetBrains Mono Bold&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Bold&lt;/td&gt;
&lt;td&gt;Clean, developer-friendly, bold for readability&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;font_size = 15&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;15px&lt;/td&gt;
&lt;td&gt;Comfortable without wasting space&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;color_scheme = "Dracula (Official)"&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Dracula&lt;/td&gt;
&lt;td&gt;Consistent theme across everything&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;window_background_opacity = 0.99&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Near-solid&lt;/td&gt;
&lt;td&gt;Slight transparency for a modern feel&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  The Startup Module
&lt;/h3&gt;

&lt;p&gt;This is the glue that connects WezTerm to Tmux:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight lua"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- ~/.config/wezterm/startup/init.lua&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;wezterm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"gui-startup"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="kd"&gt;local&lt;/span&gt; &lt;span class="n"&gt;tmux_path&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"/opt/homebrew/bin/tmux"&lt;/span&gt;
        &lt;span class="n"&gt;wezterm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;mux&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;spawn_window&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
            &lt;span class="n"&gt;args&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;tmux_path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"new-session"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"-A"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When WezTerm launches, it runs &lt;code&gt;tmux new-session -A&lt;/code&gt; — attach to the most recent Tmux session if one exists, or create a new one. The full path to &lt;code&gt;tmux&lt;/code&gt; is required because WezTerm's spawn process does not inherit the user shell PATH.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Keybindings Module
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight lua"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- ~/.config/wezterm/keybindings/init.lua&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;keys&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"F"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;mods&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"CTRL|SHIFT"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;action&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;wezterm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="n"&gt;FitToDisplay&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
        &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One binding: &lt;code&gt;Ctrl+Shift+F&lt;/code&gt; fits the window to the active display. This exists because disconnecting an external monitor leaves the terminal window sized for a larger screen. One keystroke fixes it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tmux — The Session Manager
&lt;/h2&gt;

&lt;p&gt;WezTerm renders the pixels. Tmux keeps your work alive. The two together are better than either alone.&lt;/p&gt;

&lt;p&gt;Config at &lt;code&gt;~/.config/tmux/tmux.conf&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;set -g prefix C-a                # Changed from C-b — easier reach
set -g mouse on                   # Click panes, scroll, resize with mouse
bind r source-file ~/.config/tmux/tmux.conf  # Quick reload
bind i run ~/.config/tmux/plugins/tpm/bin/install_plugins
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Plugins
&lt;/h3&gt;

&lt;p&gt;Managed by TPM (Tmux Plugin Manager):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Plugin&lt;/th&gt;
&lt;th&gt;Purpose&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;tmux-plugins/tpm&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Plugin manager itself&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;tmux-plugins/tmux-sensible&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Saner defaults (better escape time, UTF-8, improved pane behavior)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;tmux-plugins/tmux-yank&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Copy to system clipboard from inside tmux&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Why Tmux Over WezTerm Tabs
&lt;/h3&gt;

&lt;p&gt;WezTerm can do tabs natively. I chose Tmux because:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Persistence&lt;/strong&gt; — close WezTerm, reopen it, and you are back in the same Tmux session. Everything is where you left it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SSH&lt;/strong&gt; — Tmux works over SSH exactly as it works locally. Detach from a remote session, reconnect later from a different machine.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Muscle memory&lt;/strong&gt; — Tmux has been doing this since 2007. Every terminal multiplexer since then has been catching up.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Zsh — The Shell
&lt;/h2&gt;

&lt;p&gt;The shell config at &lt;code&gt;~/.zshrc&lt;/code&gt; sets up the environment that WezTerm and Tmux run inside:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Prompt&lt;/span&gt;
&lt;span class="nb"&gt;eval&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;starship init zsh&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;span class="c"&gt;# Python&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;PYENV_ROOT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$HOME&lt;/span&gt;&lt;span class="s2"&gt;/.pyenv"&lt;/span&gt;
&lt;span class="nb"&gt;command&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; pyenv &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;eval&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;pyenv init -&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;span class="c"&gt;# Editor&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;EDITOR&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"code --wait"&lt;/span&gt;

&lt;span class="c"&gt;# Autosuggestions&lt;/span&gt;
&lt;span class="nb"&gt;source&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;brew &lt;span class="nt"&gt;--prefix&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;/share/zsh-autosuggestions/zsh-autosuggestions.zsh"&lt;/span&gt;

&lt;span class="c"&gt;# Java&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;JAVA_HOME&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"/Library/Java/JavaVirtualMachines/jdk-22.jdk/Contents/Home"&lt;/span&gt;

&lt;span class="c"&gt;# Corporate SSL (Netskope — only on work machines)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="s2"&gt;"/Library/Application Support/Netskope/STAgent/stagent.pem"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
    &lt;/span&gt;&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;AWS_CA_BUNDLE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"/Library/Application Support/Netskope/STAgent/stagent.pem"&lt;/span&gt;
    &lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;CURL_CA_BUNDLE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"/Library/Application Support/Netskope/STAgent/stagent.pem"&lt;/span&gt;
    &lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;SSL_CERT_FILE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"/Library/Application Support/Netskope/STAgent/stagent.pem"&lt;/span&gt;
    &lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;NODE_EXTRA_CA_CERTS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"/Library/Application Support/Netskope/STAgent/stagent.pem"&lt;/span&gt;
&lt;span class="k"&gt;fi&lt;/span&gt;

&lt;span class="c"&gt;# Aliases&lt;/span&gt;
&lt;span class="nb"&gt;alias &lt;/span&gt;&lt;span class="nv"&gt;vc&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"vcluster platform connect vcluster"&lt;/span&gt;
&lt;span class="nb"&gt;alias &lt;/span&gt;&lt;span class="nv"&gt;ap&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"argopm install . -n default -f -c ."&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Netskope block is only active on corporate machines where the cert file exists. On personal machines, those env vars are never set, and nothing breaks.&lt;/p&gt;

&lt;h2&gt;
  
  
  Starship — The Prompt
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://starship.rs" rel="noopener noreferrer"&gt;Starship&lt;/a&gt; is a cross-shell prompt written in Rust. It is fast, minimal, and shows contextual information without clutter.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight toml"&gt;&lt;code&gt;&lt;span class="c"&gt;# ~/.config/starship.toml&lt;/span&gt;
&lt;span class="c"&gt;# Each module gets its own format string&lt;/span&gt;
&lt;span class="nn"&gt;[git_branch]&lt;/span&gt;
&lt;span class="py"&gt;format&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"on [$symbol$branch]($style) "&lt;/span&gt;

&lt;span class="nn"&gt;[python]&lt;/span&gt;
&lt;span class="py"&gt;format&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"via [${symbol}${version}]($style) "&lt;/span&gt;

&lt;span class="nn"&gt;[cmd_duration]&lt;/span&gt;
&lt;span class="py"&gt;show_milliseconds&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What it shows on every prompt:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Current directory&lt;/strong&gt; — truncated to fit&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Git branch and status&lt;/strong&gt; — dirty/clean, ahead/behind&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Python version&lt;/strong&gt; (if in a venv or project)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Command duration&lt;/strong&gt; — how long the last command took&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Exit code&lt;/strong&gt; — red indicator on failure&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The Dotfiles Repository
&lt;/h2&gt;

&lt;p&gt;Everything above lives in a single repo: &lt;a href="https://github.com/prateek11rai/dotfiles" rel="noopener noreferrer"&gt;&lt;code&gt;github.com/prateek11rai/dotfiles&lt;/code&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The structure mirrors &lt;code&gt;~/.config/&lt;/code&gt; directly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;dotfiles/
├── .zshrc
├── .config/
│   ├── starship.toml
│   ├── tmux/tmux.conf
│   ├── neofetch/
│   │   ├── config.conf
│   │   └── custom-ascii.txt
│   └── wezterm/
│       ├── wezterm.lua
│       ├── startup/init.lua
│       └── keybindings/init.lua
├── wallpapers/macos.png
├── scripts/mac-bootstrap.sh
└── README.md
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  The Bootstrap Script
&lt;/h3&gt;

&lt;p&gt;The script at &lt;code&gt;scripts/mac-bootstrap.sh&lt;/code&gt; automates setting up a new Mac:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Install Homebrew (if missing)&lt;/li&gt;
&lt;li&gt;Install packages: &lt;code&gt;tmux&lt;/code&gt;, &lt;code&gt;starship&lt;/code&gt;, &lt;code&gt;gh&lt;/code&gt;, &lt;code&gt;zsh-autosuggestions&lt;/code&gt;, &lt;code&gt;pyenv&lt;/code&gt;, &lt;code&gt;wezterm&lt;/code&gt;, &lt;code&gt;font-jetbrains-mono&lt;/code&gt;, &lt;code&gt;neofetch&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Back up any existing config files that are NOT already symlinks (renames to &lt;code&gt;.backup.YYYYMMDD-HHMMSS&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Symlink every config file from the repo into the correct &lt;code&gt;~/&lt;/code&gt; or &lt;code&gt;~/.config/&lt;/code&gt; location&lt;/li&gt;
&lt;li&gt;Install TPM and Tmux plugins&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Running it on a fresh Mac:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/prateek11rai/dotfiles.git ~/github/prateek11rai/dotfiles
~/github/prateek11rai/dotfiles/scripts/mac-bootstrap.sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The backup step makes this safe to run on an already-configured machine — nothing gets lost.&lt;/p&gt;

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

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2Fpako%3AeNqFUlFPwyAQ_iuEB59cF18Xs4d1Tk1cNVKjGfWBtddBLNAAjTrjf_e6NsqiiTxcuI_v4Pvu-KClrYDO6M6JVpJ8URiCy3fbASioFuUtK-iA9-vxYsMfYZ-D0-dbN51f3j2QPlFGNM8_tHz98MRz3b0dSAy8V9aQtTBiBy7ibdgV33hJmIQmrmc5Z0E4L1VL7pzVbYgOsxXPwNYQSjlc_-4DaHJtajuywFTD5peh1Jpa7Y4c3fBX2Af0kDSdiD2kPKCDpMSSWHLKk72XrozlptyPcpNgdewkS7kZxU7_Vbe0oVYNeHIPrY1FLu_5TgXZbVGNnrZOBICXszMn1LQaa6I3F4zj4CZba4MPeHXi5d9PLxiZTObYAnKCdjFs-sD6kKUDBQc-co7zfsID0u8OEA5zQHBzAFh-nGerMU_HY3pKNTZeqAo_4UdBgwQNBZ1hKwx0KL0p6CeSurZCxxeVCtbRWS0aD6dUdMGyd1N-AwNrqQS2U9NZcB18fgGUN9v8" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2Fpako%3AeNqFUlFPwyAQ_iuEB59cF18Xs4d1Tk1cNVKjGfWBtddBLNAAjTrjf_e6NsqiiTxcuI_v4Pvu-KClrYDO6M6JVpJ8URiCy3fbASioFuUtK-iA9-vxYsMfYZ-D0-dbN51f3j2QPlFGNM8_tHz98MRz3b0dSAy8V9aQtTBiBy7ibdgV33hJmIQmrmc5Z0E4L1VL7pzVbYgOsxXPwNYQSjlc_-4DaHJtajuywFTD5peh1Jpa7Y4c3fBX2Af0kDSdiD2kPKCDpMSSWHLKk72XrozlptyPcpNgdewkS7kZxU7_Vbe0oVYNeHIPrY1FLu_5TgXZbVGNnrZOBICXszMn1LQaa6I3F4zj4CZba4MPeHXi5d9PLxiZTObYAnKCdjFs-sD6kKUDBQc-co7zfsID0u8OEA5zQHBzAFh-nGerMU_HY3pKNTZeqAo_4UdBgwQNBZ1hKwx0KL0p6CeSurZCxxeVCtbRWS0aD6dUdMGyd1N-AwNrqQS2U9NZcB18fgGUN9v8" alt="Architecture diagram" width="1482" height="604"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Neofetch is Archived&lt;/strong&gt;&lt;br&gt;
Neofetch has been unmaintained since 2023. The bootstrap script falls back to downloading the raw script from GitHub if &lt;code&gt;brew install&lt;/code&gt; fails. Consider migrating to &lt;code&gt;fastfetch&lt;/code&gt; as a maintained replacement.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  What It Looks Like
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyl36qnqk5ufgq4qewvpn.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyl36qnqk5ufgq4qewvpn.png" alt="Franky building the staircase — creating something that takes you higher" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;WezTerm opens, attaches to the last Tmux session, and I see my Zsh prompt with the Dracula theme, Starship showing git status, and Neofetch greeting me with system info. Everything is the same as I left it — even if I closed the terminal an hour ago, on a different display, in a different city.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Philosophy
&lt;/h2&gt;

&lt;p&gt;Five principles that shaped this setup:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Tmux-first&lt;/strong&gt; — Tmux manages sessions, not the terminal. WezTerm is just a display layer. This separation means I can close the window, switch machines, or lose power without losing my place.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One config repo&lt;/strong&gt; — All dotfiles in one place, one bootstrap script, one &lt;code&gt;git clone&lt;/code&gt; to set up a new machine.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Minimal bootstrap&lt;/strong&gt; — Only Homebrew and git needed to go from zero to fully configured.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Consistent theme&lt;/strong&gt; — Dracula everywhere. Same colors in the terminal, editor, and browser. Reduces visual friction.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Safe to re-run&lt;/strong&gt; — The bootstrap script backs up existing files before symlinking. I can run it on an already-configured machine without breaking anything.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Every chef has their own knife kit, sharpened and arranged exactly how they like it. This is mine. WezTerm is the blade, Tmux is the workbench, Zsh is the cutting board, and Starship is the scale that tells you exactly what you are working with. The dotfiles repo is the roll that keeps it all together.&lt;/p&gt;




&lt;p&gt;Image credits and attribution — please &lt;a href="mailto:prateek11rai@protonmail.com"&gt;contact&lt;/a&gt; if anything needs updating.&lt;br&gt;
WezTerm has its own multiplexer, but Tmux is preferred because sessions persist when the window closes, it works identically over SSH, and it has decades of community tooling around it.&lt;/p&gt;

</description>
      <category>wezterm</category>
      <category>tmux</category>
      <category>terminal</category>
      <category>dotfiles</category>
    </item>
    <item>
      <title>Freeing the Screensaver: 4K Anime Art on Google TV</title>
      <dc:creator>Prateek Rai</dc:creator>
      <pubDate>Sat, 27 Jun 2026 19:26:34 +0000</pubDate>
      <link>https://dev.to/prateek11rai/freeing-the-screensaver-4k-anime-art-on-google-tv-di7</link>
      <guid>https://dev.to/prateek11rai/freeing-the-screensaver-4k-anime-art-on-google-tv-di7</guid>
      <description>&lt;p&gt;My TV spends more time idle than playing content. And every idle minute, Google TV shows me the same curated AI art or Google Photos memories — things I did not choose, do not control, and cannot change.&lt;/p&gt;

&lt;p&gt;I wanted my own screensaver. 4K anime art from Wallhaven, video loops from MoeWalls — playing on my TV, not streamed from a cloud service I never asked for.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fz0ym0osl7uh3dap1jano.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fz0ym0osl7uh3dap1jano.jpg" alt="Aerial Views screensaver on Google TV" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem
&lt;/h2&gt;

&lt;p&gt;Since Android TV 12, Google TV devices have locked down the screensaver selection. The Settings UI no longer lists third-party screensaver apps. You get Ambient Mode — Google Photos, AI art, or a black screen. That is it.&lt;/p&gt;

&lt;p&gt;The change affected every Google TV device: Chromecast with Google TV, Google TV Streamer, and built-in Google TV on Sony, TCL, and Philips TVs.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Solution
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/theothernt/AerialViews" rel="noopener noreferrer"&gt;Aerial Views&lt;/a&gt; is an open-source screensaver app that supports 4K HDR videos, local photos, and media from USB/Samba/WebDAV. It is on the Play Store, but Google TV hides it from the launcher. You install it anyway via ADB and force the system to use it.&lt;/p&gt;

&lt;p&gt;The full pipeline:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2Fpako%3AeNqFklFrwjAUhf9KyOt0dZuMIUPoaKeidcWKfUj3EJs7DWubkrS6If73pakbmQ4WCKTfPek995ADTgUDPMAbScstmi2SAuml6nULEhzQ9EmI9wS3lWa53hPRG-04RWsJ-1erFE6JC5LTDK047FUDrHLgexOX9KconI_QFQrCPnrmGaiTBArWHi5cjITYZICWK9vHZB4t3dmMRJxBJigDhtyytNpFy5eFTxzFUiqZ0_oythxL5C18NyBRKgEKRXcgUQRyx1P411QkapmCsi3FYxLTLNvq_xSPa-kM7x76vY_bm_teM7KdREwCAY1WGZ3ORKfxd8sm6253-D3vb2gibZE5GmgGP-nC6eXl04cpmPlbbK6dw3iMutdnjWIL4Q7OQeaUM_2MDgmutpBDggc6oALqStIswUctqktGK_AZr4TEgzeaKehgWlci-izSH9CqPE51yDkeVLKG4xdw48GU" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2Fpako%3AeNqFklFrwjAUhf9KyOt0dZuMIUPoaKeidcWKfUj3EJs7DWubkrS6If73pakbmQ4WCKTfPek995ADTgUDPMAbScstmi2SAuml6nULEhzQ9EmI9wS3lWa53hPRG-04RWsJ-1erFE6JC5LTDK047FUDrHLgexOX9KconI_QFQrCPnrmGaiTBArWHi5cjITYZICWK9vHZB4t3dmMRJxBJigDhtyytNpFy5eFTxzFUiqZ0_oythxL5C18NyBRKgEKRXcgUQRyx1P411QkapmCsi3FYxLTLNvq_xSPa-kM7x76vY_bm_teM7KdREwCAY1WGZ3ORKfxd8sm6253-D3vb2gibZE5GmgGP-nC6eXl04cpmPlbbK6dw3iMutdnjWIL4Q7OQeaUM_2MDgmutpBDggc6oALqStIswUctqktGK_AZr4TEgzeaKehgWlci-izSH9CqPE51yDkeVLKG4xdw48GU" alt="Architecture diagram" width="1075" height="591"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Enable Developer Options on the TV
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Settings → Device Preferences → About → Build → click 7 times
Settings → System → Developer options → USB debugging → ON
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note down the TV's IP address: &lt;code&gt;Settings → Network → Wi-Fi → IP address&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Install ADB
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;brew &lt;span class="nb"&gt;install &lt;/span&gt;android-platform-tools
adb version
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  3. Connect to the TV
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;adb connect &amp;lt;TV_IP&amp;gt;:5555
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the connection is refused (common on Android 14+ Chromecast with Google TV):&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Go to &lt;strong&gt;Developer options → Wireless debugging&lt;/strong&gt; on the TV&lt;/li&gt;
&lt;li&gt;Select &lt;strong&gt;Pair device with pairing code&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Run the pair command shown on TV, then connect:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;adb pair &amp;lt;IP&amp;gt;:&amp;lt;PORT&amp;gt;   &lt;span class="c"&gt;# enter the 6-digit code&lt;/span&gt;
adb connect &amp;lt;IP&amp;gt;:&amp;lt;PORT&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  4. Download and Install Aerial Views
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-LO&lt;/span&gt; https://github.com/theothernt/AerialViews/releases/download/1.8.2/aerial-views-1.8.2.apk
adb &lt;span class="nb"&gt;install &lt;/span&gt;aerial-views-1.8.2.apk
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  5. Set It as the System Screensaver
&lt;/h3&gt;

&lt;p&gt;This is the critical step — it bypasses Google TV's UI lock:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;adb shell settings put secure screensaver_components &lt;span class="se"&gt;\&lt;/span&gt;
  com.neilturner.aerialviews/.ui.screensaver.DreamActivity

&lt;span class="c"&gt;# Verify&lt;/span&gt;
adb shell settings get secure screensaver_components
&lt;span class="c"&gt;# Expected: com.neilturner.aerialviews/.ui.screensaver.DreamActivity&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  6. Push Your Own Media
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Create a folder on the TV&lt;/span&gt;
adb shell &lt;span class="nb"&gt;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; /sdcard/AerialViews

&lt;span class="c"&gt;# Push 4K images and videos&lt;/span&gt;
adb push ~/Downloads/wallhaven/&lt;span class="k"&gt;*&lt;/span&gt;.png /sdcard/AerialViews/
adb push ~/Downloads/moewall/&lt;span class="k"&gt;*&lt;/span&gt;.mp4 /sdcard/AerialViews/

&lt;span class="c"&gt;# Verify&lt;/span&gt;
adb shell find /sdcard/AerialViews &lt;span class="nt"&gt;-type&lt;/span&gt; f
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  7. Configure Aerial Views on the TV
&lt;/h3&gt;

&lt;p&gt;Open the Aerial Views app from the app drawer and configure:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Sources → Local storage&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Mode: &lt;strong&gt;Folder access&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Path: &lt;code&gt;/AerialViews&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Ensure only one volume is selected&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Playlist&lt;/strong&gt; — disable ALL online sources (Apple, Amazon, Jetson Creative, Robin Fourcade). Enable only &lt;strong&gt;Local storage&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Set &lt;strong&gt;Media type&lt;/strong&gt;: Mixed or Photos only&lt;/li&gt;
&lt;li&gt;Adjust &lt;strong&gt;Photo duration&lt;/strong&gt; to your preference&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Overlays&lt;/strong&gt; (optional) — clock, date, location&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Back → Test screensaver&lt;/strong&gt; to preview&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Where to Find 4K Anime Media
&lt;/h2&gt;

&lt;p&gt;Google TV is 16:9 4K, so aim for &lt;strong&gt;3840×2160&lt;/strong&gt; content:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Site&lt;/th&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;th&gt;Notes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://wallhaven.cc" rel="noopener noreferrer"&gt;Wallhaven&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Images&lt;/td&gt;
&lt;td&gt;4K PNG, best for anime art, bans AI art&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://desktophut.com" rel="noopener noreferrer"&gt;DesktopHut&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Video loops&lt;/td&gt;
&lt;td&gt;Free 4K MP4, anime/gaming/nature&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://moewalls.lat" rel="noopener noreferrer"&gt;MoeWalls&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Video loops&lt;/td&gt;
&lt;td&gt;Anime-focused 4K MP4/WEBM, free&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://pixabay.com/videos/search/anime%20background/" rel="noopener noreferrer"&gt;Pixabay&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Videos&lt;/td&gt;
&lt;td&gt;15K+ free backgrounds, 4K, royalty-free&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  The Current Setup
&lt;/h2&gt;

&lt;p&gt;My TV now cycles through a mix of 4K Wallhaven images and a video loop from MoeWalls — about 151 MB of local content that plays regardless of internet connectivity.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/sdcard/AerialViews/
├── wallhaven-je86dy_3840x2160.png
├── wallhaven-d86o9g_3840x2160.png
├── wallhaven-6llg7q_3840x2160.png
├── ...                # 8 more images
└── moewalls/
    └── cat-in-the-swamp-moewalls-com.mp4
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Quick Reference
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Action&lt;/th&gt;
&lt;th&gt;Command&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Connect&lt;/td&gt;
&lt;td&gt;&lt;code&gt;adb connect &amp;lt;IP&amp;gt;:5555&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pair (if needed)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;adb pair &amp;lt;IP&amp;gt;:&amp;lt;PORT&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Install APK&lt;/td&gt;
&lt;td&gt;&lt;code&gt;adb install app.apk&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Push files&lt;/td&gt;
&lt;td&gt;&lt;code&gt;adb push &amp;lt;local&amp;gt; &amp;lt;remote&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Set screensaver&lt;/td&gt;
&lt;td&gt;&lt;code&gt;adb shell settings put secure screensaver_components com.neilturner.aerialviews/.ui.screensaver.DreamActivity&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Restore default&lt;/td&gt;
&lt;td&gt;&lt;code&gt;adb shell settings put secure screensaver_components com.google.android.apps.tv.dreamx/.service.Backdrop&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Change idle timeout&lt;/td&gt;
&lt;td&gt;&lt;code&gt;adb shell settings put system screen_off_timeout 300000&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The fix took 15 minutes. Most of that was downloading wallpapers. The TV sits idle, and now it shows exactly what I want — no cloud, no AI, no algorithm.&lt;/p&gt;




&lt;p&gt;Image credits and attribution — please &lt;a href="mailto:prateek11rai@protonmail.com"&gt;contact&lt;/a&gt; if anything needs updating.&lt;/p&gt;

</description>
      <category>android</category>
      <category>homelab</category>
      <category>screensaver</category>
      <category>google</category>
    </item>
    <item>
      <title>Ohara Learns to Talk: Self-Hosting an AI Agent with Hermes</title>
      <dc:creator>Prateek Rai</dc:creator>
      <pubDate>Sat, 27 Jun 2026 19:26:24 +0000</pubDate>
      <link>https://dev.to/prateek11rai/ohara-learns-to-talk-self-hosting-an-ai-agent-with-hermes-mlj</link>
      <guid>https://dev.to/prateek11rai/ohara-learns-to-talk-self-hosting-an-ai-agent-with-hermes-mlj</guid>
      <description>&lt;p&gt;Ohara had been running quietly under my desk for weeks. Jellyfin streamed movies. Suwayomi downloaded manga chapters overnight. The server did its job without complaint, invisible and silent.&lt;/p&gt;

&lt;p&gt;But I wanted more. I wanted it to talk back.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdlac3oht09u5axr8f1pk.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdlac3oht09u5axr8f1pk.jpg" alt="Hermes AI Agent interface" width="800" height="137"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The idea was simple: an always-on AI agent I could message from my phone, anywhere in the world, running on my own hardware. No subscriptions, no cloud lock-in, no monthly fees for toys I half-use. Just a Telegram bot that could think, remember, and eventually do things.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Hermes
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://hermes-agent.nousresearch.com" rel="noopener noreferrer"&gt;Hermes Agent&lt;/a&gt; by Nous Research is an open-source AI agent framework designed to run on your own infrastructure. It connects to LLM providers (OpenRouter, OpenAI, Anthropic, local models) and messaging platforms (Telegram, WhatsApp, Discord) to become a persistent AI assistant.&lt;/p&gt;

&lt;p&gt;What sold me:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Requirement&lt;/th&gt;
&lt;th&gt;Why Hermes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Runs on a Core 2 Duo&lt;/td&gt;
&lt;td&gt;No local GPU needed — all inference is API-based&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Self-hosted&lt;/td&gt;
&lt;td&gt;My data, my config, my keys&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Multi-platform&lt;/td&gt;
&lt;td&gt;One agent, many messaging backends&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Extensible&lt;/td&gt;
&lt;td&gt;Skills, cron jobs, terminal access, memory&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The server's 15-year-old hardware does not matter here. Hermes does not run a model locally — it calls APIs. Ohara just needs to run a Docker container and have internet access. That is it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Docker Setup
&lt;/h2&gt;

&lt;p&gt;Hermes ships as an official Docker image, so it fits right into Ohara's existing infrastructure. I keep all app data under &lt;code&gt;/srv/appdata&lt;/code&gt;, so Hermes lives at &lt;code&gt;/srv/appdata/hermes/&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/srv/appdata/hermes/
├── docker-compose.yml
├── .env
└── data/
    ├── .env
    ├── config.yaml
    ├── platforms/
    ├── sessions/
    ├── memories/
    └── skills/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  docker-compose.yml
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;hermes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;nousresearch/hermes-agent:latest&lt;/span&gt;
    &lt;span class="na"&gt;container_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;hermes&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
    &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;gateway run&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;8642:8642"&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/srv/appdata/hermes/data:/opt/data&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;OPENROUTER_API_KEY=${OPENROUTER_API_KEY}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;.env&lt;/code&gt; file beside the compose file holds just the OpenRouter key:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;OPENROUTER_API_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;sk-or-...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Start it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cd&lt;/span&gt; /srv/appdata/hermes
docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  The Permission Trap
&lt;/h3&gt;

&lt;p&gt;On first boot, the &lt;code&gt;data/&lt;/code&gt; directory gets created as root. Hermes then drops privileges to UID 10000 (the &lt;code&gt;hermes&lt;/code&gt; user) inside the container and can no longer read its own files. You will know this happened because the container enters a restart loop with &lt;code&gt;PermissionError&lt;/code&gt; in the logs.&lt;/p&gt;

&lt;p&gt;The fix is simple — one-time, then it stays fixed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo chown&lt;/span&gt; &lt;span class="nt"&gt;-R&lt;/span&gt; 1000:1000 /srv/appdata/hermes/data
docker compose down &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the single most important thing to know about running Hermes in Docker. Every permission issue traces back to this. If you write files with &lt;code&gt;sudo tee&lt;/code&gt; or &lt;code&gt;docker cp&lt;/code&gt;, run that &lt;code&gt;chown&lt;/code&gt; command afterwards.&lt;/p&gt;

&lt;h2&gt;
  
  
  OpenRouter: Free LLM Access
&lt;/h2&gt;

&lt;p&gt;Hermes needs an LLM to think. Running one locally on a Core 2 Duo with 4 GB RAM is not realistic. Instead, I route everything through &lt;a href="https://openrouter.ai" rel="noopener noreferrer"&gt;OpenRouter&lt;/a&gt; — a single API key that gives access to 200+ models from every provider.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Inside the container&lt;/span&gt;
docker &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-it&lt;/span&gt; hermes bash
&lt;span class="nb"&gt;source&lt;/span&gt; /opt/hermes/.venv/bin/activate
hermes model
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This launches an interactive wizard. Two choices:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Select &lt;strong&gt;OpenRouter&lt;/strong&gt; (the key is auto-picked from the &lt;code&gt;OPENROUTER_API_KEY&lt;/code&gt; env var)&lt;/li&gt;
&lt;li&gt;Pick a model&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;The model I chose:&lt;/strong&gt; &lt;code&gt;nvidia/llama-3.1-nemotron-70b-instruct:free&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;This is a NVIDIA-hosted Llama 3.1 variant available at no cost through OpenRouter's free tier.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2Fpako%3AeNp1kEGLwkAMhf9KyNnSexFBdNcK2kotvbR7iDbagXampNPCIv73Ha24LrubU_LywhfeBY-mZAzwLNRWsEkKDa66_jAKBcYVCcGeZWApcFzfKsxDloY7WBhtSWmWj-_lInfqSZ3B72TwqW1LsuRX94OHjXU5Nr-Im80WdmIGVf4kxkket6wT01sWmO_WL8Aoy6NsvVzPIeLGWDF6ehB_9i7MkKrnb_9DF7VibV9x6SpPuWZnaP6-Tlcw9TzYctfRmcHzZhCOm_A-xI8w4-Q-RhlOsHERkCpd4JcCbcUNFxg4vObeCtUFXp2pv8XFb6WyRjA4Ud3xBKm3Zv-pj09hdC0V3T7EwErP1y-FiI14" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2Fpako%3AeNp1kEGLwkAMhf9KyNnSexFBdNcK2kotvbR7iDbagXampNPCIv73Ha24LrubU_LywhfeBY-mZAzwLNRWsEkKDa66_jAKBcYVCcGeZWApcFzfKsxDloY7WBhtSWmWj-_lInfqSZ3B72TwqW1LsuRX94OHjXU5Nr-Im80WdmIGVf4kxkket6wT01sWmO_WL8Aoy6NsvVzPIeLGWDF6ehB_9i7MkKrnb_9DF7VibV9x6SpPuWZnaP6-Tlcw9TzYctfRmcHzZhCOm_A-xI8w4-Q-RhlOsHERkCpd4JcCbcUNFxg4vObeCtUFXp2pv8XFb6WyRjA4Ud3xBKm3Zv-pj09hdC0V3T7EwErP1y-FiI14" alt="Architecture diagram" width="1127" height="256"&gt;&lt;/a&gt;&lt;br&gt;
The key insight: &lt;strong&gt;the API route means your server's hardware does not matter.&lt;/strong&gt; A Raspberry Pi would work just as well. The server just needs to make HTTP requests and relay responses. No GPU, no RAM for model weights, no quantization — just a network connection.&lt;/p&gt;
&lt;h2&gt;
  
  
  The Rate Limit Reality
&lt;/h2&gt;

&lt;p&gt;Here is where the free tier fantasy meets reality. A brand-new OpenRouter key gets &lt;strong&gt;50 free requests per day&lt;/strong&gt;. That sounds like a lot until you realize each Hermes conversation turn calls the model &lt;strong&gt;3-5 times&lt;/strong&gt; — one for thinking, one for tool calls, one for the final response. Those 50 requests become 10-15 usable conversations before the key hits the wall.&lt;/p&gt;

&lt;p&gt;You know you have hit the limit when Hermes stops responding and OpenRouter returns a &lt;code&gt;429&lt;/code&gt; or a vague gateway error:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0yki0b27md9g24oxnl69.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0yki0b27md9g24oxnl69.jpg" alt="Rate limit error on OpenRouter" width="800" height="226"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The fix is unsurprising:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Add a minimum of &lt;strong&gt;$10&lt;/strong&gt; in credits to your OpenRouter account. This unlocks &lt;strong&gt;1000 requests/day&lt;/strong&gt; — more than enough for a personal bot.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;$10 on OpenRouter lasts months for a single-user bot. The free tier is a generous trial — the paid tier is where it becomes actually useful. And $10 is still cheaper than a month of any subscription service.&lt;/p&gt;
&lt;h2&gt;
  
  
  Telegram Integration
&lt;/h2&gt;

&lt;p&gt;The messaging layer is what makes this useful. A web UI is fine for configuration, but the real power is messaging your server like a person.&lt;/p&gt;
&lt;h3&gt;
  
  
  Step 1: Create the Bot
&lt;/h3&gt;

&lt;p&gt;Open Telegram, search for &lt;a href="https://t.me/BotFather" rel="noopener noreferrer"&gt;@BotFather&lt;/a&gt;, and send &lt;code&gt;/newbot&lt;/code&gt;:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Pick a name (I used &lt;code&gt;Ohara Hermes&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Pick a username ending in &lt;code&gt;_bot&lt;/code&gt; (e.g. &lt;code&gt;ohara_hermes_bot&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;BotFather replies with a token: &lt;code&gt;YOUR_BOT_TOKEN&lt;/code&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;
  
  
  Step 2: Get Your User ID
&lt;/h3&gt;

&lt;p&gt;Telegram bots need to know who is allowed to talk to them. Find your numeric user ID by messaging &lt;a href="https://t.me/userinfobot" rel="noopener noreferrer"&gt;@userinfobot&lt;/a&gt;.&lt;/p&gt;
&lt;h3&gt;
  
  
  Step 3: Configure Hermes
&lt;/h3&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'TELEGRAM_ENABLED=true'&lt;/span&gt; | &lt;span class="nb"&gt;sudo tee&lt;/span&gt; &lt;span class="nt"&gt;-a&lt;/span&gt; /srv/appdata/hermes/data/.env
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"TELEGRAM_BOT_TOKEN=YOUR_BOT_TOKEN"&lt;/span&gt; | &lt;span class="nb"&gt;sudo tee&lt;/span&gt; &lt;span class="nt"&gt;-a&lt;/span&gt; /srv/appdata/hermes/data/.env
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'TELEGRAM_ALLOWED_USERS=8523398033'&lt;/span&gt; | &lt;span class="nb"&gt;sudo tee&lt;/span&gt; &lt;span class="nt"&gt;-a&lt;/span&gt; /srv/appdata/hermes/data/.env
&lt;span class="nb"&gt;sudo chown&lt;/span&gt; &lt;span class="nt"&gt;-R&lt;/span&gt; 1000:1000 /srv/appdata/hermes/data
docker restart hermes
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Environment variable name&lt;/strong&gt;&lt;br&gt;
The correct variable is &lt;code&gt;TELEGRAM_ALLOWED_USERS&lt;/code&gt;. Using &lt;code&gt;TELEGRAM_ALLOWED_USER_IDS&lt;/code&gt; (which some older docs reference) will cause "Unauthorized user" errors in the logs and the bot will reject every message.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3&gt;
  
  
  Step 4: Test It
&lt;/h3&gt;

&lt;p&gt;Open Telegram, find your bot by username, send a message. The agent responds. That is the whole setup — four commands and you have an AI assistant on your phone.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fc91pyi9jkoguzu8o4ajd.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fc91pyi9jkoguzu8o4ajd.jpg" alt="Telegram conversation with Hermes bot" width="800" height="652"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  The WhatsApp Detour
&lt;/h2&gt;

&lt;p&gt;Hermes also supports WhatsApp through a Baileys-based bridge (emulates WhatsApp Web, no Business API needed). I tried it. I removed it.&lt;/p&gt;

&lt;p&gt;The self-chat mode works in theory — you message yourself, Hermes replies in the same thread. In practice:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The bridge rejected messages with &lt;code&gt;self_chat_mode_rejects_non_self&lt;/code&gt; even though the sender was the paired number&lt;/li&gt;
&lt;li&gt;Permission conflicts between the bridge log (owned by root) and the gateway&lt;/li&gt;
&lt;li&gt;The user experience is worse than Telegram — no voice messages, limited formatting, and the self-chat thread gets confusing&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Removal is clean:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Inside container&lt;/span&gt;
&lt;span class="nb"&gt;sed&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="s1"&gt;'/^WHATSAPP/d'&lt;/span&gt; /opt/data/.env
&lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="nt"&gt;-rf&lt;/span&gt; /opt/data/platforms/whatsapp
docker restart hermes
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnpfpwwzgt1rhvgyycriw.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnpfpwwzgt1rhvgyycriw.jpg" alt="WhatsApp self-chat with Hermes bot" width="800" height="1657"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Telegram is the first-class messaging platform for Hermes. WhatsApp works but has rough edges I did not want to maintain.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Permission Pattern (It Keeps Happening)
&lt;/h2&gt;

&lt;p&gt;Every Hermes issue I ran into traced back to the same root cause: &lt;strong&gt;file ownership&lt;/strong&gt;. Here is the complete list of symptoms and the universal fix:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Symptom&lt;/th&gt;
&lt;th&gt;Cause&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Container restart loop&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;data/&lt;/code&gt; owned by root, container UID 10000 can't write&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;PermissionError: .bundled_manifest&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Skills file owned by root&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;I have no name!&lt;/code&gt; shell prompt&lt;/td&gt;
&lt;td&gt;Cosmetic — UID 10000 not in /etc/passwd&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Telegram unauthorized&lt;/td&gt;
&lt;td&gt;Wrong env var name (&lt;code&gt;_USER_IDS&lt;/code&gt; vs &lt;code&gt;_USERS&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;.env&lt;/code&gt; changes not picked up&lt;/td&gt;
&lt;td&gt;File written with sudo, owned by root&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The fix every time:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo chown&lt;/span&gt; &lt;span class="nt"&gt;-R&lt;/span&gt; 1000:1000 /srv/appdata/hermes/data
docker restart hermes
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  A DNS Detour: When Tailscale Broke GitHub Pages
&lt;/h2&gt;

&lt;p&gt;This one was unexpected. After setting up Hermes, I tried to visit my GitHub Pages site (&lt;code&gt;https://prateek11rai.github.io/sanji/&lt;/code&gt;) to check the previous blog post. The browser showed nothing. DNS lookup failed. But everything else on the internet worked fine.&lt;/p&gt;

&lt;p&gt;The culprit was hiding in plain sight:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;dig prateek11rai.github.io

&lt;span class="p"&gt;;;&lt;/span&gt; SERVER: 100.100.100.100#53  &lt;span class="c"&gt;# ← Tailscale DNS&lt;/span&gt;
&lt;span class="p"&gt;;;&lt;/span&gt; status: SERVFAIL
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Tailscale's MagicDNS was intercepting the &lt;code&gt;.github.io&lt;/code&gt; lookup and failing to resolve it. The server at &lt;code&gt;100.100.100.100&lt;/code&gt; — Tailscale's built-in DNS resolver — did not know how to handle GitHub Pages domains.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The fix&lt;/strong&gt;&lt;br&gt;
&lt;strong&gt;Temporarily disable Tailscale&lt;/strong&gt; — the site loads immediately through the normal DNS resolver. Alternatively, add a secondary DNS server (like &lt;code&gt;8.8.8.8&lt;/code&gt;) in your system network settings while Tailscale is connected.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This is a quirk of how Tailscale handles DNS on macOS. It overrides your system DNS with its own resolver, and that resolver does not forward non-tailnet domains correctly in all configurations. The &lt;code&gt;.github.io&lt;/code&gt; namespace is a GitHub-controlled TLD that Tailscale's DNS does not recognize.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Complete Architecture
&lt;/h2&gt;

&lt;p&gt;Here is what Ohara looks like now:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2Fpako%3AeNqFU99v2jAQ_lcsP-xlMKapYxuqKoVRCkgBlGTpg-nDkRzBIrGR7dCiqv_7LpiuqToJPzi-7777kfPnZ57pHPmAFwb2W5YMV4rRsvXaAys-0RWyObpHbXYr7t3NikSka4fmgXW7NywWiy0YYDGaA5rrtend_FnXytXsMxvpbEc8H4oq94cPhTyN_dbKgVRobLvabCxmWJbHjVSn5EttHPv59Vf_4Y0T34u4foSjruQb5-p7_0eLM4nEBE2FlgUFKtfK1b_6drHF2GkDBbYbC0XPmkOvwlzCKVuoDxJthyVph4W1lRl9QBXQa3UR-CDY73NwPox-eyMLS-MK0UED9y62c_tE81dQsmA5fTetRSQWe1T-ghpvq_Y8FfN0OpoGdKmVdkb7gY4NIgtJC-XFsiFaC4VURbtkcicSLJE41cUECcjSZlBSQbTbdpbpUsjlVitsNZykIlC50TKnYwsPhyKEbKj17v8F45Mwvao8clZYA8_G7BMJhrZJ5J2ENI7wHHzftl7ZDRR4aBKdrMU5euHNeerN5I5ddxvgNft0SRmSlLZwyLpfmhfDO7wiKYLM6fk9r7jbYkXSGtCAFNbOQLniL0SqG5HgbS5JfHywgdJih0PtdHxU2T_As0YSmhvgA2dqfPkLjkEilw" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2Fpako%3AeNqFU99v2jAQ_lcsP-xlMKapYxuqKoVRCkgBlGTpg-nDkRzBIrGR7dCiqv_7LpiuqToJPzi-7777kfPnZ57pHPmAFwb2W5YMV4rRsvXaAys-0RWyObpHbXYr7t3NikSka4fmgXW7NywWiy0YYDGaA5rrtend_FnXytXsMxvpbEc8H4oq94cPhTyN_dbKgVRobLvabCxmWJbHjVSn5EttHPv59Vf_4Y0T34u4foSjruQb5-p7_0eLM4nEBE2FlgUFKtfK1b_6drHF2GkDBbYbC0XPmkOvwlzCKVuoDxJthyVph4W1lRl9QBXQa3UR-CDY73NwPox-eyMLS-MK0UED9y62c_tE81dQsmA5fTetRSQWe1T-ghpvq_Y8FfN0OpoGdKmVdkb7gY4NIgtJC-XFsiFaC4VURbtkcicSLJE41cUECcjSZlBSQbTbdpbpUsjlVitsNZykIlC50TKnYwsPhyKEbKj17v8F45Mwvao8clZYA8_G7BMJhrZJ5J2ENI7wHHzftl7ZDRR4aBKdrMU5euHNeerN5I5ddxvgNft0SRmSlLZwyLpfmhfDO7wiKYLM6fk9r7jbYkXSGtCAFNbOQLniL0SqG5HgbS5JfHywgdJih0PtdHxU2T_As0YSmhvgA2dqfPkLjkEilw" alt="Architecture diagram" width="1019" height="896"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What Comes Next
&lt;/h2&gt;

&lt;p&gt;Hermes has a built-in cron scheduler. I have started experimenting with daily prompts:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;hermes cron add &lt;span class="nt"&gt;--schedule&lt;/span&gt; &lt;span class="s2"&gt;"0 6 * * *"&lt;/span&gt; &lt;span class="nt"&gt;--prompt&lt;/span&gt; &lt;span class="s2"&gt;"Check my Jellyfin library and recommend something to watch tonight"&lt;/span&gt; &lt;span class="nt"&gt;--platform&lt;/span&gt; telegram
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The longer-term plan is a &lt;strong&gt;coding agent&lt;/strong&gt; — Hermes that can clone a repo, run Claude Code through OpenRouter, make changes, commit, and create a PR, all triggered from a Telegram message. The infrastructure is ready: Hermes has terminal access, file system access, and network connectivity. It just needs the right skills configured.&lt;/p&gt;

&lt;p&gt;But that is a post for another day.&lt;/p&gt;

&lt;h2&gt;
  
  
  Things I Learned
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Permission issues are the #1 cause of Docker container crashes&lt;/strong&gt; — any file written as root inside a volume will eventually break something. &lt;code&gt;chown 1000:1000&lt;/code&gt; after every file write and move on.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;API-based AI does not need a GPU&lt;/strong&gt; — a 15-year-old Core 2 Duo with 4 GB RAM is enough to run a full AI agent. The only requirement is internet access.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;OpenRouter free tier is a trial, not a plan&lt;/strong&gt; — 50 requests/day sounds fine until Hermes burns 3-5 per turn. Budget $10 to unlock 1000/day. It lasts months for a personal bot.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tailscale's DNS can interfere with non-tailnet domains&lt;/strong&gt; — &lt;code&gt;.github.io&lt;/code&gt;, and potentially other TLDs, may fail to resolve through Tailscale's MagicDNS. Know how to bypass it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Telegram is the better platform&lt;/strong&gt; — WhatsApp integration exists but Telegram's bot API is cleaner, more reliable, and better documented. Start with Telegram.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fedy12x0zuccda8hsl071.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fedy12x0zuccda8hsl071.jpg" alt="Dr. Vegapunk — the mind behind the future" width="800" height="480"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The server under my desk still runs Jellyfin and Suwayomi. But now it also talks back. When I send a message to &lt;code&gt;@ohara_hermes_bot&lt;/code&gt;, a container on that dusty old OptiPlex wakes up, calls a free NVIDIA-hosted model through OpenRouter, thinks about what I asked, and replies. No cloud subscription. No exposed ports. No GPU required.&lt;/p&gt;

&lt;p&gt;That is the magic of self-hosting in 2026. The hardware you already own is enough.&lt;/p&gt;




&lt;p&gt;Image attribution and credits — please &lt;a href="mailto:prateek11rai@protonmail.com"&gt;contact&lt;/a&gt; if anything needs updating.&lt;/p&gt;

</description>
      <category>selfhosting</category>
      <category>ai</category>
      <category>llm</category>
      <category>hermes</category>
    </item>
    <item>
      <title>Breathing Life Into an Old PC: Building a Self-Hosted Media &amp; Manga Server</title>
      <dc:creator>Prateek Rai</dc:creator>
      <pubDate>Sat, 27 Jun 2026 19:01:44 +0000</pubDate>
      <link>https://dev.to/prateek11rai/breathing-life-into-an-old-pc-building-a-self-hosted-media-manga-server-16a5</link>
      <guid>https://dev.to/prateek11rai/breathing-life-into-an-old-pc-building-a-self-hosted-media-manga-server-16a5</guid>
      <description>&lt;p&gt;I had an old PC lying around — one of those machines you keep meaning to throw out but never do. It was slow, loud, and completely useless for modern computing. No one was going to browse the web on it, let alone run any real software.&lt;/p&gt;

&lt;p&gt;Then it hit me: I could turn it into a server.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzln5yokgticgfk7d39mv.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzln5yokgticgfk7d39mv.jpg" alt="Old PC repurposed as a server" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This is the story of how that dusty old tower became the backbone of my home media setup. I call it &lt;strong&gt;Ohara&lt;/strong&gt; — after the island of scholars in &lt;em&gt;One Piece&lt;/em&gt; — because it preserves and serves knowledge (and manga, and movies, and music) to every device in my house.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Hardware: What I Was Working With
&lt;/h2&gt;

&lt;p&gt;Before you go out and buy a Raspberry Pi or a NAS, look under your desk. Chances are you already own a server.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;🖥️ &lt;strong&gt;The Specs&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Old Dell OptiPlex office PC&lt;/li&gt;
&lt;li&gt;Intel Core 2 Duo (~15 years old)&lt;/li&gt;
&lt;li&gt;4 GB DDR3 RAM&lt;/li&gt;
&lt;li&gt;NVIDIA GeForce 8400 GS (2007-era, useless for AI)&lt;/li&gt;
&lt;li&gt;500 GB HDD + external USB drives&lt;/li&gt;
&lt;li&gt;Gigabit Ethernet port&lt;/li&gt;
&lt;li&gt;No monitor, no keyboard, no mouse&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;✅ &lt;strong&gt;What it can do&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Stream 1080p media to multiple devices ✔️&lt;/li&gt;
&lt;li&gt;Self-host a manga server with auto-downloads ✔️&lt;/li&gt;
&lt;li&gt;Run 24/7 on ~40W power ✔️&lt;/li&gt;
&lt;li&gt;Serve files over a private encrypted network ✔️&lt;/li&gt;
&lt;li&gt;Run LLMs or AI workloads ❌&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2Fpako%3AeNptUcFOAjEQ_ZVJz2AI4mUPJkoJkqgYupy6HIbdARp2W1JajSH8u4MluhudS-e9N8m8Nz2J0lUkMrH1eNhBLgsLXMe4TkQh5jv0CIr8O_lCJPlS47elntlANYydJxiCjG71Ky8eXvQIpo8g5eK2xT9Jqe8Gg4vCbUt4nY311GxxbQJMwo68pXCVyVap-WNOuU34QF6vApb7tr250st1tCFercNwdDMYwXOuWjulK_fkdXpgYrfGUkvOlc7R1McSa_rfCh8B-v173pYwp-5gjtjBHLKD5-obJgMdKleiJxryDZqKv-dUCL5JQ4XIOLalGDzWhTjzUDxUGGhSmeC8yDZYH6knMAanPm35Q6QpaZBP14gs-EjnL9u4k2o" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2Fpako%3AeNptUcFOAjEQ_ZVJz2AI4mUPJkoJkqgYupy6HIbdARp2W1JajSH8u4MluhudS-e9N8m8Nz2J0lUkMrH1eNhBLgsLXMe4TkQh5jv0CIr8O_lCJPlS47elntlANYydJxiCjG71Ky8eXvQIpo8g5eK2xT9Jqe8Gg4vCbUt4nY311GxxbQJMwo68pXCVyVap-WNOuU34QF6vApb7tr250st1tCFercNwdDMYwXOuWjulK_fkdXpgYrfGUkvOlc7R1McSa_rfCh8B-v173pYwp-5gjtjBHLKD5-obJgMdKleiJxryDZqKv-dUCL5JQ4XIOLalGDzWhTjzUDxUGGhSmeC8yDZYH6knMAanPm35Q6QpaZBP14gs-EjnL9u4k2o" alt="Architecture diagram" width="886" height="378"&gt;&lt;/a&gt;&lt;br&gt;
The GPU is so old it predates CUDA entirely. But here is the thing: &lt;strong&gt;media serving is not GPU-bound.&lt;/strong&gt; Jellyfin can hardware-transcode if you have a modern GPU, but for direct-play or software transcoding of 1080p content, even this Core 2 Duo handles it fine. The real bottleneck is always the network, not the CPU.&lt;/p&gt;
&lt;h2&gt;
  
  
  The OS Choice: Ubuntu Server
&lt;/h2&gt;

&lt;p&gt;I went with &lt;strong&gt;Ubuntu Server 24.04 LTS&lt;/strong&gt; — minimal install, no desktop environment. The alternatives were:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Debian&lt;/strong&gt; — lighter, but hardware detection is worse and you lose out on broader community support&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Alpine&lt;/strong&gt; — very light, but musl libc can cause Docker compatibility surprises&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Proxmox&lt;/strong&gt; — overkill for a single machine, eats RAM for the hypervisor layer&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Ubuntu Server wins because when something breaks, someone has already asked and answered the question on the internet.&lt;/p&gt;
&lt;h3&gt;
  
  
  Installation Steps
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;Download the Ubuntu Server ISO via &lt;a href="https://ubuntu.com/download/server" rel="noopener noreferrer"&gt;torrent&lt;/a&gt; — browser downloads are slow, magnets are faster&lt;/li&gt;
&lt;li&gt;Create a bootable USB with &lt;a href="https://rufus.ie" rel="noopener noreferrer"&gt;Rufus&lt;/a&gt; (Windows) or &lt;code&gt;dd&lt;/code&gt; (Mac/Linux)&lt;/li&gt;
&lt;li&gt;Boot the old PC from the USB, install Ubuntu Server — &lt;strong&gt;do not install a GUI&lt;/strong&gt;, select "Install Ubuntu Server" and keep it minimal&lt;/li&gt;
&lt;li&gt;During install, enable SSH and create your user account&lt;/li&gt;
&lt;li&gt;After first boot:
&lt;/li&gt;
&lt;/ol&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;apt update &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;sudo &lt;/span&gt;apt upgrade &lt;span class="nt"&gt;-y&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;apt &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; docker.io docker-compose-v2 ufw
&lt;span class="nb"&gt;sudo &lt;/span&gt;snap remove lxd  &lt;span class="c"&gt;# remove unnecessary bloat&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;h2&gt;
  
  
  Running It Headless
&lt;/h2&gt;

&lt;p&gt;The PC sits under my desk with nothing plugged in except power and ethernet. No monitor, no keyboard. Everything is done over SSH.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2Fpako%3AeNp1UU1LAzEQ_StDTgptbYuK5lAUKygULfbQy17GZGyX7iZxMlGk9L-bkrZCizmE8OZ9zGTWynhLSqtIn4mcoXGNC8a2cpBPQJba1AGdwASD-HCKv_kkxKf4jPhrj5e7OHRHo1LSMGWKEYL_Job3JOIdnHmG-eskO5kVyXkRFn4WliwN46eHKfC24yhQpeHgdggLkggIk_sXeJ4eBe51rsUA3eggC3qD65veoNe_GF4WdiFldpHpXWx2A1tH4_Ob7D-TxLiEwChEq7s_76v-Uf8H5yU1DaAx2_nzdzshqzqqJW6xtnkb60rJklqqlIZKOUrC2FRqk0kp2JzzaGvxrPQHNpE6CpP42Y8zB6CwdstUWjjR5he0zqLR" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2Fpako%3AeNp1UU1LAzEQ_StDTgptbYuK5lAUKygULfbQy17GZGyX7iZxMlGk9L-bkrZCizmE8OZ9zGTWynhLSqtIn4mcoXGNC8a2cpBPQJba1AGdwASD-HCKv_kkxKf4jPhrj5e7OHRHo1LSMGWKEYL_Job3JOIdnHmG-eskO5kVyXkRFn4WliwN46eHKfC24yhQpeHgdggLkggIk_sXeJ4eBe51rsUA3eggC3qD65veoNe_GF4WdiFldpHpXWx2A1tH4_Ob7D-TxLiEwChEq7s_76v-Uf8H5yU1DaAx2_nzdzshqzqqJW6xtnkb60rJklqqlIZKOUrC2FRqk0kp2JzzaGvxrPQHNpE6CpP42Y8zB6CwdstUWjjR5he0zqLR" alt="Architecture diagram" width="758" height="445"&gt;&lt;/a&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Find the server on your network&lt;/span&gt;
nmap &lt;span class="nt"&gt;-sn&lt;/span&gt; 192.168.1.0/24

&lt;span class="c"&gt;# SSH in (use the IP you found)&lt;/span&gt;
ssh prateek@192.168.1.50
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Making Sure It Never Sleeps
&lt;/h3&gt;

&lt;p&gt;Servers should run 24/7. The cleanest way to disable sleep on Ubuntu Server is through &lt;code&gt;logind&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; /etc/systemd/logind.conf.d
&lt;span class="nb"&gt;sudo tee&lt;/span&gt; /etc/systemd/logind.conf.d/nosleep.conf &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;'
[Login]
HandleSuspendKey=ignore
HandleHibernateKey=ignore
HandleLidSwitch=ignore
HandleLidSwitchExternalPower=ignore
IdleAction=ignore
&lt;/span&gt;&lt;span class="no"&gt;EOF

&lt;/span&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl restart systemd-logind
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Storage Layout
&lt;/h2&gt;

&lt;p&gt;All data lives under &lt;code&gt;/srv&lt;/code&gt;, following the Linux Filesystem Hierarchy Standard:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/srv/
├── media/
│   ├── movies/
│   ├── tv/
│   ├── music/
│   ├── music-videos/
│   └── manga/
└── appdata/
    ├── jellyfin/
    └── suwayomi/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The separation is deliberate: &lt;code&gt;/srv/media&lt;/code&gt; holds the actual content (large, slow-changing), while &lt;code&gt;/srv/appdata&lt;/code&gt; holds Docker configs and databases (small, needs backup). You can back up configs independently of media this way.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tailscale: The Networking Layer
&lt;/h2&gt;

&lt;p&gt;I never expose any port to the public internet. Instead, everything is accessed through &lt;a href="https://tailscale.com" rel="noopener noreferrer"&gt;Tailscale&lt;/a&gt; — a WireGuard-based mesh VPN that makes all my devices think they are on the same local network.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2Fpako%3AeNpV0MtqwzAQBdBfEbNqIbRJHxtTFxraXU0NNvGiymJiTWJRWzKy1Ach_94pgtQagdAdndU9QmsVQQYHh2Mn6rU0gmcKu7iQUKPuDXkJ8edvqncJbx06FBcVuU9ylw87d_24Wi6vvvmsJGz_ccG4wHZt7UeibhJVstJlZw0l6DZB9YbVk1HOasUhkXeJbBg22ij7NYlXHL0dE3x_xmRUfMS7Enmei2IeynmoN_PUwAIGcgNqxQ0eJfiOBpKQcW2GgnfYSzgxCqNCTy9Ke-sg22M_0QIweFv9mPa8iOpZI1c_QOZdoNMv0NF4wQ" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2Fpako%3AeNpV0MtqwzAQBdBfEbNqIbRJHxtTFxraXU0NNvGiymJiTWJRWzKy1Ach_94pgtQagdAdndU9QmsVQQYHh2Mn6rU0gmcKu7iQUKPuDXkJ8edvqncJbx06FBcVuU9ylw87d_24Wi6vvvmsJGz_ccG4wHZt7UeibhJVstJlZw0l6DZB9YbVk1HOasUhkXeJbBg22ij7NYlXHL0dE3x_xmRUfMS7Enmei2IeynmoN_PUwAIGcgNqxQ0eJfiOBpKQcW2GgnfYSzgxCqNCTy9Ke-sg22M_0QIweFv9mPa8iOpZI1c_QOZdoNMv0NF4wQ" alt="Architecture diagram" width="511" height="548"&gt;&lt;/a&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# On the server&lt;/span&gt;
curl &lt;span class="nt"&gt;-fsSL&lt;/span&gt; https://tailscale.com/install.sh | sh
&lt;span class="nb"&gt;sudo &lt;/span&gt;tailscale up
&lt;span class="c"&gt;# Opens a URL — authenticate once, done forever&lt;/span&gt;

&lt;span class="c"&gt;# On Mac&lt;/span&gt;
brew &lt;span class="nb"&gt;install &lt;/span&gt;tailscale &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; open /Applications/Tailscale.app

&lt;span class="c"&gt;# On iPhone / Android TV&lt;/span&gt;
&lt;span class="c"&gt;# Install from App Store / Play Store, log in&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Once Tailscale is running, every device gets a &lt;code&gt;100.x.x.x&lt;/code&gt; IP. I reach my server by its hostname from anywhere in the world — no DDNS, no port forwarding, no Cloudflare tunnels. It survives reboots automatically. If the server restarts, Tailscale reconnects without manual intervention.&lt;/p&gt;

&lt;h3&gt;
  
  
  Verification Checklist
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Test&lt;/th&gt;
&lt;th&gt;Command&lt;/th&gt;
&lt;th&gt;Expected&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Device visibility&lt;/td&gt;
&lt;td&gt;&lt;code&gt;tailscale status&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;All 4-5 devices listed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Network connectivity&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ping 100.x.x.x&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Replies from server&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SSH over mesh&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ssh ohara&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Login prompt from anywhere&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;File transfer&lt;/td&gt;
&lt;td&gt;&lt;code&gt;rsync -avz test.txt ohara:/tmp/&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;File arrives&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Docker Compose: The Infrastructure as Code
&lt;/h2&gt;

&lt;p&gt;Every service runs in a container defined by a &lt;code&gt;docker-compose.yml&lt;/code&gt;. The entire server can be rebuilt from these files in minutes.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Main Compose File
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# /srv/appdata/docker-compose.yml&lt;/span&gt;
&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;jellyfin&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;jellyfin/jellyfin:latest&lt;/span&gt;
    &lt;span class="na"&gt;container_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;jellyfin&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;8096:8096"&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/srv/appdata/jellyfin:/config&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/srv/media:/media:ro&lt;/span&gt;
    &lt;span class="na"&gt;devices&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/dev/dri:/dev/dri&lt;/span&gt;  &lt;span class="c1"&gt;# hardware transcoding (if available)&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Suwayomi: The Manga Server
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://github.com/Suwayomi/Suwayomi-Server" rel="noopener noreferrer"&gt;Suwayomi&lt;/a&gt; is a self-hosted manga reader server. It is a rewrite of &lt;strong&gt;Tachiyomi&lt;/strong&gt; (the popular Android manga reader) for the server — it downloads chapters, stores them on disk, and serves them to clients over the web. The workflow is simple:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Browse and add manga from your phone via the WebUI&lt;/li&gt;
&lt;li&gt;Suwayomi downloads the chapters to &lt;code&gt;/srv/media/manga/&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Read from any device — progress syncs automatically&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The Docker Compose for Suwayomi lives alongside Jellyfin in my setup:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Suwayomi service — add to /srv/appdata/docker-compose.yml&lt;/span&gt;
&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;suwayomi&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ghcr.io/suwayomi/suwayomi-server:latest&lt;/span&gt;
    &lt;span class="na"&gt;container_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;suwayomi&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;4567:4567"&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;SUWAYOMI_USER=prateek&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;SUWAYOMI_PASSWORD=your_secure_password_here&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/srv/appdata/suwayomi:/home/suwayomi/.local/share/Suwayomi&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/srv/media/manga:/manga&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Start it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cd&lt;/span&gt; /srv/appdata
docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt; suwayomi

&lt;span class="c"&gt;# Check it's running&lt;/span&gt;
docker compose ps

&lt;span class="c"&gt;# Watch logs&lt;/span&gt;
docker compose logs &lt;span class="nt"&gt;-f&lt;/span&gt; suwayomi
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h4&gt;
  
  
  Adding Manga Sources (Extensions)
&lt;/h4&gt;

&lt;p&gt;Suwayomi is compatible with the &lt;strong&gt;Tachiyomi/Mihon extension ecosystem&lt;/strong&gt;, but the way you add sources is different from the Android app. Extensions are added from &lt;em&gt;inside&lt;/em&gt; the Suwayomi WebUI, not from the Docker config. This separation is intentional — infrastructure stays clean, and application config stays flexible.&lt;/p&gt;

&lt;p&gt;Here is the important backstory. The original &lt;strong&gt;Tachiyomi&lt;/strong&gt; project was discontinued. The community forked it into &lt;strong&gt;Mihon&lt;/strong&gt; (the Android reader), and the extension ecosystem moved to &lt;strong&gt;Keiyoushi&lt;/strong&gt; — a community-maintained repository of manga sources. Suwayomi can use this same repository.&lt;/p&gt;

&lt;p&gt;I have both repos starred on GitHub:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Repo&lt;/th&gt;
&lt;th&gt;What it is&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/Suwayomi/Suwayomi-Server" rel="noopener noreferrer"&gt;&lt;code&gt;Suwayomi/Suwayomi-Server&lt;/code&gt;&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;The server itself — a rewrite of Tachiyomi for desktop/server&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://github.com/keiyoushi/extensions" rel="noopener noreferrer"&gt;&lt;code&gt;keiyoushi/extensions&lt;/code&gt;&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Extension repository for Mihon and Suwayomi — sources like MangaDex, MangaSee, etc.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;To add sources to Suwayomi:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Step 1: Open the WebUI&lt;/span&gt;
&lt;span class="c"&gt;# In your browser (over Tailscale):&lt;/span&gt;
&lt;span class="c"&gt;# http://ohara:4567&lt;/span&gt;
&lt;span class="c"&gt;# or http://100.x.x.x:4567&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;Log in with the credentials you set in &lt;code&gt;docker-compose.yml&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Go to &lt;strong&gt;Extensions&lt;/strong&gt; → &lt;strong&gt;Repositories&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Add the Keiyoushi extensions repository URL:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;   https://keiyoushi.github.io/extensions/index.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;Click &lt;strong&gt;Save&lt;/strong&gt; — the repository is now registered&lt;/li&gt;
&lt;li&gt;Go to &lt;strong&gt;Extensions&lt;/strong&gt; → &lt;strong&gt;All&lt;/strong&gt;, refresh the list&lt;/li&gt;
&lt;li&gt;You should now see sources like &lt;strong&gt;MangaDex&lt;/strong&gt;, &lt;strong&gt;MangaSee&lt;/strong&gt;, &lt;strong&gt;Comick&lt;/strong&gt; available for install&lt;/li&gt;
&lt;li&gt;Click &lt;strong&gt;Install&lt;/strong&gt; on each source you want&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2Fpako%3AeNqFUk1rwkAQ_SvDniwYcw_Fy8ZCsUJxWxSSHsZkNEuT3bAfVhH_e1fT2oQWOqeZ994Mb2b3xApdEkvYzmBbwdMyVxDC-k0H5Ez4DzzqRuasoy7BM0FmTwa4Vg6lIvP2Q4psFOCt3N1vTDyNrdnH2LYlOozt16y7nnyRjRaodggPsib709NQKTFuLtS3nFTZJb9M8lqScrbvcZUJ3KKRsKLN6yNEIJ8rrejfUUJ7U5CFvUSYkzxqb6vB8vPsBl_dzg6OlJVaWVhSq_urpdl1tZQOfVR0qCDqoXyd8XCZ4v1vgxyiaAqiXyy6YgXRBF5Q1rbAmmASGN4x86uMD3rSQTWYx9dszBoyDcoyfIhTzlxFDeUsCVdR5J3BOmfnIPKX16RZKZ02LNlibWnM0Dstjqq4AZ0qlRgu27DEGU_nT6sJurc" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2Fpako%3AeNqFUk1rwkAQ_SvDniwYcw_Fy8ZCsUJxWxSSHsZkNEuT3bAfVhH_e1fT2oQWOqeZ994Mb2b3xApdEkvYzmBbwdMyVxDC-k0H5Ez4DzzqRuasoy7BM0FmTwa4Vg6lIvP2Q4psFOCt3N1vTDyNrdnH2LYlOozt16y7nnyRjRaodggPsib709NQKTFuLtS3nFTZJb9M8lqScrbvcZUJ3KKRsKLN6yNEIJ8rrejfUUJ7U5CFvUSYkzxqb6vB8vPsBl_dzg6OlJVaWVhSq_urpdl1tZQOfVR0qCDqoXyd8XCZ4v1vgxyiaAqiXyy6YgXRBF5Q1rbAmmASGN4x86uMD3rSQTWYx9dszBoyDcoyfIhTzlxFDeUsCVdR5J3BOmfnIPKX16RZKZ02LNlibWnM0Dstjqq4AZ0qlRgu27DEGU_nT6sJurc" alt="Architecture diagram" width="856" height="698"&gt;&lt;/a&gt;&lt;br&gt;
Once installed, search for manga, add to your library, and enable &lt;strong&gt;auto-download&lt;/strong&gt; for new chapters. The server becomes largely autonomous — new chapters are downloaded in the background, and your library updates on a schedule.&lt;/p&gt;
&lt;h3&gt;
  
  
  Jellyfin: Movies, TV, and Music
&lt;/h3&gt;

&lt;p&gt;Jellyfin is my unified media server. From the single docker-compose above, I get:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Movies &amp;amp; TV&lt;/strong&gt; — organized by metadata scrapers, streamed to any device&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Music&lt;/strong&gt; — with album art, artist info, and gapless playback&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Live TV&lt;/strong&gt; (optional) — if you plug in a tuner&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The library structure maps directly to &lt;code&gt;/srv/media/&lt;/code&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Library Type&lt;/th&gt;
&lt;th&gt;Path in Container&lt;/th&gt;
&lt;th&gt;Path on Disk&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Movies&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/media/movies&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/srv/media/movies&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;TV Shows&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/media/tv&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/srv/media/tv&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Music&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/media/music&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/srv/media/music&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Inside Jellyfin WebUI, add each library:&lt;/span&gt;
&lt;span class="c"&gt;# Dashboard → Libraries → Add Media Library&lt;/span&gt;
&lt;span class="c"&gt;# Type: Movies → Folder: /media/movies&lt;/span&gt;
&lt;span class="c"&gt;# Type: TV Shows → Folder: /media/tv&lt;/span&gt;
&lt;span class="c"&gt;# Type: Music → Folder: /media/music&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Configure the "Prefer ARTISTS tag if available" setting under the Music library for correct artist metadata.&lt;/p&gt;
&lt;h2&gt;
  
  
  How I Consume Everything Across Devices
&lt;/h2&gt;

&lt;p&gt;The best part of this setup is that content follows me everywhere. Here is exactly how each device connects:&lt;/p&gt;
&lt;h3&gt;
  
  
  iPhone
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2Fpako%3AeNptUF1Lw0AQ_CvLPrcqRasGKRQ_0OKDuJE8JD5ccxt7cB_heqeU0v_u1bS2QffpZmeWmZs11k4yZvjhRbuA59fKQpplnHeLCtXLwlmGW63YBiAOsa2wU21nNipnrPWqURambXsz96eTxjuzBUDBeX4_iGlUkmiEV1Dw_O3pRzyVkiUEB4_OMFDtme3uhK3sHn9CEftP9r0cD4cc2dXZ9fjYtigpfomVMwqy84vx5RGXU5kLpZe10Py_7WwEw-EkGXSQOkhFj82px-5hTjA8OTreYypwgIa9EUqm8tcVhgUbrjBLn7Mcgxe6wk0SxVaKwPdSpSoxa4Re8gBFDI5Wtv5ddKo7JVJBBrPgI2--Af7AjDk" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2Fpako%3AeNptUF1Lw0AQ_CvLPrcqRasGKRQ_0OKDuJE8JD5ccxt7cB_heqeU0v_u1bS2QffpZmeWmZs11k4yZvjhRbuA59fKQpplnHeLCtXLwlmGW63YBiAOsa2wU21nNipnrPWqURambXsz96eTxjuzBUDBeX4_iGlUkmiEV1Dw_O3pRzyVkiUEB4_OMFDtme3uhK3sHn9CEftP9r0cD4cc2dXZ9fjYtigpfomVMwqy84vx5RGXU5kLpZe10Py_7WwEw-EkGXSQOkhFj82px-5hTjA8OTreYypwgIa9EUqm8tcVhgUbrjBLn7Mcgxe6wk0SxVaKwPdSpSoxa4Re8gBFDI5Wtv5ddKo7JVJBBrPgI2--Af7AjDk" alt="Architecture diagram" width="742" height="300"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Jellyfin&lt;/strong&gt;: Install the Jellyfin Mobile app from the App Store, sign in with &lt;code&gt;http://ohara:8096&lt;/code&gt; (the Tailscale MagicDNS hostname resolves over the mesh)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Manga (Suwayomi)&lt;/strong&gt;: Open Safari → &lt;code&gt;http://ohara:4567&lt;/code&gt; → log in → tap the Share button → &lt;strong&gt;Add to Home Screen&lt;/strong&gt;. It behaves like a native app: full-screen, dedicated icon, persistent session&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tailscale&lt;/strong&gt;: Always-on in the background. Without it, none of the above works outside my home WiFi&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;
  
  
  Android TV
&lt;/h3&gt;

&lt;p&gt;My TV runs Android TV, which does not come with Tailscale or Jellyfin pre-installed. You can sideload both via ADB:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 1. Enable Developer Options on TV&lt;/span&gt;
&lt;span class="c"&gt;# Settings → Device Preferences → About → Build → tap 7 times&lt;/span&gt;

&lt;span class="c"&gt;# 2. Enable USB Debugging&lt;/span&gt;
&lt;span class="c"&gt;# Settings → Developer Options → USB Debugging → ON&lt;/span&gt;

&lt;span class="c"&gt;# 3. Find TV IP from network settings&lt;/span&gt;
&lt;span class="c"&gt;# Example: 192.168.1.100&lt;/span&gt;

&lt;span class="c"&gt;# 4. On your Mac/Laptop, connect and install&lt;/span&gt;
adb connect 192.168.1.100
adb &lt;span class="nb"&gt;install &lt;/span&gt;jellyfin-androidtv.apk
adb &lt;span class="nb"&gt;install &lt;/span&gt;tailscale.apk

&lt;span class="c"&gt;# 5. On the TV, open Tailscale → log in&lt;/span&gt;
&lt;span class="c"&gt;# 6. Open Jellyfin → sign in with http://ohara:8096&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Once set up, the TV works outside home too — take it to a different city, connect to WiFi, Tailscale reconnects automatically and Jellyfin works.&lt;/p&gt;

&lt;h3&gt;
  
  
  MacBook
&lt;/h3&gt;

&lt;p&gt;The MacBook is my management machine. I use it for:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# SSH into the server from anywhere&lt;/span&gt;
ssh ohara

&lt;span class="c"&gt;# Rsync backups&lt;/span&gt;
rsync &lt;span class="nt"&gt;-avz&lt;/span&gt; &lt;span class="nt"&gt;--progress&lt;/span&gt; ohara:/srv/media/manga/ ~/ohara-backup/manga/

&lt;span class="c"&gt;# Edit compose files remotely&lt;/span&gt;
ssh ohara &lt;span class="s2"&gt;"vim /srv/appdata/docker-compose.yml"&lt;/span&gt;

&lt;span class="c"&gt;# Rebuild containers after config changes&lt;/span&gt;
ssh ohara &lt;span class="s2"&gt;"cd /srv/appdata &amp;amp;&amp;amp; docker compose up -d"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For media consumption, I use the Jellyfin WebUI in a browser. There are desktop clients (Jellyfin Media Player, Swiftfin) but the WebUI is good enough for occasional viewing.&lt;/p&gt;

&lt;h3&gt;
  
  
  Windows Laptop
&lt;/h3&gt;

&lt;p&gt;The Windows laptop serves as a secondary backup target. Over Tailscale:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Pull backup from server&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="n"&gt;rsync&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-avz&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;prateek&lt;/span&gt;&lt;span class="err"&gt;@&lt;/span&gt;&lt;span class="nx"&gt;ohara:/srv/media/manga/&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;D:\ohara-backup\manga\&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;span class="c"&gt;# Push files to server&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="n"&gt;rsync&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-avz&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;D:\documents\&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;prateek&lt;/span&gt;&lt;span class="err"&gt;@&lt;/span&gt;&lt;span class="nx"&gt;ohara:/srv/media/documents\&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No SMB shares, no mapped drives, no exposed ports — just SSH and rsync over the encrypted mesh.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Complete Architecture
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2Fpako%3AeNqFU1Fv2jAQ_iuWXwdlD1u3oakStEMFkQ2RlDwkfTjig1gkvshxQKjqf68TExropN5L7M_f3X1nf3nhCQnkQ77VUKQsGMeK2SirtQNi_kg5sr9oDqR3MXfHdSyjJVUG9TPr9--YHwaRf5AmSZ_fKRZ0h9G_FDQwH_Ue9e-1Htw9rStlKvaFPVCys0VcEirhFh9UOBq7J2VAKtRlV4qQGhMjSZ311zGbRDPMsuNGqqblgrRhP7_-ur1QGPnVAY6Uy3fOt--3Pz4V5BvSsMWuDC8alHo_yFFIaKp5tJdY9liw6jGvKmViP6C2MOgoGLkkKAoBxqXZITdyW9rL8dBADQ8-lROAzMoEMrQ5Zfq_9_LGkQfJmGjXNjGaMttkDMmuKjqSpotILlJS6Iaox6m11MrZfSZRmQ45WEUjJTRJYZdNwlzupdqyJVHOFhkcz69bRziPQqkEHUo2h8JQ0aT4mJASoI-XYq7G9RszOSc45OSKGp5NPkB-6KDZpNl6pyphd3c6G12cjbpdvTHr39QP7j_aW9DlUSUxZzd1fUeYLhyhNRsbFcWJ0YpqKa3XWIjrp2lb5iQzWF3V2ZC24FWpcO5YF0J4j-eoc5DC_skvMTcp5taaQ8tTWBkNWcxfLamqTYZ_hLTm5cMNZCX2OFSGfFvsDDjWgwRrrpwPja7w9Q0G4TwE" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2Fpako%3AeNqFU1Fv2jAQ_iuWXwdlD1u3oakStEMFkQ2RlDwkfTjig1gkvshxQKjqf68TExropN5L7M_f3X1nf3nhCQnkQ77VUKQsGMeK2SirtQNi_kg5sr9oDqR3MXfHdSyjJVUG9TPr9--YHwaRf5AmSZ_fKRZ0h9G_FDQwH_Ue9e-1Htw9rStlKvaFPVCys0VcEirhFh9UOBq7J2VAKtRlV4qQGhMjSZ311zGbRDPMsuNGqqblgrRhP7_-ur1QGPnVAY6Uy3fOt--3Pz4V5BvSsMWuDC8alHo_yFFIaKp5tJdY9liw6jGvKmViP6C2MOgoGLkkKAoBxqXZITdyW9rL8dBADQ8-lROAzMoEMrQ5Zfq_9_LGkQfJmGjXNjGaMttkDMmuKjqSpotILlJS6Iaox6m11MrZfSZRmQ45WEUjJTRJYZdNwlzupdqyJVHOFhkcz69bRziPQqkEHUo2h8JQ0aT4mJASoI-XYq7G9RszOSc45OSKGp5NPkB-6KDZpNl6pyphd3c6G12cjbpdvTHr39QP7j_aW9DlUSUxZzd1fUeYLhyhNRsbFcWJ0YpqKa3XWIjrp2lb5iQzWF3V2ZC24FWpcO5YF0J4j-eoc5DC_skvMTcp5taaQ8tTWBkNWcxfLamqTYZ_hLTm5cMNZCX2OFSGfFvsDDjWgwRrrpwPja7w9Q0G4TwE" alt="Architecture diagram" width="1325" height="919"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Backup Strategy
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# From Mac — pull a full media backup&lt;/span&gt;
rsync &lt;span class="nt"&gt;-avz&lt;/span&gt; &lt;span class="nt"&gt;--progress&lt;/span&gt; ohara:/srv/media/ ~/ohara-backup/

&lt;span class="c"&gt;# From Windows — mirror to external drive&lt;/span&gt;
rsync &lt;span class="nt"&gt;-avz&lt;/span&gt; prateek@ohara:/srv/media/manga/ D:&lt;span class="se"&gt;\o&lt;/span&gt;hara-backup&lt;span class="se"&gt;\m&lt;/span&gt;anga&lt;span class="se"&gt;\&lt;/span&gt;

&lt;span class="c"&gt;# From server itself — backup to USB drive&lt;/span&gt;
rsync &lt;span class="nt"&gt;-avz&lt;/span&gt; /srv/appdata/ /mnt/usb-backup/appdata/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three golden rules:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Always use &lt;code&gt;--dry-run&lt;/code&gt; first&lt;/strong&gt; — rsync is silent when it works and destructive when it doesn't&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Trailing slashes matter&lt;/strong&gt; — &lt;code&gt;ohara:/srv/media/&lt;/code&gt; copies contents, &lt;code&gt;ohara:/srv/media&lt;/code&gt; copies the folder itself&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No SMB needed&lt;/strong&gt; — SSH + rsync is cleaner, scriptable, and works over Tailscale from anywhere&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  What Running This Feels Like Day to Day
&lt;/h2&gt;

&lt;p&gt;The server is invisible. It sits under my desk, silent, doing its job. Here is what a typical day looks like:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Morning&lt;/strong&gt;: New manga chapters were downloaded overnight (Suwayomi auto-updates at 3 AM)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Afternoon&lt;/strong&gt;: I start a movie on the TV via Jellyfin, pause it, continue on my phone in another room&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Evening&lt;/strong&gt;: I discover a new manga on my phone, add it to my library via Suwayomi WebUI&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Night&lt;/strong&gt;: An automated rsync cron job backs up appdata to an external drive&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The only time I SSH into the server is when I add a new service or update a container. Everything else runs itself.&lt;/p&gt;

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

&lt;p&gt;In &lt;em&gt;One Piece&lt;/em&gt;, Ohara was the island of scholars — a place where knowledge was pursued, preserved, and archived. The World Government destroyed it for holding too much knowledge, but the legacy survived through the survivors who carried books in their memory.&lt;/p&gt;

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

&lt;p&gt;A home server is a much smaller act of preservation. But it is the same impulse: keep your own things on your own hardware, under your own control. Your media library, your manga collection, your files — they should outlive any streaming service's licensing decisions or any cloud provider's terms of service changes.&lt;/p&gt;

&lt;p&gt;You do not need a data hoarding disorder to want that. You just need an old PC, a weekend, and the willingness to &lt;code&gt;ssh&lt;/code&gt; into a box that lives under your desk.&lt;/p&gt;

&lt;h2&gt;
  
  
  What You Will Have at the End
&lt;/h2&gt;

&lt;p&gt;✅ A 24/7 headless server that needs no monitor, keyboard, or mouse&lt;br&gt;&lt;br&gt;
✅ Remote access from anywhere via Tailscale — no ports open to the internet&lt;br&gt;&lt;br&gt;
✅ A full media server (Jellyfin) for movies, TV, and music&lt;br&gt;&lt;br&gt;
✅ A self-hosted manga server (Suwayomi) with auto-downloads and cross-device sync&lt;br&gt;&lt;br&gt;
✅ Clients working on iPhone, Android TV, Mac, and Windows&lt;br&gt;&lt;br&gt;
✅ An automated backup pipeline over SSH&lt;br&gt;&lt;br&gt;
✅ Infrastructure-as-code with Docker Compose — rebuild the entire server from a single YAML file&lt;/p&gt;

&lt;p&gt;The barrier to entry for homelabbing has never been lower. Ubuntu Server is free. Docker Compose turns deployment into a single YAML file. Tailscale makes networking trivially secure. All you need is the hardware you already have lying around, gathering dust.&lt;/p&gt;

&lt;p&gt;Go build your Ohara.&lt;/p&gt;




&lt;p&gt;If I have used any images that need attribution, please &lt;a href="mailto:prateek11rai@protonmail.com"&gt;contact&lt;/a&gt; me.&lt;/p&gt;

</description>
      <category>selfhosting</category>
      <category>homelab</category>
      <category>jellyfin</category>
      <category>tailscale</category>
    </item>
  </channel>
</rss>
