<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Build a Homelab</title><description>Step-by-step guides for building a homelab from scratch: Proxmox VE setup, LXC containers, self-hosted AI inference with Ollama, Grafana monitoring, Tailscale networking, media servers (Plex, Jellyfin), and the *arr stack — documented from a working 4-node mini PC cluster.</description><link>https://peira.dev/</link><language>en-us</language><item><title>RAG for Your Homelab Wiki: Answers That Cite Their Source</title><link>https://peira.dev/blog/homelab-wiki-rag-cited-answers/</link><guid isPermaLink="true">https://peira.dev/blog/homelab-wiki-rag-cited-answers/</guid><description>Turn your homelab wiki into a cited Q&amp;A endpoint with dependency-free Node: retrieve-then-generate, every answer naming its source page, stale ones labelled.</description><pubDate>Fri, 31 Jul 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;I have written a lot of notes about my homelab, and for a long time the only way to use them was to remember they existed. Semantic search fixed the finding problem — but I still had to read four pages to answer one question. This post adds the last mile: a small service that reads those pages for me and answers in a sentence, with the sources listed underneath so I can check its work.&lt;/p&gt;
&lt;div class=&quot;callout callout-tip&quot; data-astro-cid-q2ml7llr&gt;&lt;div class=&quot;callout-header&quot; data-astro-cid-q2ml7llr&gt;&lt;span class=&quot;callout-icon&quot; aria-hidden=&quot;true&quot; data-astro-cid-q2ml7llr&gt;💡&lt;/span&gt;&lt;span class=&quot;callout-label&quot; data-astro-cid-q2ml7llr&gt;The principle&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;callout-body&quot; data-astro-cid-q2ml7llr&gt;&lt;p&gt;An answer without a source is a rumour. Retrieval picks the passages, the model writes the prose, and the response carries the page names and the exact revision it all came from. If that revision is behind your wiki, the answer says so — out of date is fine, out of date and quiet is not.&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;
&lt;div class=&quot;callout callout-warning&quot; data-astro-cid-q2ml7llr&gt;&lt;div class=&quot;callout-header&quot; data-astro-cid-q2ml7llr&gt;&lt;span class=&quot;callout-icon&quot; aria-hidden=&quot;true&quot; data-astro-cid-q2ml7llr&gt;⚠️&lt;/span&gt;&lt;span class=&quot;callout-label&quot; data-astro-cid-q2ml7llr&gt;First: make these values your own&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;callout-body&quot; data-astro-cid-q2ml7llr&gt;&lt;p&gt;Every specific value below is a stand-in. Swap &lt;strong&gt;&lt;code&gt;10.0.0.65&lt;/code&gt;/&lt;code&gt;.66&lt;/code&gt;/&lt;code&gt;.67&lt;/code&gt;&lt;/strong&gt; for your own Ollama node addresses (one host is fine — point all three at it), &lt;strong&gt;&lt;code&gt;10.0.0.76&lt;/code&gt;&lt;/strong&gt; for whatever machine runs this service, &lt;strong&gt;&lt;code&gt;9113&lt;/code&gt;&lt;/strong&gt; for any free port, &lt;strong&gt;&lt;code&gt;/wiki-data/git&lt;/code&gt;&lt;/strong&gt; and &lt;code&gt;/wiki-data/embeddings-index&lt;/code&gt; for your own repository and index paths, and &lt;strong&gt;&lt;code&gt;YOUR_API_KEY&lt;/code&gt;&lt;/strong&gt; for a real key kept in your secret store — never pasted into a note. Rule of thumb: if a value looks specific to one machine, it is a placeholder to change, not a literal to copy.&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;what-retrieval-already-gave-me-and-what-was-missing&quot;&gt;What retrieval already gave me, and what was missing&lt;/h2&gt;
&lt;p&gt;The &lt;a href=&quot;https://peira.dev/blog/second-brain-local-rag-embeddings/&quot;&gt;local embeddings index&lt;/a&gt; from earlier in this series turns every section of every page into a vector — a list of numbers that captures roughly what the passage &lt;em&gt;means&lt;/em&gt;, so a search for “boot repair” finds the page titled “Recover GRUB after a failed EFI update” even though they share no words. Ask it a question and you get back a ranked list of sections.&lt;/p&gt;
&lt;p&gt;That is the &lt;strong&gt;retrieval&lt;/strong&gt; half of retrieval-augmented generation, the technique introduced by &lt;a href=&quot;https://arxiv.org/abs/2005.11401&quot;&gt;Lewis et al. (2020)&lt;/a&gt; — pairing a language model with a searchable index instead of relying on what the model memorised during training. The &lt;strong&gt;generation&lt;/strong&gt; half is what I was doing by hand: reading the top few hits and synthesising an answer.&lt;/p&gt;
&lt;p&gt;So the whole job is a short pipeline. Embed the question, rank the sections, hand the winners to a model with strict instructions, and label the result.&lt;/p&gt;
&lt;svg viewBox=&quot;0 0 720 300&quot; width=&quot;100%&quot; role=&quot;img&quot; aria-label=&quot;The retrieve-then-generate pipeline. A question enters and is embedded using the same search query prefix used when the documents were indexed. Cosine ranking over the cached index picks the top sections along with their full text. Those numbered excerpts go to a language model under a system prompt that forbids answering from anything else. The answer comes back with numbered citations, and a labelling stage stamps every response with the index revision, the live wiki revision, and a stale flag when the two differ.&quot;&gt;&lt;rect width=&quot;720&quot; height=&quot;300&quot; rx=&quot;12&quot; fill=&quot;#1c1c32&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;360&quot; y=&quot;30&quot; text-anchor=&quot;middle&quot; fill=&quot;#f4f4f5&quot; font-size=&quot;15&quot; font-weight=&quot;bold&quot;&gt;Retrieve, then generate — and label what came out&lt;/text&gt;&lt;rect x=&quot;24&quot; y=&quot;62&quot; width=&quot;138&quot; height=&quot;92&quot; rx=&quot;10&quot; fill=&quot;#2c2c4c&quot; stroke=&quot;#3d3d5c&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;93&quot; y=&quot;88&quot; text-anchor=&quot;middle&quot; fill=&quot;#14b8a6&quot; font-size=&quot;12&quot; font-weight=&quot;bold&quot;&gt;embed the question&lt;/text&gt;&lt;text x=&quot;93&quot; y=&quot;112&quot; text-anchor=&quot;middle&quot; fill=&quot;#e5e7eb&quot; font-size=&quot;10&quot;&gt;search_query: prefix&lt;/text&gt;&lt;text x=&quot;93&quot; y=&quot;130&quot; text-anchor=&quot;middle&quot; fill=&quot;#a1a1aa&quot; font-size=&quot;10&quot;&gt;same model as the docs&lt;/text&gt;&lt;rect x=&quot;202&quot; y=&quot;62&quot; width=&quot;138&quot; height=&quot;92&quot; rx=&quot;10&quot; fill=&quot;#2c2c4c&quot; stroke=&quot;#3d3d5c&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;271&quot; y=&quot;88&quot; text-anchor=&quot;middle&quot; fill=&quot;#14b8a6&quot; font-size=&quot;12&quot; font-weight=&quot;bold&quot;&gt;rank in memory&lt;/text&gt;&lt;text x=&quot;271&quot; y=&quot;112&quot; text-anchor=&quot;middle&quot; fill=&quot;#e5e7eb&quot; font-size=&quot;10&quot;&gt;cosine over the index&lt;/text&gt;&lt;text x=&quot;271&quot; y=&quot;130&quot; text-anchor=&quot;middle&quot; fill=&quot;#a1a1aa&quot; font-size=&quot;10&quot;&gt;keep the section text&lt;/text&gt;&lt;rect x=&quot;380&quot; y=&quot;62&quot; width=&quot;138&quot; height=&quot;92&quot; rx=&quot;10&quot; fill=&quot;#2c2c4c&quot; stroke=&quot;#3d3d5c&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;449&quot; y=&quot;88&quot; text-anchor=&quot;middle&quot; fill=&quot;#14b8a6&quot; font-size=&quot;12&quot; font-weight=&quot;bold&quot;&gt;generate&lt;/text&gt;&lt;text x=&quot;449&quot; y=&quot;112&quot; text-anchor=&quot;middle&quot; fill=&quot;#e5e7eb&quot; font-size=&quot;10&quot;&gt;answer ONLY from these&lt;/text&gt;&lt;text x=&quot;449&quot; y=&quot;130&quot; text-anchor=&quot;middle&quot; fill=&quot;#a1a1aa&quot; font-size=&quot;10&quot;&gt;cite excerpts as [n]&lt;/text&gt;&lt;rect x=&quot;558&quot; y=&quot;62&quot; width=&quot;138&quot; height=&quot;92&quot; rx=&quot;10&quot; fill=&quot;#2c2c4c&quot; stroke=&quot;#3d3d5c&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;627&quot; y=&quot;88&quot; text-anchor=&quot;middle&quot; fill=&quot;#14b8a6&quot; font-size=&quot;12&quot; font-weight=&quot;bold&quot;&gt;label&lt;/text&gt;&lt;text x=&quot;627&quot; y=&quot;112&quot; text-anchor=&quot;middle&quot; fill=&quot;#e5e7eb&quot; font-size=&quot;10&quot;&gt;index vs live revision&lt;/text&gt;&lt;text x=&quot;627&quot; y=&quot;130&quot; text-anchor=&quot;middle&quot; fill=&quot;#a1a1aa&quot; font-size=&quot;10&quot;&gt;stale = say so&lt;/text&gt;&lt;path d=&quot;M 178 108 L 200 108&quot; stroke=&quot;#818cf8&quot; stroke-width=&quot;2&quot; fill=&quot;none&quot; marker-end=&quot;url(#rag-arrow)&quot;&gt;&lt;/path&gt;&lt;path d=&quot;M 356 108 L 378 108&quot; stroke=&quot;#818cf8&quot; stroke-width=&quot;2&quot; fill=&quot;none&quot; marker-end=&quot;url(#rag-arrow)&quot;&gt;&lt;/path&gt;&lt;path d=&quot;M 534 108 L 556 108&quot; stroke=&quot;#818cf8&quot; stroke-width=&quot;2&quot; fill=&quot;none&quot; marker-end=&quot;url(#rag-arrow)&quot;&gt;&lt;/path&gt;&lt;defs&gt;&lt;marker id=&quot;rag-arrow&quot; markerWidth=&quot;7&quot; markerHeight=&quot;7&quot; refX=&quot;6&quot; refY=&quot;3.5&quot; orient=&quot;auto&quot;&gt;&lt;path d=&quot;M 0 0 L 7 3.5 L 0 7 z&quot; fill=&quot;#818cf8&quot;&gt;&lt;/path&gt;&lt;/marker&gt;&lt;/defs&gt;&lt;rect x=&quot;24&quot; y=&quot;182&quot; width=&quot;316&quot; height=&quot;86&quot; rx=&quot;10&quot; fill=&quot;#242440&quot; stroke=&quot;#3d3d5c&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;182&quot; y=&quot;206&quot; text-anchor=&quot;middle&quot; fill=&quot;#a1a1aa&quot; font-size=&quot;11&quot;&gt;what the caller gets back&lt;/text&gt;&lt;text x=&quot;182&quot; y=&quot;228&quot; text-anchor=&quot;middle&quot; fill=&quot;#f4f4f5&quot; font-size=&quot;11&quot;&gt;a short answer with [1] [2] markers&lt;/text&gt;&lt;text x=&quot;182&quot; y=&quot;248&quot; text-anchor=&quot;middle&quot; fill=&quot;#f4f4f5&quot; font-size=&quot;11&quot;&gt;+ the page and heading behind each one&lt;/text&gt;&lt;rect x=&quot;380&quot; y=&quot;182&quot; width=&quot;316&quot; height=&quot;86&quot; rx=&quot;10&quot; fill=&quot;#242440&quot; stroke=&quot;#f59e0b&quot; stroke-width=&quot;1.5&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;538&quot; y=&quot;206&quot; text-anchor=&quot;middle&quot; fill=&quot;#f59e0b&quot; font-size=&quot;11&quot; font-weight=&quot;bold&quot;&gt;the part people skip&lt;/text&gt;&lt;text x=&quot;538&quot; y=&quot;228&quot; text-anchor=&quot;middle&quot; fill=&quot;#f4f4f5&quot; font-size=&quot;11&quot;&gt;index revision · live revision · stale?&lt;/text&gt;&lt;text x=&quot;538&quot; y=&quot;248&quot; text-anchor=&quot;middle&quot; fill=&quot;#a1a1aa&quot; font-size=&quot;11&quot;&gt;a lagging index that admits it is harmless&lt;/text&gt;&lt;/svg&gt;
&lt;p&gt;The service that does this is about 250 lines of Node with no dependencies at all — the standard library has an HTTP server and &lt;code&gt;fetch&lt;/code&gt;, and everything else is arithmetic. It runs beside the index it reads, on the same small container that hosts the wiki.&lt;/p&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;the-three-parts-worth-getting-right&quot;&gt;The three parts worth getting right&lt;/h2&gt;
&lt;div class=&quot;step&quot; data-astro-cid-wwaqy5cd&gt;&lt;div class=&quot;step-header&quot; data-astro-cid-wwaqy5cd&gt;&lt;span class=&quot;step-num&quot; data-astro-cid-wwaqy5cd&gt;1&lt;/span&gt;&lt;span class=&quot;step-title&quot; data-astro-cid-wwaqy5cd&gt;Embed the question the same way you embedded the pages&lt;/span&gt;&lt;span class=&quot;step-time&quot; data-astro-cid-wwaqy5cd&gt;10 min&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;step-body&quot; data-astro-cid-wwaqy5cd&gt;&lt;p&gt;This is the step that quietly breaks things. &lt;a href=&quot;https://ollama.com/library/nomic-embed-text&quot;&gt;nomic-embed-text&lt;/a&gt; — the model doing the embedding — expects a short &lt;strong&gt;task prefix&lt;/strong&gt; on every string, and the prefix differs by job: Nomic’s documentation defines &lt;code&gt;search_query&lt;/code&gt; as “a query for retrieval” and &lt;code&gt;search_document&lt;/code&gt; as “a document for retrieval,” and tells you to embed documents one way and user queries the other.&lt;/p&gt;&lt;p&gt;Get this backwards and nothing errors. You just get slightly worse results forever, which is a much harder bug to notice than a crash.&lt;/p&gt;&lt;figure class=&quot;terminal&quot; data-astro-cid-kenkipkp&gt;&lt;figcaption class=&quot;terminal-bar&quot; data-astro-cid-kenkipkp&gt;&lt;span class=&quot;dot red&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot yellow&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot green&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;terminal-title&quot; data-astro-cid-kenkipkp&gt;Embed a question, on any Ollama node&lt;/span&gt;&lt;button class=&quot;terminal-copy&quot; type=&quot;button&quot; aria-label=&quot;Copy commands&quot; data-astro-cid-kenkipkp&gt;&lt;svg width=&quot;13&quot; height=&quot;13&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;none&quot; stroke=&quot;currentColor&quot; stroke-width=&quot;2&quot; stroke-linecap=&quot;round&quot; stroke-linejoin=&quot;round&quot; aria-hidden=&quot;true&quot; data-astro-cid-kenkipkp&gt;&lt;rect x=&quot;9&quot; y=&quot;9&quot; width=&quot;13&quot; height=&quot;13&quot; rx=&quot;2&quot; data-astro-cid-kenkipkp&gt;&lt;/rect&gt;&lt;path d=&quot;M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1&quot; data-astro-cid-kenkipkp&gt;&lt;/path&gt;&lt;/svg&gt;&lt;span class=&quot;terminal-copy-label&quot; data-astro-cid-kenkipkp&gt;Copy&lt;/span&gt;&lt;/button&gt;&lt;/figcaption&gt;&lt;pre class=&quot;terminal-body&quot; data-astro-cid-kenkipkp&gt;&lt;code data-astro-cid-kenkipkp&gt;
curl -s http://10.0.0.65:11434/api/embed -d &amp;#39;{
&amp;quot;model&amp;quot;: &amp;quot;nomic-embed-text&amp;quot;,
&amp;quot;input&amp;quot;: &amp;quot;search_query: how do I recover a locked keyring&amp;quot;,
&amp;quot;truncate&amp;quot;: true
}&amp;#39;
&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;p&gt;Note the endpoint: &lt;a href=&quot;https://ollama.com&quot;&gt;Ollama&lt;/a&gt;’s API docs mark the older &lt;code&gt;/api/embeddings&lt;/code&gt; as superseded by &lt;code&gt;/api/embed&lt;/code&gt;, which takes &lt;code&gt;input&lt;/code&gt; (a string or a list) rather than &lt;code&gt;prompt&lt;/code&gt;, and a &lt;code&gt;truncate&lt;/code&gt; flag that trims over-long input to fit the model’s context instead of failing. If you built your index against the old endpoint, as I did, this is a free upgrade.&lt;/p&gt;&lt;p&gt;I ask each of my &lt;a href=&quot;https://peira.dev/blog/ollama-lxc-cluster/&quot;&gt;three Ollama nodes&lt;/a&gt; in turn and take the first one that answers. Embedding a question is a tiny amount of work, so this is about surviving one node being down for updates, not about throughput.&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;
&lt;div class=&quot;step&quot; data-astro-cid-wwaqy5cd&gt;&lt;div class=&quot;step-header&quot; data-astro-cid-wwaqy5cd&gt;&lt;span class=&quot;step-num&quot; data-astro-cid-wwaqy5cd&gt;2&lt;/span&gt;&lt;span class=&quot;step-title&quot; data-astro-cid-wwaqy5cd&gt;Rank in memory — and keep the text&lt;/span&gt;&lt;span class=&quot;step-time&quot; data-astro-cid-wwaqy5cd&gt;15 min&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;step-body&quot; data-astro-cid-wwaqy5cd&gt;&lt;p&gt;My existing search tool returned page names and scores, which is all you need to &lt;em&gt;find&lt;/em&gt; something. To &lt;em&gt;answer&lt;/em&gt; from it you need the words themselves, so this service loads the index into memory and does the ranking in-process instead of shelling out.&lt;/p&gt;&lt;p&gt;That turns out to be the cheap part. Cosine similarity is a dot product and two lengths; over roughly 1,100 section chunks it is not something you optimise. The whole index sits in about 50 MB of RAM, and embedding plus ranking a question takes around four tenths of a second, nearly all of it the network round trip to the embedding model.&lt;/p&gt;&lt;p&gt;The index reloads itself when the revision file next to it changes, so a rebuild is picked up without a restart.&lt;/p&gt;&lt;div class=&quot;callout callout-tip&quot; data-astro-cid-q2ml7llr&gt;&lt;div class=&quot;callout-header&quot; data-astro-cid-q2ml7llr&gt;&lt;span class=&quot;callout-icon&quot; aria-hidden=&quot;true&quot; data-astro-cid-q2ml7llr&gt;💡&lt;/span&gt;&lt;span class=&quot;callout-label&quot; data-astro-cid-q2ml7llr&gt;Make follow-up questions work&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;callout-body&quot; data-astro-cid-q2ml7llr&gt;&lt;p&gt;“And which node has the most RAM?” is meaningless on its own — embed those seven words and you retrieve noise. Before embedding, I prepend the previous user message to the current one. The conversation still gets sent to the model in full; this is only about giving the &lt;em&gt;search&lt;/em&gt; enough context to find the right pages.&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;&lt;/div&gt;&lt;/div&gt;
&lt;div class=&quot;step&quot; data-astro-cid-wwaqy5cd&gt;&lt;div class=&quot;step-header&quot; data-astro-cid-wwaqy5cd&gt;&lt;span class=&quot;step-num&quot; data-astro-cid-wwaqy5cd&gt;3&lt;/span&gt;&lt;span class=&quot;step-title&quot; data-astro-cid-wwaqy5cd&gt;Let the model write, but not invent&lt;/span&gt;&lt;span class=&quot;step-time&quot; data-astro-cid-wwaqy5cd&gt;20 min&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;step-body&quot; data-astro-cid-wwaqy5cd&gt;&lt;p&gt;The excerpts go into the prompt numbered, each with its page path and heading, and the system prompt is deliberately narrow: answer only from the numbered excerpts, cite them inline as &lt;code&gt;[1]&lt;/code&gt;, &lt;code&gt;[2]&lt;/code&gt;, say plainly when they do not contain the answer and name the closest one instead — and never repeat anything that looks like a secret, even if an excerpt contains it.&lt;/p&gt;&lt;p&gt;That last clause is not paranoia about the model. It is a second pair of hands on a rule I already enforce when writing the notes, and rules worth having are worth having twice.&lt;/p&gt;&lt;figure class=&quot;terminal&quot; data-astro-cid-kenkipkp&gt;&lt;figcaption class=&quot;terminal-bar&quot; data-astro-cid-kenkipkp&gt;&lt;span class=&quot;dot red&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot yellow&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot green&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;terminal-title&quot; data-astro-cid-kenkipkp&gt;The generation call — hosted path&lt;/span&gt;&lt;button class=&quot;terminal-copy&quot; type=&quot;button&quot; aria-label=&quot;Copy commands&quot; data-astro-cid-kenkipkp&gt;&lt;svg width=&quot;13&quot; height=&quot;13&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;none&quot; stroke=&quot;currentColor&quot; stroke-width=&quot;2&quot; stroke-linecap=&quot;round&quot; stroke-linejoin=&quot;round&quot; aria-hidden=&quot;true&quot; data-astro-cid-kenkipkp&gt;&lt;rect x=&quot;9&quot; y=&quot;9&quot; width=&quot;13&quot; height=&quot;13&quot; rx=&quot;2&quot; data-astro-cid-kenkipkp&gt;&lt;/rect&gt;&lt;path d=&quot;M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1&quot; data-astro-cid-kenkipkp&gt;&lt;/path&gt;&lt;/svg&gt;&lt;span class=&quot;terminal-copy-label&quot; data-astro-cid-kenkipkp&gt;Copy&lt;/span&gt;&lt;/button&gt;&lt;/figcaption&gt;&lt;pre class=&quot;terminal-body&quot; data-astro-cid-kenkipkp&gt;&lt;code data-astro-cid-kenkipkp&gt;
curl -s https://api.anthropic.com/v1/messages \
-H &amp;#39;x-api-key: YOUR_API_KEY&amp;#39; \
-H &amp;#39;anthropic-version: 2023-06-01&amp;#39; \
-H &amp;#39;content-type: application/json&amp;#39; \
-d &amp;#39;{
  &amp;quot;model&amp;quot;: &amp;quot;claude-haiku-4-5&amp;quot;,
  &amp;quot;max_tokens&amp;quot;: 1024,
  &amp;quot;system&amp;quot;: &amp;quot;Answer ONLY from the numbered excerpts. Cite them as [1], [2].&amp;quot;,
  &amp;quot;messages&amp;quot;: [{&amp;quot;role&amp;quot;: &amp;quot;user&amp;quot;, &amp;quot;content&amp;quot;: &amp;quot;&amp;lt;excerpts&amp;gt;\n\nQuestion: ...&amp;quot;}]
}&amp;#39;
&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;p&gt;Four things carry the request and they are all visible above: the key goes in &lt;code&gt;x-api-key&lt;/code&gt;, the API version is pinned in a header, the instructions ride in a top-level &lt;code&gt;system&lt;/code&gt; field rather than as a fake first message, and &lt;code&gt;max_tokens&lt;/code&gt; caps the reply. That shape is straight from &lt;a href=&quot;https://platform.claude.com/docs/en/api/messages&quot;&gt;Anthropic’s Messages API reference&lt;/a&gt;; a small, fast model is the right tool here because the hard thinking was already done by retrieval — what is left is summarising six passages faithfully.&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;the-honest-arithmetic-on-running-it-locally&quot;&gt;The honest arithmetic on running it locally&lt;/h2&gt;
&lt;p&gt;I wanted this fully local. I measured it instead of assuming, and the numbers made the decision for me.&lt;/p&gt;
&lt;svg viewBox=&quot;0 0 720 250&quot; width=&quot;100%&quot; role=&quot;img&quot; aria-label=&quot;Measured latency comparison. Retrieval takes about zero point four seconds on either path. Hosted generation using a small fast model adds roughly three seconds for a total of about three and a half seconds with six excerpts. Local generation on CPU-only nodes takes about a hundred and thirty-eight seconds with just one excerpt, which is roughly forty times slower end to end. A note records that retrieval is local either way and only generation leaves the network.&quot;&gt;&lt;rect width=&quot;720&quot; height=&quot;250&quot; rx=&quot;12&quot; fill=&quot;#1c1c32&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;360&quot; y=&quot;30&quot; text-anchor=&quot;middle&quot; fill=&quot;#f4f4f5&quot; font-size=&quot;15&quot; font-weight=&quot;bold&quot;&gt;Same retrieval, very different generation — measured on my hardware&lt;/text&gt;&lt;text x=&quot;24&quot; y=&quot;66&quot; fill=&quot;#a1a1aa&quot; font-size=&quot;11&quot;&gt;hosted model&lt;/text&gt;&lt;text x=&quot;696&quot; y=&quot;66&quot; text-anchor=&quot;end&quot; fill=&quot;#22c55e&quot; font-size=&quot;11&quot; font-weight=&quot;bold&quot;&gt;≈ 3.6 s total, 6 excerpts&lt;/text&gt;&lt;rect x=&quot;24&quot; y=&quot;78&quot; width=&quot;30&quot; height=&quot;26&quot; rx=&quot;5&quot; fill=&quot;#2c2c4c&quot; stroke=&quot;#14b8a6&quot; stroke-width=&quot;1.5&quot;&gt;&lt;/rect&gt;&lt;rect x=&quot;58&quot; y=&quot;78&quot; width=&quot;96&quot; height=&quot;26&quot; rx=&quot;5&quot; fill=&quot;#2c2c4c&quot; stroke=&quot;#22c55e&quot; stroke-width=&quot;1.5&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;24&quot; y=&quot;132&quot; fill=&quot;#a1a1aa&quot; font-size=&quot;11&quot;&gt;local model, CPU only&lt;/text&gt;&lt;text x=&quot;696&quot; y=&quot;132&quot; text-anchor=&quot;end&quot; fill=&quot;#ef4444&quot; font-size=&quot;11&quot; font-weight=&quot;bold&quot;&gt;≈ 138 s — and only 1 excerpt&lt;/text&gt;&lt;rect x=&quot;24&quot; y=&quot;144&quot; width=&quot;30&quot; height=&quot;26&quot; rx=&quot;5&quot; fill=&quot;#2c2c4c&quot; stroke=&quot;#14b8a6&quot; stroke-width=&quot;1.5&quot;&gt;&lt;/rect&gt;&lt;rect x=&quot;58&quot; y=&quot;144&quot; width=&quot;596&quot; height=&quot;26&quot; rx=&quot;5&quot; fill=&quot;#2c2c4c&quot; stroke=&quot;#ef4444&quot; stroke-width=&quot;1.5&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;24&quot; y=&quot;182&quot; fill=&quot;#6b7280&quot; font-size=&quot;10&quot;&gt;teal = embed + rank (local either way, about 0.4 s) · coloured = generation&lt;/text&gt;&lt;text x=&quot;24&quot; y=&quot;198&quot; fill=&quot;#6b7280&quot; font-size=&quot;10&quot;&gt;bars are compressed, not to scale — the real gap is roughly 40×&lt;/text&gt;&lt;rect x=&quot;24&quot; y=&quot;212&quot; width=&quot;672&quot; height=&quot;26&quot; rx=&quot;6&quot; fill=&quot;#242440&quot; stroke=&quot;#3d3d5c&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;360&quot; y=&quot;229&quot; text-anchor=&quot;middle&quot; fill=&quot;#e5e7eb&quot; font-size=&quot;11&quot;&gt;your documents never leave the LAN in either case — only the excerpts chosen for one question do&lt;/text&gt;&lt;/svg&gt;
&lt;p&gt;Roughly three and a half seconds against a hundred and thirty-eight, and the slow one had to be handed a &lt;em&gt;single&lt;/em&gt; excerpt rather than six to fit its context window at all. My nodes have no GPU, and reading a long prompt is exactly the work CPUs are worst at. So hosted became the default and local stayed as a per-request switch — genuinely useful when a question touches something I would rather not send anywhere, and worth re-testing the day I put a GPU in the rack.&lt;/p&gt;
&lt;p&gt;Worth being clear about what does and does not leave the network: retrieval is local in both modes. Even on the hosted path, what goes out is the question and the handful of passages picked for it — not the wiki.&lt;/p&gt;
&lt;div class=&quot;callout callout-warning&quot; data-astro-cid-q2ml7llr&gt;&lt;div class=&quot;callout-header&quot; data-astro-cid-q2ml7llr&gt;&lt;span class=&quot;callout-icon&quot; aria-hidden=&quot;true&quot; data-astro-cid-q2ml7llr&gt;⚠️&lt;/span&gt;&lt;span class=&quot;callout-label&quot; data-astro-cid-q2ml7llr&gt;A service holding an API key is a service worth fencing in&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;callout-body&quot; data-astro-cid-q2ml7llr&gt;&lt;p&gt;The moment you add a hosted backend, that little endpoint can spend money. Mine listens on the LAN only and is deliberately not published through my tunnel, because anyone who can reach it can spend my tokens. If you expose one, put authentication in front of it first, and watch &lt;a href=&quot;https://peira.dev/blog/ai-spend-telemetry-grafana/&quot;&gt;what it actually costs&lt;/a&gt;.&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;the-label-is-the-feature&quot;&gt;The label is the feature&lt;/h2&gt;
&lt;p&gt;Everything above is standard. This is the part I would fight for.&lt;/p&gt;
&lt;p&gt;The search index is &lt;strong&gt;derived&lt;/strong&gt; — built from the wiki, never the other way round. It can therefore be behind. &lt;a href=&quot;https://peira.dev/blog/second-brain-projection-health/&quot;&gt;Keeping derived copies honest&lt;/a&gt; is a running theme in this series, and the rule is always the same: a derived thing must know, and report, which revision it represents.&lt;/p&gt;
&lt;p&gt;So every response carries three fields: the revision the index was built from, the live revision of the wiki right now, and whether they differ. When they differ, that fact is also injected into the model’s instructions, so the answer itself opens by warning you rather than leaving it to a badge you might not look at.&lt;/p&gt;
&lt;figure class=&quot;terminal&quot; data-astro-cid-kenkipkp&gt;&lt;figcaption class=&quot;terminal-bar&quot; data-astro-cid-kenkipkp&gt;&lt;span class=&quot;dot red&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot yellow&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot green&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;terminal-title&quot; data-astro-cid-kenkipkp&gt;Health check — the same three fields, before you even ask anything&lt;/span&gt;&lt;button class=&quot;terminal-copy&quot; type=&quot;button&quot; aria-label=&quot;Copy commands&quot; data-astro-cid-kenkipkp&gt;&lt;svg width=&quot;13&quot; height=&quot;13&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;none&quot; stroke=&quot;currentColor&quot; stroke-width=&quot;2&quot; stroke-linecap=&quot;round&quot; stroke-linejoin=&quot;round&quot; aria-hidden=&quot;true&quot; data-astro-cid-kenkipkp&gt;&lt;rect x=&quot;9&quot; y=&quot;9&quot; width=&quot;13&quot; height=&quot;13&quot; rx=&quot;2&quot; data-astro-cid-kenkipkp&gt;&lt;/rect&gt;&lt;path d=&quot;M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1&quot; data-astro-cid-kenkipkp&gt;&lt;/path&gt;&lt;/svg&gt;&lt;span class=&quot;terminal-copy-label&quot; data-astro-cid-kenkipkp&gt;Copy&lt;/span&gt;&lt;/button&gt;&lt;/figcaption&gt;&lt;pre class=&quot;terminal-body&quot; data-astro-cid-kenkipkp&gt;&lt;code data-astro-cid-kenkipkp&gt;
curl -s http://10.0.0.76:9113/health

# {
#   &amp;quot;ok&amp;quot;: true,
#   &amp;quot;revision&amp;quot;:       &amp;quot;64dc195...&amp;quot;,   &amp;lt;- what the index knows
#   &amp;quot;canonical_head&amp;quot;: &amp;quot;64dc195...&amp;quot;,   &amp;lt;- what the wiki actually is
#   &amp;quot;stale&amp;quot;: false,
#   &amp;quot;chunks&amp;quot;: 1114, &amp;quot;pages&amp;quot;: 165
# }
&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;
&lt;p&gt;One wrinkle worth passing on. Reading the live revision by shelling out to &lt;code&gt;git&lt;/code&gt; did not work: the service runs as its own unprivileged user, and Git refuses to operate on a repository owned by somebody else, failing with &lt;code&gt;fatal: detected dubious ownership in repository at &amp;#39;&amp;lt;path&amp;gt;&amp;#39;&lt;/code&gt; unless you add a &lt;code&gt;safe.directory&lt;/code&gt; exception. Rather than widen that (&lt;a href=&quot;https://git-scm.com/docs/git-config&quot;&gt;Git’s own documentation&lt;/a&gt; explains it exists to stop a repository you do not control from executing configuration you did not write), I read the ref files directly — &lt;code&gt;HEAD&lt;/code&gt;, then the branch ref, then &lt;code&gt;packed-refs&lt;/code&gt; as a fallback. A dozen lines, no shell, no exception, and the reader never gains a capability it does not need.&lt;/p&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;read-only-by-construction-not-by-good-intentions&quot;&gt;Read-only by construction, not by good intentions&lt;/h2&gt;
&lt;p&gt;This thing sits next to my knowledge base with a language model attached. The safest version of that is one that &lt;em&gt;cannot&lt;/em&gt; write, so there is no write path, no rebuild trigger, and no code that mutates the repository at all — the only file operations in it are reads.&lt;/p&gt;
&lt;p&gt;Then the ordinary hardening: no new privileges, no capabilities, a memory ceiling, and validation on everything a caller can send (a length cap on the question, a cap on how much history it will accept, a bounded result count, and a pattern check on the topic filter, with oversized request bodies dropped rather than buffered).&lt;/p&gt;
&lt;div class=&quot;callout callout-warning&quot; data-astro-cid-q2ml7llr&gt;&lt;div class=&quot;callout-header&quot; data-astro-cid-q2ml7llr&gt;&lt;span class=&quot;callout-icon&quot; aria-hidden=&quot;true&quot; data-astro-cid-q2ml7llr&gt;⚠️&lt;/span&gt;&lt;span class=&quot;callout-label&quot; data-astro-cid-q2ml7llr&gt;A container gotcha that cost me an afternoon&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;callout-body&quot; data-astro-cid-q2ml7llr&gt;&lt;p&gt;My first unit used systemd’s filesystem sandboxing — &lt;code&gt;DynamicUser&lt;/code&gt;, &lt;code&gt;ProtectSystem&lt;/code&gt;, &lt;code&gt;PrivateTmp&lt;/code&gt; — and refused to start. All three work by building a private mount namespace for the service (&lt;a href=&quot;https://man7.org/linux/man-pages/man5/systemd.exec.5.html&quot;&gt;systemd’s manual&lt;/a&gt; describes &lt;code&gt;PrivateTmp&lt;/code&gt; as giving the unit “a private mount namespace”), and an unprivileged container that is not allowed to nest cannot create one. Nothing was misconfigured; the sandbox simply had nowhere to stand. I dropped back to a plain system user plus the non-namespace protections and let the container itself be the outer sandbox — which, on &lt;a href=&quot;https://www.proxmox.com/en/proxmox-virtual-environment&quot;&gt;Proxmox&lt;/a&gt;, it already was. Worth knowing before you assume a hardened unit file will port into a container unchanged.&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;what-it-feels-like-to-use&quot;&gt;What it feels like to use&lt;/h2&gt;
&lt;p&gt;The honest test is not a benchmark, it is the Sunday-evening question you would otherwise answer by opening four tabs. Mine tend to be things like “what was the deal with the NAS double hop again?” — and the useful part of the reply is not the paragraph, it is the two page names underneath it, because that is where I go next.&lt;/p&gt;
&lt;p&gt;I put the same endpoint behind a tab in my &lt;a href=&quot;https://peira.dev/blog/flutter-proxmox-dashboard/&quot;&gt;homelab dashboard app&lt;/a&gt;, where the citations render as chips you can tap to see the passage, and the staleness flag renders as a small amber badge on the answer. Same three fields, just wearing a UI.&lt;/p&gt;
&lt;p&gt;If you already have the index, this is an afternoon. The paired playbook — &lt;strong&gt;A cited RAG endpoint over your Markdown wiki&lt;/strong&gt; — has the whole retrieve-then-generate core in one copy-paste file. And if you have not built the index yet, start there instead: the retrieval half is the half that decides whether any of this is worth reading, and no amount of clever prompting rescues a search that returned the wrong pages.&lt;/p&gt;
&lt;hr/&gt;
&lt;p&gt;&lt;strong&gt;Related posts:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/second-brain-local-rag-embeddings/&quot;&gt;Semantic Search for Your Notes, Running Locally&lt;/a&gt; — the embeddings index this reads; build it first&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/second-brain-projection-health/&quot;&gt;Prove Your Homelab Docs Are Actually Up to Date&lt;/a&gt; — where the revision-labelling discipline comes from&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/ollama-lxc-cluster/&quot;&gt;CPU-Only Ollama Cluster on Proxmox LXC: 3-Node Setup with Load Balancing&lt;/a&gt; — the local inference nodes doing the embedding&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/second-brain-ai-begin-end-skills/&quot;&gt;Make Your AI Agents Read the Docs First, Every Time&lt;/a&gt; — another consumer of the same index, on the retrieval-only path&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/flutter-proxmox-dashboard/&quot;&gt;Building a Native Proxmox Dashboard in Flutter: Linux Desktop + iOS&lt;/a&gt; — the app that renders these answers and their citations&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/ai-spend-telemetry-grafana/&quot;&gt;Track AI Token Spend in Grafana: Claude, Codex, and Ollama&lt;/a&gt; — watching the spend once a hosted model is in the loop&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/second-brain-git-markdown-vault/&quot;&gt;Never Lose a Homelab Note Again: A Git + Markdown Vault&lt;/a&gt; — the vault everything here is derived from&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/open-webui-advanced/&quot;&gt;Open WebUI Advanced: RAG, Pipelines, and Custom System Prompts&lt;/a&gt; — a general chat front end over the same Ollama cluster&lt;/li&gt;
&lt;/ul&gt;</content:encoded><category>ai</category><category>ollama</category><category>self-hosted</category></item><item><title>A Proxmox Container Born, Configured, and Destroyed From Code</title><link>https://peira.dev/blog/opentofu-scratch-container-lifecycle/</link><guid isPermaLink="true">https://peira.dev/blog/opentofu-scratch-container-lifecycle/</guid><description>Prove your homelab&apos;s infrastructure code with a scratch container — OpenTofu creates it, Ansible configures it, a destroy removes it, production untouched.</description><pubDate>Thu, 30 Jul 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;By the end of &lt;a href=&quot;https://peira.dev/blog/proxmox-ansible-capture-then-assert/&quot;&gt;part three&lt;/a&gt; this series had a complete written description of my cluster: every container definition under &lt;a href=&quot;https://opentofu.org/&quot;&gt;OpenTofu&lt;/a&gt;, every node role under &lt;a href=&quot;https://docs.ansible.com/&quot;&gt;Ansible&lt;/a&gt;, and two commands that continuously verify reality still matches the record. It sounds finished. It has a hole in it you could drive a truck through.&lt;/p&gt;
&lt;p&gt;Nothing in that description has ever &lt;em&gt;built&lt;/em&gt; anything. The containers were imported, not created; the roles were captured from configured nodes, not converged onto blank ones. A description that has never been used to build something is a hypothesis — and the whole promise of the series finale, rebuilding from zero, rests on it. So before trusting it with a disaster, I made it prove itself on something disposable: one small container, created by code, configured by code, verified, destroyed by code — while production didn’t feel a thing.&lt;/p&gt;
&lt;div class=&quot;callout callout-warning&quot; data-astro-cid-q2ml7llr&gt;&lt;div class=&quot;callout-header&quot; data-astro-cid-q2ml7llr&gt;&lt;span class=&quot;callout-icon&quot; aria-hidden=&quot;true&quot; data-astro-cid-q2ml7llr&gt;⚠️&lt;/span&gt;&lt;span class=&quot;callout-label&quot; data-astro-cid-q2ml7llr&gt;First: make these values your own&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;callout-body&quot; data-astro-cid-q2ml7llr&gt;&lt;p&gt;Stand-ins throughout: &lt;code&gt;10.0.0.73&lt;/code&gt; is a documentation address (use your node’s), &lt;code&gt;pvelab01&lt;/code&gt; is my node name, &lt;code&gt;9001&lt;/code&gt; is my scratch container ID (pick any ID range your production guests will never use), and &lt;code&gt;scratch@pve&lt;/code&gt; is a temporary account name. The token secret that appears exists only inside your shell session — it is never written to a file, and it dies when the account does. If a value looks specific to one machine, it’s a placeholder to change — not a literal to copy.&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;the-shape-of-the-proof&quot;&gt;The shape of the proof&lt;/h2&gt;
&lt;p&gt;The loop has six stations, and the last one is the point: after the scratch container has lived and died, the production plan must still say &lt;code&gt;No changes.&lt;/code&gt;&lt;/p&gt;
&lt;svg viewBox=&quot;0 0 720 330&quot; width=&quot;100%&quot; role=&quot;img&quot; aria-label=&quot;Six-station lifecycle loop. Mint a temporary identity, create the container with tofu apply, configure it with Ansible, verify over SSH, destroy it with tofu destroy, then clean up the identity. In the centre, the production state remains at plan no changes throughout.&quot;&gt;&lt;rect width=&quot;720&quot; height=&quot;330&quot; rx=&quot;12&quot; fill=&quot;#1c1c32&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;360&quot; y=&quot;30&quot; text-anchor=&quot;middle&quot; fill=&quot;#f4f4f5&quot; font-size=&quot;15&quot; font-weight=&quot;bold&quot;&gt;The scratch lifecycle — production untouched at every station&lt;/text&gt;&lt;rect x=&quot;30&quot; y=&quot;56&quot; width=&quot;200&quot; height=&quot;74&quot; rx=&quot;9&quot; fill=&quot;#2c2c4c&quot; stroke=&quot;#818cf8&quot; stroke-width=&quot;1.4&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;130&quot; y=&quot;80&quot; text-anchor=&quot;middle&quot; fill=&quot;#818cf8&quot; font-size=&quot;12&quot; font-weight=&quot;bold&quot;&gt;1 · mint&lt;/text&gt;&lt;text x=&quot;130&quot; y=&quot;100&quot; text-anchor=&quot;middle&quot; fill=&quot;#e5e7eb&quot; font-size=&quot;10&quot;&gt;throwaway user,&lt;/text&gt;&lt;text x=&quot;130&quot; y=&quot;116&quot; text-anchor=&quot;middle&quot; fill=&quot;#a1a1aa&quot; font-size=&quot;10&quot;&gt;scoped write token&lt;/text&gt;&lt;rect x=&quot;260&quot; y=&quot;56&quot; width=&quot;200&quot; height=&quot;74&quot; rx=&quot;9&quot; fill=&quot;#2c2c4c&quot; stroke=&quot;#14b8a6&quot; stroke-width=&quot;1.4&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;360&quot; y=&quot;80&quot; text-anchor=&quot;middle&quot; fill=&quot;#14b8a6&quot; font-size=&quot;12&quot; font-weight=&quot;bold&quot;&gt;2 · create&lt;/text&gt;&lt;text x=&quot;360&quot; y=&quot;100&quot; text-anchor=&quot;middle&quot; fill=&quot;#e5e7eb&quot; font-size=&quot;10&quot;&gt;tofu apply&lt;/text&gt;&lt;text x=&quot;360&quot; y=&quot;116&quot; text-anchor=&quot;middle&quot; fill=&quot;#a1a1aa&quot; font-size=&quot;10&quot;&gt;1 added&lt;/text&gt;&lt;rect x=&quot;490&quot; y=&quot;56&quot; width=&quot;200&quot; height=&quot;74&quot; rx=&quot;9&quot; fill=&quot;#2c2c4c&quot; stroke=&quot;#14b8a6&quot; stroke-width=&quot;1.4&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;590&quot; y=&quot;80&quot; text-anchor=&quot;middle&quot; fill=&quot;#14b8a6&quot; font-size=&quot;12&quot; font-weight=&quot;bold&quot;&gt;3 · configure&lt;/text&gt;&lt;text x=&quot;590&quot; y=&quot;100&quot; text-anchor=&quot;middle&quot; fill=&quot;#e5e7eb&quot; font-size=&quot;10&quot;&gt;ansible-playbook&lt;/text&gt;&lt;text x=&quot;590&quot; y=&quot;116&quot; text-anchor=&quot;middle&quot; fill=&quot;#a1a1aa&quot; font-size=&quot;10&quot;&gt;real converge&lt;/text&gt;&lt;rect x=&quot;490&quot; y=&quot;236&quot; width=&quot;200&quot; height=&quot;74&quot; rx=&quot;9&quot; fill=&quot;#2c2c4c&quot; stroke=&quot;#22c55e&quot; stroke-width=&quot;1.4&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;590&quot; y=&quot;260&quot; text-anchor=&quot;middle&quot; fill=&quot;#22c55e&quot; font-size=&quot;12&quot; font-weight=&quot;bold&quot;&gt;4 · verify&lt;/text&gt;&lt;text x=&quot;590&quot; y=&quot;280&quot; text-anchor=&quot;middle&quot; fill=&quot;#e5e7eb&quot; font-size=&quot;10&quot;&gt;SSH in, look&lt;/text&gt;&lt;text x=&quot;590&quot; y=&quot;296&quot; text-anchor=&quot;middle&quot; fill=&quot;#a1a1aa&quot; font-size=&quot;10&quot;&gt;motd, packages&lt;/text&gt;&lt;rect x=&quot;260&quot; y=&quot;236&quot; width=&quot;200&quot; height=&quot;74&quot; rx=&quot;9&quot; fill=&quot;#2c2c4c&quot; stroke=&quot;#f59e0b&quot; stroke-width=&quot;1.4&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;360&quot; y=&quot;260&quot; text-anchor=&quot;middle&quot; fill=&quot;#f59e0b&quot; font-size=&quot;12&quot; font-weight=&quot;bold&quot;&gt;5 · destroy&lt;/text&gt;&lt;text x=&quot;360&quot; y=&quot;280&quot; text-anchor=&quot;middle&quot; fill=&quot;#e5e7eb&quot; font-size=&quot;10&quot;&gt;tofu destroy&lt;/text&gt;&lt;text x=&quot;360&quot; y=&quot;296&quot; text-anchor=&quot;middle&quot; fill=&quot;#a1a1aa&quot; font-size=&quot;10&quot;&gt;1 destroyed&lt;/text&gt;&lt;rect x=&quot;30&quot; y=&quot;236&quot; width=&quot;200&quot; height=&quot;74&quot; rx=&quot;9&quot; fill=&quot;#2c2c4c&quot; stroke=&quot;#818cf8&quot; stroke-width=&quot;1.4&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;130&quot; y=&quot;260&quot; text-anchor=&quot;middle&quot; fill=&quot;#818cf8&quot; font-size=&quot;12&quot; font-weight=&quot;bold&quot;&gt;6 · clean&lt;/text&gt;&lt;text x=&quot;130&quot; y=&quot;280&quot; text-anchor=&quot;middle&quot; fill=&quot;#e5e7eb&quot; font-size=&quot;10&quot;&gt;delete user + ACLs&lt;/text&gt;&lt;text x=&quot;130&quot; y=&quot;296&quot; text-anchor=&quot;middle&quot; fill=&quot;#a1a1aa&quot; font-size=&quot;10&quot;&gt;known_hosts too&lt;/text&gt;&lt;path d=&quot;M 230 93 L 254 93&quot; stroke=&quot;#818cf8&quot; stroke-width=&quot;2&quot; marker-end=&quot;url(#lifeArrow)&quot;&gt;&lt;/path&gt;&lt;path d=&quot;M 460 93 L 484 93&quot; stroke=&quot;#818cf8&quot; stroke-width=&quot;2&quot; marker-end=&quot;url(#lifeArrow)&quot;&gt;&lt;/path&gt;&lt;path d=&quot;M 590 130 L 590 230&quot; stroke=&quot;#818cf8&quot; stroke-width=&quot;2&quot; marker-end=&quot;url(#lifeArrow)&quot;&gt;&lt;/path&gt;&lt;path d=&quot;M 484 273 L 460 273&quot; stroke=&quot;#818cf8&quot; stroke-width=&quot;2&quot; marker-end=&quot;url(#lifeArrow)&quot;&gt;&lt;/path&gt;&lt;path d=&quot;M 254 273 L 230 273&quot; stroke=&quot;#818cf8&quot; stroke-width=&quot;2&quot; marker-end=&quot;url(#lifeArrow)&quot;&gt;&lt;/path&gt;&lt;rect x=&quot;252&quot; y=&quot;150&quot; width=&quot;216&quot; height=&quot;52&quot; rx=&quot;9&quot; fill=&quot;#30305a&quot; stroke=&quot;#22c55e&quot; stroke-width=&quot;1.6&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;360&quot; y=&quot;172&quot; text-anchor=&quot;middle&quot; fill=&quot;#22c55e&quot; font-size=&quot;11&quot; font-weight=&quot;bold&quot;&gt;production plan, before and after:&lt;/text&gt;&lt;text x=&quot;360&quot; y=&quot;190&quot; text-anchor=&quot;middle&quot; fill=&quot;#22c55e&quot; font-size=&quot;12&quot; font-weight=&quot;bold&quot;&gt;“No changes.”&lt;/text&gt;&lt;defs&gt;&lt;marker id=&quot;lifeArrow&quot; viewBox=&quot;0 0 10 10&quot; refX=&quot;9&quot; refY=&quot;5&quot; markerWidth=&quot;5&quot; markerHeight=&quot;5&quot; orient=&quot;auto-start-reverse&quot;&gt;&lt;path d=&quot;M 0 0 L 10 5 L 0 10 z&quot; fill=&quot;#818cf8&quot;&gt;&lt;/path&gt;&lt;/marker&gt;&lt;/defs&gt;&lt;/svg&gt;
&lt;p&gt;There’s a copy-pasteable version of the whole sequence in the &lt;a href=&quot;https://peira.dev/playbooks/scratch-container-lifecycle/&quot;&gt;scratch container lifecycle playbook&lt;/a&gt; — the prose below is the &lt;em&gt;why&lt;/em&gt; behind each station.&lt;/p&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;station-0-the-write-credential-problem&quot;&gt;Station 0: the write-credential problem&lt;/h2&gt;
&lt;p&gt;Here’s the first genuinely interesting obstacle. &lt;a href=&quot;https://peira.dev/blog/proxmox-cluster-as-code-import-first/&quot;&gt;Part one&lt;/a&gt; put the whole safety posture on a read-only API token — the tool physically cannot modify the cluster. But a lifecycle test must create and destroy. Something has to hold write access, and the naive answer — &lt;em&gt;just grant the existing token more permissions for an afternoon&lt;/em&gt; — turns out not to work, for a reason worth understanding.&lt;/p&gt;
&lt;p&gt;Proxmox API tokens default to what it calls privilege separation. A separated token doesn’t inherit its user’s permissions; &lt;a href=&quot;https://pve.proxmox.com/pve-docs/chapter-pveum.html&quot;&gt;in the documentation’s words&lt;/a&gt;, its “effective permissions are calculated by intersecting user and token permissions,” and “privilege separated tokens can never have permissions on any given path that their associated user does not have.” Read that second sentence again: it’s an &lt;em&gt;intersection&lt;/em&gt;, not a union.&lt;/p&gt;
&lt;svg viewBox=&quot;0 0 720 260&quot; width=&quot;100%&quot; role=&quot;img&quot; aria-label=&quot;Venn diagram of Proxmox privilege separation. One circle holds the user&apos;s permissions, read only. Another circle holds the token&apos;s granted ACLs including write. The token&apos;s effective permissions are only the overlap, which contains no write access. A write token on a read-only user can never write.&quot;&gt;&lt;rect width=&quot;720&quot; height=&quot;260&quot; rx=&quot;12&quot; fill=&quot;#1c1c32&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;360&quot; y=&quot;30&quot; text-anchor=&quot;middle&quot; fill=&quot;#f4f4f5&quot; font-size=&quot;15&quot; font-weight=&quot;bold&quot;&gt;Why a write token on the auditor user can never write&lt;/text&gt;&lt;circle cx=&quot;302&quot; cy=&quot;130&quot; r=&quot;86&quot; fill=&quot;#2c2c4c&quot; fill-opacity=&quot;0.7&quot; stroke=&quot;#14b8a6&quot; stroke-width=&quot;1.6&quot;&gt;&lt;/circle&gt;&lt;circle cx=&quot;418&quot; cy=&quot;130&quot; r=&quot;86&quot; fill=&quot;#30305a&quot; fill-opacity=&quot;0.7&quot; stroke=&quot;#f59e0b&quot; stroke-width=&quot;1.6&quot;&gt;&lt;/circle&gt;&lt;text x=&quot;268&quot; y=&quot;106&quot; text-anchor=&quot;middle&quot; fill=&quot;#14b8a6&quot; font-size=&quot;11&quot; font-weight=&quot;bold&quot;&gt;user’s permissions&lt;/text&gt;&lt;text x=&quot;268&quot; y=&quot;124&quot; text-anchor=&quot;middle&quot; fill=&quot;#e5e7eb&quot; font-size=&quot;10&quot;&gt;read-only&lt;/text&gt;&lt;text x=&quot;268&quot; y=&quot;140&quot; text-anchor=&quot;middle&quot; fill=&quot;#e5e7eb&quot; font-size=&quot;10&quot;&gt;auditor&lt;/text&gt;&lt;text x=&quot;452&quot; y=&quot;106&quot; text-anchor=&quot;middle&quot; fill=&quot;#f59e0b&quot; font-size=&quot;11&quot; font-weight=&quot;bold&quot;&gt;token’s ACLs&lt;/text&gt;&lt;text x=&quot;452&quot; y=&quot;124&quot; text-anchor=&quot;middle&quot; fill=&quot;#e5e7eb&quot; font-size=&quot;10&quot;&gt;write granted&lt;/text&gt;&lt;text x=&quot;452&quot; y=&quot;140&quot; text-anchor=&quot;middle&quot; fill=&quot;#e5e7eb&quot; font-size=&quot;10&quot;&gt;here&lt;/text&gt;&lt;text x=&quot;360&quot; y=&quot;118&quot; text-anchor=&quot;middle&quot; fill=&quot;#f4f4f5&quot; font-size=&quot;10&quot; font-weight=&quot;bold&quot;&gt;effective&lt;/text&gt;&lt;text x=&quot;360&quot; y=&quot;134&quot; text-anchor=&quot;middle&quot; fill=&quot;#f4f4f5&quot; font-size=&quot;10&quot; font-weight=&quot;bold&quot;&gt;= overlap&lt;/text&gt;&lt;text x=&quot;360&quot; y=&quot;152&quot; text-anchor=&quot;middle&quot; fill=&quot;#ef4444&quot; font-size=&quot;10&quot; font-weight=&quot;bold&quot;&gt;no write&lt;/text&gt;&lt;text x=&quot;360&quot; y=&quot;244&quot; text-anchor=&quot;middle&quot; fill=&quot;#a1a1aa&quot; font-size=&quot;10&quot;&gt;the fix isn’t a stronger token — it’s a different, disposable user&lt;/text&gt;&lt;/svg&gt;
&lt;p&gt;I’d designed my automation user as a pure auditor on purpose, and this rule means that purity is &lt;em&gt;enforced&lt;/em&gt;: no token minted under it can ever exceed read-only, no matter what ACLs the token gets. Which is the platform telling me the right answer. Don’t corrupt the auditor — mint a &lt;strong&gt;separate, disposable user&lt;/strong&gt; for the test:&lt;/p&gt;
&lt;figure class=&quot;terminal&quot; data-astro-cid-kenkipkp&gt;&lt;figcaption class=&quot;terminal-bar&quot; data-astro-cid-kenkipkp&gt;&lt;span class=&quot;dot red&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot yellow&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot green&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;terminal-title&quot; data-astro-cid-kenkipkp&gt;A temporary identity with a deliberately small world&lt;/span&gt;&lt;button class=&quot;terminal-copy&quot; type=&quot;button&quot; aria-label=&quot;Copy commands&quot; data-astro-cid-kenkipkp&gt;&lt;svg width=&quot;13&quot; height=&quot;13&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;none&quot; stroke=&quot;currentColor&quot; stroke-width=&quot;2&quot; stroke-linecap=&quot;round&quot; stroke-linejoin=&quot;round&quot; aria-hidden=&quot;true&quot; data-astro-cid-kenkipkp&gt;&lt;rect x=&quot;9&quot; y=&quot;9&quot; width=&quot;13&quot; height=&quot;13&quot; rx=&quot;2&quot; data-astro-cid-kenkipkp&gt;&lt;/rect&gt;&lt;path d=&quot;M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1&quot; data-astro-cid-kenkipkp&gt;&lt;/path&gt;&lt;/svg&gt;&lt;span class=&quot;terminal-copy-label&quot; data-astro-cid-kenkipkp&gt;Copy&lt;/span&gt;&lt;/button&gt;&lt;/figcaption&gt;&lt;pre class=&quot;terminal-body&quot; data-astro-cid-kenkipkp&gt;&lt;code data-astro-cid-kenkipkp&gt;
# a user that exists only for this test
pveum user add scratch@pve

# write access ONLY where the test lives: one VMID, its storage,
# its network zone, its node — plus read access to see the cluster
pveum acl modify /vms/9001 --users scratch@pve --roles PVEAdmin
pveum acl modify /storage/local-lvm --users scratch@pve --roles PVEAdmin
pveum acl modify /nodes/pvelab01 --users scratch@pve --roles PVEAdmin
pveum acl modify / --users scratch@pve --roles PVEAuditor

# its token — the secret is shown once; it lives in your shell only
pveum user token add scratch@pve tf --privsep 0
&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;
&lt;p&gt;The blast radius of the entire experiment is now: container 9001, on one storage, on one node. The account holding it will be deleted within the hour. My real automation credential never changed at all — and that’s the pattern I’d recommend even if the intersection rule didn’t force it, because “temporarily upgraded and definitely remembered to downgrade later” is not a sentence with a good track record in a homelab.&lt;/p&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;station-1-birth&quot;&gt;Station 1: birth&lt;/h2&gt;
&lt;p&gt;The scratch container gets its own configuration directory with its own state — deliberately separate from production’s. State is the tool’s memory of what it manages; giving the throwaway experiment a separate memory means no command run here, however clumsy, can even &lt;em&gt;see&lt;/em&gt; a production resource.&lt;/p&gt;
&lt;figure class=&quot;terminal&quot; data-astro-cid-kenkipkp&gt;&lt;figcaption class=&quot;terminal-bar&quot; data-astro-cid-kenkipkp&gt;&lt;span class=&quot;dot red&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot yellow&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot green&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;terminal-title&quot; data-astro-cid-kenkipkp&gt;terraform/scratch/main.tf (the interesting parts)&lt;/span&gt;&lt;button class=&quot;terminal-copy&quot; type=&quot;button&quot; aria-label=&quot;Copy commands&quot; data-astro-cid-kenkipkp&gt;&lt;svg width=&quot;13&quot; height=&quot;13&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;none&quot; stroke=&quot;currentColor&quot; stroke-width=&quot;2&quot; stroke-linecap=&quot;round&quot; stroke-linejoin=&quot;round&quot; aria-hidden=&quot;true&quot; data-astro-cid-kenkipkp&gt;&lt;rect x=&quot;9&quot; y=&quot;9&quot; width=&quot;13&quot; height=&quot;13&quot; rx=&quot;2&quot; data-astro-cid-kenkipkp&gt;&lt;/rect&gt;&lt;path d=&quot;M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1&quot; data-astro-cid-kenkipkp&gt;&lt;/path&gt;&lt;/svg&gt;&lt;span class=&quot;terminal-copy-label&quot; data-astro-cid-kenkipkp&gt;Copy&lt;/span&gt;&lt;/button&gt;&lt;/figcaption&gt;&lt;pre class=&quot;terminal-body&quot; data-astro-cid-kenkipkp&gt;&lt;code data-astro-cid-kenkipkp&gt;
resource &amp;quot;proxmox_virtual_environment_container&amp;quot; &amp;quot;scratch&amp;quot; {
node_name    = &amp;quot;pvelab01&amp;quot;
vm_id        = 9001
description  = &amp;quot;scratch guest - safe to destroy&amp;quot;
unprivileged = true
started      = true

operating_system {
  template_file_id = &amp;quot;local:vztmpl/debian-13-standard_13.1-2_amd64.tar.zst&amp;quot;
  type             = &amp;quot;debian&amp;quot;
}

cpu    { cores = 1 }
memory { dedicated = 512 }
disk   { datastore_id = &amp;quot;local-lvm&amp;quot; size = 4 }

network_interface {
  name   = &amp;quot;eth0&amp;quot;
  bridge = &amp;quot;vmbr0&amp;quot;
}

initialization {
  hostname = &amp;quot;iac-scratch&amp;quot;
  ip_config {
    ipv4 { address = &amp;quot;dhcp&amp;quot; }
  }
  user_account {
    keys = [trimspace(file(pathexpand(&amp;quot;~/.ssh/id_ed25519.pub&amp;quot;)))]
  }
}
}
&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;
&lt;p&gt;Two choices worth a sentence each. &lt;strong&gt;DHCP is deliberate:&lt;/strong&gt; my gateway only routes clients it handed a lease to, so a static-IP scratch guest would sit there with no internet and fail its first &lt;code&gt;apt update&lt;/code&gt; mysteriously — on my network, &lt;code&gt;dhcp&lt;/code&gt; is load-bearing, not lazy. &lt;strong&gt;The SSH key injection&lt;/strong&gt; is what lets Ansible in later without any password ceremony.&lt;/p&gt;
&lt;figure class=&quot;terminal&quot; data-astro-cid-kenkipkp&gt;&lt;figcaption class=&quot;terminal-bar&quot; data-astro-cid-kenkipkp&gt;&lt;span class=&quot;dot red&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot yellow&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot green&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;terminal-title&quot; data-astro-cid-kenkipkp&gt;Create it&lt;/span&gt;&lt;button class=&quot;terminal-copy&quot; type=&quot;button&quot; aria-label=&quot;Copy commands&quot; data-astro-cid-kenkipkp&gt;&lt;svg width=&quot;13&quot; height=&quot;13&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;none&quot; stroke=&quot;currentColor&quot; stroke-width=&quot;2&quot; stroke-linecap=&quot;round&quot; stroke-linejoin=&quot;round&quot; aria-hidden=&quot;true&quot; data-astro-cid-kenkipkp&gt;&lt;rect x=&quot;9&quot; y=&quot;9&quot; width=&quot;13&quot; height=&quot;13&quot; rx=&quot;2&quot; data-astro-cid-kenkipkp&gt;&lt;/rect&gt;&lt;path d=&quot;M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1&quot; data-astro-cid-kenkipkp&gt;&lt;/path&gt;&lt;/svg&gt;&lt;span class=&quot;terminal-copy-label&quot; data-astro-cid-kenkipkp&gt;Copy&lt;/span&gt;&lt;/button&gt;&lt;/figcaption&gt;&lt;pre class=&quot;terminal-body&quot; data-astro-cid-kenkipkp&gt;&lt;code data-astro-cid-kenkipkp&gt;
cd terraform/scratch
tofu apply

# Apply complete! Resources: 1 added, 0 changed, 0 destroyed.
&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;
&lt;p&gt;That one line — &lt;code&gt;1 added&lt;/code&gt; — is the first time in this entire series the code has &lt;em&gt;created&lt;/em&gt; something. The hypothesis is now an experiment.&lt;/p&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;station-2-the-dhcp-wait-done-properly&quot;&gt;Station 2: the DHCP wait, done properly&lt;/h2&gt;
&lt;p&gt;A freshly booted container can exist before its network does — ask for its IP too early and there’s simply no lease yet. The tempting fix is &lt;code&gt;sleep 10&lt;/code&gt;, which is how you end up with a script that works except when it doesn’t. The clean fix is a command that &lt;em&gt;blocks until the condition is true&lt;/em&gt;: run the interface bring-up inside the container and let it return when the lease exists.&lt;/p&gt;
&lt;figure class=&quot;terminal&quot; data-astro-cid-kenkipkp&gt;&lt;figcaption class=&quot;terminal-bar&quot; data-astro-cid-kenkipkp&gt;&lt;span class=&quot;dot red&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot yellow&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot green&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;terminal-title&quot; data-astro-cid-kenkipkp&gt;Wait for the network by asking for it, then read the address&lt;/span&gt;&lt;button class=&quot;terminal-copy&quot; type=&quot;button&quot; aria-label=&quot;Copy commands&quot; data-astro-cid-kenkipkp&gt;&lt;svg width=&quot;13&quot; height=&quot;13&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;none&quot; stroke=&quot;currentColor&quot; stroke-width=&quot;2&quot; stroke-linecap=&quot;round&quot; stroke-linejoin=&quot;round&quot; aria-hidden=&quot;true&quot; data-astro-cid-kenkipkp&gt;&lt;rect x=&quot;9&quot; y=&quot;9&quot; width=&quot;13&quot; height=&quot;13&quot; rx=&quot;2&quot; data-astro-cid-kenkipkp&gt;&lt;/rect&gt;&lt;path d=&quot;M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1&quot; data-astro-cid-kenkipkp&gt;&lt;/path&gt;&lt;/svg&gt;&lt;span class=&quot;terminal-copy-label&quot; data-astro-cid-kenkipkp&gt;Copy&lt;/span&gt;&lt;/button&gt;&lt;/figcaption&gt;&lt;pre class=&quot;terminal-body&quot; data-astro-cid-kenkipkp&gt;&lt;code data-astro-cid-kenkipkp&gt;
# on the node: blocks until eth0 is up with its lease
pct exec 9001 -- ifup eth0

# now this reliably has an answer
pct exec 9001 -- ip -4 addr show eth0
&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;
&lt;div class=&quot;callout callout-tip&quot; data-astro-cid-q2ml7llr&gt;&lt;div class=&quot;callout-header&quot; data-astro-cid-q2ml7llr&gt;&lt;span class=&quot;callout-icon&quot; aria-hidden=&quot;true&quot; data-astro-cid-q2ml7llr&gt;💡&lt;/span&gt;&lt;span class=&quot;callout-label&quot; data-astro-cid-q2ml7llr&gt;Prefer blocking commands to sleeps&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;callout-body&quot; data-astro-cid-q2ml7llr&gt;&lt;p&gt;Any time a script needs to wait, look for a command that returns when the thing is ready rather than a timer you hope is long enough. Timers encode a guess about a machine’s speed; blocking commands encode the actual condition. The guess is the thing that breaks six months later on a busy node.&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;station-3-a-real-converge-at-last&quot;&gt;Station 3: a real converge, at last&lt;/h2&gt;
&lt;p&gt;&lt;a href=&quot;https://peira.dev/blog/proxmox-ansible-capture-then-assert/&quot;&gt;Part three&lt;/a&gt; left an honest debt: the node roles had only ever been check-run against machines that were already configured. This is the repayment. The scratch playbook is small but each task is chosen to &lt;em&gt;prove&lt;/em&gt; something, not just to do something: refresh the package cache (proves DNS, routing, and egress all work), install a package (proves the whole apt path), and write a marker file (proves file convergence).&lt;/p&gt;
&lt;figure class=&quot;terminal&quot; data-astro-cid-kenkipkp&gt;&lt;figcaption class=&quot;terminal-bar&quot; data-astro-cid-kenkipkp&gt;&lt;span class=&quot;dot red&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot yellow&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot green&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;terminal-title&quot; data-astro-cid-kenkipkp&gt;Point Ansible at one ad-hoc host — note the trailing comma&lt;/span&gt;&lt;button class=&quot;terminal-copy&quot; type=&quot;button&quot; aria-label=&quot;Copy commands&quot; data-astro-cid-kenkipkp&gt;&lt;svg width=&quot;13&quot; height=&quot;13&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;none&quot; stroke=&quot;currentColor&quot; stroke-width=&quot;2&quot; stroke-linecap=&quot;round&quot; stroke-linejoin=&quot;round&quot; aria-hidden=&quot;true&quot; data-astro-cid-kenkipkp&gt;&lt;rect x=&quot;9&quot; y=&quot;9&quot; width=&quot;13&quot; height=&quot;13&quot; rx=&quot;2&quot; data-astro-cid-kenkipkp&gt;&lt;/rect&gt;&lt;path d=&quot;M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1&quot; data-astro-cid-kenkipkp&gt;&lt;/path&gt;&lt;/svg&gt;&lt;span class=&quot;terminal-copy-label&quot; data-astro-cid-kenkipkp&gt;Copy&lt;/span&gt;&lt;/button&gt;&lt;/figcaption&gt;&lt;pre class=&quot;terminal-body&quot; data-astro-cid-kenkipkp&gt;&lt;code data-astro-cid-kenkipkp&gt;
ansible-playbook scratch.yml -i &amp;quot;10.0.0.201,&amp;quot;

# PLAY RECAP
# 10.0.0.201 : ok=3  changed=3  failed=0
&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;
&lt;p&gt;The trailing comma is doing quiet work: it tells Ansible the &lt;code&gt;-i&lt;/code&gt; value is a literal list of hosts rather than the path to an inventory file. And this recap line is the mirror image of part three’s — there, &lt;code&gt;changed=0&lt;/code&gt; was the goal, because the code was asserting an existing reality. Here &lt;code&gt;changed=3&lt;/code&gt; is the goal, because the code just &lt;em&gt;made&lt;/em&gt; reality on a machine that had none. Same roles-and-tasks machinery, both directions proven.&lt;/p&gt;
&lt;p&gt;I then did the thing you should always do after automation reports success: logged in over SSH and looked with my own eyes. The package was there; the marker file said what the code said it would say.&lt;/p&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;stations-4-and-5-death-and-cleanup&quot;&gt;Stations 4 and 5: death and cleanup&lt;/h2&gt;
&lt;p&gt;The destroy is the station people flinch at, so it’s worth being precise about what the command can reach. &lt;a href=&quot;https://opentofu.org/docs/cli/commands/destroy/&quot;&gt;OpenTofu’s documentation&lt;/a&gt; describes &lt;code&gt;tofu destroy&lt;/code&gt; as the way “to destroy all remote objects managed by a particular OpenTofu configuration” — &lt;em&gt;this&lt;/em&gt; configuration, &lt;em&gt;this&lt;/em&gt; state. Our state contains exactly one object. Production lives in a different directory, in a different state, where &lt;a href=&quot;https://peira.dev/blog/proxmox-opentofu-import-plan-zero/&quot;&gt;every resource additionally carries &lt;code&gt;prevent_destroy&lt;/code&gt;&lt;/a&gt; and the standing credential can’t write anyway. Three independent walls.&lt;/p&gt;
&lt;figure class=&quot;terminal&quot; data-astro-cid-kenkipkp&gt;&lt;figcaption class=&quot;terminal-bar&quot; data-astro-cid-kenkipkp&gt;&lt;span class=&quot;dot red&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot yellow&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot green&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;terminal-title&quot; data-astro-cid-kenkipkp&gt;Destroy, then confirm with a different tool than the one that promised&lt;/span&gt;&lt;button class=&quot;terminal-copy&quot; type=&quot;button&quot; aria-label=&quot;Copy commands&quot; data-astro-cid-kenkipkp&gt;&lt;svg width=&quot;13&quot; height=&quot;13&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;none&quot; stroke=&quot;currentColor&quot; stroke-width=&quot;2&quot; stroke-linecap=&quot;round&quot; stroke-linejoin=&quot;round&quot; aria-hidden=&quot;true&quot; data-astro-cid-kenkipkp&gt;&lt;rect x=&quot;9&quot; y=&quot;9&quot; width=&quot;13&quot; height=&quot;13&quot; rx=&quot;2&quot; data-astro-cid-kenkipkp&gt;&lt;/rect&gt;&lt;path d=&quot;M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1&quot; data-astro-cid-kenkipkp&gt;&lt;/path&gt;&lt;/svg&gt;&lt;span class=&quot;terminal-copy-label&quot; data-astro-cid-kenkipkp&gt;Copy&lt;/span&gt;&lt;/button&gt;&lt;/figcaption&gt;&lt;pre class=&quot;terminal-body&quot; data-astro-cid-kenkipkp&gt;&lt;code data-astro-cid-kenkipkp&gt;
tofu destroy
# Destroy complete! Resources: 1 destroyed.

# trust, then verify against the platform itself:
pct list        # 9001 is gone
&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;
&lt;p&gt;Then the identity follows its container into oblivion, and the last footprints get swept:&lt;/p&gt;
&lt;figure class=&quot;terminal&quot; data-astro-cid-kenkipkp&gt;&lt;figcaption class=&quot;terminal-bar&quot; data-astro-cid-kenkipkp&gt;&lt;span class=&quot;dot red&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot yellow&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot green&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;terminal-title&quot; data-astro-cid-kenkipkp&gt;Leave nothing behind&lt;/span&gt;&lt;button class=&quot;terminal-copy&quot; type=&quot;button&quot; aria-label=&quot;Copy commands&quot; data-astro-cid-kenkipkp&gt;&lt;svg width=&quot;13&quot; height=&quot;13&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;none&quot; stroke=&quot;currentColor&quot; stroke-width=&quot;2&quot; stroke-linecap=&quot;round&quot; stroke-linejoin=&quot;round&quot; aria-hidden=&quot;true&quot; data-astro-cid-kenkipkp&gt;&lt;rect x=&quot;9&quot; y=&quot;9&quot; width=&quot;13&quot; height=&quot;13&quot; rx=&quot;2&quot; data-astro-cid-kenkipkp&gt;&lt;/rect&gt;&lt;path d=&quot;M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1&quot; data-astro-cid-kenkipkp&gt;&lt;/path&gt;&lt;/svg&gt;&lt;span class=&quot;terminal-copy-label&quot; data-astro-cid-kenkipkp&gt;Copy&lt;/span&gt;&lt;/button&gt;&lt;/figcaption&gt;&lt;pre class=&quot;terminal-body&quot; data-astro-cid-kenkipkp&gt;&lt;code data-astro-cid-kenkipkp&gt;
# deleting the user drops its ACLs and its token with it
pveum user delete scratch@pve
pveum acl list     # confirm: no scratch entries remain

# the scratch guest&amp;#39;s host key is stale the moment it died
ssh-keygen -R 10.0.0.201
&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;
&lt;p&gt;The &lt;code&gt;known_hosts&lt;/code&gt; line is small but real hygiene: the next guest to get that DHCP address will present a different host key, and future-you doesn’t need a scary key-mismatch warning caused by a container that no longer exists.&lt;/p&gt;
&lt;p&gt;Final station: &lt;code&gt;tofu plan&lt;/code&gt; in the &lt;em&gt;production&lt;/em&gt; directory. &lt;code&gt;No changes.&lt;/code&gt; The entire lifecycle happened beside production, not near it.&lt;/p&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;what-this-proved--and-the-one-thing-it-didnt&quot;&gt;What this proved — and the one thing it didn’t&lt;/h2&gt;
&lt;p&gt;The description can build. Create path: proven. Configure path on a blank machine: proven. Destroy path: proven, with the blast walls holding. The write-access pattern — disposable user, scoped ACLs, shell-only secret, delete after — is now a tested recipe rather than a theory, and it’s the same recipe I’ll reach for any time code needs temporary hands.&lt;/p&gt;
&lt;p&gt;What it didn’t prove: scale and composition. One 512 MB container is not nine production guests with their real contents, and a lifecycle test says nothing about restoring the &lt;em&gt;data&lt;/em&gt; inside those guests — that’s &lt;a href=&quot;https://peira.dev/blog/proxmox-backup-server/&quot;&gt;backup territory&lt;/a&gt;, and it always was. The finale composes everything this series has built — the imported definitions, the captured roles, the proven loop, and the backups — into the scenario that justifies all of it: &lt;a href=&quot;https://peira.dev/blog/proxmox-rebuild-from-zero/&quot;&gt;the cluster is gone; rebuild it from zero&lt;/a&gt;.&lt;/p&gt;
&lt;hr/&gt;
&lt;p&gt;&lt;strong&gt;Related posts:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/proxmox-cluster-as-code-import-first/&quot;&gt;Turn a Hand-Built Proxmox Cluster Into Code Without Breaking It&lt;/a&gt; — part one: the read-only posture this test had to deliberately (and temporarily) step around&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/proxmox-opentofu-import-plan-zero/&quot;&gt;Import a Live Proxmox Cluster Into OpenTofu With Zero Changes&lt;/a&gt; — part two: where production’s separate state and prevent_destroy walls were built&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/proxmox-ansible-capture-then-assert/&quot;&gt;Ansible for a Cluster You Built by Hand: Capture, Then Assert&lt;/a&gt; — part three: the roles whose create-path debt this post repays&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/proxmox-rebuild-from-zero/&quot;&gt;Rebuild Your Homelab From Zero: What Survives a Total Loss&lt;/a&gt; — part five: the disaster this rehearsal exists for&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/proxmox-create-lxc/&quot;&gt;Creating Your First Proxmox LXC Container: Step-by-Step&lt;/a&gt; — the same container birth, done by hand so you know what the code is doing&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/proxmox-security-hardening/&quot;&gt;Proxmox Security Hardening: SSH Keys, Firewall, and Locking Down a New Cluster&lt;/a&gt; — the wider access model the scoped-user pattern belongs to&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/proxmox-backup-server/&quot;&gt;Proxmox Backup Server: Automated CT and VM Backups with Deduplication&lt;/a&gt; — what protects the part of production no lifecycle test can: the data&lt;/li&gt;
&lt;/ul&gt;</content:encoded><category>proxmox</category><category>automation</category><category>lxc</category></item><item><title>Ansible for a Cluster You Built by Hand: Capture, Then Assert</title><link>https://peira.dev/blog/proxmox-ansible-capture-then-assert/</link><guid isPermaLink="true">https://peira.dev/blog/proxmox-ansible-capture-then-assert/</guid><description>Bring hand-configured Proxmox nodes under Ansible without changing them — capture live config into roles and make check mode your acceptance test.</description><pubDate>Thu, 30 Jul 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;In &lt;a href=&quot;https://peira.dev/blog/proxmox-opentofu-import-plan-zero/&quot;&gt;part two&lt;/a&gt; I brought nine running containers under &lt;a href=&quot;https://opentofu.org/&quot;&gt;OpenTofu&lt;/a&gt; without touching one of them, and ended with a clean plan as a standing drift detector. But that only describes half the cluster. The containers sit on four physical nodes, and those nodes carry months of accumulated hand-configuration: a backup hook I wrote at midnight, a metrics exporter I installed one way on one node and swore I’d installed the same way on the others, watchdog scripts, network tweaks. None of it written down anywhere except the machines themselves.&lt;/p&gt;
&lt;p&gt;This post is about codifying that half with &lt;a href=&quot;https://docs.ansible.com/&quot;&gt;Ansible&lt;/a&gt; — and about the trick that makes it safe on a live cluster: &lt;strong&gt;don’t tell your nodes what to be. Ask them what they are, write that down, and then hold them to it.&lt;/strong&gt;&lt;/p&gt;
&lt;figure&gt;&lt;img src=&quot;https://peira.dev/_astro/HP_EliteDesk_800_35_W_G2_mini_002.k0HrOavK_Z21YFi4.webp&quot; alt=&quot;An HP EliteDesk 800 G2 mini PC, a one-litre desktop commonly used as a homelab node&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot; width=&quot;900&quot; height=&quot;880&quot;&gt;&lt;figcaption&gt;The kind of machine this series is about: a one-litre HP EliteDesk mini (an 800 G2 pictured here as an example — my nodes are 705 G4s). Photo: Miguel Durán, Museo8bits, &lt;a href=&quot;https://creativecommons.org/licenses/by-sa/4.0/&quot;&gt;CC BY-SA 4.0&lt;/a&gt;, via &lt;a href=&quot;https://commons.wikimedia.org/wiki/File:HP_EliteDesk_800_35_W_G2_mini_002.jpg&quot;&gt;Wikimedia Commons&lt;/a&gt;.&lt;/figcaption&gt;&lt;/figure&gt;
&lt;div class=&quot;callout callout-warning&quot; data-astro-cid-q2ml7llr&gt;&lt;div class=&quot;callout-header&quot; data-astro-cid-q2ml7llr&gt;&lt;span class=&quot;callout-icon&quot; aria-hidden=&quot;true&quot; data-astro-cid-q2ml7llr&gt;⚠️&lt;/span&gt;&lt;span class=&quot;callout-label&quot; data-astro-cid-q2ml7llr&gt;First: make these values your own&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;callout-body&quot; data-astro-cid-q2ml7llr&gt;&lt;p&gt;Everything below uses stand-in values. &lt;code&gt;10.0.0.70&lt;/code&gt; through &lt;code&gt;10.0.0.73&lt;/code&gt; are documentation addresses — use your own node IPs. &lt;code&gt;pvelab01&lt;/code&gt; through &lt;code&gt;pvelab04&lt;/code&gt; are my node names. The role names (&lt;code&gt;backup-hooks&lt;/code&gt;, &lt;code&gt;node-exporter&lt;/code&gt;, &lt;code&gt;tailscale&lt;/code&gt;, &lt;code&gt;watchdogs&lt;/code&gt;) describe &lt;em&gt;my&lt;/em&gt; nodes’ jobs; yours will have different ones. Any path like &lt;code&gt;/root/.my-secrets&lt;/code&gt; stands for your own secret store, and the values inside it never enter the repository. If a value looks specific to one machine, it’s a placeholder to change — not a literal to copy.&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;two-ways-to-meet-ansible&quot;&gt;Two ways to meet Ansible&lt;/h2&gt;
&lt;p&gt;Almost every Ansible tutorial assumes a fresh server: here’s a blank machine, here’s a role, run it and the machine becomes what the role says. That direction — &lt;em&gt;impose&lt;/em&gt; — is exactly wrong for a cluster that already works. Running an imposing role against a hand-built node is how you find out, at the worst moment, that the role’s idea of “configured” and the node’s idea of “configured” were never the same thing.&lt;/p&gt;
&lt;p&gt;The direction that works is the reverse. &lt;strong&gt;Capture&lt;/strong&gt; what the live nodes actually do, commit that as the role, and then use Ansible’s check mode to &lt;strong&gt;assert&lt;/strong&gt; that the nodes still match. The code starts life agreeing with reality by construction, because it was copied from reality.&lt;/p&gt;
&lt;svg viewBox=&quot;0 0 720 320&quot; width=&quot;100%&quot; role=&quot;img&quot; aria-label=&quot;Two directions of configuration management. Impose: a role written from scratch pushes configuration onto a blank server, risky against a live node. Capture then assert: files are fetched from the live node into the role, secret-scanned, and check mode then confirms the node still matches. The safe direction points from the machine to the code first.&quot;&gt;&lt;rect width=&quot;720&quot; height=&quot;320&quot; rx=&quot;12&quot; fill=&quot;#1c1c32&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;360&quot; y=&quot;30&quot; text-anchor=&quot;middle&quot; fill=&quot;#f4f4f5&quot; font-size=&quot;15&quot; font-weight=&quot;bold&quot;&gt;Impose vs capture-then-assert&lt;/text&gt;&lt;rect x=&quot;24&quot; y=&quot;52&quot; width=&quot;330&quot; height=&quot;240&quot; rx=&quot;10&quot; fill=&quot;#242440&quot; stroke=&quot;#3d3d5c&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;189&quot; y=&quot;78&quot; text-anchor=&quot;middle&quot; fill=&quot;#ef4444&quot; font-size=&quot;12&quot; font-weight=&quot;bold&quot;&gt;impose (fresh-server tutorials)&lt;/text&gt;&lt;rect x=&quot;52&quot; y=&quot;96&quot; width=&quot;120&quot; height=&quot;52&quot; rx=&quot;8&quot; fill=&quot;#2c2c4c&quot; stroke=&quot;#3d3d5c&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;112&quot; y=&quot;118&quot; text-anchor=&quot;middle&quot; fill=&quot;#e5e7eb&quot; font-size=&quot;11&quot;&gt;role, written&lt;/text&gt;&lt;text x=&quot;112&quot; y=&quot;134&quot; text-anchor=&quot;middle&quot; fill=&quot;#a1a1aa&quot; font-size=&quot;10&quot;&gt;from scratch&lt;/text&gt;&lt;rect x=&quot;216&quot; y=&quot;96&quot; width=&quot;120&quot; height=&quot;52&quot; rx=&quot;8&quot; fill=&quot;#30305a&quot; stroke=&quot;#4d4d82&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;276&quot; y=&quot;118&quot; text-anchor=&quot;middle&quot; fill=&quot;#e5e7eb&quot; font-size=&quot;11&quot;&gt;live node&lt;/text&gt;&lt;text x=&quot;276&quot; y=&quot;134&quot; text-anchor=&quot;middle&quot; fill=&quot;#a1a1aa&quot; font-size=&quot;10&quot;&gt;months of history&lt;/text&gt;&lt;path d=&quot;M 172 122 L 210 122&quot; stroke=&quot;#ef4444&quot; stroke-width=&quot;2&quot; marker-end=&quot;url(#capArrowRed)&quot;&gt;&lt;/path&gt;&lt;text x=&quot;189&quot; y=&quot;180&quot; text-anchor=&quot;middle&quot; fill=&quot;#a1a1aa&quot; font-size=&quot;10&quot;&gt;the role’s guess overwrites&lt;/text&gt;&lt;text x=&quot;189&quot; y=&quot;196&quot; text-anchor=&quot;middle&quot; fill=&quot;#a1a1aa&quot; font-size=&quot;10&quot;&gt;whatever the node really was —&lt;/text&gt;&lt;text x=&quot;189&quot; y=&quot;212&quot; text-anchor=&quot;middle&quot; fill=&quot;#ef4444&quot; font-size=&quot;10&quot;&gt;you learn the difference afterwards&lt;/text&gt;&lt;rect x=&quot;366&quot; y=&quot;52&quot; width=&quot;330&quot; height=&quot;240&quot; rx=&quot;10&quot; fill=&quot;#242440&quot; stroke=&quot;#3d3d5c&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;531&quot; y=&quot;78&quot; text-anchor=&quot;middle&quot; fill=&quot;#22c55e&quot; font-size=&quot;12&quot; font-weight=&quot;bold&quot;&gt;capture, then assert (this post)&lt;/text&gt;&lt;rect x=&quot;394&quot; y=&quot;96&quot; width=&quot;120&quot; height=&quot;52&quot; rx=&quot;8&quot; fill=&quot;#30305a&quot; stroke=&quot;#4d4d82&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;454&quot; y=&quot;118&quot; text-anchor=&quot;middle&quot; fill=&quot;#e5e7eb&quot; font-size=&quot;11&quot;&gt;live node&lt;/text&gt;&lt;text x=&quot;454&quot; y=&quot;134&quot; text-anchor=&quot;middle&quot; fill=&quot;#a1a1aa&quot; font-size=&quot;10&quot;&gt;the authority&lt;/text&gt;&lt;rect x=&quot;558&quot; y=&quot;96&quot; width=&quot;120&quot; height=&quot;52&quot; rx=&quot;8&quot; fill=&quot;#2c2c4c&quot; stroke=&quot;#14b8a6&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;618&quot; y=&quot;118&quot; text-anchor=&quot;middle&quot; fill=&quot;#e5e7eb&quot; font-size=&quot;11&quot;&gt;role, captured&lt;/text&gt;&lt;text x=&quot;618&quot; y=&quot;134&quot; text-anchor=&quot;middle&quot; fill=&quot;#a1a1aa&quot; font-size=&quot;10&quot;&gt;secret-scanned&lt;/text&gt;&lt;path d=&quot;M 514 112 L 552 112&quot; stroke=&quot;#14b8a6&quot; stroke-width=&quot;2&quot; marker-end=&quot;url(#capArrowTeal)&quot;&gt;&lt;/path&gt;&lt;path d=&quot;M 552 132 L 514 132&quot; stroke=&quot;#818cf8&quot; stroke-width=&quot;2&quot; stroke-dasharray=&quot;4 3&quot; marker-end=&quot;url(#capArrowIndigo)&quot;&gt;&lt;/path&gt;&lt;text x=&quot;533&quot; y=&quot;176&quot; text-anchor=&quot;middle&quot; fill=&quot;#818cf8&quot; font-size=&quot;10&quot;&gt;check mode asserts, never writes&lt;/text&gt;&lt;rect x=&quot;415&quot; y=&quot;196&quot; width=&quot;232&quot; height=&quot;40&quot; rx=&quot;8&quot; fill=&quot;#2c2c4c&quot; stroke=&quot;#22c55e&quot; stroke-width=&quot;1.5&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;531&quot; y=&quot;221&quot; text-anchor=&quot;middle&quot; fill=&quot;#22c55e&quot; font-size=&quot;12&quot; font-weight=&quot;bold&quot;&gt;changed: 0 on every node&lt;/text&gt;&lt;text x=&quot;531&quot; y=&quot;264&quot; text-anchor=&quot;middle&quot; fill=&quot;#a1a1aa&quot; font-size=&quot;10&quot;&gt;code agrees with reality by construction&lt;/text&gt;&lt;defs&gt;&lt;marker id=&quot;capArrowRed&quot; viewBox=&quot;0 0 10 10&quot; refX=&quot;9&quot; refY=&quot;5&quot; markerWidth=&quot;5&quot; markerHeight=&quot;5&quot; orient=&quot;auto-start-reverse&quot;&gt;&lt;path d=&quot;M 0 0 L 10 5 L 0 10 z&quot; fill=&quot;#ef4444&quot;&gt;&lt;/path&gt;&lt;/marker&gt;&lt;marker id=&quot;capArrowTeal&quot; viewBox=&quot;0 0 10 10&quot; refX=&quot;9&quot; refY=&quot;5&quot; markerWidth=&quot;5&quot; markerHeight=&quot;5&quot; orient=&quot;auto-start-reverse&quot;&gt;&lt;path d=&quot;M 0 0 L 10 5 L 0 10 z&quot; fill=&quot;#14b8a6&quot;&gt;&lt;/path&gt;&lt;/marker&gt;&lt;marker id=&quot;capArrowIndigo&quot; viewBox=&quot;0 0 10 10&quot; refX=&quot;9&quot; refY=&quot;5&quot; markerWidth=&quot;5&quot; markerHeight=&quot;5&quot; orient=&quot;auto-start-reverse&quot;&gt;&lt;path d=&quot;M 0 0 L 10 5 L 0 10 z&quot; fill=&quot;#818cf8&quot;&gt;&lt;/path&gt;&lt;/marker&gt;&lt;/defs&gt;&lt;/svg&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;decide-whats-worth-asserting&quot;&gt;Decide what’s worth asserting&lt;/h2&gt;
&lt;p&gt;My first instinct was to manage everything — package lists, sysctls, the works. I talked myself out of it, and I’m glad I did. Every task you add is a claim you have to keep true on four machines forever. The things actually worth that cost are the things that exist nowhere else: the artifacts I built myself.&lt;/p&gt;
&lt;p&gt;For my nodes that came to four roles:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;backup-hooks&lt;/strong&gt; — a hook script every backup job runs (it quiesces databases before a snapshot; the jobs themselves stay managed by &lt;a href=&quot;https://www.proxmox.com/en/proxmox-virtual-environment&quot;&gt;Proxmox VE&lt;/a&gt;, by design — more on that boundary below)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;node-exporter&lt;/strong&gt; — the &lt;a href=&quot;https://github.com/prometheus/node_exporter&quot;&gt;Prometheus node_exporter&lt;/a&gt; service that feeds &lt;a href=&quot;https://peira.dev/blog/node-exporter-grafana-proxmox/&quot;&gt;my Grafana dashboards&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;tailscale&lt;/strong&gt; — the config that makes one node a &lt;a href=&quot;https://peira.dev/blog/tailscale-subnet-router/&quot;&gt;subnet router&lt;/a&gt;: a forwarding sysctl and a small network-tuning unit&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;watchdogs&lt;/strong&gt; — the health-check and reporting scripts, and the cron file that schedules them&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Base OS state — apt repositories, kernel versions, the distribution’s own defaults — deliberately stays out of version one. The installer owns it, I’ve never audited it, and asserting things you’ve never audited is how a drift detector becomes a noise machine.&lt;/p&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;capture-copy-reality-into-the-repo&quot;&gt;Capture: copy reality into the repo&lt;/h2&gt;
&lt;p&gt;The capture step is almost embarrassingly literal: fetch the real files off the real nodes into the role’s &lt;code&gt;files/&lt;/code&gt; directory. Not a cleaned-up version you write from memory — the actual bytes the machines run today.&lt;/p&gt;
&lt;figure class=&quot;terminal&quot; data-astro-cid-kenkipkp&gt;&lt;figcaption class=&quot;terminal-bar&quot; data-astro-cid-kenkipkp&gt;&lt;span class=&quot;dot red&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot yellow&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot green&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;terminal-title&quot; data-astro-cid-kenkipkp&gt;Pull the live artifacts into the role (one example)&lt;/span&gt;&lt;button class=&quot;terminal-copy&quot; type=&quot;button&quot; aria-label=&quot;Copy commands&quot; data-astro-cid-kenkipkp&gt;&lt;svg width=&quot;13&quot; height=&quot;13&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;none&quot; stroke=&quot;currentColor&quot; stroke-width=&quot;2&quot; stroke-linecap=&quot;round&quot; stroke-linejoin=&quot;round&quot; aria-hidden=&quot;true&quot; data-astro-cid-kenkipkp&gt;&lt;rect x=&quot;9&quot; y=&quot;9&quot; width=&quot;13&quot; height=&quot;13&quot; rx=&quot;2&quot; data-astro-cid-kenkipkp&gt;&lt;/rect&gt;&lt;path d=&quot;M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1&quot; data-astro-cid-kenkipkp&gt;&lt;/path&gt;&lt;/svg&gt;&lt;span class=&quot;terminal-copy-label&quot; data-astro-cid-kenkipkp&gt;Copy&lt;/span&gt;&lt;/button&gt;&lt;/figcaption&gt;&lt;pre class=&quot;terminal-body&quot; data-astro-cid-kenkipkp&gt;&lt;code data-astro-cid-kenkipkp&gt;
# from the repo root, one file at a time, straight off the node
scp root@10.0.0.73:/usr/local/lib/vzdump-quiesce-hook.sh   ansible/roles/backup-hooks/files/

scp root@10.0.0.73:/etc/cron.d/homelab   ansible/roles/watchdogs/files/homelab.cron
&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;
&lt;p&gt;Before any of it gets committed, every captured file gets scanned for secrets. This matters more with captured files than hand-written ones, because you didn’t compose them line by line at commit time — you wrote them months ago, and whether midnight-you pasted a token into one is exactly the kind of thing present-you doesn’t remember. My scripts passed because of a habit I’d picked up from &lt;a href=&quot;https://peira.dev/blog/second-brain-secrets-discipline/&quot;&gt;running a knowledge base&lt;/a&gt;: they reference a secret-store path like &lt;code&gt;/root/.my-secrets&lt;/code&gt; and read values at runtime, so the files themselves contain names, never values.&lt;/p&gt;
&lt;div class=&quot;callout callout-danger&quot; data-astro-cid-q2ml7llr&gt;&lt;div class=&quot;callout-header&quot; data-astro-cid-q2ml7llr&gt;&lt;span class=&quot;callout-icon&quot; aria-hidden=&quot;true&quot; data-astro-cid-q2ml7llr&gt;🚨&lt;/span&gt;&lt;span class=&quot;callout-label&quot; data-astro-cid-q2ml7llr&gt;Scan the captured files before the first commit&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;callout-body&quot; data-astro-cid-q2ml7llr&gt;&lt;p&gt;A repository is forever in a way a server is not. If a captured file does contain a secret, don’t commit-then-scrub — history keeps a copy. Move the value into your secret store, change the script to read it from there, deploy that fixed script to the node, and capture again. And treat a leaked credential as burned: rotate it, don’t just delete it.&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;assert-tasks-that-put-back-whats-already-there&quot;&gt;Assert: tasks that put back what’s already there&lt;/h2&gt;
&lt;p&gt;With reality captured, the tasks write themselves — copy this file with these permissions, make sure this service is enabled and running. Ansible’s modules are idempotent, meaning they compare desired state to actual state and only act on the difference. Since we captured the desired state &lt;em&gt;from&lt;/em&gt; the actual state, on a healthy node the difference is nothing.&lt;/p&gt;
&lt;figure class=&quot;terminal&quot; data-astro-cid-kenkipkp&gt;&lt;figcaption class=&quot;terminal-bar&quot; data-astro-cid-kenkipkp&gt;&lt;span class=&quot;dot red&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot yellow&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot green&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;terminal-title&quot; data-astro-cid-kenkipkp&gt;ansible/roles/backup-hooks/tasks/main.yml&lt;/span&gt;&lt;button class=&quot;terminal-copy&quot; type=&quot;button&quot; aria-label=&quot;Copy commands&quot; data-astro-cid-kenkipkp&gt;&lt;svg width=&quot;13&quot; height=&quot;13&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;none&quot; stroke=&quot;currentColor&quot; stroke-width=&quot;2&quot; stroke-linecap=&quot;round&quot; stroke-linejoin=&quot;round&quot; aria-hidden=&quot;true&quot; data-astro-cid-kenkipkp&gt;&lt;rect x=&quot;9&quot; y=&quot;9&quot; width=&quot;13&quot; height=&quot;13&quot; rx=&quot;2&quot; data-astro-cid-kenkipkp&gt;&lt;/rect&gt;&lt;path d=&quot;M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1&quot; data-astro-cid-kenkipkp&gt;&lt;/path&gt;&lt;/svg&gt;&lt;span class=&quot;terminal-copy-label&quot; data-astro-cid-kenkipkp&gt;Copy&lt;/span&gt;&lt;/button&gt;&lt;/figcaption&gt;&lt;pre class=&quot;terminal-body&quot; data-astro-cid-kenkipkp&gt;&lt;code data-astro-cid-kenkipkp&gt;
# The backup jobs themselves stay PVE-managed; this role owns the
# hook script every job runs.
- name: vzdump quiesce hook
ansible.builtin.copy:
  src: vzdump-quiesce-hook.sh
  dest: /usr/local/lib/vzdump-quiesce-hook.sh
  owner: root
  group: root
  mode: &amp;quot;0755&amp;quot;
&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;
&lt;figure class=&quot;terminal&quot; data-astro-cid-kenkipkp&gt;&lt;figcaption class=&quot;terminal-bar&quot; data-astro-cid-kenkipkp&gt;&lt;span class=&quot;dot red&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot yellow&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot green&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;terminal-title&quot; data-astro-cid-kenkipkp&gt;ansible/site.yml — the whole cluster in one page&lt;/span&gt;&lt;button class=&quot;terminal-copy&quot; type=&quot;button&quot; aria-label=&quot;Copy commands&quot; data-astro-cid-kenkipkp&gt;&lt;svg width=&quot;13&quot; height=&quot;13&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;none&quot; stroke=&quot;currentColor&quot; stroke-width=&quot;2&quot; stroke-linecap=&quot;round&quot; stroke-linejoin=&quot;round&quot; aria-hidden=&quot;true&quot; data-astro-cid-kenkipkp&gt;&lt;rect x=&quot;9&quot; y=&quot;9&quot; width=&quot;13&quot; height=&quot;13&quot; rx=&quot;2&quot; data-astro-cid-kenkipkp&gt;&lt;/rect&gt;&lt;path d=&quot;M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1&quot; data-astro-cid-kenkipkp&gt;&lt;/path&gt;&lt;/svg&gt;&lt;span class=&quot;terminal-copy-label&quot; data-astro-cid-kenkipkp&gt;Copy&lt;/span&gt;&lt;/button&gt;&lt;/figcaption&gt;&lt;pre class=&quot;terminal-body&quot; data-astro-cid-kenkipkp&gt;&lt;code data-astro-cid-kenkipkp&gt;
- name: PVE nodes (common)
hosts: pve
gather_facts: false
roles:
  - backup-hooks
  - node-exporter
  - tailscale

- name: pvelab04 extras (watchdogs, crons)
hosts: pvelab04
gather_facts: false
roles:
  - watchdogs
&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;
&lt;p&gt;Note the shape: three roles apply to every node, and one node carries extras. That asymmetry is real information about the cluster — one machine is the watchdog and backup keystone — and now it’s written down instead of living in my head.&lt;/p&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;the-acceptance-test-check-mode-with-a-diff&quot;&gt;The acceptance test: check mode with a diff&lt;/h2&gt;
&lt;p&gt;Here’s the moment the whole approach hinges on. Ansible’s check mode is a dry run — in the &lt;a href=&quot;https://docs.ansible.com/ansible/latest/playbook_guide/playbooks_checkmode.html&quot;&gt;official documentation’s words&lt;/a&gt;, “In check mode, Ansible runs without making any changes on remote systems.” Add diff mode and any module that supports it “reports the changes made or, if used with –check, the changes that would have been made.”&lt;/p&gt;
&lt;p&gt;So this command asks every node: &lt;em&gt;do you still match the record?&lt;/em&gt; — and is physically incapable of altering the answer:&lt;/p&gt;
&lt;figure class=&quot;terminal&quot; data-astro-cid-kenkipkp&gt;&lt;figcaption class=&quot;terminal-bar&quot; data-astro-cid-kenkipkp&gt;&lt;span class=&quot;dot red&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot yellow&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot green&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;terminal-title&quot; data-astro-cid-kenkipkp&gt;The drift detector for the node half of the cluster&lt;/span&gt;&lt;button class=&quot;terminal-copy&quot; type=&quot;button&quot; aria-label=&quot;Copy commands&quot; data-astro-cid-kenkipkp&gt;&lt;svg width=&quot;13&quot; height=&quot;13&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;none&quot; stroke=&quot;currentColor&quot; stroke-width=&quot;2&quot; stroke-linecap=&quot;round&quot; stroke-linejoin=&quot;round&quot; aria-hidden=&quot;true&quot; data-astro-cid-kenkipkp&gt;&lt;rect x=&quot;9&quot; y=&quot;9&quot; width=&quot;13&quot; height=&quot;13&quot; rx=&quot;2&quot; data-astro-cid-kenkipkp&gt;&lt;/rect&gt;&lt;path d=&quot;M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1&quot; data-astro-cid-kenkipkp&gt;&lt;/path&gt;&lt;/svg&gt;&lt;span class=&quot;terminal-copy-label&quot; data-astro-cid-kenkipkp&gt;Copy&lt;/span&gt;&lt;/button&gt;&lt;/figcaption&gt;&lt;pre class=&quot;terminal-body&quot; data-astro-cid-kenkipkp&gt;&lt;code data-astro-cid-kenkipkp&gt;
ansible-playbook site.yml --check --diff

# the number that matters in every node&amp;#39;s recap line:
#   pvelab01 : ... changed=0  failed=0
#   pvelab02 : ... changed=0  failed=0
#   pvelab03 : ... changed=0  failed=0
#   pvelab04 : ... changed=0  failed=0
&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;
&lt;p&gt;&lt;code&gt;changed=0&lt;/code&gt; on all four nodes is this post’s version of part two’s &lt;code&gt;No changes.&lt;/code&gt; — and the pair together now covers the whole stack. The OpenTofu plan detects drift in the guest definitions; the check-mode run detects drift on the nodes. I keep both wired into one script (there’s a copy in the &lt;a href=&quot;https://peira.dev/playbooks/cluster-drift-detector/&quot;&gt;cluster drift detector playbook&lt;/a&gt;) so one command answers &lt;em&gt;has anything changed that nobody wrote down?&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;If check mode reports a change, that’s not an error to silence — it’s the tool doing its job. Either the node drifted (fix the node) or you changed something intentionally and didn’t update the role (fix the role). Both are the record and reality disagreeing, which is precisely the thing you built this to catch.&lt;/p&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;the-recon-payoff-my-documentation-was-wrong&quot;&gt;The recon payoff: my documentation was wrong&lt;/h2&gt;
&lt;p&gt;Here’s the part I didn’t expect. Capturing configuration means &lt;em&gt;looking&lt;/em&gt; — actually reading what each node runs rather than what you remember installing. And on my very first role, looking corrected the record.&lt;/p&gt;
&lt;p&gt;My notes said the metrics exporter came from the Debian package. So the role’s first draft asserted the package. Check mode immediately disagreed — because on every single node the running exporter was an upstream binary under a hand-written systemd unit, and on one node the Debian package existed only as a ghost: removed, with configuration files left behind. That residual state is real enough that &lt;a href=&quot;https://man7.org/linux/man-pages/man1/dpkg.1.html&quot;&gt;dpkg has a name for it&lt;/a&gt; — &lt;code&gt;config-files&lt;/code&gt;, meaning “Only the configuration files or the postrm script and the data it needs to remove of the package exist on the system.”&lt;/p&gt;
&lt;svg viewBox=&quot;0 0 720 280&quot; width=&quot;100%&quot; role=&quot;img&quot; aria-label=&quot;One node examined during capture. The running service is an upstream node exporter binary under a hand-written systemd unit, shown active. Below it a faded dashed box shows the Debian package in dpkg config-files state, a ghost that is not running anything. The written documentation pointed at the ghost.&quot;&gt;&lt;rect width=&quot;720&quot; height=&quot;280&quot; rx=&quot;12&quot; fill=&quot;#1c1c32&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;360&quot; y=&quot;30&quot; text-anchor=&quot;middle&quot; fill=&quot;#f4f4f5&quot; font-size=&quot;15&quot; font-weight=&quot;bold&quot;&gt;What capture found on one node&lt;/text&gt;&lt;rect x=&quot;60&quot; y=&quot;56&quot; width=&quot;600&quot; height=&quot;86&quot; rx=&quot;10&quot; fill=&quot;#2c2c4c&quot; stroke=&quot;#22c55e&quot; stroke-width=&quot;1.5&quot;&gt;&lt;/rect&gt;&lt;circle cx=&quot;96&quot; cy=&quot;99&quot; r=&quot;7&quot; fill=&quot;#22c55e&quot;&gt;&lt;/circle&gt;&lt;text x=&quot;120&quot; y=&quot;88&quot; fill=&quot;#f4f4f5&quot; font-size=&quot;12&quot; font-weight=&quot;bold&quot;&gt;node_exporter — upstream binary, hand-written unit&lt;/text&gt;&lt;text x=&quot;120&quot; y=&quot;108&quot; fill=&quot;#a1a1aa&quot; font-size=&quot;10&quot;&gt;actually running, actually serving metrics, on every node&lt;/text&gt;&lt;text x=&quot;120&quot; y=&quot;126&quot; fill=&quot;#14b8a6&quot; font-size=&quot;10&quot;&gt;what the role now asserts&lt;/text&gt;&lt;rect x=&quot;60&quot; y=&quot;166&quot; width=&quot;600&quot; height=&quot;72&quot; rx=&quot;10&quot; fill=&quot;#242440&quot; stroke=&quot;#3d3d5c&quot; stroke-dasharray=&quot;6 4&quot;&gt;&lt;/rect&gt;&lt;circle cx=&quot;96&quot; cy=&quot;202&quot; r=&quot;7&quot; fill=&quot;#1c1c32&quot; stroke=&quot;#6b7280&quot;&gt;&lt;/circle&gt;&lt;text x=&quot;120&quot; y=&quot;196&quot; fill=&quot;#9ca3af&quot; font-size=&quot;12&quot; font-weight=&quot;bold&quot;&gt;prometheus-node-exporter — dpkg state: config-files&lt;/text&gt;&lt;text x=&quot;120&quot; y=&quot;216&quot; fill=&quot;#6b7280&quot; font-size=&quot;10&quot;&gt;a ghost: package removed, leftover config only — running nothing&lt;/text&gt;&lt;text x=&quot;640&quot; y=&quot;196&quot; text-anchor=&quot;end&quot; fill=&quot;#f59e0b&quot; font-size=&quot;10&quot;&gt;← what my notes claimed&lt;/text&gt;&lt;text x=&quot;360&quot; y=&quot;264&quot; text-anchor=&quot;middle&quot; fill=&quot;#a1a1aa&quot; font-size=&quot;10&quot;&gt;writing the code didn’t just record the truth — it corrected the documentation&lt;/text&gt;&lt;/svg&gt;
&lt;p&gt;I’d been carrying a wrong belief about my own cluster for months, and no amount of &lt;em&gt;remembering harder&lt;/em&gt; would have caught it. The act of codifying did. That’s the quiet, unadvertised benefit of this whole exercise: the code is a form of audit, and the first capture pass is the most honest inventory your nodes will ever get.&lt;/p&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;the-boundary-what-stays-manual-on-purpose&quot;&gt;The boundary: what stays manual, on purpose&lt;/h2&gt;
&lt;p&gt;Same honesty as &lt;a href=&quot;https://peira.dev/blog/proxmox-cluster-as-code-import-first/&quot;&gt;part one&lt;/a&gt;: the code covers what it covers, and pretending otherwise makes the record worse than useless. On my cluster the node roles own the hook script, the exporter service, the subnet-router config, and the watchdog fleet. They deliberately do &lt;em&gt;not&lt;/em&gt; own:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;The backup jobs themselves.&lt;/strong&gt; Proxmox stores job definitions in its cluster-replicated config, and the web interface is genuinely the better editor for them. The role asserts the hook script the jobs call — the part I wrote — and leaves the scheduling to the platform. &lt;a href=&quot;https://peira.dev/blog/proxmox-backup-server/&quot;&gt;The backup system&lt;/a&gt; is its own pillar.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Cluster formation and storage layout.&lt;/strong&gt; One-time installer-driven acts, covered in the finale when we rebuild from nothing.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Base OS state.&lt;/strong&gt; Unaudited, installer-owned, version two’s problem at the earliest.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;One more honest caveat: these roles have only ever been &lt;em&gt;check&lt;/em&gt;-run against nodes that were already configured. They assert reality; they’ve never had to create it on a blank machine. That untested half matters, and testing it is exactly where this series goes next — &lt;a href=&quot;https://peira.dev/blog/opentofu-scratch-container-lifecycle/&quot;&gt;part four&lt;/a&gt; builds a real container from code, points Ansible at it for a genuine converge, and then destroys it, proving the whole loop while production stays untouched.&lt;/p&gt;
&lt;hr/&gt;
&lt;p&gt;&lt;strong&gt;Related posts:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/proxmox-cluster-as-code-import-first/&quot;&gt;Turn a Hand-Built Proxmox Cluster Into Code Without Breaking It&lt;/a&gt; — part one: the import-first posture and the read-only credential&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/proxmox-opentofu-import-plan-zero/&quot;&gt;Import a Live Proxmox Cluster Into OpenTofu With Zero Changes&lt;/a&gt; — part two: the guest half of the drift detector this post completes&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/opentofu-scratch-container-lifecycle/&quot;&gt;A Proxmox Container Born, Configured, and Destroyed From Code&lt;/a&gt; — part four: proving these descriptions can actually build something&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/proxmox-rebuild-from-zero/&quot;&gt;Rebuild Your Homelab From Zero: What Survives a Total Loss&lt;/a&gt; — part five: where the captured roles earn their keep&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/node-exporter-grafana-proxmox/&quot;&gt;Node Exporter + pve-exporter: Complete Proxmox Metrics in Grafana&lt;/a&gt; — the exporter these roles turned out to be running from upstream&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/tailscale-subnet-router/&quot;&gt;Tailscale Subnet Router: Remote Homelab Access Without Port Forwarding&lt;/a&gt; — the service behind the tailscale role’s forwarding sysctl&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/second-brain-secrets-discipline/&quot;&gt;Never Commit a Secret to Your Knowledge Base&lt;/a&gt; — the names-not-values habit that made the capture scan boring&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/proxmox-backup-server/&quot;&gt;Proxmox Backup Server: Automated CT and VM Backups with Deduplication&lt;/a&gt; — the jobs that stay platform-managed while the hook becomes code&lt;/li&gt;
&lt;/ul&gt;</content:encoded><category>proxmox</category><category>automation</category><category>infrastructure</category></item><item><title>Rebuild Your Homelab From Zero: What Survives a Total Loss</title><link>https://peira.dev/blog/proxmox-rebuild-from-zero/</link><guid isPermaLink="true">https://peira.dev/blog/proxmox-rebuild-from-zero/</guid><description>A Proxmox disaster-recovery walkthrough — what survives losing every node, the restore order that unblocks itself, and two commands that prove the rebuild.</description><pubDate>Thu, 30 Jul 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Every post in this series has been building toward one question, and it’s time to ask it plainly: &lt;strong&gt;if the office where my cluster lives burned down tonight, could I get it all back?&lt;/strong&gt; Not one dead node — &lt;a href=&quot;https://peira.dev/blog/homelab-capacity-planning-node-failure/&quot;&gt;that’s a capacity-planning problem&lt;/a&gt;, and a much gentler one. All four nodes, gone, along with every disk in them.&lt;/p&gt;
&lt;p&gt;This is the post where &lt;a href=&quot;https://peira.dev/blog/proxmox-opentofu-import-plan-zero/&quot;&gt;the imported definitions&lt;/a&gt;, &lt;a href=&quot;https://peira.dev/blog/proxmox-ansible-capture-then-assert/&quot;&gt;the captured node roles&lt;/a&gt;, and &lt;a href=&quot;https://peira.dev/blog/opentofu-scratch-container-lifecycle/&quot;&gt;the proven lifecycle loop&lt;/a&gt; stop being tidiness and start being a disaster-recovery plan. It’s also the most honesty-dense post of the five, because a rebuild plan is exactly the kind of document where a confident guess is worse than a flagged gap.&lt;/p&gt;
&lt;figure&gt;&lt;img src=&quot;https://peira.dev/_astro/Server-UPS-and-EA6350v3-in-a-shelf.Cg7WfDY6_2khGeW.webp&quot; alt=&quot;A home server, UPS, and router arranged on a shelf&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot; width=&quot;900&quot; height=&quot;600&quot;&gt;&lt;figcaption&gt;Somebody’s shelf, and the layer no code restores: hardware, power, cabling. An example setup (not my lab). Photo: Tomskyhaha, &lt;a href=&quot;https://creativecommons.org/licenses/by-sa/4.0/&quot;&gt;CC BY-SA 4.0&lt;/a&gt;, via &lt;a href=&quot;https://commons.wikimedia.org/wiki/File:Server-UPS-and-EA6350v3-in-a-shelf.jpg&quot;&gt;Wikimedia Commons&lt;/a&gt;.&lt;/figcaption&gt;&lt;/figure&gt;
&lt;div class=&quot;callout callout-warning&quot; data-astro-cid-q2ml7llr&gt;&lt;div class=&quot;callout-header&quot; data-astro-cid-q2ml7llr&gt;&lt;span class=&quot;callout-icon&quot; aria-hidden=&quot;true&quot; data-astro-cid-q2ml7llr&gt;⚠️&lt;/span&gt;&lt;span class=&quot;callout-label&quot; data-astro-cid-q2ml7llr&gt;First: make these values your own&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;callout-body&quot; data-astro-cid-q2ml7llr&gt;&lt;p&gt;Stand-ins as always: &lt;code&gt;10.0.0.x&lt;/code&gt; addresses, node names &lt;code&gt;pvelab01&lt;/code&gt;–&lt;code&gt;pvelab04&lt;/code&gt;, guest IDs like &lt;code&gt;106&lt;/code&gt;, and identity names like &lt;code&gt;terraform@pve!tf&lt;/code&gt; are my documentation values — substitute your own. The measured speeds and sizes below are from &lt;em&gt;my&lt;/em&gt; lab and &lt;em&gt;my&lt;/em&gt; internet connection; yours will differ, which is exactly why the post keeps telling you to measure rather than trust my numbers. If a value looks specific to one machine, it’s a placeholder to change — not a literal to copy.&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;start-with-the-survival-inventory&quot;&gt;Start with the survival inventory&lt;/h2&gt;
&lt;p&gt;The first act of disaster planning isn’t buying anything — it’s honestly listing what would still exist after the event. My cluster lives at an office; my NAS lives at my house; my laptop lives with me. That geography &lt;em&gt;is&lt;/em&gt; the plan:&lt;/p&gt;
&lt;svg viewBox=&quot;0 0 720 330&quot; width=&quot;100%&quot; role=&quot;img&quot; aria-label=&quot;Two-site map. The office site, marked lost, contains all four cluster nodes, the per-node local backup tier, and the backup server datastore — everything there dies together. The home site, marked survives, holds the NAS with the weekly off-site backup tier. The laptop, also surviving, holds the infrastructure code repository and the secret store. Arrows show the rebuild flows from the surviving site and laptop back toward replacement hardware.&quot;&gt;&lt;rect width=&quot;720&quot; height=&quot;330&quot; rx=&quot;12&quot; fill=&quot;#1c1c32&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;360&quot; y=&quot;30&quot; text-anchor=&quot;middle&quot; fill=&quot;#f4f4f5&quot; font-size=&quot;15&quot; font-weight=&quot;bold&quot;&gt;What a site-level event actually takes — and what it can’t reach&lt;/text&gt;&lt;rect x=&quot;24&quot; y=&quot;52&quot; width=&quot;330&quot; height=&quot;250&quot; rx=&quot;10&quot; fill=&quot;#242440&quot; stroke=&quot;#ef4444&quot; stroke-width=&quot;1.5&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;189&quot; y=&quot;78&quot; text-anchor=&quot;middle&quot; fill=&quot;#ef4444&quot; font-size=&quot;12&quot; font-weight=&quot;bold&quot;&gt;office — lost&lt;/text&gt;&lt;rect x=&quot;48&quot; y=&quot;94&quot; width=&quot;282&quot; height=&quot;56&quot; rx=&quot;8&quot; fill=&quot;#30305a&quot; stroke=&quot;#4d4d82&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;189&quot; y=&quot;116&quot; text-anchor=&quot;middle&quot; fill=&quot;#e5e7eb&quot; font-size=&quot;11&quot;&gt;4 nodes + every guest disk&lt;/text&gt;&lt;text x=&quot;189&quot; y=&quot;134&quot; text-anchor=&quot;middle&quot; fill=&quot;#a1a1aa&quot; font-size=&quot;10&quot;&gt;the cluster itself&lt;/text&gt;&lt;rect x=&quot;48&quot; y=&quot;162&quot; width=&quot;282&quot; height=&quot;50&quot; rx=&quot;8&quot; fill=&quot;#2c2c4c&quot; stroke=&quot;#3d3d5c&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;189&quot; y=&quot;182&quot; text-anchor=&quot;middle&quot; fill=&quot;#e5e7eb&quot; font-size=&quot;11&quot;&gt;tier 1 — per-node local dumps&lt;/text&gt;&lt;text x=&quot;189&quot; y=&quot;199&quot; text-anchor=&quot;middle&quot; fill=&quot;#a1a1aa&quot; font-size=&quot;10&quot;&gt;die with each node&lt;/text&gt;&lt;rect x=&quot;48&quot; y=&quot;224&quot; width=&quot;282&quot; height=&quot;50&quot; rx=&quot;8&quot; fill=&quot;#2c2c4c&quot; stroke=&quot;#3d3d5c&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;189&quot; y=&quot;244&quot; text-anchor=&quot;middle&quot; fill=&quot;#e5e7eb&quot; font-size=&quot;11&quot;&gt;tier 2 — backup-server datastore&lt;/text&gt;&lt;text x=&quot;189&quot; y=&quot;261&quot; text-anchor=&quot;middle&quot; fill=&quot;#a1a1aa&quot; font-size=&quot;10&quot;&gt;fast, deduplicated — and on-site&lt;/text&gt;&lt;text x=&quot;189&quot; y=&quot;292&quot; text-anchor=&quot;middle&quot; fill=&quot;#ef4444&quot; font-size=&quot;10&quot;&gt;everything in this box shares one fate&lt;/text&gt;&lt;rect x=&quot;392&quot; y=&quot;52&quot; width=&quot;304&quot; height=&quot;126&quot; rx=&quot;10&quot; fill=&quot;#242440&quot; stroke=&quot;#22c55e&quot; stroke-width=&quot;1.5&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;544&quot; y=&quot;78&quot; text-anchor=&quot;middle&quot; fill=&quot;#22c55e&quot; font-size=&quot;12&quot; font-weight=&quot;bold&quot;&gt;home — survives&lt;/text&gt;&lt;rect x=&quot;416&quot; y=&quot;94&quot; width=&quot;256&quot; height=&quot;66&quot; rx=&quot;8&quot; fill=&quot;#30305a&quot; stroke=&quot;#22c55e&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;544&quot; y=&quot;116&quot; text-anchor=&quot;middle&quot; fill=&quot;#e5e7eb&quot; font-size=&quot;11&quot;&gt;NAS — tier 3, weekly off-site dumps&lt;/text&gt;&lt;text x=&quot;544&quot; y=&quot;134&quot; text-anchor=&quot;middle&quot; fill=&quot;#14b8a6&quot; font-size=&quot;10&quot; font-weight=&quot;bold&quot;&gt;the total-loss restore source&lt;/text&gt;&lt;text x=&quot;544&quot; y=&quot;150&quot; text-anchor=&quot;middle&quot; fill=&quot;#a1a1aa&quot; font-size=&quot;10&quot;&gt;reachable at internet speed only&lt;/text&gt;&lt;rect x=&quot;392&quot; y=&quot;196&quot; width=&quot;304&quot; height=&quot;106&quot; rx=&quot;10&quot; fill=&quot;#242440&quot; stroke=&quot;#22c55e&quot; stroke-width=&quot;1.5&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;544&quot; y=&quot;222&quot; text-anchor=&quot;middle&quot; fill=&quot;#22c55e&quot; font-size=&quot;12&quot; font-weight=&quot;bold&quot;&gt;laptop — survives&lt;/text&gt;&lt;rect x=&quot;416&quot; y=&quot;236&quot; width=&quot;122&quot; height=&quot;52&quot; rx=&quot;8&quot; fill=&quot;#2c2c4c&quot; stroke=&quot;#14b8a6&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;477&quot; y=&quot;258&quot; text-anchor=&quot;middle&quot; fill=&quot;#e5e7eb&quot; font-size=&quot;10&quot;&gt;IaC repo +&lt;/text&gt;&lt;text x=&quot;477&quot; y=&quot;274&quot; text-anchor=&quot;middle&quot; fill=&quot;#e5e7eb&quot; font-size=&quot;10&quot;&gt;Ansible roles&lt;/text&gt;&lt;rect x=&quot;550&quot; y=&quot;236&quot; width=&quot;122&quot; height=&quot;52&quot; rx=&quot;8&quot; fill=&quot;#2c2c4c&quot; stroke=&quot;#14b8a6&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;611&quot; y=&quot;258&quot; text-anchor=&quot;middle&quot; fill=&quot;#e5e7eb&quot; font-size=&quot;10&quot;&gt;secret store&lt;/text&gt;&lt;text x=&quot;611&quot; y=&quot;274&quot; text-anchor=&quot;middle&quot; fill=&quot;#e5e7eb&quot; font-size=&quot;10&quot;&gt;(names + values)&lt;/text&gt;&lt;/svg&gt;
&lt;p&gt;Three observations that generalize beyond my shelf:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Backup tiers that live with the cluster protect against disk death, not site death.&lt;/strong&gt; My fast, deduplicated &lt;a href=&quot;https://peira.dev/blog/proxmox-backup-server/&quot;&gt;Proxmox Backup Server&lt;/a&gt; tier is the thing I restore from for everyday mistakes — and it sits at the office. In this scenario it contributes nothing. The slow weekly tier at my house is suddenly the only tier that matters.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The infrastructure code survives because it lives on the laptop and in a remote repository&lt;/strong&gt; — not because it’s special, but because that’s where code naturally lives. The tooling’s &lt;em&gt;state&lt;/em&gt; file died with its cluster-side mirror, and that turns out not to matter: state is re-derivable by re-importing, as we’ll see. Code and backups survive; state is disposable. That asymmetry is worth designing around.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Secrets survive in the laptop’s store — but every credential the &lt;em&gt;cluster&lt;/em&gt; held is dead anyway.&lt;/strong&gt; More on this below; it’s the phase everyone forgets.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;what-the-code-restores--and-what-it-cant&quot;&gt;What the code restores — and what it can’t&lt;/h2&gt;
&lt;p&gt;Being blunt about the boundary, because a rebuild plan that overpromises is a trap with a bow on it. The code asserts guest &lt;em&gt;definitions&lt;/em&gt; and node &lt;em&gt;roles&lt;/em&gt;. It does not contain guest &lt;em&gt;contents&lt;/em&gt; — those are the backups’ job, &lt;a href=&quot;https://peira.dev/blog/proxmox-cluster-as-code-import-first/&quot;&gt;as decided back in part one&lt;/a&gt;. And below both layers sits work that belongs to hands: installing Proxmox from the ISO on replacement hardware, forming the cluster, carving storage, mounting the NAS export. The &lt;a href=&quot;https://pve.proxmox.com/pve-docs/chapter-pvecm.html&quot;&gt;official installer and cluster docs&lt;/a&gt; cover that layer; the code picks up where it ends.&lt;/p&gt;
&lt;figure class=&quot;terminal&quot; data-astro-cid-kenkipkp&gt;&lt;figcaption class=&quot;terminal-bar&quot; data-astro-cid-kenkipkp&gt;&lt;span class=&quot;dot red&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot yellow&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot green&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;terminal-title&quot; data-astro-cid-kenkipkp&gt;The manual floor: re-form the cluster (per the pvecm docs)&lt;/span&gt;&lt;button class=&quot;terminal-copy&quot; type=&quot;button&quot; aria-label=&quot;Copy commands&quot; data-astro-cid-kenkipkp&gt;&lt;svg width=&quot;13&quot; height=&quot;13&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;none&quot; stroke=&quot;currentColor&quot; stroke-width=&quot;2&quot; stroke-linecap=&quot;round&quot; stroke-linejoin=&quot;round&quot; aria-hidden=&quot;true&quot; data-astro-cid-kenkipkp&gt;&lt;rect x=&quot;9&quot; y=&quot;9&quot; width=&quot;13&quot; height=&quot;13&quot; rx=&quot;2&quot; data-astro-cid-kenkipkp&gt;&lt;/rect&gt;&lt;path d=&quot;M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1&quot; data-astro-cid-kenkipkp&gt;&lt;/path&gt;&lt;/svg&gt;&lt;span class=&quot;terminal-copy-label&quot; data-astro-cid-kenkipkp&gt;Copy&lt;/span&gt;&lt;/button&gt;&lt;/figcaption&gt;&lt;pre class=&quot;terminal-body&quot; data-astro-cid-kenkipkp&gt;&lt;code data-astro-cid-kenkipkp&gt;
# on the first replacement node
pvecm create homelab

# on each subsequent node
pvecm add 10.0.0.70

# then confirm quorum
pvecm status
&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;
&lt;p&gt;Two details from the docs that bite if skipped: nodes should run matching versions, and “Date and time must be synchronized” — the cluster communication layer genuinely cares about clock skew.&lt;/p&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;restore-in-dependency-order-not-affection-order&quot;&gt;Restore in dependency order, not affection order&lt;/h2&gt;
&lt;p&gt;With a bare cluster standing, the pull to restore your favorite service first is strong. Resist it. The right order is the one where each restore unblocks the next:&lt;/p&gt;
&lt;svg viewBox=&quot;0 0 720 350&quot; width=&quot;100%&quot; role=&quot;img&quot; aria-label=&quot;Restore dependency ladder. Rung zero, the keystone node with remote access and backup infrastructure. Rung one, the documentation service, because the rebuild reads its own instructions from it. Rung two, monitoring, the eyes for everything after. Rung three, task tracking and automation services. Rung four, everything else. A deliberately excluded box notes the disposable guest that is not restored at all.&quot;&gt;&lt;rect width=&quot;720&quot; height=&quot;350&quot; rx=&quot;12&quot; fill=&quot;#1c1c32&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;360&quot; y=&quot;30&quot; text-anchor=&quot;middle&quot; fill=&quot;#f4f4f5&quot; font-size=&quot;15&quot; font-weight=&quot;bold&quot;&gt;The restore ladder — each rung unblocks the next&lt;/text&gt;&lt;rect x=&quot;60&quot; y=&quot;50&quot; width=&quot;600&quot; height=&quot;44&quot; rx=&quot;8&quot; fill=&quot;#2c2c4c&quot; stroke=&quot;#f59e0b&quot; stroke-width=&quot;1.3&quot;&gt;&lt;/rect&gt;&lt;circle cx=&quot;92&quot; cy=&quot;72&quot; r=&quot;13&quot; fill=&quot;#30305a&quot; stroke=&quot;#f59e0b&quot;&gt;&lt;/circle&gt;&lt;text x=&quot;92&quot; y=&quot;77&quot; text-anchor=&quot;middle&quot; fill=&quot;#f59e0b&quot; font-size=&quot;12&quot; font-weight=&quot;bold&quot;&gt;0&lt;/text&gt;&lt;text x=&quot;118&quot; y=&quot;69&quot; fill=&quot;#f4f4f5&quot; font-size=&quot;11&quot; font-weight=&quot;bold&quot;&gt;keystone node first&lt;/text&gt;&lt;text x=&quot;118&quot; y=&quot;86&quot; fill=&quot;#a1a1aa&quot; font-size=&quot;10&quot;&gt;remote access + backup infrastructure — every later rung flows through it&lt;/text&gt;&lt;rect x=&quot;60&quot; y=&quot;106&quot; width=&quot;600&quot; height=&quot;44&quot; rx=&quot;8&quot; fill=&quot;#2c2c4c&quot; stroke=&quot;#14b8a6&quot; stroke-width=&quot;1.3&quot;&gt;&lt;/rect&gt;&lt;circle cx=&quot;92&quot; cy=&quot;128&quot; r=&quot;13&quot; fill=&quot;#30305a&quot; stroke=&quot;#14b8a6&quot;&gt;&lt;/circle&gt;&lt;text x=&quot;92&quot; y=&quot;133&quot; text-anchor=&quot;middle&quot; fill=&quot;#14b8a6&quot; font-size=&quot;12&quot; font-weight=&quot;bold&quot;&gt;1&lt;/text&gt;&lt;text x=&quot;118&quot; y=&quot;125&quot; fill=&quot;#f4f4f5&quot; font-size=&quot;11&quot; font-weight=&quot;bold&quot;&gt;the documentation service&lt;/text&gt;&lt;text x=&quot;118&quot; y=&quot;142&quot; fill=&quot;#a1a1aa&quot; font-size=&quot;10&quot;&gt;the rebuild reads its own instructions from here — restore it before you need it&lt;/text&gt;&lt;rect x=&quot;60&quot; y=&quot;162&quot; width=&quot;600&quot; height=&quot;44&quot; rx=&quot;8&quot; fill=&quot;#2c2c4c&quot; stroke=&quot;#14b8a6&quot; stroke-width=&quot;1.3&quot;&gt;&lt;/rect&gt;&lt;circle cx=&quot;92&quot; cy=&quot;184&quot; r=&quot;13&quot; fill=&quot;#30305a&quot; stroke=&quot;#14b8a6&quot;&gt;&lt;/circle&gt;&lt;text x=&quot;92&quot; y=&quot;189&quot; text-anchor=&quot;middle&quot; fill=&quot;#14b8a6&quot; font-size=&quot;12&quot; font-weight=&quot;bold&quot;&gt;2&lt;/text&gt;&lt;text x=&quot;118&quot; y=&quot;181&quot; fill=&quot;#f4f4f5&quot; font-size=&quot;11&quot; font-weight=&quot;bold&quot;&gt;monitoring&lt;/text&gt;&lt;text x=&quot;118&quot; y=&quot;198&quot; fill=&quot;#a1a1aa&quot; font-size=&quot;10&quot;&gt;eyes on everything that follows; you want dashboards before you want workloads&lt;/text&gt;&lt;rect x=&quot;60&quot; y=&quot;218&quot; width=&quot;600&quot; height=&quot;44&quot; rx=&quot;8&quot; fill=&quot;#2c2c4c&quot; stroke=&quot;#818cf8&quot; stroke-width=&quot;1.3&quot;&gt;&lt;/rect&gt;&lt;circle cx=&quot;92&quot; cy=&quot;240&quot; r=&quot;13&quot; fill=&quot;#30305a&quot; stroke=&quot;#818cf8&quot;&gt;&lt;/circle&gt;&lt;text x=&quot;92&quot; y=&quot;245&quot; text-anchor=&quot;middle&quot; fill=&quot;#818cf8&quot; font-size=&quot;12&quot; font-weight=&quot;bold&quot;&gt;3&lt;/text&gt;&lt;text x=&quot;118&quot; y=&quot;237&quot; fill=&quot;#f4f4f5&quot; font-size=&quot;11&quot; font-weight=&quot;bold&quot;&gt;task tracking + automation&lt;/text&gt;&lt;text x=&quot;118&quot; y=&quot;254&quot; fill=&quot;#a1a1aa&quot; font-size=&quot;10&quot;&gt;the services that coordinate the rest of the recovery&lt;/text&gt;&lt;rect x=&quot;60&quot; y=&quot;274&quot; width=&quot;600&quot; height=&quot;44&quot; rx=&quot;8&quot; fill=&quot;#2c2c4c&quot; stroke=&quot;#818cf8&quot; stroke-width=&quot;1.3&quot;&gt;&lt;/rect&gt;&lt;circle cx=&quot;92&quot; cy=&quot;296&quot; r=&quot;13&quot; fill=&quot;#30305a&quot; stroke=&quot;#818cf8&quot;&gt;&lt;/circle&gt;&lt;text x=&quot;92&quot; y=&quot;301&quot; text-anchor=&quot;middle&quot; fill=&quot;#818cf8&quot; font-size=&quot;12&quot; font-weight=&quot;bold&quot;&gt;4&lt;/text&gt;&lt;text x=&quot;118&quot; y=&quot;293&quot; fill=&quot;#f4f4f5&quot; font-size=&quot;11&quot; font-weight=&quot;bold&quot;&gt;everything else&lt;/text&gt;&lt;text x=&quot;118&quot; y=&quot;310&quot; fill=&quot;#a1a1aa&quot; font-size=&quot;10&quot;&gt;AI workloads, game servers, the fun stuff — last, because nothing depends on it&lt;/text&gt;&lt;text x=&quot;360&quot; y=&quot;340&quot; text-anchor=&quot;middle&quot; fill=&quot;#6b7280&quot; font-size=&quot;10&quot;&gt;deliberately not restored: the throwaway demo guest — disposable by design, so its loss is a feature working&lt;/text&gt;&lt;/svg&gt;
&lt;p&gt;Rung one deserves a beat, because it’s the one I’d never have ranked that high before living with &lt;a href=&quot;https://peira.dev/blog/second-brain-git-markdown-vault/&quot;&gt;a homelab knowledge base&lt;/a&gt;: my documentation — including the rebuild runbook this very post is based on — lives in a service &lt;em&gt;on the cluster&lt;/em&gt;. During a rebuild, that’s the book of instructions, so it comes back first, and &lt;a href=&quot;https://peira.dev/blog/second-brain-repair-rebuild/&quot;&gt;its own restore procedure&lt;/a&gt; includes integrity checks before it counts as an authority again. If your docs live in a wiki on the thing you’re rebuilding, the restore order has to account for that circularity — or you keep an off-cluster copy, or both.&lt;/p&gt;
&lt;p&gt;The restores themselves are single commands against the surviving tier’s archives — &lt;a href=&quot;https://pve.proxmox.com/pve-docs/chapter-vzdump.html&quot;&gt;the platform docs&lt;/a&gt; note that “A backup archive can be restored through the Proxmox VE web GUI or through the following CLI tools,” &lt;code&gt;pct restore&lt;/code&gt; for containers among them:&lt;/p&gt;
&lt;figure class=&quot;terminal&quot; data-astro-cid-kenkipkp&gt;&lt;figcaption class=&quot;terminal-bar&quot; data-astro-cid-kenkipkp&gt;&lt;span class=&quot;dot red&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot yellow&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot green&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;terminal-title&quot; data-astro-cid-kenkipkp&gt;One guest back from the off-site tier&lt;/span&gt;&lt;button class=&quot;terminal-copy&quot; type=&quot;button&quot; aria-label=&quot;Copy commands&quot; data-astro-cid-kenkipkp&gt;&lt;svg width=&quot;13&quot; height=&quot;13&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;none&quot; stroke=&quot;currentColor&quot; stroke-width=&quot;2&quot; stroke-linecap=&quot;round&quot; stroke-linejoin=&quot;round&quot; aria-hidden=&quot;true&quot; data-astro-cid-kenkipkp&gt;&lt;rect x=&quot;9&quot; y=&quot;9&quot; width=&quot;13&quot; height=&quot;13&quot; rx=&quot;2&quot; data-astro-cid-kenkipkp&gt;&lt;/rect&gt;&lt;path d=&quot;M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1&quot; data-astro-cid-kenkipkp&gt;&lt;/path&gt;&lt;/svg&gt;&lt;span class=&quot;terminal-copy-label&quot; data-astro-cid-kenkipkp&gt;Copy&lt;/span&gt;&lt;/button&gt;&lt;/figcaption&gt;&lt;pre class=&quot;terminal-body&quot; data-astro-cid-kenkipkp&gt;&lt;code data-astro-cid-kenkipkp&gt;
# list what the surviving storage holds
pvesm list nas-backups --content backup

# restore a guest to its home node&amp;#39;s storage
pct restore 106 nas-backups:backup/vzdump-lxc-106-....tar.zst --storage local-lvm
&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;
&lt;p&gt;&lt;strong&gt;Placement matters.&lt;/strong&gt; Restore each guest to the node with the resources it actually needs — my biggest AI workload only fits on one node’s RAM, a fact I know &lt;em&gt;because&lt;/em&gt; &lt;a href=&quot;https://peira.dev/blog/homelab-capacity-planning-node-failure/&quot;&gt;the capacity-planning exercise&lt;/a&gt; surfaced it on a calm day rather than during a crisis.&lt;/p&gt;
&lt;h3 id=&quot;the-speed-reality-nobody-budgets-for&quot;&gt;The speed reality nobody budgets for&lt;/h3&gt;
&lt;p&gt;Here are my measured numbers, and the shape of them matters more than the values. From my on-site backup server, a restore moves at &lt;strong&gt;110–119 MiB/s&lt;/strong&gt; (measured on a real scratch restore). From the off-site NAS tier, the same operation moved at about &lt;strong&gt;8 MiB/s extracted — roughly 3.3 MiB/s of compressed data over the wire&lt;/strong&gt;. Fourteen times slower, and there is nothing to tune: the bottleneck is my home connection’s upload speed, because in a total loss the off-site tier is on the wrong side of the internet from wherever the new cluster stands. My production archives total about 31 GiB compressed, so the data movement alone is a &lt;strong&gt;2.5–3 hour floor&lt;/strong&gt; — before any hands touch anything. Measure your own single-guest restore once, multiply, and write the number down where the 3 a.m. version of you will find it.&lt;/p&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;the-phase-everyone-forgets-every-credential-is-dead&quot;&gt;The phase everyone forgets: every credential is dead&lt;/h2&gt;
&lt;p&gt;Restored guests come back &lt;em&gt;believing&lt;/em&gt; their old credentials. The cluster those credentials belonged to no longer exists. Proxmox keeps its access-control state — tokens included — in the cluster filesystem at &lt;code&gt;/etc/pve&lt;/code&gt;, which &lt;a href=&quot;https://pve.proxmox.com/pve-docs/chapter-pmxcfs.html&quot;&gt;the docs describe&lt;/a&gt; as “a database-driven file system for storing configuration files, replicated in real time to all cluster nodes using corosync.” Replicated to all nodes — and in this scenario, all nodes died. The database died with them.&lt;/p&gt;
&lt;p&gt;So the rebuild needs an explicit re-minting pass, and this is where a written identity table earns its keep. Mine lists each identity, its role, and where the secret lands — the monitoring exporter’s read-only token, the automation tools’ tokens, &lt;a href=&quot;https://peira.dev/blog/proxmox-cluster-as-code-import-first/&quot;&gt;the pure-auditor token from part one&lt;/a&gt;, and the backup server’s token, each re-created and each delivered to the one config file that consumes it. Two platform rules to remember while re-minting, both learned the mildly annoying way:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Secrets are shown &lt;strong&gt;once&lt;/strong&gt; at creation — the backup server’s docs &lt;a href=&quot;https://pbs.proxmox.com/docs/user-management.html&quot;&gt;say it flatly&lt;/a&gt;: “The displayed secret value needs to be saved, since it cannot be displayed again after generating the API token.”&lt;/li&gt;
&lt;li&gt;On the backup server, “Permissions for API tokens are always limited to those of the user” — the same intersection logic &lt;a href=&quot;https://peira.dev/blog/opentofu-scratch-container-lifecycle/&quot;&gt;part four hit on the cluster side&lt;/a&gt;, so grant the permission to the user &lt;em&gt;and&lt;/em&gt; the token or the token 403s while looking correctly configured.&lt;/li&gt;
&lt;/ul&gt;
&lt;div class=&quot;callout callout-danger&quot; data-astro-cid-q2ml7llr&gt;&lt;div class=&quot;callout-header&quot; data-astro-cid-q2ml7llr&gt;&lt;span class=&quot;callout-icon&quot; aria-hidden=&quot;true&quot; data-astro-cid-q2ml7llr&gt;🚨&lt;/span&gt;&lt;span class=&quot;callout-label&quot; data-astro-cid-q2ml7llr&gt;Secrets go to stores, never to notes&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;callout-body&quot; data-astro-cid-q2ml7llr&gt;&lt;p&gt;Re-minting a dozen credentials in an afternoon is exactly the moment a secret ends up pasted in a scratch file “for a second.” Every re-minted value goes directly into your secret store and the one config file that consumes it — nowhere else. A rebuild is a bad day; leaking a fresh credential into your notes makes it two bad days.&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;the-payoff-two-commands-prove-the-rebuild&quot;&gt;The payoff: two commands prove the rebuild&lt;/h2&gt;
&lt;p&gt;Everything until now was recovery. This is the part that’s &lt;em&gt;verification&lt;/em&gt; — and the reason this series exists. The cluster standing in front of you claims to be the old cluster. Is it?&lt;/p&gt;
&lt;p&gt;Before this series, that question had no crisp answer — you’d click around, things would look right, and doubt would linger for weeks. Now it has exit codes:&lt;/p&gt;
&lt;figure class=&quot;terminal&quot; data-astro-cid-kenkipkp&gt;&lt;figcaption class=&quot;terminal-bar&quot; data-astro-cid-kenkipkp&gt;&lt;span class=&quot;dot red&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot yellow&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot green&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;terminal-title&quot; data-astro-cid-kenkipkp&gt;Acceptance test 1 — the guest definitions match the record&lt;/span&gt;&lt;button class=&quot;terminal-copy&quot; type=&quot;button&quot; aria-label=&quot;Copy commands&quot; data-astro-cid-kenkipkp&gt;&lt;svg width=&quot;13&quot; height=&quot;13&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;none&quot; stroke=&quot;currentColor&quot; stroke-width=&quot;2&quot; stroke-linecap=&quot;round&quot; stroke-linejoin=&quot;round&quot; aria-hidden=&quot;true&quot; data-astro-cid-kenkipkp&gt;&lt;rect x=&quot;9&quot; y=&quot;9&quot; width=&quot;13&quot; height=&quot;13&quot; rx=&quot;2&quot; data-astro-cid-kenkipkp&gt;&lt;/rect&gt;&lt;path d=&quot;M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1&quot; data-astro-cid-kenkipkp&gt;&lt;/path&gt;&lt;/svg&gt;&lt;span class=&quot;terminal-copy-label&quot; data-astro-cid-kenkipkp&gt;Copy&lt;/span&gt;&lt;/button&gt;&lt;/figcaption&gt;&lt;pre class=&quot;terminal-body&quot; data-astro-cid-kenkipkp&gt;&lt;code data-astro-cid-kenkipkp&gt;
cd terraform &amp;amp;&amp;amp; tofu init &amp;amp;&amp;amp; tofu plan

# if state died with the cluster, the import blocks from part two
# re-import every guest by node/vmid first — a read-only state
# operation. Iterate until:
#
# No changes. Your infrastructure matches the configuration.
&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;
&lt;figure class=&quot;terminal&quot; data-astro-cid-kenkipkp&gt;&lt;figcaption class=&quot;terminal-bar&quot; data-astro-cid-kenkipkp&gt;&lt;span class=&quot;dot red&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot yellow&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot green&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;terminal-title&quot; data-astro-cid-kenkipkp&gt;Acceptance test 2 — the node configuration matches the record&lt;/span&gt;&lt;button class=&quot;terminal-copy&quot; type=&quot;button&quot; aria-label=&quot;Copy commands&quot; data-astro-cid-kenkipkp&gt;&lt;svg width=&quot;13&quot; height=&quot;13&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;none&quot; stroke=&quot;currentColor&quot; stroke-width=&quot;2&quot; stroke-linecap=&quot;round&quot; stroke-linejoin=&quot;round&quot; aria-hidden=&quot;true&quot; data-astro-cid-kenkipkp&gt;&lt;rect x=&quot;9&quot; y=&quot;9&quot; width=&quot;13&quot; height=&quot;13&quot; rx=&quot;2&quot; data-astro-cid-kenkipkp&gt;&lt;/rect&gt;&lt;path d=&quot;M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1&quot; data-astro-cid-kenkipkp&gt;&lt;/path&gt;&lt;/svg&gt;&lt;span class=&quot;terminal-copy-label&quot; data-astro-cid-kenkipkp&gt;Copy&lt;/span&gt;&lt;/button&gt;&lt;/figcaption&gt;&lt;pre class=&quot;terminal-body&quot; data-astro-cid-kenkipkp&gt;&lt;code data-astro-cid-kenkipkp&gt;
cd ../ansible

# fresh nodes: a real converge first (part four proved this path)
ansible-playbook site.yml

# then the assertion:
ansible-playbook site.yml --check --diff
#   every node: changed=0
&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;
&lt;svg viewBox=&quot;0 0 720 240&quot; width=&quot;100%&quot; role=&quot;img&quot; aria-label=&quot;Two acceptance gauges side by side. The OpenTofu gauge reads plan no changes, proving guest definitions match. The Ansible gauge reads check diff changed zero, proving node configuration matches. Together they certify the rebuilt cluster matches the record, turning does it match from a feeling into two exit codes.&quot;&gt;&lt;rect width=&quot;720&quot; height=&quot;240&quot; rx=&quot;12&quot; fill=&quot;#1c1c32&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;360&quot; y=&quot;32&quot; text-anchor=&quot;middle&quot; fill=&quot;#f4f4f5&quot; font-size=&quot;15&quot; font-weight=&quot;bold&quot;&gt;Rebuild acceptance — the same two drift detectors, now run as proof&lt;/text&gt;&lt;rect x=&quot;60&quot; y=&quot;56&quot; width=&quot;270&quot; height=&quot;130&quot; rx=&quot;10&quot; fill=&quot;#242440&quot; stroke=&quot;#3d3d5c&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;195&quot; y=&quot;82&quot; text-anchor=&quot;middle&quot; fill=&quot;#a1a1aa&quot; font-size=&quot;11&quot;&gt;guest definitions&lt;/text&gt;&lt;rect x=&quot;84&quot; y=&quot;96&quot; width=&quot;222&quot; height=&quot;34&quot; rx=&quot;7&quot; fill=&quot;#0d0d1a&quot; stroke=&quot;#3d3d5c&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;195&quot; y=&quot;118&quot; text-anchor=&quot;middle&quot; fill=&quot;#e5e7eb&quot; font-size=&quot;11&quot; font-family=&quot;monospace&quot;&gt;tofu plan&lt;/text&gt;&lt;rect x=&quot;84&quot; y=&quot;140&quot; width=&quot;222&quot; height=&quot;32&quot; rx=&quot;7&quot; fill=&quot;#2c2c4c&quot; stroke=&quot;#22c55e&quot; stroke-width=&quot;1.5&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;195&quot; y=&quot;161&quot; text-anchor=&quot;middle&quot; fill=&quot;#22c55e&quot; font-size=&quot;11&quot; font-weight=&quot;bold&quot;&gt;&amp;quot;No changes.&amp;quot;&lt;/text&gt;&lt;rect x=&quot;390&quot; y=&quot;56&quot; width=&quot;270&quot; height=&quot;130&quot; rx=&quot;10&quot; fill=&quot;#242440&quot; stroke=&quot;#3d3d5c&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;525&quot; y=&quot;82&quot; text-anchor=&quot;middle&quot; fill=&quot;#a1a1aa&quot; font-size=&quot;11&quot;&gt;node configuration&lt;/text&gt;&lt;rect x=&quot;414&quot; y=&quot;96&quot; width=&quot;222&quot; height=&quot;34&quot; rx=&quot;7&quot; fill=&quot;#0d0d1a&quot; stroke=&quot;#3d3d5c&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;525&quot; y=&quot;118&quot; text-anchor=&quot;middle&quot; fill=&quot;#e5e7eb&quot; font-size=&quot;11&quot; font-family=&quot;monospace&quot;&gt;ansible --check --diff&lt;/text&gt;&lt;rect x=&quot;414&quot; y=&quot;140&quot; width=&quot;222&quot; height=&quot;32&quot; rx=&quot;7&quot; fill=&quot;#2c2c4c&quot; stroke=&quot;#22c55e&quot; stroke-width=&quot;1.5&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;525&quot; y=&quot;161&quot; text-anchor=&quot;middle&quot; fill=&quot;#22c55e&quot; font-size=&quot;11&quot; font-weight=&quot;bold&quot;&gt;changed=0 × 4 nodes&lt;/text&gt;&lt;text x=&quot;360&quot; y=&quot;216&quot; text-anchor=&quot;middle&quot; fill=&quot;#a1a1aa&quot; font-size=&quot;10&quot;&gt;green + green = the rebuilt cluster matches the record — not a feeling, two exit codes&lt;/text&gt;&lt;/svg&gt;
&lt;p&gt;Note what made test one possible: losing the state file cost nothing, because the import blocks &lt;a href=&quot;https://peira.dev/blog/proxmox-opentofu-import-plan-zero/&quot;&gt;from part two&lt;/a&gt; rebuild state from the live cluster with the read-only token. The record that matters — the configuration — was in git all along.&lt;/p&gt;
&lt;p&gt;After the tests pass, the last chores: re-create the backup jobs and let a full cycle run, verify the first night’s archives actually appeared, and re-arm whatever &lt;a href=&quot;https://peira.dev/blog/nut-ups-safe-shutdown/&quot;&gt;protects the hardware layer&lt;/a&gt;. Then the two acceptance commands go back to their day job as standing drift detectors, and the loop closes.&lt;/p&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;an-honest-closing-what-i-have-and-havent-proven&quot;&gt;An honest closing: what I have and haven’t proven&lt;/h2&gt;
&lt;p&gt;Every piece of this plan has been individually drilled in my lab: real restores from both tiers with integrity checks, the vault restore, the plan-zero import, the check-clean run, the scratch lifecycle, and a live cluster formation. The &lt;strong&gt;full composition — bare metal to green acceptance tests in one continuous run — has never been executed as a single drill.&lt;/strong&gt; The seams I know about are flagged through this post; the first real execution will find the ones I don’t, and its deviations get written back into the runbook. A recovery plan is a living document with exactly one author worth trusting: the last rehearsal.&lt;/p&gt;
&lt;p&gt;That’s the series. A hand-built cluster became code without a single restart — import first, capture then assert, prove the loop on something disposable — and the payoff isn’t automation for its own sake. It’s that “could I get it all back?” now has an answer with evidence behind it, and two commands that keep the answer honest, every day, from here on.&lt;/p&gt;
&lt;hr/&gt;
&lt;p&gt;&lt;strong&gt;Related posts:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/proxmox-cluster-as-code-import-first/&quot;&gt;Turn a Hand-Built Proxmox Cluster Into Code Without Breaking It&lt;/a&gt; — part one: the posture that made all of this safe&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/proxmox-opentofu-import-plan-zero/&quot;&gt;Import a Live Proxmox Cluster Into OpenTofu With Zero Changes&lt;/a&gt; — part two: the import blocks that made the dead state file a non-event&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/proxmox-ansible-capture-then-assert/&quot;&gt;Ansible for a Cluster You Built by Hand: Capture, Then Assert&lt;/a&gt; — part three: the node roles the second acceptance test runs&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/opentofu-scratch-container-lifecycle/&quot;&gt;A Proxmox Container Born, Configured, and Destroyed From Code&lt;/a&gt; — part four: the rehearsal that proved the create path this rebuild depends on&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/proxmox-backup-server/&quot;&gt;Proxmox Backup Server: Automated CT and VM Backups with Deduplication&lt;/a&gt; — the tiered backups this whole plan restores from&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/homelab-capacity-planning-node-failure/&quot;&gt;Homelab Capacity Planning: What If a Node Dies Tonight?&lt;/a&gt; — the single-node version of this question, and where the placement facts come from&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/second-brain-repair-rebuild/&quot;&gt;Already Made a Mess of Your Notes? Here’s the Fix&lt;/a&gt; — the documentation service’s own repair-and-rebuild procedure, rung one of the ladder&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/nut-ups-safe-shutdown/&quot;&gt;One UPS, Whole Homelab: Safe Auto-Shutdown with NUT&lt;/a&gt; — protecting the new hardware from the most common disaster: power&lt;/li&gt;
&lt;/ul&gt;</content:encoded><category>proxmox</category><category>infrastructure</category><category>automation</category></item><item><title>Turn a Hand-Built Proxmox Cluster Into Code Without Breaking It</title><link>https://peira.dev/blog/proxmox-cluster-as-code-import-first/</link><guid isPermaLink="true">https://peira.dev/blog/proxmox-cluster-as-code-import-first/</guid><description>IaC guides assume an empty account. Here&apos;s the import-first approach for a Proxmox homelab already running things you cannot afford to lose.</description><pubDate>Wed, 29 Jul 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Every infrastructure-as-code tutorial I read had the same opening move: start with an empty cloud account, write some code, watch a server appear. Mine was not an empty account. Mine was four machines in a cupboard running ten containers that my house genuinely depends on — DNS, backups, the wiki, a game server the kids play on — all of it built by hand over months, none of it described anywhere except in my own head.&lt;/p&gt;
&lt;p&gt;The gap between those two situations is the whole problem. Following the standard advice on a live lab means letting the tool create fresh copies of things you already have, which is a polite way of saying “delete your homelab and rebuild it.” I wasn’t going to do that. So I took the other route, the one the tutorials skip: describe what already exists, prove the description is exact, and never let the tool write anything at all.&lt;/p&gt;
&lt;p&gt;This is part one of a series about codifying a running Proxmox cluster. It’s the part about posture — what you’re actually trying to achieve, what code can and can’t save you from, and how to set things up so a mistake is impossible rather than merely unlikely. &lt;a href=&quot;https://peira.dev/blog/proxmox-opentofu-import-plan-zero/&quot;&gt;Part two&lt;/a&gt; is the hands-on import recipe.&lt;/p&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;why-build-it-from-scratch-is-the-wrong-goal&quot;&gt;Why “build it from scratch” is the wrong goal&lt;/h2&gt;
&lt;p&gt;The appeal of infrastructure as code is usually pitched as reproducibility: delete everything, run one command, get it all back. That’s a genuine superpower when your infrastructure is disposable — when a server is a thing you rent for an afternoon.&lt;/p&gt;
&lt;p&gt;A homelab is not disposable. The container running &lt;a href=&quot;https://peira.dev/blog/pihole-proxmox-lxc/&quot;&gt;Pi-hole&lt;/a&gt; has months of configuration in it. The wiki container has the notes I’d need to rebuild everything else. “Destroy and recreate” isn’t a recovery plan for those, it’s the disaster.&lt;/p&gt;
&lt;p&gt;So the goal changes. Instead of &lt;em&gt;code that can build my lab&lt;/em&gt;, I wanted &lt;em&gt;code that describes my lab exactly&lt;/em&gt; — and the value comes from somewhere less obvious than rebuilding.&lt;/p&gt;
&lt;div class=&quot;callout callout-tip&quot; data-astro-cid-q2ml7llr&gt;&lt;div class=&quot;callout-header&quot; data-astro-cid-q2ml7llr&gt;&lt;span class=&quot;callout-icon&quot; aria-hidden=&quot;true&quot; data-astro-cid-q2ml7llr&gt;💡&lt;/span&gt;&lt;span class=&quot;callout-label&quot; data-astro-cid-q2ml7llr&gt;The real payoff is a diff, not a rebuild&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;callout-body&quot; data-astro-cid-q2ml7llr&gt;&lt;p&gt;Once your code matches reality exactly, any difference between the two is a change someone made and didn’t write down. A single command tells you whether your lab still looks the way you think it does. That drift detector turns out to be worth more, week to week, than the ability to recreate everything from nothing.&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;
&lt;p&gt;There’s a second payoff, which is that writing the description forces you to actually look. When I codified my four nodes, I discovered every one of them was running a monitoring agent from a manually-downloaded binary while the system package sat installed-but-inert alongside it. Nothing was broken, and my own documentation said something different. Writing the code corrected the documentation — a pattern that repeated several times before I was finished.&lt;/p&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;what-code-can-bring-back-and-what-it-cant&quot;&gt;What code can bring back, and what it can’t&lt;/h2&gt;
&lt;p&gt;This is the part I’d want someone to tell me before I started, because getting it wrong produces false confidence — the most expensive kind.&lt;/p&gt;
&lt;p&gt;Infrastructure code describes the &lt;strong&gt;shape&lt;/strong&gt; of a container: how much memory it gets, how big its disk is, which node it lives on, what its network settings are, whether it starts on boot. Recreate a container from that description and you get a correctly-shaped empty box.&lt;/p&gt;
&lt;p&gt;What made it &lt;em&gt;your&lt;/em&gt; container — the installed packages, the databases, the config files, the media library, the years of accumulated state — was never in the code. That lives in &lt;a href=&quot;https://peira.dev/blog/proxmox-backup-server/&quot;&gt;your backups&lt;/a&gt;.&lt;/p&gt;
&lt;svg viewBox=&quot;0 0 720 380&quot; width=&quot;100%&quot; role=&quot;img&quot; aria-label=&quot;Diagram contrasting what infrastructure code restores against what backups restore. Code restores the container shape: memory, disk size, node placement, network and boot settings. Backups restore the contents: packages, databases, config files and data. Both together restore a working service.&quot;&gt;&lt;rect width=&quot;720&quot; height=&quot;380&quot; rx=&quot;12&quot; fill=&quot;#1c1c32&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;360&quot; y=&quot;32&quot; text-anchor=&quot;middle&quot; fill=&quot;#f4f4f5&quot; font-size=&quot;15&quot; font-weight=&quot;bold&quot;&gt;Two halves of one recovery&lt;/text&gt;&lt;text x=&quot;360&quot; y=&quot;52&quot; text-anchor=&quot;middle&quot; fill=&quot;#6b7280&quot; font-size=&quot;11&quot;&gt;Neither half is a recovery plan on its own&lt;/text&gt;&lt;rect x=&quot;30&quot; y=&quot;78&quot; width=&quot;290&quot; height=&quot;212&quot; rx=&quot;10&quot; fill=&quot;#2c2c4c&quot; stroke=&quot;#14b8a6&quot; stroke-width=&quot;1.5&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;175&quot; y=&quot;104&quot; text-anchor=&quot;middle&quot; fill=&quot;#14b8a6&quot; font-size=&quot;13&quot; font-weight=&quot;bold&quot;&gt;Code restores the shape&lt;/text&gt;&lt;rect x=&quot;50&quot; y=&quot;122&quot; width=&quot;250&quot; height=&quot;24&quot; rx=&quot;5&quot; fill=&quot;#30305a&quot; stroke=&quot;#3d3d5c&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;62&quot; y=&quot;139&quot; fill=&quot;#e5e7eb&quot; font-size=&quot;11&quot;&gt;4 GB memory, 2 cores&lt;/text&gt;&lt;rect x=&quot;50&quot; y=&quot;154&quot; width=&quot;250&quot; height=&quot;24&quot; rx=&quot;5&quot; fill=&quot;#30305a&quot; stroke=&quot;#3d3d5c&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;62&quot; y=&quot;171&quot; fill=&quot;#e5e7eb&quot; font-size=&quot;11&quot;&gt;32 GB disk on local-lvm&lt;/text&gt;&lt;rect x=&quot;50&quot; y=&quot;186&quot; width=&quot;250&quot; height=&quot;24&quot; rx=&quot;5&quot; fill=&quot;#30305a&quot; stroke=&quot;#3d3d5c&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;62&quot; y=&quot;203&quot; fill=&quot;#e5e7eb&quot; font-size=&quot;11&quot;&gt;lives on node pvelab02&lt;/text&gt;&lt;rect x=&quot;50&quot; y=&quot;218&quot; width=&quot;250&quot; height=&quot;24&quot; rx=&quot;5&quot; fill=&quot;#30305a&quot; stroke=&quot;#3d3d5c&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;62&quot; y=&quot;235&quot; fill=&quot;#e5e7eb&quot; font-size=&quot;11&quot;&gt;static address, vmbr0 bridge&lt;/text&gt;&lt;rect x=&quot;50&quot; y=&quot;250&quot; width=&quot;250&quot; height=&quot;24&quot; rx=&quot;5&quot; fill=&quot;#30305a&quot; stroke=&quot;#3d3d5c&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;62&quot; y=&quot;267&quot; fill=&quot;#e5e7eb&quot; font-size=&quot;11&quot;&gt;unprivileged, starts on boot&lt;/text&gt;&lt;rect x=&quot;400&quot; y=&quot;78&quot; width=&quot;290&quot; height=&quot;212&quot; rx=&quot;10&quot; fill=&quot;#2c2c4c&quot; stroke=&quot;#818cf8&quot; stroke-width=&quot;1.5&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;545&quot; y=&quot;104&quot; text-anchor=&quot;middle&quot; fill=&quot;#818cf8&quot; font-size=&quot;13&quot; font-weight=&quot;bold&quot;&gt;Backups restore the contents&lt;/text&gt;&lt;rect x=&quot;420&quot; y=&quot;122&quot; width=&quot;250&quot; height=&quot;24&quot; rx=&quot;5&quot; fill=&quot;#30305a&quot; stroke=&quot;#3d3d5c&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;432&quot; y=&quot;139&quot; fill=&quot;#e5e7eb&quot; font-size=&quot;11&quot;&gt;installed packages&lt;/text&gt;&lt;rect x=&quot;420&quot; y=&quot;154&quot; width=&quot;250&quot; height=&quot;24&quot; rx=&quot;5&quot; fill=&quot;#30305a&quot; stroke=&quot;#3d3d5c&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;432&quot; y=&quot;171&quot; fill=&quot;#e5e7eb&quot; font-size=&quot;11&quot;&gt;databases and their data&lt;/text&gt;&lt;rect x=&quot;420&quot; y=&quot;186&quot; width=&quot;250&quot; height=&quot;24&quot; rx=&quot;5&quot; fill=&quot;#30305a&quot; stroke=&quot;#3d3d5c&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;432&quot; y=&quot;203&quot; fill=&quot;#e5e7eb&quot; font-size=&quot;11&quot;&gt;config files you hand-edited&lt;/text&gt;&lt;rect x=&quot;420&quot; y=&quot;218&quot; width=&quot;250&quot; height=&quot;24&quot; rx=&quot;5&quot; fill=&quot;#30305a&quot; stroke=&quot;#3d3d5c&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;432&quot; y=&quot;235&quot; fill=&quot;#e5e7eb&quot; font-size=&quot;11&quot;&gt;certificates and keys&lt;/text&gt;&lt;rect x=&quot;420&quot; y=&quot;250&quot; width=&quot;250&quot; height=&quot;24&quot; rx=&quot;5&quot; fill=&quot;#30305a&quot; stroke=&quot;#3d3d5c&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;432&quot; y=&quot;267&quot; fill=&quot;#e5e7eb&quot; font-size=&quot;11&quot;&gt;everything you actually made&lt;/text&gt;&lt;path d=&quot;M 175 296 L 175 316 L 545 316 L 545 296&quot; fill=&quot;none&quot; stroke=&quot;#3a3a5c&quot; stroke-width=&quot;1.5&quot;&gt;&lt;/path&gt;&lt;rect x=&quot;250&quot; y=&quot;326&quot; width=&quot;220&quot; height=&quot;34&quot; rx=&quot;8&quot; fill=&quot;#30305a&quot; stroke=&quot;#22c55e&quot; stroke-width=&quot;1.5&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;360&quot; y=&quot;348&quot; text-anchor=&quot;middle&quot; fill=&quot;#22c55e&quot; font-size=&quot;12&quot; font-weight=&quot;bold&quot;&gt;a working service&lt;/text&gt;&lt;/svg&gt;
&lt;p&gt;The honest framing is that code gets you a correctly-arranged empty lab very quickly, and that’s genuinely useful — recreating ten containers with the right settings by hand is an afternoon of tedious clicking and at least one typo. But it’s the first half of a recovery, not the whole thing. Anyone who tells you their infrastructure is “fully in code” and doesn’t mention backups has described half a system.&lt;/p&gt;
&lt;p&gt;The same split applies to the hosts. Code can reapply the things you configured on top of a fresh Proxmox install — the monitoring agents, the mesh networking, the scheduled scripts. It cannot reinstall Proxmox itself, and it cannot bring back the API tokens, because those live in a cluster database that dies with the machine. Every token has to be reissued by hand after a rebuild. That’s worth writing down before you need it.&lt;/p&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;give-the-tool-a-credential-that-cannot-break-anything&quot;&gt;Give the tool a credential that cannot break anything&lt;/h2&gt;
&lt;p&gt;Here’s the decision that makes all of this safe, and it’s the one I’d argue hardest for.&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://www.proxmox.com/en/proxmox-virtual-environment&quot;&gt;Proxmox VE&lt;/a&gt; has a built-in role called &lt;strong&gt;PVEAuditor&lt;/strong&gt;, described in the official documentation as having &lt;a href=&quot;https://pve.proxmox.com/wiki/User_Management&quot;&gt;read only access&lt;/a&gt;. Create an API token, give it that role and nothing else, and hand &lt;em&gt;that&lt;/em&gt; to your infrastructure tool.&lt;/p&gt;
&lt;p&gt;The token can read every setting it needs to build an accurate description of your cluster. It cannot create, modify, or delete a single guest. Not because you configured the tool carefully — because the API will refuse.&lt;/p&gt;
&lt;div class=&quot;callout callout-warning&quot; data-astro-cid-q2ml7llr&gt;&lt;div class=&quot;callout-header&quot; data-astro-cid-q2ml7llr&gt;&lt;span class=&quot;callout-icon&quot; aria-hidden=&quot;true&quot; data-astro-cid-q2ml7llr&gt;⚠️&lt;/span&gt;&lt;span class=&quot;callout-label&quot; data-astro-cid-q2ml7llr&gt;First: make these values your own&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;callout-body&quot; data-astro-cid-q2ml7llr&gt;&lt;p&gt;Everything below uses stand-in values. &lt;code&gt;10.0.0.73&lt;/code&gt; is a documentation address — use your own node’s address. &lt;code&gt;terraform@pve&lt;/code&gt; and &lt;code&gt;tf&lt;/code&gt; are the user and token names I picked; use whatever names you like. &lt;code&gt;pvelab01&lt;/code&gt; through &lt;code&gt;pvelab04&lt;/code&gt; are my node names and yours will differ. Container IDs like &lt;code&gt;100&lt;/code&gt; are examples — use the IDs your cluster actually assigned. The token secret Proxmox shows you goes into your own secret store, never into a file you commit. If a value looks specific to one machine, it’s a placeholder to change — not a literal to copy.&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;
&lt;figure class=&quot;terminal&quot; data-astro-cid-kenkipkp&gt;&lt;figcaption class=&quot;terminal-bar&quot; data-astro-cid-kenkipkp&gt;&lt;span class=&quot;dot red&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot yellow&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot green&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;terminal-title&quot; data-astro-cid-kenkipkp&gt;Create a read-only API token on any cluster node&lt;/span&gt;&lt;button class=&quot;terminal-copy&quot; type=&quot;button&quot; aria-label=&quot;Copy commands&quot; data-astro-cid-kenkipkp&gt;&lt;svg width=&quot;13&quot; height=&quot;13&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;none&quot; stroke=&quot;currentColor&quot; stroke-width=&quot;2&quot; stroke-linecap=&quot;round&quot; stroke-linejoin=&quot;round&quot; aria-hidden=&quot;true&quot; data-astro-cid-kenkipkp&gt;&lt;rect x=&quot;9&quot; y=&quot;9&quot; width=&quot;13&quot; height=&quot;13&quot; rx=&quot;2&quot; data-astro-cid-kenkipkp&gt;&lt;/rect&gt;&lt;path d=&quot;M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1&quot; data-astro-cid-kenkipkp&gt;&lt;/path&gt;&lt;/svg&gt;&lt;span class=&quot;terminal-copy-label&quot; data-astro-cid-kenkipkp&gt;Copy&lt;/span&gt;&lt;/button&gt;&lt;/figcaption&gt;&lt;pre class=&quot;terminal-body&quot; data-astro-cid-kenkipkp&gt;&lt;code data-astro-cid-kenkipkp&gt;
# Create a dedicated user for the tool
pveum user add terraform@pve

# Grant read-only access at the root of the permission tree
pveum acl modify / --users terraform@pve --roles PVEAuditor

# Create the token. Copy the secret it prints — it is shown once.
pveum user token add terraform@pve tf --privsep 1
&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;
&lt;p&gt;The &lt;code&gt;--privsep 1&lt;/code&gt; flag is worth understanding rather than copying — and it’s &lt;a href=&quot;https://pve.proxmox.com/pve-docs/pveum.1.html&quot;&gt;the documented default&lt;/a&gt;, so writing it out is me being explicit about a choice, not switching something on. Proxmox calls it privilege separation, and with it enabled the token’s effective permissions are, in the documentation’s words, “calculated by intersecting user and token permissions.” Two locks in series: the token can only do what the &lt;em&gt;user&lt;/em&gt; is allowed to do &lt;strong&gt;and&lt;/strong&gt; what the &lt;em&gt;token&lt;/em&gt; is separately granted. Since the user is an auditor, the ceiling is read-only no matter what happens to the token’s own grants later.&lt;/p&gt;
&lt;div class=&quot;callout callout-danger&quot; data-astro-cid-q2ml7llr&gt;&lt;div class=&quot;callout-header&quot; data-astro-cid-q2ml7llr&gt;&lt;span class=&quot;callout-icon&quot; aria-hidden=&quot;true&quot; data-astro-cid-q2ml7llr&gt;🚨&lt;/span&gt;&lt;span class=&quot;callout-label&quot; data-astro-cid-q2ml7llr&gt;The intersection cuts both ways&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;callout-body&quot; data-astro-cid-q2ml7llr&gt;&lt;p&gt;Because permissions are an intersection, a privilege-separated token can never exceed its user. If you later need a token that &lt;em&gt;can&lt;/em&gt; write — to build a throwaway test container, say — do not promote the auditor user to get it. Create a separate short-lived user for that job and delete it afterwards. Keeping one identity permanently read-only is the whole point; widening it “just for a minute” quietly removes the safety net from everything else that uses it.&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;why-prevent_destroy-is-the-second-lock-not-the-first&quot;&gt;Why &lt;code&gt;prevent_destroy&lt;/code&gt; is the second lock, not the first&lt;/h2&gt;
&lt;p&gt;Both &lt;a href=&quot;https://opentofu.org/&quot;&gt;OpenTofu&lt;/a&gt; — the open source fork created after &lt;a href=&quot;https://opentofu.org/manifesto/&quot;&gt;HashiCorp relicensed Terraform under the Business Source License&lt;/a&gt; — and Terraform itself offer a lifecycle setting called &lt;code&gt;prevent_destroy&lt;/code&gt;. Set it on a resource and the tool will refuse to proceed with any plan that would destroy that object. It looks like exactly the guardrail you want, and I do set it on every imported container.&lt;/p&gt;
&lt;p&gt;But read the documentation carefully, because there’s a gap in it that surprised me:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;“Since this argument must be present in configuration for the protection to be active, this setting cannot prevent the remote object from being destroyed when the &lt;code&gt;resource&lt;/code&gt; block is removed from configuration entirely: in that case, the &lt;code&gt;prevent_destroy&lt;/code&gt; setting is removed along with it, and so OpenTofu would allow the destroy operation to succeed.”&lt;/p&gt;
&lt;p&gt;— &lt;a href=&quot;https://opentofu.org/docs/language/resources/behavior/&quot;&gt;OpenTofu, Resource Behavior&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;The protection lives inside the thing it’s protecting. Delete the block — a careless edit, a bad merge, a tidy-up of a file you thought was unused — and you delete the guard along with it. The tool will then cheerfully destroy the container, and it will be &lt;em&gt;right&lt;/em&gt; to, because as far as the configuration is concerned you asked for that.&lt;/p&gt;
&lt;svg viewBox=&quot;0 0 720 300&quot; width=&quot;100%&quot; role=&quot;img&quot; aria-label=&quot;Diagram of two safety layers. A destroy request first meets prevent_destroy in the configuration file, which stops it only while the resource block still exists and is bypassed if the block is deleted. The request then meets the read-only API token, which refuses it unconditionally at the Proxmox API.&quot;&gt;&lt;rect width=&quot;720&quot; height=&quot;300&quot; rx=&quot;12&quot; fill=&quot;#1c1c32&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;360&quot; y=&quot;30&quot; text-anchor=&quot;middle&quot; fill=&quot;#f4f4f5&quot; font-size=&quot;15&quot; font-weight=&quot;bold&quot;&gt;Two locks, and only one of them always holds&lt;/text&gt;&lt;rect x=&quot;24&quot; y=&quot;112&quot; width=&quot;118&quot; height=&quot;60&quot; rx=&quot;8&quot; fill=&quot;#30305a&quot; stroke=&quot;#ef4444&quot; stroke-width=&quot;1.5&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;83&quot; y=&quot;139&quot; text-anchor=&quot;middle&quot; fill=&quot;#ef4444&quot; font-size=&quot;11&quot; font-weight=&quot;bold&quot;&gt;destroy&lt;/text&gt;&lt;text x=&quot;83&quot; y=&quot;156&quot; text-anchor=&quot;middle&quot; fill=&quot;#ef4444&quot; font-size=&quot;11&quot; font-weight=&quot;bold&quot;&gt;request&lt;/text&gt;&lt;path d=&quot;M 142 142 L 196 142&quot; stroke=&quot;#ef4444&quot; stroke-width=&quot;2&quot; marker-end=&quot;url(#cacArrow)&quot;&gt;&lt;/path&gt;&lt;rect x=&quot;200&quot; y=&quot;88&quot; width=&quot;200&quot; height=&quot;108&quot; rx=&quot;8&quot; fill=&quot;#2c2c4c&quot; stroke=&quot;#f59e0b&quot; stroke-width=&quot;1.5&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;300&quot; y=&quot;114&quot; text-anchor=&quot;middle&quot; fill=&quot;#f59e0b&quot; font-size=&quot;12&quot; font-weight=&quot;bold&quot;&gt;prevent_destroy&lt;/text&gt;&lt;text x=&quot;300&quot; y=&quot;136&quot; text-anchor=&quot;middle&quot; fill=&quot;#a1a1aa&quot; font-size=&quot;10&quot;&gt;in your .tf file&lt;/text&gt;&lt;text x=&quot;300&quot; y=&quot;160&quot; text-anchor=&quot;middle&quot; fill=&quot;#22c55e&quot; font-size=&quot;10&quot;&gt;holds while the block exists&lt;/text&gt;&lt;text x=&quot;300&quot; y=&quot;178&quot; text-anchor=&quot;middle&quot; fill=&quot;#ef4444&quot; font-size=&quot;10&quot;&gt;gone if the block is deleted&lt;/text&gt;&lt;path d=&quot;M 400 142 L 454 142&quot; stroke=&quot;#ef4444&quot; stroke-width=&quot;2&quot; marker-end=&quot;url(#cacArrow)&quot;&gt;&lt;/path&gt;&lt;text x=&quot;427&quot; y=&quot;132&quot; text-anchor=&quot;middle&quot; fill=&quot;#6b7280&quot; font-size=&quot;9&quot;&gt;bypassed&lt;/text&gt;&lt;rect x=&quot;458&quot; y=&quot;88&quot; width=&quot;238&quot; height=&quot;108&quot; rx=&quot;8&quot; fill=&quot;#2c2c4c&quot; stroke=&quot;#14b8a6&quot; stroke-width=&quot;1.5&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;577&quot; y=&quot;114&quot; text-anchor=&quot;middle&quot; fill=&quot;#14b8a6&quot; font-size=&quot;12&quot; font-weight=&quot;bold&quot;&gt;PVEAuditor token&lt;/text&gt;&lt;text x=&quot;577&quot; y=&quot;136&quot; text-anchor=&quot;middle&quot; fill=&quot;#a1a1aa&quot; font-size=&quot;10&quot;&gt;enforced at the Proxmox API&lt;/text&gt;&lt;text x=&quot;577&quot; y=&quot;160&quot; text-anchor=&quot;middle&quot; fill=&quot;#22c55e&quot; font-size=&quot;10&quot;&gt;holds unconditionally&lt;/text&gt;&lt;text x=&quot;577&quot; y=&quot;178&quot; text-anchor=&quot;middle&quot; fill=&quot;#22c55e&quot; font-size=&quot;10&quot;&gt;nothing in your repo can remove it&lt;/text&gt;&lt;text x=&quot;360&quot; y=&quot;232&quot; text-anchor=&quot;middle&quot; fill=&quot;#e5e7eb&quot; font-size=&quot;11&quot;&gt;A guard that lives inside the file it guards is only as durable as the file.&lt;/text&gt;&lt;text x=&quot;360&quot; y=&quot;254&quot; text-anchor=&quot;middle&quot; fill=&quot;#e5e7eb&quot; font-size=&quot;11&quot;&gt;A guard that lives on the server survives anything you do to the repo.&lt;/text&gt;&lt;defs&gt;&lt;marker id=&quot;cacArrow&quot; viewBox=&quot;0 0 10 10&quot; refX=&quot;9&quot; refY=&quot;5&quot; markerWidth=&quot;5&quot; markerHeight=&quot;5&quot; orient=&quot;auto-start-reverse&quot;&gt;&lt;path d=&quot;M 0 0 L 10 5 L 0 10 z&quot; fill=&quot;#ef4444&quot;&gt;&lt;/path&gt;&lt;/marker&gt;&lt;/defs&gt;&lt;/svg&gt;
&lt;p&gt;This is why I keep insisting on the read-only token. &lt;code&gt;prevent_destroy&lt;/code&gt; protects against the plan you didn’t read properly. The auditor token protects against everything else, including the edit you make six months from now in a repo you’ve half-forgotten, because it’s enforced on the server where no change to your files can reach it.&lt;/p&gt;
&lt;p&gt;Belt and braces, in that order of importance.&lt;/p&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;the-posture-in-four-rules&quot;&gt;The posture, in four rules&lt;/h2&gt;
&lt;p&gt;Everything above condenses to a short list. These are the rules I hold to across the rest of the series:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Import, never create.&lt;/strong&gt; The code describes what exists. Bringing a guest under management must not restart it, resize it, or touch it in any way.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The credential is read-only, permanently.&lt;/strong&gt; Write access is minted separately, scoped narrowly, used briefly, and deleted. It is never a property of the main identity.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;prevent_destroy&lt;/code&gt; on everything imported.&lt;/strong&gt; A second lock, understood as second.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;An exact match is the deliverable.&lt;/strong&gt; The artifact isn’t a lab you can rebuild — it’s a command that says “no changes” and therefore means something when it doesn’t.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;That fourth one is the subject of the next post. Describing a running container accurately enough that a tool agrees, attribute for attribute, is harder than it sounds — the provider fills in defaults you never set, trims whitespace you didn’t notice, and generates a starter configuration with invalid values in it. Getting from “roughly right” to a genuinely clean plan is where the real work is, and it’s what &lt;a href=&quot;https://peira.dev/blog/proxmox-opentofu-import-plan-zero/&quot;&gt;part two&lt;/a&gt; walks through end to end.&lt;/p&gt;
&lt;div class=&quot;callout callout-info&quot; data-astro-cid-q2ml7llr&gt;&lt;div class=&quot;callout-header&quot; data-astro-cid-q2ml7llr&gt;&lt;span class=&quot;callout-icon&quot; aria-hidden=&quot;true&quot; data-astro-cid-q2ml7llr&gt;ℹ️&lt;/span&gt;&lt;span class=&quot;callout-label&quot; data-astro-cid-q2ml7llr&gt;Before you start&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;callout-body&quot; data-astro-cid-q2ml7llr&gt;&lt;p&gt;You need a working cluster before any of this is worth doing. If you’re earlier in the journey, &lt;a href=&quot;https://peira.dev/blog/proxmox-install-guide/&quot;&gt;set up Proxmox&lt;/a&gt; first, then &lt;a href=&quot;https://peira.dev/blog/proxmox-backup-server/&quot;&gt;get your backups running&lt;/a&gt; — code is the second half of a recovery plan and backups are the first.&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;
&lt;hr/&gt;
&lt;p&gt;&lt;strong&gt;Related posts:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/proxmox-opentofu-import-plan-zero/&quot;&gt;Import a Live Proxmox Cluster Into OpenTofu With Zero Changes&lt;/a&gt; — part two: the hands-on import recipe that turns this posture into a clean plan&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/proxmox-ansible-capture-then-assert/&quot;&gt;Ansible for a Cluster You Built by Hand: Capture, Then Assert&lt;/a&gt; — part three: the same describe-don’t-impose posture, applied to the nodes&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/opentofu-scratch-container-lifecycle/&quot;&gt;A Proxmox Container Born, Configured, and Destroyed From Code&lt;/a&gt; — part four: proving the description can build, with write access minted the scoped-and-brief way rule 2 demands&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/proxmox-rebuild-from-zero/&quot;&gt;Rebuild Your Homelab From Zero: What Survives a Total Loss&lt;/a&gt; — part five: the disaster scenario this whole posture exists for&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/homelab-capacity-planning-node-failure/&quot;&gt;Homelab Capacity Planning: What If a Node Dies Tonight?&lt;/a&gt; — the other half of thinking about failure, using measured RAM rather than configured limits&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/proxmox-backup-server/&quot;&gt;Proxmox Backup Server: Automated CT and VM Backups with Deduplication&lt;/a&gt; — the contents half of recovery, which code cannot replace&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/proxmox-cluster-setup/&quot;&gt;Forming a Proxmox Cluster: Quorum, Corosync, and Joining Multiple Nodes&lt;/a&gt; — the multi-node setup this series assumes you already have&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/proxmox-security-hardening/&quot;&gt;Proxmox Security Hardening: SSH Keys, Firewall, and Locking Down a New Cluster&lt;/a&gt; — where API tokens and permission scoping fit into the wider access model&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/second-brain-secrets-discipline/&quot;&gt;Never Commit a Secret to Your Knowledge Base&lt;/a&gt; — where the token secret goes instead of into your repo&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/proxmox-create-lxc/&quot;&gt;Creating Your First Proxmox LXC Container: Step-by-Step&lt;/a&gt; — what the imported resources are actually describing&lt;/li&gt;
&lt;/ul&gt;</content:encoded><category>proxmox</category><category>automation</category><category>infrastructure</category></item><item><title>Import a Live Proxmox Cluster Into OpenTofu With Zero Changes</title><link>https://peira.dev/blog/proxmox-opentofu-import-plan-zero/</link><guid isPermaLink="true">https://peira.dev/blog/proxmox-opentofu-import-plan-zero/</guid><description>Bring running Proxmox containers under OpenTofu without restarting one: import blocks, the generated-config cleanup, and a clean plan that becomes your drift detector.</description><pubDate>Wed, 29 Jul 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;In &lt;a href=&quot;https://peira.dev/blog/proxmox-cluster-as-code-import-first/&quot;&gt;part one&lt;/a&gt; I argued that codifying a running homelab means describing what exists rather than building from scratch, and that the tool should hold a read-only credential so it physically cannot damage anything. This post is the part where that becomes real: nine containers that had been running for months, brought under management without one of them noticing.&lt;/p&gt;
&lt;p&gt;The finish line is a single line of output — &lt;code&gt;No changes. Your infrastructure matches the configuration.&lt;/code&gt; — and getting there took considerably longer than I expected, because “matches” turns out to be a strict word.&lt;/p&gt;
&lt;div class=&quot;callout callout-warning&quot; data-astro-cid-q2ml7llr&gt;&lt;div class=&quot;callout-header&quot; data-astro-cid-q2ml7llr&gt;&lt;span class=&quot;callout-icon&quot; aria-hidden=&quot;true&quot; data-astro-cid-q2ml7llr&gt;⚠️&lt;/span&gt;&lt;span class=&quot;callout-label&quot; data-astro-cid-q2ml7llr&gt;First: make these values your own&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;callout-body&quot; data-astro-cid-q2ml7llr&gt;&lt;p&gt;Every value below is a stand-in. &lt;code&gt;10.0.0.73&lt;/code&gt; is a documentation address — use your own node’s address. &lt;code&gt;pvelab01&lt;/code&gt; through &lt;code&gt;pvelab04&lt;/code&gt; are my node names; yours will differ. Container IDs like &lt;code&gt;100&lt;/code&gt; and names like &lt;code&gt;wikijs&lt;/code&gt; are mine — use the ones your cluster actually has. &lt;code&gt;terraform@pve!tf&lt;/code&gt; is the token identity created in part one, and its secret belongs in your own secret store, never in a committed file. If a value looks specific to one machine, it’s a placeholder to change — not a literal to copy.&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;the-shape-of-the-job&quot;&gt;The shape of the job&lt;/h2&gt;
&lt;p&gt;Four steps, and the third is where the time goes.&lt;/p&gt;
&lt;svg viewBox=&quot;0 0 720 260&quot; width=&quot;100%&quot; role=&quot;img&quot; aria-label=&quot;Four-stage pipeline. Declare import blocks naming each container, generate a starter configuration with the generate-config-out flag, curate that draft by removing invalid attributes and ignoring provider defaults, then apply which writes state only. The result is a plan reporting no changes.&quot;&gt;&lt;rect width=&quot;720&quot; height=&quot;260&quot; rx=&quot;12&quot; fill=&quot;#1c1c32&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;360&quot; y=&quot;32&quot; text-anchor=&quot;middle&quot; fill=&quot;#f4f4f5&quot; font-size=&quot;15&quot; font-weight=&quot;bold&quot;&gt;Import pipeline — only the last step writes anything&lt;/text&gt;&lt;rect x=&quot;24&quot; y=&quot;68&quot; width=&quot;156&quot; height=&quot;92&quot; rx=&quot;8&quot; fill=&quot;#2c2c4c&quot; stroke=&quot;#3d3d5c&quot; stroke-width=&quot;1.2&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;102&quot; y=&quot;94&quot; text-anchor=&quot;middle&quot; fill=&quot;#14b8a6&quot; font-size=&quot;12&quot; font-weight=&quot;bold&quot;&gt;1 · declare&lt;/text&gt;&lt;text x=&quot;102&quot; y=&quot;118&quot; text-anchor=&quot;middle&quot; fill=&quot;#e5e7eb&quot; font-size=&quot;10&quot;&gt;one import block&lt;/text&gt;&lt;text x=&quot;102&quot; y=&quot;136&quot; text-anchor=&quot;middle&quot; fill=&quot;#a1a1aa&quot; font-size=&quot;10&quot;&gt;per container&lt;/text&gt;&lt;rect x=&quot;196&quot; y=&quot;68&quot; width=&quot;156&quot; height=&quot;92&quot; rx=&quot;8&quot; fill=&quot;#2c2c4c&quot; stroke=&quot;#3d3d5c&quot; stroke-width=&quot;1.2&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;274&quot; y=&quot;94&quot; text-anchor=&quot;middle&quot; fill=&quot;#14b8a6&quot; font-size=&quot;12&quot; font-weight=&quot;bold&quot;&gt;2 · generate&lt;/text&gt;&lt;text x=&quot;274&quot; y=&quot;118&quot; text-anchor=&quot;middle&quot; fill=&quot;#e5e7eb&quot; font-size=&quot;10&quot;&gt;-generate-config-out&lt;/text&gt;&lt;text x=&quot;274&quot; y=&quot;136&quot; text-anchor=&quot;middle&quot; fill=&quot;#a1a1aa&quot; font-size=&quot;10&quot;&gt;writes a draft&lt;/text&gt;&lt;rect x=&quot;368&quot; y=&quot;68&quot; width=&quot;156&quot; height=&quot;92&quot; rx=&quot;8&quot; fill=&quot;#2c2c4c&quot; stroke=&quot;#f59e0b&quot; stroke-width=&quot;1.8&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;446&quot; y=&quot;94&quot; text-anchor=&quot;middle&quot; fill=&quot;#f59e0b&quot; font-size=&quot;12&quot; font-weight=&quot;bold&quot;&gt;3 · curate&lt;/text&gt;&lt;text x=&quot;446&quot; y=&quot;118&quot; text-anchor=&quot;middle&quot; fill=&quot;#e5e7eb&quot; font-size=&quot;10&quot;&gt;strip invalid attrs&lt;/text&gt;&lt;text x=&quot;446&quot; y=&quot;136&quot; text-anchor=&quot;middle&quot; fill=&quot;#a1a1aa&quot; font-size=&quot;10&quot;&gt;ignore provider defaults&lt;/text&gt;&lt;rect x=&quot;540&quot; y=&quot;68&quot; width=&quot;156&quot; height=&quot;92&quot; rx=&quot;8&quot; fill=&quot;#2c2c4c&quot; stroke=&quot;#3d3d5c&quot; stroke-width=&quot;1.2&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;618&quot; y=&quot;94&quot; text-anchor=&quot;middle&quot; fill=&quot;#14b8a6&quot; font-size=&quot;12&quot; font-weight=&quot;bold&quot;&gt;4 · apply&lt;/text&gt;&lt;text x=&quot;618&quot; y=&quot;118&quot; text-anchor=&quot;middle&quot; fill=&quot;#e5e7eb&quot; font-size=&quot;10&quot;&gt;writes state only&lt;/text&gt;&lt;text x=&quot;618&quot; y=&quot;136&quot; text-anchor=&quot;middle&quot; fill=&quot;#a1a1aa&quot; font-size=&quot;10&quot;&gt;cluster untouched&lt;/text&gt;&lt;path d=&quot;M 180 114 L 194 114&quot; stroke=&quot;#818cf8&quot; stroke-width=&quot;2&quot; marker-end=&quot;url(#impArrow)&quot;&gt;&lt;/path&gt;&lt;path d=&quot;M 352 114 L 366 114&quot; stroke=&quot;#818cf8&quot; stroke-width=&quot;2&quot; marker-end=&quot;url(#impArrow)&quot;&gt;&lt;/path&gt;&lt;path d=&quot;M 524 114 L 538 114&quot; stroke=&quot;#818cf8&quot; stroke-width=&quot;2&quot; marker-end=&quot;url(#impArrow)&quot;&gt;&lt;/path&gt;&lt;text x=&quot;446&quot; y=&quot;180&quot; text-anchor=&quot;middle&quot; fill=&quot;#f59e0b&quot; font-size=&quot;10&quot;&gt;most of the work lives here&lt;/text&gt;&lt;rect x=&quot;216&quot; y=&quot;196&quot; width=&quot;288&quot; height=&quot;38&quot; rx=&quot;8&quot; fill=&quot;#30305a&quot; stroke=&quot;#22c55e&quot; stroke-width=&quot;1.5&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;360&quot; y=&quot;220&quot; text-anchor=&quot;middle&quot; fill=&quot;#22c55e&quot; font-size=&quot;12&quot; font-weight=&quot;bold&quot;&gt;plan → “No changes.”&lt;/text&gt;&lt;defs&gt;&lt;marker id=&quot;impArrow&quot; viewBox=&quot;0 0 10 10&quot; refX=&quot;9&quot; refY=&quot;5&quot; markerWidth=&quot;5&quot; markerHeight=&quot;5&quot; orient=&quot;auto-start-reverse&quot;&gt;&lt;path d=&quot;M 0 0 L 10 5 L 0 10 z&quot; fill=&quot;#818cf8&quot;&gt;&lt;/path&gt;&lt;/marker&gt;&lt;/defs&gt;&lt;/svg&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;step-1-point-the-provider-at-your-cluster&quot;&gt;Step 1: point the provider at your cluster&lt;/h2&gt;
&lt;p&gt;I’m using &lt;a href=&quot;https://opentofu.org/&quot;&gt;OpenTofu&lt;/a&gt; with the &lt;a href=&quot;https://github.com/bpg/terraform-provider-proxmox&quot;&gt;bpg/proxmox provider&lt;/a&gt;, which at the time of writing is at &lt;a href=&quot;https://github.com/bpg/terraform-provider-proxmox/releases&quot;&gt;v0.111.1&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;A word on the version constraint, because it promises less than it looks like it does. The &lt;code&gt;~&amp;gt;&lt;/code&gt; operator &lt;a href=&quot;https://opentofu.org/docs/language/expressions/version-constraints/&quot;&gt;allows the rightmost component to increment&lt;/a&gt;, so &lt;code&gt;~&amp;gt; 0.60&lt;/code&gt; accepts anything from 0.60 up to (not including) 1.0 — which is every release this provider has ever made. On a project still below 1.0 there is no stability guarantee across minor versions, so that constraint is a floor, not a shield. The thing actually pinning your builds is the generated &lt;code&gt;.terraform.lock.hcl&lt;/code&gt; file, which records the exact version and its checksums. Commit it.&lt;/p&gt;
&lt;figure class=&quot;terminal&quot; data-astro-cid-kenkipkp&gt;&lt;figcaption class=&quot;terminal-bar&quot; data-astro-cid-kenkipkp&gt;&lt;span class=&quot;dot red&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot yellow&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot green&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;terminal-title&quot; data-astro-cid-kenkipkp&gt;providers.tf&lt;/span&gt;&lt;button class=&quot;terminal-copy&quot; type=&quot;button&quot; aria-label=&quot;Copy commands&quot; data-astro-cid-kenkipkp&gt;&lt;svg width=&quot;13&quot; height=&quot;13&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;none&quot; stroke=&quot;currentColor&quot; stroke-width=&quot;2&quot; stroke-linecap=&quot;round&quot; stroke-linejoin=&quot;round&quot; aria-hidden=&quot;true&quot; data-astro-cid-kenkipkp&gt;&lt;rect x=&quot;9&quot; y=&quot;9&quot; width=&quot;13&quot; height=&quot;13&quot; rx=&quot;2&quot; data-astro-cid-kenkipkp&gt;&lt;/rect&gt;&lt;path d=&quot;M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1&quot; data-astro-cid-kenkipkp&gt;&lt;/path&gt;&lt;/svg&gt;&lt;span class=&quot;terminal-copy-label&quot; data-astro-cid-kenkipkp&gt;Copy&lt;/span&gt;&lt;/button&gt;&lt;/figcaption&gt;&lt;pre class=&quot;terminal-body&quot; data-astro-cid-kenkipkp&gt;&lt;code data-astro-cid-kenkipkp&gt;
terraform {
required_version = &amp;quot;&amp;gt;= 1.8&amp;quot;
required_providers {
  proxmox = {
    source  = &amp;quot;bpg/proxmox&amp;quot;
    version = &amp;quot;~&amp;gt; 0.60&amp;quot;
  }
}
}

provider &amp;quot;proxmox&amp;quot; {
endpoint = &amp;quot;https://10.0.0.73:8006&amp;quot;
# Self-signed cluster certificate on a LAN-only API.
insecure = true
# Auth comes from the PROXMOX_VE_API_TOKEN environment variable —
# never write the token secret into a file you commit.
}
&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;
&lt;p&gt;The credential stays out of the file entirely. The provider reads it from an environment variable, which I source from a secret file at the moment I run a command — the same &lt;a href=&quot;https://peira.dev/blog/second-brain-secrets-discipline/&quot;&gt;secrets discipline&lt;/a&gt; that applies everywhere else.&lt;/p&gt;
&lt;figure class=&quot;terminal&quot; data-astro-cid-kenkipkp&gt;&lt;figcaption class=&quot;terminal-bar&quot; data-astro-cid-kenkipkp&gt;&lt;span class=&quot;dot red&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot yellow&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot green&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;terminal-title&quot; data-astro-cid-kenkipkp&gt;A Makefile target that loads the secret only for this command&lt;/span&gt;&lt;button class=&quot;terminal-copy&quot; type=&quot;button&quot; aria-label=&quot;Copy commands&quot; data-astro-cid-kenkipkp&gt;&lt;svg width=&quot;13&quot; height=&quot;13&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;none&quot; stroke=&quot;currentColor&quot; stroke-width=&quot;2&quot; stroke-linecap=&quot;round&quot; stroke-linejoin=&quot;round&quot; aria-hidden=&quot;true&quot; data-astro-cid-kenkipkp&gt;&lt;rect x=&quot;9&quot; y=&quot;9&quot; width=&quot;13&quot; height=&quot;13&quot; rx=&quot;2&quot; data-astro-cid-kenkipkp&gt;&lt;/rect&gt;&lt;path d=&quot;M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1&quot; data-astro-cid-kenkipkp&gt;&lt;/path&gt;&lt;/svg&gt;&lt;span class=&quot;terminal-copy-label&quot; data-astro-cid-kenkipkp&gt;Copy&lt;/span&gt;&lt;/button&gt;&lt;/figcaption&gt;&lt;pre class=&quot;terminal-body&quot; data-astro-cid-kenkipkp&gt;&lt;code data-astro-cid-kenkipkp&gt;
plan:
@bash -c &amp;#39;set -a; source ~/.my-secrets; set +a; cd terraform &amp;amp;&amp;amp; tofu plan&amp;#39;
&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;step-2-declare-what-you-want-to-import&quot;&gt;Step 2: declare what you want to import&lt;/h2&gt;
&lt;p&gt;An import block says “this resource address should correspond to that existing object.” It contains no settings — just the mapping.&lt;/p&gt;
&lt;p&gt;The identifier format is provider-specific. For containers under this provider, the official documentation shows it as &lt;code&gt;node_name/vm_id&lt;/code&gt; — the node the container lives on, a slash, then its numeric ID.&lt;/p&gt;
&lt;figure class=&quot;terminal&quot; data-astro-cid-kenkipkp&gt;&lt;figcaption class=&quot;terminal-bar&quot; data-astro-cid-kenkipkp&gt;&lt;span class=&quot;dot red&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot yellow&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot green&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;terminal-title&quot; data-astro-cid-kenkipkp&gt;imports.tf&lt;/span&gt;&lt;button class=&quot;terminal-copy&quot; type=&quot;button&quot; aria-label=&quot;Copy commands&quot; data-astro-cid-kenkipkp&gt;&lt;svg width=&quot;13&quot; height=&quot;13&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;none&quot; stroke=&quot;currentColor&quot; stroke-width=&quot;2&quot; stroke-linecap=&quot;round&quot; stroke-linejoin=&quot;round&quot; aria-hidden=&quot;true&quot; data-astro-cid-kenkipkp&gt;&lt;rect x=&quot;9&quot; y=&quot;9&quot; width=&quot;13&quot; height=&quot;13&quot; rx=&quot;2&quot; data-astro-cid-kenkipkp&gt;&lt;/rect&gt;&lt;path d=&quot;M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1&quot; data-astro-cid-kenkipkp&gt;&lt;/path&gt;&lt;/svg&gt;&lt;span class=&quot;terminal-copy-label&quot; data-astro-cid-kenkipkp&gt;Copy&lt;/span&gt;&lt;/button&gt;&lt;/figcaption&gt;&lt;pre class=&quot;terminal-body&quot; data-astro-cid-kenkipkp&gt;&lt;code data-astro-cid-kenkipkp&gt;
import {
to = proxmox_virtual_environment_container.wikijs
id = &amp;quot;pvelab03/106&amp;quot;
}

import {
to = proxmox_virtual_environment_container.monitoring
id = &amp;quot;pvelab02/105&amp;quot;
}
&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;
&lt;div class=&quot;callout callout-tip&quot; data-astro-cid-q2ml7llr&gt;&lt;div class=&quot;callout-header&quot; data-astro-cid-q2ml7llr&gt;&lt;span class=&quot;callout-icon&quot; aria-hidden=&quot;true&quot; data-astro-cid-q2ml7llr&gt;💡&lt;/span&gt;&lt;span class=&quot;callout-label&quot; data-astro-cid-q2ml7llr&gt;Leave the disposable ones out&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;callout-body&quot; data-astro-cid-q2ml7llr&gt;&lt;p&gt;I deliberately did not import one container — a throwaway I use for screenshots that gets rebuilt constantly. Codifying something whose whole purpose is to be disposable adds noise to every plan for no benefit. Managing everything is not the goal; managing the things whose loss would hurt is.&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;step-3-generate-a-draft-then-curate-it&quot;&gt;Step 3: generate a draft, then curate it&lt;/h2&gt;
&lt;p&gt;You could write each resource block by hand from the settings shown in the Proxmox web interface. Don’t — there are far more attributes than the interface displays, and missing one produces a plan that wants to “fix” it.&lt;/p&gt;
&lt;p&gt;Instead let the tool write a first draft. OpenTofu will generate configuration for any resource named in an import block that doesn’t yet exist in your files:&lt;/p&gt;
&lt;figure class=&quot;terminal&quot; data-astro-cid-kenkipkp&gt;&lt;figcaption class=&quot;terminal-bar&quot; data-astro-cid-kenkipkp&gt;&lt;span class=&quot;dot red&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot yellow&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot green&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;terminal-title&quot; data-astro-cid-kenkipkp&gt;Generate a starter configuration&lt;/span&gt;&lt;button class=&quot;terminal-copy&quot; type=&quot;button&quot; aria-label=&quot;Copy commands&quot; data-astro-cid-kenkipkp&gt;&lt;svg width=&quot;13&quot; height=&quot;13&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;none&quot; stroke=&quot;currentColor&quot; stroke-width=&quot;2&quot; stroke-linecap=&quot;round&quot; stroke-linejoin=&quot;round&quot; aria-hidden=&quot;true&quot; data-astro-cid-kenkipkp&gt;&lt;rect x=&quot;9&quot; y=&quot;9&quot; width=&quot;13&quot; height=&quot;13&quot; rx=&quot;2&quot; data-astro-cid-kenkipkp&gt;&lt;/rect&gt;&lt;path d=&quot;M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1&quot; data-astro-cid-kenkipkp&gt;&lt;/path&gt;&lt;/svg&gt;&lt;span class=&quot;terminal-copy-label&quot; data-astro-cid-kenkipkp&gt;Copy&lt;/span&gt;&lt;/button&gt;&lt;/figcaption&gt;&lt;pre class=&quot;terminal-body&quot; data-astro-cid-kenkipkp&gt;&lt;code data-astro-cid-kenkipkp&gt;
tofu plan -generate-config-out=generated.tf
&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;
&lt;p&gt;Now the important part, and the reason this step gets its own section. The documentation is refreshingly blunt about what you get: OpenTofu produces HCL containing its &lt;a href=&quot;https://opentofu.org/docs/language/import/generating-configuration/&quot;&gt;“best guess at the appropriate value for each resource argument”&lt;/a&gt;, recommends “iterating to find your ideal configuration by removing some attributes, adjusting the value of others,” and warns that for complex resources it may generate “conflicting resource arguments” where “you must choose only one.”&lt;/p&gt;
&lt;p&gt;That is exactly what happened. The generated file for my containers included attributes that were invalid on their face — an empty entrypoint string, a numeric field set to zero where the provider expects it unset, and a &lt;code&gt;vm_id&lt;/code&gt; of &lt;code&gt;null&lt;/code&gt; on resources whose ID was the one thing I definitely knew. None of it validated until I removed them.&lt;/p&gt;
&lt;div class=&quot;callout callout-warning&quot; data-astro-cid-q2ml7llr&gt;&lt;div class=&quot;callout-header&quot; data-astro-cid-q2ml7llr&gt;&lt;span class=&quot;callout-icon&quot; aria-hidden=&quot;true&quot; data-astro-cid-q2ml7llr&gt;⚠️&lt;/span&gt;&lt;span class=&quot;callout-label&quot; data-astro-cid-q2ml7llr&gt;Read the draft before you trust it&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;callout-body&quot; data-astro-cid-q2ml7llr&gt;&lt;p&gt;Generated configuration is a typing shortcut, not an authority. It is produced by inspecting the object through the provider’s schema, so anything the schema models awkwardly comes out awkward. Curate it into something you’d be willing to write yourself, because in six months you will be reading it as though you did.&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;
&lt;h3 id=&quot;the-two-differences-you-cant-fix-by-editing&quot;&gt;The two differences you can’t fix by editing&lt;/h3&gt;
&lt;p&gt;After clearing the invalid attributes I still had a plan that wanted to change things — and neither difference was a real one.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Provider-injected defaults.&lt;/strong&gt; The provider adds its own operation timeouts to every resource. They aren’t settings on your container; they’re the provider’s local behaviour, and they’ll never match a configuration that doesn’t mention them.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Normalised values.&lt;/strong&gt; One container’s description differed from the live value by trailing whitespace the provider had trimmed. Editing the real container to add the whitespace back would be absurd.&lt;/p&gt;
&lt;p&gt;The right instrument for both is &lt;code&gt;ignore_changes&lt;/code&gt;, which tells the tool not to track specific attributes. The documentation notes that &lt;a href=&quot;https://opentofu.org/docs/language/resources/behavior/&quot;&gt;“only attributes defined by the resource type can be ignored”&lt;/a&gt;, so this is a scalpel, not a blanket.&lt;/p&gt;
&lt;figure class=&quot;terminal&quot; data-astro-cid-kenkipkp&gt;&lt;figcaption class=&quot;terminal-bar&quot; data-astro-cid-kenkipkp&gt;&lt;span class=&quot;dot red&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot yellow&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot green&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;terminal-title&quot; data-astro-cid-kenkipkp&gt;A curated resource — the lifecycle block does the reconciling&lt;/span&gt;&lt;button class=&quot;terminal-copy&quot; type=&quot;button&quot; aria-label=&quot;Copy commands&quot; data-astro-cid-kenkipkp&gt;&lt;svg width=&quot;13&quot; height=&quot;13&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;none&quot; stroke=&quot;currentColor&quot; stroke-width=&quot;2&quot; stroke-linecap=&quot;round&quot; stroke-linejoin=&quot;round&quot; aria-hidden=&quot;true&quot; data-astro-cid-kenkipkp&gt;&lt;rect x=&quot;9&quot; y=&quot;9&quot; width=&quot;13&quot; height=&quot;13&quot; rx=&quot;2&quot; data-astro-cid-kenkipkp&gt;&lt;/rect&gt;&lt;path d=&quot;M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1&quot; data-astro-cid-kenkipkp&gt;&lt;/path&gt;&lt;/svg&gt;&lt;span class=&quot;terminal-copy-label&quot; data-astro-cid-kenkipkp&gt;Copy&lt;/span&gt;&lt;/button&gt;&lt;/figcaption&gt;&lt;pre class=&quot;terminal-body&quot; data-astro-cid-kenkipkp&gt;&lt;code data-astro-cid-kenkipkp&gt;
resource &amp;quot;proxmox_virtual_environment_container&amp;quot; &amp;quot;wikijs&amp;quot; {
lifecycle {
  prevent_destroy = true
  # timeout_* are provider-local defaults injected at plan time;
  # ignoring them keeps the import pure. The description differs
  # only by whitespace the provider trims.
  ignore_changes = [
    timeout_clone, timeout_create, timeout_delete,
    timeout_start, timeout_update, description,
  ]
}

node_name    = &amp;quot;pvelab03&amp;quot;
started      = true
unprivileged = true

cpu {
  cores = 2
}

memory {
  dedicated = 4096
}

disk {
  datastore_id = &amp;quot;local-lvm&amp;quot;
  size         = 32
}

initialization {
  hostname = &amp;quot;wikijs&amp;quot;
  ip_config {
    ipv4 {
      address = &amp;quot;10.0.0.76/24&amp;quot;
      gateway = &amp;quot;10.0.0.1&amp;quot;
    }
  }
}

network_interface {
  bridge = &amp;quot;vmbr0&amp;quot;
  name   = &amp;quot;eth0&amp;quot;
}
}
&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;
&lt;p&gt;Note &lt;code&gt;prevent_destroy = true&lt;/code&gt; sitting alongside. Every imported production resource gets it — as &lt;a href=&quot;https://peira.dev/blog/proxmox-cluster-as-code-import-first/&quot;&gt;part one&lt;/a&gt; explains, it’s the second lock behind the read-only token, and it’s second for a specific reason.&lt;/p&gt;
&lt;div class=&quot;callout callout-danger&quot; data-astro-cid-q2ml7llr&gt;&lt;div class=&quot;callout-header&quot; data-astro-cid-q2ml7llr&gt;&lt;span class=&quot;callout-icon&quot; aria-hidden=&quot;true&quot; data-astro-cid-q2ml7llr&gt;🚨&lt;/span&gt;&lt;span class=&quot;callout-label&quot; data-astro-cid-q2ml7llr&gt;Ignore differences, don&amp;#39;t paper over them&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;callout-body&quot; data-astro-cid-q2ml7llr&gt;&lt;p&gt;&lt;code&gt;ignore_changes&lt;/code&gt; is the correct tool for a difference that isn’t real. It is the wrong tool for a difference that is. If the plan says a container has 8 GB and your code says 4 GB, one of them is wrong and you need to find out which — silencing it hides a genuine drift and turns your drift detector into decoration.&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;step-4-apply-and-watch-nothing-happen&quot;&gt;Step 4: apply, and watch nothing happen&lt;/h2&gt;
&lt;p&gt;Once the plan shows only imports and no modifications, applying it writes the containers into state. Nothing is sent to the cluster — which you can prove, because the read-only token could not have written anything even if the plan had asked it to.&lt;/p&gt;
&lt;p&gt;This is where the safety posture pays off concretely: the operation that feels riskiest is the one your credentials make impossible to get wrong.&lt;/p&gt;
&lt;figure class=&quot;terminal&quot; data-astro-cid-kenkipkp&gt;&lt;figcaption class=&quot;terminal-bar&quot; data-astro-cid-kenkipkp&gt;&lt;span class=&quot;dot red&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot yellow&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot green&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;terminal-title&quot; data-astro-cid-kenkipkp&gt;Apply the imports&lt;/span&gt;&lt;button class=&quot;terminal-copy&quot; type=&quot;button&quot; aria-label=&quot;Copy commands&quot; data-astro-cid-kenkipkp&gt;&lt;svg width=&quot;13&quot; height=&quot;13&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;none&quot; stroke=&quot;currentColor&quot; stroke-width=&quot;2&quot; stroke-linecap=&quot;round&quot; stroke-linejoin=&quot;round&quot; aria-hidden=&quot;true&quot; data-astro-cid-kenkipkp&gt;&lt;rect x=&quot;9&quot; y=&quot;9&quot; width=&quot;13&quot; height=&quot;13&quot; rx=&quot;2&quot; data-astro-cid-kenkipkp&gt;&lt;/rect&gt;&lt;path d=&quot;M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1&quot; data-astro-cid-kenkipkp&gt;&lt;/path&gt;&lt;/svg&gt;&lt;span class=&quot;terminal-copy-label&quot; data-astro-cid-kenkipkp&gt;Copy&lt;/span&gt;&lt;/button&gt;&lt;/figcaption&gt;&lt;pre class=&quot;terminal-body&quot; data-astro-cid-kenkipkp&gt;&lt;code data-astro-cid-kenkipkp&gt;
tofu apply
&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;
&lt;p&gt;Then the moment the whole exercise exists for:&lt;/p&gt;
&lt;figure class=&quot;terminal&quot; data-astro-cid-kenkipkp&gt;&lt;figcaption class=&quot;terminal-bar&quot; data-astro-cid-kenkipkp&gt;&lt;span class=&quot;dot red&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot yellow&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot green&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;terminal-title&quot; data-astro-cid-kenkipkp&gt;The deliverable&lt;/span&gt;&lt;button class=&quot;terminal-copy&quot; type=&quot;button&quot; aria-label=&quot;Copy commands&quot; data-astro-cid-kenkipkp&gt;&lt;svg width=&quot;13&quot; height=&quot;13&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;none&quot; stroke=&quot;currentColor&quot; stroke-width=&quot;2&quot; stroke-linecap=&quot;round&quot; stroke-linejoin=&quot;round&quot; aria-hidden=&quot;true&quot; data-astro-cid-kenkipkp&gt;&lt;rect x=&quot;9&quot; y=&quot;9&quot; width=&quot;13&quot; height=&quot;13&quot; rx=&quot;2&quot; data-astro-cid-kenkipkp&gt;&lt;/rect&gt;&lt;path d=&quot;M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1&quot; data-astro-cid-kenkipkp&gt;&lt;/path&gt;&lt;/svg&gt;&lt;span class=&quot;terminal-copy-label&quot; data-astro-cid-kenkipkp&gt;Copy&lt;/span&gt;&lt;/button&gt;&lt;/figcaption&gt;&lt;pre class=&quot;terminal-body&quot; data-astro-cid-kenkipkp&gt;&lt;code data-astro-cid-kenkipkp&gt;
$ tofu plan

No changes. Your infrastructure matches the configuration.
&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;turning-a-clean-plan-into-a-drift-detector&quot;&gt;Turning a clean plan into a drift detector&lt;/h2&gt;
&lt;p&gt;A clean plan you have to read yourself is a nice feeling. A clean plan a machine can check is a monitor.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;plan&lt;/code&gt; accepts a &lt;code&gt;-detailed-exitcode&lt;/code&gt; flag that, in the documentation’s terms, changes the exit codes to describe what the plan contains: &lt;strong&gt;0&lt;/strong&gt; for an empty diff, &lt;strong&gt;1&lt;/strong&gt; for an error, &lt;strong&gt;2&lt;/strong&gt; for a non-empty diff. That’s the whole mechanism — a scheduled run can now tell the difference between “nothing changed,” “something changed,” and “I couldn’t check.”&lt;/p&gt;
&lt;figure class=&quot;terminal&quot; data-astro-cid-kenkipkp&gt;&lt;figcaption class=&quot;terminal-bar&quot; data-astro-cid-kenkipkp&gt;&lt;span class=&quot;dot red&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot yellow&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot green&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;terminal-title&quot; data-astro-cid-kenkipkp&gt;Machine-checkable drift&lt;/span&gt;&lt;button class=&quot;terminal-copy&quot; type=&quot;button&quot; aria-label=&quot;Copy commands&quot; data-astro-cid-kenkipkp&gt;&lt;svg width=&quot;13&quot; height=&quot;13&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;none&quot; stroke=&quot;currentColor&quot; stroke-width=&quot;2&quot; stroke-linecap=&quot;round&quot; stroke-linejoin=&quot;round&quot; aria-hidden=&quot;true&quot; data-astro-cid-kenkipkp&gt;&lt;rect x=&quot;9&quot; y=&quot;9&quot; width=&quot;13&quot; height=&quot;13&quot; rx=&quot;2&quot; data-astro-cid-kenkipkp&gt;&lt;/rect&gt;&lt;path d=&quot;M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1&quot; data-astro-cid-kenkipkp&gt;&lt;/path&gt;&lt;/svg&gt;&lt;span class=&quot;terminal-copy-label&quot; data-astro-cid-kenkipkp&gt;Copy&lt;/span&gt;&lt;/button&gt;&lt;/figcaption&gt;&lt;pre class=&quot;terminal-body&quot; data-astro-cid-kenkipkp&gt;&lt;code data-astro-cid-kenkipkp&gt;
tofu plan -detailed-exitcode

# 0 = no changes   1 = error   2 = drift detected
&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;
&lt;div class=&quot;callout callout-info&quot; data-astro-cid-q2ml7llr&gt;&lt;div class=&quot;callout-header&quot; data-astro-cid-q2ml7llr&gt;&lt;span class=&quot;callout-icon&quot; aria-hidden=&quot;true&quot; data-astro-cid-q2ml7llr&gt;ℹ️&lt;/span&gt;&lt;span class=&quot;callout-label&quot; data-astro-cid-q2ml7llr&gt;Two flags worth knowing about the plan file&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;callout-body&quot; data-astro-cid-q2ml7llr&gt;&lt;p&gt;If you save a plan with &lt;code&gt;-out&lt;/code&gt;, the documentation warns it may contain sensitive data in cleartext unless plan encryption is enabled — treat the file as a secret and delete it when done. It also cautions against naming it with a &lt;code&gt;.tf&lt;/code&gt; or &lt;code&gt;.tofu&lt;/code&gt; suffix, because the tool would then try to parse your plan as configuration.&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;
&lt;p&gt;Run that on a schedule and you get an answer to a question that’s otherwise very hard to ask: &lt;em&gt;has anyone changed my cluster without writing it down?&lt;/em&gt; In a lab where you’re the only administrator, “anyone” usually means you, three months ago, at eleven at night. That’s exactly the change most worth catching, because it’s the one you’ll have completely forgotten by the time it matters — the same class of problem as &lt;a href=&quot;https://peira.dev/blog/docker-localhost-trap/&quot;&gt;a monitor that’s been quietly wrong for a day&lt;/a&gt;.&lt;/p&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;what-this-doesnt-give-you&quot;&gt;What this doesn’t give you&lt;/h2&gt;
&lt;p&gt;Worth restating, because a clean plan is seductive.&lt;/p&gt;
&lt;p&gt;The state file now describes nine containers precisely. It says nothing about what’s inside them — that remains entirely the business of &lt;a href=&quot;https://peira.dev/blog/proxmox-backup-server/&quot;&gt;your backups&lt;/a&gt;. And the import applies were state-only operations, so nothing here has been tested by an actual create. A description that has never been used to build anything is a hypothesis, and proving it is a separate exercise: creating a real container from this code, configuring it, and destroying it again. That’s &lt;a href=&quot;https://peira.dev/blog/opentofu-scratch-container-lifecycle/&quot;&gt;part four&lt;/a&gt; of the series.&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://peira.dev/blog/proxmox-ansible-capture-then-assert/&quot;&gt;Part three&lt;/a&gt; is the other half of the description — the hosts themselves, and the trick of writing configuration management that &lt;em&gt;asserts&lt;/em&gt; what your nodes already do rather than imposing something new on them.&lt;/p&gt;
&lt;hr/&gt;
&lt;p&gt;&lt;strong&gt;Related posts:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/proxmox-cluster-as-code-import-first/&quot;&gt;Turn a Hand-Built Proxmox Cluster Into Code Without Breaking It&lt;/a&gt; — part one: why import-first, and the read-only credential that makes it safe&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/proxmox-ansible-capture-then-assert/&quot;&gt;Ansible for a Cluster You Built by Hand: Capture, Then Assert&lt;/a&gt; — part three: the node half of the description, captured the same import-first way&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/opentofu-scratch-container-lifecycle/&quot;&gt;A Proxmox Container Born, Configured, and Destroyed From Code&lt;/a&gt; — part four: the create-path proof this post admits it still owes&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/proxmox-rebuild-from-zero/&quot;&gt;Rebuild Your Homelab From Zero: What Survives a Total Loss&lt;/a&gt; — part five: where the import blocks resurrect a dead state file&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/proxmox-backup-server/&quot;&gt;Proxmox Backup Server: Automated CT and VM Backups with Deduplication&lt;/a&gt; — the contents half of recovery, which this code deliberately does not cover&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/homelab-capacity-planning-node-failure/&quot;&gt;Homelab Capacity Planning: What If a Node Dies Tonight?&lt;/a&gt; — the other read-only tool that reasons about your cluster without touching it&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/proxmox-cluster-setup/&quot;&gt;Forming a Proxmox Cluster: Quorum, Corosync, and Joining Multiple Nodes&lt;/a&gt; — the multi-node setup being imported here&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/proxmox-create-lxc/&quot;&gt;Creating Your First Proxmox LXC Container: Step-by-Step&lt;/a&gt; — what these resource blocks are describing, done by hand&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/second-brain-secrets-discipline/&quot;&gt;Never Commit a Secret to Your Knowledge Base&lt;/a&gt; — where the API token secret lives instead of in your repo&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/docker-localhost-trap/&quot;&gt;The Docker localhost Trap: 24 Hours of Red, Zero Real Downtime&lt;/a&gt; — what happens when nothing tells you a check has been wrong for a day&lt;/li&gt;
&lt;/ul&gt;</content:encoded><category>proxmox</category><category>automation</category><category>infrastructure</category></item><item><title>Homelab Capacity Planning: What If a Node Dies Tonight?</title><link>https://peira.dev/blog/homelab-capacity-planning-node-failure/</link><guid isPermaLink="true">https://peira.dev/blog/homelab-capacity-planning-node-failure/</guid><description>I built a read-only simulator that answers whether a dead node&apos;s containers fit on the survivors — using the RAM they actually use, not the RAM you configured.</description><pubDate>Sat, 25 Jul 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Every dashboard in my rack could tell me what was happening right now, and not one of them could answer the question that actually keeps me up: if one of these four machines dies tonight, do the containers it was running fit on the three that are left — and how long until they’re back? I’d been answering that with a shrug and a vague sense that it was “probably fine.” So I spent a day building something that answers it properly: a read-only simulator that reads my real cluster, does the arithmetic, and tells me plainly when the answer is no.&lt;/p&gt;
&lt;p&gt;It told me no on day one. Here’s how it works, and what it found.&lt;/p&gt;
&lt;svg viewBox=&quot;0 0 720 400&quot; width=&quot;100%&quot; role=&quot;img&quot; aria-label=&quot;Diagram showing a failed node whose four containers are repacked onto three surviving nodes by size, largest first. Three containers find a home and the largest one is stranded because no survivor has enough free memory.&quot;&gt;&lt;rect width=&quot;720&quot; height=&quot;400&quot; rx=&quot;12&quot; fill=&quot;#1c1c32&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;360&quot; y=&quot;30&quot; text-anchor=&quot;middle&quot; fill=&quot;#f4f4f5&quot; font-size=&quot;15&quot; font-weight=&quot;bold&quot;&gt;One node dies. Where does its work go?&lt;/text&gt;&lt;rect x=&quot;24&quot; y=&quot;60&quot; width=&quot;180&quot; height=&quot;300&quot; rx=&quot;10&quot; fill=&quot;#2c2c4c&quot; stroke=&quot;#ef4444&quot; stroke-width=&quot;1.5&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;114&quot; y=&quot;86&quot; text-anchor=&quot;middle&quot; fill=&quot;#ef4444&quot; font-size=&quot;13&quot; font-weight=&quot;bold&quot;&gt;pvelab01 — down&lt;/text&gt;&lt;text x=&quot;114&quot; y=&quot;104&quot; text-anchor=&quot;middle&quot; fill=&quot;#6b7280&quot; font-size=&quot;10&quot;&gt;4 running guests&lt;/text&gt;&lt;rect x=&quot;44&quot; y=&quot;120&quot; width=&quot;140&quot; height=&quot;52&quot; rx=&quot;6&quot; fill=&quot;#30305a&quot; stroke=&quot;#4d4d82&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;114&quot; y=&quot;141&quot; text-anchor=&quot;middle&quot; fill=&quot;#f4f4f5&quot; font-size=&quot;11&quot;&gt;llm-node&lt;/text&gt;&lt;text x=&quot;114&quot; y=&quot;158&quot; text-anchor=&quot;middle&quot; fill=&quot;#a1a1aa&quot; font-size=&quot;10&quot;&gt;peak 12.9 GiB&lt;/text&gt;&lt;rect x=&quot;44&quot; y=&quot;182&quot; width=&quot;140&quot; height=&quot;52&quot; rx=&quot;6&quot; fill=&quot;#30305a&quot; stroke=&quot;#4d4d82&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;114&quot; y=&quot;203&quot; text-anchor=&quot;middle&quot; fill=&quot;#f4f4f5&quot; font-size=&quot;11&quot;&gt;agent&lt;/text&gt;&lt;text x=&quot;114&quot; y=&quot;220&quot; text-anchor=&quot;middle&quot; fill=&quot;#a1a1aa&quot; font-size=&quot;10&quot;&gt;peak 1.4 GiB&lt;/text&gt;&lt;rect x=&quot;44&quot; y=&quot;244&quot; width=&quot;140&quot; height=&quot;52&quot; rx=&quot;6&quot; fill=&quot;#30305a&quot; stroke=&quot;#4d4d82&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;114&quot; y=&quot;265&quot; text-anchor=&quot;middle&quot; fill=&quot;#f4f4f5&quot; font-size=&quot;11&quot;&gt;scratch&lt;/text&gt;&lt;text x=&quot;114&quot; y=&quot;282&quot; text-anchor=&quot;middle&quot; fill=&quot;#a1a1aa&quot; font-size=&quot;10&quot;&gt;peak 0.5 GiB&lt;/text&gt;&lt;rect x=&quot;44&quot; y=&quot;306&quot; width=&quot;140&quot; height=&quot;38&quot; rx=&quot;6&quot; fill=&quot;#30305a&quot; stroke=&quot;#4d4d82&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;114&quot; y=&quot;330&quot; text-anchor=&quot;middle&quot; fill=&quot;#f4f4f5&quot; font-size=&quot;11&quot;&gt;dns · peak 0.2 GiB&lt;/text&gt;&lt;text x=&quot;256&quot; y=&quot;200&quot; text-anchor=&quot;middle&quot; fill=&quot;#818cf8&quot; font-size=&quot;11&quot;&gt;sort by&lt;/text&gt;&lt;text x=&quot;256&quot; y=&quot;216&quot; text-anchor=&quot;middle&quot; fill=&quot;#818cf8&quot; font-size=&quot;11&quot;&gt;size, then&lt;/text&gt;&lt;text x=&quot;256&quot; y=&quot;232&quot; text-anchor=&quot;middle&quot; fill=&quot;#818cf8&quot; font-size=&quot;11&quot;&gt;first fit&lt;/text&gt;&lt;path d=&quot;M216 190 L296 190&quot; stroke=&quot;#818cf8&quot; stroke-width=&quot;1.5&quot; marker-end=&quot;url(#arrowIndigo)&quot;&gt;&lt;/path&gt;&lt;rect x=&quot;320&quot; y=&quot;60&quot; width=&quot;180&quot; height=&quot;88&quot; rx=&quot;8&quot; fill=&quot;#2c2c4c&quot; stroke=&quot;#3d3d5c&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;410&quot; y=&quot;84&quot; text-anchor=&quot;middle&quot; fill=&quot;#f4f4f5&quot; font-size=&quot;12&quot;&gt;pvelab02&lt;/text&gt;&lt;text x=&quot;410&quot; y=&quot;102&quot; text-anchor=&quot;middle&quot; fill=&quot;#a1a1aa&quot; font-size=&quot;10&quot;&gt;3.5 GiB free&lt;/text&gt;&lt;rect x=&quot;336&quot; y=&quot;112&quot; width=&quot;148&quot; height=&quot;26&quot; rx=&quot;5&quot; fill=&quot;#14b8a6&quot; fill-opacity=&quot;0.18&quot; stroke=&quot;#14b8a6&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;410&quot; y=&quot;129&quot; text-anchor=&quot;middle&quot; fill=&quot;#14b8a6&quot; font-size=&quot;10&quot;&gt;← agent (1.4 GiB)&lt;/text&gt;&lt;rect x=&quot;320&quot; y=&quot;164&quot; width=&quot;180&quot; height=&quot;88&quot; rx=&quot;8&quot; fill=&quot;#2c2c4c&quot; stroke=&quot;#3d3d5c&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;410&quot; y=&quot;188&quot; text-anchor=&quot;middle&quot; fill=&quot;#f4f4f5&quot; font-size=&quot;12&quot;&gt;pvelab03&lt;/text&gt;&lt;text x=&quot;410&quot; y=&quot;206&quot; text-anchor=&quot;middle&quot; fill=&quot;#a1a1aa&quot; font-size=&quot;10&quot;&gt;0.5 GiB free&lt;/text&gt;&lt;rect x=&quot;336&quot; y=&quot;216&quot; width=&quot;148&quot; height=&quot;26&quot; rx=&quot;5&quot; fill=&quot;#14b8a6&quot; fill-opacity=&quot;0.18&quot; stroke=&quot;#14b8a6&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;410&quot; y=&quot;233&quot; text-anchor=&quot;middle&quot; fill=&quot;#14b8a6&quot; font-size=&quot;10&quot;&gt;← dns (0.2 GiB)&lt;/text&gt;&lt;rect x=&quot;320&quot; y=&quot;268&quot; width=&quot;180&quot; height=&quot;88&quot; rx=&quot;8&quot; fill=&quot;#2c2c4c&quot; stroke=&quot;#3d3d5c&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;410&quot; y=&quot;292&quot; text-anchor=&quot;middle&quot; fill=&quot;#f4f4f5&quot; font-size=&quot;12&quot;&gt;pvelab04&lt;/text&gt;&lt;text x=&quot;410&quot; y=&quot;310&quot; text-anchor=&quot;middle&quot; fill=&quot;#a1a1aa&quot; font-size=&quot;10&quot;&gt;3.5 GiB free&lt;/text&gt;&lt;rect x=&quot;336&quot; y=&quot;320&quot; width=&quot;148&quot; height=&quot;26&quot; rx=&quot;5&quot; fill=&quot;#14b8a6&quot; fill-opacity=&quot;0.18&quot; stroke=&quot;#14b8a6&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;410&quot; y=&quot;337&quot; text-anchor=&quot;middle&quot; fill=&quot;#14b8a6&quot; font-size=&quot;10&quot;&gt;← scratch (0.5 GiB)&lt;/text&gt;&lt;rect x=&quot;536&quot; y=&quot;120&quot; width=&quot;160&quot; height=&quot;120&quot; rx=&quot;10&quot; fill=&quot;#2c2c4c&quot; stroke=&quot;#ef4444&quot; stroke-width=&quot;1.5&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;616&quot; y=&quot;150&quot; text-anchor=&quot;middle&quot; fill=&quot;#ef4444&quot; font-size=&quot;13&quot; font-weight=&quot;bold&quot;&gt;STRANDED&lt;/text&gt;&lt;text x=&quot;616&quot; y=&quot;175&quot; text-anchor=&quot;middle&quot; fill=&quot;#f4f4f5&quot; font-size=&quot;12&quot;&gt;llm-node&lt;/text&gt;&lt;text x=&quot;616&quot; y=&quot;196&quot; text-anchor=&quot;middle&quot; fill=&quot;#a1a1aa&quot; font-size=&quot;10&quot;&gt;needs 12.9 GiB&lt;/text&gt;&lt;text x=&quot;616&quot; y=&quot;214&quot; text-anchor=&quot;middle&quot; fill=&quot;#a1a1aa&quot; font-size=&quot;10&quot;&gt;largest survivor&lt;/text&gt;&lt;text x=&quot;616&quot; y=&quot;230&quot; text-anchor=&quot;middle&quot; fill=&quot;#a1a1aa&quot; font-size=&quot;10&quot;&gt;has 3.5 GiB&lt;/text&gt;&lt;path d=&quot;M204 146 L536 165&quot; stroke=&quot;#ef4444&quot; stroke-width=&quot;1.5&quot; stroke-dasharray=&quot;4 3&quot; marker-end=&quot;url(#arrowRed)&quot;&gt;&lt;/path&gt;&lt;text x=&quot;360&quot; y=&quot;384&quot; text-anchor=&quot;middle&quot; fill=&quot;#6b7280&quot; font-size=&quot;10&quot;&gt;Placements are restore targets, not live migrations — this cluster has no shared storage.&lt;/text&gt;&lt;defs&gt;&lt;marker id=&quot;arrowIndigo&quot; markerWidth=&quot;8&quot; markerHeight=&quot;8&quot; refX=&quot;7&quot; refY=&quot;4&quot; orient=&quot;auto&quot;&gt;&lt;path d=&quot;M0,0 L8,4 L0,8&quot; fill=&quot;none&quot; stroke=&quot;#818cf8&quot; stroke-width=&quot;1.2&quot;&gt;&lt;/path&gt;&lt;/marker&gt;&lt;marker id=&quot;arrowRed&quot; markerWidth=&quot;8&quot; markerHeight=&quot;8&quot; refX=&quot;7&quot; refY=&quot;4&quot; orient=&quot;auto&quot;&gt;&lt;path d=&quot;M0,0 L8,4 L0,8&quot; fill=&quot;none&quot; stroke=&quot;#ef4444&quot; stroke-width=&quot;1.2&quot;&gt;&lt;/path&gt;&lt;/marker&gt;&lt;/defs&gt;&lt;/svg&gt;
&lt;div class=&quot;callout callout-warning&quot; data-astro-cid-q2ml7llr&gt;&lt;div class=&quot;callout-header&quot; data-astro-cid-q2ml7llr&gt;&lt;span class=&quot;callout-icon&quot; aria-hidden=&quot;true&quot; data-astro-cid-q2ml7llr&gt;⚠️&lt;/span&gt;&lt;span class=&quot;callout-label&quot; data-astro-cid-q2ml7llr&gt;First: make these values your own&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;callout-body&quot; data-astro-cid-q2ml7llr&gt;&lt;p&gt;Every machine-specific value below is a placeholder. Swap in your own before pasting anything: the monitoring host &lt;code&gt;10.0.0.5&lt;/code&gt;, the simulator port &lt;code&gt;9112&lt;/code&gt;, the Prometheus address &lt;code&gt;10.0.0.5:9090&lt;/code&gt;, node names &lt;code&gt;pvelab01&lt;/code&gt;–&lt;code&gt;pvelab04&lt;/code&gt;, guest names like &lt;code&gt;llm-node&lt;/code&gt;, the datastore name &lt;code&gt;pbs-local&lt;/code&gt;, the internal domain &lt;code&gt;homelab.lan&lt;/code&gt;, the API user and token names &lt;code&gt;twin@pve!twin&lt;/code&gt; and &lt;code&gt;twin@pbs!twin&lt;/code&gt;, and any secret shown as &lt;code&gt;YOUR_TOKEN_SECRET&lt;/code&gt;. Secrets belong in your own secret store, never pasted into a note or a config you commit. If a value looks specific to one machine, it’s a placeholder to change — not a literal to copy.&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;why-just-migrate-it-isnt-the-answer&quot;&gt;Why “just migrate it” isn’t the answer&lt;/h2&gt;
&lt;p&gt;The tempting mental model is the one from the enterprise world: a node dies, the cluster notices, and your workloads reappear elsewhere a minute later. That’s real, and Proxmox does it — but it rests on a requirement most home clusters don’t meet. The &lt;a href=&quot;https://pve.proxmox.com/pve-docs/chapter-ha-manager.html&quot;&gt;Proxmox VE high availability documentation&lt;/a&gt; is blunt about the prerequisites: you need “at least three cluster nodes”, “shared storage for VMs and containers”, “hardware redundancy (everywhere)”, and reliable server components.&lt;/p&gt;
&lt;p&gt;Shared storage is the one that bites. It means the guest’s disk lives somewhere both machines can reach — a SAN, a Ceph pool, an NFS share — so a survivor can simply start a guest whose disk it already has. My four nodes each have their own local NVMe and nothing shared. When one dies, its disks die with it. There is no “start it elsewhere,” because elsewhere doesn’t have the data.&lt;/p&gt;
&lt;p&gt;So the honest recovery path on a cluster like mine is: restore the guest from a backup onto a surviving node. That reframes the whole planning question. I don’t need to know whether a guest &lt;em&gt;could migrate&lt;/em&gt;. I need to know two things:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Does it fit?&lt;/strong&gt; Does some survivor have enough free memory to run this guest once it’s back?&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;How long?&lt;/strong&gt; How many minutes of restore stand between “node died” and “service answering again”?&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Those are both arithmetic. Arithmetic is exactly the kind of thing a computer should be doing for me at 2am instead of me.&lt;/p&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;the-number-that-makes-the-model-honest&quot;&gt;The number that makes the model honest&lt;/h2&gt;
&lt;p&gt;My first instinct was to plan against configured memory — the &lt;code&gt;maxmem&lt;/code&gt; value each container is set to. It’s right there in the API, it’s one field, done.&lt;/p&gt;
&lt;p&gt;It’s also close to useless for this. I’d sized most of those containers by guessing generously and never revisiting, which is what everyone does. Planning against those numbers made my cluster look nearly full when it was mostly idle, and it would have told me a node loss was catastrophic when it wasn’t. The configured limit is a &lt;em&gt;safety ceiling&lt;/em&gt; — it stops a runaway process from eating the host. It is not a statement about what the workload needs.&lt;/p&gt;
&lt;p&gt;What the workload needs is what it actually used, over long enough to have caught its busy moments. I already had that: &lt;a href=&quot;https://peira.dev/blog/grafana-prometheus-proxmox&quot;&gt;Prometheus has been scraping my cluster&lt;/a&gt; for months, and the &lt;a href=&quot;https://github.com/prometheus-pve/prometheus-pve-exporter&quot;&gt;Proxmox VE exporter&lt;/a&gt; publishes per-guest memory as &lt;code&gt;pve_memory_usage_bytes&lt;/code&gt;. So demand became a seven-day peak, and node capacity became a seven-day typical.&lt;/p&gt;
&lt;svg viewBox=&quot;0 0 720 300&quot; width=&quot;100%&quot; role=&quot;img&quot; aria-label=&quot;Comparison of two planning models for the same four containers. Planning against configured maximum memory totals 30 gibibytes and reports the cluster as full, while planning against observed seven-day peak memory totals 15 gibibytes and reports room to spare.&quot;&gt;&lt;rect width=&quot;720&quot; height=&quot;300&quot; rx=&quot;12&quot; fill=&quot;#1c1c32&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;360&quot; y=&quot;30&quot; text-anchor=&quot;middle&quot; fill=&quot;#f4f4f5&quot; font-size=&quot;15&quot; font-weight=&quot;bold&quot;&gt;Same four containers, two different stories&lt;/text&gt;&lt;text x=&quot;180&quot; y=&quot;62&quot; text-anchor=&quot;middle&quot; fill=&quot;#f59e0b&quot; font-size=&quot;12&quot; font-weight=&quot;bold&quot;&gt;Configured maximum&lt;/text&gt;&lt;text x=&quot;180&quot; y=&quot;80&quot; text-anchor=&quot;middle&quot; fill=&quot;#6b7280&quot; font-size=&quot;10&quot;&gt;a number I guessed once&lt;/text&gt;&lt;rect x=&quot;70&quot; y=&quot;96&quot; width=&quot;220&quot; height=&quot;30&quot; rx=&quot;4&quot; fill=&quot;#f59e0b&quot; fill-opacity=&quot;0.25&quot; stroke=&quot;#f59e0b&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;180&quot; y=&quot;116&quot; text-anchor=&quot;middle&quot; fill=&quot;#f4f4f5&quot; font-size=&quot;11&quot;&gt;llm-node — 16 GiB&lt;/text&gt;&lt;rect x=&quot;70&quot; y=&quot;134&quot; width=&quot;110&quot; height=&quot;30&quot; rx=&quot;4&quot; fill=&quot;#f59e0b&quot; fill-opacity=&quot;0.25&quot; stroke=&quot;#f59e0b&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;125&quot; y=&quot;154&quot; text-anchor=&quot;middle&quot; fill=&quot;#f4f4f5&quot; font-size=&quot;11&quot;&gt;agent — 8 GiB&lt;/text&gt;&lt;rect x=&quot;70&quot; y=&quot;172&quot; width=&quot;55&quot; height=&quot;30&quot; rx=&quot;4&quot; fill=&quot;#f59e0b&quot; fill-opacity=&quot;0.25&quot; stroke=&quot;#f59e0b&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;97&quot; y=&quot;192&quot; text-anchor=&quot;middle&quot; fill=&quot;#f4f4f5&quot; font-size=&quot;10&quot;&gt;4 GiB&lt;/text&gt;&lt;rect x=&quot;70&quot; y=&quot;210&quot; width=&quot;28&quot; height=&quot;30&quot; rx=&quot;4&quot; fill=&quot;#f59e0b&quot; fill-opacity=&quot;0.25&quot; stroke=&quot;#f59e0b&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;84&quot; y=&quot;230&quot; text-anchor=&quot;middle&quot; fill=&quot;#f4f4f5&quot; font-size=&quot;10&quot;&gt;2&lt;/text&gt;&lt;text x=&quot;180&quot; y=&quot;266&quot; text-anchor=&quot;middle&quot; fill=&quot;#ef4444&quot; font-size=&quot;12&quot; font-weight=&quot;bold&quot;&gt;30 GiB — “cluster is full”&lt;/text&gt;&lt;line x1=&quot;360&quot; y1=&quot;55&quot; x2=&quot;360&quot; y2=&quot;275&quot; stroke=&quot;#3d3d5c&quot; stroke-width=&quot;1&quot;&gt;&lt;/line&gt;&lt;text x=&quot;540&quot; y=&quot;62&quot; text-anchor=&quot;middle&quot; fill=&quot;#14b8a6&quot; font-size=&quot;12&quot; font-weight=&quot;bold&quot;&gt;Observed 7-day peak&lt;/text&gt;&lt;text x=&quot;540&quot; y=&quot;80&quot; text-anchor=&quot;middle&quot; fill=&quot;#6b7280&quot; font-size=&quot;10&quot;&gt;a number the lab measured&lt;/text&gt;&lt;rect x=&quot;430&quot; y=&quot;96&quot; width=&quot;177&quot; height=&quot;30&quot; rx=&quot;4&quot; fill=&quot;#14b8a6&quot; fill-opacity=&quot;0.25&quot; stroke=&quot;#14b8a6&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;518&quot; y=&quot;116&quot; text-anchor=&quot;middle&quot; fill=&quot;#f4f4f5&quot; font-size=&quot;11&quot;&gt;llm-node — 12.9 GiB&lt;/text&gt;&lt;rect x=&quot;430&quot; y=&quot;134&quot; width=&quot;19&quot; height=&quot;30&quot; rx=&quot;4&quot; fill=&quot;#14b8a6&quot; fill-opacity=&quot;0.25&quot; stroke=&quot;#14b8a6&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;470&quot; y=&quot;154&quot; text-anchor=&quot;start&quot; fill=&quot;#a1a1aa&quot; font-size=&quot;10&quot;&gt;agent — 1.4 GiB&lt;/text&gt;&lt;rect x=&quot;430&quot; y=&quot;172&quot; width=&quot;7&quot; height=&quot;30&quot; rx=&quot;2&quot; fill=&quot;#14b8a6&quot; fill-opacity=&quot;0.25&quot; stroke=&quot;#14b8a6&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;452&quot; y=&quot;192&quot; text-anchor=&quot;start&quot; fill=&quot;#a1a1aa&quot; font-size=&quot;10&quot;&gt;0.5 GiB&lt;/text&gt;&lt;rect x=&quot;430&quot; y=&quot;210&quot; width=&quot;3&quot; height=&quot;30&quot; rx=&quot;1&quot; fill=&quot;#14b8a6&quot; fill-opacity=&quot;0.25&quot; stroke=&quot;#14b8a6&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;448&quot; y=&quot;230&quot; text-anchor=&quot;start&quot; fill=&quot;#a1a1aa&quot; font-size=&quot;10&quot;&gt;0.2 GiB&lt;/text&gt;&lt;text x=&quot;540&quot; y=&quot;266&quot; text-anchor=&quot;middle&quot; fill=&quot;#22c55e&quot; font-size=&quot;12&quot; font-weight=&quot;bold&quot;&gt;15 GiB — room to spare&lt;/text&gt;&lt;/svg&gt;
&lt;p&gt;Two &lt;a href=&quot;https://prometheus.io/docs/prometheus/latest/querying/functions/&quot;&gt;Prometheus range queries&lt;/a&gt; do the whole job. &lt;code&gt;max_over_time&lt;/code&gt; returns “the maximum value of all float samples in the specified interval” — the busiest that guest got all week. &lt;code&gt;quantile_over_time&lt;/code&gt; returns the φ-quantile over the interval, and at φ = 0.95 that’s a node’s typical-but-busy baseline with brief spikes ignored:&lt;/p&gt;
&lt;figure class=&quot;terminal&quot; data-astro-cid-kenkipkp&gt;&lt;figcaption class=&quot;terminal-bar&quot; data-astro-cid-kenkipkp&gt;&lt;span class=&quot;dot red&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot yellow&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot green&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;terminal-title&quot; data-astro-cid-kenkipkp&gt;The two queries the whole model rests on&lt;/span&gt;&lt;button class=&quot;terminal-copy&quot; type=&quot;button&quot; aria-label=&quot;Copy commands&quot; data-astro-cid-kenkipkp&gt;&lt;svg width=&quot;13&quot; height=&quot;13&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;none&quot; stroke=&quot;currentColor&quot; stroke-width=&quot;2&quot; stroke-linecap=&quot;round&quot; stroke-linejoin=&quot;round&quot; aria-hidden=&quot;true&quot; data-astro-cid-kenkipkp&gt;&lt;rect x=&quot;9&quot; y=&quot;9&quot; width=&quot;13&quot; height=&quot;13&quot; rx=&quot;2&quot; data-astro-cid-kenkipkp&gt;&lt;/rect&gt;&lt;path d=&quot;M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1&quot; data-astro-cid-kenkipkp&gt;&lt;/path&gt;&lt;/svg&gt;&lt;span class=&quot;terminal-copy-label&quot; data-astro-cid-kenkipkp&gt;Copy&lt;/span&gt;&lt;/button&gt;&lt;/figcaption&gt;&lt;pre class=&quot;terminal-body&quot; data-astro-cid-kenkipkp&gt;&lt;code data-astro-cid-kenkipkp&gt;
# Per-guest demand: the busiest each guest got in seven days
max_over_time(pve_memory_usage_bytes{id=~&amp;quot;(lxc|qemu)/.+&amp;quot;}[7d])

# Per-node baseline: what the node itself is typically carrying
quantile_over_time(0.95, pve_memory_usage_bytes{id=~&amp;quot;node/.+&amp;quot;}[7d])
&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;
&lt;p&gt;Why the asymmetry — peak for guests, 95th percentile for nodes? Because they’re answering different questions. A guest’s demand has to cover its worst moment, or the restore fills memory and something gets killed. A node’s &lt;em&gt;existing load&lt;/em&gt; is a baseline I’m subtracting from its total, and using that node’s worst moment there would double-count the same spikes and make every node look fuller than it is. Peak what you’re placing; percentile what you’re placing it onto.&lt;/p&gt;
&lt;p&gt;Free capacity then comes out as one line, with a gigabyte held back so I’m never planning to fill a hypervisor to the last byte:&lt;/p&gt;
&lt;pre class=&quot;astro-code gruvbox-dark-hard&quot; style=&quot;background-color:#1d2021;color:#ebdbb2;overflow-x:auto;white-space:pre-wrap;word-wrap:break-word&quot; tabindex=&quot;0&quot; data-language=&quot;python&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#FB4934&quot;&gt;def&lt;/span&gt;&lt;span style=&quot;color:#8EC07C&quot;&gt; node_free&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#83A598&quot;&gt;node&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt;,&lt;/span&gt;&lt;span style=&quot;color:#83A598&quot;&gt; p95_used&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt;,&lt;/span&gt;&lt;span style=&quot;color:#83A598&quot;&gt; reserve&lt;/span&gt;&lt;span style=&quot;color:#8EC07C&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#D3869B&quot;&gt;1&lt;/span&gt;&lt;span style=&quot;color:#8EC07C&quot;&gt; * &lt;/span&gt;&lt;span style=&quot;color:#D3869B&quot;&gt;1024&lt;/span&gt;&lt;span style=&quot;color:#8EC07C&quot;&gt;**&lt;/span&gt;&lt;span style=&quot;color:#D3869B&quot;&gt;3&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt;):&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#A89984&quot;&gt;    &amp;quot;&amp;quot;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#B8BB26&quot;&gt;Placement capacity: total - observed p95 (fallback: current) - reserve.&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt;&amp;quot;&amp;quot;&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#EBDBB2&quot;&gt;    used &lt;/span&gt;&lt;span style=&quot;color:#8EC07C&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#EBDBB2&quot;&gt; p95_used &lt;/span&gt;&lt;span style=&quot;color:#FB4934&quot;&gt;if&lt;/span&gt;&lt;span style=&quot;color:#EBDBB2&quot;&gt; p95_used &lt;/span&gt;&lt;span style=&quot;color:#FB4934&quot;&gt;is&lt;/span&gt;&lt;span style=&quot;color:#FB4934&quot;&gt; not&lt;/span&gt;&lt;span style=&quot;color:#D3869B&quot;&gt; None&lt;/span&gt;&lt;span style=&quot;color:#FB4934&quot;&gt; else&lt;/span&gt;&lt;span style=&quot;color:#EBDBB2&quot;&gt; node&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt;[&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#B8BB26&quot;&gt;used_mem_now&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt;]&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#FB4934&quot;&gt;    return&lt;/span&gt;&lt;span style=&quot;color:#FE8019&quot;&gt; max&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#D3869B&quot;&gt;0&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt;,&lt;/span&gt;&lt;span style=&quot;color:#D5C4A1&quot;&gt; node&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt;[&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#B8BB26&quot;&gt;total_mem&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt;]&lt;/span&gt;&lt;span style=&quot;color:#8EC07C&quot;&gt; -&lt;/span&gt;&lt;span style=&quot;color:#D5C4A1&quot;&gt; used &lt;/span&gt;&lt;span style=&quot;color:#8EC07C&quot;&gt;-&lt;/span&gt;&lt;span style=&quot;color:#D5C4A1&quot;&gt; reserve&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt;)&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The fallback in that line matters more than it looks. If Prometheus is unreachable, the model doesn’t crash and it doesn’t lie — it drops to the node’s current memory reading and carries on with a worse number. Same for guests: no seven-day history means fall back to configured &lt;code&gt;maxmem&lt;/code&gt;, and every result labels which source it used, so I can see at a glance whether I’m reading measurement or guesswork.&lt;/p&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;packing-the-survivors-in-fifteen-lines&quot;&gt;Packing the survivors in fifteen lines&lt;/h2&gt;
&lt;p&gt;Once every guest has a demand number and every survivor has a free number, “does it fit” is a bin-packing problem: fit a set of items into as few containers as possible. It’s a genuinely hard problem in the general case, but the good approximations are almost insultingly simple.&lt;/p&gt;
&lt;p&gt;I used &lt;strong&gt;first-fit decreasing&lt;/strong&gt;: sort the guests largest-first, then walk the list and drop each one into the first node it fits in. The sorting is the whole trick — place the awkward large guests while there’s still somewhere to put them, instead of filling every node with small fry and discovering the 13 GiB container has nowhere to go.&lt;/p&gt;
&lt;p&gt;It’s also a well-studied choice rather than a hunch. The asymptotic quality of first-fit decreasing was established by Johnson in 1973, and the exact tight bound — that it never needs more than 11/9 of the optimal number of bins plus 6/9 — was &lt;a href=&quot;https://link.springer.com/chapter/10.1007/978-3-540-74450-4_1&quot;&gt;proved by Dósa in 2007&lt;/a&gt; and given a complete proof in &lt;a href=&quot;https://doi.org/10.1016/j.tcs.2013.09.007&quot;&gt;Theoretical Computer Science in 2013&lt;/a&gt;. For deciding whether three home servers can absorb a fourth, being provably within about 22% of a perfect packing is far more rigour than the question needs.&lt;/p&gt;
&lt;pre class=&quot;astro-code gruvbox-dark-hard&quot; style=&quot;background-color:#1d2021;color:#ebdbb2;overflow-x:auto;white-space:pre-wrap;word-wrap:break-word&quot; tabindex=&quot;0&quot; data-language=&quot;python&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#FB4934&quot;&gt;def&lt;/span&gt;&lt;span style=&quot;color:#8EC07C&quot;&gt; pack&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#83A598&quot;&gt;items&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt;,&lt;/span&gt;&lt;span style=&quot;color:#83A598&quot;&gt; bins&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt;):&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#A89984&quot;&gt;    &amp;quot;&amp;quot;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#B8BB26&quot;&gt;First-fit-decreasing bin packing. Inputs are not mutated.&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt;&amp;quot;&amp;quot;&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#EBDBB2&quot;&gt;    order &lt;/span&gt;&lt;span style=&quot;color:#8EC07C&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#FE8019&quot;&gt; sorted&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#D5C4A1&quot;&gt;items&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt;,&lt;/span&gt;&lt;span style=&quot;color:#83A598&quot;&gt; key&lt;/span&gt;&lt;span style=&quot;color:#8EC07C&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#FE8019&quot;&gt;lambda&lt;/span&gt;&lt;span style=&quot;color:#83A598&quot;&gt; g&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt;:&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt; (&lt;/span&gt;&lt;span style=&quot;color:#8EC07C&quot;&gt;-&lt;/span&gt;&lt;span style=&quot;color:#D5C4A1&quot;&gt;g&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt;[&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#B8BB26&quot;&gt;demand&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt;],&lt;/span&gt;&lt;span style=&quot;color:#D5C4A1&quot;&gt; g&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#83A598&quot;&gt;get&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#B8BB26&quot;&gt;vmid&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt;,&lt;/span&gt;&lt;span style=&quot;color:#D3869B&quot;&gt; 0&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt;)))&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#EBDBB2&quot;&gt;    state &lt;/span&gt;&lt;span style=&quot;color:#8EC07C&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt; [&lt;/span&gt;&lt;span style=&quot;color:#FABD2F&quot;&gt;dict&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#D5C4A1&quot;&gt;b&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt;)&lt;/span&gt;&lt;span style=&quot;color:#FB4934&quot;&gt; for&lt;/span&gt;&lt;span style=&quot;color:#EBDBB2&quot;&gt; b &lt;/span&gt;&lt;span style=&quot;color:#FB4934&quot;&gt;in&lt;/span&gt;&lt;span style=&quot;color:#FE8019&quot;&gt; sorted&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#D5C4A1&quot;&gt;bins&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt;,&lt;/span&gt;&lt;span style=&quot;color:#83A598&quot;&gt; key&lt;/span&gt;&lt;span style=&quot;color:#8EC07C&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#FE8019&quot;&gt;lambda&lt;/span&gt;&lt;span style=&quot;color:#83A598&quot;&gt; b&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt;:&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt; (&lt;/span&gt;&lt;span style=&quot;color:#8EC07C&quot;&gt;-&lt;/span&gt;&lt;span style=&quot;color:#D5C4A1&quot;&gt;b&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt;[&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#B8BB26&quot;&gt;free&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt;],&lt;/span&gt;&lt;span style=&quot;color:#D5C4A1&quot;&gt; b&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt;[&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#B8BB26&quot;&gt;name&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt;]))]&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#EBDBB2&quot;&gt;    placements&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt;,&lt;/span&gt;&lt;span style=&quot;color:#EBDBB2&quot;&gt; stranded &lt;/span&gt;&lt;span style=&quot;color:#8EC07C&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt; [],&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt; []&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#FB4934&quot;&gt;    for&lt;/span&gt;&lt;span style=&quot;color:#EBDBB2&quot;&gt; g &lt;/span&gt;&lt;span style=&quot;color:#FB4934&quot;&gt;in&lt;/span&gt;&lt;span style=&quot;color:#EBDBB2&quot;&gt; order&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt;:&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#FB4934&quot;&gt;        for&lt;/span&gt;&lt;span style=&quot;color:#EBDBB2&quot;&gt; b &lt;/span&gt;&lt;span style=&quot;color:#FB4934&quot;&gt;in&lt;/span&gt;&lt;span style=&quot;color:#EBDBB2&quot;&gt; state&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt;:&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#FB4934&quot;&gt;            if&lt;/span&gt;&lt;span style=&quot;color:#EBDBB2&quot;&gt; b&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt;[&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#B8BB26&quot;&gt;free&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt;]&lt;/span&gt;&lt;span style=&quot;color:#8EC07C&quot;&gt; &amp;gt;=&lt;/span&gt;&lt;span style=&quot;color:#EBDBB2&quot;&gt; g&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt;[&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#B8BB26&quot;&gt;demand&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt;]:&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#EBDBB2&quot;&gt;                b&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt;[&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#B8BB26&quot;&gt;free&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt;]&lt;/span&gt;&lt;span style=&quot;color:#8EC07C&quot;&gt; -=&lt;/span&gt;&lt;span style=&quot;color:#EBDBB2&quot;&gt; g&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt;[&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#B8BB26&quot;&gt;demand&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt;]&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#EBDBB2&quot;&gt;                p &lt;/span&gt;&lt;span style=&quot;color:#8EC07C&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#FABD2F&quot;&gt; dict&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#D5C4A1&quot;&gt;g&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#EBDBB2&quot;&gt;                p&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt;[&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#B8BB26&quot;&gt;target&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt;]&lt;/span&gt;&lt;span style=&quot;color:#8EC07C&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:#EBDBB2&quot;&gt; b&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt;[&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#B8BB26&quot;&gt;name&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt;]&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#EBDBB2&quot;&gt;                placements&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#83A598&quot;&gt;append&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#D5C4A1&quot;&gt;p&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#FB4934&quot;&gt;                break&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#FB4934&quot;&gt;        else&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt;:&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#EBDBB2&quot;&gt;            stranded&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#83A598&quot;&gt;append&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#FABD2F&quot;&gt;dict&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#D5C4A1&quot;&gt;g&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt;))&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#FB4934&quot;&gt;    return&lt;/span&gt;&lt;span style=&quot;color:#EBDBB2&quot;&gt; placements&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt;,&lt;/span&gt;&lt;span style=&quot;color:#EBDBB2&quot;&gt; stranded&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt;,&lt;/span&gt;&lt;span style=&quot;color:#EBDBB2&quot;&gt; state&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That &lt;code&gt;for...else&lt;/code&gt; is one of Python’s odder pieces of syntax and it’s perfect here: the &lt;code&gt;else&lt;/code&gt; branch runs only if the inner loop finished &lt;em&gt;without&lt;/em&gt; hitting &lt;code&gt;break&lt;/code&gt;, which is precisely the case where no node had room. Anything landing in &lt;code&gt;stranded&lt;/code&gt; is a guest that has nowhere to go — the answer I actually built this to find.&lt;/p&gt;
&lt;p&gt;The tie-breakers exist for a reason too. Sorting by demand &lt;em&gt;and then&lt;/em&gt; by guest ID means the same cluster state always produces the same plan; without it, two guests of equal size could swap targets between runs and I’d never be sure whether the model or the lab had changed.&lt;/p&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;how-long-until-its-back&quot;&gt;How long until it’s back?&lt;/h2&gt;
&lt;p&gt;A plan that says “it fits” but not “in twenty minutes” is only half an answer. Restore time is the other half, and it’s the part where it’s easiest to quietly invent a number.&lt;/p&gt;
&lt;p&gt;The arithmetic is trivial — backup size divided by restore throughput. Getting an honest throughput figure is not, because it depends on your disks, your network, and how compressible your data is. So I measured mine: during a disaster-recovery drill I restored real guests from &lt;a href=&quot;https://peira.dev/blog/proxmox-backup-server&quot;&gt;Proxmox Backup Server&lt;/a&gt; and timed them, which gave a range of 110–119 MiB/s. The model uses 110 — the pessimistic end, on the principle that a recovery estimate should disappoint you in the right direction.&lt;/p&gt;
&lt;div class=&quot;callout callout-warning&quot; data-astro-cid-q2ml7llr&gt;&lt;div class=&quot;callout-header&quot; data-astro-cid-q2ml7llr&gt;&lt;span class=&quot;callout-icon&quot; aria-hidden=&quot;true&quot; data-astro-cid-q2ml7llr&gt;⚠️&lt;/span&gt;&lt;span class=&quot;callout-label&quot; data-astro-cid-q2ml7llr&gt;Don&amp;#39;t borrow my number&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;callout-body&quot; data-astro-cid-q2ml7llr&gt;&lt;p&gt;110 MiB/s is a measurement of &lt;em&gt;my&lt;/em&gt; hardware — local NVMe, my own network, and my particular mix of containers. It is not a property of Proxmox Backup Server and it will not be your figure. Restore one real guest, time it, divide the backup size by the seconds it took, and use that. A borrowed constant gives you an estimate that is confidently wrong, which is worse than no estimate at all.&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;
&lt;p&gt;The simulator asks the backup server for the latest snapshot of each affected guest, sums the sizes, divides, and reports both the total and a per-guest breakdown — because knowing that the whole node takes 40 minutes is less useful than knowing DNS is back in 30 seconds and the big one is the long pole. It also reports each backup’s &lt;em&gt;age&lt;/em&gt;, which turned out to be an accidental audit: if a guest’s most recent backup is 200 hours old, the restore estimate is the least of my problems.&lt;/p&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;day-one-it-told-me-something-i-didnt-want-to-hear&quot;&gt;Day one, it told me something I didn’t want to hear&lt;/h2&gt;
&lt;p&gt;I built this expecting reassurance. The first full run gave me a red result instead: &lt;strong&gt;losing my first node strands the large language model container.&lt;/strong&gt; Its observed seven-day peak is 12.85 GiB, and no survivor has anywhere near that much free memory. The other guests on that node place fine. That one has nowhere to go.&lt;/p&gt;
&lt;p&gt;This was the moment the tool paid for itself. I’d been vaguely intending to add memory to the cluster “at some point,” in the way you vaguely intend to do most homelab things. What I had instead, in one query, was a specific sentence: &lt;em&gt;this specific container cannot come back after this specific failure, and it needs 12.85 GiB of headroom somewhere for that to change.&lt;/em&gt; A vague intention became a sized purchase with a reason attached.&lt;/p&gt;
&lt;p&gt;That’s the real argument for building something like this. Not the dashboard — the forced honesty. A model that only ever agrees with you isn’t telling you anything.&lt;/p&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;the-scenario-with-no-eta&quot;&gt;The scenario with no ETA&lt;/h2&gt;
&lt;p&gt;There’s one node in my cluster whose loss doesn’t get a number, and refusing to give it one was a deliberate design decision.&lt;/p&gt;
&lt;p&gt;That node runs the backup server itself. If it dies, I don’t just lose its guests — I lose the backups I’d restore them &lt;em&gt;from&lt;/em&gt;. The fast recovery path and the thing that failed are the same machine. Two other things live there too: the &lt;a href=&quot;https://peira.dev/blog/tailscale-subnet-router&quot;&gt;Tailscale subnet router&lt;/a&gt; that gets me into the lab remotely, and a mirror of my documentation vault. Losing that node means losing guests, backups, and my remote way in, all at once.&lt;/p&gt;
&lt;p&gt;The simulator flags this case explicitly rather than smoothing it over. It sets a &lt;code&gt;pbs_lost&lt;/code&gt; marker, sets the recovery estimate to &lt;code&gt;null&lt;/code&gt; instead of a number, and returns notes in plain language: recovery falls back to the weekly archive that lives on separate hardware at a different location, which is hours rather than minutes; remote access is down until the router is replaced.&lt;/p&gt;
&lt;div class=&quot;callout callout-tip&quot; data-astro-cid-q2ml7llr&gt;&lt;div class=&quot;callout-header&quot; data-astro-cid-q2ml7llr&gt;&lt;span class=&quot;callout-icon&quot; aria-hidden=&quot;true&quot; data-astro-cid-q2ml7llr&gt;💡&lt;/span&gt;&lt;span class=&quot;callout-label&quot; data-astro-cid-q2ml7llr&gt;Null is a valid answer&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;callout-body&quot; data-astro-cid-q2ml7llr&gt;&lt;p&gt;It would have been easy — and much more satisfying — to compute &lt;em&gt;some&lt;/em&gt; number for this scenario. It would also have been a lie, and a lie I’d have believed at 2am when I needed it least. If your model can’t honestly estimate a case, the correct output is a clearly-marked “no estimate” plus a description of what recovery actually looks like. A confident wrong number is worse than an honest blank.&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;the-bug-that-taught-me-the-most&quot;&gt;The bug that taught me the most&lt;/h2&gt;
&lt;p&gt;Alongside the failure scenarios I added a “can I fit a new container?” form: type in cores, memory, and disk, get back which node it should go on. I wired it up, tested it, shipped it.&lt;/p&gt;
&lt;p&gt;Then I watched the access log while someone else used it. Every request was arriving fine. Every request was also coming back &lt;strong&gt;fits&lt;/strong&gt; — 10 cores, fine. 15 cores, fine. 24 cores on an 8-core machine, fine. A 3.2 TB disk, fine. The verdict never changed, so the form looked like it wasn’t running at all.&lt;/p&gt;
&lt;p&gt;It was running perfectly. That was the bug. I’d implemented every constraint except memory as a &lt;em&gt;soft&lt;/em&gt; one — the response carried polite advisory notes about oversubscription that nobody reads, while the headline verdict stayed green. I’d optimised for not being annoying and landed on not being useful.&lt;/p&gt;
&lt;svg viewBox=&quot;0 0 720 320&quot; width=&quot;100%&quot; role=&quot;img&quot; aria-label=&quot;Before and after comparison of the placement calculator. Before, requests for 24 cores and a 3.2 terabyte disk both return a green fits verdict with only advisory notes. After, the same requests return a red does not fit verdict with a specific per-node reason for each refusal.&quot;&gt;&lt;rect width=&quot;720&quot; height=&quot;320&quot; rx=&quot;12&quot; fill=&quot;#1c1c32&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;360&quot; y=&quot;30&quot; text-anchor=&quot;middle&quot; fill=&quot;#f4f4f5&quot; font-size=&quot;15&quot; font-weight=&quot;bold&quot;&gt;A calculator that always says yes looks broken&lt;/text&gt;&lt;rect x=&quot;24&quot; y=&quot;52&quot; width=&quot;320&quot; height=&quot;240&quot; rx=&quot;10&quot; fill=&quot;#262640&quot; stroke=&quot;#3d3d5c&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;184&quot; y=&quot;76&quot; text-anchor=&quot;middle&quot; fill=&quot;#f59e0b&quot; font-size=&quot;12&quot; font-weight=&quot;bold&quot;&gt;Before — soft constraints&lt;/text&gt;&lt;rect x=&quot;44&quot; y=&quot;92&quot; width=&quot;280&quot; height=&quot;30&quot; rx=&quot;5&quot; fill=&quot;#1c1c32&quot; stroke=&quot;#3d3d5c&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;58&quot; y=&quot;112&quot; fill=&quot;#a1a1aa&quot; font-size=&quot;11&quot;&gt;request: 24 cores, 8 GiB, 3200 GiB&lt;/text&gt;&lt;rect x=&quot;44&quot; y=&quot;132&quot; width=&quot;280&quot; height=&quot;34&quot; rx=&quot;5&quot; fill=&quot;#22c55e&quot; fill-opacity=&quot;0.15&quot; stroke=&quot;#22c55e&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;184&quot; y=&quot;154&quot; text-anchor=&quot;middle&quot; fill=&quot;#22c55e&quot; font-size=&quot;13&quot; font-weight=&quot;bold&quot;&gt;✓ fits — best: pvelab02&lt;/text&gt;&lt;rect x=&quot;44&quot; y=&quot;176&quot; width=&quot;280&quot; height=&quot;30&quot; rx=&quot;5&quot; fill=&quot;#1c1c32&quot; stroke=&quot;#3d3d5c&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;58&quot; y=&quot;196&quot; fill=&quot;#6b7280&quot; font-size=&quot;10&quot;&gt;note: soft: vCPU:core ratio would reach 4.1&lt;/text&gt;&lt;text x=&quot;184&quot; y=&quot;236&quot; text-anchor=&quot;middle&quot; fill=&quot;#a1a1aa&quot; font-size=&quot;11&quot;&gt;Every input returns green.&lt;/text&gt;&lt;text x=&quot;184&quot; y=&quot;256&quot; text-anchor=&quot;middle&quot; fill=&quot;#a1a1aa&quot; font-size=&quot;11&quot;&gt;The user concludes it never ran.&lt;/text&gt;&lt;text x=&quot;184&quot; y=&quot;276&quot; text-anchor=&quot;middle&quot; fill=&quot;#ef4444&quot; font-size=&quot;11&quot; font-weight=&quot;bold&quot;&gt;Correct code, useless answer.&lt;/text&gt;&lt;rect x=&quot;376&quot; y=&quot;52&quot; width=&quot;320&quot; height=&quot;240&quot; rx=&quot;10&quot; fill=&quot;#262640&quot; stroke=&quot;#3d3d5c&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;536&quot; y=&quot;76&quot; text-anchor=&quot;middle&quot; fill=&quot;#14b8a6&quot; font-size=&quot;12&quot; font-weight=&quot;bold&quot;&gt;After — hard constraints&lt;/text&gt;&lt;rect x=&quot;396&quot; y=&quot;92&quot; width=&quot;280&quot; height=&quot;30&quot; rx=&quot;5&quot; fill=&quot;#1c1c32&quot; stroke=&quot;#3d3d5c&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;410&quot; y=&quot;112&quot; fill=&quot;#a1a1aa&quot; font-size=&quot;11&quot;&gt;request: 24 cores, 8 GiB, 3200 GiB&lt;/text&gt;&lt;rect x=&quot;396&quot; y=&quot;132&quot; width=&quot;280&quot; height=&quot;34&quot; rx=&quot;5&quot; fill=&quot;#ef4444&quot; fill-opacity=&quot;0.15&quot; stroke=&quot;#ef4444&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;536&quot; y=&quot;154&quot; text-anchor=&quot;middle&quot; fill=&quot;#ef4444&quot; font-size=&quot;13&quot; font-weight=&quot;bold&quot;&gt;✗ does not fit&lt;/text&gt;&lt;rect x=&quot;396&quot; y=&quot;176&quot; width=&quot;280&quot; height=&quot;52&quot; rx=&quot;5&quot; fill=&quot;#1c1c32&quot; stroke=&quot;#3d3d5c&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;410&quot; y=&quot;195&quot; fill=&quot;#f4f4f5&quot; font-size=&quot;10&quot;&gt;pvelab02: only 8 cores (need 24)&lt;/text&gt;&lt;text x=&quot;410&quot; y=&quot;213&quot; fill=&quot;#f4f4f5&quot; font-size=&quot;10&quot;&gt;pvelab03: local-lvm 412 GiB &amp;lt; 3200 GiB disk&lt;/text&gt;&lt;text x=&quot;536&quot; y=&quot;256&quot; text-anchor=&quot;middle&quot; fill=&quot;#a1a1aa&quot; font-size=&quot;11&quot;&gt;Refusals name the node and the reason.&lt;/text&gt;&lt;text x=&quot;536&quot; y=&quot;276&quot; text-anchor=&quot;middle&quot; fill=&quot;#22c55e&quot; font-size=&quot;11&quot; font-weight=&quot;bold&quot;&gt;Now it’s a tool.&lt;/text&gt;&lt;/svg&gt;
&lt;p&gt;The fix was to decide which constraints are real. Cores and disk space are physical: a node with 8 cores cannot host a guest configured for 24, and a node with 412 GiB free cannot store a 3.2 TB root disk. Those became hard refusals. Only the oversubscription ratio — how many virtual CPUs are promised across all guests versus how many the node has — stayed advisory, because oversubscription is genuinely normal and usually fine.&lt;/p&gt;
&lt;p&gt;Then each refusal got a sentence naming the node and the reason, so the answer is “pvelab02: only 8 cores (need 24)” rather than a bare no. And the form clears its verdict the moment you change any input, so a stale green result can never sit under a question you’ve since edited.&lt;/p&gt;
&lt;div class=&quot;callout callout-tip&quot; data-astro-cid-q2ml7llr&gt;&lt;div class=&quot;callout-header&quot; data-astro-cid-q2ml7llr&gt;&lt;span class=&quot;callout-icon&quot; aria-hidden=&quot;true&quot; data-astro-cid-q2ml7llr&gt;💡&lt;/span&gt;&lt;span class=&quot;callout-label&quot; data-astro-cid-q2ml7llr&gt;The lesson generalises&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;callout-body&quot; data-astro-cid-q2ml7llr&gt;&lt;p&gt;If a tool’s output looks identical no matter what you feed it, users will assume it’s broken — and functionally they’re right. Any check worth running is worth letting change the headline verdict, and any refusal is worth explaining. “No” is a useful answer. “No, because pvelab03 has 412 GiB free and you asked for 3200” is a useful answer that also tells you what to do next.&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;four-gotchas-that-cost-me-an-evening&quot;&gt;Four gotchas that cost me an evening&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;API tokens can’t outrank their user.&lt;/strong&gt; I gave the simulator a read-only &lt;a href=&quot;https://pve.proxmox.com/pve-docs/chapter-pveum.html&quot;&gt;Proxmox API token&lt;/a&gt; with privilege separation, which is the right call — but it means the token’s “effective permissions are calculated by intersecting user and token permissions.” A permission the user doesn’t have cannot be granted to its token, no matter what you put in the token’s ACL. Worth knowing before you spend an hour debugging a 403 on a token you’re certain you configured correctly.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Backup server tokens work the same way, but need their own entry.&lt;/strong&gt; &lt;a href=&quot;https://pbs.proxmox.com/docs/user-management.html&quot;&gt;Proxmox Backup Server&lt;/a&gt; states it plainly: “API tokens require their own ACL entries” and “API tokens can never do more than their corresponding user.” I granted the &lt;code&gt;DatastoreAudit&lt;/code&gt; role — which “can view datastore metrics, settings and list content” but not read the actual data, exactly the access a size-reading tool should have — to the token and got 403s until I granted it to the &lt;em&gt;user&lt;/em&gt; as well. Both entries, every time.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Filter non-finite values out of Prometheus results.&lt;/strong&gt; A range query over a window where a target was down can hand back values that aren’t real numbers. Feed one into a subtraction and it propagates silently, turning a capacity figure into nonsense that still looks like a figure. Three characters fix it — for a float, &lt;code&gt;v == v&lt;/code&gt; is false precisely when that float is NaN — and it’s the difference between a wrong answer and no answer.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Speak the vocabulary your interface already uses.&lt;/strong&gt; My first refusal messages said “threads,” because on these particular CPUs the number in question is the thread count. But the field a reader fills in when creating a container is labelled &lt;strong&gt;Cores&lt;/strong&gt;, and that’s the same pool the number is drawn from — so my form said cores, my error said threads, and both were individually defensible. I made them match the label the reader is actually looking at. Pick the vocabulary of the interface in front of your user, even when it’s not the word you’d choose.&lt;/p&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;what-this-is-not&quot;&gt;What this is not&lt;/h2&gt;
&lt;p&gt;Honesty about the limits, since the whole point of the thing is honesty:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;It is not a production web service.&lt;/strong&gt; It’s stdlib Python built on &lt;code&gt;http.server&lt;/code&gt;, whose own documentation warns it “is not recommended for production” and “only implements basic security checks” (&lt;a href=&quot;https://docs.python.org/3/library/http.server.html&quot;&gt;Python docs&lt;/a&gt;). It binds to my LAN, serves only GET requests, holds read-only credentials, and computes from a cached snapshot. That’s the entire threat model, and it’s why this is acceptable — put it on the internet and it stops being acceptable immediately.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;It models memory, cores, and disk. Nothing else.&lt;/strong&gt; Not disk throughput, not network saturation, not the thermal reality of three nodes carrying four nodes’ work. A guest that “fits” on RAM can still run badly.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;It’s a point estimate, not a promise.&lt;/strong&gt; It reports what would happen if the node died &lt;em&gt;now&lt;/em&gt;, with the current backups and the current load. It’s a planning aid, not an SLA.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;It has no alerting and no opinions.&lt;/strong&gt; It never pages anyone. Nothing it says is urgent; it answers when asked.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;None of that undermines the value, because the value was never precision. It was replacing “probably fine” with a specific number I can act on — and, once, with a specific number I didn’t want to hear.&lt;/p&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;whats-next&quot;&gt;What’s next&lt;/h2&gt;
&lt;p&gt;If you want to build the same thing, the order that worked for me: get &lt;a href=&quot;https://peira.dev/blog/node-exporter-grafana-proxmox&quot;&gt;Prometheus scraping your cluster&lt;/a&gt; and leave it running for a week so you have real history to plan against, make sure &lt;a href=&quot;https://peira.dev/blog/proxmox-backup-server&quot;&gt;your backups actually restore&lt;/a&gt; and time one while you’re there, then write the arithmetic. The simulator is the easy part — a few hundred lines. The week of history and the measured restore rate are what make its answers mean anything.&lt;/p&gt;
&lt;p&gt;And run it before you think you need to. The useful result isn’t the one that confirms you’re fine.&lt;/p&gt;
&lt;hr/&gt;
&lt;p&gt;&lt;strong&gt;Related posts:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/grafana-prometheus-proxmox&quot;&gt;Prometheus and Grafana on Proxmox&lt;/a&gt; — the metrics stack this whole model reads from; build it first&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/node-exporter-grafana-proxmox&quot;&gt;Node Exporter on Proxmox&lt;/a&gt; — adds the per-node hardware metrics that give the capacity numbers their baseline&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/proxmox-backup-server&quot;&gt;Proxmox Backup Server in an LXC&lt;/a&gt; — the restore path every one of these scenarios depends on, and where you measure your own restore rate&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/proxmox-cluster-setup&quot;&gt;Build a Proxmox Cluster&lt;/a&gt; — you need more than one node before “what if one dies” is an interesting question&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/proxmox-cluster-hp-elitedesk&quot;&gt;A 4-Node Proxmox Cluster on HP EliteDesks&lt;/a&gt; — the actual hardware being simulated here, and why it has no shared storage&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/tailscale-subnet-router&quot;&gt;Tailscale Subnet Router&lt;/a&gt; — the remote access that disappears along with one specific node in these scenarios&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/uptime-kuma-monitoring&quot;&gt;Uptime Kuma Monitoring&lt;/a&gt; — tells you a node died; this post is about what to do in the ten minutes after&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/ai-spend-telemetry-grafana&quot;&gt;Track AI Token Spend in Grafana&lt;/a&gt; — the other “measure the thing you were guessing about” project in this lab&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/proxmox-cluster-as-code-import-first/&quot;&gt;Turn a Hand-Built Proxmox Cluster Into Code Without Breaking It&lt;/a&gt; — the other half of planning for failure: what code restores after a node dies, and what it can’t&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/proxmox-rebuild-from-zero/&quot;&gt;Rebuild Your Homelab From Zero: What Survives a Total Loss&lt;/a&gt; — the total-loss version of this question, where placement facts from here become load-bearing&lt;/li&gt;
&lt;/ul&gt;</content:encoded><category>monitoring</category><category>proxmox</category><category>prometheus</category><category>infrastructure</category></item><item><title>Track AI Token Spend in Grafana: Claude, Codex, and Ollama</title><link>https://peira.dev/blog/ai-spend-telemetry-grafana/</link><guid isPermaLink="true">https://peira.dev/blog/ai-spend-telemetry-grafana/</guid><description>Wire every Claude Code, Codex, and Ollama call into Prometheus and one Grafana dashboard — tokens, latency, cache hits, and real dollars vs quota burn.</description><pubDate>Fri, 24 Jul 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;I let three different AIs work in my homelab every day — a coding assistant on my laptop, a second CLI for pair-work, and a small automated agent that triages alerts overnight. One evening I realized I couldn’t answer a basic question: &lt;strong&gt;what is all of this actually costing me?&lt;/strong&gt; Two of them burn subscription quota I’ve already paid for, one spends real API dollars, and none of them showed up on the &lt;a href=&quot;https://grafana.com&quot;&gt;Grafana&lt;/a&gt; dashboards I’d built for everything else in the rack. So I spent an evening fixing that. By the end, every call from every AI in the lab — tokens, latency, cache hits, and dollars — landed in &lt;a href=&quot;https://prometheus.io&quot;&gt;Prometheus&lt;/a&gt; and one Grafana dashboard.&lt;/p&gt;
&lt;p&gt;This post walks through the four measurement legs, the PromQL traps that made my first dashboard lie to me, and the privacy scrub that makes the screenshots publishable.&lt;/p&gt;
&lt;svg viewBox=&quot;0 0 720 420&quot; width=&quot;100%&quot; role=&quot;img&quot; aria-label=&quot;Diagram of four AI telemetry sources flowing into Prometheus and Grafana: Claude Code and Codex on the laptop, the agent exporter, and the Ollama nodes all feed Prometheus, which feeds the AI Spend and Quality Grafana dashboard.&quot;&gt;&lt;rect width=&quot;720&quot; height=&quot;420&quot; rx=&quot;12&quot; fill=&quot;#1c1c32&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;360&quot; y=&quot;30&quot; text-anchor=&quot;middle&quot; fill=&quot;#f4f4f5&quot; font-size=&quot;15&quot; font-weight=&quot;bold&quot;&gt;Four legs, one dashboard&lt;/text&gt;&lt;rect x=&quot;20&quot; y=&quot;55&quot; width=&quot;160&quot; height=&quot;64&quot; rx=&quot;8&quot; fill=&quot;#2c2c4c&quot; stroke=&quot;#3d3d5c&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;100&quot; y=&quot;80&quot; text-anchor=&quot;middle&quot; fill=&quot;#f4f4f5&quot; font-size=&quot;12&quot;&gt;Claude Code (laptop)&lt;/text&gt;&lt;text x=&quot;100&quot; y=&quot;98&quot; text-anchor=&quot;middle&quot; fill=&quot;#a1a1aa&quot; font-size=&quot;10&quot;&gt;native OpenTelemetry&lt;/text&gt;&lt;rect x=&quot;20&quot; y=&quot;140&quot; width=&quot;160&quot; height=&quot;64&quot; rx=&quot;8&quot; fill=&quot;#2c2c4c&quot; stroke=&quot;#3d3d5c&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;100&quot; y=&quot;165&quot; text-anchor=&quot;middle&quot; fill=&quot;#f4f4f5&quot; font-size=&quot;12&quot;&gt;Codex CLI (laptop)&lt;/text&gt;&lt;text x=&quot;100&quot; y=&quot;183&quot; text-anchor=&quot;middle&quot; fill=&quot;#a1a1aa&quot; font-size=&quot;10&quot;&gt;session JSONL parser&lt;/text&gt;&lt;rect x=&quot;20&quot; y=&quot;225&quot; width=&quot;160&quot; height=&quot;64&quot; rx=&quot;8&quot; fill=&quot;#2c2c4c&quot; stroke=&quot;#3d3d5c&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;100&quot; y=&quot;250&quot; text-anchor=&quot;middle&quot; fill=&quot;#f4f4f5&quot; font-size=&quot;12&quot;&gt;Agent (container)&lt;/text&gt;&lt;text x=&quot;100&quot; y=&quot;268&quot; text-anchor=&quot;middle&quot; fill=&quot;#a1a1aa&quot; font-size=&quot;10&quot;&gt;log-tail exporter :9109&lt;/text&gt;&lt;rect x=&quot;20&quot; y=&quot;310&quot; width=&quot;160&quot; height=&quot;64&quot; rx=&quot;8&quot; fill=&quot;#2c2c4c&quot; stroke=&quot;#3d3d5c&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;100&quot; y=&quot;335&quot; text-anchor=&quot;middle&quot; fill=&quot;#f4f4f5&quot; font-size=&quot;12&quot;&gt;Ollama nodes ×3&lt;/text&gt;&lt;text x=&quot;100&quot; y=&quot;353&quot; text-anchor=&quot;middle&quot; fill=&quot;#a1a1aa&quot; font-size=&quot;10&quot;&gt;journald + /api/ps :9110&lt;/text&gt;&lt;rect x=&quot;270&quot; y=&quot;55&quot; width=&quot;180&quot; height=&quot;64&quot; rx=&quot;8&quot; fill=&quot;#30305a&quot; stroke=&quot;#4d4d82&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;360&quot; y=&quot;80&quot; text-anchor=&quot;middle&quot; fill=&quot;#f4f4f5&quot; font-size=&quot;12&quot;&gt;OTel Collector&lt;/text&gt;&lt;text x=&quot;360&quot; y=&quot;98&quot; text-anchor=&quot;middle&quot; fill=&quot;#22c55e&quot; font-size=&quot;10&quot;&gt;PII scrub → :8889&lt;/text&gt;&lt;rect x=&quot;270&quot; y=&quot;140&quot; width=&quot;180&quot; height=&quot;64&quot; rx=&quot;8&quot; fill=&quot;#30305a&quot; stroke=&quot;#4d4d82&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;360&quot; y=&quot;165&quot; text-anchor=&quot;middle&quot; fill=&quot;#f4f4f5&quot; font-size=&quot;12&quot;&gt;Pushgateway :9091&lt;/text&gt;&lt;text x=&quot;360&quot; y=&quot;183&quot; text-anchor=&quot;middle&quot; fill=&quot;#a1a1aa&quot; font-size=&quot;10&quot;&gt;holds laptop pushes&lt;/text&gt;&lt;line x1=&quot;180&quot; y1=&quot;87&quot; x2=&quot;270&quot; y2=&quot;87&quot; stroke=&quot;#818cf8&quot; stroke-width=&quot;1.5&quot; marker-end=&quot;url(#arrow)&quot;&gt;&lt;/line&gt;&lt;line x1=&quot;180&quot; y1=&quot;172&quot; x2=&quot;270&quot; y2=&quot;172&quot; stroke=&quot;#818cf8&quot; stroke-width=&quot;1.5&quot; marker-end=&quot;url(#arrow)&quot;&gt;&lt;/line&gt;&lt;rect x=&quot;510&quot; y=&quot;140&quot; width=&quot;180&quot; height=&quot;90&quot; rx=&quot;8&quot; fill=&quot;#2c2c4c&quot; stroke=&quot;#14b8a6&quot; stroke-width=&quot;1.5&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;600&quot; y=&quot;170&quot; text-anchor=&quot;middle&quot; fill=&quot;#f4f4f5&quot; font-size=&quot;13&quot; font-weight=&quot;bold&quot;&gt;Prometheus&lt;/text&gt;&lt;text x=&quot;600&quot; y=&quot;190&quot; text-anchor=&quot;middle&quot; fill=&quot;#a1a1aa&quot; font-size=&quot;10&quot;&gt;4 scrape jobs:&lt;/text&gt;&lt;text x=&quot;600&quot; y=&quot;206&quot; text-anchor=&quot;middle&quot; fill=&quot;#a1a1aa&quot; font-size=&quot;10&quot;&gt;claude · codex · agent · ollama&lt;/text&gt;&lt;line x1=&quot;450&quot; y1=&quot;87&quot; x2=&quot;510&quot; y2=&quot;160&quot; stroke=&quot;#14b8a6&quot; stroke-width=&quot;1.5&quot; marker-end=&quot;url(#arrow)&quot;&gt;&lt;/line&gt;&lt;line x1=&quot;450&quot; y1=&quot;172&quot; x2=&quot;510&quot; y2=&quot;182&quot; stroke=&quot;#14b8a6&quot; stroke-width=&quot;1.5&quot; marker-end=&quot;url(#arrow)&quot;&gt;&lt;/line&gt;&lt;line x1=&quot;180&quot; y1=&quot;257&quot; x2=&quot;510&quot; y2=&quot;200&quot; stroke=&quot;#14b8a6&quot; stroke-width=&quot;1.5&quot; marker-end=&quot;url(#arrow)&quot;&gt;&lt;/line&gt;&lt;line x1=&quot;180&quot; y1=&quot;342&quot; x2=&quot;510&quot; y2=&quot;215&quot; stroke=&quot;#14b8a6&quot; stroke-width=&quot;1.5&quot; marker-end=&quot;url(#arrow)&quot;&gt;&lt;/line&gt;&lt;rect x=&quot;510&quot; y=&quot;290&quot; width=&quot;180&quot; height=&quot;64&quot; rx=&quot;8&quot; fill=&quot;#2c2c4c&quot; stroke=&quot;#f59e0b&quot; stroke-width=&quot;1.5&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;600&quot; y=&quot;315&quot; text-anchor=&quot;middle&quot; fill=&quot;#f4f4f5&quot; font-size=&quot;13&quot; font-weight=&quot;bold&quot;&gt;Grafana&lt;/text&gt;&lt;text x=&quot;600&quot; y=&quot;333&quot; text-anchor=&quot;middle&quot; fill=&quot;#f59e0b&quot; font-size=&quot;10&quot;&gt;“AI Spend &amp;amp; Quality”&lt;/text&gt;&lt;line x1=&quot;600&quot; y1=&quot;230&quot; x2=&quot;600&quot; y2=&quot;290&quot; stroke=&quot;#f59e0b&quot; stroke-width=&quot;1.5&quot; marker-end=&quot;url(#arrowAmber)&quot;&gt;&lt;/line&gt;&lt;defs&gt;&lt;marker id=&quot;arrow&quot; markerWidth=&quot;8&quot; markerHeight=&quot;8&quot; refX=&quot;7&quot; refY=&quot;4&quot; orient=&quot;auto&quot;&gt;&lt;path d=&quot;M0,0 L8,4 L0,8&quot; fill=&quot;none&quot; stroke=&quot;#818cf8&quot; stroke-width=&quot;1.2&quot;&gt;&lt;/path&gt;&lt;/marker&gt;&lt;marker id=&quot;arrowAmber&quot; markerWidth=&quot;8&quot; markerHeight=&quot;8&quot; refX=&quot;7&quot; refY=&quot;4&quot; orient=&quot;auto&quot;&gt;&lt;path d=&quot;M0,0 L8,4 L0,8&quot; fill=&quot;none&quot; stroke=&quot;#f59e0b&quot; stroke-width=&quot;1.2&quot;&gt;&lt;/path&gt;&lt;/marker&gt;&lt;/defs&gt;&lt;/svg&gt;
&lt;div class=&quot;callout callout-warning&quot; data-astro-cid-q2ml7llr&gt;&lt;div class=&quot;callout-header&quot; data-astro-cid-q2ml7llr&gt;&lt;span class=&quot;callout-icon&quot; aria-hidden=&quot;true&quot; data-astro-cid-q2ml7llr&gt;⚠️&lt;/span&gt;&lt;span class=&quot;callout-label&quot; data-astro-cid-q2ml7llr&gt;First: make these values your own&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;callout-body&quot; data-astro-cid-q2ml7llr&gt;&lt;p&gt;Every machine-specific value in this post is a placeholder. Swap in your own before pasting anything: the monitoring host &lt;code&gt;10.0.0.5&lt;/code&gt;, the agent host &lt;code&gt;10.0.0.7&lt;/code&gt;, the Ollama nodes &lt;code&gt;10.0.0.1&lt;/code&gt;–&lt;code&gt;10.0.0.3&lt;/code&gt;, the internal domain &lt;code&gt;homelab.lan&lt;/code&gt;, exporter ports &lt;code&gt;9109&lt;/code&gt;/&lt;code&gt;9110&lt;/code&gt;, and any file paths under &lt;code&gt;/home/youradmin&lt;/code&gt;. If a value looks specific to one machine, it’s a placeholder to change — not a literal to copy.&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;why-bother-measuring-ai-at-all&quot;&gt;Why bother measuring AI at all?&lt;/h2&gt;
&lt;p&gt;My lab already graphs everything else — I covered the base setup in &lt;a href=&quot;https://peira.dev/blog/grafana-prometheus-proxmox&quot;&gt;Prometheus and Grafana on Proxmox&lt;/a&gt;. But the AI layer was invisible, and it has a genuinely weird cost structure. The two interactive CLIs run on flat subscriptions, so their “cost” is quota: a percentage of a weekly allowance. The automated agent calls a hosted model API directly and pays per token — at the time of writing, the small model it uses costs $1 per million input tokens, $0.10 per million cached input tokens, and $5 per million output tokens (&lt;a href=&quot;https://platform.claude.com/docs/en/about-claude/pricing&quot;&gt;Anthropic’s pricing page&lt;/a&gt;). Same lab, three billing models.&lt;/p&gt;
&lt;p&gt;That’s why the dashboard has two distinct columns: &lt;strong&gt;quota burn&lt;/strong&gt; (a percentage that resets) for the subscription tools, and &lt;strong&gt;real dollars&lt;/strong&gt; for the metered agent. Once I could see both, the anxiety went away — the agent’s overnight triage runs cost cents, and the interesting number turned out to be the cache-hit rate, which is what keeps it that cheap.&lt;/p&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;leg-1-claude-code-already-speaks-opentelemetry&quot;&gt;Leg 1: Claude Code already speaks OpenTelemetry&lt;/h2&gt;
&lt;p&gt;The nicest surprise of the whole build: the coding CLI needs no wrapper at all. Claude Code has &lt;a href=&quot;https://code.claude.com/docs/en/monitoring-usage&quot;&gt;native OpenTelemetry support&lt;/a&gt; — OpenTelemetry being the vendor-neutral standard for shipping metrics out of applications. You switch it on with environment variables:&lt;/p&gt;
&lt;figure class=&quot;terminal&quot; data-astro-cid-kenkipkp&gt;&lt;figcaption class=&quot;terminal-bar&quot; data-astro-cid-kenkipkp&gt;&lt;span class=&quot;dot red&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot yellow&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot green&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;terminal-title&quot; data-astro-cid-kenkipkp&gt;Enable Claude Code telemetry (laptop, ~/.claude/settings.json env or shell profile)&lt;/span&gt;&lt;button class=&quot;terminal-copy&quot; type=&quot;button&quot; aria-label=&quot;Copy commands&quot; data-astro-cid-kenkipkp&gt;&lt;svg width=&quot;13&quot; height=&quot;13&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;none&quot; stroke=&quot;currentColor&quot; stroke-width=&quot;2&quot; stroke-linecap=&quot;round&quot; stroke-linejoin=&quot;round&quot; aria-hidden=&quot;true&quot; data-astro-cid-kenkipkp&gt;&lt;rect x=&quot;9&quot; y=&quot;9&quot; width=&quot;13&quot; height=&quot;13&quot; rx=&quot;2&quot; data-astro-cid-kenkipkp&gt;&lt;/rect&gt;&lt;path d=&quot;M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1&quot; data-astro-cid-kenkipkp&gt;&lt;/path&gt;&lt;/svg&gt;&lt;span class=&quot;terminal-copy-label&quot; data-astro-cid-kenkipkp&gt;Copy&lt;/span&gt;&lt;/button&gt;&lt;/figcaption&gt;&lt;pre class=&quot;terminal-body&quot; data-astro-cid-kenkipkp&gt;&lt;code data-astro-cid-kenkipkp&gt;
export CLAUDE_CODE_ENABLE_TELEMETRY=1
export OTEL_METRICS_EXPORTER=otlp
export OTEL_EXPORTER_OTLP_PROTOCOL=grpc
export OTEL_EXPORTER_OTLP_ENDPOINT=http://10.0.0.5:4317
&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;
&lt;p&gt;Every session then exports metrics like &lt;code&gt;claude_code.token.usage&lt;/code&gt; (by token type — input, output, cache read), &lt;code&gt;claude_code.cost.usage&lt;/code&gt; in USD, and &lt;code&gt;claude_code.session.count&lt;/code&gt;, with the model and session id attached as labels.&lt;/p&gt;
&lt;p&gt;The catch: those metrics arrive over OTLP, the OpenTelemetry wire protocol, which Prometheus doesn’t scrape directly in my setup. The bridge is the &lt;a href=&quot;https://opentelemetry.io/docs/collector/&quot;&gt;OpenTelemetry Collector&lt;/a&gt; (the contrib build), a small service on the monitoring host that listens on &lt;code&gt;:4317&lt;/code&gt;, applies processors — more on those in the privacy section — and re-exposes everything in Prometheus format on &lt;code&gt;:8889&lt;/code&gt;.&lt;/p&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;leg-2-tail-the-agents-log&quot;&gt;Leg 2: tail the agent’s log&lt;/h2&gt;
&lt;p&gt;The automated agent doesn’t speak OpenTelemetry, but it writes an honest log line for every model call: provider, model, token counts, latency, whether the response came from cache, and the occasional “fallback activated” when it drops to a local model. That’s enough. A ~200-line Python exporter — standard library only, no pip installs — tails the log with a regex, keeps counters in memory, and serves them on &lt;code&gt;:9109&lt;/code&gt; in Prometheus text format. A systemd unit keeps it running as the agent’s own unprivileged user.&lt;/p&gt;
&lt;p&gt;The same exporter reads the agent’s task database (read-only) and publishes queue-depth gauges, so the dashboard shows quality alongside spend: is the agent actually finishing its work, or just burning tokens?&lt;/p&gt;
&lt;p&gt;When I deployed it, the exporter replayed the existing log and seeded itself with the agent’s whole history — 99 API calls and about 2.5 million input tokens at a ~78% cache rate. Then came the moment that made the evening worth it: I triggered one small agent task and watched &lt;code&gt;ai_agent_api_calls_total&lt;/code&gt; tick from 99 to 100 on the dashboard. One call, traced end to end.&lt;/p&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;leg-3-ollama-measured-from-the-outside&quot;&gt;Leg 3: Ollama, measured from the outside&lt;/h2&gt;
&lt;p&gt;My three-node &lt;a href=&quot;https://ollama.com&quot;&gt;Ollama&lt;/a&gt; cluster — &lt;a href=&quot;https://peira.dev/blog/ollama-lxc-cluster&quot;&gt;built here&lt;/a&gt; — was the stubborn one: Ollama doesn’t ship a Prometheus metrics endpoint (true as of the 0.30 releases I run). But it leaks everything you need in two places:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Its request log.&lt;/strong&gt; Every API hit produces an access-log line in journald with status code, latency, and caller IP. A second small exporter follows the journal and turns those lines into counters and histograms.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The &lt;a href=&quot;https://github.com/ollama/ollama/blob/main/docs/api.md#list-running-models&quot;&gt;&lt;code&gt;/api/ps&lt;/code&gt; endpoint&lt;/a&gt;.&lt;/strong&gt; Officially documented, it lists which models are loaded into memory right now. Polling it gives you a residency gauge — handy for spotting a model that got evicted before a burst of requests paid the reload cost.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;One exporter instance runs on each node at &lt;code&gt;:9110&lt;/code&gt;. The caller-IP label turned out to be unexpectedly useful: it shows &lt;em&gt;who&lt;/em&gt; is using the local models — the agent’s fallback path, the web UI, or me poking at things.&lt;/p&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;leg-4-the-laptop-via-pushgateway&quot;&gt;Leg 4: the laptop, via Pushgateway&lt;/h2&gt;
&lt;p&gt;The last leg is the second CLI on my laptop, &lt;a href=&quot;https://github.com/openai/codex&quot;&gt;Codex&lt;/a&gt;, which records token counts and rate-limit quota in its session files. Two problems: the laptop sleeps, moves, and disconnects — so Prometheus can’t reliably scrape it — and the interesting numbers only change while I’m logged in and working.&lt;/p&gt;
&lt;p&gt;That’s the one shape of problem the &lt;a href=&quot;https://prometheus.io/docs/practices/pushing/&quot;&gt;Pushgateway&lt;/a&gt; exists for: a job Prometheus can’t scrape pushes its metrics to a small gateway service, and Prometheus scrapes the gateway. A systemd &lt;em&gt;user&lt;/em&gt; timer on the laptop parses the session files every five minutes and pushes lifetime token totals plus the current quota percentage to &lt;code&gt;:9091&lt;/code&gt; on the monitoring host.&lt;/p&gt;
&lt;div class=&quot;callout callout-info&quot; data-astro-cid-q2ml7llr&gt;&lt;div class=&quot;callout-header&quot; data-astro-cid-q2ml7llr&gt;&lt;span class=&quot;callout-icon&quot; aria-hidden=&quot;true&quot; data-astro-cid-q2ml7llr&gt;ℹ️&lt;/span&gt;&lt;span class=&quot;callout-label&quot; data-astro-cid-q2ml7llr&gt;Pushgateway honesty check&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;callout-body&quot; data-astro-cid-q2ml7llr&gt;&lt;p&gt;The Prometheus docs are blunt that the Pushgateway fits only limited cases, and this is one of them — but know its quirks. It never forgets: a pushed series is exposed forever until you delete it. And the Prometheus scrape job for it needs &lt;a href=&quot;https://prometheus.io/docs/prometheus/latest/configuration/configuration/&quot;&gt;honor_labels: true&lt;/a&gt; so the pushed job labels survive instead of being renamed. Also remember a user timer only fires while you’re logged in — gaps in that graph are me being asleep, not an outage.&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;
&lt;p&gt;Wiring it all together is four scrape jobs in &lt;code&gt;prometheus.yml&lt;/code&gt;:&lt;/p&gt;
&lt;pre class=&quot;astro-code gruvbox-dark-hard&quot; style=&quot;background-color:#1d2021;color:#ebdbb2;overflow-x:auto;white-space:pre-wrap;word-wrap:break-word&quot; tabindex=&quot;0&quot; data-language=&quot;yaml&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#8EC07C&quot;&gt;scrape_configs&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt;:&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#A89984&quot;&gt;  -&lt;/span&gt;&lt;span style=&quot;color:#8EC07C&quot;&gt; job_name&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt;:&lt;/span&gt;&lt;span style=&quot;color:#B8BB26&quot;&gt; ai_claude_code&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#8EC07C&quot;&gt;    static_configs&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt;:&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt; [{&lt;/span&gt;&lt;span style=&quot;color:#8EC07C&quot;&gt; targets&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt;:&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt; [&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#B8BB26&quot;&gt;10.0.0.5:8889&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt;]&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt; }]&lt;/span&gt;&lt;span style=&quot;color:#928374;font-style:italic&quot;&gt;   # OTel Collector&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#A89984&quot;&gt;  -&lt;/span&gt;&lt;span style=&quot;color:#8EC07C&quot;&gt; job_name&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt;:&lt;/span&gt;&lt;span style=&quot;color:#B8BB26&quot;&gt; ai_codex&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#8EC07C&quot;&gt;    honor_labels&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt;:&lt;/span&gt;&lt;span style=&quot;color:#D3869B&quot;&gt; true&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#8EC07C&quot;&gt;    static_configs&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt;:&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt; [{&lt;/span&gt;&lt;span style=&quot;color:#8EC07C&quot;&gt; targets&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt;:&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt; [&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#B8BB26&quot;&gt;10.0.0.5:9091&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt;]&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt; }]&lt;/span&gt;&lt;span style=&quot;color:#928374;font-style:italic&quot;&gt;   # Pushgateway&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#A89984&quot;&gt;  -&lt;/span&gt;&lt;span style=&quot;color:#8EC07C&quot;&gt; job_name&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt;:&lt;/span&gt;&lt;span style=&quot;color:#B8BB26&quot;&gt; ai_agent&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#8EC07C&quot;&gt;    static_configs&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt;:&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt; [{&lt;/span&gt;&lt;span style=&quot;color:#8EC07C&quot;&gt; targets&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt;:&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt; [&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#B8BB26&quot;&gt;10.0.0.7:9109&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt;]&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt; }]&lt;/span&gt;&lt;span style=&quot;color:#928374;font-style:italic&quot;&gt;   # log-tail exporter&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#A89984&quot;&gt;  -&lt;/span&gt;&lt;span style=&quot;color:#8EC07C&quot;&gt; job_name&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt;:&lt;/span&gt;&lt;span style=&quot;color:#B8BB26&quot;&gt; ai_ollama&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#8EC07C&quot;&gt;    static_configs&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt;:&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt; [{&lt;/span&gt;&lt;span style=&quot;color:#8EC07C&quot;&gt; targets&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt;:&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt; [&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#B8BB26&quot;&gt;10.0.0.1:9110&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt;,&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt; &amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#B8BB26&quot;&gt;10.0.0.2:9110&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt;,&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt; &amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#B8BB26&quot;&gt;10.0.0.3:9110&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt;]&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt; }]&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;the-three-promql-traps-that-made-v1-of-the-dashboard-lie&quot;&gt;The three PromQL traps that made v1 of the dashboard lie&lt;/h2&gt;
&lt;p&gt;My first dashboard rendered beautifully and was wrong three different ways. Each trap is worth knowing before you build yours.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Trap 1: per-session counters break &lt;code&gt;increase()&lt;/code&gt;.&lt;/strong&gt; Claude Code’s counters are &lt;em&gt;per-session and ephemeral&lt;/em&gt; — a new session starts a new series, and a short session may leave only a single sample in your query window. Prometheus range functions &lt;a href=&quot;https://prometheus.io/docs/prometheus/latest/querying/functions/&quot;&gt;need at least two samples&lt;/a&gt; to compute anything, so &lt;code&gt;increase()&lt;/code&gt; returns nothing and the panel shows zero — while a real 30,000-token session sits invisible in storage. The fix is to read the last value each session reported and add sessions together:&lt;/p&gt;
&lt;pre class=&quot;astro-code gruvbox-dark-hard&quot; style=&quot;background-color:#1d2021;color:#ebdbb2;overflow-x:auto;white-space:pre-wrap;word-wrap:break-word&quot; tabindex=&quot;0&quot; data-language=&quot;plaintext&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span&gt;sum(max_over_time(claude_code_token_usage_tokens_total[1d]))&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;svg viewBox=&quot;0 0 720 250&quot; width=&quot;100%&quot; role=&quot;img&quot; aria-label=&quot;Why increase reads a short session as zero but max_over_time reads it correctly: a single sample of 30,530 tokens inside a one-hour window gives increase nothing to compute while max_over_time returns the value.&quot;&gt;&lt;rect width=&quot;720&quot; height=&quot;250&quot; rx=&quot;12&quot; fill=&quot;#1c1c32&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;360&quot; y=&quot;26&quot; text-anchor=&quot;middle&quot; fill=&quot;#f4f4f5&quot; font-size=&quot;14&quot; font-weight=&quot;bold&quot;&gt;One short session, two queries&lt;/text&gt;&lt;line x1=&quot;60&quot; y1=&quot;190&quot; x2=&quot;660&quot; y2=&quot;190&quot; stroke=&quot;#3a3a5c&quot; stroke-width=&quot;1&quot;&gt;&lt;/line&gt;&lt;text x=&quot;60&quot; y=&quot;210&quot; fill=&quot;#6b7280&quot; font-size=&quot;10&quot;&gt;t0&lt;/text&gt;&lt;text x=&quot;640&quot; y=&quot;210&quot; fill=&quot;#6b7280&quot; font-size=&quot;10&quot;&gt;t0 + 1h&lt;/text&gt;&lt;rect x=&quot;300&quot; y=&quot;60&quot; width=&quot;130&quot; height=&quot;110&quot; rx=&quot;6&quot; fill=&quot;#262640&quot; stroke=&quot;#3d3d5c&quot; stroke-dasharray=&quot;4 3&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;365&quot; y=&quot;52&quot; text-anchor=&quot;middle&quot; fill=&quot;#a1a1aa&quot; font-size=&quot;10&quot;&gt;session lifetime&lt;/text&gt;&lt;circle cx=&quot;365&quot; cy=&quot;100&quot; r=&quot;5&quot; fill=&quot;#14b8a6&quot;&gt;&lt;/circle&gt;&lt;text x=&quot;365&quot; y=&quot;88&quot; text-anchor=&quot;middle&quot; fill=&quot;#14b8a6&quot; font-size=&quot;10&quot;&gt;single sample: 30,530&lt;/text&gt;&lt;rect x=&quot;60&quot; y=&quot;222&quot; width=&quot;290&quot; height=&quot;1&quot; fill=&quot;none&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;180&quot; y=&quot;130&quot; text-anchor=&quot;middle&quot; fill=&quot;#ef4444&quot; font-size=&quot;12&quot;&gt;increase(…[1h]) → 0&lt;/text&gt;&lt;text x=&quot;180&quot; y=&quot;148&quot; text-anchor=&quot;middle&quot; fill=&quot;#a1a1aa&quot; font-size=&quot;10&quot;&gt;needs ≥ 2 samples, finds 1&lt;/text&gt;&lt;text x=&quot;545&quot; y=&quot;130&quot; text-anchor=&quot;middle&quot; fill=&quot;#22c55e&quot; font-size=&quot;12&quot;&gt;max_over_time(…[1h]) → 30,530&lt;/text&gt;&lt;text x=&quot;545&quot; y=&quot;148&quot; text-anchor=&quot;middle&quot; fill=&quot;#a1a1aa&quot; font-size=&quot;10&quot;&gt;reads the value that exists&lt;/text&gt;&lt;/svg&gt;
&lt;p&gt;&lt;strong&gt;Trap 2: composite cost math collapses on absent series.&lt;/strong&gt; My spend panel multiplied input, cache-read, and output token rates by their prices and added them up. When the lab had produced no cache reads yet, that series simply didn’t exist — and in PromQL, arithmetic with an empty operand makes the &lt;em&gt;whole expression&lt;/em&gt; empty. Real spend rendered as $0.00. Guard every component:&lt;/p&gt;
&lt;pre class=&quot;astro-code gruvbox-dark-hard&quot; style=&quot;background-color:#1d2021;color:#ebdbb2;overflow-x:auto;white-space:pre-wrap;word-wrap:break-word&quot; tabindex=&quot;0&quot; data-language=&quot;plaintext&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span&gt;(sum(rate(input_tokens[1h]))  or vector(0)) * 1.00 / 1e6&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;+ (sum(rate(cache_tokens[1h])) or vector(0)) * 0.10 / 1e6&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;+ (sum(rate(output_tokens[1h])) or vector(0)) * 5.00 / 1e6&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Trap 3: &lt;code&gt;histogram_quantile&lt;/code&gt; returns literal &lt;code&gt;NaN&lt;/code&gt; over idle windows.&lt;/strong&gt; With &lt;a href=&quot;https://prometheus.io/docs/prometheus/latest/querying/functions/&quot;&gt;zero observations in the window&lt;/a&gt;, the function returns &lt;code&gt;NaN&lt;/code&gt; — documented behavior, not a bug — and my latency panel dutifully drew garbage across every quiet hour. Anything consuming these series needs to drop non-finite samples; in Grafana panels the graph just goes sparse when the lab is idle, which is the honest picture anyway.&lt;/p&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;the-privacy-scrub-make-your-dashboards-publishable&quot;&gt;The privacy scrub: make your dashboards publishable&lt;/h2&gt;
&lt;p&gt;Here’s the part I’d call mandatory if you ever screenshot a dashboard for a blog, a talk, or a support thread. Claude Code’s telemetry attaches identity by default: your &lt;strong&gt;email address&lt;/strong&gt;, account ids, and organization id ride along &lt;a href=&quot;https://code.claude.com/docs/en/monitoring-usage&quot;&gt;as labels on every metric&lt;/a&gt;. Useful in a company; radioactive on a public screenshot.&lt;/p&gt;
&lt;p&gt;Two-part fix. Going forward, the OTel Collector deletes those attributes before they ever reach Prometheus, using its &lt;a href=&quot;https://github.com/open-telemetry/opentelemetry-collector-contrib/blob/main/processor/attributesprocessor/README.md&quot;&gt;attributes processor&lt;/a&gt; and &lt;a href=&quot;https://github.com/open-telemetry/opentelemetry-collector-contrib/blob/main/processor/resourceprocessor/README.md&quot;&gt;resource processor&lt;/a&gt; — both support a &lt;code&gt;delete&lt;/code&gt; action:&lt;/p&gt;
&lt;pre class=&quot;astro-code gruvbox-dark-hard&quot; style=&quot;background-color:#1d2021;color:#ebdbb2;overflow-x:auto;white-space:pre-wrap;word-wrap:break-word&quot; tabindex=&quot;0&quot; data-language=&quot;yaml&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#8EC07C&quot;&gt;processors&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt;:&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#8EC07C&quot;&gt;  resource/scrub&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt;:&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#8EC07C&quot;&gt;    attributes&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt;:&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#A89984&quot;&gt;      -&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt; {&lt;/span&gt;&lt;span style=&quot;color:#8EC07C&quot;&gt; key&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt;:&lt;/span&gt;&lt;span style=&quot;color:#B8BB26&quot;&gt; user.email&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt;,&lt;/span&gt;&lt;span style=&quot;color:#8EC07C&quot;&gt;        action&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt;:&lt;/span&gt;&lt;span style=&quot;color:#B8BB26&quot;&gt; delete&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt; }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#A89984&quot;&gt;      -&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt; {&lt;/span&gt;&lt;span style=&quot;color:#8EC07C&quot;&gt; key&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt;:&lt;/span&gt;&lt;span style=&quot;color:#B8BB26&quot;&gt; user.account_uuid&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt;,&lt;/span&gt;&lt;span style=&quot;color:#8EC07C&quot;&gt; action&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt;:&lt;/span&gt;&lt;span style=&quot;color:#B8BB26&quot;&gt; delete&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt; }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#A89984&quot;&gt;      -&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt; {&lt;/span&gt;&lt;span style=&quot;color:#8EC07C&quot;&gt; key&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt;:&lt;/span&gt;&lt;span style=&quot;color:#B8BB26&quot;&gt; user.account_id&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt;,&lt;/span&gt;&lt;span style=&quot;color:#8EC07C&quot;&gt;   action&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt;:&lt;/span&gt;&lt;span style=&quot;color:#B8BB26&quot;&gt; delete&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt; }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#A89984&quot;&gt;      -&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt; {&lt;/span&gt;&lt;span style=&quot;color:#8EC07C&quot;&gt; key&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt;:&lt;/span&gt;&lt;span style=&quot;color:#B8BB26&quot;&gt; user.id&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt;,&lt;/span&gt;&lt;span style=&quot;color:#8EC07C&quot;&gt;           action&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt;:&lt;/span&gt;&lt;span style=&quot;color:#B8BB26&quot;&gt; delete&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt; }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#A89984&quot;&gt;      -&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt; {&lt;/span&gt;&lt;span style=&quot;color:#8EC07C&quot;&gt; key&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt;:&lt;/span&gt;&lt;span style=&quot;color:#B8BB26&quot;&gt; organization.id&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt;,&lt;/span&gt;&lt;span style=&quot;color:#8EC07C&quot;&gt;   action&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt;:&lt;/span&gt;&lt;span style=&quot;color:#B8BB26&quot;&gt; delete&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt; }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#8EC07C&quot;&gt;  attributes/scrub&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt;:&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#8EC07C&quot;&gt;    actions&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt;:&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#A89984&quot;&gt;      -&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt; {&lt;/span&gt;&lt;span style=&quot;color:#8EC07C&quot;&gt; key&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt;:&lt;/span&gt;&lt;span style=&quot;color:#B8BB26&quot;&gt; user.email&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt;,&lt;/span&gt;&lt;span style=&quot;color:#8EC07C&quot;&gt;        action&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt;:&lt;/span&gt;&lt;span style=&quot;color:#B8BB26&quot;&gt; delete&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt; }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#A89984&quot;&gt;      -&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt; {&lt;/span&gt;&lt;span style=&quot;color:#8EC07C&quot;&gt; key&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt;:&lt;/span&gt;&lt;span style=&quot;color:#B8BB26&quot;&gt; user.account_uuid&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt;,&lt;/span&gt;&lt;span style=&quot;color:#8EC07C&quot;&gt; action&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt;:&lt;/span&gt;&lt;span style=&quot;color:#B8BB26&quot;&gt; delete&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt; }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#A89984&quot;&gt;      -&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt; {&lt;/span&gt;&lt;span style=&quot;color:#8EC07C&quot;&gt; key&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt;:&lt;/span&gt;&lt;span style=&quot;color:#B8BB26&quot;&gt; user.account_id&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt;,&lt;/span&gt;&lt;span style=&quot;color:#8EC07C&quot;&gt;   action&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt;:&lt;/span&gt;&lt;span style=&quot;color:#B8BB26&quot;&gt; delete&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt; }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#A89984&quot;&gt;      -&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt; {&lt;/span&gt;&lt;span style=&quot;color:#8EC07C&quot;&gt; key&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt;:&lt;/span&gt;&lt;span style=&quot;color:#B8BB26&quot;&gt; user.id&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt;,&lt;/span&gt;&lt;span style=&quot;color:#8EC07C&quot;&gt;           action&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt;:&lt;/span&gt;&lt;span style=&quot;color:#B8BB26&quot;&gt; delete&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt; }&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#A89984&quot;&gt;      -&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt; {&lt;/span&gt;&lt;span style=&quot;color:#8EC07C&quot;&gt; key&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt;:&lt;/span&gt;&lt;span style=&quot;color:#B8BB26&quot;&gt; organization.id&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt;,&lt;/span&gt;&lt;span style=&quot;color:#8EC07C&quot;&gt;   action&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt;:&lt;/span&gt;&lt;span style=&quot;color:#B8BB26&quot;&gt; delete&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt; }&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Deleting by key (not by value) matters: anyone who ever exported from that laptop gets scrubbed, not just the account you thought of.&lt;/p&gt;
&lt;p&gt;For the history already on disk, Prometheus has a &lt;a href=&quot;https://prometheus.io/docs/prometheus/latest/querying/api/&quot;&gt;TSDB admin API&lt;/a&gt; — disabled by default, and it should stay that way. Open a temporary window: restart Prometheus with &lt;code&gt;--web.enable-admin-api&lt;/code&gt;, delete the identity-labeled series, clean up, and close the window:&lt;/p&gt;
&lt;figure class=&quot;terminal&quot; data-astro-cid-kenkipkp&gt;&lt;figcaption class=&quot;terminal-bar&quot; data-astro-cid-kenkipkp&gt;&lt;span class=&quot;dot red&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot yellow&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot green&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;terminal-title&quot; data-astro-cid-kenkipkp&gt;Purge identity-labeled history (temporary admin window on 10.0.0.5)&lt;/span&gt;&lt;button class=&quot;terminal-copy&quot; type=&quot;button&quot; aria-label=&quot;Copy commands&quot; data-astro-cid-kenkipkp&gt;&lt;svg width=&quot;13&quot; height=&quot;13&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;none&quot; stroke=&quot;currentColor&quot; stroke-width=&quot;2&quot; stroke-linecap=&quot;round&quot; stroke-linejoin=&quot;round&quot; aria-hidden=&quot;true&quot; data-astro-cid-kenkipkp&gt;&lt;rect x=&quot;9&quot; y=&quot;9&quot; width=&quot;13&quot; height=&quot;13&quot; rx=&quot;2&quot; data-astro-cid-kenkipkp&gt;&lt;/rect&gt;&lt;path d=&quot;M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1&quot; data-astro-cid-kenkipkp&gt;&lt;/path&gt;&lt;/svg&gt;&lt;span class=&quot;terminal-copy-label&quot; data-astro-cid-kenkipkp&gt;Copy&lt;/span&gt;&lt;/button&gt;&lt;/figcaption&gt;&lt;pre class=&quot;terminal-body&quot; data-astro-cid-kenkipkp&gt;&lt;code data-astro-cid-kenkipkp&gt;
curl -X POST &amp;#39;http://10.0.0.5:9090/api/v1/admin/tsdb/delete_series?match[]={user_email!=&amp;quot;&amp;quot;}&amp;#39;
curl -X POST &amp;#39;http://10.0.0.5:9090/api/v1/admin/tsdb/clean_tombstones&amp;#39;
# now remove --web.enable-admin-api from the unit and restart again
&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;
&lt;p&gt;Verify with instant queries for each identity label — they should all come back empty — then confirm the admin API answers 403/404 again.&lt;/p&gt;
&lt;p&gt;One deliberate non-deletion: I kept the &lt;code&gt;session_id&lt;/code&gt; label. Dropping it looks like a privacy win, but merging per-session &lt;em&gt;cumulative&lt;/em&gt; counters without a real aggregation step makes distinct sessions collide into one series — last write wins, values flap, totals undercount. Scrub identity, keep cardinality that’s structurally load-bearing.&lt;/p&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;dont-page-yourself-over-a-dead-exporter&quot;&gt;Don’t page yourself over a dead exporter&lt;/h2&gt;
&lt;p&gt;My Node Down alert used to be &lt;code&gt;up == 0&lt;/code&gt; — any scrape target down meant a page, as I set up back in &lt;a href=&quot;https://peira.dev/blog/node-exporter-grafana-proxmox&quot;&gt;the node-exporter post&lt;/a&gt;. The laptop legs made that untenable: a sleeping laptop is not an outage. The alert is now scoped to exclude the AI jobs:&lt;/p&gt;
&lt;pre class=&quot;astro-code gruvbox-dark-hard&quot; style=&quot;background-color:#1d2021;color:#ebdbb2;overflow-x:auto;white-space:pre-wrap;word-wrap:break-word&quot; tabindex=&quot;0&quot; data-language=&quot;plaintext&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span&gt;up{job!~&amp;quot;ai_.*&amp;quot;} == 0&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A dead AI exporter shows as a gap on the dashboard, which is the severity it deserves. Real infrastructure still pages. (My uptime philosophy for user-facing checks lives with &lt;a href=&quot;https://peira.dev/blog/uptime-kuma-monitoring&quot;&gt;Uptime Kuma&lt;/a&gt; — this alert is only about hosts.)&lt;/p&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;what-it-all-shows&quot;&gt;What it all shows&lt;/h2&gt;
&lt;p&gt;The finished dashboard has a spend/quota strip on top — real dollars for the metered agent, weekly-window percentages for the two subscription CLIs — then token throughput by tool and model, cache-hit rate, latency quantiles that politely go sparse when the lab sleeps, and the agent’s task-queue gauges. Total cost of the measurement layer itself: two tiny Python exporters, one collector, one gateway, and an evening.&lt;/p&gt;
&lt;p&gt;Once the numbers live in Prometheus, any client can render them. Mine also surface in the Metrics tab of my &lt;a href=&quot;https://peira.dev/blog/flutter-proxmox-dashboard/&quot;&gt;homelab dashboard app&lt;/a&gt;, same PromQL, no Grafana in the loop:&lt;/p&gt;
&lt;figure class=&quot;shot&quot; data-astro-cid-pmiqfeva&gt;&lt;div class=&quot;shot-bar&quot; data-astro-cid-pmiqfeva&gt;&lt;span class=&quot;dot red&quot; data-astro-cid-pmiqfeva&gt;&lt;/span&gt;&lt;span class=&quot;dot yellow&quot; data-astro-cid-pmiqfeva&gt;&lt;/span&gt;&lt;span class=&quot;dot green&quot; data-astro-cid-pmiqfeva&gt;&lt;/span&gt;&lt;/div&gt;&lt;img src=&quot;https://peira.dev/_astro/app-ai-telemetry.HGVcsxku_Tlvjl.webp&quot; alt=&quot;The AI telemetry section of the HomeLab app&apos;s Metrics tab: a stat strip with per-tool spend and quota, token throughput and latency charts for Hermes, Claude Code, and Codex, and the start of a what-if capacity planner&quot; data-astro-cid-pmiqfeva=&quot;true&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot; width=&quot;1400&quot; height=&quot;933&quot; class=&quot;shot-img&quot;&gt;&lt;figcaption class=&quot;shot-caption&quot; data-astro-cid-pmiqfeva&gt;The same telemetry, one rail-click away: spend strip (API dollars, quota-equivalents, cache-hit rate, fallbacks), token throughput, and latency quantiles — rendered from seeded demo data.&lt;/figcaption&gt;&lt;/figure&gt;
&lt;p&gt;The number I actually watch daily isn’t spend — it’s the cache-hit rate. At ~78% cached input, the agent’s metered bill stays in coffee money. The day that rate drops is the day something changed in how it builds prompts, and now I’ll see it the same morning. If you’re running an agent like mine, the &lt;a href=&quot;https://peira.dev/blog/hermes-agent-setup&quot;&gt;Hermes agent setup post&lt;/a&gt; covers the thing being measured here.&lt;/p&gt;
&lt;hr/&gt;
&lt;p&gt;&lt;strong&gt;Related posts:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/grafana-prometheus-proxmox&quot;&gt;Monitor Proxmox with Grafana and Prometheus&lt;/a&gt; — the base monitoring stack this telemetry layer plugs into&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/node-exporter-grafana-proxmox&quot;&gt;Node Exporter dashboards for every Proxmox node&lt;/a&gt; — where the Node Down alert scoped in this post comes from&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/ollama-lxc-cluster&quot;&gt;Build a three-node Ollama cluster in LXC&lt;/a&gt; — the local inference nodes measured by the journald exporter&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/hermes-agent-setup&quot;&gt;Hermes: an AI agent for your homelab&lt;/a&gt; — the automated agent whose spend and queue health this dashboard tracks&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/open-webui-advanced&quot;&gt;Open WebUI beyond the basics&lt;/a&gt; — another consumer of the Ollama nodes you’ll see in the caller-IP labels&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/uptime-kuma-monitoring&quot;&gt;Uptime Kuma: monitoring that tells you when things break&lt;/a&gt; — service-level checks that complement these metrics&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/homelab-capacity-planning-node-failure&quot;&gt;Homelab Capacity Planning: What If a Node Dies Tonight?&lt;/a&gt; — the other “measure what you were guessing about” build, this time for failure recovery&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/homelab-wiki-rag-cited-answers/&quot;&gt;RAG for Your Homelab Wiki: Answers That Cite Their Source&lt;/a&gt; — a hosted-model endpoint whose spend shows up in exactly these panels&lt;/li&gt;
&lt;/ul&gt;</content:encoded><category>monitoring</category><category>ai</category><category>prometheus</category><category>grafana</category></item><item><title>Real Screenshots, Zero Leaks: My Disposable Demo Lab</title><link>https://peira.dev/blog/demo-lab-screenshots-headless-chrome/</link><guid isPermaLink="true">https://peira.dev/blog/demo-lab-screenshots-headless-chrome/</guid><description>How this site captures real, publish-safe homelab screenshots — a throwaway demo lab plus headless Chrome, with no mockups and no leaked IPs.</description><pubDate>Fri, 24 Jul 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Every screenshot on this site is real — real dashboards, real uptime bars, real graphs with a day of actual history in them. And none of them show my lab. That sounds like a contradiction, so this post is me opening the workshop door: the whole pipeline is a throwaway container running a small monitoring stack seeded with dummy values, left alone until it looks lived-in, then photographed by a script driving headless Chrome. When I have what I need, the environment gets deleted. Nothing real was ever in frame, so nothing real can leak.&lt;/p&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;why-real-screenshots-and-why-not-the-real-lab&quot;&gt;Why real screenshots (and why not the real lab)&lt;/h2&gt;
&lt;p&gt;For a long time this site illustrated UI walkthroughs with hand-drawn mockups, and I’m still swapping the older ones out post by post. They were fine. They were also lies of a small kind — a mockup shows what I &lt;em&gt;remember&lt;/em&gt; an interface looking like, not what it looks like. Real captures come with the texture that makes a tutorial trustworthy: believable timestamps, graphs with actual wobble, the exact button labels of the version I told you to install.&lt;/p&gt;
&lt;p&gt;The obvious move — screenshot the production lab — is the one I won’t do, and I’d nudge you away from it too. A dashboard screenshot is a little map of your network: internal IP addresses, hostnames, service versions, sometimes a username in the corner. This is a public site, and my standing rule is that real infrastructure details never appear on it. I wrote about that boundary in the &lt;a href=&quot;https://peira.dev/blog/uptime-kuma-monitoring&quot;&gt;monitoring setup guide&lt;/a&gt;, and it applies double to images, because images are easy to forget during review.&lt;/p&gt;
&lt;p&gt;So the answer is a third path: a &lt;strong&gt;disposable demo lab&lt;/strong&gt;. Real software, fake identity.&lt;/p&gt;
&lt;div style=&quot;margin:1.75rem 0; border:1px solid var(--border); border-radius:12px; overflow:hidden; box-shadow:var(--shadow-lg)&quot;&gt;&lt;svg viewBox=&quot;0 0 720 330&quot; width=&quot;100%&quot; role=&quot;img&quot; aria-label=&quot;Pipeline diagram in four stages. Stage one: a throwaway container runs a Docker Compose stack of Homepage, Grafana, Prometheus, and Uptime Kuma seeded with dummy values. Stage two: the stack idles while real history accrues. Stage three: a capture script drives system Chrome through puppeteer-core. Stage four: sanitized PNG files are embedded in blog posts, and the lab is destroyed.&quot;&gt;&lt;defs&gt;&lt;linearGradient id=&quot;dls-bg&quot; x1=&quot;0&quot; y1=&quot;0&quot; x2=&quot;0&quot; y2=&quot;1&quot;&gt;&lt;stop offset=&quot;0&quot; stop-color=&quot;#242440&quot;&gt;&lt;/stop&gt;&lt;stop offset=&quot;1&quot; stop-color=&quot;#171730&quot;&gt;&lt;/stop&gt;&lt;/linearGradient&gt;&lt;marker id=&quot;dls-arr&quot; viewBox=&quot;0 0 10 10&quot; refX=&quot;8&quot; refY=&quot;5&quot; markerWidth=&quot;7&quot; markerHeight=&quot;7&quot; orient=&quot;auto-start-reverse&quot;&gt;&lt;path d=&quot;M0,0 L10,5 L0,10 z&quot; fill=&quot;#14b8a6&quot;&gt;&lt;/path&gt;&lt;/marker&gt;&lt;/defs&gt;&lt;rect x=&quot;0&quot; y=&quot;0&quot; width=&quot;720&quot; height=&quot;330&quot; fill=&quot;url(#dls-bg)&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;24&quot; y=&quot;34&quot; fill=&quot;#f4f4f5&quot; font-size=&quot;15&quot; font-weight=&quot;600&quot;&gt;The disposable demo-lab pipeline&lt;/text&gt;&lt;text x=&quot;24&quot; y=&quot;54&quot; fill=&quot;#a1a1aa&quot; font-size=&quot;11&quot;&gt;real software, fake identity — then delete the evidence&lt;/text&gt;&lt;rect x=&quot;24&quot; y=&quot;76&quot; width=&quot;180&quot; height=&quot;170&quot; rx=&quot;10&quot; fill=&quot;#2c2c4c&quot; stroke=&quot;#3d3d5c&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;114&quot; y=&quot;98&quot; text-anchor=&quot;middle&quot; fill=&quot;#f4f4f5&quot; font-size=&quot;12&quot; font-weight=&quot;600&quot;&gt;1 · Throwaway container&lt;/text&gt;&lt;rect x=&quot;40&quot; y=&quot;110&quot; width=&quot;148&quot; height=&quot;22&quot; rx=&quot;5&quot; fill=&quot;#262640&quot; stroke=&quot;#3d3d5c&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;114&quot; y=&quot;125&quot; text-anchor=&quot;middle&quot; fill=&quot;#e5e7eb&quot; font-size=&quot;10&quot;&gt;Homepage&lt;/text&gt;&lt;rect x=&quot;40&quot; y=&quot;136&quot; width=&quot;148&quot; height=&quot;22&quot; rx=&quot;5&quot; fill=&quot;#262640&quot; stroke=&quot;#3d3d5c&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;114&quot; y=&quot;151&quot; text-anchor=&quot;middle&quot; fill=&quot;#e5e7eb&quot; font-size=&quot;10&quot;&gt;Grafana + Prometheus&lt;/text&gt;&lt;rect x=&quot;40&quot; y=&quot;162&quot; width=&quot;148&quot; height=&quot;22&quot; rx=&quot;5&quot; fill=&quot;#262640&quot; stroke=&quot;#3d3d5c&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;114&quot; y=&quot;177&quot; text-anchor=&quot;middle&quot; fill=&quot;#e5e7eb&quot; font-size=&quot;10&quot;&gt;Uptime Kuma&lt;/text&gt;&lt;rect x=&quot;40&quot; y=&quot;192&quot; width=&quot;148&quot; height=&quot;40&quot; rx=&quot;5&quot; fill=&quot;#0d0d1a&quot; stroke=&quot;#3a3a5c&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;114&quot; y=&quot;208&quot; text-anchor=&quot;middle&quot; fill=&quot;#818cf8&quot; font-size=&quot;9.5&quot;&gt;seeded with dummy values&lt;/text&gt;&lt;text x=&quot;114&quot; y=&quot;222&quot; text-anchor=&quot;middle&quot; fill=&quot;#6b7280&quot; font-size=&quot;9.5&quot;&gt;10.0.0.x · homelab.lan · youradmin&lt;/text&gt;&lt;line x1=&quot;204&quot; y1=&quot;160&quot; x2=&quot;262&quot; y2=&quot;160&quot; stroke=&quot;#14b8a6&quot; stroke-width=&quot;1.5&quot; marker-end=&quot;url(#dls-arr)&quot;&gt;&lt;/line&gt;&lt;rect x=&quot;264&quot; y=&quot;106&quot; width=&quot;140&quot; height=&quot;108&quot; rx=&quot;10&quot; fill=&quot;#2c2c4c&quot; stroke=&quot;#3d3d5c&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;334&quot; y=&quot;130&quot; text-anchor=&quot;middle&quot; fill=&quot;#f4f4f5&quot; font-size=&quot;12&quot; font-weight=&quot;600&quot;&gt;2 · Let it live&lt;/text&gt;&lt;circle cx=&quot;334&quot; cy=&quot;164&quot; r=&quot;20&quot; fill=&quot;#262640&quot; stroke=&quot;#f59e0b&quot;&gt;&lt;/circle&gt;&lt;line x1=&quot;334&quot; y1=&quot;164&quot; x2=&quot;334&quot; y2=&quot;151&quot; stroke=&quot;#f59e0b&quot; stroke-width=&quot;1.5&quot;&gt;&lt;/line&gt;&lt;line x1=&quot;334&quot; y1=&quot;164&quot; x2=&quot;343&quot; y2=&quot;168&quot; stroke=&quot;#f59e0b&quot; stroke-width=&quot;1.5&quot;&gt;&lt;/line&gt;&lt;text x=&quot;334&quot; y=&quot;202&quot; text-anchor=&quot;middle&quot; fill=&quot;#a1a1aa&quot; font-size=&quot;9.5&quot;&gt;history accrues: ~17 min&lt;/text&gt;&lt;line x1=&quot;404&quot; y1=&quot;160&quot; x2=&quot;462&quot; y2=&quot;160&quot; stroke=&quot;#14b8a6&quot; stroke-width=&quot;1.5&quot; marker-end=&quot;url(#dls-arr)&quot;&gt;&lt;/line&gt;&lt;rect x=&quot;464&quot; y=&quot;90&quot; width=&quot;160&quot; height=&quot;140&quot; rx=&quot;10&quot; fill=&quot;#2c2c4c&quot; stroke=&quot;#3d3d5c&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;544&quot; y=&quot;114&quot; text-anchor=&quot;middle&quot; fill=&quot;#f4f4f5&quot; font-size=&quot;12&quot; font-weight=&quot;600&quot;&gt;3 · Capture script&lt;/text&gt;&lt;rect x=&quot;480&quot; y=&quot;126&quot; width=&quot;128&quot; height=&quot;24&quot; rx=&quot;5&quot; fill=&quot;#262640&quot; stroke=&quot;#3d3d5c&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;544&quot; y=&quot;142&quot; text-anchor=&quot;middle&quot; fill=&quot;#e5e7eb&quot; font-size=&quot;10&quot;&gt;puppeteer-core&lt;/text&gt;&lt;line x1=&quot;544&quot; y1=&quot;150&quot; x2=&quot;544&quot; y2=&quot;160&quot; stroke=&quot;#14b8a6&quot; stroke-width=&quot;1.5&quot; marker-end=&quot;url(#dls-arr)&quot;&gt;&lt;/line&gt;&lt;rect x=&quot;480&quot; y=&quot;162&quot; width=&quot;128&quot; height=&quot;24&quot; rx=&quot;5&quot; fill=&quot;#262640&quot; stroke=&quot;#3d3d5c&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;544&quot; y=&quot;178&quot; text-anchor=&quot;middle&quot; fill=&quot;#e5e7eb&quot; font-size=&quot;10&quot;&gt;system Chrome, headless&lt;/text&gt;&lt;text x=&quot;544&quot; y=&quot;206&quot; text-anchor=&quot;middle&quot; fill=&quot;#6b7280&quot; font-size=&quot;9.5&quot;&gt;viewport sized to content · 2× scale&lt;/text&gt;&lt;line x1=&quot;544&quot; y1=&quot;230&quot; x2=&quot;544&quot; y2=&quot;258&quot; stroke=&quot;#14b8a6&quot; stroke-width=&quot;1.5&quot; marker-end=&quot;url(#dls-arr)&quot;&gt;&lt;/line&gt;&lt;rect x=&quot;424&quot; y=&quot;260&quot; width=&quot;240&quot; height=&quot;46&quot; rx=&quot;10&quot; fill=&quot;#2c2c4c&quot; stroke=&quot;#22c55e&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;544&quot; y=&quot;279&quot; text-anchor=&quot;middle&quot; fill=&quot;#f4f4f5&quot; font-size=&quot;11&quot; font-weight=&quot;600&quot;&gt;4 · Sanitized PNGs → post embed&lt;/text&gt;&lt;text x=&quot;544&quot; y=&quot;295&quot; text-anchor=&quot;middle&quot; fill=&quot;#a1a1aa&quot; font-size=&quot;9.5&quot;&gt;eyeball at full size · security scan · commit&lt;/text&gt;&lt;rect x=&quot;60&quot; y=&quot;266&quot; width=&quot;132&quot; height=&quot;36&quot; rx=&quot;8&quot; fill=&quot;#0a0a14&quot; stroke=&quot;#ef4444&quot; stroke-dasharray=&quot;4 3&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;126&quot; y=&quot;283&quot; text-anchor=&quot;middle&quot; fill=&quot;#ef4444&quot; font-size=&quot;10&quot; font-weight=&quot;600&quot;&gt;then: destroy the lab&lt;/text&gt;&lt;text x=&quot;126&quot; y=&quot;296&quot; text-anchor=&quot;middle&quot; fill=&quot;#6b7280&quot; font-size=&quot;9&quot;&gt;nothing real ever existed here&lt;/text&gt;&lt;/svg&gt;&lt;/div&gt;
&lt;p&gt;With the idea on the table, let’s build one.&lt;/p&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;task-1-build-the-throwaway-lab&quot;&gt;Task 1: Build the throwaway lab&lt;/h2&gt;
&lt;div class=&quot;callout callout-warning&quot; data-astro-cid-q2ml7llr&gt;&lt;div class=&quot;callout-header&quot; data-astro-cid-q2ml7llr&gt;&lt;span class=&quot;callout-icon&quot; aria-hidden=&quot;true&quot; data-astro-cid-q2ml7llr&gt;⚠️&lt;/span&gt;&lt;span class=&quot;callout-label&quot; data-astro-cid-q2ml7llr&gt;First: make these values your own&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;callout-body&quot; data-astro-cid-q2ml7llr&gt;&lt;p&gt;Everything machine-specific in this post is a deliberate placeholder. Swap in your own values as you go: &lt;code&gt;10.0.0.x&lt;/code&gt; addresses and &lt;code&gt;homelab.lan&lt;/code&gt; are documentation stand-ins for whatever your demo box actually uses; &lt;code&gt;youradmin&lt;/code&gt; / &lt;code&gt;CHANGE_ME_demo_only&lt;/code&gt; are the demo credentials (pick your own, and keep even demo passwords out of your notes); &lt;code&gt;$DEMO_HOST&lt;/code&gt; is the IP or hostname of your demo container, which you set as an environment variable rather than writing into any file. Rule of thumb: if a value looks specific to one machine, it’s a placeholder to change — not a literal to copy.&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;
&lt;div class=&quot;step&quot; data-astro-cid-wwaqy5cd&gt;&lt;div class=&quot;step-header&quot; data-astro-cid-wwaqy5cd&gt;&lt;span class=&quot;step-num&quot; data-astro-cid-wwaqy5cd&gt;1&lt;/span&gt;&lt;span class=&quot;step-title&quot; data-astro-cid-wwaqy5cd&gt;Spin up a container you don&amp;#39;t care about&lt;/span&gt;&lt;span class=&quot;step-time&quot; data-astro-cid-wwaqy5cd&gt;10 min&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;step-body&quot; data-astro-cid-wwaqy5cd&gt;&lt;p&gt;The demo lab wants to be the cheapest, most deletable thing you can make. On &lt;a href=&quot;https://pve.proxmox.com/wiki/Linux_Container&quot;&gt;Proxmox&lt;/a&gt; that’s an LXC container — I covered the general recipe in &lt;a href=&quot;https://peira.dev/blog/proxmox-create-lxc&quot;&gt;creating your first LXC container&lt;/a&gt;. A VM, a spare mini PC, or a cloud VPS all work equally well. The only requirements: Docker installed, reachable from the machine where you’ll run the capture script, and &lt;strong&gt;no connection to anything real&lt;/strong&gt;. No mounts from your NAS, no credentials from your password manager, no real DNS names.&lt;/p&gt;&lt;p&gt;That last part is the whole trick. Sanitizing screenshots after the fact means hunting for leaks in pixels; building the environment from dummy values means there’s nothing to hunt. Safety by construction beats safety by inspection.&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;
&lt;div class=&quot;step&quot; data-astro-cid-wwaqy5cd&gt;&lt;div class=&quot;step-header&quot; data-astro-cid-wwaqy5cd&gt;&lt;span class=&quot;step-num&quot; data-astro-cid-wwaqy5cd&gt;2&lt;/span&gt;&lt;span class=&quot;step-title&quot; data-astro-cid-wwaqy5cd&gt;Compose a small monitoring stack with a fake identity&lt;/span&gt;&lt;span class=&quot;step-time&quot; data-astro-cid-wwaqy5cd&gt;15 min&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;step-body&quot; data-astro-cid-wwaqy5cd&gt;&lt;p&gt;Inside the container, a single &lt;a href=&quot;https://docs.docker.com/compose/how-tos/networking/&quot;&gt;Docker Compose&lt;/a&gt; file runs the cast: &lt;a href=&quot;https://gethomepage.dev/&quot;&gt;Homepage&lt;/a&gt; as the dashboard, &lt;a href=&quot;https://grafana.com/docs/grafana/latest/&quot;&gt;Grafana&lt;/a&gt; with &lt;a href=&quot;https://prometheus.io/docs/prometheus/latest/getting_started/&quot;&gt;Prometheus&lt;/a&gt; behind it, and &lt;a href=&quot;https://github.com/louislam/uptime-kuma&quot;&gt;Uptime Kuma&lt;/a&gt; doing up/down checks — the same stack from my &lt;a href=&quot;https://peira.dev/blog/first-docker-compose-stack&quot;&gt;first Docker Compose stack&lt;/a&gt; walkthrough, wearing a costume.&lt;/p&gt;&lt;p&gt;Seed every config with the dummy identity and nothing else. Hostname labels like &lt;code&gt;pve-node1.homelab.lan&lt;/code&gt;, addresses from &lt;code&gt;10.0.0.x&lt;/code&gt;, the admin user &lt;code&gt;youradmin&lt;/code&gt;. When a Homepage widget wants a “server” to display, it gets a fake one. It feels silly typing in a made-up network — but that made-up network is exactly what makes every future capture publish-safe.&lt;/p&gt;&lt;p&gt;One detail pays off later: when Uptime Kuma needs to monitor its stack-mates, point it at &lt;strong&gt;Compose service names&lt;/strong&gt;, not &lt;code&gt;localhost&lt;/code&gt;. Compose puts every service on a shared network where each container is reachable by its service name through a built-in DNS — so a monitor URL like &lt;code&gt;http://homepage:3000&lt;/code&gt; works, survives container restarts, and reads perfectly in a screenshot. A &lt;code&gt;localhost&lt;/code&gt; URL, on the other hand, points at Kuma’s own container and quietly fails forever. I learned that the embarrassing way and wrote it up in &lt;a href=&quot;https://peira.dev/blog/docker-localhost-trap&quot;&gt;the Docker localhost trap&lt;/a&gt;.&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;
&lt;div class=&quot;step&quot; data-astro-cid-wwaqy5cd&gt;&lt;div class=&quot;step-header&quot; data-astro-cid-wwaqy5cd&gt;&lt;span class=&quot;step-num&quot; data-astro-cid-wwaqy5cd&gt;3&lt;/span&gt;&lt;span class=&quot;step-title&quot; data-astro-cid-wwaqy5cd&gt;Let it accrue real history&lt;/span&gt;&lt;span class=&quot;step-time&quot; data-astro-cid-wwaqy5cd&gt;17 min – 1 day&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;step-body&quot; data-astro-cid-wwaqy5cd&gt;&lt;p&gt;A freshly deployed dashboard looks freshly deployed: empty graphs, stubby uptime bars, suspicious perfection. The fix costs nothing — walk away.&lt;/p&gt;&lt;p&gt;Uptime Kuma builds each monitor’s heartbeat bar from its check history, so at a 20-second interval a full-width bar accrues in about 17 minutes. Grafana panels querying Prometheus want longer; I let the stack idle for a day so the 24-hour views show genuine curves with genuine wobble. This waiting is what separates “real screenshot” from “technically-not-a-mockup.” The pixels you’re waiting for can’t be faked quickly, and readers’ eyes know it.&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;task-2-capture-with-headless-chrome&quot;&gt;Task 2: Capture with headless Chrome&lt;/h2&gt;
&lt;p&gt;Screenshots by hand are a chore you’ll do once and never redo — and screenshots go stale every time a UI ships a redesign. So the captures are a script. Mine is checked into the site repo (&lt;code&gt;tools/screenshots/&lt;/code&gt;), and the shape of it is small enough to describe completely.&lt;/p&gt;
&lt;div class=&quot;step&quot; data-astro-cid-wwaqy5cd&gt;&lt;div class=&quot;step-header&quot; data-astro-cid-wwaqy5cd&gt;&lt;span class=&quot;step-num&quot; data-astro-cid-wwaqy5cd&gt;1&lt;/span&gt;&lt;span class=&quot;step-title&quot; data-astro-cid-wwaqy5cd&gt;Drive the Chrome you already have&lt;/span&gt;&lt;span class=&quot;step-time&quot; data-astro-cid-wwaqy5cd&gt;10 min&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;step-body&quot; data-astro-cid-wwaqy5cd&gt;&lt;p&gt;The script uses &lt;a href=&quot;https://pptr.dev/guides/installation&quot;&gt;Puppeteer&lt;/a&gt; — the Chrome team’s browser-automation library — in its &lt;code&gt;puppeteer-core&lt;/code&gt; flavor. The difference matters on a laptop: the full &lt;code&gt;puppeteer&lt;/code&gt; package downloads its own private copy of Chrome, while &lt;code&gt;puppeteer-core&lt;/code&gt; downloads nothing and drives a browser you point it at with an explicit executable path. Since my machine already has Chrome, the whole dependency is one small npm package.&lt;/p&gt;&lt;p&gt;Modern Chrome also makes the “headless” part boring, in the best way: headless and headful are now the same unified browser, not the separate stripped-down implementation of years past — so what the script captures is what a reader’s actual Chrome would render.&lt;/p&gt;&lt;figure class=&quot;terminal&quot; data-astro-cid-kenkipkp&gt;&lt;figcaption class=&quot;terminal-bar&quot; data-astro-cid-kenkipkp&gt;&lt;span class=&quot;dot red&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot yellow&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot green&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;terminal-title&quot; data-astro-cid-kenkipkp&gt;The launch, condensed from tools/screenshots/shots.mjs&lt;/span&gt;&lt;button class=&quot;terminal-copy&quot; type=&quot;button&quot; aria-label=&quot;Copy commands&quot; data-astro-cid-kenkipkp&gt;&lt;svg width=&quot;13&quot; height=&quot;13&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;none&quot; stroke=&quot;currentColor&quot; stroke-width=&quot;2&quot; stroke-linecap=&quot;round&quot; stroke-linejoin=&quot;round&quot; aria-hidden=&quot;true&quot; data-astro-cid-kenkipkp&gt;&lt;rect x=&quot;9&quot; y=&quot;9&quot; width=&quot;13&quot; height=&quot;13&quot; rx=&quot;2&quot; data-astro-cid-kenkipkp&gt;&lt;/rect&gt;&lt;path d=&quot;M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1&quot; data-astro-cid-kenkipkp&gt;&lt;/path&gt;&lt;/svg&gt;&lt;span class=&quot;terminal-copy-label&quot; data-astro-cid-kenkipkp&gt;Copy&lt;/span&gt;&lt;/button&gt;&lt;/figcaption&gt;&lt;pre class=&quot;terminal-body&quot; data-astro-cid-kenkipkp&gt;&lt;code data-astro-cid-kenkipkp&gt;
import puppeteer from &amp;#39;puppeteer-core&amp;#39;;

const browser = await puppeteer.launch({
executablePath: &amp;#39;/usr/bin/google-chrome&amp;#39;,
headless: &amp;#39;new&amp;#39;,
args: [&amp;#39;--no-sandbox&amp;#39;, &amp;#39;--disable-gpu&amp;#39;,
       &amp;#39;--force-device-scale-factor=2&amp;#39;, &amp;#39;--hide-scrollbars&amp;#39;],
});

const page = await browser.newPage();
await page.setViewport({ width: 1600, height: 700, deviceScaleFactor: 2 });
await page.goto(&amp;#39;http://$DEMO_HOST:3000/&amp;#39;, { waitUntil: &amp;#39;networkidle2&amp;#39; });
await new Promise(r =&amp;gt; setTimeout(r, 4000)); // widgets hydrate
await page.screenshot({ path: &amp;#39;homepage-dashboard.png&amp;#39; });
&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;p&gt;Two choices in there carry most of the quality:&lt;/p&gt;&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;deviceScaleFactor: 2&lt;/code&gt;&lt;/strong&gt; renders at double pixel density, so the PNG stays crisp when the site serves it on high-DPI displays.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The viewport is sized to the content, not to a monitor.&lt;/strong&gt; A 1000-pixel-tall capture of a 500-pixel-tall UI is half blank background inside the frame. My targets each get their own height — 700 for Homepage and Prometheus, 640 for Kuma, 1000 for a full Grafana dashboard.&lt;/li&gt;
&lt;/ul&gt;&lt;/div&gt;&lt;/div&gt;
&lt;div class=&quot;step&quot; data-astro-cid-wwaqy5cd&gt;&lt;div class=&quot;step-header&quot; data-astro-cid-wwaqy5cd&gt;&lt;span class=&quot;step-num&quot; data-astro-cid-wwaqy5cd&gt;2&lt;/span&gt;&lt;span class=&quot;step-title&quot; data-astro-cid-wwaqy5cd&gt;Script the login flows too&lt;/span&gt;&lt;span class=&quot;step-time&quot; data-astro-cid-wwaqy5cd&gt;15 min&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;step-body&quot; data-astro-cid-wwaqy5cd&gt;&lt;p&gt;Grafana and Uptime Kuma sit behind login screens, so the script types the demo credentials into the form fields, submits, waits for the app to settle, then navigates to the page worth photographing. For Grafana that’s a dashboard URL with an explicit time range (&lt;code&gt;from=now-24h&amp;amp;to=now&lt;/code&gt;) — pinning the range means re-running the script next month produces the same framing. Then a deliberate pause: Grafana needs several seconds to run its panel queries and draw, and a screenshot taken too eagerly captures spinners instead of graphs.&lt;/p&gt;&lt;p&gt;There’s a companion script for seeding Uptime Kuma itself (&lt;code&gt;kuma-admin.mjs&lt;/code&gt;), because clicking “add monitor” eight times per rebuilt demo lab gets old. Kuma has no REST API for this — its REST endpoints are read-only, and write access &lt;a href=&quot;https://github.com/louislam/uptime-kuma/issues/7151&quot;&gt;remains an open feature request&lt;/a&gt; — while the web UI talks to the server over &lt;a href=&quot;https://socket.io/docs/v4/client-api/&quot;&gt;socket.io&lt;/a&gt;. So the script speaks that protocol directly: connect, authenticate, emit &lt;code&gt;add&lt;/code&gt; events for each monitor. One hard-won detail from building it: the version I ran (Uptime Kuma 2) pushes its monitor list &lt;em&gt;during&lt;/em&gt; login, so a listener attached after login has already missed the event and waits forever. Attach first, then log in. That one cost me a hung script and a debugging loop.&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;
&lt;div class=&quot;step&quot; data-astro-cid-wwaqy5cd&gt;&lt;div class=&quot;step-header&quot; data-astro-cid-wwaqy5cd&gt;&lt;span class=&quot;step-num&quot; data-astro-cid-wwaqy5cd&gt;3&lt;/span&gt;&lt;span class=&quot;step-title&quot; data-astro-cid-wwaqy5cd&gt;Review, embed, destroy&lt;/span&gt;&lt;span class=&quot;step-time&quot; data-astro-cid-wwaqy5cd&gt;10 min&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;step-body&quot; data-astro-cid-wwaqy5cd&gt;&lt;p&gt;Before any capture is committed, three checks, in order:&lt;/p&gt;&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Eyeball every image at full size.&lt;/strong&gt; Not thumbnails — actual size. You’re looking for anything you didn’t deliberately plant: a hostname in a corner, a browser autofill, a notification toast.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Run the repo’s security scan&lt;/strong&gt;, which greps for the real subnets, usernames, and domains that must never appear. The scan is the backstop; the dummy-valued environment is why it comes up empty.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Confirm the visible URLs tell the dummy story.&lt;/strong&gt; Kuma’s monitor URLs read &lt;code&gt;http://homepage:3000&lt;/code&gt; — a service name, leaking nothing. The browser-chrome frame around each embedded image gets a hand-written dummy URL, never the capture address.&lt;/li&gt;
&lt;/ol&gt;&lt;p&gt;Then the images land in the post through a small &lt;code&gt;Screenshot&lt;/code&gt; component that wraps each one in a browser-style frame with a caption. And when the shot list is done, the demo lab is deleted. Rebuilding it later from the compose file and the seeding script takes minutes — the scripts &lt;em&gt;are&lt;/em&gt; the lab; the container is just where they ran last.&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;what-this-buys-you&quot;&gt;What this buys you&lt;/h2&gt;
&lt;p&gt;The pipeline sounds like ceremony for a few PNGs, so here’s the honest accounting. Cost: one small container, two short scripts, and some waiting. In return —&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Publish-safe by construction.&lt;/strong&gt; No real value ever entered the environment, so no review slip can leak one.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Repeatable.&lt;/strong&gt; UI redesigns stop being a re-screenshotting chore; rebuild the lab, run the script, get a fresh consistent set.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Honest.&lt;/strong&gt; Every capture is a working deployment with real history — if the screenshot exists, the tutorial ran.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;If you write about your own lab anywhere public — a blog, a wiki, even a Discord — the demo-lab pattern is worth stealing. Your future self, staring at a screenshot at midnight wondering whether that corner label was real, will thank you.&lt;/p&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;whats-next&quot;&gt;What’s next&lt;/h2&gt;
&lt;p&gt;The same throwaway-environment idea scales beyond screenshots: it’s a safe place to rehearse upgrades and test tutorials before running them against machines you care about. If you’re building the monitoring stack itself, start with &lt;a href=&quot;https://peira.dev/blog/uptime-kuma-monitoring&quot;&gt;Uptime Kuma&lt;/a&gt; and graduate to &lt;a href=&quot;https://peira.dev/blog/grafana-prometheus-proxmox&quot;&gt;Grafana and Prometheus&lt;/a&gt;.&lt;/p&gt;
&lt;hr/&gt;
&lt;p&gt;&lt;strong&gt;Related posts:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/uptime-kuma-monitoring&quot;&gt;Uptime Kuma: Dead-Simple Homelab Monitoring Before You Touch Grafana&lt;/a&gt; — the up/down monitor being photographed here, set up for real&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/docker-localhost-trap&quot;&gt;The Docker localhost Trap: 24 Hours of Red, Zero Real Downtime&lt;/a&gt; — the field note this demo lab produced by accident&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/first-docker-compose-stack&quot;&gt;Your First Docker Compose Stack: From Zero to a Running Service&lt;/a&gt; — the compose mental model the demo stack is built on&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/grafana-prometheus-proxmox&quot;&gt;Proxmox Monitoring with Prometheus and Grafana: Full Stack Setup&lt;/a&gt; — the deeper monitoring stack whose dashboards need a day of history&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/homepage-dashboard&quot;&gt;One Dashboard for Your Whole Homelab: Set Up Homepage&lt;/a&gt; — the dashboard front-and-center in most of the captures&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/proxmox-create-lxc&quot;&gt;Creating Your First Proxmox LXC Container: Step-by-Step&lt;/a&gt; — the ten-minute recipe for the throwaway container itself&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Sources: &lt;a href=&quot;https://pptr.dev/guides/installation&quot;&gt;Puppeteer — installation and puppeteer-core&lt;/a&gt;, &lt;a href=&quot;https://pptr.dev/api/puppeteer.page.setviewport&quot;&gt;Puppeteer — Page.setViewport&lt;/a&gt;, &lt;a href=&quot;https://developer.chrome.com/docs/chromium/headless&quot;&gt;Chrome headless mode (Chrome for Developers)&lt;/a&gt;, &lt;a href=&quot;https://docs.docker.com/compose/how-tos/networking/&quot;&gt;Docker Compose networking&lt;/a&gt;, &lt;a href=&quot;https://github.com/louislam/uptime-kuma&quot;&gt;Uptime Kuma (GitHub)&lt;/a&gt;, &lt;a href=&quot;https://socket.io/docs/v4/client-api/&quot;&gt;Socket.IO client API&lt;/a&gt;, &lt;a href=&quot;https://pve.proxmox.com/wiki/Linux_Container&quot;&gt;Proxmox Linux Container documentation&lt;/a&gt;.&lt;/p&gt;</content:encoded><category>monitoring</category><category>automation</category><category>self-hosted</category></item><item><title>The Docker localhost Trap: 24 Hours of Red, Zero Real Downtime</title><link>https://peira.dev/blog/docker-localhost-trap/</link><guid isPermaLink="true">https://peira.dev/blog/docker-localhost-trap/</guid><description>My Uptime Kuma monitors logged 24 hours of red while every service was up. Inside a Docker container, localhost isn&apos;t your machine — here&apos;s why, and the fix.</description><pubDate>Thu, 23 Jul 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;I did this one to myself, and I’m sharing it because it’s probably the single most common trap in self-hosted monitoring. I’d set up a little demo box — a &lt;a href=&quot;https://www.docker.com/&quot;&gt;Docker&lt;/a&gt; stack with a &lt;a href=&quot;https://gethomepage.dev/&quot;&gt;Homepage&lt;/a&gt; dashboard, &lt;a href=&quot;https://grafana.com/&quot;&gt;Grafana&lt;/a&gt;, &lt;a href=&quot;https://prometheus.io/&quot;&gt;Prometheus&lt;/a&gt;, and &lt;a href=&quot;https://github.com/louislam/uptime-kuma&quot;&gt;Uptime Kuma&lt;/a&gt; watching over them — purely so I could take clean screenshots for this site. I added the monitors, saw some red, figured things were still starting up, and moved on to other things.&lt;/p&gt;
&lt;p&gt;When I came back to the dashboard — a full day later — I found a perfect wall of red. Twenty-four hours of it. &lt;strong&gt;1,440 failed checks per monitor — one every minute, all night, every single one refused.&lt;/strong&gt; And here’s the part that makes this a story worth telling: every one of those “down” services was up. I could open each of them in my browser just fine.&lt;/p&gt;
&lt;p&gt;Nothing was broken. The monitors were simply asking the wrong machine.&lt;/p&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;the-symptom-red-that-never-blinks&quot;&gt;The symptom: red that never blinks&lt;/h2&gt;
&lt;p&gt;Three HTTP monitors — Homepage, Grafana, Prometheus — all showed the same thing: &lt;code&gt;connect ECONNREFUSED&lt;/code&gt;, which is the operating system’s blunt way of saying &lt;em&gt;“nothing is listening at that address and port.”&lt;/em&gt; Not a timeout, not a slow response. An instant, confident &lt;em&gt;nope&lt;/em&gt;, 1,440 times in a row.&lt;/p&gt;
&lt;div style=&quot;margin:1.5rem 0; border:1px solid #3d3d5c; border-radius:12px; overflow:hidden; box-shadow:var(--shadow-lg)&quot;&gt;&lt;svg viewBox=&quot;0 0 720 340&quot; width=&quot;100%&quot; role=&quot;img&quot; aria-label=&quot;A mock Uptime Kuma dashboard. Three monitors named Homepage, Grafana, and Prometheus show solid red heartbeat bars at zero percent with connection refused. A fourth ping monitor shows a solid green bar at one hundred percent. An annotation reads 1,440 checks, 24 hours, every one refused.&quot;&gt;&lt;defs&gt;&lt;linearGradient id=&quot;dlt-bg1&quot; x1=&quot;0&quot; y1=&quot;0&quot; x2=&quot;0&quot; y2=&quot;1&quot;&gt;&lt;stop offset=&quot;0&quot; stop-color=&quot;#242440&quot;&gt;&lt;/stop&gt;&lt;stop offset=&quot;1&quot; stop-color=&quot;#171730&quot;&gt;&lt;/stop&gt;&lt;/linearGradient&gt;&lt;pattern id=&quot;dlt-beats-red&quot; width=&quot;13&quot; height=&quot;22&quot; patternUnits=&quot;userSpaceOnUse&quot;&gt;&lt;rect x=&quot;0&quot; y=&quot;0&quot; width=&quot;9&quot; height=&quot;22&quot; rx=&quot;2&quot; fill=&quot;#ef4444&quot; opacity=&quot;0.85&quot;&gt;&lt;/rect&gt;&lt;/pattern&gt;&lt;pattern id=&quot;dlt-beats-green&quot; width=&quot;13&quot; height=&quot;22&quot; patternUnits=&quot;userSpaceOnUse&quot;&gt;&lt;rect x=&quot;0&quot; y=&quot;0&quot; width=&quot;9&quot; height=&quot;22&quot; rx=&quot;2&quot; fill=&quot;#22c55e&quot; opacity=&quot;0.85&quot;&gt;&lt;/rect&gt;&lt;/pattern&gt;&lt;/defs&gt;&lt;rect width=&quot;720&quot; height=&quot;340&quot; fill=&quot;url(#dlt-bg1)&quot;&gt;&lt;/rect&gt;&lt;rect x=&quot;0&quot; y=&quot;0&quot; width=&quot;720&quot; height=&quot;40&quot; fill=&quot;#262640&quot;&gt;&lt;/rect&gt;&lt;circle cx=&quot;22&quot; cy=&quot;20&quot; r=&quot;5&quot; fill=&quot;#ef4444&quot;&gt;&lt;/circle&gt;&lt;circle cx=&quot;40&quot; cy=&quot;20&quot; r=&quot;5&quot; fill=&quot;#f59e0b&quot;&gt;&lt;/circle&gt;&lt;circle cx=&quot;58&quot; cy=&quot;20&quot; r=&quot;5&quot; fill=&quot;#22c55e&quot;&gt;&lt;/circle&gt;&lt;rect x=&quot;80&quot; y=&quot;10&quot; width=&quot;420&quot; height=&quot;20&quot; rx=&quot;10&quot; fill=&quot;#0a0a14&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;92&quot; y=&quot;24&quot; fill=&quot;#9ca3af&quot; font-size=&quot;11&quot; font-family=&quot;monospace&quot;&gt;&lt;a href=&quot;http://monitor.homelab.lan:3001/dashboard&quot;&gt;http://monitor.homelab.lan:3001/dashboard&lt;/a&gt;&lt;/text&gt;&lt;text x=&quot;28&quot; y=&quot;70&quot; fill=&quot;#f4f4f5&quot; font-size=&quot;16&quot; font-weight=&quot;700&quot;&gt;Status — demo lab&lt;/text&gt;&lt;rect x=&quot;28&quot; y=&quot;86&quot; width=&quot;560&quot; height=&quot;44&quot; rx=&quot;8&quot; fill=&quot;#2c2c4c&quot; stroke=&quot;#3d3d5c&quot;&gt;&lt;/rect&gt;&lt;circle cx=&quot;48&quot; cy=&quot;108&quot; r=&quot;6&quot; fill=&quot;#ef4444&quot;&gt;&lt;/circle&gt;&lt;text x=&quot;64&quot; y=&quot;104&quot; fill=&quot;#f4f4f5&quot; font-size=&quot;12&quot; font-weight=&quot;600&quot;&gt;Homepage&lt;/text&gt;&lt;text x=&quot;64&quot; y=&quot;120&quot; fill=&quot;#ef4444&quot; font-size=&quot;10&quot; font-family=&quot;monospace&quot;&gt;0% · connect ECONNREFUSED&lt;/text&gt;&lt;rect x=&quot;256&quot; y=&quot;97&quot; width=&quot;318&quot; height=&quot;22&quot; rx=&quot;3&quot; fill=&quot;url(#dlt-beats-red)&quot;&gt;&lt;/rect&gt;&lt;rect x=&quot;28&quot; y=&quot;138&quot; width=&quot;560&quot; height=&quot;44&quot; rx=&quot;8&quot; fill=&quot;#2c2c4c&quot; stroke=&quot;#3d3d5c&quot;&gt;&lt;/rect&gt;&lt;circle cx=&quot;48&quot; cy=&quot;160&quot; r=&quot;6&quot; fill=&quot;#ef4444&quot;&gt;&lt;/circle&gt;&lt;text x=&quot;64&quot; y=&quot;156&quot; fill=&quot;#f4f4f5&quot; font-size=&quot;12&quot; font-weight=&quot;600&quot;&gt;Grafana&lt;/text&gt;&lt;text x=&quot;64&quot; y=&quot;172&quot; fill=&quot;#ef4444&quot; font-size=&quot;10&quot; font-family=&quot;monospace&quot;&gt;0% · connect ECONNREFUSED&lt;/text&gt;&lt;rect x=&quot;256&quot; y=&quot;149&quot; width=&quot;318&quot; height=&quot;22&quot; rx=&quot;3&quot; fill=&quot;url(#dlt-beats-red)&quot;&gt;&lt;/rect&gt;&lt;rect x=&quot;28&quot; y=&quot;190&quot; width=&quot;560&quot; height=&quot;44&quot; rx=&quot;8&quot; fill=&quot;#2c2c4c&quot; stroke=&quot;#3d3d5c&quot;&gt;&lt;/rect&gt;&lt;circle cx=&quot;48&quot; cy=&quot;212&quot; r=&quot;6&quot; fill=&quot;#ef4444&quot;&gt;&lt;/circle&gt;&lt;text x=&quot;64&quot; y=&quot;208&quot; fill=&quot;#f4f4f5&quot; font-size=&quot;12&quot; font-weight=&quot;600&quot;&gt;Prometheus&lt;/text&gt;&lt;text x=&quot;64&quot; y=&quot;224&quot; fill=&quot;#ef4444&quot; font-size=&quot;10&quot; font-family=&quot;monospace&quot;&gt;0% · connect ECONNREFUSED&lt;/text&gt;&lt;rect x=&quot;256&quot; y=&quot;201&quot; width=&quot;318&quot; height=&quot;22&quot; rx=&quot;3&quot; fill=&quot;url(#dlt-beats-red)&quot;&gt;&lt;/rect&gt;&lt;rect x=&quot;28&quot; y=&quot;242&quot; width=&quot;560&quot; height=&quot;44&quot; rx=&quot;8&quot; fill=&quot;#2c2c4c&quot; stroke=&quot;#3d3d5c&quot;&gt;&lt;/rect&gt;&lt;circle cx=&quot;48&quot; cy=&quot;264&quot; r=&quot;6&quot; fill=&quot;#22c55e&quot;&gt;&lt;/circle&gt;&lt;text x=&quot;64&quot; y=&quot;260&quot; fill=&quot;#f4f4f5&quot; font-size=&quot;12&quot; font-weight=&quot;600&quot;&gt;Ping&lt;/text&gt;&lt;text x=&quot;64&quot; y=&quot;276&quot; fill=&quot;#22c55e&quot; font-size=&quot;10&quot; font-family=&quot;monospace&quot;&gt;100% · up the whole time&lt;/text&gt;&lt;rect x=&quot;256&quot; y=&quot;253&quot; width=&quot;318&quot; height=&quot;22&quot; rx=&quot;3&quot; fill=&quot;url(#dlt-beats-green)&quot;&gt;&lt;/rect&gt;&lt;rect x=&quot;600&quot; y=&quot;120&quot; width=&quot;96&quot; height=&quot;120&quot; rx=&quot;8&quot; fill=&quot;#262640&quot; stroke=&quot;#f59e0b&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;648&quot; y=&quot;146&quot; text-anchor=&quot;middle&quot; fill=&quot;#f59e0b&quot; font-size=&quot;20&quot; font-weight=&quot;700&quot;&gt;1,440&lt;/text&gt;&lt;text x=&quot;648&quot; y=&quot;164&quot; text-anchor=&quot;middle&quot; fill=&quot;#a1a1aa&quot; font-size=&quot;10&quot;&gt;checks&lt;/text&gt;&lt;text x=&quot;648&quot; y=&quot;192&quot; text-anchor=&quot;middle&quot; fill=&quot;#f4f4f5&quot; font-size=&quot;13&quot; font-weight=&quot;600&quot;&gt;24 hours&lt;/text&gt;&lt;text x=&quot;648&quot; y=&quot;216&quot; text-anchor=&quot;middle&quot; fill=&quot;#ef4444&quot; font-size=&quot;10&quot;&gt;every one&lt;/text&gt;&lt;text x=&quot;648&quot; y=&quot;229&quot; text-anchor=&quot;middle&quot; fill=&quot;#ef4444&quot; font-size=&quot;10&quot;&gt;refused&lt;/text&gt;&lt;text x=&quot;28&quot; y=&quot;320&quot; fill=&quot;#6b7280&quot; font-size=&quot;11&quot;&gt;Meanwhile, in my browser: all four services loaded perfectly.&lt;/text&gt;&lt;/svg&gt;&lt;/div&gt;
&lt;p&gt;Two details in that picture turned out to be the whole diagnosis, though I didn’t see it right away:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;The red never blinked.&lt;/strong&gt; A service that’s genuinely struggling &lt;em&gt;flaps&lt;/em&gt; — up, down, up, slow, down. A bar that is solid red from the first second and never once succeeds is telling you the check itself can’t reach the target. That’s a different disease.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The ping monitor stayed green the whole time.&lt;/strong&gt; Same Uptime Kuma, same 24 hours, no problem. So Kuma wasn’t broken — only its HTTP checks were failing, and all of them the same way.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;the-wrong-guesses-first&quot;&gt;The wrong guesses first&lt;/h2&gt;
&lt;p&gt;I’ll be honest about the order I checked things in, because the wrong guesses are half the lesson.&lt;/p&gt;
&lt;p&gt;First guess: the services were down. Nope — each one opened instantly in my browser. Second guess: a firewall was blocking Kuma. But there was no firewall between these containers, and &lt;code&gt;ECONNREFUSED&lt;/code&gt; isn’t what a firewall usually looks like anyway — a dropped packet times out; &lt;em&gt;refused&lt;/em&gt; means something answered immediately and said “nothing here.”&lt;/p&gt;
&lt;p&gt;Then I finally looked properly at the monitor URLs I’d typed in: &lt;code&gt;http://localhost:3000&lt;/code&gt;, &lt;code&gt;http://localhost:9090&lt;/code&gt;… and it clicked. The question that solved it wasn’t &lt;em&gt;“is the service up?”&lt;/em&gt; It was &lt;em&gt;“who is asking?”&lt;/em&gt;&lt;/p&gt;
&lt;h2 id=&quot;the-real-cause-localhost-is-a-point-of-view&quot;&gt;The real cause: localhost is a point of view&lt;/h2&gt;
&lt;p&gt;My Uptime Kuma runs in a Docker container. A container isn’t just a process — it gets its own private network world, called a &lt;strong&gt;network namespace&lt;/strong&gt;: its own network interfaces, its own routing, its own ports, and crucially &lt;strong&gt;its own loopback address&lt;/strong&gt; (that’s what &lt;code&gt;localhost&lt;/code&gt; points to). This isolation is a kernel feature, and it’s exactly what the Linux &lt;a href=&quot;https://man7.org/linux/man-pages/man7/network_namespaces.7.html&quot;&gt;network namespaces man page&lt;/a&gt; says gets separated: network devices, IP stacks, routing tables, port numbers — all of it, per namespace.&lt;/p&gt;
&lt;p&gt;Docker’s own &lt;a href=&quot;https://docs.docker.com/engine/network/&quot;&gt;networking docs&lt;/a&gt; put it from the container’s side: a container only sees a network interface with an IP address, a gateway and DNS — it has no idea what’s outside, and it even gets its own &lt;code&gt;/etc/hosts&lt;/code&gt;, with its own entries for its own hostname and &lt;code&gt;localhost&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Think of the host machine as an apartment building and each container as an apartment. &lt;code&gt;localhost&lt;/code&gt; means “my own apartment.” When I told Kuma to check &lt;code&gt;http://localhost:3000&lt;/code&gt;, it didn’t walk down the hall to the Homepage apartment — it checked &lt;strong&gt;its own&lt;/strong&gt; port 3000, found nobody home, and reported exactly that. 1,440 times. It never once tested the thing I actually cared about.&lt;/p&gt;
&lt;div style=&quot;margin:1.5rem 0; border:1px solid #3d3d5c; border-radius:12px; overflow:hidden; box-shadow:var(--shadow-lg)&quot;&gt;&lt;svg viewBox=&quot;0 0 760 470&quot; width=&quot;100%&quot; role=&quot;img&quot; aria-label=&quot;Diagram of a Docker host containing two containers, Uptime Kuma and Homepage. A red arrow inside the Uptime Kuma container loops localhost port 3000 back to itself and fails with connection refused. A green arrow goes from Uptime Kuma across the shared Compose network to the Homepage container using the URL http homepage 3000. A second green arrow comes from a browser outside the host through the published port into Homepage.&quot;&gt;&lt;defs&gt;&lt;linearGradient id=&quot;dlt-bg2&quot; x1=&quot;0&quot; y1=&quot;0&quot; x2=&quot;0&quot; y2=&quot;1&quot;&gt;&lt;stop offset=&quot;0&quot; stop-color=&quot;#242440&quot;&gt;&lt;/stop&gt;&lt;stop offset=&quot;1&quot; stop-color=&quot;#171730&quot;&gt;&lt;/stop&gt;&lt;/linearGradient&gt;&lt;pattern id=&quot;dlt-dots&quot; width=&quot;22&quot; height=&quot;22&quot; patternUnits=&quot;userSpaceOnUse&quot;&gt;&lt;circle cx=&quot;1.5&quot; cy=&quot;1.5&quot; r=&quot;1.5&quot; fill=&quot;#3a3a60&quot;&gt;&lt;/circle&gt;&lt;/pattern&gt;&lt;marker id=&quot;dlt-arr-red&quot; markerWidth=&quot;8&quot; markerHeight=&quot;8&quot; refX=&quot;6&quot; refY=&quot;3&quot; orient=&quot;auto&quot;&gt;&lt;path d=&quot;M0,0 L6,3 L0,6 z&quot; fill=&quot;#ef4444&quot;&gt;&lt;/path&gt;&lt;/marker&gt;&lt;marker id=&quot;dlt-arr-green&quot; markerWidth=&quot;8&quot; markerHeight=&quot;8&quot; refX=&quot;6&quot; refY=&quot;3&quot; orient=&quot;auto&quot;&gt;&lt;path d=&quot;M0,0 L6,3 L0,6 z&quot; fill=&quot;#22c55e&quot;&gt;&lt;/path&gt;&lt;/marker&gt;&lt;/defs&gt;&lt;rect width=&quot;760&quot; height=&quot;470&quot; fill=&quot;url(#dlt-bg2)&quot;&gt;&lt;/rect&gt;&lt;rect width=&quot;760&quot; height=&quot;470&quot; fill=&quot;url(#dlt-dots)&quot; opacity=&quot;0.5&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;28&quot; y=&quot;38&quot; fill=&quot;#f4f4f5&quot; font-size=&quot;19&quot; font-weight=&quot;700&quot;&gt;“localhost” depends on who’s asking&lt;/text&gt;&lt;text x=&quot;28&quot; y=&quot;60&quot; fill=&quot;#a1a1aa&quot; font-size=&quot;13&quot;&gt;Every container has its own private loopback — its own localhost.&lt;/text&gt;&lt;rect x=&quot;200&quot; y=&quot;84&quot; width=&quot;532&quot; height=&quot;310&quot; rx=&quot;12&quot; fill=&quot;#30305a&quot; stroke=&quot;#4d4d82&quot; stroke-width=&quot;1.5&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;222&quot; y=&quot;376&quot; fill=&quot;#f4f4f5&quot; font-size=&quot;14&quot; font-weight=&quot;600&quot;&gt;Your server (Docker host)&lt;/text&gt;&lt;rect x=&quot;228&quot; y=&quot;128&quot; width=&quot;220&quot; height=&quot;130&quot; rx=&quot;10&quot; fill=&quot;#2c2c4c&quot; stroke=&quot;#14b8a6&quot; stroke-width=&quot;2&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;244&quot; y=&quot;152&quot; fill=&quot;#f4f4f5&quot; font-size=&quot;13&quot; font-weight=&quot;700&quot;&gt;uptime-kuma container&lt;/text&gt;&lt;text x=&quot;244&quot; y=&quot;170&quot; fill=&quot;#a1a1aa&quot; font-size=&quot;10.5&quot;&gt;its own namespace, its own localhost&lt;/text&gt;&lt;rect x=&quot;244&quot; y=&quot;182&quot; width=&quot;128&quot; height=&quot;20&quot; rx=&quot;4&quot; fill=&quot;#0a0a14&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;252&quot; y=&quot;196&quot; fill=&quot;#ef4444&quot; font-size=&quot;10&quot; font-family=&quot;monospace&quot;&gt;localhost:3000 ?&lt;/text&gt;&lt;path d=&quot;M 380 192 C 420 192 420 232 380 232 L 300 232&quot; fill=&quot;none&quot; stroke=&quot;#ef4444&quot; stroke-width=&quot;2&quot; stroke-dasharray=&quot;5 4&quot; marker-end=&quot;url(#dlt-arr-red)&quot;&gt;&lt;/path&gt;&lt;text x=&quot;296&quot; y=&quot;248&quot; fill=&quot;#ef4444&quot; font-size=&quot;10.5&quot; font-weight=&quot;600&quot;&gt;my own port 3000 — nobody home&lt;/text&gt;&lt;rect x=&quot;484&quot; y=&quot;128&quot; width=&quot;220&quot; height=&quot;130&quot; rx=&quot;10&quot; fill=&quot;#2c2c4c&quot; stroke=&quot;#818cf8&quot; stroke-width=&quot;2&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;500&quot; y=&quot;152&quot; fill=&quot;#f4f4f5&quot; font-size=&quot;13&quot; font-weight=&quot;700&quot;&gt;homepage container&lt;/text&gt;&lt;text x=&quot;500&quot; y=&quot;170&quot; fill=&quot;#a1a1aa&quot; font-size=&quot;10.5&quot;&gt;the service I meant to check&lt;/text&gt;&lt;rect x=&quot;500&quot; y=&quot;182&quot; width=&quot;150&quot; height=&quot;20&quot; rx=&quot;4&quot; fill=&quot;#0a0a14&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;508&quot; y=&quot;196&quot; fill=&quot;#22c55e&quot; font-size=&quot;10&quot; font-family=&quot;monospace&quot;&gt;listening on :3000&lt;/text&gt;&lt;rect x=&quot;228&quot; y=&quot;300&quot; width=&quot;476&quot; height=&quot;40&quot; rx=&quot;8&quot; fill=&quot;#262640&quot; stroke=&quot;#3d3d5c&quot; stroke-width=&quot;1.5&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;466&quot; y=&quot;325&quot; text-anchor=&quot;middle&quot; fill=&quot;#e5e7eb&quot; font-size=&quot;12&quot;&gt;shared Compose network · built-in DNS knows every service by name&lt;/text&gt;&lt;path d=&quot;M 300 258 L 300 300&quot; fill=&quot;none&quot; stroke=&quot;#22c55e&quot; stroke-width=&quot;2.5&quot;&gt;&lt;/path&gt;&lt;path d=&quot;M 594 300 L 594 262&quot; fill=&quot;none&quot; stroke=&quot;#22c55e&quot; stroke-width=&quot;2.5&quot; marker-end=&quot;url(#dlt-arr-green)&quot;&gt;&lt;/path&gt;&lt;rect x=&quot;330&quot; y=&quot;268&quot; width=&quot;196&quot; height=&quot;22&quot; rx=&quot;11&quot; fill=&quot;#0f2b1e&quot; stroke=&quot;#22c55e&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;428&quot; y=&quot;283&quot; text-anchor=&quot;middle&quot; fill=&quot;#22c55e&quot; font-size=&quot;11&quot; font-family=&quot;monospace&quot;&gt;&lt;a href=&quot;http://homepage:3000&quot;&gt;http://homepage:3000&lt;/a&gt; ✓&lt;/text&gt;&lt;rect x=&quot;28&quot; y=&quot;150&quot; width=&quot;120&quot; height=&quot;76&quot; rx=&quot;10&quot; fill=&quot;#2c2c4c&quot; stroke=&quot;#3d3d5c&quot; stroke-width=&quot;1.5&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;88&quot; y=&quot;182&quot; text-anchor=&quot;middle&quot; fill=&quot;#f4f4f5&quot; font-size=&quot;12&quot; font-weight=&quot;600&quot;&gt;your browser&lt;/text&gt;&lt;text x=&quot;88&quot; y=&quot;200&quot; text-anchor=&quot;middle&quot; fill=&quot;#a1a1aa&quot; font-size=&quot;10&quot;&gt;outside the host&lt;/text&gt;&lt;path d=&quot;M 148 188 L 200 188&quot; fill=&quot;none&quot; stroke=&quot;#22c55e&quot; stroke-width=&quot;2.5&quot;&gt;&lt;/path&gt;&lt;path d=&quot;M 200 188 C 230 100 500 96 594 122&quot; fill=&quot;none&quot; stroke=&quot;#22c55e&quot; stroke-width=&quot;2.5&quot; marker-end=&quot;url(#dlt-arr-green)&quot;&gt;&lt;/path&gt;&lt;text x=&quot;290&quot; y=&quot;90&quot; fill=&quot;#22c55e&quot; font-size=&quot;10.5&quot;&gt;published port 3000 ✓&lt;/text&gt;&lt;circle cx=&quot;240&quot; cy=&quot;432&quot; r=&quot;6&quot; fill=&quot;#ef4444&quot;&gt;&lt;/circle&gt;&lt;text x=&quot;254&quot; y=&quot;436&quot; fill=&quot;#a1a1aa&quot; font-size=&quot;11&quot;&gt;localhost from inside a container: loops back to itself&lt;/text&gt;&lt;circle cx=&quot;240&quot; cy=&quot;454&quot; r=&quot;6&quot; fill=&quot;#22c55e&quot;&gt;&lt;/circle&gt;&lt;text x=&quot;254&quot; y=&quot;458&quot; fill=&quot;#a1a1aa&quot; font-size=&quot;11&quot;&gt;service name on the shared network, or a published port: reaches the real thing&lt;/text&gt;&lt;/svg&gt;&lt;/div&gt;
&lt;p&gt;And this explains the browser paradox too. When &lt;em&gt;I&lt;/em&gt; opened &lt;code&gt;http://my-server:3000&lt;/code&gt;, the request hit the &lt;strong&gt;host’s&lt;/strong&gt; port 3000, which Docker had published — mapped through to the Homepage container. My browser and my monitor were typing nearly the same URL and asking two completely different machines.&lt;/p&gt;
&lt;div class=&quot;callout callout-warning&quot; data-astro-cid-q2ml7llr&gt;&lt;div class=&quot;callout-header&quot; data-astro-cid-q2ml7llr&gt;&lt;span class=&quot;callout-icon&quot; aria-hidden=&quot;true&quot; data-astro-cid-q2ml7llr&gt;⚠️&lt;/span&gt;&lt;span class=&quot;callout-label&quot; data-astro-cid-q2ml7llr&gt;The rule in one sentence&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;callout-body&quot; data-astro-cid-q2ml7llr&gt;&lt;p&gt;Inside a container, &lt;code&gt;localhost&lt;/code&gt; is that container. Your monitor doesn’t check from where &lt;em&gt;you&lt;/em&gt; sit — it checks from where &lt;em&gt;it&lt;/em&gt; sits, and every URL you give it is resolved from that vantage point.&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;
&lt;h2 id=&quot;the-fix-call-containers-by-name&quot;&gt;The fix: call containers by name&lt;/h2&gt;
&lt;p&gt;The right fix was almost embarrassingly small. &lt;a href=&quot;https://docs.docker.com/compose/how-tos/networking/&quot;&gt;Docker Compose&lt;/a&gt; already puts every service in a project on a shared private network, and Docker runs a little built-in DNS server on networks like it — so each service is reachable by its &lt;strong&gt;service name&lt;/strong&gt;, and the monitor URLs just become the names from the compose file.&lt;/p&gt;
&lt;div class=&quot;callout callout-warning&quot; data-astro-cid-q2ml7llr&gt;&lt;div class=&quot;callout-header&quot; data-astro-cid-q2ml7llr&gt;&lt;span class=&quot;callout-icon&quot; aria-hidden=&quot;true&quot; data-astro-cid-q2ml7llr&gt;⚠️&lt;/span&gt;&lt;span class=&quot;callout-label&quot; data-astro-cid-q2ml7llr&gt;First: make these values your own&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;callout-body&quot; data-astro-cid-q2ml7llr&gt;&lt;p&gt;The examples below use placeholder values — swap them for yours: &lt;code&gt;homepage&lt;/code&gt;, &lt;code&gt;grafana&lt;/code&gt;, &lt;code&gt;prometheus&lt;/code&gt;, and &lt;code&gt;uptime-kuma&lt;/code&gt; are &lt;strong&gt;service names from my compose file&lt;/strong&gt; (use whatever yours are called); &lt;code&gt;3000&lt;/code&gt;, &lt;code&gt;3001&lt;/code&gt;, and &lt;code&gt;9090&lt;/code&gt; are those services’ &lt;strong&gt;internal ports&lt;/strong&gt;; &lt;code&gt;demo_default&lt;/code&gt; is a &lt;strong&gt;Compose network name&lt;/strong&gt; — Compose names it after your project folder, so check &lt;code&gt;docker network ls&lt;/code&gt; for yours; &lt;code&gt;monitor.homelab.lan&lt;/code&gt; and &lt;code&gt;my-server&lt;/code&gt; are stand-ins for &lt;strong&gt;your machine’s hostname or IP&lt;/strong&gt;. The rule of thumb: if a value looks specific to one machine, it’s a placeholder to change — not a literal to copy.&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Before (broken)&lt;/th&gt;
&lt;th&gt;After (works)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;http://localhost:3000&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;http://homepage:3000&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;http://localhost:9090&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;http://prometheus:9090&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;http://localhost:&amp;lt;published port&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;http://grafana:3000&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The Grafana row deserves a second look: in your browser you use whatever &lt;em&gt;published&lt;/em&gt; host port you mapped it to, but by name you use the port the service listens on &lt;strong&gt;inside&lt;/strong&gt; its container — Grafana’s default is 3000, the same as Homepage’s, which is exactly why the two can’t both have host port 3000 and one gets remapped. The two vantage points really are different worlds, ports included.&lt;/p&gt;
&lt;p&gt;Within minutes of the change, all three monitors went green. I set the check interval to 20 seconds — the minimum Uptime Kuma allows — purely to rebuild a respectable-looking history bar faster, and the wall of red scrolled away.&lt;/p&gt;
&lt;p&gt;You can prove the whole thing to yourself in two commands, no Kuma required. From the host, ask from a &lt;em&gt;container’s&lt;/em&gt; vantage point — first the wrong way, then the right way:&lt;/p&gt;
&lt;figure class=&quot;terminal&quot; data-astro-cid-kenkipkp&gt;&lt;figcaption class=&quot;terminal-bar&quot; data-astro-cid-kenkipkp&gt;&lt;span class=&quot;dot red&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot yellow&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot green&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;terminal-title&quot; data-astro-cid-kenkipkp&gt;Reproduce the trap, then the fix (run on the Docker host)&lt;/span&gt;&lt;button class=&quot;terminal-copy&quot; type=&quot;button&quot; aria-label=&quot;Copy commands&quot; data-astro-cid-kenkipkp&gt;&lt;svg width=&quot;13&quot; height=&quot;13&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;none&quot; stroke=&quot;currentColor&quot; stroke-width=&quot;2&quot; stroke-linecap=&quot;round&quot; stroke-linejoin=&quot;round&quot; aria-hidden=&quot;true&quot; data-astro-cid-kenkipkp&gt;&lt;rect x=&quot;9&quot; y=&quot;9&quot; width=&quot;13&quot; height=&quot;13&quot; rx=&quot;2&quot; data-astro-cid-kenkipkp&gt;&lt;/rect&gt;&lt;path d=&quot;M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1&quot; data-astro-cid-kenkipkp&gt;&lt;/path&gt;&lt;/svg&gt;&lt;span class=&quot;terminal-copy-label&quot; data-astro-cid-kenkipkp&gt;Copy&lt;/span&gt;&lt;/button&gt;&lt;/figcaption&gt;&lt;pre class=&quot;terminal-body&quot; data-astro-cid-kenkipkp&gt;&lt;code data-astro-cid-kenkipkp&gt;
# The trap: from inside a fresh container, &amp;quot;localhost&amp;quot; is that container.
# This fails with &amp;quot;Connection refused&amp;quot; even while the service works in your browser:
docker run --rm busybox wget -qO- http://localhost:3000

# The fix: join the compose project&amp;#39;s network and use the service name.
# Find the network name first with: docker network ls
docker run --rm --network demo_default busybox wget -qO- http://homepage:3000
&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;
&lt;p&gt;One more habit from that morning that I’d recommend: &lt;strong&gt;before deleting the broken monitors, I checked their history.&lt;/strong&gt; All 1,440 beats were failures — there was nothing worth keeping — but if the monitor had &lt;em&gt;ever&lt;/em&gt; been green, that history is evidence, and deleting it mid-mystery destroys your timeline.&lt;/p&gt;
&lt;h2 id=&quot;when-the-target-really-is-on-the-host&quot;&gt;When the target really is on the host&lt;/h2&gt;
&lt;p&gt;Sometimes the thing you want to monitor genuinely runs on the host machine itself — not in a container. From inside a container, &lt;code&gt;localhost&lt;/code&gt; still can’t see it, but Docker gives you a doorway: the special hostname &lt;code&gt;host.docker.internal&lt;/code&gt;. On Docker Desktop it resolves automatically; on a Linux server you wire it up with the special &lt;code&gt;host-gateway&lt;/code&gt; value, which Docker’s &lt;a href=&quot;https://docs.docker.com/reference/cli/docker/container/run/#add-host&quot;&gt;run reference&lt;/a&gt; maps to the host’s internal IP:&lt;/p&gt;
&lt;figure class=&quot;terminal&quot; data-astro-cid-kenkipkp&gt;&lt;figcaption class=&quot;terminal-bar&quot; data-astro-cid-kenkipkp&gt;&lt;span class=&quot;dot red&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot yellow&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot green&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;terminal-title&quot; data-astro-cid-kenkipkp&gt;Give a container a route to its own host (Compose)&lt;/span&gt;&lt;button class=&quot;terminal-copy&quot; type=&quot;button&quot; aria-label=&quot;Copy commands&quot; data-astro-cid-kenkipkp&gt;&lt;svg width=&quot;13&quot; height=&quot;13&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;none&quot; stroke=&quot;currentColor&quot; stroke-width=&quot;2&quot; stroke-linecap=&quot;round&quot; stroke-linejoin=&quot;round&quot; aria-hidden=&quot;true&quot; data-astro-cid-kenkipkp&gt;&lt;rect x=&quot;9&quot; y=&quot;9&quot; width=&quot;13&quot; height=&quot;13&quot; rx=&quot;2&quot; data-astro-cid-kenkipkp&gt;&lt;/rect&gt;&lt;path d=&quot;M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1&quot; data-astro-cid-kenkipkp&gt;&lt;/path&gt;&lt;/svg&gt;&lt;span class=&quot;terminal-copy-label&quot; data-astro-cid-kenkipkp&gt;Copy&lt;/span&gt;&lt;/button&gt;&lt;/figcaption&gt;&lt;pre class=&quot;terminal-body&quot; data-astro-cid-kenkipkp&gt;&lt;code data-astro-cid-kenkipkp&gt;
services:
uptime-kuma:
  image: louislam/uptime-kuma:2
  extra_hosts:
    - &amp;quot;host.docker.internal:host-gateway&amp;quot;
&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;
&lt;p&gt;Now a monitor URL like &lt;code&gt;http://host.docker.internal:8080&lt;/code&gt; reaches port 8080 &lt;em&gt;on the host&lt;/em&gt;. Your quick decision table:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Where the target lives&lt;/th&gt;
&lt;th&gt;What to put in the monitor URL&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Another container, same Compose project&lt;/td&gt;
&lt;td&gt;The &lt;strong&gt;service name&lt;/strong&gt; — &lt;code&gt;http://homepage:3000&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A process on the Docker host itself&lt;/td&gt;
&lt;td&gt;&lt;code&gt;host.docker.internal&lt;/code&gt; (with &lt;code&gt;host-gateway&lt;/code&gt; on Linux)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Another machine on your network&lt;/td&gt;
&lt;td&gt;That machine’s &lt;strong&gt;LAN IP or hostname&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The container itself (rarely what you want)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;localhost&lt;/code&gt; — the only case where it’s right&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;There’s also a blunter instrument: running the container with &lt;a href=&quot;https://docs.docker.com/engine/network/drivers/host/&quot;&gt;host networking&lt;/a&gt; (&lt;code&gt;network_mode: host&lt;/code&gt;), which removes the network isolation entirely so the container shares the host’s network — then &lt;code&gt;localhost&lt;/code&gt; really is the host. It works (on Linux, and on Docker Desktop 4.34+ with the setting enabled), but you give up the isolation and the tidy per-project DNS that make Compose networking pleasant. I’d reach for service names first every time.&lt;/p&gt;
&lt;h2 id=&quot;what-the-wall-of-red-taught-me&quot;&gt;What the wall of red taught me&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;A monitor’s location is part of the check.&lt;/strong&gt; “Is it up?” is really “is it reachable &lt;em&gt;from here&lt;/em&gt;?” — and &lt;em&gt;here&lt;/em&gt; was inside a container I’d never thought about. When a check disagrees with your browser, believe both: they’re honestly reporting from two different places.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Solid red has a signature.&lt;/strong&gt; Real outages flap. A bar that has never once been green, failing instantly with &lt;code&gt;connection refused&lt;/code&gt;, means the checker can’t reach the target — go read the monitor URL before you go debug the service.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The trap wears many hats.&lt;/strong&gt; The same mistake breaks &lt;a href=&quot;https://peira.dev/blog/arr-stack-docker-deploy/&quot;&gt;Arr apps&lt;/a&gt; pointed at &lt;code&gt;localhost&lt;/code&gt; download clients, healthchecks, and any two containers you’ve told to find each other at &lt;code&gt;127.0.0.1&lt;/code&gt;. Once you’ve been bitten, you’ll recognize it everywhere — that’s the good news.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Monitoring the monitor matters.&lt;/strong&gt; My 24 hours of false alarms were harmless because this was a demo box. On a real lab, a monitor checking the wrong thing is worse than no monitor — it’s confidence you haven’t earned. My &lt;a href=&quot;https://peira.dev/blog/self-healing-arr-stack/&quot;&gt;self-healing Arr stack&lt;/a&gt; post walks the opposite failure: automation that reported fine while doing nothing.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;whats-next&quot;&gt;What’s next&lt;/h2&gt;
&lt;p&gt;If you don’t have monitoring yet, start with the &lt;a href=&quot;https://peira.dev/blog/uptime-kuma-monitoring/&quot;&gt;Uptime Kuma guide&lt;/a&gt; — it’s a five-minute setup, and now you know the one mistake to avoid on day one. If containers-finding-each-other still feels fuzzy, &lt;a href=&quot;https://peira.dev/blog/first-docker-compose-stack/&quot;&gt;your first Docker Compose stack&lt;/a&gt; builds that mental model gently, and the &lt;a href=&quot;https://peira.dev/blog/homepage-dashboard/&quot;&gt;Homepage dashboard guide&lt;/a&gt; is a nice place to practice service-name URLs on purpose.&lt;/p&gt;
&lt;hr/&gt;
&lt;p&gt;&lt;strong&gt;Related posts:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/uptime-kuma-monitoring/&quot;&gt;Uptime Kuma: Dead-Simple Homelab Monitoring Before You Touch Grafana&lt;/a&gt; — the tool in this story, set up right from the start&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/first-docker-compose-stack/&quot;&gt;Your First Docker Compose Stack&lt;/a&gt; — the mental model for services, networks, and why names beat IPs&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/docker-proxmox-lxc-vs-vm/&quot;&gt;Docker in Proxmox: LXC or VM?&lt;/a&gt; — where your Docker host itself should live&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/homepage-dashboard/&quot;&gt;Homepage: One Dashboard for Your Whole Homelab&lt;/a&gt; — the service my broken monitor was supposed to be watching&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/grafana-prometheus-proxmox/&quot;&gt;Grafana + Prometheus on Proxmox&lt;/a&gt; — the deeper monitoring stack, once up/down isn’t enough&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/self-healing-arr-stack/&quot;&gt;The Self-Healing Arr Stack: Why My Watchlist Silently Stopped Downloading&lt;/a&gt; — another field note about automation that fails silently&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/demo-lab-screenshots-headless-chrome/&quot;&gt;Real Screenshots, Zero Leaks: My Disposable Demo Lab&lt;/a&gt; — the demo box from this story, and the screenshot pipeline it was built for&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Sources: &lt;a href=&quot;https://docs.docker.com/engine/network/&quot;&gt;Docker networking documentation&lt;/a&gt;, &lt;a href=&quot;https://docs.docker.com/compose/how-tos/networking/&quot;&gt;Docker Compose networking&lt;/a&gt;, &lt;a href=&quot;https://docs.docker.com/reference/cli/docker/container/run/#add-host&quot;&gt;docker run reference — &lt;code&gt;--add-host&lt;/code&gt;&lt;/a&gt;, &lt;a href=&quot;https://docs.docker.com/engine/network/drivers/host/&quot;&gt;Docker host network driver&lt;/a&gt;, &lt;a href=&quot;https://man7.org/linux/man-pages/man7/network_namespaces.7.html&quot;&gt;network_namespaces(7)&lt;/a&gt;, &lt;a href=&quot;https://github.com/louislam/uptime-kuma&quot;&gt;Uptime Kuma (GitHub)&lt;/a&gt;.&lt;/p&gt;</content:encoded><category>monitoring</category><category>self-hosted</category></item><item><title>One UPS, Whole Homelab: Safe Auto-Shutdown with NUT</title><link>https://peira.dev/blog/nut-ups-safe-shutdown/</link><guid isPermaLink="true">https://peira.dev/blog/nut-ups-safe-shutdown/</guid><description>Use Network UPS Tools (NUT) so a single UPS can cleanly shut down every machine in your homelab when the power fails — one server, many network clients, step by step.</description><pubDate>Wed, 22 Jul 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;The power flickers, the lights come back, and you go check on the lab — one Proxmox node is fscking a corrupted root, a VM won’t boot, and your NAS is resilvering. A &lt;a href=&quot;https://en.wikipedia.org/wiki/Uninterruptible_power_supply&quot;&gt;UPS&lt;/a&gt; (uninterruptible power supply — a battery that carries your gear through a blip or gives you a few minutes to shut down) stops the hard yank, but the battery only lasts a few minutes. If nobody is home to run &lt;code&gt;shutdown&lt;/code&gt;, those minutes run out and everything drops at once anyway.&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://networkupstools.org/&quot;&gt;Network UPS Tools&lt;/a&gt; (NUT) turns those minutes into an automatic, orderly shutdown of your &lt;strong&gt;entire&lt;/strong&gt; homelab — from a single UPS with a single data cable. This guide wires up one machine as the NUT server and the rest as network clients, so a power cut ends with a clean shutdown of every box instead of a filesystem check in the morning.&lt;/p&gt;
&lt;div style=&quot;margin:1.5rem 0; border:1px solid #3d3d5c; border-radius:12px; overflow:hidden; box-shadow:var(--shadow-lg)&quot;&gt;&lt;svg viewBox=&quot;0 0 760 430&quot; width=&quot;100%&quot; role=&quot;img&quot; aria-label=&quot;One UPS is connected by a USB data cable to the primary NUT server. The primary shares UPS status over the LAN to secondary NUT clients on other nodes and the NAS. Power flows from the UPS to every machine.&quot;&gt;&lt;defs&gt;&lt;linearGradient id=&quot;nut-bg&quot; x1=&quot;0&quot; y1=&quot;0&quot; x2=&quot;0&quot; y2=&quot;1&quot;&gt;&lt;stop offset=&quot;0&quot; stop-color=&quot;#242440&quot;&gt;&lt;/stop&gt;&lt;stop offset=&quot;1&quot; stop-color=&quot;#171730&quot;&gt;&lt;/stop&gt;&lt;/linearGradient&gt;&lt;pattern id=&quot;nut-dots&quot; width=&quot;22&quot; height=&quot;22&quot; patternUnits=&quot;userSpaceOnUse&quot;&gt;&lt;circle cx=&quot;1.5&quot; cy=&quot;1.5&quot; r=&quot;1.5&quot; fill=&quot;#3a3a60&quot;&gt;&lt;/circle&gt;&lt;/pattern&gt;&lt;/defs&gt;&lt;rect width=&quot;760&quot; height=&quot;430&quot; fill=&quot;url(#nut-bg)&quot;&gt;&lt;/rect&gt;&lt;rect width=&quot;760&quot; height=&quot;430&quot; fill=&quot;url(#nut-dots)&quot; opacity=&quot;0.5&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;28&quot; y=&quot;40&quot; fill=&quot;#f4f4f5&quot; font-size=&quot;19&quot; font-weight=&quot;700&quot;&gt;One UPS protects the whole cluster&lt;/text&gt;&lt;text x=&quot;28&quot; y=&quot;62&quot; fill=&quot;#a1a1aa&quot; font-size=&quot;13&quot;&gt;One data cable. One machine manages the UPS; the rest listen over the network.&lt;/text&gt;&lt;rect x=&quot;30&quot; y=&quot;150&quot; width=&quot;140&quot; height=&quot;150&quot; rx=&quot;10&quot; fill=&quot;#30305a&quot; stroke=&quot;#4d4d82&quot; stroke-width=&quot;1.5&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;100&quot; y=&quot;180&quot; text-anchor=&quot;middle&quot; fill=&quot;#f4f4f5&quot; font-size=&quot;15&quot; font-weight=&quot;600&quot;&gt;UPS&lt;/text&gt;&lt;rect x=&quot;52&quot; y=&quot;196&quot; width=&quot;96&quot; height=&quot;18&quot; rx=&quot;4&quot; fill=&quot;#0a0a14&quot;&gt;&lt;/rect&gt;&lt;rect x=&quot;54&quot; y=&quot;198&quot; width=&quot;70&quot; height=&quot;14&quot; rx=&quot;2&quot; fill=&quot;#22c55e&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;100&quot; y=&quot;234&quot; text-anchor=&quot;middle&quot; fill=&quot;#a1a1aa&quot; font-size=&quot;11&quot;&gt;battery&lt;/text&gt;&lt;text x=&quot;100&quot; y=&quot;286&quot; text-anchor=&quot;middle&quot; fill=&quot;#9ca3af&quot; font-size=&quot;10&quot;&gt;230V mains in&lt;/text&gt;&lt;rect x=&quot;300&quot; y=&quot;70&quot; width=&quot;230&quot; height=&quot;96&quot; rx=&quot;10&quot; fill=&quot;#2c2c4c&quot; stroke=&quot;#14b8a6&quot; stroke-width=&quot;2&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;315&quot; y=&quot;98&quot; fill=&quot;#f4f4f5&quot; font-size=&quot;15&quot; font-weight=&quot;700&quot;&gt;Node 1 — NUT server&lt;/text&gt;&lt;text x=&quot;315&quot; y=&quot;118&quot; fill=&quot;#14b8a6&quot; font-size=&quot;12&quot; font-weight=&quot;600&quot;&gt;role: primary&lt;/text&gt;&lt;text x=&quot;315&quot; y=&quot;140&quot; fill=&quot;#a1a1aa&quot; font-size=&quot;11&quot;&gt;usbhid-ups → upsd → upsmon&lt;/text&gt;&lt;text x=&quot;315&quot; y=&quot;156&quot; fill=&quot;#6b7280&quot; font-size=&quot;10&quot;&gt;the one machine wired to the UPS&lt;/text&gt;&lt;rect x=&quot;330&quot; y=&quot;238&quot; width=&quot;170&quot; height=&quot;40&quot; rx=&quot;6&quot; fill=&quot;#262640&quot; stroke=&quot;#3d3d5c&quot; stroke-width=&quot;1.5&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;415&quot; y=&quot;263&quot; text-anchor=&quot;middle&quot; fill=&quot;#e5e7eb&quot; font-size=&quot;12&quot;&gt;LAN switch&lt;/text&gt;&lt;rect x=&quot;560&quot; y=&quot;150&quot; width=&quot;175&quot; height=&quot;72&quot; rx=&quot;10&quot; fill=&quot;#2c2c4c&quot; stroke=&quot;#818cf8&quot; stroke-width=&quot;1.5&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;573&quot; y=&quot;176&quot; fill=&quot;#f4f4f5&quot; font-size=&quot;14&quot; font-weight=&quot;600&quot;&gt;Node 2&lt;/text&gt;&lt;text x=&quot;573&quot; y=&quot;196&quot; fill=&quot;#818cf8&quot; font-size=&quot;11.5&quot;&gt;NUT client · secondary&lt;/text&gt;&lt;text x=&quot;573&quot; y=&quot;212&quot; fill=&quot;#6b7280&quot; font-size=&quot;10&quot;&gt;upsmon only&lt;/text&gt;&lt;rect x=&quot;560&quot; y=&quot;300&quot; width=&quot;175&quot; height=&quot;72&quot; rx=&quot;10&quot; fill=&quot;#2c2c4c&quot; stroke=&quot;#818cf8&quot; stroke-width=&quot;1.5&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;573&quot; y=&quot;326&quot; fill=&quot;#f4f4f5&quot; font-size=&quot;14&quot; font-weight=&quot;600&quot;&gt;NAS&lt;/text&gt;&lt;text x=&quot;573&quot; y=&quot;346&quot; fill=&quot;#818cf8&quot; font-size=&quot;11.5&quot;&gt;NUT client · secondary&lt;/text&gt;&lt;text x=&quot;573&quot; y=&quot;362&quot; fill=&quot;#6b7280&quot; font-size=&quot;10&quot;&gt;upsmon only&lt;/text&gt;&lt;path d=&quot;M170 180 L300 110&quot; stroke=&quot;#14b8a6&quot; stroke-width=&quot;2.5&quot; fill=&quot;none&quot;&gt;&lt;/path&gt;&lt;text x=&quot;205&quot; y=&quot;135&quot; fill=&quot;#14b8a6&quot; font-size=&quot;11&quot; font-weight=&quot;600&quot; transform=&quot;rotate(-27 205 135)&quot;&gt;USB data&lt;/text&gt;&lt;path d=&quot;M170 250 L170 336 L560 336&quot; stroke=&quot;#e0562b&quot; stroke-width=&quot;3&quot; fill=&quot;none&quot; opacity=&quot;0.9&quot;&gt;&lt;/path&gt;&lt;path d=&quot;M170 250 L170 205 L295 120&quot; stroke=&quot;#e0562b&quot; stroke-width=&quot;3&quot; fill=&quot;none&quot; opacity=&quot;0.9&quot;&gt;&lt;/path&gt;&lt;path d=&quot;M180 260 L180 186 L560 186&quot; stroke=&quot;#e0562b&quot; stroke-width=&quot;3&quot; fill=&quot;none&quot; opacity=&quot;0.9&quot;&gt;&lt;/path&gt;&lt;text x=&quot;196&quot; y=&quot;330&quot; fill=&quot;#e0562b&quot; font-size=&quot;11&quot; font-weight=&quot;600&quot;&gt;power to every machine&lt;/text&gt;&lt;path d=&quot;M415 166 L415 238&quot; stroke=&quot;#818cf8&quot; stroke-width=&quot;2&quot; fill=&quot;none&quot; stroke-dasharray=&quot;5 4&quot;&gt;&lt;/path&gt;&lt;path d=&quot;M500 250 L560 190&quot; stroke=&quot;#818cf8&quot; stroke-width=&quot;2&quot; fill=&quot;none&quot; stroke-dasharray=&quot;5 4&quot;&gt;&lt;/path&gt;&lt;path d=&quot;M500 262 L560 336&quot; stroke=&quot;#818cf8&quot; stroke-width=&quot;2&quot; fill=&quot;none&quot; stroke-dasharray=&quot;5 4&quot;&gt;&lt;/path&gt;&lt;text x=&quot;424&quot; y=&quot;212&quot; fill=&quot;#818cf8&quot; font-size=&quot;11&quot; font-weight=&quot;600&quot;&gt;NUT status · TCP 3493&lt;/text&gt;&lt;rect x=&quot;30&quot; y=&quot;386&quot; width=&quot;14&quot; height=&quot;10&quot; rx=&quot;2&quot; fill=&quot;#e0562b&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;50&quot; y=&quot;395&quot; fill=&quot;#9ca3af&quot; font-size=&quot;11&quot;&gt;power&lt;/text&gt;&lt;line x1=&quot;110&quot; y1=&quot;391&quot; x2=&quot;134&quot; y2=&quot;391&quot; stroke=&quot;#14b8a6&quot; stroke-width=&quot;2.5&quot;&gt;&lt;/line&gt;&lt;text x=&quot;140&quot; y=&quot;395&quot; fill=&quot;#9ca3af&quot; font-size=&quot;11&quot;&gt;USB data (primary only)&lt;/text&gt;&lt;line x1=&quot;330&quot; y1=&quot;391&quot; x2=&quot;354&quot; y2=&quot;391&quot; stroke=&quot;#818cf8&quot; stroke-width=&quot;2&quot; stroke-dasharray=&quot;5 4&quot;&gt;&lt;/line&gt;&lt;text x=&quot;360&quot; y=&quot;395&quot; fill=&quot;#9ca3af&quot; font-size=&quot;11&quot;&gt;UPS status over the network&lt;/text&gt;&lt;/svg&gt;&lt;/div&gt;
&lt;h2 id=&quot;how-nut-protects-a-multi-machine-homelab&quot;&gt;How NUT protects a multi-machine homelab&lt;/h2&gt;
&lt;p&gt;NUT splits into three small daemons, and the whole design follows one physical fact: &lt;strong&gt;only the machine with the data cable can talk to the UPS.&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;The driver&lt;/strong&gt; (for USB units, &lt;code&gt;usbhid-ups&lt;/code&gt;) talks to the UPS hardware and translates it into NUT variables like &lt;code&gt;ups.status: OL&lt;/code&gt; (on line) or &lt;code&gt;OB&lt;/code&gt; (on battery).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;upsd&lt;/code&gt;&lt;/strong&gt; is the network server. It publishes those variables on TCP port 3493 so other machines — and you — can read them.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;upsmon&lt;/code&gt;&lt;/strong&gt; is the monitor. It watches the status and, when the battery goes critical, runs your shutdown command.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The machine wired to the UPS runs all three and is the &lt;strong&gt;primary&lt;/strong&gt;. Every other machine on that same UPS runs only &lt;code&gt;upsmon&lt;/code&gt;, pointed at the primary over the network — that’s a &lt;strong&gt;secondary&lt;/strong&gt;. (NUT renamed these from &lt;em&gt;master&lt;/em&gt;/&lt;em&gt;slave&lt;/em&gt; in version 2.8.0; the old keywords still work but the docs use primary/secondary now.)&lt;/p&gt;
&lt;p&gt;When the power fails, the sequence is deliberate: the secondaries shut down first, and the primary shuts down &lt;strong&gt;last&lt;/strong&gt; — because the primary is the only one that can tell the UPS to cut its own power, and it must not do that until everyone else is safely down.&lt;/p&gt;
&lt;div class=&quot;callout callout-info&quot; data-astro-cid-q2ml7llr&gt;&lt;div class=&quot;callout-header&quot; data-astro-cid-q2ml7llr&gt;&lt;span class=&quot;callout-icon&quot; aria-hidden=&quot;true&quot; data-astro-cid-q2ml7llr&gt;ℹ️&lt;/span&gt;&lt;span class=&quot;callout-label&quot; data-astro-cid-q2ml7llr&gt;What NUT does and doesn&amp;#39;t need&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;callout-body&quot; data-astro-cid-q2ml7llr&gt;&lt;p&gt;NUT does not need a big UPS or a fancy one — it needs a UPS with a &lt;strong&gt;data port&lt;/strong&gt; (USB on most consumer units). A bargain UPS with no data cable can hold the power up, but no software can see its battery, so nothing can trigger an automatic shutdown. If you’re buying, that data port is the feature that matters here.&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;task-1-install-and-enable-nut-on-the-primary&quot;&gt;Task 1: Install and enable NUT on the primary&lt;/h2&gt;
&lt;p&gt;Do this on the one machine physically connected to the UPS by its USB cable — a Proxmox host, a NAS, or a dedicated always-on box.&lt;/p&gt;
&lt;div class=&quot;callout callout-warning&quot; data-astro-cid-q2ml7llr&gt;&lt;div class=&quot;callout-header&quot; data-astro-cid-q2ml7llr&gt;&lt;span class=&quot;callout-icon&quot; aria-hidden=&quot;true&quot; data-astro-cid-q2ml7llr&gt;⚠️&lt;/span&gt;&lt;span class=&quot;callout-label&quot; data-astro-cid-q2ml7llr&gt;First: make these values your own&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;callout-body&quot; data-astro-cid-q2ml7llr&gt;&lt;p&gt;This guide uses example values you must replace: the UPS name &lt;code&gt;myups&lt;/code&gt;, the primary’s LAN address &lt;code&gt;10.0.0.10&lt;/code&gt;, the monitor account &lt;code&gt;monuser&lt;/code&gt; with password &lt;code&gt;CHANGE_ME&lt;/code&gt;, and an admin password &lt;code&gt;CHANGE_ME_TOO&lt;/code&gt;. Your secondaries live at addresses like &lt;code&gt;10.0.0.11&lt;/code&gt; and &lt;code&gt;10.0.0.12&lt;/code&gt;. Put the real passwords in your own secret store, not in a note — and if a value looks specific to one machine (an IP, a hostname, a password), it’s a placeholder to change, not a literal to copy.&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;
&lt;div class=&quot;step&quot; data-astro-cid-wwaqy5cd&gt;&lt;div class=&quot;step-header&quot; data-astro-cid-wwaqy5cd&gt;&lt;span class=&quot;step-num&quot; data-astro-cid-wwaqy5cd&gt;1&lt;/span&gt;&lt;span class=&quot;step-title&quot; data-astro-cid-wwaqy5cd&gt;Install the NUT server package&lt;/span&gt;&lt;span class=&quot;step-time&quot; data-astro-cid-wwaqy5cd&gt;3 min&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;step-body&quot; data-astro-cid-wwaqy5cd&gt;&lt;p&gt;On Debian or Ubuntu, &lt;code&gt;nut-server&lt;/code&gt; brings the driver and &lt;code&gt;upsd&lt;/code&gt;; &lt;code&gt;nut-client&lt;/code&gt; brings &lt;code&gt;upsmon&lt;/code&gt; and the command-line tools. Install both on the primary:&lt;/p&gt;&lt;figure class=&quot;terminal&quot; data-astro-cid-kenkipkp&gt;&lt;figcaption class=&quot;terminal-bar&quot; data-astro-cid-kenkipkp&gt;&lt;span class=&quot;dot red&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot yellow&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot green&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;terminal-title&quot; data-astro-cid-kenkipkp&gt;Primary — install NUT&lt;/span&gt;&lt;button class=&quot;terminal-copy&quot; type=&quot;button&quot; aria-label=&quot;Copy commands&quot; data-astro-cid-kenkipkp&gt;&lt;svg width=&quot;13&quot; height=&quot;13&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;none&quot; stroke=&quot;currentColor&quot; stroke-width=&quot;2&quot; stroke-linecap=&quot;round&quot; stroke-linejoin=&quot;round&quot; aria-hidden=&quot;true&quot; data-astro-cid-kenkipkp&gt;&lt;rect x=&quot;9&quot; y=&quot;9&quot; width=&quot;13&quot; height=&quot;13&quot; rx=&quot;2&quot; data-astro-cid-kenkipkp&gt;&lt;/rect&gt;&lt;path d=&quot;M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1&quot; data-astro-cid-kenkipkp&gt;&lt;/path&gt;&lt;/svg&gt;&lt;span class=&quot;terminal-copy-label&quot; data-astro-cid-kenkipkp&gt;Copy&lt;/span&gt;&lt;/button&gt;&lt;/figcaption&gt;&lt;pre class=&quot;terminal-body&quot; data-astro-cid-kenkipkp&gt;&lt;code data-astro-cid-kenkipkp&gt;
apt update &amp;amp;&amp;amp; apt install -y nut-server nut-client
&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;p&gt;Verify the version — anything 2.8.0 or newer uses the primary/secondary terminology in this guide (Debian 13 ships 2.8.1):&lt;/p&gt;&lt;figure class=&quot;terminal&quot; data-astro-cid-kenkipkp&gt;&lt;figcaption class=&quot;terminal-bar&quot; data-astro-cid-kenkipkp&gt;&lt;span class=&quot;dot red&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot yellow&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot green&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;terminal-title&quot; data-astro-cid-kenkipkp&gt;Primary — check the version&lt;/span&gt;&lt;button class=&quot;terminal-copy&quot; type=&quot;button&quot; aria-label=&quot;Copy commands&quot; data-astro-cid-kenkipkp&gt;&lt;svg width=&quot;13&quot; height=&quot;13&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;none&quot; stroke=&quot;currentColor&quot; stroke-width=&quot;2&quot; stroke-linecap=&quot;round&quot; stroke-linejoin=&quot;round&quot; aria-hidden=&quot;true&quot; data-astro-cid-kenkipkp&gt;&lt;rect x=&quot;9&quot; y=&quot;9&quot; width=&quot;13&quot; height=&quot;13&quot; rx=&quot;2&quot; data-astro-cid-kenkipkp&gt;&lt;/rect&gt;&lt;path d=&quot;M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1&quot; data-astro-cid-kenkipkp&gt;&lt;/path&gt;&lt;/svg&gt;&lt;span class=&quot;terminal-copy-label&quot; data-astro-cid-kenkipkp&gt;Copy&lt;/span&gt;&lt;/button&gt;&lt;/figcaption&gt;&lt;pre class=&quot;terminal-body&quot; data-astro-cid-kenkipkp&gt;&lt;code data-astro-cid-kenkipkp&gt;
upsc -V
&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;&lt;/div&gt;
&lt;div class=&quot;step&quot; data-astro-cid-wwaqy5cd&gt;&lt;div class=&quot;step-header&quot; data-astro-cid-wwaqy5cd&gt;&lt;span class=&quot;step-num&quot; data-astro-cid-wwaqy5cd&gt;2&lt;/span&gt;&lt;span class=&quot;step-title&quot; data-astro-cid-wwaqy5cd&gt;Detect the UPS over USB&lt;/span&gt;&lt;span class=&quot;step-time&quot; data-astro-cid-wwaqy5cd&gt;2 min&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;step-body&quot; data-astro-cid-wwaqy5cd&gt;&lt;p&gt;With the UPS plugged into the wall &lt;strong&gt;and&lt;/strong&gt; its USB cable into this machine, ask NUT to find it:&lt;/p&gt;&lt;figure class=&quot;terminal&quot; data-astro-cid-kenkipkp&gt;&lt;figcaption class=&quot;terminal-bar&quot; data-astro-cid-kenkipkp&gt;&lt;span class=&quot;dot red&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot yellow&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot green&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;terminal-title&quot; data-astro-cid-kenkipkp&gt;Primary — scan for the UPS&lt;/span&gt;&lt;button class=&quot;terminal-copy&quot; type=&quot;button&quot; aria-label=&quot;Copy commands&quot; data-astro-cid-kenkipkp&gt;&lt;svg width=&quot;13&quot; height=&quot;13&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;none&quot; stroke=&quot;currentColor&quot; stroke-width=&quot;2&quot; stroke-linecap=&quot;round&quot; stroke-linejoin=&quot;round&quot; aria-hidden=&quot;true&quot; data-astro-cid-kenkipkp&gt;&lt;rect x=&quot;9&quot; y=&quot;9&quot; width=&quot;13&quot; height=&quot;13&quot; rx=&quot;2&quot; data-astro-cid-kenkipkp&gt;&lt;/rect&gt;&lt;path d=&quot;M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1&quot; data-astro-cid-kenkipkp&gt;&lt;/path&gt;&lt;/svg&gt;&lt;span class=&quot;terminal-copy-label&quot; data-astro-cid-kenkipkp&gt;Copy&lt;/span&gt;&lt;/button&gt;&lt;/figcaption&gt;&lt;pre class=&quot;terminal-body&quot; data-astro-cid-kenkipkp&gt;&lt;code data-astro-cid-kenkipkp&gt;
nut-scanner -U
&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;p&gt;&lt;code&gt;nut-scanner&lt;/code&gt; prints a ready-made &lt;code&gt;ups.conf&lt;/code&gt; block naming the driver it detected (almost always &lt;code&gt;usbhid-ups&lt;/code&gt; for USB units). If it finds nothing, re-seat the USB cable and confirm the OS sees it with &lt;code&gt;lsusb&lt;/code&gt;.&lt;/p&gt;&lt;div class=&quot;callout callout-warning&quot; data-astro-cid-q2ml7llr&gt;&lt;div class=&quot;callout-header&quot; data-astro-cid-q2ml7llr&gt;&lt;span class=&quot;callout-icon&quot; aria-hidden=&quot;true&quot; data-astro-cid-q2ml7llr&gt;⚠️&lt;/span&gt;&lt;span class=&quot;callout-label&quot; data-astro-cid-q2ml7llr&gt;Only one program can own the UPS&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;callout-body&quot; data-astro-cid-q2ml7llr&gt;&lt;p&gt;If you previously installed &lt;code&gt;apcupsd&lt;/code&gt; or another UPS tool, stop and disable it first. Two programs cannot both hold the USB device — the second one gets “device busy” and the driver fails to start.&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;&lt;/div&gt;&lt;/div&gt;
&lt;div class=&quot;step&quot; data-astro-cid-wwaqy5cd&gt;&lt;div class=&quot;step-header&quot; data-astro-cid-wwaqy5cd&gt;&lt;span class=&quot;step-num&quot; data-astro-cid-wwaqy5cd&gt;3&lt;/span&gt;&lt;span class=&quot;step-title&quot; data-astro-cid-wwaqy5cd&gt;Set the NUT mode to netserver&lt;/span&gt;&lt;span class=&quot;step-time&quot; data-astro-cid-wwaqy5cd&gt;1 min&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;step-body&quot; data-astro-cid-wwaqy5cd&gt;&lt;p&gt;&lt;code&gt;/etc/nut/nut.conf&lt;/code&gt; has a single meaningful line, &lt;code&gt;MODE&lt;/code&gt;, and it ships as &lt;code&gt;MODE=none&lt;/code&gt; so nothing starts by accident. The primary serves the UPS to the network, so set it to &lt;code&gt;netserver&lt;/code&gt;:&lt;/p&gt;&lt;figure class=&quot;terminal&quot; data-astro-cid-kenkipkp&gt;&lt;figcaption class=&quot;terminal-bar&quot; data-astro-cid-kenkipkp&gt;&lt;span class=&quot;dot red&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot yellow&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot green&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;terminal-title&quot; data-astro-cid-kenkipkp&gt;Primary — /etc/nut/nut.conf&lt;/span&gt;&lt;button class=&quot;terminal-copy&quot; type=&quot;button&quot; aria-label=&quot;Copy commands&quot; data-astro-cid-kenkipkp&gt;&lt;svg width=&quot;13&quot; height=&quot;13&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;none&quot; stroke=&quot;currentColor&quot; stroke-width=&quot;2&quot; stroke-linecap=&quot;round&quot; stroke-linejoin=&quot;round&quot; aria-hidden=&quot;true&quot; data-astro-cid-kenkipkp&gt;&lt;rect x=&quot;9&quot; y=&quot;9&quot; width=&quot;13&quot; height=&quot;13&quot; rx=&quot;2&quot; data-astro-cid-kenkipkp&gt;&lt;/rect&gt;&lt;path d=&quot;M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1&quot; data-astro-cid-kenkipkp&gt;&lt;/path&gt;&lt;/svg&gt;&lt;span class=&quot;terminal-copy-label&quot; data-astro-cid-kenkipkp&gt;Copy&lt;/span&gt;&lt;/button&gt;&lt;/figcaption&gt;&lt;pre class=&quot;terminal-body&quot; data-astro-cid-kenkipkp&gt;&lt;code data-astro-cid-kenkipkp&gt;
# MODE=none        (default — nothing runs)
# MODE=standalone  (this box only, no network clients)
# MODE=netserver   (this box owns the UPS AND serves clients)  &amp;lt;- use this
# MODE=netclient   (no local UPS; only watches another server)
echo &amp;quot;MODE=netserver&amp;quot; &amp;gt; /etc/nut/nut.conf
&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;&lt;/div&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;task-2-point-nut-at-your-ups&quot;&gt;Task 2: Point NUT at your UPS&lt;/h2&gt;
&lt;div class=&quot;step&quot; data-astro-cid-wwaqy5cd&gt;&lt;div class=&quot;step-header&quot; data-astro-cid-wwaqy5cd&gt;&lt;span class=&quot;step-num&quot; data-astro-cid-wwaqy5cd&gt;1&lt;/span&gt;&lt;span class=&quot;step-title&quot; data-astro-cid-wwaqy5cd&gt;Define the UPS in ups.conf&lt;/span&gt;&lt;span class=&quot;step-time&quot; data-astro-cid-wwaqy5cd&gt;3 min&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;step-body&quot; data-astro-cid-wwaqy5cd&gt;&lt;p&gt;Tell the driver which UPS to talk to. Use the block &lt;code&gt;nut-scanner&lt;/code&gt; printed, or write it by hand. For a USB unit:&lt;/p&gt;&lt;figure class=&quot;terminal&quot; data-astro-cid-kenkipkp&gt;&lt;figcaption class=&quot;terminal-bar&quot; data-astro-cid-kenkipkp&gt;&lt;span class=&quot;dot red&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot yellow&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot green&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;terminal-title&quot; data-astro-cid-kenkipkp&gt;Primary — /etc/nut/ups.conf&lt;/span&gt;&lt;button class=&quot;terminal-copy&quot; type=&quot;button&quot; aria-label=&quot;Copy commands&quot; data-astro-cid-kenkipkp&gt;&lt;svg width=&quot;13&quot; height=&quot;13&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;none&quot; stroke=&quot;currentColor&quot; stroke-width=&quot;2&quot; stroke-linecap=&quot;round&quot; stroke-linejoin=&quot;round&quot; aria-hidden=&quot;true&quot; data-astro-cid-kenkipkp&gt;&lt;rect x=&quot;9&quot; y=&quot;9&quot; width=&quot;13&quot; height=&quot;13&quot; rx=&quot;2&quot; data-astro-cid-kenkipkp&gt;&lt;/rect&gt;&lt;path d=&quot;M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1&quot; data-astro-cid-kenkipkp&gt;&lt;/path&gt;&lt;/svg&gt;&lt;span class=&quot;terminal-copy-label&quot; data-astro-cid-kenkipkp&gt;Copy&lt;/span&gt;&lt;/button&gt;&lt;/figcaption&gt;&lt;pre class=&quot;terminal-body&quot; data-astro-cid-kenkipkp&gt;&lt;code data-astro-cid-kenkipkp&gt;
cat &amp;gt; /etc/nut/ups.conf &amp;lt;&amp;lt;&amp;#39;EOF&amp;#39;
[myups]
  driver = usbhid-ups
  port = auto
  desc = &amp;quot;Homelab UPS&amp;quot;
EOF
&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;p&gt;&lt;code&gt;port = auto&lt;/code&gt; is correct for USB — the driver finds the device itself; the value is only meaningful for old serial units. The name in brackets, &lt;code&gt;myups&lt;/code&gt;, is how every other config and command refers to this UPS.&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;
&lt;div class=&quot;step&quot; data-astro-cid-wwaqy5cd&gt;&lt;div class=&quot;step-header&quot; data-astro-cid-wwaqy5cd&gt;&lt;span class=&quot;step-num&quot; data-astro-cid-wwaqy5cd&gt;2&lt;/span&gt;&lt;span class=&quot;step-title&quot; data-astro-cid-wwaqy5cd&gt;Start the driver and confirm it reads the battery&lt;/span&gt;&lt;span class=&quot;step-time&quot; data-astro-cid-wwaqy5cd&gt;2 min&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;step-body&quot; data-astro-cid-wwaqy5cd&gt;&lt;p&gt;Restart the server so the driver connects, then read the live values back with &lt;code&gt;upsc&lt;/code&gt;:&lt;/p&gt;&lt;figure class=&quot;terminal&quot; data-astro-cid-kenkipkp&gt;&lt;figcaption class=&quot;terminal-bar&quot; data-astro-cid-kenkipkp&gt;&lt;span class=&quot;dot red&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot yellow&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot green&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;terminal-title&quot; data-astro-cid-kenkipkp&gt;Primary — start and verify&lt;/span&gt;&lt;button class=&quot;terminal-copy&quot; type=&quot;button&quot; aria-label=&quot;Copy commands&quot; data-astro-cid-kenkipkp&gt;&lt;svg width=&quot;13&quot; height=&quot;13&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;none&quot; stroke=&quot;currentColor&quot; stroke-width=&quot;2&quot; stroke-linecap=&quot;round&quot; stroke-linejoin=&quot;round&quot; aria-hidden=&quot;true&quot; data-astro-cid-kenkipkp&gt;&lt;rect x=&quot;9&quot; y=&quot;9&quot; width=&quot;13&quot; height=&quot;13&quot; rx=&quot;2&quot; data-astro-cid-kenkipkp&gt;&lt;/rect&gt;&lt;path d=&quot;M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1&quot; data-astro-cid-kenkipkp&gt;&lt;/path&gt;&lt;/svg&gt;&lt;span class=&quot;terminal-copy-label&quot; data-astro-cid-kenkipkp&gt;Copy&lt;/span&gt;&lt;/button&gt;&lt;/figcaption&gt;&lt;pre class=&quot;terminal-body&quot; data-astro-cid-kenkipkp&gt;&lt;code data-astro-cid-kenkipkp&gt;
systemctl restart nut-server
upsc myups
&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;p&gt;You should see a dump of variables. The two that matter most:&lt;/p&gt;&lt;figure class=&quot;terminal&quot; data-astro-cid-kenkipkp&gt;&lt;figcaption class=&quot;terminal-bar&quot; data-astro-cid-kenkipkp&gt;&lt;span class=&quot;dot red&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot yellow&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot green&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;terminal-title&quot; data-astro-cid-kenkipkp&gt;Primary — the key status line&lt;/span&gt;&lt;button class=&quot;terminal-copy&quot; type=&quot;button&quot; aria-label=&quot;Copy commands&quot; data-astro-cid-kenkipkp&gt;&lt;svg width=&quot;13&quot; height=&quot;13&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;none&quot; stroke=&quot;currentColor&quot; stroke-width=&quot;2&quot; stroke-linecap=&quot;round&quot; stroke-linejoin=&quot;round&quot; aria-hidden=&quot;true&quot; data-astro-cid-kenkipkp&gt;&lt;rect x=&quot;9&quot; y=&quot;9&quot; width=&quot;13&quot; height=&quot;13&quot; rx=&quot;2&quot; data-astro-cid-kenkipkp&gt;&lt;/rect&gt;&lt;path d=&quot;M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1&quot; data-astro-cid-kenkipkp&gt;&lt;/path&gt;&lt;/svg&gt;&lt;span class=&quot;terminal-copy-label&quot; data-astro-cid-kenkipkp&gt;Copy&lt;/span&gt;&lt;/button&gt;&lt;/figcaption&gt;&lt;pre class=&quot;terminal-body&quot; data-astro-cid-kenkipkp&gt;&lt;code data-astro-cid-kenkipkp&gt;
upsc myups ups.status
# OL          = On Line  (running on mains)
# OB          = On Battery (mains lost)
# OB LB       = On Battery, Low Battery (critical — shutdown territory)
&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;p&gt;If &lt;code&gt;upsc&lt;/code&gt; prints &lt;code&gt;Error: Driver not connected&lt;/code&gt;, the driver couldn’t reach the UPS — recheck the USB cable and the driver name in &lt;code&gt;ups.conf&lt;/code&gt;.&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;task-3-open-the-network-and-add-a-monitor-account&quot;&gt;Task 3: Open the network and add a monitor account&lt;/h2&gt;
&lt;p&gt;By default &lt;code&gt;upsd&lt;/code&gt; listens on &lt;code&gt;localhost&lt;/code&gt; only — so a fresh install works on the primary but every secondary gets &lt;strong&gt;connection refused&lt;/strong&gt;. You have to open it up explicitly and give the clients an account.&lt;/p&gt;
&lt;div class=&quot;step&quot; data-astro-cid-wwaqy5cd&gt;&lt;div class=&quot;step-header&quot; data-astro-cid-wwaqy5cd&gt;&lt;span class=&quot;step-num&quot; data-astro-cid-wwaqy5cd&gt;1&lt;/span&gt;&lt;span class=&quot;step-title&quot; data-astro-cid-wwaqy5cd&gt;Listen on the LAN in upsd.conf&lt;/span&gt;&lt;span class=&quot;step-time&quot; data-astro-cid-wwaqy5cd&gt;2 min&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;step-body&quot; data-astro-cid-wwaqy5cd&gt;&lt;p&gt;Add a &lt;code&gt;LISTEN&lt;/code&gt; line for the primary’s own LAN address (keep localhost too, so local tools still work):&lt;/p&gt;&lt;figure class=&quot;terminal&quot; data-astro-cid-kenkipkp&gt;&lt;figcaption class=&quot;terminal-bar&quot; data-astro-cid-kenkipkp&gt;&lt;span class=&quot;dot red&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot yellow&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot green&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;terminal-title&quot; data-astro-cid-kenkipkp&gt;Primary — /etc/nut/upsd.conf&lt;/span&gt;&lt;button class=&quot;terminal-copy&quot; type=&quot;button&quot; aria-label=&quot;Copy commands&quot; data-astro-cid-kenkipkp&gt;&lt;svg width=&quot;13&quot; height=&quot;13&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;none&quot; stroke=&quot;currentColor&quot; stroke-width=&quot;2&quot; stroke-linecap=&quot;round&quot; stroke-linejoin=&quot;round&quot; aria-hidden=&quot;true&quot; data-astro-cid-kenkipkp&gt;&lt;rect x=&quot;9&quot; y=&quot;9&quot; width=&quot;13&quot; height=&quot;13&quot; rx=&quot;2&quot; data-astro-cid-kenkipkp&gt;&lt;/rect&gt;&lt;path d=&quot;M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1&quot; data-astro-cid-kenkipkp&gt;&lt;/path&gt;&lt;/svg&gt;&lt;span class=&quot;terminal-copy-label&quot; data-astro-cid-kenkipkp&gt;Copy&lt;/span&gt;&lt;/button&gt;&lt;/figcaption&gt;&lt;pre class=&quot;terminal-body&quot; data-astro-cid-kenkipkp&gt;&lt;code data-astro-cid-kenkipkp&gt;
cat &amp;gt; /etc/nut/upsd.conf &amp;lt;&amp;lt;&amp;#39;EOF&amp;#39;
LISTEN 127.0.0.1 3493
LISTEN 10.0.0.10 3493
EOF
&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;div class=&quot;callout callout-warning&quot; data-astro-cid-q2ml7llr&gt;&lt;div class=&quot;callout-header&quot; data-astro-cid-q2ml7llr&gt;&lt;span class=&quot;callout-icon&quot; aria-hidden=&quot;true&quot; data-astro-cid-q2ml7llr&gt;⚠️&lt;/span&gt;&lt;span class=&quot;callout-label&quot; data-astro-cid-q2ml7llr&gt;Use distinct addresses, not 0.0.0.0 plus 127.0.0.1&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;callout-body&quot; data-astro-cid-q2ml7llr&gt;&lt;p&gt;List the specific addresses you want, or a single &lt;code&gt;LISTEN 0.0.0.0 3493&lt;/code&gt; to listen everywhere. Do &lt;strong&gt;not&lt;/strong&gt; combine &lt;code&gt;LISTEN 0.0.0.0 3493&lt;/code&gt; with &lt;code&gt;LISTEN 127.0.0.1 3493&lt;/code&gt; — the ranges overlap, one bind loses, and &lt;code&gt;upsd&lt;/code&gt; ends up listening only on localhost while looking like it started fine. Bind localhost plus your real LAN IP, as above.&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;&lt;/div&gt;&lt;/div&gt;
&lt;div class=&quot;step&quot; data-astro-cid-wwaqy5cd&gt;&lt;div class=&quot;step-header&quot; data-astro-cid-wwaqy5cd&gt;&lt;span class=&quot;step-num&quot; data-astro-cid-wwaqy5cd&gt;2&lt;/span&gt;&lt;span class=&quot;step-title&quot; data-astro-cid-wwaqy5cd&gt;Create the monitor account in upsd.users&lt;/span&gt;&lt;span class=&quot;step-time&quot; data-astro-cid-wwaqy5cd&gt;2 min&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;step-body&quot; data-astro-cid-wwaqy5cd&gt;&lt;p&gt;Every &lt;code&gt;upsmon&lt;/code&gt; — the primary’s own and each secondary’s — logs into &lt;code&gt;upsd&lt;/code&gt; with an account. Create a monitor user, and an admin user you’ll use to test:&lt;/p&gt;&lt;figure class=&quot;terminal&quot; data-astro-cid-kenkipkp&gt;&lt;figcaption class=&quot;terminal-bar&quot; data-astro-cid-kenkipkp&gt;&lt;span class=&quot;dot red&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot yellow&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot green&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;terminal-title&quot; data-astro-cid-kenkipkp&gt;Primary — /etc/nut/upsd.users&lt;/span&gt;&lt;button class=&quot;terminal-copy&quot; type=&quot;button&quot; aria-label=&quot;Copy commands&quot; data-astro-cid-kenkipkp&gt;&lt;svg width=&quot;13&quot; height=&quot;13&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;none&quot; stroke=&quot;currentColor&quot; stroke-width=&quot;2&quot; stroke-linecap=&quot;round&quot; stroke-linejoin=&quot;round&quot; aria-hidden=&quot;true&quot; data-astro-cid-kenkipkp&gt;&lt;rect x=&quot;9&quot; y=&quot;9&quot; width=&quot;13&quot; height=&quot;13&quot; rx=&quot;2&quot; data-astro-cid-kenkipkp&gt;&lt;/rect&gt;&lt;path d=&quot;M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1&quot; data-astro-cid-kenkipkp&gt;&lt;/path&gt;&lt;/svg&gt;&lt;span class=&quot;terminal-copy-label&quot; data-astro-cid-kenkipkp&gt;Copy&lt;/span&gt;&lt;/button&gt;&lt;/figcaption&gt;&lt;pre class=&quot;terminal-body&quot; data-astro-cid-kenkipkp&gt;&lt;code data-astro-cid-kenkipkp&gt;
cat &amp;gt; /etc/nut/upsd.users &amp;lt;&amp;lt;&amp;#39;EOF&amp;#39;
[monuser]
  password = CHANGE_ME
  upsmon primary

[admin]
  password = CHANGE_ME_TOO
  actions = SET
  instcmds = ALL
EOF
chown root:nut /etc/nut/upsd.users
chmod 640 /etc/nut/upsd.users
&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;p&gt;The &lt;code&gt;upsmon primary&lt;/code&gt; line grants this account the primary role. The file holds passwords, so lock it down to &lt;code&gt;root:nut&lt;/code&gt; mode &lt;code&gt;640&lt;/code&gt; — the &lt;code&gt;upsd&lt;/code&gt; process runs as the &lt;code&gt;nut&lt;/code&gt; user and reads it by group.&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;task-4-tell-upsmon-when-and-how-to-shut-down&quot;&gt;Task 4: Tell upsmon when and how to shut down&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;upsmon.conf&lt;/code&gt; is where the policy lives: which UPS to watch, and what to run when it’s critical.&lt;/p&gt;
&lt;div class=&quot;step&quot; data-astro-cid-wwaqy5cd&gt;&lt;div class=&quot;step-header&quot; data-astro-cid-wwaqy5cd&gt;&lt;span class=&quot;step-num&quot; data-astro-cid-wwaqy5cd&gt;1&lt;/span&gt;&lt;span class=&quot;step-title&quot; data-astro-cid-wwaqy5cd&gt;Configure the primary&amp;#39;s upsmon&lt;/span&gt;&lt;span class=&quot;step-time&quot; data-astro-cid-wwaqy5cd&gt;3 min&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;step-body&quot; data-astro-cid-wwaqy5cd&gt;&lt;figure class=&quot;terminal&quot; data-astro-cid-kenkipkp&gt;&lt;figcaption class=&quot;terminal-bar&quot; data-astro-cid-kenkipkp&gt;&lt;span class=&quot;dot red&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot yellow&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot green&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;terminal-title&quot; data-astro-cid-kenkipkp&gt;Primary — /etc/nut/upsmon.conf&lt;/span&gt;&lt;button class=&quot;terminal-copy&quot; type=&quot;button&quot; aria-label=&quot;Copy commands&quot; data-astro-cid-kenkipkp&gt;&lt;svg width=&quot;13&quot; height=&quot;13&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;none&quot; stroke=&quot;currentColor&quot; stroke-width=&quot;2&quot; stroke-linecap=&quot;round&quot; stroke-linejoin=&quot;round&quot; aria-hidden=&quot;true&quot; data-astro-cid-kenkipkp&gt;&lt;rect x=&quot;9&quot; y=&quot;9&quot; width=&quot;13&quot; height=&quot;13&quot; rx=&quot;2&quot; data-astro-cid-kenkipkp&gt;&lt;/rect&gt;&lt;path d=&quot;M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1&quot; data-astro-cid-kenkipkp&gt;&lt;/path&gt;&lt;/svg&gt;&lt;span class=&quot;terminal-copy-label&quot; data-astro-cid-kenkipkp&gt;Copy&lt;/span&gt;&lt;/button&gt;&lt;/figcaption&gt;&lt;pre class=&quot;terminal-body&quot; data-astro-cid-kenkipkp&gt;&lt;code data-astro-cid-kenkipkp&gt;
cat &amp;gt; /etc/nut/upsmon.conf &amp;lt;&amp;lt;&amp;#39;EOF&amp;#39;
MONITOR myups@localhost 1 monuser CHANGE_ME primary
MINSUPPLIES 1
SHUTDOWNCMD &amp;quot;/sbin/shutdown -h +0&amp;quot;
POWERDOWNFLAG /run/nut/killpower
EOF
chown root:nut /etc/nut/upsmon.conf
chmod 640 /etc/nut/upsmon.conf
&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;p&gt;Reading the &lt;code&gt;MONITOR&lt;/code&gt; line field by field — the order is fixed:&lt;/p&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Field&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;UPS&lt;/td&gt;
&lt;td&gt;&lt;code&gt;myups@localhost&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;the UPS name, and where its &lt;code&gt;upsd&lt;/code&gt; runs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;power value&lt;/td&gt;
&lt;td&gt;&lt;code&gt;1&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;how many power supplies this machine draws from it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;username&lt;/td&gt;
&lt;td&gt;&lt;code&gt;monuser&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;account from &lt;code&gt;upsd.users&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;password&lt;/td&gt;
&lt;td&gt;&lt;code&gt;CHANGE_ME&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;its password&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;role&lt;/td&gt;
&lt;td&gt;&lt;code&gt;primary&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;this box manages the UPS&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;p&gt;&lt;code&gt;MINSUPPLIES 1&lt;/code&gt; means “this machine needs at least one working supply” — a normal single-PSU box. &lt;code&gt;SHUTDOWNCMD&lt;/code&gt; is what runs when the battery is critical. &lt;code&gt;POWERDOWNFLAG&lt;/code&gt; is a small file &lt;code&gt;upsmon&lt;/code&gt; drops on its way down; the system’s late-shutdown step sees it and runs &lt;code&gt;upsdrvctl shutdown&lt;/code&gt;, which tells the UPS itself to cut power — so the UPS power-cycles and your machines boot back up when mains returns.&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;
&lt;div class=&quot;step&quot; data-astro-cid-wwaqy5cd&gt;&lt;div class=&quot;step-header&quot; data-astro-cid-wwaqy5cd&gt;&lt;span class=&quot;step-num&quot; data-astro-cid-wwaqy5cd&gt;2&lt;/span&gt;&lt;span class=&quot;step-title&quot; data-astro-cid-wwaqy5cd&gt;Start monitoring on the primary&lt;/span&gt;&lt;span class=&quot;step-time&quot; data-astro-cid-wwaqy5cd&gt;1 min&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;step-body&quot; data-astro-cid-wwaqy5cd&gt;&lt;figure class=&quot;terminal&quot; data-astro-cid-kenkipkp&gt;&lt;figcaption class=&quot;terminal-bar&quot; data-astro-cid-kenkipkp&gt;&lt;span class=&quot;dot red&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot yellow&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot green&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;terminal-title&quot; data-astro-cid-kenkipkp&gt;Primary — start upsmon&lt;/span&gt;&lt;button class=&quot;terminal-copy&quot; type=&quot;button&quot; aria-label=&quot;Copy commands&quot; data-astro-cid-kenkipkp&gt;&lt;svg width=&quot;13&quot; height=&quot;13&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;none&quot; stroke=&quot;currentColor&quot; stroke-width=&quot;2&quot; stroke-linecap=&quot;round&quot; stroke-linejoin=&quot;round&quot; aria-hidden=&quot;true&quot; data-astro-cid-kenkipkp&gt;&lt;rect x=&quot;9&quot; y=&quot;9&quot; width=&quot;13&quot; height=&quot;13&quot; rx=&quot;2&quot; data-astro-cid-kenkipkp&gt;&lt;/rect&gt;&lt;path d=&quot;M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1&quot; data-astro-cid-kenkipkp&gt;&lt;/path&gt;&lt;/svg&gt;&lt;span class=&quot;terminal-copy-label&quot; data-astro-cid-kenkipkp&gt;Copy&lt;/span&gt;&lt;/button&gt;&lt;/figcaption&gt;&lt;pre class=&quot;terminal-body&quot; data-astro-cid-kenkipkp&gt;&lt;code data-astro-cid-kenkipkp&gt;
systemctl restart nut-server nut-monitor
systemctl status nut-monitor --no-pager
&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;p&gt;On Debian the monitor service is &lt;code&gt;nut-monitor&lt;/code&gt; (&lt;code&gt;nut-client&lt;/code&gt; is an alias for it). It should be &lt;code&gt;active (running)&lt;/code&gt; and, in &lt;code&gt;journalctl -u nut-monitor&lt;/code&gt;, log &lt;code&gt;Communications with UPS myups@localhost established&lt;/code&gt;.&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;task-5-protect-the-other-machines-the-secondaries&quot;&gt;Task 5: Protect the other machines (the secondaries)&lt;/h2&gt;
&lt;p&gt;Now repeat on every &lt;strong&gt;other&lt;/strong&gt; machine running on that UPS — the other Proxmox nodes, the NAS, anything with a plug in the battery side. These never touch the UPS hardware; they only watch the primary.&lt;/p&gt;
&lt;div class=&quot;step&quot; data-astro-cid-wwaqy5cd&gt;&lt;div class=&quot;step-header&quot; data-astro-cid-wwaqy5cd&gt;&lt;span class=&quot;step-num&quot; data-astro-cid-wwaqy5cd&gt;1&lt;/span&gt;&lt;span class=&quot;step-title&quot; data-astro-cid-wwaqy5cd&gt;Install NUT and set netclient mode&lt;/span&gt;&lt;span class=&quot;step-time&quot; data-astro-cid-wwaqy5cd&gt;2 min&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;step-body&quot; data-astro-cid-wwaqy5cd&gt;&lt;p&gt;On a secondary you only need the client package:&lt;/p&gt;&lt;figure class=&quot;terminal&quot; data-astro-cid-kenkipkp&gt;&lt;figcaption class=&quot;terminal-bar&quot; data-astro-cid-kenkipkp&gt;&lt;span class=&quot;dot red&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot yellow&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot green&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;terminal-title&quot; data-astro-cid-kenkipkp&gt;Secondary — install and set mode&lt;/span&gt;&lt;button class=&quot;terminal-copy&quot; type=&quot;button&quot; aria-label=&quot;Copy commands&quot; data-astro-cid-kenkipkp&gt;&lt;svg width=&quot;13&quot; height=&quot;13&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;none&quot; stroke=&quot;currentColor&quot; stroke-width=&quot;2&quot; stroke-linecap=&quot;round&quot; stroke-linejoin=&quot;round&quot; aria-hidden=&quot;true&quot; data-astro-cid-kenkipkp&gt;&lt;rect x=&quot;9&quot; y=&quot;9&quot; width=&quot;13&quot; height=&quot;13&quot; rx=&quot;2&quot; data-astro-cid-kenkipkp&gt;&lt;/rect&gt;&lt;path d=&quot;M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1&quot; data-astro-cid-kenkipkp&gt;&lt;/path&gt;&lt;/svg&gt;&lt;span class=&quot;terminal-copy-label&quot; data-astro-cid-kenkipkp&gt;Copy&lt;/span&gt;&lt;/button&gt;&lt;/figcaption&gt;&lt;pre class=&quot;terminal-body&quot; data-astro-cid-kenkipkp&gt;&lt;code data-astro-cid-kenkipkp&gt;
apt update &amp;amp;&amp;amp; apt install -y nut-client
echo &amp;quot;MODE=netclient&amp;quot; &amp;gt; /etc/nut/nut.conf
&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;&lt;/div&gt;
&lt;div class=&quot;step&quot; data-astro-cid-wwaqy5cd&gt;&lt;div class=&quot;step-header&quot; data-astro-cid-wwaqy5cd&gt;&lt;span class=&quot;step-num&quot; data-astro-cid-wwaqy5cd&gt;2&lt;/span&gt;&lt;span class=&quot;step-title&quot; data-astro-cid-wwaqy5cd&gt;Point its upsmon at the primary&lt;/span&gt;&lt;span class=&quot;step-time&quot; data-astro-cid-wwaqy5cd&gt;2 min&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;step-body&quot; data-astro-cid-wwaqy5cd&gt;&lt;p&gt;The only real difference from the primary: the UPS is &lt;code&gt;myups@10.0.0.10&lt;/code&gt; (the primary’s address, not localhost), and the role is &lt;code&gt;secondary&lt;/code&gt;:&lt;/p&gt;&lt;figure class=&quot;terminal&quot; data-astro-cid-kenkipkp&gt;&lt;figcaption class=&quot;terminal-bar&quot; data-astro-cid-kenkipkp&gt;&lt;span class=&quot;dot red&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot yellow&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot green&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;terminal-title&quot; data-astro-cid-kenkipkp&gt;Secondary — /etc/nut/upsmon.conf&lt;/span&gt;&lt;button class=&quot;terminal-copy&quot; type=&quot;button&quot; aria-label=&quot;Copy commands&quot; data-astro-cid-kenkipkp&gt;&lt;svg width=&quot;13&quot; height=&quot;13&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;none&quot; stroke=&quot;currentColor&quot; stroke-width=&quot;2&quot; stroke-linecap=&quot;round&quot; stroke-linejoin=&quot;round&quot; aria-hidden=&quot;true&quot; data-astro-cid-kenkipkp&gt;&lt;rect x=&quot;9&quot; y=&quot;9&quot; width=&quot;13&quot; height=&quot;13&quot; rx=&quot;2&quot; data-astro-cid-kenkipkp&gt;&lt;/rect&gt;&lt;path d=&quot;M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1&quot; data-astro-cid-kenkipkp&gt;&lt;/path&gt;&lt;/svg&gt;&lt;span class=&quot;terminal-copy-label&quot; data-astro-cid-kenkipkp&gt;Copy&lt;/span&gt;&lt;/button&gt;&lt;/figcaption&gt;&lt;pre class=&quot;terminal-body&quot; data-astro-cid-kenkipkp&gt;&lt;code data-astro-cid-kenkipkp&gt;
cat &amp;gt; /etc/nut/upsmon.conf &amp;lt;&amp;lt;&amp;#39;EOF&amp;#39;
MONITOR myups@10.0.0.10 1 monuser CHANGE_ME secondary
MINSUPPLIES 1
SHUTDOWNCMD &amp;quot;/sbin/shutdown -h +0&amp;quot;
EOF
chown root:nut /etc/nut/upsmon.conf
chmod 640 /etc/nut/upsmon.conf
systemctl restart nut-monitor
&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;p&gt;Confirm the secondary can read the UPS across the network — this is the moment the &lt;code&gt;LISTEN&lt;/code&gt; line from Task 3 pays off:&lt;/p&gt;&lt;figure class=&quot;terminal&quot; data-astro-cid-kenkipkp&gt;&lt;figcaption class=&quot;terminal-bar&quot; data-astro-cid-kenkipkp&gt;&lt;span class=&quot;dot red&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot yellow&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot green&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;terminal-title&quot; data-astro-cid-kenkipkp&gt;Secondary — read the UPS over the network&lt;/span&gt;&lt;button class=&quot;terminal-copy&quot; type=&quot;button&quot; aria-label=&quot;Copy commands&quot; data-astro-cid-kenkipkp&gt;&lt;svg width=&quot;13&quot; height=&quot;13&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;none&quot; stroke=&quot;currentColor&quot; stroke-width=&quot;2&quot; stroke-linecap=&quot;round&quot; stroke-linejoin=&quot;round&quot; aria-hidden=&quot;true&quot; data-astro-cid-kenkipkp&gt;&lt;rect x=&quot;9&quot; y=&quot;9&quot; width=&quot;13&quot; height=&quot;13&quot; rx=&quot;2&quot; data-astro-cid-kenkipkp&gt;&lt;/rect&gt;&lt;path d=&quot;M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1&quot; data-astro-cid-kenkipkp&gt;&lt;/path&gt;&lt;/svg&gt;&lt;span class=&quot;terminal-copy-label&quot; data-astro-cid-kenkipkp&gt;Copy&lt;/span&gt;&lt;/button&gt;&lt;/figcaption&gt;&lt;pre class=&quot;terminal-body&quot; data-astro-cid-kenkipkp&gt;&lt;code data-astro-cid-kenkipkp&gt;
upsc myups@10.0.0.10 ups.status
&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;p&gt;&lt;code&gt;OL&lt;/code&gt; means it’s connected and watching. &lt;code&gt;Connection refused&lt;/code&gt; means the primary isn’t listening on the LAN (recheck &lt;code&gt;upsd.conf&lt;/code&gt;); an access error means the username or password doesn’t match &lt;code&gt;upsd.users&lt;/code&gt;.&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;the-shutdown-sequence-in-order&quot;&gt;The shutdown sequence, in order&lt;/h2&gt;
&lt;p&gt;This is the payoff, and it’s worth knowing exactly what happens so you trust it. When mains fails, the UPS reports &lt;code&gt;OB&lt;/code&gt; (on battery) and NUT logs an &lt;code&gt;ONBATT&lt;/code&gt; event on every machine — but nothing shuts down yet; you’re just running on battery. When the charge falls to the low-battery threshold, the status becomes &lt;code&gt;OB LB&lt;/code&gt; and it turns critical:&lt;/p&gt;
&lt;div style=&quot;margin:1.5rem 0; border:1px solid #3d3d5c; border-radius:12px; overflow:hidden; box-shadow:var(--shadow-lg)&quot;&gt;&lt;svg viewBox=&quot;0 0 760 250&quot; width=&quot;100%&quot; role=&quot;img&quot; aria-label=&quot;Timeline of the NUT shutdown: On line, then On battery, then Low battery critical, then the secondaries shut down first, then the primary shuts down and cuts UPS power.&quot;&gt;&lt;rect width=&quot;760&quot; height=&quot;250&quot; fill=&quot;#1c1c32&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;28&quot; y=&quot;38&quot; fill=&quot;#f4f4f5&quot; font-size=&quot;18&quot; font-weight=&quot;700&quot;&gt;What happens when the battery runs low&lt;/text&gt;&lt;rect x=&quot;28&quot; y=&quot;70&quot; width=&quot;128&quot; height=&quot;88&quot; rx=&quot;9&quot; fill=&quot;#2c2c4c&quot; stroke=&quot;#22c55e&quot; stroke-width=&quot;2&quot;&gt;&lt;/rect&gt;&lt;rect x=&quot;28&quot; y=&quot;70&quot; width=&quot;128&quot; height=&quot;24&quot; rx=&quot;9&quot; fill=&quot;#22c55e&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;92&quot; y=&quot;87&quot; text-anchor=&quot;middle&quot; fill=&quot;#0a0a14&quot; font-size=&quot;13&quot; font-weight=&quot;700&quot;&gt;OL&lt;/text&gt;&lt;text x=&quot;92&quot; y=&quot;120&quot; text-anchor=&quot;middle&quot; fill=&quot;#e5e7eb&quot; font-size=&quot;12&quot;&gt;On line&lt;/text&gt;&lt;text x=&quot;92&quot; y=&quot;140&quot; text-anchor=&quot;middle&quot; fill=&quot;#9ca3af&quot; font-size=&quot;10.5&quot;&gt;mains is fine&lt;/text&gt;&lt;rect x=&quot;188&quot; y=&quot;70&quot; width=&quot;128&quot; height=&quot;88&quot; rx=&quot;9&quot; fill=&quot;#2c2c4c&quot; stroke=&quot;#f59e0b&quot; stroke-width=&quot;2&quot;&gt;&lt;/rect&gt;&lt;rect x=&quot;188&quot; y=&quot;70&quot; width=&quot;128&quot; height=&quot;24&quot; rx=&quot;9&quot; fill=&quot;#f59e0b&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;252&quot; y=&quot;87&quot; text-anchor=&quot;middle&quot; fill=&quot;#0a0a14&quot; font-size=&quot;13&quot; font-weight=&quot;700&quot;&gt;OB&lt;/text&gt;&lt;text x=&quot;252&quot; y=&quot;120&quot; text-anchor=&quot;middle&quot; fill=&quot;#e5e7eb&quot; font-size=&quot;12&quot;&gt;On battery&lt;/text&gt;&lt;text x=&quot;252&quot; y=&quot;140&quot; text-anchor=&quot;middle&quot; fill=&quot;#9ca3af&quot; font-size=&quot;10.5&quot;&gt;mains lost — waiting&lt;/text&gt;&lt;rect x=&quot;348&quot; y=&quot;70&quot; width=&quot;128&quot; height=&quot;88&quot; rx=&quot;9&quot; fill=&quot;#2c2c4c&quot; stroke=&quot;#ef4444&quot; stroke-width=&quot;2&quot;&gt;&lt;/rect&gt;&lt;rect x=&quot;348&quot; y=&quot;70&quot; width=&quot;128&quot; height=&quot;24&quot; rx=&quot;9&quot; fill=&quot;#ef4444&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;412&quot; y=&quot;87&quot; text-anchor=&quot;middle&quot; fill=&quot;#0a0a14&quot; font-size=&quot;13&quot; font-weight=&quot;700&quot;&gt;OB LB&lt;/text&gt;&lt;text x=&quot;412&quot; y=&quot;120&quot; text-anchor=&quot;middle&quot; fill=&quot;#e5e7eb&quot; font-size=&quot;12&quot;&gt;Low battery&lt;/text&gt;&lt;text x=&quot;412&quot; y=&quot;140&quot; text-anchor=&quot;middle&quot; fill=&quot;#9ca3af&quot; font-size=&quot;10.5&quot;&gt;critical — go now&lt;/text&gt;&lt;rect x=&quot;508&quot; y=&quot;70&quot; width=&quot;224&quot; height=&quot;88&quot; rx=&quot;9&quot; fill=&quot;#262640&quot; stroke=&quot;#818cf8&quot; stroke-width=&quot;1.5&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;620&quot; y=&quot;94&quot; text-anchor=&quot;middle&quot; fill=&quot;#818cf8&quot; font-size=&quot;12.5&quot; font-weight=&quot;700&quot;&gt;1. Secondaries shut down&lt;/text&gt;&lt;text x=&quot;620&quot; y=&quot;116&quot; text-anchor=&quot;middle&quot; fill=&quot;#e5e7eb&quot; font-size=&quot;11&quot;&gt;clients power off first&lt;/text&gt;&lt;text x=&quot;620&quot; y=&quot;134&quot; text-anchor=&quot;middle&quot; fill=&quot;#9ca3af&quot; font-size=&quot;10.5&quot;&gt;primary waits for them (HOSTSYNC)&lt;/text&gt;&lt;rect x=&quot;508&quot; y=&quot;176&quot; width=&quot;224&quot; height=&quot;52&quot; rx=&quot;9&quot; fill=&quot;#2c2c4c&quot; stroke=&quot;#14b8a6&quot; stroke-width=&quot;1.5&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;620&quot; y=&quot;198&quot; text-anchor=&quot;middle&quot; fill=&quot;#14b8a6&quot; font-size=&quot;12.5&quot; font-weight=&quot;700&quot;&gt;2. Primary shuts down last&lt;/text&gt;&lt;text x=&quot;620&quot; y=&quot;216&quot; text-anchor=&quot;middle&quot; fill=&quot;#9ca3af&quot; font-size=&quot;10.5&quot;&gt;then signals the UPS to cut power&lt;/text&gt;&lt;path d=&quot;M156 114 L184 114&quot; stroke=&quot;#6b7280&quot; stroke-width=&quot;2&quot; fill=&quot;none&quot;&gt;&lt;/path&gt;&lt;path d=&quot;M316 114 L344 114&quot; stroke=&quot;#6b7280&quot; stroke-width=&quot;2&quot; fill=&quot;none&quot;&gt;&lt;/path&gt;&lt;path d=&quot;M476 114 L504 114&quot; stroke=&quot;#6b7280&quot; stroke-width=&quot;2&quot; fill=&quot;none&quot;&gt;&lt;/path&gt;&lt;path d=&quot;M620 158 L620 174&quot; stroke=&quot;#6b7280&quot; stroke-width=&quot;2&quot; fill=&quot;none&quot;&gt;&lt;/path&gt;&lt;polygon points=&quot;620,176 615,168 625,168&quot; fill=&quot;#6b7280&quot;&gt;&lt;/polygon&gt;&lt;/svg&gt;&lt;/div&gt;
&lt;p&gt;In a real run against a NUT server and one network client, the primary logs &lt;code&gt;Host sync timer expired, forcing shutdown&lt;/code&gt; after waiting the &lt;code&gt;HOSTSYNC&lt;/code&gt; window for the client to drop, then &lt;code&gt;Executing automatic power-fail shutdown&lt;/code&gt; — a couple of seconds &lt;em&gt;after&lt;/em&gt; the secondary has already gone. That ordering is the entire point: nothing races, and the machine that controls the UPS is the last to leave.&lt;/p&gt;
&lt;div class=&quot;callout callout-danger&quot; data-astro-cid-q2ml7llr&gt;&lt;div class=&quot;callout-header&quot; data-astro-cid-q2ml7llr&gt;&lt;span class=&quot;callout-icon&quot; aria-hidden=&quot;true&quot; data-astro-cid-q2ml7llr&gt;🚨&lt;/span&gt;&lt;span class=&quot;callout-label&quot; data-astro-cid-q2ml7llr&gt;Test it before you trust it — carefully&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;callout-body&quot; data-astro-cid-q2ml7llr&gt;&lt;p&gt;&lt;code&gt;upsmon -c fsd&lt;/code&gt; forces the exact critical-shutdown path on demand — the real thing, so it will actually power your machines down. Test on a scratch machine, or when you can afford the reboot, and watch &lt;code&gt;journalctl -u nut-monitor -f&lt;/code&gt; in another window. Never run it for the first time on a box you can’t afford to lose. To rehearse the logic without real hardware at all, NUT ships a &lt;code&gt;dummy-ups&lt;/code&gt; driver whose status you can flip by hand.&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;common-gotchas&quot;&gt;Common gotchas&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;upsd&lt;/code&gt; listens on localhost by default.&lt;/strong&gt; The single most common “my clients can’t connect” cause. Add the &lt;code&gt;LISTEN&lt;/code&gt; line (Task 3) and restart &lt;code&gt;nut-server&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;0.0.0.0&lt;/code&gt; and &lt;code&gt;127.0.0.1&lt;/code&gt; together bind only localhost.&lt;/strong&gt; Overlapping listen addresses silently collapse to one. Use distinct addresses or a lone &lt;code&gt;0.0.0.0&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Config files are &lt;code&gt;root:nut&lt;/code&gt; mode &lt;code&gt;640&lt;/code&gt;.&lt;/strong&gt; They hold passwords; if you hand-edit and the perms drift, &lt;code&gt;upsd&lt;/code&gt; may refuse to read them.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Only one UPS tool at a time.&lt;/strong&gt; &lt;code&gt;apcupsd&lt;/code&gt; and NUT both grab the USB device; disable one.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;POWERDOWNFLAG&lt;/code&gt; is mandatory in primary mode.&lt;/strong&gt; &lt;code&gt;upsmon&lt;/code&gt; has no built-in default; without the line, the “cut UPS power” step can’t fire.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;whats-next&quot;&gt;What’s next&lt;/h2&gt;
&lt;p&gt;With a UPS watched and a clean shutdown wired up, the natural next step is knowing the moment it kicks in: point &lt;a href=&quot;https://peira.dev/blog/uptime-kuma-monitoring/&quot;&gt;Uptime Kuma&lt;/a&gt; or your &lt;a href=&quot;https://peira.dev/blog/grafana-prometheus-proxmox/&quot;&gt;Grafana and Prometheus&lt;/a&gt; stack at the events, and make sure the machines this protects are themselves backed up with &lt;a href=&quot;https://peira.dev/blog/proxmox-backup-server/&quot;&gt;Proxmox Backup Server&lt;/a&gt;. Want the whole setup as one copy-paste script? It’s in the &lt;a href=&quot;https://peira.dev/playbooks/nut-ups-network-shutdown/&quot;&gt;NUT network-shutdown playbook&lt;/a&gt;.&lt;/p&gt;
&lt;hr/&gt;
&lt;p&gt;&lt;strong&gt;Related posts:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/proxmox-backup-server/&quot;&gt;Proxmox Backup Server: Automated CT and VM Backups&lt;/a&gt; — the other half of resilience; a clean shutdown protects today, backups protect the bad day&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/uptime-kuma-monitoring/&quot;&gt;Uptime Kuma: Dead-Simple Homelab Monitoring&lt;/a&gt; — get a phone alert the moment the UPS goes on battery&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/grafana-prometheus-proxmox/&quot;&gt;Grafana + Prometheus for Proxmox&lt;/a&gt; — graph battery charge and runtime over time&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/proxmox-security-hardening/&quot;&gt;Proxmox Security Hardening&lt;/a&gt; — more ways to keep the nodes this UPS protects healthy&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/proxmox-cluster-setup/&quot;&gt;Proxmox Cluster Setup: Quorum, Corosync, and Node Joining&lt;/a&gt; — the multi-node cluster that makes one-UPS-many-clients worth doing&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/proxmox-rebuild-from-zero/&quot;&gt;Rebuild Your Homelab From Zero: What Survives a Total Loss&lt;/a&gt; — the recovery plan for the disaster this UPS exists to prevent&lt;/li&gt;
&lt;/ul&gt;
&lt;hr/&gt;
&lt;p&gt;&lt;strong&gt;Recommended hardware for this setup:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://www.amazon.com/s?k=CyberPower+CP1500PFCLCD&amp;amp;tag=buildahomelab-20&quot;&gt;Line-interactive UPS with USB (CyberPower CP1500PFCLCD)&lt;/a&gt; — sine-wave output and a USB data port NUT reads out of the box&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://www.amazon.com/s?k=APC+Back-UPS+Pro+BR1500MS&amp;amp;tag=buildahomelab-20&quot;&gt;APC Back-UPS Pro with USB&lt;/a&gt; — another well-supported &lt;code&gt;usbhid-ups&lt;/code&gt; unit&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://www.amazon.com/s?k=Eaton+5S+1500+UPS&amp;amp;tag=buildahomelab-20&quot;&gt;Eaton 5S UPS&lt;/a&gt; — compact, USB, and on the NUT compatibility list&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Sources: &lt;a href=&quot;https://networkupstools.org/docs/user-manual.chunked/Configuration_notes.html&quot;&gt;Network UPS Tools — Configuration notes&lt;/a&gt;, &lt;a href=&quot;https://networkupstools.org/docs/man/upsmon.conf.html&quot;&gt;upsmon.conf(5) manual&lt;/a&gt;, &lt;a href=&quot;https://networkupstools.org/docs/man/dummy-ups.html&quot;&gt;dummy-ups(8) manual&lt;/a&gt;, &lt;a href=&quot;https://networkupstools.org/stable-hcl.html&quot;&gt;NUT Hardware Compatibility List&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;This post contains Amazon affiliate links (tag: buildahomelab-20). I earn a small commission on qualifying purchases at no extra cost to you.&lt;/em&gt;&lt;/p&gt;</content:encoded><category>monitoring</category><category>infrastructure</category></item><item><title>Proxmox Cloud-Init Templates: New VMs in 30 Seconds Flat</title><link>https://peira.dev/blog/proxmox-cloud-init-template/</link><guid isPermaLink="true">https://peira.dev/blog/proxmox-cloud-init-template/</guid><description>Turn an Ubuntu cloud image into a Proxmox template and clone ready-to-boot VMs in seconds — user, SSH key, and IP pre-configured by cloud-init, no installer wizard ever again.</description><pubDate>Mon, 20 Jul 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;You’ve &lt;a href=&quot;https://peira.dev/blog/proxmox-create-vm&quot;&gt;created a VM the traditional way&lt;/a&gt;: upload an ISO, click through the wizard, answer the installer’s questions, wait for packages to copy, install the guest agent. Twenty minutes, easy. Now imagine needing five more VMs this month — a test box, a Docker host, something to break on purpose. Nobody wants to sit through that installer five more times.&lt;/p&gt;
&lt;p&gt;This is the problem cloud-init templates solve. You build one golden master from a pre-installed cloud image, and from then on every new VM is a 30-second clone that boots already configured — your user, your SSH key, its own IP — no installer, no wizard, no console typing.&lt;/p&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;how-the-template-pipeline-works&quot;&gt;How the template pipeline works&lt;/h2&gt;
&lt;p&gt;Cloud-init is a small service baked into the “cloud image” builds of most Linux distributions. On a VM’s very first boot it looks for configuration — Proxmox hands it over on a tiny virtual CD-ROM called the cloud-init drive — and applies it: creates your user, installs your SSH key, configures the network. Then it gets out of the way forever.&lt;/p&gt;
&lt;svg viewBox=&quot;0 0 760 300&quot; xmlns=&quot;http://www.w3.org/2000/svg&quot; style=&quot;background:#1c1c32;border-radius:12px&quot; role=&quot;img&quot; aria-label=&quot;Diagram of the cloud-init template pipeline: a downloaded Ubuntu cloud image is imported into a Proxmox VM shell, converted into a read-only template, and then cloned into three ready-to-boot VMs, each receiving its own user, SSH key, and IP from the cloud-init drive&quot;&gt;&lt;defs&gt;&lt;pattern id=&quot;dots&quot; width=&quot;22&quot; height=&quot;22&quot; patternUnits=&quot;userSpaceOnUse&quot;&gt;&lt;circle cx=&quot;2&quot; cy=&quot;2&quot; r=&quot;1&quot; fill=&quot;#3a3a60&quot;&gt;&lt;/circle&gt;&lt;/pattern&gt;&lt;/defs&gt;&lt;rect x=&quot;0&quot; y=&quot;0&quot; width=&quot;760&quot; height=&quot;300&quot; fill=&quot;url(#dots)&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;380&quot; y=&quot;30&quot; text-anchor=&quot;middle&quot; fill=&quot;#f4f4f5&quot; font-size=&quot;15&quot; font-weight=&quot;bold&quot;&gt;One template, endless clones&lt;/text&gt;&lt;rect x=&quot;30&quot; y=&quot;70&quot; width=&quot;150&quot; height=&quot;90&quot; rx=&quot;10&quot; fill=&quot;#2c2c4c&quot; stroke=&quot;#3d3d5c&quot; stroke-width=&quot;1.5&quot;&gt;&lt;/rect&gt;&lt;path d=&quot;M 75 108 a14 14 0 0 1 27 -5 a11 11 0 0 1 3 21 h-32 a9 9 0 0 1 2 -16z&quot; fill=&quot;none&quot; stroke=&quot;#818cf8&quot; stroke-width=&quot;2&quot;&gt;&lt;/path&gt;&lt;text x=&quot;105&quot; y=&quot;145&quot; text-anchor=&quot;middle&quot; fill=&quot;#e5e7eb&quot; font-size=&quot;12&quot;&gt;Cloud image&lt;/text&gt;&lt;text x=&quot;105&quot; y=&quot;76&quot; text-anchor=&quot;middle&quot; fill=&quot;#6b7280&quot; font-size=&quot;9&quot;&gt;.img · pre-installed OS&lt;/text&gt;&lt;path d=&quot;M 185 115 H 235&quot; stroke=&quot;#14b8a6&quot; stroke-width=&quot;2&quot; fill=&quot;none&quot;&gt;&lt;/path&gt;&lt;path d=&quot;M 228 109 L 238 115 L 228 121&quot; fill=&quot;none&quot; stroke=&quot;#14b8a6&quot; stroke-width=&quot;2&quot;&gt;&lt;/path&gt;&lt;text x=&quot;211&quot; y=&quot;103&quot; text-anchor=&quot;middle&quot; fill=&quot;#a1a1aa&quot; font-size=&quot;9&quot;&gt;import&lt;/text&gt;&lt;rect x=&quot;240&quot; y=&quot;70&quot; width=&quot;160&quot; height=&quot;90&quot; rx=&quot;10&quot; fill=&quot;#30305a&quot; stroke=&quot;#4d4d82&quot; stroke-width=&quot;1.5&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;320&quot; y=&quot;100&quot; text-anchor=&quot;middle&quot; fill=&quot;#f4f4f5&quot; font-size=&quot;12&quot; font-weight=&quot;bold&quot;&gt;Template 9000&lt;/text&gt;&lt;text x=&quot;320&quot; y=&quot;118&quot; text-anchor=&quot;middle&quot; fill=&quot;#a1a1aa&quot; font-size=&quot;10&quot;&gt;read-only master&lt;/text&gt;&lt;rect x=&quot;262&quot; y=&quot;128&quot; width=&quot;116&quot; height=&quot;18&quot; rx=&quot;4&quot; fill=&quot;#0d0d1a&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;320&quot; y=&quot;141&quot; text-anchor=&quot;middle&quot; fill=&quot;#f59e0b&quot; font-size=&quot;9&quot;&gt;LOCKED — never boots&lt;/text&gt;&lt;path d=&quot;M 405 100 C 450 85, 470 85, 510 92&quot; stroke=&quot;#14b8a6&quot; stroke-width=&quot;2&quot; fill=&quot;none&quot;&gt;&lt;/path&gt;&lt;path d=&quot;M 502 87 L 512 93 L 501 97&quot; fill=&quot;none&quot; stroke=&quot;#14b8a6&quot; stroke-width=&quot;2&quot;&gt;&lt;/path&gt;&lt;path d=&quot;M 405 115 H 510&quot; stroke=&quot;#14b8a6&quot; stroke-width=&quot;2&quot; fill=&quot;none&quot;&gt;&lt;/path&gt;&lt;path d=&quot;M 503 109 L 513 115 L 503 121&quot; fill=&quot;none&quot; stroke=&quot;#14b8a6&quot; stroke-width=&quot;2&quot;&gt;&lt;/path&gt;&lt;path d=&quot;M 405 130 C 450 145, 470 145, 510 138&quot; stroke=&quot;#14b8a6&quot; stroke-width=&quot;2&quot; fill=&quot;none&quot;&gt;&lt;/path&gt;&lt;path d=&quot;M 501 133 L 512 137 L 502 143&quot; fill=&quot;none&quot; stroke=&quot;#14b8a6&quot; stroke-width=&quot;2&quot;&gt;&lt;/path&gt;&lt;text x=&quot;458&quot; y=&quot;165&quot; text-anchor=&quot;middle&quot; fill=&quot;#a1a1aa&quot; font-size=&quot;9&quot;&gt;clone · ~30 s each&lt;/text&gt;&lt;rect x=&quot;520&quot; y=&quot;58&quot; width=&quot;210&quot; height=&quot;54&quot; rx=&quot;8&quot; fill=&quot;#2c2c4c&quot; stroke=&quot;#3d3d5c&quot; stroke-width=&quot;1.5&quot;&gt;&lt;/rect&gt;&lt;circle cx=&quot;540&quot; cy=&quot;85&quot; r=&quot;5&quot; fill=&quot;#22c55e&quot;&gt;&lt;/circle&gt;&lt;text x=&quot;556&quot; y=&quot;82&quot; fill=&quot;#f4f4f5&quot; font-size=&quot;11&quot; font-weight=&quot;bold&quot;&gt;web01&lt;/text&gt;&lt;text x=&quot;556&quot; y=&quot;98&quot; fill=&quot;#a1a1aa&quot; font-size=&quot;9&quot;&gt;10.0.0.41 · your key · booted&lt;/text&gt;&lt;rect x=&quot;520&quot; y=&quot;123&quot; width=&quot;210&quot; height=&quot;54&quot; rx=&quot;8&quot; fill=&quot;#2c2c4c&quot; stroke=&quot;#3d3d5c&quot; stroke-width=&quot;1.5&quot;&gt;&lt;/rect&gt;&lt;circle cx=&quot;540&quot; cy=&quot;150&quot; r=&quot;5&quot; fill=&quot;#22c55e&quot;&gt;&lt;/circle&gt;&lt;text x=&quot;556&quot; y=&quot;147&quot; fill=&quot;#f4f4f5&quot; font-size=&quot;11&quot; font-weight=&quot;bold&quot;&gt;docker01&lt;/text&gt;&lt;text x=&quot;556&quot; y=&quot;163&quot; fill=&quot;#a1a1aa&quot; font-size=&quot;9&quot;&gt;10.0.0.42 · your key · booted&lt;/text&gt;&lt;rect x=&quot;520&quot; y=&quot;188&quot; width=&quot;210&quot; height=&quot;54&quot; rx=&quot;8&quot; fill=&quot;#2c2c4c&quot; stroke=&quot;#3d3d5c&quot; stroke-width=&quot;1.5&quot;&gt;&lt;/rect&gt;&lt;circle cx=&quot;540&quot; cy=&quot;215&quot; r=&quot;5&quot; fill=&quot;#22c55e&quot;&gt;&lt;/circle&gt;&lt;text x=&quot;556&quot; y=&quot;212&quot; fill=&quot;#f4f4f5&quot; font-size=&quot;11&quot; font-weight=&quot;bold&quot;&gt;test01&lt;/text&gt;&lt;text x=&quot;556&quot; y=&quot;228&quot; fill=&quot;#a1a1aa&quot; font-size=&quot;9&quot;&gt;10.0.0.43 · your key · booted&lt;/text&gt;&lt;rect x=&quot;30&quot; y=&quot;200&quot; width=&quot;370&quot; height=&quot;72&quot; rx=&quot;10&quot; fill=&quot;#262640&quot; stroke=&quot;#3d3d5c&quot; stroke-width=&quot;1.5&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;46&quot; y=&quot;222&quot; fill=&quot;#14b8a6&quot; font-size=&quot;10&quot; font-weight=&quot;bold&quot;&gt;cloud-init drive (virtual CD-ROM)&lt;/text&gt;&lt;text x=&quot;46&quot; y=&quot;240&quot; fill=&quot;#e5e7eb&quot; font-size=&quot;10&quot;&gt;user · password · SSH key · IP config&lt;/text&gt;&lt;text x=&quot;46&quot; y=&quot;258&quot; fill=&quot;#6b7280&quot; font-size=&quot;9&quot;&gt;read once on first boot, then ignored forever&lt;/text&gt;&lt;path d=&quot;M 400 236 C 450 236, 470 210, 515 172&quot; stroke=&quot;#818cf8&quot; stroke-width=&quot;1.5&quot; stroke-dasharray=&quot;4,4&quot; fill=&quot;none&quot;&gt;&lt;/path&gt;&lt;/svg&gt;
&lt;p&gt;The pipeline has three stages: download a cloud image, wrap it in a VM shell and convert that to a &lt;strong&gt;template&lt;/strong&gt; (a read-only master Proxmox will never boot), then clone the template whenever you need a fresh VM. The clone reads its personal settings from the cloud-init drive on first boot and comes up ready for SSH.&lt;/p&gt;
&lt;div class=&quot;callout callout-warning&quot; data-astro-cid-q2ml7llr&gt;&lt;div class=&quot;callout-header&quot; data-astro-cid-q2ml7llr&gt;&lt;span class=&quot;callout-icon&quot; aria-hidden=&quot;true&quot; data-astro-cid-q2ml7llr&gt;⚠️&lt;/span&gt;&lt;span class=&quot;callout-label&quot; data-astro-cid-q2ml7llr&gt;Warning&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;callout-body&quot; data-astro-cid-q2ml7llr&gt;&lt;p&gt;&lt;strong&gt;First: make these values your own.&lt;/strong&gt; The commands below use placeholders — swap them before running: &lt;code&gt;local-lvm&lt;/code&gt; (your VM disk storage; check &lt;a href=&quot;https://peira.dev/blog/proxmox-storage-guide&quot;&gt;your storage setup&lt;/a&gt;), &lt;code&gt;local&lt;/code&gt; (your ISO/snippet storage), &lt;code&gt;9000&lt;/code&gt; (template VMID), &lt;code&gt;10.0.0.41/24&lt;/code&gt; and &lt;code&gt;10.0.0.1&lt;/code&gt; (example IP and gateway on a dummy &lt;code&gt;10.0.0.x&lt;/code&gt; network — use your own subnet), &lt;code&gt;ubuntu&lt;/code&gt; (the username you want), and the path to your SSH public key.&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;task-1-download-the-ubuntu-cloud-image&quot;&gt;Task 1: Download the Ubuntu cloud image&lt;/h2&gt;
&lt;div class=&quot;step&quot; data-astro-cid-wwaqy5cd&gt;&lt;div class=&quot;step-header&quot; data-astro-cid-wwaqy5cd&gt;&lt;span class=&quot;step-num&quot; data-astro-cid-wwaqy5cd&gt;1&lt;/span&gt;&lt;span class=&quot;step-title&quot; data-astro-cid-wwaqy5cd&gt;Grab the image on the Proxmox host&lt;/span&gt;&lt;span class=&quot;step-time&quot; data-astro-cid-wwaqy5cd&gt;2 min&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;step-body&quot; data-astro-cid-wwaqy5cd&gt;&lt;p&gt;SSH into your Proxmox node and download Ubuntu’s current LTS cloud image. Note it’s an &lt;code&gt;.img&lt;/code&gt;, not an &lt;code&gt;.iso&lt;/code&gt; — there is no installer inside, just a finished OS.&lt;/p&gt;&lt;figure class=&quot;terminal&quot; data-astro-cid-kenkipkp&gt;&lt;figcaption class=&quot;terminal-bar&quot; data-astro-cid-kenkipkp&gt;&lt;span class=&quot;dot red&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot yellow&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot green&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;terminal-title&quot; data-astro-cid-kenkipkp&gt;On the Proxmox host&lt;/span&gt;&lt;button class=&quot;terminal-copy&quot; type=&quot;button&quot; aria-label=&quot;Copy commands&quot; data-astro-cid-kenkipkp&gt;&lt;svg width=&quot;13&quot; height=&quot;13&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;none&quot; stroke=&quot;currentColor&quot; stroke-width=&quot;2&quot; stroke-linecap=&quot;round&quot; stroke-linejoin=&quot;round&quot; aria-hidden=&quot;true&quot; data-astro-cid-kenkipkp&gt;&lt;rect x=&quot;9&quot; y=&quot;9&quot; width=&quot;13&quot; height=&quot;13&quot; rx=&quot;2&quot; data-astro-cid-kenkipkp&gt;&lt;/rect&gt;&lt;path d=&quot;M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1&quot; data-astro-cid-kenkipkp&gt;&lt;/path&gt;&lt;/svg&gt;&lt;span class=&quot;terminal-copy-label&quot; data-astro-cid-kenkipkp&gt;Copy&lt;/span&gt;&lt;/button&gt;&lt;/figcaption&gt;&lt;pre class=&quot;terminal-body&quot; data-astro-cid-kenkipkp&gt;&lt;code data-astro-cid-kenkipkp&gt;
wget https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-amd64.img \
-O /var/lib/vz/template/iso/noble-server-cloudimg-amd64.img
&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;p&gt;Debian, Fedora, Rocky, and openSUSE all publish equivalent cloud images — the rest of this guide works the same with any of them.&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;task-2-build-the-vm-shell&quot;&gt;Task 2: Build the VM shell&lt;/h2&gt;
&lt;div class=&quot;step&quot; data-astro-cid-wwaqy5cd&gt;&lt;div class=&quot;step-header&quot; data-astro-cid-wwaqy5cd&gt;&lt;span class=&quot;step-num&quot; data-astro-cid-wwaqy5cd&gt;1&lt;/span&gt;&lt;span class=&quot;step-title&quot; data-astro-cid-wwaqy5cd&gt;Create an empty VM&lt;/span&gt;&lt;span class=&quot;step-time&quot; data-astro-cid-wwaqy5cd&gt;2 min&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;step-body&quot; data-astro-cid-wwaqy5cd&gt;&lt;p&gt;This creates the virtual hardware only — no disk yet. The flags mirror the wizard fields from &lt;a href=&quot;https://peira.dev/blog/proxmox-create-vm&quot;&gt;the VM walkthrough&lt;/a&gt;, with one addition: a serial console, which cloud images expect.&lt;/p&gt;&lt;figure class=&quot;terminal&quot; data-astro-cid-kenkipkp&gt;&lt;figcaption class=&quot;terminal-bar&quot; data-astro-cid-kenkipkp&gt;&lt;span class=&quot;dot red&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot yellow&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot green&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;terminal-title&quot; data-astro-cid-kenkipkp&gt;Create the shell&lt;/span&gt;&lt;button class=&quot;terminal-copy&quot; type=&quot;button&quot; aria-label=&quot;Copy commands&quot; data-astro-cid-kenkipkp&gt;&lt;svg width=&quot;13&quot; height=&quot;13&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;none&quot; stroke=&quot;currentColor&quot; stroke-width=&quot;2&quot; stroke-linecap=&quot;round&quot; stroke-linejoin=&quot;round&quot; aria-hidden=&quot;true&quot; data-astro-cid-kenkipkp&gt;&lt;rect x=&quot;9&quot; y=&quot;9&quot; width=&quot;13&quot; height=&quot;13&quot; rx=&quot;2&quot; data-astro-cid-kenkipkp&gt;&lt;/rect&gt;&lt;path d=&quot;M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1&quot; data-astro-cid-kenkipkp&gt;&lt;/path&gt;&lt;/svg&gt;&lt;span class=&quot;terminal-copy-label&quot; data-astro-cid-kenkipkp&gt;Copy&lt;/span&gt;&lt;/button&gt;&lt;/figcaption&gt;&lt;pre class=&quot;terminal-body&quot; data-astro-cid-kenkipkp&gt;&lt;code data-astro-cid-kenkipkp&gt;
qm create 9000 \
--name ubuntu-2404-template \
--machine q35 --bios ovmf --efidisk0 local-lvm:0,efitype=4m,pre-enrolled-keys=1 \
--ostype l26 \
--cpu host --cores 2 --memory 2048 --balloon 1 \
--scsihw virtio-scsi-single \
--net0 virtio,bridge=vmbr0 \
--serial0 socket --vga serial0 \
--agent enabled=1
&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;p&gt;Using VMID &lt;code&gt;9000&lt;/code&gt; keeps templates visually separated from real VMs — a convention, not a requirement, but a good one. And &lt;code&gt;efitype=4m,pre-enrolled-keys=1&lt;/code&gt; on the EFI disk is not optional dressing: the CLI’s bare default creates the legacy 2m variant, and when I verified this guide on my own cluster, that VM hung silently before the bootloader — the 4m variant booted cleanly.&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;
&lt;div class=&quot;step&quot; data-astro-cid-wwaqy5cd&gt;&lt;div class=&quot;step-header&quot; data-astro-cid-wwaqy5cd&gt;&lt;span class=&quot;step-num&quot; data-astro-cid-wwaqy5cd&gt;2&lt;/span&gt;&lt;span class=&quot;step-title&quot; data-astro-cid-wwaqy5cd&gt;Import the cloud image as the boot disk&lt;/span&gt;&lt;span class=&quot;step-time&quot; data-astro-cid-wwaqy5cd&gt;2 min&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;step-body&quot; data-astro-cid-wwaqy5cd&gt;&lt;figure class=&quot;terminal&quot; data-astro-cid-kenkipkp&gt;&lt;figcaption class=&quot;terminal-bar&quot; data-astro-cid-kenkipkp&gt;&lt;span class=&quot;dot red&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot yellow&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot green&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;terminal-title&quot; data-astro-cid-kenkipkp&gt;Attach the image and make it bootable&lt;/span&gt;&lt;button class=&quot;terminal-copy&quot; type=&quot;button&quot; aria-label=&quot;Copy commands&quot; data-astro-cid-kenkipkp&gt;&lt;svg width=&quot;13&quot; height=&quot;13&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;none&quot; stroke=&quot;currentColor&quot; stroke-width=&quot;2&quot; stroke-linecap=&quot;round&quot; stroke-linejoin=&quot;round&quot; aria-hidden=&quot;true&quot; data-astro-cid-kenkipkp&gt;&lt;rect x=&quot;9&quot; y=&quot;9&quot; width=&quot;13&quot; height=&quot;13&quot; rx=&quot;2&quot; data-astro-cid-kenkipkp&gt;&lt;/rect&gt;&lt;path d=&quot;M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1&quot; data-astro-cid-kenkipkp&gt;&lt;/path&gt;&lt;/svg&gt;&lt;span class=&quot;terminal-copy-label&quot; data-astro-cid-kenkipkp&gt;Copy&lt;/span&gt;&lt;/button&gt;&lt;/figcaption&gt;&lt;pre class=&quot;terminal-body&quot; data-astro-cid-kenkipkp&gt;&lt;code data-astro-cid-kenkipkp&gt;
qm set 9000 --scsi0 local-lvm:0,import-from=/var/lib/vz/template/iso/noble-server-cloudimg-amd64.img,discard=on,ssd=1
qm set 9000 --boot order=scsi0
&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;p&gt;&lt;code&gt;import-from&lt;/code&gt; copies the downloaded image into your VM storage as the template’s disk. Don’t worry that it’s only ~3.5 GB — clones grow their disks later.&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;
&lt;div class=&quot;step&quot; data-astro-cid-wwaqy5cd&gt;&lt;div class=&quot;step-header&quot; data-astro-cid-wwaqy5cd&gt;&lt;span class=&quot;step-num&quot; data-astro-cid-wwaqy5cd&gt;3&lt;/span&gt;&lt;span class=&quot;step-title&quot; data-astro-cid-wwaqy5cd&gt;Add the cloud-init drive&lt;/span&gt;&lt;span class=&quot;step-time&quot; data-astro-cid-wwaqy5cd&gt;1 min&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;step-body&quot; data-astro-cid-wwaqy5cd&gt;&lt;figure class=&quot;terminal&quot; data-astro-cid-kenkipkp&gt;&lt;figcaption class=&quot;terminal-bar&quot; data-astro-cid-kenkipkp&gt;&lt;span class=&quot;dot red&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot yellow&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot green&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;terminal-title&quot; data-astro-cid-kenkipkp&gt;The drive cloud-init reads on first boot&lt;/span&gt;&lt;button class=&quot;terminal-copy&quot; type=&quot;button&quot; aria-label=&quot;Copy commands&quot; data-astro-cid-kenkipkp&gt;&lt;svg width=&quot;13&quot; height=&quot;13&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;none&quot; stroke=&quot;currentColor&quot; stroke-width=&quot;2&quot; stroke-linecap=&quot;round&quot; stroke-linejoin=&quot;round&quot; aria-hidden=&quot;true&quot; data-astro-cid-kenkipkp&gt;&lt;rect x=&quot;9&quot; y=&quot;9&quot; width=&quot;13&quot; height=&quot;13&quot; rx=&quot;2&quot; data-astro-cid-kenkipkp&gt;&lt;/rect&gt;&lt;path d=&quot;M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1&quot; data-astro-cid-kenkipkp&gt;&lt;/path&gt;&lt;/svg&gt;&lt;span class=&quot;terminal-copy-label&quot; data-astro-cid-kenkipkp&gt;Copy&lt;/span&gt;&lt;/button&gt;&lt;/figcaption&gt;&lt;pre class=&quot;terminal-body&quot; data-astro-cid-kenkipkp&gt;&lt;code data-astro-cid-kenkipkp&gt;
qm set 9000 --ide2 local-lvm:cloudinit
&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;p&gt;This is the magic piece: a tiny virtual CD-ROM Proxmox regenerates for every clone, carrying that clone’s personal user, key, and network settings.&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;task-3-convert-it-to-a-template&quot;&gt;Task 3: Convert it to a template&lt;/h2&gt;
&lt;div class=&quot;step&quot; data-astro-cid-wwaqy5cd&gt;&lt;div class=&quot;step-header&quot; data-astro-cid-wwaqy5cd&gt;&lt;span class=&quot;step-num&quot; data-astro-cid-wwaqy5cd&gt;1&lt;/span&gt;&lt;span class=&quot;step-title&quot; data-astro-cid-wwaqy5cd&gt;Freeze the master&lt;/span&gt;&lt;span class=&quot;step-time&quot; data-astro-cid-wwaqy5cd&gt;30 sec&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;step-body&quot; data-astro-cid-wwaqy5cd&gt;&lt;figure class=&quot;terminal&quot; data-astro-cid-kenkipkp&gt;&lt;figcaption class=&quot;terminal-bar&quot; data-astro-cid-kenkipkp&gt;&lt;span class=&quot;dot red&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot yellow&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot green&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;terminal-title&quot; data-astro-cid-kenkipkp&gt;One-way door — templates never boot again&lt;/span&gt;&lt;button class=&quot;terminal-copy&quot; type=&quot;button&quot; aria-label=&quot;Copy commands&quot; data-astro-cid-kenkipkp&gt;&lt;svg width=&quot;13&quot; height=&quot;13&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;none&quot; stroke=&quot;currentColor&quot; stroke-width=&quot;2&quot; stroke-linecap=&quot;round&quot; stroke-linejoin=&quot;round&quot; aria-hidden=&quot;true&quot; data-astro-cid-kenkipkp&gt;&lt;rect x=&quot;9&quot; y=&quot;9&quot; width=&quot;13&quot; height=&quot;13&quot; rx=&quot;2&quot; data-astro-cid-kenkipkp&gt;&lt;/rect&gt;&lt;path d=&quot;M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1&quot; data-astro-cid-kenkipkp&gt;&lt;/path&gt;&lt;/svg&gt;&lt;span class=&quot;terminal-copy-label&quot; data-astro-cid-kenkipkp&gt;Copy&lt;/span&gt;&lt;/button&gt;&lt;/figcaption&gt;&lt;pre class=&quot;terminal-body&quot; data-astro-cid-kenkipkp&gt;&lt;code data-astro-cid-kenkipkp&gt;
qm template 9000
&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;p&gt;The VM’s icon changes in the web UI and Proxmox now refuses to start it. That’s the point: the master can never drift, so every clone starts from the same known-good state. (Official cloud images ship with a blank machine-id, which prevents the classic duplicate-DHCP-lease problem — see the FAQ if you build a template from a hand-installed VM instead.)&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;task-4-clone-and-personalize&quot;&gt;Task 4: Clone and personalize&lt;/h2&gt;
&lt;div class=&quot;step&quot; data-astro-cid-wwaqy5cd&gt;&lt;div class=&quot;step-header&quot; data-astro-cid-wwaqy5cd&gt;&lt;span class=&quot;step-num&quot; data-astro-cid-wwaqy5cd&gt;1&lt;/span&gt;&lt;span class=&quot;step-title&quot; data-astro-cid-wwaqy5cd&gt;Clone the template&lt;/span&gt;&lt;span class=&quot;step-time&quot; data-astro-cid-wwaqy5cd&gt;30 sec&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;step-body&quot; data-astro-cid-wwaqy5cd&gt;&lt;figure class=&quot;terminal&quot; data-astro-cid-kenkipkp&gt;&lt;figcaption class=&quot;terminal-bar&quot; data-astro-cid-kenkipkp&gt;&lt;span class=&quot;dot red&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot yellow&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot green&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;terminal-title&quot; data-astro-cid-kenkipkp&gt;A new VM in one command&lt;/span&gt;&lt;button class=&quot;terminal-copy&quot; type=&quot;button&quot; aria-label=&quot;Copy commands&quot; data-astro-cid-kenkipkp&gt;&lt;svg width=&quot;13&quot; height=&quot;13&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;none&quot; stroke=&quot;currentColor&quot; stroke-width=&quot;2&quot; stroke-linecap=&quot;round&quot; stroke-linejoin=&quot;round&quot; aria-hidden=&quot;true&quot; data-astro-cid-kenkipkp&gt;&lt;rect x=&quot;9&quot; y=&quot;9&quot; width=&quot;13&quot; height=&quot;13&quot; rx=&quot;2&quot; data-astro-cid-kenkipkp&gt;&lt;/rect&gt;&lt;path d=&quot;M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1&quot; data-astro-cid-kenkipkp&gt;&lt;/path&gt;&lt;/svg&gt;&lt;span class=&quot;terminal-copy-label&quot; data-astro-cid-kenkipkp&gt;Copy&lt;/span&gt;&lt;/button&gt;&lt;/figcaption&gt;&lt;pre class=&quot;terminal-body&quot; data-astro-cid-kenkipkp&gt;&lt;code data-astro-cid-kenkipkp&gt;
qm clone 9000 141 --name web01 --full
&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;p&gt;&lt;code&gt;--full&lt;/code&gt; makes an independent copy. On thin-provisioned storage you can drop it to get a linked clone — near-instant and space-efficient, at the cost of depending on the template’s base disk. For a small homelab, full clones are the simpler default.&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;
&lt;div class=&quot;step&quot; data-astro-cid-wwaqy5cd&gt;&lt;div class=&quot;step-header&quot; data-astro-cid-wwaqy5cd&gt;&lt;span class=&quot;step-num&quot; data-astro-cid-wwaqy5cd&gt;2&lt;/span&gt;&lt;span class=&quot;step-title&quot; data-astro-cid-wwaqy5cd&gt;Give it a user, a key, and an IP&lt;/span&gt;&lt;span class=&quot;step-time&quot; data-astro-cid-wwaqy5cd&gt;1 min&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;step-body&quot; data-astro-cid-wwaqy5cd&gt;&lt;figure class=&quot;terminal&quot; data-astro-cid-kenkipkp&gt;&lt;figcaption class=&quot;terminal-bar&quot; data-astro-cid-kenkipkp&gt;&lt;span class=&quot;dot red&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot yellow&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot green&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;terminal-title&quot; data-astro-cid-kenkipkp&gt;Personalize via cloud-init&lt;/span&gt;&lt;button class=&quot;terminal-copy&quot; type=&quot;button&quot; aria-label=&quot;Copy commands&quot; data-astro-cid-kenkipkp&gt;&lt;svg width=&quot;13&quot; height=&quot;13&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;none&quot; stroke=&quot;currentColor&quot; stroke-width=&quot;2&quot; stroke-linecap=&quot;round&quot; stroke-linejoin=&quot;round&quot; aria-hidden=&quot;true&quot; data-astro-cid-kenkipkp&gt;&lt;rect x=&quot;9&quot; y=&quot;9&quot; width=&quot;13&quot; height=&quot;13&quot; rx=&quot;2&quot; data-astro-cid-kenkipkp&gt;&lt;/rect&gt;&lt;path d=&quot;M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1&quot; data-astro-cid-kenkipkp&gt;&lt;/path&gt;&lt;/svg&gt;&lt;span class=&quot;terminal-copy-label&quot; data-astro-cid-kenkipkp&gt;Copy&lt;/span&gt;&lt;/button&gt;&lt;/figcaption&gt;&lt;pre class=&quot;terminal-body&quot; data-astro-cid-kenkipkp&gt;&lt;code data-astro-cid-kenkipkp&gt;
qm set 141 --ciuser ubuntu --sshkeys ~/.ssh/id_ed25519.pub
qm set 141 --ipconfig0 ip=10.0.0.41/24,gw=10.0.0.1
qm resize 141 scsi0 +28G
&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;p&gt;Each flag maps to the VM’s &lt;strong&gt;Cloud-Init&lt;/strong&gt; tab in the web UI, where you can see and edit the same values. &lt;code&gt;--ipconfig0 ip=dhcp&lt;/code&gt; works too if you’d rather let your router assign the address. The resize grows the tiny cloud-image disk to a useful 32 GB total — cloud-init expands the filesystem to match on the next boot, automatically.&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;
&lt;div class=&quot;step&quot; data-astro-cid-wwaqy5cd&gt;&lt;div class=&quot;step-header&quot; data-astro-cid-wwaqy5cd&gt;&lt;span class=&quot;step-num&quot; data-astro-cid-wwaqy5cd&gt;3&lt;/span&gt;&lt;span class=&quot;step-title&quot; data-astro-cid-wwaqy5cd&gt;Boot and log in&lt;/span&gt;&lt;span class=&quot;step-time&quot; data-astro-cid-wwaqy5cd&gt;1 min&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;step-body&quot; data-astro-cid-wwaqy5cd&gt;&lt;figure class=&quot;terminal&quot; data-astro-cid-kenkipkp&gt;&lt;figcaption class=&quot;terminal-bar&quot; data-astro-cid-kenkipkp&gt;&lt;span class=&quot;dot red&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot yellow&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot green&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;terminal-title&quot; data-astro-cid-kenkipkp&gt;First boot — already configured&lt;/span&gt;&lt;button class=&quot;terminal-copy&quot; type=&quot;button&quot; aria-label=&quot;Copy commands&quot; data-astro-cid-kenkipkp&gt;&lt;svg width=&quot;13&quot; height=&quot;13&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;none&quot; stroke=&quot;currentColor&quot; stroke-width=&quot;2&quot; stroke-linecap=&quot;round&quot; stroke-linejoin=&quot;round&quot; aria-hidden=&quot;true&quot; data-astro-cid-kenkipkp&gt;&lt;rect x=&quot;9&quot; y=&quot;9&quot; width=&quot;13&quot; height=&quot;13&quot; rx=&quot;2&quot; data-astro-cid-kenkipkp&gt;&lt;/rect&gt;&lt;path d=&quot;M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1&quot; data-astro-cid-kenkipkp&gt;&lt;/path&gt;&lt;/svg&gt;&lt;span class=&quot;terminal-copy-label&quot; data-astro-cid-kenkipkp&gt;Copy&lt;/span&gt;&lt;/button&gt;&lt;/figcaption&gt;&lt;pre class=&quot;terminal-body&quot; data-astro-cid-kenkipkp&gt;&lt;code data-astro-cid-kenkipkp&gt;
qm start 141
ssh ubuntu@10.0.0.41
&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;p&gt;No console, no installer, no password prompts — the VM comes up with your user created and your key installed. First boot takes a little longer than usual while cloud-init does its one-time work; every boot after that is normal speed.&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;
&lt;div class=&quot;callout callout-warning&quot; data-astro-cid-q2ml7llr&gt;&lt;div class=&quot;callout-header&quot; data-astro-cid-q2ml7llr&gt;&lt;span class=&quot;callout-icon&quot; aria-hidden=&quot;true&quot; data-astro-cid-q2ml7llr&gt;⚠️&lt;/span&gt;&lt;span class=&quot;callout-label&quot; data-astro-cid-q2ml7llr&gt;Warning&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;callout-body&quot; data-astro-cid-q2ml7llr&gt;&lt;p&gt;One honest gotcha from verifying this on my own cluster: Ubuntu’s cloud image does &lt;strong&gt;not&lt;/strong&gt; ship the &lt;code&gt;qemu-guest-agent&lt;/code&gt; package, so IP reporting and clean shutdowns don’t work until you install it. Either add it per clone (&lt;code&gt;ssh&lt;/code&gt; in and &lt;code&gt;sudo apt install -y qemu-guest-agent&lt;/code&gt;) or bake it into the template once before the &lt;code&gt;qm template&lt;/code&gt; step with &lt;code&gt;virt-customize -a noble-server-cloudimg-amd64.img --install qemu-guest-agent&lt;/code&gt; (from &lt;code&gt;libguestfs-tools&lt;/code&gt;). The &lt;code&gt;--agent enabled=1&lt;/code&gt; flag on the template means Proxmox starts listening the moment the package lands.&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;when-to-use-this-and-when-not-to&quot;&gt;When to use this (and when not to)&lt;/h2&gt;
&lt;p&gt;Cloud-init templates shine for anything you create more than once: Docker hosts, test machines, &lt;a href=&quot;https://peira.dev/blog/proxmox-cluster-setup&quot;&gt;cluster&lt;/a&gt; workers, short-lived experiment boxes. The template is also the natural base for infrastructure-as-code tools — Terraform’s Proxmox provider clones these exact templates.&lt;/p&gt;
&lt;p&gt;They’re the wrong tool for one-off appliances with their own installers (a firewall distro, TrueNAS) — those still want the &lt;a href=&quot;https://peira.dev/blog/proxmox-create-vm&quot;&gt;traditional ISO route&lt;/a&gt;. And if the workload is Linux-only and doesn’t need its own kernel, remember that &lt;a href=&quot;https://peira.dev/blog/proxmox-vm-vs-lxc&quot;&gt;an LXC container&lt;/a&gt; is lighter than any VM, cloned or not.&lt;/p&gt;
&lt;p&gt;A closing reminder: every IP, VMID, storage name, and username above is a placeholder from a dummy network — substitute your own values throughout.&lt;/p&gt;
&lt;hr/&gt;
&lt;p&gt;&lt;strong&gt;Related posts:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/proxmox-create-vm&quot;&gt;Creating Your First Proxmox VM: A Complete Walkthrough&lt;/a&gt; — the manual ISO route this post automates away; read it first to understand the hardware choices&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/proxmox-vm-vs-lxc&quot;&gt;Proxmox VM vs LXC: When to Use Each&lt;/a&gt; — decide whether the workload even needs a VM&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/proxmox-create-lxc&quot;&gt;Creating Your First Proxmox LXC Container: Step-by-Step&lt;/a&gt; — the container equivalent of this workflow&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/proxmox-storage-guide&quot;&gt;Proxmox Storage Guide: local-lvm, ZFS, NFS, and When to Use Each&lt;/a&gt; — where linked vs full clones live and why storage choice matters&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/proxmox-backup-server&quot;&gt;Proxmox Backup Server: Automated CT and VM Backups with Deduplication&lt;/a&gt; — templates make VMs cheap; backups make them safe&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/proxmox-cluster-setup&quot;&gt;Forming a Proxmox Cluster: Quorum, Corosync, and Joining Multiple Nodes&lt;/a&gt; — clone templates across nodes once you cluster&lt;/li&gt;
&lt;/ul&gt;</content:encoded><category>proxmox</category><category>infrastructure</category></item><item><title>What Is a NAS? (And Which Kind Your Homelab Actually Needs)</title><link>https://peira.dev/blog/what-is-a-nas/</link><guid isPermaLink="true">https://peira.dev/blog/what-is-a-nas/</guid><description>NAS explained in plain English — what network-attached storage does, commercial box vs DIY server vs a share on your Proxmox host, and how to choose the right one for your budget.</description><pubDate>Sun, 19 Jul 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Somewhere around your third self-hosted service, the same question shows up for everyone: &lt;em&gt;where do all the files actually go?&lt;/em&gt; Your &lt;a href=&quot;https://peira.dev/blog/plex-media-server-nas-lxc/&quot;&gt;Plex library&lt;/a&gt; is growing, &lt;a href=&quot;https://peira.dev/blog/immich-proxmox-photos/&quot;&gt;Immich&lt;/a&gt; wants a home for ten years of photos, and stuffing everything onto the boot drive of one container feels — correctly — like a mistake waiting to happen. The answer the entire homelab world converges on is a NAS. This post explains what that actually means, walks through the three forms a NAS can take, and helps you pick the one that fits your budget and your appetite for tinkering.&lt;/p&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;what-a-nas-actually-is&quot;&gt;What a NAS actually is&lt;/h2&gt;
&lt;p&gt;NAS stands for &lt;em&gt;network-attached storage&lt;/em&gt;: a device whose whole job is to hold files and serve them to every other machine in your house over the network. That’s the entire trick. Instead of your movies living on the media server, your photos on a laptop, and your backups on a USB drive in a drawer, everything lives in one place that every device — TVs, phones, laptops, and your &lt;a href=&quot;https://peira.dev/blog/complete-proxmox-homelab-guide/&quot;&gt;Proxmox cluster&lt;/a&gt; — can reach.&lt;/p&gt;
&lt;p&gt;Under the hood, a NAS is just a computer with a lot of disk space running file-sharing protocols. The two you’ll meet constantly are SMB (the one &lt;a href=&quot;https://learn.microsoft.com/en-us/windows-server/storage/file-server/file-server-smb-overview&quot;&gt;Windows speaks natively&lt;/a&gt; — Macs and Linux speak it too) and NFS (the Unix-native option, and usually the better choice between Linux machines). We cover setting both up in &lt;a href=&quot;https://peira.dev/blog/homelab-nas-samba-shares/&quot;&gt;Shared Storage for Your Homelab&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;The important mental shift: &lt;strong&gt;a NAS is a role, not necessarily a product.&lt;/strong&gt; A $600 Synology box is a NAS. A ten-year-old PC running TrueNAS is a NAS. A Samba share in an LXC container on the Proxmox host you already own is &lt;em&gt;also&lt;/em&gt; a NAS. Which form is right for you is the real question.&lt;/p&gt;
&lt;svg viewBox=&quot;0 0 720 340&quot; xmlns=&quot;http://www.w3.org/2000/svg&quot; role=&quot;img&quot; aria-label=&quot;A NAS in the middle of a home network serving files to a TV, a laptop, a phone, and a Proxmox cluster&quot;&gt;&lt;rect width=&quot;720&quot; height=&quot;340&quot; fill=&quot;#1c1712&quot; rx=&quot;12&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;360&quot; y=&quot;34&quot; text-anchor=&quot;middle&quot; fill=&quot;#e8e1d3&quot; font-size=&quot;15&quot; font-weight=&quot;bold&quot; font-family=&quot;sans-serif&quot;&gt;One place for every file, served to everything&lt;/text&gt;&lt;rect x=&quot;290&quot; y=&quot;120&quot; width=&quot;140&quot; height=&quot;110&quot; rx=&quot;10&quot; fill=&quot;#262019&quot; stroke=&quot;#e8a33e&quot; stroke-width=&quot;2&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;360&quot; y=&quot;145&quot; text-anchor=&quot;middle&quot; fill=&quot;#e8a33e&quot; font-size=&quot;13&quot; font-weight=&quot;bold&quot; font-family=&quot;sans-serif&quot;&gt;NAS&lt;/text&gt;&lt;rect x=&quot;308&quot; y=&quot;158&quot; width=&quot;104&quot; height=&quot;14&quot; rx=&quot;3&quot; fill=&quot;#33291c&quot; stroke=&quot;#8a8071&quot; stroke-width=&quot;1&quot;&gt;&lt;/rect&gt;&lt;rect x=&quot;308&quot; y=&quot;178&quot; width=&quot;104&quot; height=&quot;14&quot; rx=&quot;3&quot; fill=&quot;#33291c&quot; stroke=&quot;#8a8071&quot; stroke-width=&quot;1&quot;&gt;&lt;/rect&gt;&lt;rect x=&quot;308&quot; y=&quot;198&quot; width=&quot;104&quot; height=&quot;14&quot; rx=&quot;3&quot; fill=&quot;#33291c&quot; stroke=&quot;#8a8071&quot; stroke-width=&quot;1&quot;&gt;&lt;/rect&gt;&lt;circle cx=&quot;403&quot; cy=&quot;165&quot; r=&quot;2.5&quot; fill=&quot;#8fb573&quot;&gt;&lt;/circle&gt;&lt;circle cx=&quot;403&quot; cy=&quot;185&quot; r=&quot;2.5&quot; fill=&quot;#8fb573&quot;&gt;&lt;/circle&gt;&lt;circle cx=&quot;403&quot; cy=&quot;205&quot; r=&quot;2.5&quot; fill=&quot;#8fb573&quot;&gt;&lt;/circle&gt;&lt;text x=&quot;360&quot; y=&quot;250&quot; text-anchor=&quot;middle&quot; fill=&quot;#8a8071&quot; font-size=&quot;10&quot; font-family=&quot;monospace&quot;&gt;movies · photos · backups · documents&lt;/text&gt;&lt;rect x=&quot;60&quot; y=&quot;80&quot; width=&quot;130&quot; height=&quot;52&quot; rx=&quot;8&quot; fill=&quot;#262019&quot; stroke=&quot;#6f9bd1&quot; stroke-width=&quot;1.5&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;125&quot; y=&quot;102&quot; text-anchor=&quot;middle&quot; fill=&quot;#6f9bd1&quot; font-size=&quot;12&quot; font-weight=&quot;bold&quot; font-family=&quot;sans-serif&quot;&gt;TV / streaming&lt;/text&gt;&lt;text x=&quot;125&quot; y=&quot;120&quot; text-anchor=&quot;middle&quot; fill=&quot;#8a8071&quot; font-size=&quot;10&quot; font-family=&quot;sans-serif&quot;&gt;plays movies via Plex&lt;/text&gt;&lt;rect x=&quot;60&quot; y=&quot;210&quot; width=&quot;130&quot; height=&quot;52&quot; rx=&quot;8&quot; fill=&quot;#262019&quot; stroke=&quot;#8fb573&quot; stroke-width=&quot;1.5&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;125&quot; y=&quot;232&quot; text-anchor=&quot;middle&quot; fill=&quot;#8fb573&quot; font-size=&quot;12&quot; font-weight=&quot;bold&quot; font-family=&quot;sans-serif&quot;&gt;Laptop&lt;/text&gt;&lt;text x=&quot;125&quot; y=&quot;250&quot; text-anchor=&quot;middle&quot; fill=&quot;#8a8071&quot; font-size=&quot;10&quot; font-family=&quot;sans-serif&quot;&gt;opens the shared folder&lt;/text&gt;&lt;rect x=&quot;530&quot; y=&quot;80&quot; width=&quot;130&quot; height=&quot;52&quot; rx=&quot;8&quot; fill=&quot;#262019&quot; stroke=&quot;#d9a13d&quot; stroke-width=&quot;1.5&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;595&quot; y=&quot;102&quot; text-anchor=&quot;middle&quot; fill=&quot;#d9a13d&quot; font-size=&quot;12&quot; font-weight=&quot;bold&quot; font-family=&quot;sans-serif&quot;&gt;Phone&lt;/text&gt;&lt;text x=&quot;595&quot; y=&quot;120&quot; text-anchor=&quot;middle&quot; fill=&quot;#8a8071&quot; font-size=&quot;10&quot; font-family=&quot;sans-serif&quot;&gt;backs up photos nightly&lt;/text&gt;&lt;rect x=&quot;530&quot; y=&quot;210&quot; width=&quot;130&quot; height=&quot;52&quot; rx=&quot;8&quot; fill=&quot;#262019&quot; stroke=&quot;#c96a52&quot; stroke-width=&quot;1.5&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;595&quot; y=&quot;232&quot; text-anchor=&quot;middle&quot; fill=&quot;#c96a52&quot; font-size=&quot;12&quot; font-weight=&quot;bold&quot; font-family=&quot;sans-serif&quot;&gt;Proxmox cluster&lt;/text&gt;&lt;text x=&quot;595&quot; y=&quot;250&quot; text-anchor=&quot;middle&quot; fill=&quot;#8a8071&quot; font-size=&quot;10&quot; font-family=&quot;sans-serif&quot;&gt;mounts media + backups&lt;/text&gt;&lt;line x1=&quot;190&quot; y1=&quot;106&quot; x2=&quot;290&quot; y2=&quot;150&quot; stroke=&quot;#6f9bd1&quot; stroke-width=&quot;1.5&quot;&gt;&lt;/line&gt;&lt;line x1=&quot;190&quot; y1=&quot;236&quot; x2=&quot;290&quot; y2=&quot;200&quot; stroke=&quot;#8fb573&quot; stroke-width=&quot;1.5&quot;&gt;&lt;/line&gt;&lt;line x1=&quot;530&quot; y1=&quot;106&quot; x2=&quot;430&quot; y2=&quot;150&quot; stroke=&quot;#d9a13d&quot; stroke-width=&quot;1.5&quot;&gt;&lt;/line&gt;&lt;line x1=&quot;530&quot; y1=&quot;236&quot; x2=&quot;430&quot; y2=&quot;200&quot; stroke=&quot;#c96a52&quot; stroke-width=&quot;1.5&quot;&gt;&lt;/line&gt;&lt;text x=&quot;360&quot; y=&quot;300&quot; text-anchor=&quot;middle&quot; fill=&quot;#8a8071&quot; font-size=&quot;11&quot; font-family=&quot;sans-serif&quot;&gt;Every device sees the same files — nothing lives in only one place anymore&lt;/text&gt;&lt;text x=&quot;710&quot; y=&quot;332&quot; text-anchor=&quot;end&quot; font-size=&quot;10&quot; fill=&quot;#8a8071&quot; opacity=&quot;0.6&quot; font-family=&quot;monospace&quot;&gt;peira.dev&lt;/text&gt;&lt;/svg&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;the-three-forms-a-nas-can-take&quot;&gt;The three forms a NAS can take&lt;/h2&gt;
&lt;h3 id=&quot;1-the-commercial-box-synology-qnap-ugreen-asustor&quot;&gt;1. The commercial box: Synology, QNAP, UGREEN, Asustor&lt;/h3&gt;
&lt;p&gt;This is the appliance route: a small plastic-or-metal enclosure with two to six drive bays, a low-power CPU, and a polished web interface that holds your hand through everything — RAID setup, shared folders, phone photo backup, even running Docker containers on the higher-end models. &lt;a href=&quot;https://www.synology.com/en-us/dsm&quot;&gt;Synology’s DSM&lt;/a&gt; is the reference point for how good this software can be; competitors have spent a decade chasing it.&lt;/p&gt;
&lt;figure&gt;&lt;img src=&quot;https://peira.dev/_astro/Synology_Disk_Station_DS223J_-_NAS-Server.CLfg5xFE_Z2k7fOU.webp&quot; alt=&quot;A Synology DiskStation DS223j, a typical two-bay commercial NAS enclosure&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot; width=&quot;900&quot; height=&quot;600&quot;&gt;&lt;figcaption&gt;A typical two-bay commercial NAS (Synology DS223j). Photo: DYVER, &lt;a href=&quot;https://creativecommons.org/licenses/by-sa/4.0/&quot;&gt;CC BY-SA 4.0&lt;/a&gt;, via &lt;a href=&quot;https://commons.wikimedia.org/wiki/File:Synology_Disk_Station_DS223J_-_NAS-Server.jpg&quot;&gt;Wikimedia Commons&lt;/a&gt;.&lt;/figcaption&gt;&lt;/figure&gt;
&lt;p&gt;&lt;strong&gt;Choose this when:&lt;/strong&gt; the data matters more than the hobby. If the NAS is holding the only copies of family photos and you want it working — reliably, with alerts and updates handled — by Sunday dinner, the premium is worth it. The trade-offs are real, though: you pay two to three times the raw hardware cost, the CPUs are usually anemic, and you’re inside one vendor’s ecosystem for as long as the box lives.&lt;/p&gt;
&lt;h3 id=&quot;2-the-diy-build-truenas-or-unraid-on-your-own-hardware&quot;&gt;2. The DIY build: TrueNAS or Unraid on your own hardware&lt;/h3&gt;
&lt;p&gt;Take any PC with room for a few drives — an old desktop tower is genuinely ideal — and install a purpose-built storage OS. &lt;a href=&quot;https://www.truenas.com/docs/&quot;&gt;TrueNAS&lt;/a&gt; is the free, open-source heavyweight built on ZFS, the filesystem that checksums every block of your data and heals silent corruption on the fly (we dig into why that matters in the &lt;a href=&quot;https://peira.dev/blog/proxmox-storage-guide/&quot;&gt;Proxmox storage guide&lt;/a&gt;). Unraid is its commercial cousin, beloved for letting you mix mismatched drive sizes.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Choose this when:&lt;/strong&gt; you want maximum storage per dollar and you’ve got the appetite to learn. A used tower plus two 8 TB drives costs less than an empty two-bay Synology, and ZFS is honestly the better filesystem. The trade-off is that &lt;em&gt;you&lt;/em&gt; are the support department — though if you’re reading this site, that may be a feature.&lt;/p&gt;
&lt;h3 id=&quot;3-the-share-on-hardware-you-already-own&quot;&gt;3. The share on hardware you already own&lt;/h3&gt;
&lt;p&gt;Here’s the option the buying guides skip, and the one most readers of this site should actually start with: your &lt;a href=&quot;https://peira.dev/blog/proxmox-install-guide/&quot;&gt;Proxmox host&lt;/a&gt; is already an always-on computer. Add a dedicated data drive, spin up a lightweight LXC container, and serve Samba and NFS from there — the complete walkthrough is in &lt;a href=&quot;https://peira.dev/blog/homelab-nas-samba-shares/&quot;&gt;Shared Storage for Your Homelab&lt;/a&gt;. Total cost: one hard drive.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Choose this when:&lt;/strong&gt; you’re starting out, the budget went to the mini PCs, and your data is measured in hundreds of gigabytes rather than tens of terabytes. You lose the separation between storage and compute — if the host is down, so are your files — but you can graduate to either of the other options later and simply move the drives.&lt;/p&gt;
&lt;div class=&quot;callout callout-warning&quot; data-astro-cid-q2ml7llr&gt;&lt;div class=&quot;callout-header&quot; data-astro-cid-q2ml7llr&gt;&lt;span class=&quot;callout-icon&quot; aria-hidden=&quot;true&quot; data-astro-cid-q2ml7llr&gt;⚠️&lt;/span&gt;&lt;span class=&quot;callout-label&quot; data-astro-cid-q2ml7llr&gt;RAID is not a backup — read this twice&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;callout-body&quot; data-astro-cid-q2ml7llr&gt;&lt;p&gt;Whatever form you pick, understand what RAID does and doesn’t do. RAID keeps the NAS &lt;em&gt;running&lt;/em&gt; when a drive fails. It does nothing about accidental deletion, ransomware, a lightning strike, or theft — whatever happens to the array happens to every “copy” in it. Follow the 3-2-1 rule: three copies of anything you can’t lose, on two different kinds of media, one of them off-site. Our &lt;a href=&quot;https://peira.dev/blog/proxmox-backup-server/&quot;&gt;Proxmox Backup Server guide&lt;/a&gt; covers the on-site half.&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;how-to-actually-choose&quot;&gt;How to actually choose&lt;/h2&gt;
&lt;p&gt;Skip the spec sheets for a moment and answer three questions.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;How much data, honestly?&lt;/strong&gt; Add up what you have, then double it — media libraries only grow. Under ~2 TB: the share-on-Proxmox route is fine. Two to 20 TB: any of the three works; the decision is about time and money. Beyond that you’re building a DIY box, because commercial bays get expensive fast.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;What happens if it dies on a Tuesday?&lt;/strong&gt; If the answer is “mild annoyance,” optimize for cost. If the answer involves irreplaceable photos, optimize for boring reliability: mirrored drives, a vendor (or an OS) that emails you when a disk starts failing, and a tested backup. On drive choice, ignore the marketing tiers and read &lt;a href=&quot;https://www.backblaze.com/cloud-storage/resources/hard-drive-test-data&quot;&gt;Backblaze’s published failure statistics&lt;/a&gt; — real annualized failure rates from hundreds of thousands of drives beat any “Pro” label.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Who maintains it?&lt;/strong&gt; A commercial box asks an hour a month. TrueNAS asks a weekend up front and occasional care after. The LXC share asks nothing you aren’t already doing to run Proxmox. Be honest about which future version of you shows up for maintenance.&lt;/p&gt;
&lt;p&gt;The quiet good news: this decision is reversible. Drives move between enclosures, files copy across the network, and the protocols — SMB and NFS — are identical in all three worlds. Most people who stay in this hobby own two or three of these forms over the years. Start with the one that fits today.&lt;/p&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;related-posts&quot;&gt;Related posts&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/homelab-nas-samba-shares/&quot;&gt;Shared Storage for Your Homelab: Samba and NFS Done Right&lt;/a&gt; — build the LXC-share version, step by step&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/proxmox-storage-guide/&quot;&gt;Proxmox Storage Guide: local-lvm, ZFS, NFS&lt;/a&gt; — the filesystems underneath all of this&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/proxmox-backup-server/&quot;&gt;Proxmox Backup Server&lt;/a&gt; — the backup half of the 3-2-1 rule&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/plex-media-server-nas-lxc/&quot;&gt;Install Plex on a NAS or Proxmox LXC&lt;/a&gt; — the most common thing a homelab NAS feeds&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/jellyfin-install-nas-lxc/&quot;&gt;Install Jellyfin on a NAS or Proxmox LXC&lt;/a&gt; — same, for the free-software route&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/immich-proxmox-photos/&quot;&gt;Self-Host Your Photos with Immich&lt;/a&gt; — why your phone backup deserves NAS storage&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/homelab-hardware-guide/&quot;&gt;Choosing Homelab Hardware&lt;/a&gt; — where a NAS fits in the bigger shopping list&lt;/li&gt;
&lt;/ul&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;recommended-hardware&quot;&gt;Recommended hardware&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Recommended hardware for this setup:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://www.amazon.com/s?k=Synology+DS224%2B&amp;amp;tag=buildahomelab-20&quot;&gt;Synology DS224+ 2-bay NAS&lt;/a&gt; — the “it just works” starting point with the best software in the appliance world&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://www.amazon.com/s?k=UGREEN+NASync+DXP2800&amp;amp;tag=buildahomelab-20&quot;&gt;UGREEN NASync DXP2800&lt;/a&gt; — newer challenger with much stronger hardware per dollar&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://www.amazon.com/s?k=WD+Red+Plus+8TB&amp;amp;tag=buildahomelab-20&quot;&gt;WD Red Plus 8TB NAS drive&lt;/a&gt; — CMR NAS-rated drive for any of the three builds&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://www.amazon.com/s?k=Seagate+IronWolf+8TB&amp;amp;tag=buildahomelab-20&quot;&gt;Seagate IronWolf 8TB&lt;/a&gt; — the other safe default; check Backblaze’s stats and buy whichever is cheaper&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Sources: &lt;a href=&quot;https://learn.microsoft.com/en-us/windows-server/storage/file-server/file-server-smb-overview&quot;&gt;Microsoft — SMB file sharing overview&lt;/a&gt;, &lt;a href=&quot;https://www.truenas.com/docs/&quot;&gt;TrueNAS documentation&lt;/a&gt;, &lt;a href=&quot;https://www.synology.com/en-us/dsm&quot;&gt;Synology DSM&lt;/a&gt;, &lt;a href=&quot;https://www.backblaze.com/cloud-storage/resources/hard-drive-test-data&quot;&gt;Backblaze Drive Stats&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;This post contains Amazon affiliate links (tag: buildahomelab-20). I earn a small commission on qualifying purchases at no extra cost to you.&lt;/em&gt;&lt;/p&gt;</content:encoded><category>getting-started</category><category>infrastructure</category><category>self-hosted</category></item><item><title>The Complete Proxmox Homelab Guide (2026)</title><link>https://peira.dev/blog/complete-proxmox-homelab-guide/</link><guid isPermaLink="true">https://peira.dev/blog/complete-proxmox-homelab-guide/</guid><description>A start-to-finish roadmap for building a Proxmox homelab: hardware, install, clustering, networking, storage, security, backups, and the self-hosted services worth running — each step linked to a full walkthrough.</description><pubDate>Fri, 17 Jul 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Building a homelab on &lt;a href=&quot;https://www.proxmox.com/en/proxmox-virtual-environment&quot;&gt;Proxmox VE&lt;/a&gt; can feel like standing in front of a wall of disconnected tutorials — one for installing, one for networking, twelve for whatever an LXC is — with no sense of which comes first or why. This guide is the map. It walks the whole journey &lt;strong&gt;in the order you should actually do it&lt;/strong&gt; — from choosing hardware to running your first self-hosted services — and links each step to a full, standalone walkthrough. You’ll never be told “just do X” without a guide that shows you how.&lt;/p&gt;
&lt;p&gt;Think of it as a table of contents for an entire homelab. Read it top to bottom the first time to see the shape of the whole thing; come back to it later as the hub you navigate from.&lt;/p&gt;
&lt;p&gt;I built the lab this describes one wrong turn at a time — this map is the one I wish someone had handed me. Today it’s four mini PCs running everything my household and my work actually depend on, but the sequence below is deliberately the order that would have saved me the most backtracking. If you take one thing from it: get a single node stable and backed up before you let the clustering rabbit hole pull you in.&lt;/p&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;the-homelab-in-three-layers&quot;&gt;The homelab, in three layers&lt;/h2&gt;
&lt;p&gt;Every durable homelab is built the same way: a &lt;strong&gt;foundation&lt;/strong&gt; you install once and rarely touch, a &lt;strong&gt;core platform&lt;/strong&gt; that makes it safe and reachable, and the &lt;strong&gt;services&lt;/strong&gt; — the actual reason you built any of it. Each layer rests on the one below it.&lt;/p&gt;
&lt;svg viewBox=&quot;0 0 760 320&quot; xmlns=&quot;http://www.w3.org/2000/svg&quot; style=&quot;width:100%;border-radius:12px;background:#1c1712;border:1px solid #443a2b;margin:2rem 0&quot; role=&quot;img&quot; aria-label=&quot;Three-layer homelab stack: foundation, core platform, and services&quot;&gt;&lt;defs&gt;&lt;pattern id=&quot;gp-completeprox0&quot; width=&quot;20&quot; height=&quot;20&quot; patternUnits=&quot;userSpaceOnUse&quot;&gt;&lt;path d=&quot;M 20 0 H 0 V 20&quot; fill=&quot;none&quot; stroke=&quot;#221c15&quot; stroke-width=&quot;1&quot;&gt;&lt;/path&gt;&lt;/pattern&gt;&lt;/defs&gt;&lt;rect x=&quot;0&quot; y=&quot;0&quot; width=&quot;760&quot; height=&quot;320&quot; fill=&quot;url(#gp-completeprox0)&quot;&gt;&lt;/rect&gt;&lt;rect x=&quot;6&quot; y=&quot;6&quot; width=&quot;748&quot; height=&quot;308&quot; fill=&quot;none&quot; stroke=&quot;#2e2820&quot; stroke-width=&quot;1&quot;&gt;&lt;/rect&gt;&lt;path d=&quot;M 11,16 H 21 M 16,11 V 21 M 739,16 H 749 M 744,11 V 21 M 11,304 H 21 M 16,299 V 309 M 739,304 H 749 M 744,299 V 309&quot; stroke=&quot;#4a3f2e&quot; stroke-width=&quot;1&quot; fill=&quot;none&quot;&gt;&lt;/path&gt;&lt;text x=&quot;380&quot; y=&quot;30&quot; text-anchor=&quot;middle&quot; fill=&quot;#f2ede3&quot; font-size=&quot;14&quot; font-family=&quot;monospace&quot; font-weight=&quot;bold&quot;&gt;The homelab stack — each layer built on the one below&lt;/text&gt;&lt;rect x=&quot;30&quot; y=&quot;52&quot; width=&quot;700&quot; height=&quot;64&quot; rx=&quot;8&quot; fill=&quot;#221c16&quot; stroke=&quot;#443a2b&quot; stroke-width=&quot;1&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;46&quot; y=&quot;78&quot; fill=&quot;#8fb573&quot; font-size=&quot;11&quot; font-family=&quot;monospace&quot; font-weight=&quot;bold&quot;&gt;WHAT YOU&lt;/text&gt;&lt;text x=&quot;46&quot; y=&quot;94&quot; fill=&quot;#8fb573&quot; font-size=&quot;11&quot; font-family=&quot;monospace&quot; font-weight=&quot;bold&quot;&gt;RUN&lt;/text&gt;&lt;rect x=&quot;150&quot; y=&quot;66&quot; width=&quot;132&quot; height=&quot;36&quot; rx=&quot;6&quot; fill=&quot;#262019&quot; stroke=&quot;#8fb573&quot; stroke-width=&quot;1.5&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;216&quot; y=&quot;89&quot; text-anchor=&quot;middle&quot; fill=&quot;#f2ede3&quot; font-size=&quot;11&quot; font-family=&quot;monospace&quot;&gt;Media (Plex/arr)&lt;/text&gt;&lt;rect x=&quot;294&quot; y=&quot;66&quot; width=&quot;128&quot; height=&quot;36&quot; rx=&quot;6&quot; fill=&quot;#262019&quot; stroke=&quot;#8fb573&quot; stroke-width=&quot;1.5&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;358&quot; y=&quot;89&quot; text-anchor=&quot;middle&quot; fill=&quot;#f2ede3&quot; font-size=&quot;11&quot; font-family=&quot;monospace&quot;&gt;Monitoring&lt;/text&gt;&lt;rect x=&quot;434&quot; y=&quot;66&quot; width=&quot;128&quot; height=&quot;36&quot; rx=&quot;6&quot; fill=&quot;#262019&quot; stroke=&quot;#8fb573&quot; stroke-width=&quot;1.5&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;498&quot; y=&quot;89&quot; text-anchor=&quot;middle&quot; fill=&quot;#f2ede3&quot; font-size=&quot;11&quot; font-family=&quot;monospace&quot;&gt;Remote access&lt;/text&gt;&lt;rect x=&quot;574&quot; y=&quot;66&quot; width=&quot;140&quot; height=&quot;36&quot; rx=&quot;6&quot; fill=&quot;#262019&quot; stroke=&quot;#8fb573&quot; stroke-width=&quot;1.5&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;644&quot; y=&quot;89&quot; text-anchor=&quot;middle&quot; fill=&quot;#f2ede3&quot; font-size=&quot;11&quot; font-family=&quot;monospace&quot;&gt;Local AI / apps&lt;/text&gt;&lt;text x=&quot;380&quot; y=&quot;132&quot; text-anchor=&quot;middle&quot; fill=&quot;#8a8071&quot; font-size=&quot;16&quot; font-family=&quot;monospace&quot;&gt;▲ built on ▲&lt;/text&gt;&lt;rect x=&quot;30&quot; y=&quot;144&quot; width=&quot;700&quot; height=&quot;64&quot; rx=&quot;8&quot; fill=&quot;#221c16&quot; stroke=&quot;#443a2b&quot; stroke-width=&quot;1&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;46&quot; y=&quot;170&quot; fill=&quot;#6f9bd1&quot; font-size=&quot;11&quot; font-family=&quot;monospace&quot; font-weight=&quot;bold&quot;&gt;CORE&lt;/text&gt;&lt;text x=&quot;46&quot; y=&quot;186&quot; fill=&quot;#6f9bd1&quot; font-size=&quot;11&quot; font-family=&quot;monospace&quot; font-weight=&quot;bold&quot;&gt;PLATFORM&lt;/text&gt;&lt;rect x=&quot;150&quot; y=&quot;158&quot; width=&quot;132&quot; height=&quot;36&quot; rx=&quot;6&quot; fill=&quot;#262019&quot; stroke=&quot;#6f9bd1&quot; stroke-width=&quot;1.5&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;216&quot; y=&quot;181&quot; text-anchor=&quot;middle&quot; fill=&quot;#f2ede3&quot; font-size=&quot;11&quot; font-family=&quot;monospace&quot;&gt;Networking&lt;/text&gt;&lt;rect x=&quot;294&quot; y=&quot;158&quot; width=&quot;128&quot; height=&quot;36&quot; rx=&quot;6&quot; fill=&quot;#262019&quot; stroke=&quot;#6f9bd1&quot; stroke-width=&quot;1.5&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;358&quot; y=&quot;181&quot; text-anchor=&quot;middle&quot; fill=&quot;#f2ede3&quot; font-size=&quot;11&quot; font-family=&quot;monospace&quot;&gt;Storage&lt;/text&gt;&lt;rect x=&quot;434&quot; y=&quot;158&quot; width=&quot;128&quot; height=&quot;36&quot; rx=&quot;6&quot; fill=&quot;#262019&quot; stroke=&quot;#6f9bd1&quot; stroke-width=&quot;1.5&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;498&quot; y=&quot;181&quot; text-anchor=&quot;middle&quot; fill=&quot;#f2ede3&quot; font-size=&quot;11&quot; font-family=&quot;monospace&quot;&gt;Security&lt;/text&gt;&lt;rect x=&quot;574&quot; y=&quot;158&quot; width=&quot;140&quot; height=&quot;36&quot; rx=&quot;6&quot; fill=&quot;#262019&quot; stroke=&quot;#6f9bd1&quot; stroke-width=&quot;1.5&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;644&quot; y=&quot;181&quot; text-anchor=&quot;middle&quot; fill=&quot;#f2ede3&quot; font-size=&quot;11&quot; font-family=&quot;monospace&quot;&gt;Backups&lt;/text&gt;&lt;text x=&quot;380&quot; y=&quot;224&quot; text-anchor=&quot;middle&quot; fill=&quot;#8a8071&quot; font-size=&quot;16&quot; font-family=&quot;monospace&quot;&gt;▲ built on ▲&lt;/text&gt;&lt;rect x=&quot;30&quot; y=&quot;236&quot; width=&quot;700&quot; height=&quot;64&quot; rx=&quot;8&quot; fill=&quot;#221c16&quot; stroke=&quot;#443a2b&quot; stroke-width=&quot;1&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;46&quot; y=&quot;262&quot; fill=&quot;#e8a33e&quot; font-size=&quot;11&quot; font-family=&quot;monospace&quot; font-weight=&quot;bold&quot;&gt;FOUNDATION&lt;/text&gt;&lt;rect x=&quot;180&quot; y=&quot;250&quot; width=&quot;150&quot; height=&quot;36&quot; rx=&quot;6&quot; fill=&quot;#262019&quot; stroke=&quot;#e8a33e&quot; stroke-width=&quot;1.5&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;255&quot; y=&quot;273&quot; text-anchor=&quot;middle&quot; fill=&quot;#f2ede3&quot; font-size=&quot;11&quot; font-family=&quot;monospace&quot;&gt;Hardware&lt;/text&gt;&lt;rect x=&quot;342&quot; y=&quot;250&quot; width=&quot;150&quot; height=&quot;36&quot; rx=&quot;6&quot; fill=&quot;#262019&quot; stroke=&quot;#e8a33e&quot; stroke-width=&quot;1.5&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;417&quot; y=&quot;273&quot; text-anchor=&quot;middle&quot; fill=&quot;#f2ede3&quot; font-size=&quot;11&quot; font-family=&quot;monospace&quot;&gt;Proxmox install&lt;/text&gt;&lt;rect x=&quot;504&quot; y=&quot;250&quot; width=&quot;150&quot; height=&quot;36&quot; rx=&quot;6&quot; fill=&quot;#262019&quot; stroke=&quot;#e8a33e&quot; stroke-width=&quot;1.5&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;579&quot; y=&quot;273&quot; text-anchor=&quot;middle&quot; fill=&quot;#f2ede3&quot; font-size=&quot;11&quot; font-family=&quot;monospace&quot;&gt;Cluster (optional)&lt;/text&gt;&lt;text x=&quot;750&quot; y=&quot;312&quot; text-anchor=&quot;end&quot; font-size=&quot;10&quot; fill=&quot;#8a8071&quot; opacity=&quot;0.6&quot; font-family=&quot;monospace&quot;&gt;peira.dev&lt;/text&gt;&lt;/svg&gt;
&lt;p&gt;The rest of this guide follows those layers from the bottom up.&lt;/p&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;layer-1--foundation&quot;&gt;Layer 1 — Foundation&lt;/h2&gt;
&lt;p&gt;This is what you set up once. Get it right and stable, and everything above it just works.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Start with the why.&lt;/strong&gt; If you’re not sure what a homelab even is or whether you need one, read &lt;a href=&quot;https://peira.dev/blog/what-is-a-homelab/&quot;&gt;What Is a Homelab?&lt;/a&gt; first — it frames the whole project so you buy and build for &lt;em&gt;your&lt;/em&gt; goals instead of copying someone else’s rack.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Pick hardware to match.&lt;/strong&gt; You don’t need a server. A single mini PC with 16–32 GB of RAM runs a remarkable amount. The &lt;a href=&quot;https://peira.dev/blog/homelab-hardware-guide/&quot;&gt;Homelab Hardware Guide&lt;/a&gt; covers what actually matters (RAM, cores, NIC, power draw) and what to ignore, and the &lt;a href=&quot;https://peira.dev/blog/homelab-switch-guide/&quot;&gt;Homelab Network Switch Guide&lt;/a&gt; covers the one piece of networking gear worth thinking about early.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Install Proxmox.&lt;/strong&gt; Write the ISO to a &lt;a href=&quot;https://peira.dev/blog/ventoy-bootable-usb/&quot;&gt;Ventoy USB&lt;/a&gt; and follow &lt;a href=&quot;https://peira.dev/blog/proxmox-install-guide/&quot;&gt;Installing Proxmox VE&lt;/a&gt; from USB to first login. From there you’ll decide how to carve up the machine — the &lt;a href=&quot;https://peira.dev/blog/proxmox-vm-vs-lxc/&quot;&gt;VM vs LXC guide&lt;/a&gt; explains the single most common choice you’ll make, and &lt;a href=&quot;https://peira.dev/blog/proxmox-create-lxc/&quot;&gt;Creating Your First LXC Container&lt;/a&gt; and &lt;a href=&quot;https://peira.dev/blog/proxmox-create-vm/&quot;&gt;Creating Your First VM&lt;/a&gt; are the two hands-on starting points.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Cluster only when you have a reason.&lt;/strong&gt; One node is a complete homelab. If you later want live migration or zero-downtime maintenance, &lt;a href=&quot;https://peira.dev/blog/proxmox-cluster-setup/&quot;&gt;Proxmox Cluster Setup&lt;/a&gt; walks through quorum and node joining, and &lt;a href=&quot;https://peira.dev/blog/proxmox-cluster-hp-elitedesk/&quot;&gt;Building a 4-Node Cluster on HP EliteDesk Mini PCs&lt;/a&gt; is a real-world build.&lt;/p&gt;
&lt;div class=&quot;callout callout-tip&quot; data-astro-cid-q2ml7llr&gt;&lt;div class=&quot;callout-header&quot; data-astro-cid-q2ml7llr&gt;&lt;span class=&quot;callout-icon&quot; aria-hidden=&quot;true&quot; data-astro-cid-q2ml7llr&gt;💡&lt;/span&gt;&lt;span class=&quot;callout-label&quot; data-astro-cid-q2ml7llr&gt;Resist the urge to over-build&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;callout-body&quot; data-astro-cid-q2ml7llr&gt;&lt;p&gt;The most common beginner mistake is buying a cluster’s worth of hardware before running a single service. Get one node solid, run something real on it, and let your actual usage — not a YouTube rack tour — decide what you add next.&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;layer-2--core-platform&quot;&gt;Layer 2 — Core platform&lt;/h2&gt;
&lt;p&gt;This layer turns “a machine running Proxmox” into a homelab that’s reachable, safe, and recoverable.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Networking.&lt;/strong&gt; Give your containers and VMs stable, predictable addresses. &lt;a href=&quot;https://peira.dev/blog/proxmox-lxc-networking/&quot;&gt;Proxmox LXC Networking&lt;/a&gt; covers bridges, VLANs, and static IPs — the plumbing every service sits on. Add &lt;a href=&quot;https://peira.dev/blog/pihole-proxmox-lxc/&quot;&gt;Pi-hole&lt;/a&gt; for network-wide DNS and ad blocking, which also lets you resolve your own services by name.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Storage.&lt;/strong&gt; Decide where data lives before you fill it. The &lt;a href=&quot;https://peira.dev/blog/proxmox-storage-guide/&quot;&gt;Proxmox Storage Guide&lt;/a&gt; compares LVM, ZFS, NFS, and Ceph, and &lt;a href=&quot;https://peira.dev/blog/homelab-nas-samba-shares/&quot;&gt;Homelab NAS with Samba Shares&lt;/a&gt; sets up a central file server that every other service can read from.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Security.&lt;/strong&gt; Lock down access before you expose anything. &lt;a href=&quot;https://peira.dev/blog/proxmox-security-hardening/&quot;&gt;Proxmox Security Hardening&lt;/a&gt; raises the bar without making admin painful, and the &lt;a href=&quot;https://peira.dev/blog/proxmox-firewall-guide/&quot;&gt;Proxmox Firewall Guide&lt;/a&gt; adds rules and zone isolation. Store the credentials you generate along the way in your own &lt;a href=&quot;https://peira.dev/blog/vaultwarden-proxmox-lxc/&quot;&gt;Vaultwarden&lt;/a&gt; password manager.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Backups — do this early.&lt;/strong&gt; &lt;a href=&quot;https://peira.dev/blog/proxmox-backup-server/&quot;&gt;Proxmox Backup Server&lt;/a&gt; gives you deduplicated, incremental, automated backups of every VM and container. Set it up &lt;em&gt;before&lt;/em&gt; you depend on anything, and test a restore so you know it works.&lt;/p&gt;
&lt;div class=&quot;callout callout-danger&quot; data-astro-cid-q2ml7llr&gt;&lt;div class=&quot;callout-header&quot; data-astro-cid-q2ml7llr&gt;&lt;span class=&quot;callout-icon&quot; aria-hidden=&quot;true&quot; data-astro-cid-q2ml7llr&gt;🚨&lt;/span&gt;&lt;span class=&quot;callout-label&quot; data-astro-cid-q2ml7llr&gt;An untested backup is not a backup&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;callout-body&quot; data-astro-cid-q2ml7llr&gt;&lt;p&gt;It’s a hopeful guess. The only way to know your backups work is to restore one. Do it once, on purpose, while nothing is on fire — so that when something is, you already know the drill.&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;layer-3--what-you-actually-run&quot;&gt;Layer 3 — What you actually run&lt;/h2&gt;
&lt;p&gt;With a stable foundation and a safe platform underneath, the fun part is almost anticlimactic — services become quick to add, because all the hard questions (where does data live? how do I reach it? what happens if it breaks?) were answered a layer ago. Pick whichever cluster of services matches why you built the lab in the first place.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Media.&lt;/strong&gt; Automate a full media library: the &lt;a href=&quot;https://peira.dev/blog/arr-stack-explained/&quot;&gt;Arr stack explained&lt;/a&gt;, &lt;a href=&quot;https://peira.dev/blog/arr-stack-docker-deploy/&quot;&gt;deployed with Docker Compose&lt;/a&gt;, &lt;a href=&quot;https://peira.dev/blog/arr-stack-automation/&quot;&gt;automated end to end&lt;/a&gt;, and made bulletproof in &lt;a href=&quot;https://peira.dev/blog/self-healing-arr-stack/&quot;&gt;The Self-Healing Arr Stack&lt;/a&gt;. Serve it with &lt;a href=&quot;https://peira.dev/blog/plex-media-server-nas-lxc/&quot;&gt;Plex&lt;/a&gt; or &lt;a href=&quot;https://peira.dev/blog/jellyfin-install-nas-lxc/&quot;&gt;Jellyfin&lt;/a&gt;, and back up your photos with &lt;a href=&quot;https://peira.dev/blog/immich-proxmox-photos/&quot;&gt;Immich&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Monitoring.&lt;/strong&gt; Know when something breaks. Start with &lt;a href=&quot;https://peira.dev/blog/uptime-kuma-monitoring/&quot;&gt;Uptime Kuma&lt;/a&gt; for instant up/down alerts, then add deep metrics with &lt;a href=&quot;https://peira.dev/blog/grafana-prometheus-proxmox/&quot;&gt;Prometheus and Grafana&lt;/a&gt; and &lt;a href=&quot;https://peira.dev/blog/node-exporter-grafana-proxmox/&quot;&gt;node/PVE exporters&lt;/a&gt;. Pull it all onto one screen with a &lt;a href=&quot;https://peira.dev/blog/homepage-dashboard/&quot;&gt;Homepage dashboard&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Remote access.&lt;/strong&gt; Reach your lab from anywhere without opening ports, using a &lt;a href=&quot;https://peira.dev/blog/tailscale-subnet-router/&quot;&gt;Tailscale Subnet Router&lt;/a&gt; or an &lt;a href=&quot;https://peira.dev/blog/tailscale-exit-node/&quot;&gt;exit node&lt;/a&gt;. Put public-facing services behind &lt;a href=&quot;https://peira.dev/blog/traefik-letsencrypt-setup/&quot;&gt;Traefik with Let’s Encrypt&lt;/a&gt; for clean HTTPS.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Apps and AI.&lt;/strong&gt; Run &lt;a href=&quot;https://peira.dev/blog/home-assistant-proxmox-vm/&quot;&gt;Home Assistant&lt;/a&gt; for smart-home control, a &lt;a href=&quot;https://peira.dev/blog/ollama-lxc-cluster/&quot;&gt;self-hosted LLM cluster with Ollama&lt;/a&gt; behind &lt;a href=&quot;https://peira.dev/blog/open-webui-advanced/&quot;&gt;Open WebUI&lt;/a&gt;, or even your own &lt;a href=&quot;https://peira.dev/blog/hermes-agent-setup/&quot;&gt;Hermes AI agent&lt;/a&gt;. New to containers? &lt;a href=&quot;https://peira.dev/blog/first-docker-compose-stack/&quot;&gt;Your First Docker Compose Stack&lt;/a&gt; is the pattern nearly all of these use.&lt;/p&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;how-to-use-this-guide&quot;&gt;How to use this guide&lt;/h2&gt;
&lt;p&gt;You don’t have to build every layer before you get value — you need each layer &lt;em&gt;below&lt;/em&gt; whatever you’re adding. Want a media server this weekend? You still need a node (Layer 1) and somewhere to put the files (Layer 2 storage) first. This ordering is why the guide is shaped the way it is: every service you’ll ever add already has its prerequisites sitting underneath it.&lt;/p&gt;
&lt;p&gt;Bookmark this page and treat it as home base. Each linked guide stands on its own, but they all fit into this one map.&lt;/p&gt;
&lt;hr/&gt;
&lt;p&gt;&lt;strong&gt;Related posts:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/what-is-a-homelab/&quot;&gt;What Is a Homelab?&lt;/a&gt; — the “why” before the “how”&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/proxmox-install-guide/&quot;&gt;Installing Proxmox VE&lt;/a&gt; — the foundation everything else builds on&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/proxmox-vm-vs-lxc/&quot;&gt;Proxmox VM vs LXC: When to Use Each&lt;/a&gt; — the choice you’ll make for every workload&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/proxmox-backup-server/&quot;&gt;Proxmox Backup Server&lt;/a&gt; — the safety net to set up early&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/self-healing-arr-stack/&quot;&gt;The Self-Healing Arr Stack&lt;/a&gt; — a flagship example of a service done right&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/grafana-prometheus-proxmox/&quot;&gt;Proxmox Monitoring with Prometheus and Grafana&lt;/a&gt; — know when something breaks&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/tailscale-subnet-router/&quot;&gt;Tailscale Subnet Router&lt;/a&gt; — reach the whole lab from anywhere&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Sources: &lt;a href=&quot;https://pve.proxmox.com/pve-docs/pve-admin-guide.html&quot;&gt;Proxmox VE Administration Guide&lt;/a&gt;, &lt;a href=&quot;https://pve.proxmox.com/pve-docs/&quot;&gt;Proxmox VE documentation&lt;/a&gt;, &lt;a href=&quot;https://pbs.proxmox.com/docs/&quot;&gt;Proxmox Backup Server documentation&lt;/a&gt;.&lt;/p&gt;</content:encoded><category>getting-started</category><category>proxmox</category><category>infrastructure</category><category>self-hosted</category></item><item><title>Jellyseerr: Let Your Family Request Movies Themselves</title><link>https://peira.dev/blog/jellyseerr-requests-arr-stack/</link><guid isPermaLink="true">https://peira.dev/blog/jellyseerr-requests-arr-stack/</guid><description>Give your whole household a Netflix-style page to browse and request titles — Jellyseerr passes each approved request straight to Radarr and Sonarr, hands-free.</description><pubDate>Fri, 17 Jul 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;You built the &lt;a href=&quot;https://peira.dev/blog/arr-stack-explained/&quot;&gt;Arr stack&lt;/a&gt;, &lt;a href=&quot;https://peira.dev/blog/arr-stack-docker-deploy/&quot;&gt;deployed it with Docker&lt;/a&gt;, and &lt;a href=&quot;https://peira.dev/blog/arr-stack-automation/&quot;&gt;wired the automation together&lt;/a&gt;. It works — but only &lt;em&gt;you&lt;/em&gt; can drive it. When your partner wants a film, they still text you, and you still log into Radarr like it’s a job. &lt;a href=&quot;https://github.com/fallenbagel/jellyseerr&quot;&gt;Jellyseerr&lt;/a&gt; closes that gap: a clean, streaming-style page where anyone in the house browses, taps &lt;strong&gt;Request&lt;/strong&gt;, and the title flows through Radarr or Sonarr into &lt;a href=&quot;https://peira.dev/blog/plex-media-server-nas-lxc/&quot;&gt;Plex&lt;/a&gt; or &lt;a href=&quot;https://peira.dev/blog/jellyfin-install-nas-lxc/&quot;&gt;Jellyfin&lt;/a&gt; on its own.&lt;/p&gt;
&lt;p&gt;This is the piece that turns a media stack into a media &lt;em&gt;service&lt;/em&gt;. Below is the full deploy and the exact integration settings that make a request actually download.&lt;/p&gt;
&lt;div class=&quot;callout callout-warning&quot; data-astro-cid-q2ml7llr&gt;&lt;div class=&quot;callout-header&quot; data-astro-cid-q2ml7llr&gt;&lt;span class=&quot;callout-icon&quot; aria-hidden=&quot;true&quot; data-astro-cid-q2ml7llr&gt;⚠️&lt;/span&gt;&lt;span class=&quot;callout-label&quot; data-astro-cid-q2ml7llr&gt;First: make these values your own&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;callout-body&quot; data-astro-cid-q2ml7llr&gt;&lt;p&gt;Commands below use placeholder values — swap in yours before running:&lt;/p&gt;&lt;ul&gt;
&lt;li&gt;&lt;code&gt;&amp;lt;host-ip&amp;gt;&lt;/code&gt; — the LAN IP of the machine running your stack (e.g. &lt;code&gt;192.168.1.50&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;TZ=America/New_York&lt;/code&gt; — your own &lt;a href=&quot;https://en.wikipedia.org/wiki/List_of_tz_database_time_zones&quot;&gt;timezone&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Service ports (&lt;code&gt;7878&lt;/code&gt; Radarr, &lt;code&gt;8989&lt;/code&gt; Sonarr, &lt;code&gt;8096&lt;/code&gt; Jellyfin) — change only if you moved them off the defaults&lt;/li&gt;
&lt;li&gt;&lt;code&gt;YOUR_RADARR_API_KEY&lt;/code&gt; / &lt;code&gt;YOUR_SONARR_API_KEY&lt;/code&gt; — copied from each app, kept in your secret store, never pasted into a note&lt;/li&gt;
&lt;/ul&gt;&lt;p&gt;Rule of thumb: if a value looks specific to one machine, it’s a placeholder to change — not a literal to copy.&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;what-jellyseerr-actually-does&quot;&gt;What Jellyseerr actually does&lt;/h2&gt;
&lt;p&gt;Jellyseerr is a request-and-discovery front end. It does &lt;strong&gt;not&lt;/strong&gt; download anything itself — it orchestrates the apps you already run. A request travels the whole pipeline without you touching Radarr:&lt;/p&gt;
&lt;svg viewBox=&quot;0 0 760 210&quot; xmlns=&quot;http://www.w3.org/2000/svg&quot; role=&quot;img&quot; aria-label=&quot;Request pipeline: a household request flows through Jellyseerr, into Radarr or Sonarr, out to the download client via Prowlarr, and lands in Plex or Jellyfin as available.&quot;&gt;&lt;defs&gt;&lt;linearGradient id=&quot;jsbg&quot; x1=&quot;0&quot; y1=&quot;0&quot; x2=&quot;0&quot; y2=&quot;1&quot;&gt;&lt;stop offset=&quot;0&quot; stop-color=&quot;#1f1a15&quot;&gt;&lt;/stop&gt;&lt;stop offset=&quot;1&quot; stop-color=&quot;#141110&quot;&gt;&lt;/stop&gt;&lt;/linearGradient&gt;&lt;/defs&gt;&lt;rect x=&quot;0&quot; y=&quot;0&quot; width=&quot;760&quot; height=&quot;210&quot; rx=&quot;12&quot; fill=&quot;url(#jsbg)&quot;&gt;&lt;/rect&gt;&lt;defs&gt;&lt;pattern id=&quot;gp-jellyseerrre0&quot; width=&quot;20&quot; height=&quot;20&quot; patternUnits=&quot;userSpaceOnUse&quot;&gt;&lt;path d=&quot;M 20 0 H 0 V 20&quot; fill=&quot;none&quot; stroke=&quot;#221c15&quot; stroke-width=&quot;1&quot;&gt;&lt;/path&gt;&lt;/pattern&gt;&lt;/defs&gt;&lt;rect x=&quot;0&quot; y=&quot;0&quot; width=&quot;760&quot; height=&quot;210&quot; fill=&quot;url(#gp-jellyseerrre0)&quot;&gt;&lt;/rect&gt;&lt;rect x=&quot;6&quot; y=&quot;6&quot; width=&quot;748&quot; height=&quot;198&quot; fill=&quot;none&quot; stroke=&quot;#2e2820&quot; stroke-width=&quot;1&quot;&gt;&lt;/rect&gt;&lt;path d=&quot;M 11,16 H 21 M 16,11 V 21 M 739,16 H 749 M 744,11 V 21 M 11,194 H 21 M 16,189 V 199 M 739,194 H 749 M 744,189 V 199&quot; stroke=&quot;#4a3f2e&quot; stroke-width=&quot;1&quot; fill=&quot;none&quot;&gt;&lt;/path&gt;&lt;text x=&quot;24&quot; y=&quot;34&quot; fill=&quot;#aba192&quot; font-size=&quot;13&quot; font-family=&quot;monospace&quot;&gt;A request, end to end — no admin in the loop&lt;/text&gt;&lt;rect x=&quot;24&quot; y=&quot;70&quot; width=&quot;118&quot; height=&quot;66&quot; rx=&quot;9&quot; fill=&quot;#262019&quot; stroke=&quot;#6f9bd1&quot; stroke-width=&quot;1.5&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;83&quot; y=&quot;99&quot; text-anchor=&quot;middle&quot; fill=&quot;#f2ede3&quot; font-size=&quot;13&quot; font-weight=&quot;700&quot;&gt;Household&lt;/text&gt;&lt;text x=&quot;83&quot; y=&quot;118&quot; text-anchor=&quot;middle&quot; fill=&quot;#aba192&quot; font-size=&quot;10.5&quot;&gt;taps Request&lt;/text&gt;&lt;rect x=&quot;184&quot; y=&quot;70&quot; width=&quot;118&quot; height=&quot;66&quot; rx=&quot;9&quot; fill=&quot;#262019&quot; stroke=&quot;#e8a33e&quot; stroke-width=&quot;1.5&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;243&quot; y=&quot;99&quot; text-anchor=&quot;middle&quot; fill=&quot;#f2ede3&quot; font-size=&quot;13&quot; font-weight=&quot;700&quot;&gt;Jellyseerr&lt;/text&gt;&lt;text x=&quot;243&quot; y=&quot;118&quot; text-anchor=&quot;middle&quot; fill=&quot;#aba192&quot; font-size=&quot;10.5&quot;&gt;approve&lt;/text&gt;&lt;rect x=&quot;344&quot; y=&quot;70&quot; width=&quot;118&quot; height=&quot;66&quot; rx=&quot;9&quot; fill=&quot;#262019&quot; stroke=&quot;#6f9bd1&quot; stroke-width=&quot;1.5&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;403&quot; y=&quot;95&quot; text-anchor=&quot;middle&quot; fill=&quot;#f2ede3&quot; font-size=&quot;13&quot; font-weight=&quot;700&quot;&gt;Radarr /&lt;/text&gt;&lt;text x=&quot;403&quot; y=&quot;112&quot; text-anchor=&quot;middle&quot; fill=&quot;#f2ede3&quot; font-size=&quot;13&quot; font-weight=&quot;700&quot;&gt;Sonarr&lt;/text&gt;&lt;text x=&quot;403&quot; y=&quot;129&quot; text-anchor=&quot;middle&quot; fill=&quot;#aba192&quot; font-size=&quot;10.5&quot;&gt;search + grab&lt;/text&gt;&lt;rect x=&quot;504&quot; y=&quot;70&quot; width=&quot;118&quot; height=&quot;66&quot; rx=&quot;9&quot; fill=&quot;#262019&quot; stroke=&quot;#d9a13d&quot; stroke-width=&quot;1.5&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;563&quot; y=&quot;95&quot; text-anchor=&quot;middle&quot; fill=&quot;#f2ede3&quot; font-size=&quot;13&quot; font-weight=&quot;700&quot;&gt;Download&lt;/text&gt;&lt;text x=&quot;563&quot; y=&quot;112&quot; text-anchor=&quot;middle&quot; fill=&quot;#f2ede3&quot; font-size=&quot;13&quot; font-weight=&quot;700&quot;&gt;client&lt;/text&gt;&lt;text x=&quot;563&quot; y=&quot;129&quot; text-anchor=&quot;middle&quot; fill=&quot;#aba192&quot; font-size=&quot;10.5&quot;&gt;via Prowlarr&lt;/text&gt;&lt;rect x=&quot;664&quot; y=&quot;70&quot; width=&quot;72&quot; height=&quot;66&quot; rx=&quot;9&quot; fill=&quot;#262019&quot; stroke=&quot;#8fb573&quot; stroke-width=&quot;1.5&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;700&quot; y=&quot;95&quot; text-anchor=&quot;middle&quot; fill=&quot;#f2ede3&quot; font-size=&quot;12&quot; font-weight=&quot;700&quot;&gt;Plex /&lt;/text&gt;&lt;text x=&quot;700&quot; y=&quot;111&quot; text-anchor=&quot;middle&quot; fill=&quot;#f2ede3&quot; font-size=&quot;12&quot; font-weight=&quot;700&quot;&gt;Jellyfin&lt;/text&gt;&lt;text x=&quot;700&quot; y=&quot;128&quot; text-anchor=&quot;middle&quot; fill=&quot;#8fb573&quot; font-size=&quot;10&quot;&gt;available&lt;/text&gt;&lt;line x1=&quot;142&quot; y1=&quot;103&quot; x2=&quot;184&quot; y2=&quot;103&quot; stroke=&quot;#6b5a3e&quot; stroke-width=&quot;2&quot; marker-end=&quot;url(#jsar)&quot;&gt;&lt;/line&gt;&lt;line x1=&quot;302&quot; y1=&quot;103&quot; x2=&quot;344&quot; y2=&quot;103&quot; stroke=&quot;#6b5a3e&quot; stroke-width=&quot;2&quot; marker-end=&quot;url(#jsar)&quot;&gt;&lt;/line&gt;&lt;line x1=&quot;462&quot; y1=&quot;103&quot; x2=&quot;504&quot; y2=&quot;103&quot; stroke=&quot;#6b5a3e&quot; stroke-width=&quot;2&quot; marker-end=&quot;url(#jsar)&quot;&gt;&lt;/line&gt;&lt;line x1=&quot;622&quot; y1=&quot;103&quot; x2=&quot;664&quot; y2=&quot;103&quot; stroke=&quot;#6b5a3e&quot; stroke-width=&quot;2&quot; marker-end=&quot;url(#jsar)&quot;&gt;&lt;/line&gt;&lt;defs&gt;&lt;marker id=&quot;jsar&quot; markerWidth=&quot;8&quot; markerHeight=&quot;8&quot; refX=&quot;6&quot; refY=&quot;3&quot; orient=&quot;auto&quot;&gt;&lt;path d=&quot;M0,0 L6,3 L0,6 Z&quot; fill=&quot;#6b5a3e&quot;&gt;&lt;/path&gt;&lt;/marker&gt;&lt;/defs&gt;&lt;text x=&quot;750&quot; y=&quot;202&quot; text-anchor=&quot;end&quot; font-size=&quot;10&quot; fill=&quot;#8a8071&quot; opacity=&quot;0.6&quot; font-family=&quot;monospace&quot;&gt;peira.dev&lt;/text&gt;&lt;/svg&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Browse &amp;amp; search&lt;/strong&gt; — pulls artwork and metadata from &lt;a href=&quot;https://www.themoviedb.org/&quot;&gt;TMDB&lt;/a&gt; so titles look like a streaming catalogue, and shows what’s already in your library.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Request&lt;/strong&gt; — a user taps a poster; Jellyseerr records the request and (optionally) asks for approval.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Hand off&lt;/strong&gt; — on approval it sends the title to &lt;a href=&quot;https://radarr.video/&quot;&gt;Radarr&lt;/a&gt; (movies) or &lt;a href=&quot;https://sonarr.tv/&quot;&gt;Sonarr&lt;/a&gt; (TV), with a root folder and quality profile you choose.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Track &amp;amp; notify&lt;/strong&gt; — it watches the download and marks the request &lt;em&gt;Available&lt;/em&gt; once the file lands in your library, optionally emailing or pinging the requester.&lt;/li&gt;
&lt;/ul&gt;
&lt;div class=&quot;callout callout-info&quot; data-astro-cid-q2ml7llr&gt;&lt;div class=&quot;callout-header&quot; data-astro-cid-q2ml7llr&gt;&lt;span class=&quot;callout-icon&quot; aria-hidden=&quot;true&quot; data-astro-cid-q2ml7llr&gt;ℹ️&lt;/span&gt;&lt;span class=&quot;callout-label&quot; data-astro-cid-q2ml7llr&gt;Jellyseerr vs Overseerr&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;callout-body&quot; data-astro-cid-q2ml7llr&gt;&lt;p&gt;&lt;a href=&quot;https://overseerr.dev/&quot;&gt;Overseerr&lt;/a&gt; is Plex-only. &lt;strong&gt;Jellyseerr&lt;/strong&gt; is the community fork that adds native &lt;a href=&quot;https://jellyfin.org/&quot;&gt;Jellyfin&lt;/a&gt; and Emby support while keeping Plex working exactly the same. Pick Jellyseerr — it costs you nothing and keeps the door open if you ever switch servers.&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;task-1-deploy-the-container&quot;&gt;Task 1: Deploy the container&lt;/h2&gt;
&lt;p&gt;Run Jellyseerr wherever the rest of your stack lives — the same &lt;a href=&quot;https://peira.dev/blog/docker-proxmox-lxc-vs-vm/&quot;&gt;Docker-on-Proxmox LXC or VM&lt;/a&gt;, your NAS, or a dedicated box. It’s a single lightweight container with one persistent volume for its config.&lt;/p&gt;
&lt;div class=&quot;step&quot; data-astro-cid-wwaqy5cd&gt;&lt;div class=&quot;step-header&quot; data-astro-cid-wwaqy5cd&gt;&lt;span class=&quot;step-num&quot; data-astro-cid-wwaqy5cd&gt;1&lt;/span&gt;&lt;span class=&quot;step-title&quot; data-astro-cid-wwaqy5cd&gt;Add the service to your compose file&lt;/span&gt;&lt;span class=&quot;step-time&quot; data-astro-cid-wwaqy5cd&gt;2 min&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;step-body&quot; data-astro-cid-wwaqy5cd&gt;&lt;figure class=&quot;terminal&quot; data-astro-cid-kenkipkp&gt;&lt;figcaption class=&quot;terminal-bar&quot; data-astro-cid-kenkipkp&gt;&lt;span class=&quot;dot red&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot yellow&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot green&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;terminal-title&quot; data-astro-cid-kenkipkp&gt;Add to your media docker-compose.yml&lt;/span&gt;&lt;button class=&quot;terminal-copy&quot; type=&quot;button&quot; aria-label=&quot;Copy commands&quot; data-astro-cid-kenkipkp&gt;&lt;svg width=&quot;13&quot; height=&quot;13&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;none&quot; stroke=&quot;currentColor&quot; stroke-width=&quot;2&quot; stroke-linecap=&quot;round&quot; stroke-linejoin=&quot;round&quot; aria-hidden=&quot;true&quot; data-astro-cid-kenkipkp&gt;&lt;rect x=&quot;9&quot; y=&quot;9&quot; width=&quot;13&quot; height=&quot;13&quot; rx=&quot;2&quot; data-astro-cid-kenkipkp&gt;&lt;/rect&gt;&lt;path d=&quot;M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1&quot; data-astro-cid-kenkipkp&gt;&lt;/path&gt;&lt;/svg&gt;&lt;span class=&quot;terminal-copy-label&quot; data-astro-cid-kenkipkp&gt;Copy&lt;/span&gt;&lt;/button&gt;&lt;/figcaption&gt;&lt;pre class=&quot;terminal-body&quot; data-astro-cid-kenkipkp&gt;&lt;code data-astro-cid-kenkipkp&gt;
jellyseerr:
  image: fallenbagel/jellyseerr:latest
  container_name: jellyseerr
  environment:
    - TZ=America/New_York
    - PORT=5055
  ports:
    - &amp;quot;5055:5055&amp;quot;
  volumes:
    - ./jellyseerr/config:/app/config
  restart: unless-stopped
&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;&lt;/div&gt;
&lt;div class=&quot;step&quot; data-astro-cid-wwaqy5cd&gt;&lt;div class=&quot;step-header&quot; data-astro-cid-wwaqy5cd&gt;&lt;span class=&quot;step-num&quot; data-astro-cid-wwaqy5cd&gt;2&lt;/span&gt;&lt;span class=&quot;step-title&quot; data-astro-cid-wwaqy5cd&gt;Bring it up and open the UI&lt;/span&gt;&lt;span class=&quot;step-time&quot; data-astro-cid-wwaqy5cd&gt;3 min&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;step-body&quot; data-astro-cid-wwaqy5cd&gt;&lt;figure class=&quot;terminal&quot; data-astro-cid-kenkipkp&gt;&lt;figcaption class=&quot;terminal-bar&quot; data-astro-cid-kenkipkp&gt;&lt;span class=&quot;dot red&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot yellow&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot green&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;terminal-title&quot; data-astro-cid-kenkipkp&gt;Start it&lt;/span&gt;&lt;button class=&quot;terminal-copy&quot; type=&quot;button&quot; aria-label=&quot;Copy commands&quot; data-astro-cid-kenkipkp&gt;&lt;svg width=&quot;13&quot; height=&quot;13&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;none&quot; stroke=&quot;currentColor&quot; stroke-width=&quot;2&quot; stroke-linecap=&quot;round&quot; stroke-linejoin=&quot;round&quot; aria-hidden=&quot;true&quot; data-astro-cid-kenkipkp&gt;&lt;rect x=&quot;9&quot; y=&quot;9&quot; width=&quot;13&quot; height=&quot;13&quot; rx=&quot;2&quot; data-astro-cid-kenkipkp&gt;&lt;/rect&gt;&lt;path d=&quot;M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1&quot; data-astro-cid-kenkipkp&gt;&lt;/path&gt;&lt;/svg&gt;&lt;span class=&quot;terminal-copy-label&quot; data-astro-cid-kenkipkp&gt;Copy&lt;/span&gt;&lt;/button&gt;&lt;/figcaption&gt;&lt;pre class=&quot;terminal-body&quot; data-astro-cid-kenkipkp&gt;&lt;code data-astro-cid-kenkipkp&gt;
docker compose up -d jellyseerr
docker compose logs -f jellyseerr
&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;p&gt;Then browse to &lt;code&gt;http://&amp;lt;host-ip&amp;gt;:5055&lt;/code&gt; to reach the setup wizard.&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;
&lt;div class=&quot;callout callout-tip&quot; data-astro-cid-q2ml7llr&gt;&lt;div class=&quot;callout-header&quot; data-astro-cid-q2ml7llr&gt;&lt;span class=&quot;callout-icon&quot; aria-hidden=&quot;true&quot; data-astro-cid-q2ml7llr&gt;💡&lt;/span&gt;&lt;span class=&quot;callout-label&quot; data-astro-cid-q2ml7llr&gt;Give it a real URL&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;callout-body&quot; data-astro-cid-q2ml7llr&gt;&lt;p&gt;Port 5055 is fine for testing, but this is the one service non-technical people in your house will actually use. Put it behind your reverse proxy so it gets a clean HTTPS name like &lt;code&gt;requests.homelab.lan&lt;/code&gt; — see the &lt;a href=&quot;https://peira.dev/blog/traefik-letsencrypt-setup/&quot;&gt;Traefik + Let’s Encrypt guide&lt;/a&gt;. A memorable URL is the difference between people using it and going back to texting you.&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;task-2-connect-your-media-server&quot;&gt;Task 2: Connect your media server&lt;/h2&gt;
&lt;p&gt;The setup wizard runs on first launch. It signs you in as the admin and links your library so Jellyseerr knows what you already own.&lt;/p&gt;
&lt;div class=&quot;step&quot; data-astro-cid-wwaqy5cd&gt;&lt;div class=&quot;step-header&quot; data-astro-cid-wwaqy5cd&gt;&lt;span class=&quot;step-num&quot; data-astro-cid-wwaqy5cd&gt;1&lt;/span&gt;&lt;span class=&quot;step-title&quot; data-astro-cid-wwaqy5cd&gt;Sign in with your media server&lt;/span&gt;&lt;span class=&quot;step-time&quot; data-astro-cid-wwaqy5cd&gt;3 min&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;step-body&quot; data-astro-cid-wwaqy5cd&gt;&lt;p&gt;Choose &lt;strong&gt;Plex&lt;/strong&gt; or &lt;strong&gt;Jellyfin/Emby&lt;/strong&gt; and authenticate. For Jellyfin, enter the server URL (e.g. &lt;code&gt;http://&amp;lt;host-ip&amp;gt;:8096&lt;/code&gt;) and an admin login. This first account becomes the Jellyseerr owner.&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;
&lt;div class=&quot;step&quot; data-astro-cid-wwaqy5cd&gt;&lt;div class=&quot;step-header&quot; data-astro-cid-wwaqy5cd&gt;&lt;span class=&quot;step-num&quot; data-astro-cid-wwaqy5cd&gt;2&lt;/span&gt;&lt;span class=&quot;step-title&quot; data-astro-cid-wwaqy5cd&gt;Import your libraries&lt;/span&gt;&lt;span class=&quot;step-time&quot; data-astro-cid-wwaqy5cd&gt;2 min&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;step-body&quot; data-astro-cid-wwaqy5cd&gt;&lt;p&gt;Select which libraries Jellyseerr should scan (Movies, TV). It reads them so already-owned titles show as &lt;strong&gt;Available&lt;/strong&gt; instead of offering a pointless second request. Run the initial scan before moving on.&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;
&lt;div class=&quot;step&quot; data-astro-cid-wwaqy5cd&gt;&lt;div class=&quot;step-header&quot; data-astro-cid-wwaqy5cd&gt;&lt;span class=&quot;step-num&quot; data-astro-cid-wwaqy5cd&gt;3&lt;/span&gt;&lt;span class=&quot;step-title&quot; data-astro-cid-wwaqy5cd&gt;Import users (optional)&lt;/span&gt;&lt;span class=&quot;step-time&quot; data-astro-cid-wwaqy5cd&gt;2 min&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;step-body&quot; data-astro-cid-wwaqy5cd&gt;&lt;p&gt;Jellyseerr can pull in your existing Plex or Jellyfin users so household members log in with credentials they already have — no new accounts to hand out.&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;task-3-wire-in-radarr-and-sonarr--the-part-that-matters&quot;&gt;Task 3: Wire in Radarr and Sonarr — the part that matters&lt;/h2&gt;
&lt;p&gt;This is where most setups break. A request that has nowhere to go just sits as &lt;em&gt;Pending&lt;/em&gt; forever. Under &lt;strong&gt;Settings → Services&lt;/strong&gt;, add each Arr app with the &lt;strong&gt;same root folder and quality profile you’d pick if you added the title by hand&lt;/strong&gt;. The official &lt;a href=&quot;https://docs.jellyseerr.dev/using-jellyseerr/settings/services&quot;&gt;Jellyseerr configuration docs&lt;/a&gt; walk through the same fields.&lt;/p&gt;
&lt;div class=&quot;step&quot; data-astro-cid-wwaqy5cd&gt;&lt;div class=&quot;step-header&quot; data-astro-cid-wwaqy5cd&gt;&lt;span class=&quot;step-num&quot; data-astro-cid-wwaqy5cd&gt;1&lt;/span&gt;&lt;span class=&quot;step-title&quot; data-astro-cid-wwaqy5cd&gt;Add Radarr&lt;/span&gt;&lt;span class=&quot;step-time&quot; data-astro-cid-wwaqy5cd&gt;4 min&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;step-body&quot; data-astro-cid-wwaqy5cd&gt;&lt;p&gt;Enter the Radarr URL (&lt;code&gt;http://&amp;lt;host-ip&amp;gt;:7878&lt;/code&gt;) and its API key (Radarr → Settings → General → &lt;code&gt;YOUR_RADARR_API_KEY&lt;/code&gt;). Test the connection, then set the &lt;strong&gt;Root Folder&lt;/strong&gt; (your &lt;code&gt;/data/media/movies&lt;/code&gt;) and the &lt;strong&gt;Quality Profile&lt;/strong&gt; Jellyseerr should request with. Mark it the &lt;strong&gt;Default&lt;/strong&gt; server for movies.&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;
&lt;div class=&quot;step&quot; data-astro-cid-wwaqy5cd&gt;&lt;div class=&quot;step-header&quot; data-astro-cid-wwaqy5cd&gt;&lt;span class=&quot;step-num&quot; data-astro-cid-wwaqy5cd&gt;2&lt;/span&gt;&lt;span class=&quot;step-title&quot; data-astro-cid-wwaqy5cd&gt;Add Sonarr&lt;/span&gt;&lt;span class=&quot;step-time&quot; data-astro-cid-wwaqy5cd&gt;4 min&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;step-body&quot; data-astro-cid-wwaqy5cd&gt;&lt;p&gt;Same process on port &lt;code&gt;8989&lt;/code&gt; with &lt;code&gt;YOUR_SONARR_API_KEY&lt;/code&gt;. Sonarr also asks for a &lt;strong&gt;Language Profile&lt;/strong&gt; and lets you set a default &lt;strong&gt;Series Type&lt;/strong&gt; (Standard for most shows, Anime if that’s your thing). Set it as the default TV server.&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;
&lt;div class=&quot;callout callout-danger&quot; data-astro-cid-q2ml7llr&gt;&lt;div class=&quot;callout-header&quot; data-astro-cid-q2ml7llr&gt;&lt;span class=&quot;callout-icon&quot; aria-hidden=&quot;true&quot; data-astro-cid-q2ml7llr&gt;🚨&lt;/span&gt;&lt;span class=&quot;callout-label&quot; data-astro-cid-q2ml7llr&gt;Match the folder and profile exactly&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;callout-body&quot; data-astro-cid-q2ml7llr&gt;&lt;p&gt;The single most common failure is Jellyseerr adding titles with the wrong root folder or a profile your indexers can’t satisfy — the request is “approved” but nothing ever downloads. Whatever root folder and quality profile your working manual adds use in Radarr/Sonarr, set &lt;strong&gt;the same values here.&lt;/strong&gt; If manual adds work and Jellyseerr’s don’t, this mismatch is why.&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;task-4-set-approvals-and-quotas&quot;&gt;Task 4: Set approvals and quotas&lt;/h2&gt;
&lt;p&gt;Out of the box the admin’s own requests auto-approve and everyone else’s wait for you. Tune this under &lt;strong&gt;Settings → Users&lt;/strong&gt; and each user’s permissions:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Auto-approve&lt;/strong&gt; — grant it to trusted adults so their requests download instantly with no notification to you. Leave kids on manual approval.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Request quotas&lt;/strong&gt; — cap how many movies/shows a user can request per week so one person can’t queue up 400 titles overnight.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Notifications&lt;/strong&gt; — Jellyseerr can ping you (email, Discord, Telegram, Gotify, and more) on new requests, and notify the requester when their title is ready to watch.&lt;/li&gt;
&lt;/ul&gt;
&lt;div class=&quot;callout callout-tip&quot; data-astro-cid-q2ml7llr&gt;&lt;div class=&quot;callout-header&quot; data-astro-cid-q2ml7llr&gt;&lt;span class=&quot;callout-icon&quot; aria-hidden=&quot;true&quot; data-astro-cid-q2ml7llr&gt;💡&lt;/span&gt;&lt;span class=&quot;callout-label&quot; data-astro-cid-q2ml7llr&gt;Roles beat per-user settings&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;callout-body&quot; data-astro-cid-q2ml7llr&gt;&lt;p&gt;Set permissions on a couple of roles — “Adults” (auto-approve, higher quota) and “Kids” (manual, low quota) — rather than clicking through every account. New household members just get dropped into the right role.&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;whats-next&quot;&gt;What’s next&lt;/h2&gt;
&lt;p&gt;With this in place your media stack is genuinely hands-off: someone opens the request page on their phone, taps &lt;strong&gt;Request&lt;/strong&gt;, and Radarr searches your &lt;a href=&quot;https://prowlarr.com/&quot;&gt;Prowlarr&lt;/a&gt; indexers, grabs the title through your VPN-shielded download client, and imports it as an instant hardlink — while Jellyseerr flips the request to &lt;em&gt;Available&lt;/em&gt; and notifies the requester. No admin in the loop, no texts, no logging into Radarr.&lt;/p&gt;
&lt;p&gt;The next thing worth adding is monitoring so you &lt;em&gt;know&lt;/em&gt; the pipeline is healthy even when nobody’s requesting — the &lt;a href=&quot;https://peira.dev/blog/self-healing-arr-stack/&quot;&gt;self-healing Arr stack&lt;/a&gt; covers the failure mode where this all silently stops working.&lt;/p&gt;
&lt;hr/&gt;
&lt;p&gt;&lt;strong&gt;Related posts:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/arr-stack-explained/&quot;&gt;What Is the Arr Stack? Sonarr, Radarr, and Prowlarr Explained&lt;/a&gt; — the concepts Jellyseerr sits on top of&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/arr-stack-docker-deploy/&quot;&gt;Deploy the Arr Stack with Docker Compose&lt;/a&gt; — the containers Jellyseerr hands requests to&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/arr-stack-automation/&quot;&gt;Automating the Arr Stack&lt;/a&gt; — connect the download pipeline to your media server&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/self-healing-arr-stack/&quot;&gt;The Self-Healing Arr Stack&lt;/a&gt; — keep the pipeline alive when a key rotates or a cron dies&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/plex-media-server-nas-lxc/&quot;&gt;Install Plex Media Server on a NAS or Proxmox LXC&lt;/a&gt; — the server Jellyseerr can front&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/jellyfin-install-nas-lxc/&quot;&gt;Install Jellyfin on a NAS or Proxmox LXC&lt;/a&gt; — the fully open-source alternative&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/traefik-letsencrypt-setup/&quot;&gt;How to Set Up Traefik with Let’s Encrypt&lt;/a&gt; — give the request page a clean HTTPS URL&lt;/li&gt;
&lt;/ul&gt;</content:encoded><category>self-hosted</category><category>infrastructure</category><category>automation</category><category>media</category></item><item><title>The Self-Healing Arr Stack: Why My Watchlist Silently Stopped Downloading</title><link>https://peira.dev/blog/self-healing-arr-stack/</link><guid isPermaLink="true">https://peira.dev/blog/self-healing-arr-stack/</guid><description>A real homelab incident — rotated API keys silently killed my Arr automation for days with zero errors. Here&apos;s the debugging story and the four patterns that make media automation immune to it: runtime keys, one consolidated script, an auto-detecting VPN-leak check, and a dead-man&apos;s-switch alert.</description><pubDate>Fri, 17 Jul 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;I built a &lt;a href=&quot;https://peira.dev/blog/arr-stack-automation/&quot;&gt;fully automated Arr stack&lt;/a&gt;: watchlist a movie on my phone, and minutes later it’s downloaded, renamed, and showing in Plex. It worked beautifully — right up until it didn’t, and the way it failed is the most useful thing this stack ever taught me.&lt;/p&gt;
&lt;p&gt;For several days, &lt;strong&gt;watchlisted titles just never showed up.&lt;/strong&gt; No error. No alert. No crash. The apps were healthy, the VPN was up, the download client was idle and cheerful. Everything looked fine, and nothing was happening.&lt;/p&gt;
&lt;p&gt;This post is the debugging story and — more usefully — the four patterns that came out of it. If you run any kind of Arr automation, these are the changes that turn “works until something rotates” into “heals itself and tells you when it can’t.”&lt;/p&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;the-symptom-silence&quot;&gt;The symptom: silence&lt;/h2&gt;
&lt;p&gt;The tell was the &lt;em&gt;absence&lt;/em&gt; of anything. Movies I’d watchlisted sat un-grabbed. The download queue was empty. Radarr and Sonarr reported healthy. The reconcile cron that’s supposed to sync my watchlist into Radarr was scheduled and — according to cron — running fine every 15 minutes.&lt;/p&gt;
&lt;p&gt;The trap here is that a healthy-looking stack and a working stack are not the same thing. Nothing was &lt;em&gt;broken&lt;/em&gt; in the sense of throwing an error. The automation had simply, quietly, stopped doing its job.&lt;/p&gt;
&lt;div class=&quot;callout callout-warning&quot; data-astro-cid-q2ml7llr&gt;&lt;div class=&quot;callout-header&quot; data-astro-cid-q2ml7llr&gt;&lt;span class=&quot;callout-icon&quot; aria-hidden=&quot;true&quot; data-astro-cid-q2ml7llr&gt;⚠️&lt;/span&gt;&lt;span class=&quot;callout-label&quot; data-astro-cid-q2ml7llr&gt;Silent failure is the real enemy&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;callout-body&quot; data-astro-cid-q2ml7llr&gt;&lt;p&gt;A service that crashes gets noticed. A cron job that runs, gets rejected, swallows the rejection, and exits 0 will fail forever without a single alert. Most homelab automation fails this way — not with a bang, but with a &lt;code&gt;try/except&lt;/code&gt; that eats the error.&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;the-root-cause-a-rotated-key-and-a-hardcoded-copy-of-it&quot;&gt;The root cause: a rotated key and a hardcoded copy of it&lt;/h2&gt;
&lt;p&gt;The &lt;strong&gt;API keys had rotated.&lt;/strong&gt; Radarr, Sonarr, and Prowlarr had all been issued new keys, and the only live copies of those keys lived in each service’s own &lt;code&gt;config.xml&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;My reconcile script had the &lt;em&gt;old&lt;/em&gt; key baked in as a string. Every run, it sent that stale key, the app answered &lt;strong&gt;HTTP 401 Unauthorized&lt;/strong&gt;, and the script’s error handling logged nothing and exited cleanly. Cron saw a successful run. I saw an empty queue.&lt;/p&gt;
&lt;p&gt;Here’s the detail that pointed straight at the fix. I actually had &lt;em&gt;three&lt;/em&gt; automation scripts. Two of them — the reconcile job and a separate anime-sorting job — hardcoded the key and both died the instant it rotated. The third, a monitor script, &lt;strong&gt;kept working perfectly.&lt;/strong&gt; Why?&lt;/p&gt;
&lt;p&gt;Because that one script read the key from &lt;code&gt;config.xml&lt;/code&gt; at runtime &lt;em&gt;and&lt;/em&gt; talked to the app over &lt;code&gt;127.0.0.1&lt;/code&gt;, where I’d set authentication to “Disabled for Local Addresses.” It never used the stale key at all. The rotation was invisible to it.&lt;/p&gt;
&lt;p&gt;That contrast &lt;em&gt;was&lt;/em&gt; the design spec for the rebuild.&lt;/p&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;pattern-1-never-hardcode-the-key--read-it-at-runtime&quot;&gt;Pattern 1: Never hardcode the key — read it at runtime&lt;/h2&gt;
&lt;p&gt;The single source of truth for a Servarr app’s API key is its own &lt;code&gt;config.xml&lt;/code&gt;. Read it there, every run, and a rotation can never break you.&lt;/p&gt;
&lt;figure class=&quot;terminal&quot; data-astro-cid-kenkipkp&gt;&lt;figcaption class=&quot;terminal-bar&quot; data-astro-cid-kenkipkp&gt;&lt;span class=&quot;dot red&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot yellow&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot green&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;terminal-title&quot; data-astro-cid-kenkipkp&gt;Pull the current key from config.xml at runtime (Python)&lt;/span&gt;&lt;button class=&quot;terminal-copy&quot; type=&quot;button&quot; aria-label=&quot;Copy commands&quot; data-astro-cid-kenkipkp&gt;&lt;svg width=&quot;13&quot; height=&quot;13&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;none&quot; stroke=&quot;currentColor&quot; stroke-width=&quot;2&quot; stroke-linecap=&quot;round&quot; stroke-linejoin=&quot;round&quot; aria-hidden=&quot;true&quot; data-astro-cid-kenkipkp&gt;&lt;rect x=&quot;9&quot; y=&quot;9&quot; width=&quot;13&quot; height=&quot;13&quot; rx=&quot;2&quot; data-astro-cid-kenkipkp&gt;&lt;/rect&gt;&lt;path d=&quot;M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1&quot; data-astro-cid-kenkipkp&gt;&lt;/path&gt;&lt;/svg&gt;&lt;span class=&quot;terminal-copy-label&quot; data-astro-cid-kenkipkp&gt;Copy&lt;/span&gt;&lt;/button&gt;&lt;/figcaption&gt;&lt;pre class=&quot;terminal-body&quot; data-astro-cid-kenkipkp&gt;&lt;code data-astro-cid-kenkipkp&gt;
import re, pathlib

def arr_api_key(config_xml: str) -&amp;gt; str:
  text = pathlib.Path(config_xml).read_text()
  # config.xml has a single &amp;lt;ApiKey&amp;gt;...&amp;lt;/ApiKey&amp;gt; element
  return re.search(r&amp;quot;&amp;lt;ApiKey&amp;gt;([^&amp;lt;]+)&amp;lt;/ApiKey&amp;gt;&amp;quot;, text).group(1)

RADARR_KEY = arr_api_key(&amp;quot;/opt/arr/radarr/config/config.xml&amp;quot;)
SONARR_KEY = arr_api_key(&amp;quot;/opt/arr/sonarr/config/config.xml&amp;quot;)
&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;
&lt;p&gt;Even better, if your script runs &lt;strong&gt;on the same host&lt;/strong&gt; as the apps, you may not need the key at all:&lt;/p&gt;
&lt;div class=&quot;step&quot; data-astro-cid-wwaqy5cd&gt;&lt;div class=&quot;step-header&quot; data-astro-cid-wwaqy5cd&gt;&lt;span class=&quot;step-num&quot; data-astro-cid-wwaqy5cd&gt;1&lt;/span&gt;&lt;span class=&quot;step-title&quot; data-astro-cid-wwaqy5cd&gt;Turn on local-address auth bypass&lt;/span&gt;&lt;span class=&quot;step-time&quot; data-astro-cid-wwaqy5cd&gt;2 min&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;step-body&quot; data-astro-cid-wwaqy5cd&gt;&lt;p&gt;In each app: &lt;strong&gt;Settings → General → Security&lt;/strong&gt;, set &lt;strong&gt;Authentication Method&lt;/strong&gt; to &lt;em&gt;Forms (Login Page)&lt;/em&gt; and &lt;strong&gt;Authentication Required&lt;/strong&gt; to &lt;em&gt;Disabled for Local Addresses&lt;/em&gt;.&lt;/p&gt;&lt;p&gt;Now any request from &lt;code&gt;127.0.0.1&lt;/code&gt; skips authentication. Your local cron script talks to &lt;code&gt;http://127.0.0.1:7878&lt;/code&gt; (Radarr) or &lt;code&gt;:8989&lt;/code&gt; (Sonarr) with &lt;strong&gt;no key at all&lt;/strong&gt; — nothing to rotate, nothing to leak, nothing to hardcode. (Browser and off-LAN access still require login.)&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;
&lt;div class=&quot;callout callout-tip&quot; data-astro-cid-q2ml7llr&gt;&lt;div class=&quot;callout-header&quot; data-astro-cid-q2ml7llr&gt;&lt;span class=&quot;callout-icon&quot; aria-hidden=&quot;true&quot; data-astro-cid-q2ml7llr&gt;💡&lt;/span&gt;&lt;span class=&quot;callout-label&quot; data-astro-cid-q2ml7llr&gt;The principle generalizes&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;callout-body&quot; data-astro-cid-q2ml7llr&gt;&lt;p&gt;Any credential your automation copies is a credential that can drift. Wherever you can, read the live value from its source of truth at runtime instead of storing a second copy. A key you never wrote down can’t go stale.&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;pattern-2-consolidate--one-script-that-heals-not-three-that-rot&quot;&gt;Pattern 2: Consolidate — one script that heals, not three that rot&lt;/h2&gt;
&lt;p&gt;Three scripts meant three things to keep in sync and three places for a bug to hide. I merged the reconcile job, the re-monitor sweep, and the anime autosort into &lt;strong&gt;one script&lt;/strong&gt; that runs every 15 minutes and does the whole loop:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Reconcile the Plex/Jellyfin watchlist into Radarr &lt;strong&gt;and&lt;/strong&gt; Sonarr (matching by &lt;strong&gt;TMDb/TVDB ID, not title&lt;/strong&gt; — apps store variant titles like “Guardians of the Galaxy Vol. 2” without the colon, and title-matching silently misses them).&lt;/li&gt;
&lt;li&gt;Re-monitor movies and shows that should be grabbed.&lt;/li&gt;
&lt;li&gt;Sort adopted anime into the right libraries.&lt;/li&gt;
&lt;li&gt;Remediate dead grabs (Pattern 3 below).&lt;/li&gt;
&lt;li&gt;Run health checks and emit a heartbeat (Pattern 4).&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;One script, one cron entry, one place to reason about. The old scripts became thin shims that just exec the new one, so nothing that referenced them broke.&lt;/p&gt;
&lt;div class=&quot;callout callout-danger&quot; data-astro-cid-q2ml7llr&gt;&lt;div class=&quot;callout-header&quot; data-astro-cid-q2ml7llr&gt;&lt;span class=&quot;callout-icon&quot; aria-hidden=&quot;true&quot; data-astro-cid-q2ml7llr&gt;🚨&lt;/span&gt;&lt;span class=&quot;callout-label&quot; data-astro-cid-q2ml7llr&gt;Auto-remediate the deadlocked grab&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;callout-body&quot; data-astro-cid-q2ml7llr&gt;&lt;p&gt;The nastiest stall: a 4K release grabs, then dies (“no connections” / stalled / missing files), but Radarr thinks the item &lt;em&gt;already meets cutoff&lt;/em&gt; — so it won’t grab a live alternative. The queue looks busy; nothing progresses. My script tracks each queue item across runs, and if one is dead for &lt;strong&gt;two consecutive runs&lt;/strong&gt;, it evicts and blocklists it and re-searches — which lets a working release through. (Manual escape hatch: force-grab a specific release with &lt;code&gt;POST /api/v3/release {guid, indexerId}&lt;/code&gt;, which overrides the size/cutoff rejection.)&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;pattern-3-a-vpn-leak-check-that-finds-your-home-ip-by-itself&quot;&gt;Pattern 3: A VPN-leak check that finds your home IP by itself&lt;/h2&gt;
&lt;p&gt;My torrent client runs &lt;a href=&quot;https://peira.dev/playbooks/vpn-torrent-stack-docker/&quot;&gt;inside a gluetun VPN container&lt;/a&gt;, so its traffic should &lt;em&gt;never&lt;/em&gt; exit on my home WAN. The classic way to verify that is to compare the client’s exit IP against your home IP — but if you &lt;strong&gt;hardcode&lt;/strong&gt; your home IP, the check silently goes stale the moment your ISP hands you a new address, and now you have a leak detector that can’t detect leaks.&lt;/p&gt;
&lt;p&gt;The fix is to auto-detect. The NAS host sits &lt;em&gt;outside&lt;/em&gt; the VPN namespace, so it can fetch its own current public IP every run and compare:&lt;/p&gt;
&lt;figure class=&quot;terminal&quot; data-astro-cid-kenkipkp&gt;&lt;figcaption class=&quot;terminal-bar&quot; data-astro-cid-kenkipkp&gt;&lt;span class=&quot;dot red&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot yellow&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot green&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;terminal-title&quot; data-astro-cid-kenkipkp&gt;Auto-detecting leak check — no hardcoded home IP&lt;/span&gt;&lt;button class=&quot;terminal-copy&quot; type=&quot;button&quot; aria-label=&quot;Copy commands&quot; data-astro-cid-kenkipkp&gt;&lt;svg width=&quot;13&quot; height=&quot;13&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;none&quot; stroke=&quot;currentColor&quot; stroke-width=&quot;2&quot; stroke-linecap=&quot;round&quot; stroke-linejoin=&quot;round&quot; aria-hidden=&quot;true&quot; data-astro-cid-kenkipkp&gt;&lt;rect x=&quot;9&quot; y=&quot;9&quot; width=&quot;13&quot; height=&quot;13&quot; rx=&quot;2&quot; data-astro-cid-kenkipkp&gt;&lt;/rect&gt;&lt;path d=&quot;M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1&quot; data-astro-cid-kenkipkp&gt;&lt;/path&gt;&lt;/svg&gt;&lt;span class=&quot;terminal-copy-label&quot; data-astro-cid-kenkipkp&gt;Copy&lt;/span&gt;&lt;/button&gt;&lt;/figcaption&gt;&lt;pre class=&quot;terminal-body&quot; data-astro-cid-kenkipkp&gt;&lt;code data-astro-cid-kenkipkp&gt;
# The HOST&amp;#39;s own public IP (it&amp;#39;s outside the VPN) = your current home WAN:
HOME_WAN=$(wget -qO- https://api.ipify.org)

# The download CLIENT&amp;#39;s exit IP (it&amp;#39;s inside gluetun):
QBIT_IP=$(docker exec gluetun wget -qO- https://api.ipify.org)

# If they match, traffic is leaking onto your home connection — alert!
[ &amp;quot;$HOME_WAN&amp;quot; = &amp;quot;$QBIT_IP&amp;quot; ] &amp;amp;&amp;amp; echo &amp;quot;LEAK: qBittorrent is on the home WAN&amp;quot;
&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;
&lt;p&gt;Because &lt;code&gt;HOME_WAN&lt;/code&gt; is fetched fresh each run, the check keeps working across ISP IP changes. (You can add an optional CIDR allowlist for an ISP that rotates you within a known block.)&lt;/p&gt;
&lt;div class=&quot;callout callout-warning&quot; data-astro-cid-q2ml7llr&gt;&lt;div class=&quot;callout-header&quot; data-astro-cid-q2ml7llr&gt;&lt;span class=&quot;callout-icon&quot; aria-hidden=&quot;true&quot; data-astro-cid-q2ml7llr&gt;⚠️&lt;/span&gt;&lt;span class=&quot;callout-label&quot; data-astro-cid-q2ml7llr&gt;After any reboot, re-verify egress&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;callout-body&quot; data-astro-cid-q2ml7llr&gt;&lt;p&gt;A container restart or NAS reboot can bring the download client up before the tunnel. Bake the leak check into your automation so it runs continuously — don’t rely on a one-time manual check at setup.&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;pattern-4-a-dead-mans-switch-so-silence-becomes-an-alert&quot;&gt;Pattern 4: A dead-man’s-switch so silence becomes an alert&lt;/h2&gt;
&lt;p&gt;Every pattern above still leaves one hole: &lt;strong&gt;what alerts you when the script itself dies?&lt;/strong&gt; A key rotation, a bad deploy, or the whole NAS going down all produce the same thing that started this whole saga — silence.&lt;/p&gt;
&lt;p&gt;The answer is a &lt;strong&gt;push (dead-man’s-switch) monitor&lt;/strong&gt;. Instead of a monitor reaching out to check a service, your script checks &lt;em&gt;in&lt;/em&gt; every run. If the check-in stops arriving, the monitor alerts on the &lt;strong&gt;missing heartbeat&lt;/strong&gt;. I use &lt;a href=&quot;https://peira.dev/blog/uptime-kuma-monitoring/&quot;&gt;Uptime Kuma&lt;/a&gt; for this:&lt;/p&gt;
&lt;div class=&quot;step&quot; data-astro-cid-wwaqy5cd&gt;&lt;div class=&quot;step-header&quot; data-astro-cid-wwaqy5cd&gt;&lt;span class=&quot;step-num&quot; data-astro-cid-wwaqy5cd&gt;1&lt;/span&gt;&lt;span class=&quot;step-title&quot; data-astro-cid-wwaqy5cd&gt;Create a Push monitor in Uptime Kuma&lt;/span&gt;&lt;span class=&quot;step-time&quot; data-astro-cid-wwaqy5cd&gt;3 min&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;step-body&quot; data-astro-cid-wwaqy5cd&gt;&lt;p&gt;Add a new monitor of type &lt;strong&gt;Push&lt;/strong&gt;. Set the heartbeat interval to &lt;strong&gt;1800s&lt;/strong&gt; — that’s double my 15-minute cron, so a single missed run is tolerated but a truly-dead script alerts within half an hour. Kuma gives you a &lt;strong&gt;push URL&lt;/strong&gt;; store it in your automation’s env file (e.g. &lt;code&gt;KUMA_PUSH_URL&lt;/code&gt;).&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;
&lt;div class=&quot;step&quot; data-astro-cid-wwaqy5cd&gt;&lt;div class=&quot;step-header&quot; data-astro-cid-wwaqy5cd&gt;&lt;span class=&quot;step-num&quot; data-astro-cid-wwaqy5cd&gt;2&lt;/span&gt;&lt;span class=&quot;step-title&quot; data-astro-cid-wwaqy5cd&gt;Push status every run — up on success, down with a reason&lt;/span&gt;&lt;span class=&quot;step-time&quot; data-astro-cid-wwaqy5cd&gt;5 min&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;step-body&quot; data-astro-cid-wwaqy5cd&gt;&lt;p&gt;At the end of each run, the script pushes to that URL. A clean run pushes &lt;code&gt;status=up&lt;/code&gt;. Any problem — a VPN leak, an Arr &lt;code&gt;/health&lt;/code&gt; error, a queue item stalled over 12h — pushes &lt;code&gt;status=down&amp;amp;msg=&amp;lt;the reason&amp;gt;&lt;/code&gt;, so the alert tells me &lt;em&gt;what’s wrong&lt;/em&gt;, not just &lt;em&gt;that&lt;/em&gt; something is.&lt;/p&gt;&lt;figure class=&quot;terminal&quot; data-astro-cid-kenkipkp&gt;&lt;figcaption class=&quot;terminal-bar&quot; data-astro-cid-kenkipkp&gt;&lt;span class=&quot;dot red&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot yellow&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot green&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;terminal-title&quot; data-astro-cid-kenkipkp&gt;Heartbeat push at the end of the run&lt;/span&gt;&lt;button class=&quot;terminal-copy&quot; type=&quot;button&quot; aria-label=&quot;Copy commands&quot; data-astro-cid-kenkipkp&gt;&lt;svg width=&quot;13&quot; height=&quot;13&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;none&quot; stroke=&quot;currentColor&quot; stroke-width=&quot;2&quot; stroke-linecap=&quot;round&quot; stroke-linejoin=&quot;round&quot; aria-hidden=&quot;true&quot; data-astro-cid-kenkipkp&gt;&lt;rect x=&quot;9&quot; y=&quot;9&quot; width=&quot;13&quot; height=&quot;13&quot; rx=&quot;2&quot; data-astro-cid-kenkipkp&gt;&lt;/rect&gt;&lt;path d=&quot;M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1&quot; data-astro-cid-kenkipkp&gt;&lt;/path&gt;&lt;/svg&gt;&lt;span class=&quot;terminal-copy-label&quot; data-astro-cid-kenkipkp&gt;Copy&lt;/span&gt;&lt;/button&gt;&lt;/figcaption&gt;&lt;pre class=&quot;terminal-body&quot; data-astro-cid-kenkipkp&gt;&lt;code data-astro-cid-kenkipkp&gt;
# Clean run:
wget -qO- &amp;quot;$KUMA_PUSH_URL?status=up&amp;amp;msg=OK&amp;quot;

# Problem detected:
wget -qO- &amp;quot;$KUMA_PUSH_URL?status=down&amp;amp;msg=VPN_leak_qbit_on_home_wan&amp;quot;
&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;&lt;/div&gt;
&lt;div class=&quot;step&quot; data-astro-cid-wwaqy5cd&gt;&lt;div class=&quot;step-header&quot; data-astro-cid-wwaqy5cd&gt;&lt;span class=&quot;step-num&quot; data-astro-cid-wwaqy5cd&gt;3&lt;/span&gt;&lt;span class=&quot;step-title&quot; data-astro-cid-wwaqy5cd&gt;Get it on your phone with ntfy&lt;/span&gt;&lt;span class=&quot;step-time&quot; data-astro-cid-wwaqy5cd&gt;5 min&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;step-body&quot; data-astro-cid-wwaqy5cd&gt;&lt;p&gt;Attach a notification to the monitor so a &lt;code&gt;down&lt;/code&gt; (or a missed heartbeat) actually reaches you. &lt;a href=&quot;https://ntfy.sh&quot;&gt;ntfy&lt;/a&gt; is the easiest: pick a hard-to-guess topic, subscribe to it in the ntfy phone app, and add it as a Notification in Kuma. Now a dead script buzzes your phone like a text. (Telegram, email-to-carrier SMS gateway, Twilio, and SMTP all work too — ntfy is just the least friction.)&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;
&lt;div class=&quot;callout callout-tip&quot; data-astro-cid-q2ml7llr&gt;&lt;div class=&quot;callout-header&quot; data-astro-cid-q2ml7llr&gt;&lt;span class=&quot;callout-icon&quot; aria-hidden=&quot;true&quot; data-astro-cid-q2ml7llr&gt;💡&lt;/span&gt;&lt;span class=&quot;callout-label&quot; data-astro-cid-q2ml7llr&gt;Kuma gotcha worth knowing&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;callout-body&quot; data-astro-cid-q2ml7llr&gt;&lt;p&gt;Uptime Kuma has no REST API — it’s socket.io only — so scripting monitor creation is painful. &lt;strong&gt;Push monitors sidestep this entirely&lt;/strong&gt;: you create one in the UI once, grab its push URL, and everything after that is a plain HTTP GET from any script or language.&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;
&lt;p&gt;Now the exact failure that started this — automation silently not running — is the one thing that’s guaranteed to page me.&lt;/p&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;bonus-when-a-gui-app-caches-the-old-key&quot;&gt;Bonus: when a GUI app caches the old key&lt;/h2&gt;
&lt;p&gt;If a desktop app (mine is a Flutter dashboard) stores Arr keys in the OS keyring, a rotation leaves it holding a dead key too. Rather than re-enter keys by hand, a small helper reads the live keys from &lt;code&gt;config.xml&lt;/code&gt; and updates the keyring item in place — the same “read from the source of truth” principle as Pattern 1, applied to a cached copy instead of a hardcoded one.&lt;/p&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;the-takeaway&quot;&gt;The takeaway&lt;/h2&gt;
&lt;p&gt;The bug wasn’t really the rotated key. The bug was &lt;strong&gt;three copies of a secret and no alarm for silence.&lt;/strong&gt; The rebuild fixes both at the root:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Read credentials at runtime&lt;/strong&gt; (or skip them via local-address auth) — nothing to go stale.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Consolidate&lt;/strong&gt; to one script that also self-heals dead grabs.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Auto-detect&lt;/strong&gt; environmental facts (your WAN IP) instead of hardcoding them.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Add a dead-man’s switch&lt;/strong&gt; so “the automation stopped” becomes a push notification, not a week of quiet.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Apply those four and your media stack stops being a thing you babysit and starts being a thing that tells you when it needs you — which, ideally, is never.&lt;/p&gt;
&lt;hr/&gt;
&lt;p&gt;&lt;strong&gt;Related posts:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/arr-stack-automation/&quot;&gt;Automating the Arr Stack&lt;/a&gt; — the hands-off loop this hardens&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/jellyseerr-requests-arr-stack/&quot;&gt;Jellyseerr: Let Your Family Request Movies Themselves&lt;/a&gt; — the request front end this keeps healthy&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/playbooks/vpn-torrent-stack-docker/&quot;&gt;VPN-Shielded Torrent Stack (gluetun + qBittorrent)&lt;/a&gt; — where the leak check lives&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/uptime-kuma-monitoring/&quot;&gt;Uptime Kuma: Dead-Simple Homelab Monitoring&lt;/a&gt; — the push-monitor tool&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/arr-stack-docker-deploy/&quot;&gt;Deploy the Arr Stack with Docker Compose&lt;/a&gt; — the stack underneath it all&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/playbooks/clamav-download-scan-purge-linux/&quot;&gt;ClamAV Download Scan + Auto-Purge&lt;/a&gt; — the other unattended-download safety net&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/docker-localhost-trap/&quot;&gt;The Docker localhost Trap: 24 Hours of Red, Zero Real Downtime&lt;/a&gt; — another field note where “healthy-looking” and “working” disagreed&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Sources: &lt;a href=&quot;https://wiki.servarr.com&quot;&gt;Servarr Wiki&lt;/a&gt;, &lt;a href=&quot;https://github.com/louislam/uptime-kuma/wiki&quot;&gt;Uptime Kuma docs&lt;/a&gt;, &lt;a href=&quot;https://github.com/qdm12/gluetun&quot;&gt;gluetun&lt;/a&gt;, &lt;a href=&quot;https://ntfy.sh&quot;&gt;ntfy&lt;/a&gt;.&lt;/p&gt;</content:encoded><category>self-hosted</category><category>infrastructure</category><category>automation</category><category>media</category><category>monitoring</category></item><item><title>How to Set Up Traefik with Let&apos;s Encrypt</title><link>https://peira.dev/blog/traefik-letsencrypt-setup/</link><guid isPermaLink="true">https://peira.dev/blog/traefik-letsencrypt-setup/</guid><description>A hands-on walkthrough: deploy Traefik v3 with Docker, get automatic browser-trusted HTTPS from Let&apos;s Encrypt, redirect all HTTP to HTTPS, and route your first service to a clean domain using labels.</description><pubDate>Fri, 17 Jul 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;&lt;a href=&quot;https://peira.dev/blog/what-is-traefik/&quot;&gt;What Is Traefik?&lt;/a&gt; covered the &lt;em&gt;why&lt;/em&gt; — one HTTPS entry point instead of a spreadsheet of &lt;code&gt;IP:port&lt;/code&gt; addresses. This post is the hands-on half: a working &lt;a href=&quot;https://traefik.io/traefik/&quot;&gt;Traefik&lt;/a&gt; v3 deployment with automatic &lt;a href=&quot;https://letsencrypt.org/&quot;&gt;Let’s Encrypt&lt;/a&gt; certificates, an HTTP-to-HTTPS redirect, and your first service routed to a real domain. The copy-paste version lives in the &lt;a href=&quot;https://peira.dev/playbooks/traefik-letsencrypt-docker/&quot;&gt;Traefik + Let’s Encrypt playbook&lt;/a&gt;; this post explains each piece as you build it.&lt;/p&gt;
&lt;div class=&quot;callout callout-info&quot; data-astro-cid-q2ml7llr&gt;&lt;div class=&quot;callout-header&quot; data-astro-cid-q2ml7llr&gt;&lt;span class=&quot;callout-icon&quot; aria-hidden=&quot;true&quot; data-astro-cid-q2ml7llr&gt;ℹ️&lt;/span&gt;&lt;span class=&quot;callout-label&quot; data-astro-cid-q2ml7llr&gt;Make these values your own&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;callout-body&quot; data-astro-cid-q2ml7llr&gt;&lt;p&gt;Throughout, replace &lt;code&gt;example.com&lt;/code&gt; with a domain you actually control, and &lt;code&gt;you@example.com&lt;/code&gt; with your real email. Traefik needs a domain to get public certificates — a cheap one is fine, and your services can still stay private.&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;what-youll-build&quot;&gt;What you’ll build&lt;/h2&gt;
&lt;p&gt;Three moving parts, all inside one Docker Compose file:&lt;/p&gt;
&lt;svg viewBox=&quot;0 0 700 300&quot; xmlns=&quot;http://www.w3.org/2000/svg&quot; style=&quot;width:100%;border-radius:12px;background:#1c1712;margin:2rem 0&quot; aria-label=&quot;Diagram: Let&apos;s Encrypt validating a domain, Traefik terminating HTTPS, routing to a backend service&quot;&gt;&lt;defs&gt;&lt;pattern id=&quot;gp-traefikletse0&quot; width=&quot;20&quot; height=&quot;20&quot; patternUnits=&quot;userSpaceOnUse&quot;&gt;&lt;path d=&quot;M 20 0 H 0 V 20&quot; fill=&quot;none&quot; stroke=&quot;#221c15&quot; stroke-width=&quot;1&quot;&gt;&lt;/path&gt;&lt;/pattern&gt;&lt;/defs&gt;&lt;rect x=&quot;0&quot; y=&quot;0&quot; width=&quot;700&quot; height=&quot;300&quot; fill=&quot;url(#gp-traefikletse0)&quot;&gt;&lt;/rect&gt;&lt;rect x=&quot;6&quot; y=&quot;6&quot; width=&quot;688&quot; height=&quot;288&quot; fill=&quot;none&quot; stroke=&quot;#2e2820&quot; stroke-width=&quot;1&quot;&gt;&lt;/rect&gt;&lt;path d=&quot;M 11,16 H 21 M 16,11 V 21 M 679,16 H 689 M 684,11 V 21 M 11,284 H 21 M 16,279 V 289 M 679,284 H 689 M 684,279 V 289&quot; stroke=&quot;#4a3f2e&quot; stroke-width=&quot;1&quot; fill=&quot;none&quot;&gt;&lt;/path&gt;&lt;rect x=&quot;30&quot; y=&quot;30&quot; width=&quot;150&quot; height=&quot;60&quot; rx=&quot;8&quot; fill=&quot;#262019&quot; stroke=&quot;#6f9bd1&quot; stroke-width=&quot;1.5&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;105&quot; y=&quot;56&quot; text-anchor=&quot;middle&quot; fill=&quot;#e5ddcd&quot; font-size=&quot;11&quot; font-family=&quot;monospace&quot; font-weight=&quot;bold&quot;&gt;Let’s Encrypt&lt;/text&gt;&lt;text x=&quot;105&quot; y=&quot;73&quot; text-anchor=&quot;middle&quot; fill=&quot;#aba192&quot; font-size=&quot;9&quot; font-family=&quot;monospace&quot;&gt;ACME challenge&lt;/text&gt;&lt;rect x=&quot;270&quot; y=&quot;120&quot; width=&quot;150&quot; height=&quot;80&quot; rx=&quot;10&quot; fill=&quot;#2b241c&quot; stroke=&quot;#e8a33e&quot; stroke-width=&quot;2&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;345&quot; y=&quot;150&quot; text-anchor=&quot;middle&quot; fill=&quot;#e8a33e&quot; font-size=&quot;12&quot; font-family=&quot;monospace&quot; font-weight=&quot;bold&quot;&gt;Traefik&lt;/text&gt;&lt;text x=&quot;345&quot; y=&quot;168&quot; text-anchor=&quot;middle&quot; fill=&quot;#aba192&quot; font-size=&quot;9&quot; font-family=&quot;monospace&quot;&gt;:80 → :443&lt;/text&gt;&lt;text x=&quot;345&quot; y=&quot;182&quot; text-anchor=&quot;middle&quot; fill=&quot;#aba192&quot; font-size=&quot;9&quot; font-family=&quot;monospace&quot;&gt;terminates TLS&lt;/text&gt;&lt;rect x=&quot;510&quot; y=&quot;130&quot; width=&quot;165&quot; height=&quot;60&quot; rx=&quot;8&quot; fill=&quot;#262019&quot; stroke=&quot;#6f9bd1&quot; stroke-width=&quot;1.5&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;592&quot; y=&quot;156&quot; text-anchor=&quot;middle&quot; fill=&quot;#e5ddcd&quot; font-size=&quot;10&quot; font-family=&quot;monospace&quot;&gt;whoami.example.com&lt;/text&gt;&lt;text x=&quot;592&quot; y=&quot;173&quot; text-anchor=&quot;middle&quot; fill=&quot;#8a8071&quot; font-size=&quot;9&quot; font-family=&quot;monospace&quot;&gt;container, no TLS&lt;/text&gt;&lt;line x1=&quot;180&quot; y1=&quot;70&quot; x2=&quot;290&quot; y2=&quot;128&quot; stroke=&quot;#6f9bd1&quot; stroke-width=&quot;1.5&quot; stroke-dasharray=&quot;4 3&quot;&gt;&lt;/line&gt;&lt;text x=&quot;205&quot; y=&quot;105&quot; fill=&quot;#6f9bd1&quot; font-size=&quot;8&quot; font-family=&quot;monospace&quot;&gt;issues cert&lt;/text&gt;&lt;rect x=&quot;30&quot; y=&quot;150&quot; width=&quot;150&quot; height=&quot;60&quot; rx=&quot;8&quot; fill=&quot;#262019&quot; stroke=&quot;#443a2b&quot; stroke-width=&quot;1.5&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;105&quot; y=&quot;176&quot; text-anchor=&quot;middle&quot; fill=&quot;#e5ddcd&quot; font-size=&quot;11&quot; font-family=&quot;monospace&quot; font-weight=&quot;bold&quot;&gt;Your browser&lt;/text&gt;&lt;text x=&quot;105&quot; y=&quot;193&quot; text-anchor=&quot;middle&quot; fill=&quot;#aba192&quot; font-size=&quot;9&quot; font-family=&quot;monospace&quot;&gt;https://&lt;/text&gt;&lt;line x1=&quot;180&quot; y1=&quot;180&quot; x2=&quot;270&quot; y2=&quot;165&quot; stroke=&quot;#e8a33e&quot; stroke-width=&quot;2&quot;&gt;&lt;/line&gt;&lt;polygon points=&quot;270,165 260,162 261,172&quot; fill=&quot;#e8a33e&quot;&gt;&lt;/polygon&gt;&lt;line x1=&quot;420&quot; y1=&quot;160&quot; x2=&quot;510&quot; y2=&quot;160&quot; stroke=&quot;#e8a33e&quot; stroke-width=&quot;2&quot;&gt;&lt;/line&gt;&lt;polygon points=&quot;510,160 500,155 500,165&quot; fill=&quot;#e8a33e&quot;&gt;&lt;/polygon&gt;&lt;text x=&quot;690&quot; y=&quot;292&quot; text-anchor=&quot;end&quot; font-size=&quot;10&quot; fill=&quot;#8a8071&quot; opacity=&quot;0.6&quot; font-family=&quot;monospace&quot;&gt;peira.dev&lt;/text&gt;&lt;/svg&gt;
&lt;p&gt;Traefik listens on 80 and 443, redirects HTTP to HTTPS, asks Let’s Encrypt for a certificate the first time each hostname is hit, and forwards decrypted traffic to the backend container — which never has to know anything about TLS.&lt;/p&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;prerequisites&quot;&gt;Prerequisites&lt;/h2&gt;
&lt;p&gt;You need &lt;a href=&quot;https://peira.dev/playbooks/docker-install-linux/&quot;&gt;Docker and Compose installed&lt;/a&gt;, a &lt;strong&gt;domain you control&lt;/strong&gt;, and an &lt;code&gt;A&lt;/code&gt; record for your first hostname (&lt;code&gt;whoami.example.com&lt;/code&gt;) pointing at the host’s public IP. For the certificate step, &lt;strong&gt;port 80 must be reachable from the internet&lt;/strong&gt; so Let’s Encrypt can validate the domain. (Prefer no open ports? The &lt;a href=&quot;#internal-only-the-dns-challenge&quot;&gt;DNS challenge&lt;/a&gt; below skips them.)&lt;/p&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;step-1--configure-traefiks-entrypoints-and-resolver&quot;&gt;Step 1 — Configure Traefik’s entrypoints and resolver&lt;/h2&gt;
&lt;p&gt;Everything is passed to Traefik as &lt;code&gt;command:&lt;/code&gt; flags in the Compose file — no separate static config. The important lines:&lt;/p&gt;
&lt;figure class=&quot;terminal&quot; data-astro-cid-kenkipkp&gt;&lt;figcaption class=&quot;terminal-bar&quot; data-astro-cid-kenkipkp&gt;&lt;span class=&quot;dot red&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot yellow&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot green&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;terminal-title&quot; data-astro-cid-kenkipkp&gt;The Traefik service (excerpt)&lt;/span&gt;&lt;button class=&quot;terminal-copy&quot; type=&quot;button&quot; aria-label=&quot;Copy commands&quot; data-astro-cid-kenkipkp&gt;&lt;svg width=&quot;13&quot; height=&quot;13&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;none&quot; stroke=&quot;currentColor&quot; stroke-width=&quot;2&quot; stroke-linecap=&quot;round&quot; stroke-linejoin=&quot;round&quot; aria-hidden=&quot;true&quot; data-astro-cid-kenkipkp&gt;&lt;rect x=&quot;9&quot; y=&quot;9&quot; width=&quot;13&quot; height=&quot;13&quot; rx=&quot;2&quot; data-astro-cid-kenkipkp&gt;&lt;/rect&gt;&lt;path d=&quot;M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1&quot; data-astro-cid-kenkipkp&gt;&lt;/path&gt;&lt;/svg&gt;&lt;span class=&quot;terminal-copy-label&quot; data-astro-cid-kenkipkp&gt;Copy&lt;/span&gt;&lt;/button&gt;&lt;/figcaption&gt;&lt;pre class=&quot;terminal-body&quot; data-astro-cid-kenkipkp&gt;&lt;code data-astro-cid-kenkipkp&gt;
command:
- &amp;quot;--entrypoints.web.address=:80&amp;quot;
- &amp;quot;--entrypoints.websecure.address=:443&amp;quot;
# redirect every HTTP request to HTTPS
- &amp;quot;--entrypoints.web.http.redirections.entrypoint.to=websecure&amp;quot;
- &amp;quot;--entrypoints.web.http.redirections.entrypoint.scheme=https&amp;quot;
# discover containers from Docker, but only opted-in ones
- &amp;quot;--providers.docker=true&amp;quot;
- &amp;quot;--providers.docker.exposedbydefault=false&amp;quot;
# Let&amp;#39;s Encrypt via the HTTP-01 challenge
- &amp;quot;--certificatesresolvers.le.acme.httpchallenge=true&amp;quot;
- &amp;quot;--certificatesresolvers.le.acme.httpchallenge.entrypoint=web&amp;quot;
- &amp;quot;--certificatesresolvers.le.acme.email=you@example.com&amp;quot;
- &amp;quot;--certificatesresolvers.le.acme.storage=/letsencrypt/acme.json&amp;quot;
&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;
&lt;p&gt;&lt;code&gt;web&lt;/code&gt; is plain HTTP, &lt;code&gt;websecure&lt;/code&gt; is HTTPS, and the redirection lines mean nobody ever stays on an unencrypted connection. &lt;code&gt;exposedbydefault=false&lt;/code&gt; is the safe default: a container is only routed if it explicitly opts in. The &lt;code&gt;le&lt;/code&gt; resolver is the &lt;a href=&quot;https://doc.traefik.io/traefik/https/acme/&quot;&gt;ACME&lt;/a&gt; client that will talk to Let’s Encrypt.&lt;/p&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;step-2--prepare-the-acme-storage-and-start-traefik&quot;&gt;Step 2 — Prepare the ACME storage and start Traefik&lt;/h2&gt;
&lt;p&gt;Traefik stores issued certificates in &lt;code&gt;acme.json&lt;/code&gt;. It &lt;strong&gt;refuses to use the file&lt;/strong&gt; unless it’s owner-only (mode 600):&lt;/p&gt;
&lt;figure class=&quot;terminal&quot; data-astro-cid-kenkipkp&gt;&lt;figcaption class=&quot;terminal-bar&quot; data-astro-cid-kenkipkp&gt;&lt;span class=&quot;dot red&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot yellow&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot green&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;terminal-title&quot; data-astro-cid-kenkipkp&gt;Create acme.json and launch&lt;/span&gt;&lt;button class=&quot;terminal-copy&quot; type=&quot;button&quot; aria-label=&quot;Copy commands&quot; data-astro-cid-kenkipkp&gt;&lt;svg width=&quot;13&quot; height=&quot;13&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;none&quot; stroke=&quot;currentColor&quot; stroke-width=&quot;2&quot; stroke-linecap=&quot;round&quot; stroke-linejoin=&quot;round&quot; aria-hidden=&quot;true&quot; data-astro-cid-kenkipkp&gt;&lt;rect x=&quot;9&quot; y=&quot;9&quot; width=&quot;13&quot; height=&quot;13&quot; rx=&quot;2&quot; data-astro-cid-kenkipkp&gt;&lt;/rect&gt;&lt;path d=&quot;M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1&quot; data-astro-cid-kenkipkp&gt;&lt;/path&gt;&lt;/svg&gt;&lt;span class=&quot;terminal-copy-label&quot; data-astro-cid-kenkipkp&gt;Copy&lt;/span&gt;&lt;/button&gt;&lt;/figcaption&gt;&lt;pre class=&quot;terminal-body&quot; data-astro-cid-kenkipkp&gt;&lt;code data-astro-cid-kenkipkp&gt;
cd /opt/traefik
mkdir -p letsencrypt &amp;amp;&amp;amp; touch letsencrypt/acme.json &amp;amp;&amp;amp; chmod 600 letsencrypt/acme.json

sudo docker compose up -d
sudo docker compose logs -f traefik
&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;
&lt;div class=&quot;callout callout-warning&quot; data-astro-cid-q2ml7llr&gt;&lt;div class=&quot;callout-header&quot; data-astro-cid-q2ml7llr&gt;&lt;span class=&quot;callout-icon&quot; aria-hidden=&quot;true&quot; data-astro-cid-q2ml7llr&gt;⚠️&lt;/span&gt;&lt;span class=&quot;callout-label&quot; data-astro-cid-q2ml7llr&gt;Test against staging first&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;callout-body&quot; data-astro-cid-q2ml7llr&gt;&lt;p&gt;Let’s Encrypt enforces &lt;a href=&quot;https://letsencrypt.org/docs/rate-limits/&quot;&gt;rate limits&lt;/a&gt; on the production CA. While you’re getting the setup right, point the resolver at the staging server by adding &lt;code&gt;--certificatesresolvers.le.acme.caserver=https://acme-staging-v02.api.letsencrypt.org/directory&lt;/code&gt;. Staging certs aren’t trusted by browsers, but they prove the flow works. Once it does, remove that line, delete &lt;code&gt;acme.json&lt;/code&gt;, and restart to fetch a real certificate.&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;step-3--route-your-first-service&quot;&gt;Step 3 — Route your first service&lt;/h2&gt;
&lt;p&gt;A backend service opts in with four labels. Here’s the bundled &lt;code&gt;whoami&lt;/code&gt; test container:&lt;/p&gt;
&lt;figure class=&quot;terminal&quot; data-astro-cid-kenkipkp&gt;&lt;figcaption class=&quot;terminal-bar&quot; data-astro-cid-kenkipkp&gt;&lt;span class=&quot;dot red&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot yellow&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot green&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;terminal-title&quot; data-astro-cid-kenkipkp&gt;whoami service labels&lt;/span&gt;&lt;button class=&quot;terminal-copy&quot; type=&quot;button&quot; aria-label=&quot;Copy commands&quot; data-astro-cid-kenkipkp&gt;&lt;svg width=&quot;13&quot; height=&quot;13&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;none&quot; stroke=&quot;currentColor&quot; stroke-width=&quot;2&quot; stroke-linecap=&quot;round&quot; stroke-linejoin=&quot;round&quot; aria-hidden=&quot;true&quot; data-astro-cid-kenkipkp&gt;&lt;rect x=&quot;9&quot; y=&quot;9&quot; width=&quot;13&quot; height=&quot;13&quot; rx=&quot;2&quot; data-astro-cid-kenkipkp&gt;&lt;/rect&gt;&lt;path d=&quot;M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1&quot; data-astro-cid-kenkipkp&gt;&lt;/path&gt;&lt;/svg&gt;&lt;span class=&quot;terminal-copy-label&quot; data-astro-cid-kenkipkp&gt;Copy&lt;/span&gt;&lt;/button&gt;&lt;/figcaption&gt;&lt;pre class=&quot;terminal-body&quot; data-astro-cid-kenkipkp&gt;&lt;code data-astro-cid-kenkipkp&gt;
labels:
- &amp;quot;traefik.enable=true&amp;quot;
- &amp;quot;traefik.http.routers.whoami.rule=Host(`whoami.example.com`)&amp;quot;
- &amp;quot;traefik.http.routers.whoami.entrypoints=websecure&amp;quot;
- &amp;quot;traefik.http.routers.whoami.tls.certresolver=le&amp;quot;
&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;
&lt;p&gt;That’s the whole integration: enable routing, match a hostname, serve it on HTTPS, and use the &lt;code&gt;le&lt;/code&gt; resolver for the certificate. Visit &lt;code&gt;https://whoami.example.com&lt;/code&gt; and you should see a valid padlock and the container’s response — no certificate warning.&lt;/p&gt;
&lt;div class=&quot;step&quot; data-astro-cid-wwaqy5cd&gt;&lt;div class=&quot;step-header&quot; data-astro-cid-wwaqy5cd&gt;&lt;span class=&quot;step-num&quot; data-astro-cid-wwaqy5cd&gt;1&lt;/span&gt;&lt;span class=&quot;step-title&quot; data-astro-cid-wwaqy5cd&gt;Add any other app the same way&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;step-body&quot; data-astro-cid-wwaqy5cd&gt;&lt;p&gt;Copy those four labels onto the next container, change the router name (&lt;code&gt;whoami&lt;/code&gt; → &lt;code&gt;grafana&lt;/code&gt;) and the &lt;code&gt;Host()&lt;/code&gt; rule, and Traefik discovers it and requests its certificate automatically. No file edits, no reloads.&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;internal-only-the-dns-challenge&quot;&gt;Internal-only: the DNS challenge&lt;/h2&gt;
&lt;p&gt;If you don’t want port 80 open to the world, switch from HTTP-01 to the &lt;a href=&quot;https://letsencrypt.org/docs/challenge-types/#dns-01-challenge&quot;&gt;DNS-01 challenge&lt;/a&gt;. Traefik proves domain control by creating a TXT record through your DNS provider’s API — no inbound ports — and it can issue a single &lt;strong&gt;wildcard&lt;/strong&gt; certificate (&lt;code&gt;*.example.com&lt;/code&gt;) that covers every internal service at once. The &lt;a href=&quot;https://peira.dev/playbooks/traefik-letsencrypt-docker#internal-services-dns-01-wildcard-variant&quot;&gt;playbook&lt;/a&gt; has the exact &lt;code&gt;command&lt;/code&gt; swap and provider token setup. Pair it with &lt;a href=&quot;https://peira.dev/blog/pihole-proxmox-lxc/&quot;&gt;Pi-hole local DNS&lt;/a&gt; so the names resolve on your LAN.&lt;/p&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;whats-next&quot;&gt;What’s next&lt;/h2&gt;
&lt;p&gt;You now have real HTTPS on a clean domain and a repeatable pattern for every future service. Sensible next steps:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Put a real app behind it — &lt;a href=&quot;https://peira.dev/blog/vaultwarden-proxmox-lxc/&quot;&gt;Vaultwarden&lt;/a&gt; is a great first candidate.&lt;/li&gt;
&lt;li&gt;Add &lt;a href=&quot;https://doc.traefik.io/traefik/middlewares/overview/&quot;&gt;middleware&lt;/a&gt; — basic auth, IP allow-lists, or rate limiting — in front of any router.&lt;/li&gt;
&lt;li&gt;Reach your proxied services from anywhere without exposing them publicly using a &lt;a href=&quot;https://peira.dev/blog/tailscale-subnet-router/&quot;&gt;Tailscale subnet router&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The &lt;a href=&quot;https://doc.traefik.io/traefik/&quot;&gt;official Traefik documentation&lt;/a&gt; is the reference for going deeper.&lt;/p&gt;
&lt;hr/&gt;
&lt;p&gt;&lt;strong&gt;Related posts:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/what-is-traefik/&quot;&gt;What Is Traefik? Reverse Proxies Explained&lt;/a&gt; — the concepts behind this walkthrough.&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/playbooks/traefik-letsencrypt-docker/&quot;&gt;Traefik + Let’s Encrypt playbook&lt;/a&gt; — the copy-paste compose file.&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/docker-proxmox-lxc-vs-vm/&quot;&gt;Docker on Proxmox: LXC vs VM&lt;/a&gt; — the host Traefik runs on.&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/pihole-proxmox-lxc/&quot;&gt;Pi-hole on Proxmox LXC&lt;/a&gt; — the local DNS names Traefik routes.&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/tailscale-subnet-router/&quot;&gt;Tailscale Subnet Router&lt;/a&gt; — reach proxied services securely from anywhere.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Sources: &lt;a href=&quot;https://doc.traefik.io/traefik/&quot;&gt;Traefik documentation&lt;/a&gt;, &lt;a href=&quot;https://letsencrypt.org/docs/&quot;&gt;Let’s Encrypt documentation&lt;/a&gt;.&lt;/p&gt;</content:encoded><category>networking</category><category>self-hosted</category><category>security</category></item><item><title>10 Mistakes I Made Building a Homelab Second Brain (And How Each Got Fixed)</title><link>https://peira.dev/blog/second-brain-lessons-learned/</link><guid isPermaLink="true">https://peira.dev/blog/second-brain-lessons-learned/</guid><description>What went wrong — and why — as I built a canonical Git vault, AI agent skills, write locks, and projection consumers: silent data loss, hanging CLIs, restore gaps, and the one mistake I made twice.</description><pubDate>Thu, 16 Jul 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;If you’ve been following this series, you’ve seen the polished version: clean architecture diagrams, tidy terminal output, a system that works. What you haven’t seen is the path I took to get there — the CLI that blocked forever, the write that committed nothing, the restore path that had never been tested.&lt;/p&gt;
&lt;p&gt;This is that post. Every mistake here was real, cost real time, and produced a real fix that made the system more robust. Read it before you build, or read it after you hit the same wall. Either way, these are the traps that aren’t obvious from the happy-path documentation.&lt;/p&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;1-the-stdin-hang--misdiagnosed-as-a-slow-network-mount&quot;&gt;1. The stdin hang — misdiagnosed as a slow network mount&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;What happened.&lt;/strong&gt; &lt;code&gt;wiki-audit.mjs&lt;/code&gt; is the validation script that runs before every write — it scans the staged pages for broken links, stale paths, and anything secret-shaped. For weeks I was running it as:&lt;/p&gt;
&lt;figure class=&quot;terminal&quot; data-astro-cid-kenkipkp&gt;&lt;figcaption class=&quot;terminal-bar&quot; data-astro-cid-kenkipkp&gt;&lt;span class=&quot;dot red&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot yellow&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot green&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;terminal-title&quot; data-astro-cid-kenkipkp&gt;The wrong invocation&lt;/span&gt;&lt;button class=&quot;terminal-copy&quot; type=&quot;button&quot; aria-label=&quot;Copy commands&quot; data-astro-cid-kenkipkp&gt;&lt;svg width=&quot;13&quot; height=&quot;13&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;none&quot; stroke=&quot;currentColor&quot; stroke-width=&quot;2&quot; stroke-linecap=&quot;round&quot; stroke-linejoin=&quot;round&quot; aria-hidden=&quot;true&quot; data-astro-cid-kenkipkp&gt;&lt;rect x=&quot;9&quot; y=&quot;9&quot; width=&quot;13&quot; height=&quot;13&quot; rx=&quot;2&quot; data-astro-cid-kenkipkp&gt;&lt;/rect&gt;&lt;path d=&quot;M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1&quot; data-astro-cid-kenkipkp&gt;&lt;/path&gt;&lt;/svg&gt;&lt;span class=&quot;terminal-copy-label&quot; data-astro-cid-kenkipkp&gt;Copy&lt;/span&gt;&lt;/button&gt;&lt;/figcaption&gt;&lt;pre class=&quot;terminal-body&quot; data-astro-cid-kenkipkp&gt;&lt;code data-astro-cid-kenkipkp&gt;
node scripts/wiki-audit.mjs .
&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;
&lt;p&gt;Sometimes it finished in a few seconds. Sometimes it sat there doing nothing for minutes. I blamed the SSHFS mount (the vault lives on a Proxmox LXC, mounted over SSH on my laptop). I tuned mount options. I checked for network drops. I restarted the connection. None of it helped.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The actual cause.&lt;/strong&gt; The script reads the file list from &lt;strong&gt;standard input&lt;/strong&gt;, not a positional argument. Invoked bare, it was blocking forever waiting for stdin that never came. The tool had no timeout, no usage message, and no detection for “I’m running interactively with no pipe.” It just sat.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The fix.&lt;/strong&gt; The correct invocation pipes the file list in explicitly:&lt;/p&gt;
&lt;figure class=&quot;terminal&quot; data-astro-cid-kenkipkp&gt;&lt;figcaption class=&quot;terminal-bar&quot; data-astro-cid-kenkipkp&gt;&lt;span class=&quot;dot red&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot yellow&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot green&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;terminal-title&quot; data-astro-cid-kenkipkp&gt;The correct invocation&lt;/span&gt;&lt;button class=&quot;terminal-copy&quot; type=&quot;button&quot; aria-label=&quot;Copy commands&quot; data-astro-cid-kenkipkp&gt;&lt;svg width=&quot;13&quot; height=&quot;13&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;none&quot; stroke=&quot;currentColor&quot; stroke-width=&quot;2&quot; stroke-linecap=&quot;round&quot; stroke-linejoin=&quot;round&quot; aria-hidden=&quot;true&quot; data-astro-cid-kenkipkp&gt;&lt;rect x=&quot;9&quot; y=&quot;9&quot; width=&quot;13&quot; height=&quot;13&quot; rx=&quot;2&quot; data-astro-cid-kenkipkp&gt;&lt;/rect&gt;&lt;path d=&quot;M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1&quot; data-astro-cid-kenkipkp&gt;&lt;/path&gt;&lt;/svg&gt;&lt;span class=&quot;terminal-copy-label&quot; data-astro-cid-kenkipkp&gt;Copy&lt;/span&gt;&lt;/button&gt;&lt;/figcaption&gt;&lt;pre class=&quot;terminal-body&quot; data-astro-cid-kenkipkp&gt;&lt;code data-astro-cid-kenkipkp&gt;
git ls-files -s | node scripts/wiki-audit.mjs .
&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;
&lt;p&gt;And the tool itself was hardened to detect a TTY or empty stdin and print a usage message with a non-zero exit rather than blocking. This one change would have saved me hours — if a CLI tool does nothing when you invoke it without arguments, &lt;em&gt;that is a bug in the tool&lt;/em&gt;, not a mystery to diagnose in the environment.&lt;/p&gt;
&lt;div class=&quot;callout callout-tip&quot; data-astro-cid-q2ml7llr&gt;&lt;div class=&quot;callout-header&quot; data-astro-cid-q2ml7llr&gt;&lt;span class=&quot;callout-icon&quot; aria-hidden=&quot;true&quot; data-astro-cid-q2ml7llr&gt;💡&lt;/span&gt;&lt;span class=&quot;callout-label&quot; data-astro-cid-q2ml7llr&gt;Lesson 1&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;callout-body&quot; data-astro-cid-q2ml7llr&gt;&lt;p&gt;Build validation CLIs to fail fast with usage when they get no input. A tool that hangs silently trains you to distrust it and look for problems in the wrong layer. Always feed file-list arguments to audit tools explicitly via a pipe — never rely on them reading from a positional.&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;2-the---stage-argument-that-silently-staged-nothing&quot;&gt;2. The &lt;code&gt;--stage&lt;/code&gt; argument that silently staged nothing&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;What happened.&lt;/strong&gt; After moving to the streamlined &lt;code&gt;vault.mjs write&lt;/code&gt; path, I was passing the stage list like this:&lt;/p&gt;
&lt;figure class=&quot;terminal&quot; data-astro-cid-kenkipkp&gt;&lt;figcaption class=&quot;terminal-bar&quot; data-astro-cid-kenkipkp&gt;&lt;span class=&quot;dot red&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot yellow&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot green&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;terminal-title&quot; data-astro-cid-kenkipkp&gt;Space-separated stage list (wrong)&lt;/span&gt;&lt;button class=&quot;terminal-copy&quot; type=&quot;button&quot; aria-label=&quot;Copy commands&quot; data-astro-cid-kenkipkp&gt;&lt;svg width=&quot;13&quot; height=&quot;13&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;none&quot; stroke=&quot;currentColor&quot; stroke-width=&quot;2&quot; stroke-linecap=&quot;round&quot; stroke-linejoin=&quot;round&quot; aria-hidden=&quot;true&quot; data-astro-cid-kenkipkp&gt;&lt;rect x=&quot;9&quot; y=&quot;9&quot; width=&quot;13&quot; height=&quot;13&quot; rx=&quot;2&quot; data-astro-cid-kenkipkp&gt;&lt;/rect&gt;&lt;path d=&quot;M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1&quot; data-astro-cid-kenkipkp&gt;&lt;/path&gt;&lt;/svg&gt;&lt;span class=&quot;terminal-copy-label&quot; data-astro-cid-kenkipkp&gt;Copy&lt;/span&gt;&lt;/button&gt;&lt;/figcaption&gt;&lt;pre class=&quot;terminal-body&quot; data-astro-cid-kenkipkp&gt;&lt;code data-astro-cid-kenkipkp&gt;
node tools/canonical-vault/vault.mjs write --message &amp;#39;feat: add restore runbook&amp;#39; --stage &amp;#39;runbooks/claude/restore-vault.md runbooks/claude/canonical-write-procedure.md&amp;#39;
&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;
&lt;p&gt;The command returned a success JSON: &lt;code&gt;{ok: true, commit1: &amp;quot;abc1234&amp;quot;, ...}&lt;/code&gt;. Everything looked fine. The write appeared to have landed.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The actual cause.&lt;/strong&gt; The &lt;code&gt;--stage&lt;/code&gt; parser split on commas only. A space-separated list collapsed into one bogus pathspec. &lt;code&gt;git add&lt;/code&gt; silently ignored a path it couldn’t resolve — and then committed only whatever happened to already be staged from the preflight step (the auto-regenerated manifest). The content files I intended to commit were not in the commit. The system reported HEAD, but the canonical content wasn’t there.&lt;/p&gt;
&lt;p&gt;I only caught this during a restore drill, when I realized the recovered state didn’t include the file I’d just written.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The fix.&lt;/strong&gt; The parser now splits on commas &lt;em&gt;or&lt;/em&gt; whitespace, and validates each path has a pending change before taking the lock. A path with no pending change produces &lt;code&gt;{ok: false, error: &amp;quot;stage_unresolved&amp;quot;}&lt;/code&gt; and exits non-zero — no silent partial commits.&lt;/p&gt;
&lt;figure class=&quot;terminal&quot; data-astro-cid-kenkipkp&gt;&lt;figcaption class=&quot;terminal-bar&quot; data-astro-cid-kenkipkp&gt;&lt;span class=&quot;dot red&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot yellow&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot green&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;terminal-title&quot; data-astro-cid-kenkipkp&gt;Correct invocation (comma-separated or space-separated, both work now)&lt;/span&gt;&lt;button class=&quot;terminal-copy&quot; type=&quot;button&quot; aria-label=&quot;Copy commands&quot; data-astro-cid-kenkipkp&gt;&lt;svg width=&quot;13&quot; height=&quot;13&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;none&quot; stroke=&quot;currentColor&quot; stroke-width=&quot;2&quot; stroke-linecap=&quot;round&quot; stroke-linejoin=&quot;round&quot; aria-hidden=&quot;true&quot; data-astro-cid-kenkipkp&gt;&lt;rect x=&quot;9&quot; y=&quot;9&quot; width=&quot;13&quot; height=&quot;13&quot; rx=&quot;2&quot; data-astro-cid-kenkipkp&gt;&lt;/rect&gt;&lt;path d=&quot;M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1&quot; data-astro-cid-kenkipkp&gt;&lt;/path&gt;&lt;/svg&gt;&lt;span class=&quot;terminal-copy-label&quot; data-astro-cid-kenkipkp&gt;Copy&lt;/span&gt;&lt;/button&gt;&lt;/figcaption&gt;&lt;pre class=&quot;terminal-body&quot; data-astro-cid-kenkipkp&gt;&lt;code data-astro-cid-kenkipkp&gt;
# Use the exact stage_list output from preflight — don&amp;#39;t type it by hand
STAGE=$(node tools/canonical-vault/vault.mjs preflight | jq -r &amp;#39;.stage_list&amp;#39;)
node tools/canonical-vault/vault.mjs write --message &amp;#39;feat: ...&amp;#39; --stage &amp;quot;$STAGE&amp;quot;
&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;
&lt;div class=&quot;callout callout-danger&quot; data-astro-cid-q2ml7llr&gt;&lt;div class=&quot;callout-header&quot; data-astro-cid-q2ml7llr&gt;&lt;span class=&quot;callout-icon&quot; aria-hidden=&quot;true&quot; data-astro-cid-q2ml7llr&gt;🚨&lt;/span&gt;&lt;span class=&quot;callout-label&quot; data-astro-cid-q2ml7llr&gt;Lesson 2&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;callout-body&quot; data-astro-cid-q2ml7llr&gt;&lt;p&gt;Never hand-author your &lt;code&gt;--stage&lt;/code&gt; list. Use the exact output from &lt;code&gt;preflight&lt;/code&gt;. And build writes that validate each path has a pending change before committing — a &lt;code&gt;{ok: true}&lt;/code&gt; that staged nothing is worse than a hard failure.&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;3-the-manifest-only-commit-that-left-consumers-lying-about-their-revision&quot;&gt;3. The manifest-only commit that left consumers lying about their revision&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;What happened.&lt;/strong&gt; I added a new page to the vault. The wiki projection and embeddings index both reported the new HEAD commit afterward. I assumed they were up to date.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The actual cause.&lt;/strong&gt; The importer’s rebuild-vs-relabel logic was keyed off whether any &lt;em&gt;published page files&lt;/em&gt; changed in the commit range. The vault’s publishable-manifest file (&lt;code&gt;wiki-publishable-manifest.tsv&lt;/code&gt;) — which tracks the &lt;em&gt;list&lt;/em&gt; of pages — is not itself a published page. So a commit that only added a row to the manifest (a new page, a removed page) produced an empty changed-set, and the importer took the fast “relabel” path: stamp the new revision onto the stale consumers without rebuilding them.&lt;/p&gt;
&lt;p&gt;The result: the new page was missing from the wiki. A removed page lingered in the index. Both consumers claimed to be at HEAD. The revision label was honest; the content was not.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The fix.&lt;/strong&gt; The rebuild trigger now includes “did the manifest file itself change?” A manifest change forces a full reconcile regardless of whether any page files moved.&lt;/p&gt;
&lt;div class=&quot;callout callout-warning&quot; data-astro-cid-q2ml7llr&gt;&lt;div class=&quot;callout-header&quot; data-astro-cid-q2ml7llr&gt;&lt;span class=&quot;callout-icon&quot; aria-hidden=&quot;true&quot; data-astro-cid-q2ml7llr&gt;⚠️&lt;/span&gt;&lt;span class=&quot;callout-label&quot; data-astro-cid-q2ml7llr&gt;Lesson 3&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;callout-body&quot; data-astro-cid-q2ml7llr&gt;&lt;p&gt;A revision label tells you &lt;em&gt;when&lt;/em&gt; a consumer was last rebuilt, not whether the rebuild was complete. If the rebuild logic has blind spots, the label is misleading. Verify that every consumer rebuild path covers the cases you care about — adding pages, removing pages, and changing the manifest — not just modifying content of existing pages.&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;4-orphaned-pages-in-the-wiki-that-nobody-deleted&quot;&gt;4. Orphaned pages in the wiki that nobody deleted&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;What happened.&lt;/strong&gt; I deleted a page from the vault. The canonical Git history showed it removed. But it kept appearing in the Wiki.js site.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The actual cause.&lt;/strong&gt; The initial importer only did create and update — it never deleted. An orphaned page in the wiki had no mechanism to be removed. Once published, it lived forever until manually hunted down.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The fix.&lt;/strong&gt; The importer now runs a delete pass: for every published page &lt;em&gt;absent&lt;/em&gt; from the current manifest, it calls &lt;code&gt;pages.delete&lt;/code&gt;. Then it asserts exact parity — the published set must equal the manifest set exactly. If the numbers don’t match, the import fails loudly.&lt;/p&gt;
&lt;div class=&quot;callout callout-warning&quot; data-astro-cid-q2ml7llr&gt;&lt;div class=&quot;callout-header&quot; data-astro-cid-q2ml7llr&gt;&lt;span class=&quot;callout-icon&quot; aria-hidden=&quot;true&quot; data-astro-cid-q2ml7llr&gt;⚠️&lt;/span&gt;&lt;span class=&quot;callout-label&quot; data-astro-cid-q2ml7llr&gt;Lesson 4&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;callout-body&quot; data-astro-cid-q2ml7llr&gt;&lt;p&gt;If your projection importer doesn’t cascade deletes, every removal from the source becomes a permanent orphan in the projection. Always assert exact parity — not just “all source pages are published” but “published set = source set.” That second direction catches the orphans.&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;5-tools-with-enoent-and-cryptic-failures-instead-of-usage-messages&quot;&gt;5. Tools with &lt;code&gt;ENOENT&lt;/code&gt; and cryptic failures instead of usage messages&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;What happened.&lt;/strong&gt; I ran &lt;code&gt;build-topic-map.mjs&lt;/code&gt; and &lt;code&gt;wiki-publishable-manifest.mjs&lt;/code&gt; several times with slightly wrong invocations — a stray &lt;code&gt;--write&lt;/code&gt; without a root, a non-directory path, an unknown flag. Instead of a clear “here’s how to use this tool” message, I got &lt;code&gt;Error: ENOENT: no such file or directory&lt;/code&gt; or a hanging process.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The actual cause.&lt;/strong&gt; The scripts didn’t validate their own arguments. They parsed flags in a loose way that let stray arguments silently misparse. A bare &lt;code&gt;--write&lt;/code&gt; without a root positional got misinterpreted as the root path, then failed to stat &lt;code&gt;--write&lt;/code&gt; as a directory.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The fix.&lt;/strong&gt; All three core CLIs now validate their arguments on startup: if the root positional is missing or isn’t a directory, they print usage and exit 2. Unknown flags produce an error. &lt;code&gt;-h/--help&lt;/code&gt; works. A failed invocation tells you exactly what it wants.&lt;/p&gt;
&lt;div class=&quot;callout callout-tip&quot; data-astro-cid-q2ml7llr&gt;&lt;div class=&quot;callout-header&quot; data-astro-cid-q2ml7llr&gt;&lt;span class=&quot;callout-icon&quot; aria-hidden=&quot;true&quot; data-astro-cid-q2ml7llr&gt;💡&lt;/span&gt;&lt;span class=&quot;callout-label&quot; data-astro-cid-q2ml7llr&gt;Lesson 5&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;callout-body&quot; data-astro-cid-q2ml7llr&gt;&lt;p&gt;Every tool in a write path will eventually be run wrong — at midnight, after a long session, by someone following a runbook slightly out of date. Build them to fail with usage, not to fail with a stack trace or hang silently. A clear error message is a form of documentation that’s always current.&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;6-not-testing-the-restore-path-until-almost-too-late&quot;&gt;6. Not testing the restore path until almost too late&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;What happened.&lt;/strong&gt; The restore runbook existed. It was documented, reviewed, and linked from the right places. I’d never actually run it destructively against a real system. I had high confidence in it.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The actual cause.&lt;/strong&gt; Untested runbooks drift. Assumptions baked in when you write the procedure aren’t always valid when the environment has changed. I only ran the full restore drill (destructive, against a scratch target) months after writing the runbook — and found two issues:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;The “vault directory lost, CT 106 alive” path worked correctly.&lt;/li&gt;
&lt;li&gt;The bundle-then-restore path from the backup mirror worked — but the embeddings index came up at 85/86 pages (one missing), which is exactly the manifest-only-commit bug from Lesson 3.&lt;/li&gt;
&lt;li&gt;The SIGINT vs SIGTERM distinction for the writer lock was documented but had never been tested — SIGTERM does not trigger the &lt;code&gt;finally&lt;/code&gt; cleanup; SIGINT does. This would leave the lock held after a crash if you used the wrong signal.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;The fix.&lt;/strong&gt; Ran the full restore drill. Fixed the bugs it surfaced. Added the drill to the regular operations schedule.&lt;/p&gt;
&lt;div class=&quot;callout callout-danger&quot; data-astro-cid-q2ml7llr&gt;&lt;div class=&quot;callout-header&quot; data-astro-cid-q2ml7llr&gt;&lt;span class=&quot;callout-icon&quot; aria-hidden=&quot;true&quot; data-astro-cid-q2ml7llr&gt;🚨&lt;/span&gt;&lt;span class=&quot;callout-label&quot; data-astro-cid-q2ml7llr&gt;Lesson 6&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;callout-body&quot; data-astro-cid-q2ml7llr&gt;&lt;p&gt;An untested runbook is a hypothesis. A restore runbook that’s never been run is a liability that gives you false confidence. Drill it destructively on a scratch target before you need it in production. The act of drilling will find bugs that reading never will — including bugs in dependencies the runbook assumes are correct.&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;7-sigint-vs-sigterm--the-wrong-signal-leaves-the-lock-held&quot;&gt;7. SIGINT vs SIGTERM — the wrong signal leaves the lock held&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;What happened.&lt;/strong&gt; I interrupted a writer-lock process to abort a write. The lock file didn’t get cleaned up. Every subsequent write attempt blocked, waiting for a lock that had no owner.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The actual cause.&lt;/strong&gt; &lt;code&gt;canonical-writer-lock.py&lt;/code&gt; uses a &lt;code&gt;try/finally&lt;/code&gt; to release the lock. Python’s &lt;code&gt;finally&lt;/code&gt; runs on &lt;code&gt;KeyboardInterrupt&lt;/code&gt; (which &lt;code&gt;SIGINT&lt;/code&gt; raises) but &lt;strong&gt;not&lt;/strong&gt; on &lt;code&gt;SIGTERM&lt;/code&gt; (which kills the process immediately without running cleanup). I’d been sending &lt;code&gt;SIGTERM&lt;/code&gt; (the default for &lt;code&gt;kill&lt;/code&gt;).&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The fix.&lt;/strong&gt; Send &lt;code&gt;SIGINT&lt;/code&gt; to abort — not &lt;code&gt;SIGTERM&lt;/code&gt;. Have a way to confirm the lock is free afterward (e.g., check if the lock file exists). If the lock is stuck, delete it manually only after confirming no live process holds it.&lt;/p&gt;
&lt;figure class=&quot;terminal&quot; data-astro-cid-kenkipkp&gt;&lt;figcaption class=&quot;terminal-bar&quot; data-astro-cid-kenkipkp&gt;&lt;span class=&quot;dot red&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot yellow&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot green&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;terminal-title&quot; data-astro-cid-kenkipkp&gt;Abort a lock process correctly&lt;/span&gt;&lt;button class=&quot;terminal-copy&quot; type=&quot;button&quot; aria-label=&quot;Copy commands&quot; data-astro-cid-kenkipkp&gt;&lt;svg width=&quot;13&quot; height=&quot;13&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;none&quot; stroke=&quot;currentColor&quot; stroke-width=&quot;2&quot; stroke-linecap=&quot;round&quot; stroke-linejoin=&quot;round&quot; aria-hidden=&quot;true&quot; data-astro-cid-kenkipkp&gt;&lt;rect x=&quot;9&quot; y=&quot;9&quot; width=&quot;13&quot; height=&quot;13&quot; rx=&quot;2&quot; data-astro-cid-kenkipkp&gt;&lt;/rect&gt;&lt;path d=&quot;M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1&quot; data-astro-cid-kenkipkp&gt;&lt;/path&gt;&lt;/svg&gt;&lt;span class=&quot;terminal-copy-label&quot; data-astro-cid-kenkipkp&gt;Copy&lt;/span&gt;&lt;/button&gt;&lt;/figcaption&gt;&lt;pre class=&quot;terminal-body&quot; data-astro-cid-kenkipkp&gt;&lt;code data-astro-cid-kenkipkp&gt;
# Wrong — leaves the lock held:
kill &amp;lt;pid&amp;gt;         # sends SIGTERM by default

# Right — triggers cleanup in the finally block:
kill -INT &amp;lt;pid&amp;gt;    # sends SIGINT = KeyboardInterrupt = cleanup runs
&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;
&lt;div class=&quot;callout callout-tip&quot; data-astro-cid-q2ml7llr&gt;&lt;div class=&quot;callout-header&quot; data-astro-cid-q2ml7llr&gt;&lt;span class=&quot;callout-icon&quot; aria-hidden=&quot;true&quot; data-astro-cid-q2ml7llr&gt;💡&lt;/span&gt;&lt;span class=&quot;callout-label&quot; data-astro-cid-q2ml7llr&gt;Lesson 7&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;callout-body&quot; data-astro-cid-q2ml7llr&gt;&lt;p&gt;Any lock that doesn’t self-release on abort is a latent outage. Know what signal triggers your cleanup path. For Python &lt;code&gt;try/finally&lt;/code&gt;, that’s SIGINT, not SIGTERM. Document this in the runbook and in any error message that mentions how to abort.&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;8-split-brain-writing-to-a-clone-instead-of-the-canonical&quot;&gt;8. Split-brain: writing to a clone instead of the canonical&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;What happened.&lt;/strong&gt; Early on, before the SSHFS mount and the canonical-writer pattern were established, I was editing vault pages directly on the Proxmox node via SSH, editing the same pages on my laptop’s local copy, and occasionally editing through the Wiki.js interface. All three were “the brain.”&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The actual cause.&lt;/strong&gt; No single authority. The three copies drifted apart. Reconciliation required a manual three-way merge that lost some history and forced a judgment call on every conflict. The hardest part was figuring out &lt;em&gt;which&lt;/em&gt; version was correct — and accepting that no version was.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The fix.&lt;/strong&gt; Single canonical authority: &lt;code&gt;/wiki-data/git&lt;/code&gt; on CT 106. One write path. Every other consumer (laptop, wiki, agents) is read-only unless going through the controlled write. This is the architectural decision that makes everything else in this series possible — and it’s the one I wish I’d made on day one instead of three months in.&lt;/p&gt;
&lt;div class=&quot;callout callout-danger&quot; data-astro-cid-q2ml7llr&gt;&lt;div class=&quot;callout-header&quot; data-astro-cid-q2ml7llr&gt;&lt;span class=&quot;callout-icon&quot; aria-hidden=&quot;true&quot; data-astro-cid-q2ml7llr&gt;🚨&lt;/span&gt;&lt;span class=&quot;callout-label&quot; data-astro-cid-q2ml7llr&gt;Lesson 8&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;callout-body&quot; data-astro-cid-q2ml7llr&gt;&lt;p&gt;Multiple writable copies of a knowledge base isn’t “distributed” — it’s a split-brain waiting to happen. Decide which copy is the canonical authority and make everything else derive from it. The earlier you make this decision, the less reconciliation debt you accumulate.&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;9-a-flat-hand-maintained-manifest-that-grew-unmanageable&quot;&gt;9. A flat hand-maintained manifest that grew unmanageable&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;What happened.&lt;/strong&gt; The agent context file started as a hand-maintained list of “important pages for AI systems to read.” Every time I added a page, I updated the manifest. Entries drifted out of date. The file grew to the point where loading it consumed a meaningful chunk of an agent’s context window.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The actual cause.&lt;/strong&gt; A hand-maintained index always drifts behind the content it describes. The more pages you add, the more maintenance it requires, and the more likely some entries are stale.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The fix.&lt;/strong&gt; Replace the hand-maintained manifest with a &lt;strong&gt;generated context digest&lt;/strong&gt;: a single file produced by a script that reads every page’s frontmatter and emits a compact, current table — region, path, last-updated, description — plus guardrails and the RESUME pointer. Regenerated automatically on every &lt;code&gt;/end&lt;/code&gt;. An agent reads one file to get a map of the whole vault.&lt;/p&gt;
&lt;div class=&quot;callout callout-tip&quot; data-astro-cid-q2ml7llr&gt;&lt;div class=&quot;callout-header&quot; data-astro-cid-q2ml7llr&gt;&lt;span class=&quot;callout-icon&quot; aria-hidden=&quot;true&quot; data-astro-cid-q2ml7llr&gt;💡&lt;/span&gt;&lt;span class=&quot;callout-label&quot; data-astro-cid-q2ml7llr&gt;Lesson 9&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;callout-body&quot; data-astro-cid-q2ml7llr&gt;&lt;p&gt;If your agent’s startup context requires a file that humans maintain by hand, it will drift. Generate it from the ground truth instead. The script that generates it is always more current than anyone’s memory of what needs to be in the index.&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;10-assuming-a-build-passed-because-ci-said-green&quot;&gt;10. Assuming a build-passed because CI said green&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;What happened.&lt;/strong&gt; Draft pages are excluded from the Astro build. Blog posts with &lt;code&gt;draft: true&lt;/code&gt; in their frontmatter are not validated, not rendered, and not visible in &lt;code&gt;astro dev&lt;/code&gt; — the build completes successfully as if they didn’t exist.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The actual cause.&lt;/strong&gt; I trusted “build passed, 0 errors” as “my content is correct.” It wasn’t — the validation simply never ran on the draft files.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The fix.&lt;/strong&gt; Before publishing, temporarily set &lt;code&gt;draft: false&lt;/code&gt;, run the build, verify every page renders without errors, then restore the draft state or just publish. Never trust a “build passed” that silently skipped half the content.&lt;/p&gt;
&lt;div class=&quot;callout callout-warning&quot; data-astro-cid-q2ml7llr&gt;&lt;div class=&quot;callout-header&quot; data-astro-cid-q2ml7llr&gt;&lt;span class=&quot;callout-icon&quot; aria-hidden=&quot;true&quot; data-astro-cid-q2ml7llr&gt;⚠️&lt;/span&gt;&lt;span class=&quot;callout-label&quot; data-astro-cid-q2ml7llr&gt;Lesson 10&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;callout-body&quot; data-astro-cid-q2ml7llr&gt;&lt;p&gt;A green build that didn’t validate your content is worse than a red build — it gives false confidence. Know what your build tool excludes, and explicitly test those excluded cases before shipping.&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;the-pattern-behind-all-of-these&quot;&gt;The pattern behind all of these&lt;/h2&gt;
&lt;p&gt;Looking back, almost every mistake here shares a shape: &lt;strong&gt;silent success&lt;/strong&gt;. The write committed nothing and said &lt;code&gt;{ok: true}&lt;/code&gt;. The restore restored the wrong content and labeled itself as HEAD. The build passed and skipped the content it was supposed to check. The tool hung and showed nothing.&lt;/p&gt;
&lt;p&gt;Noisy failures are easy to fix. Silent successes are the traps — they give you confidence that collapses the moment you actually need the system to work.&lt;/p&gt;
&lt;p&gt;Build every component to fail loud, verify its own outputs, and return something meaningful when it does nothing. Then test the failure paths — not just the happy path — before you trust the system in production.&lt;/p&gt;
&lt;hr/&gt;
&lt;p&gt;&lt;strong&gt;This is the 13th post in the Build a Second Brain for Your Homelab series.&lt;/strong&gt; The rest of the series covers the architecture this post describes from the inside out:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/second-brain-homelab-why/&quot;&gt;Your Homelab Needs a Second Brain — Here’s Why&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/second-brain-git-markdown-vault/&quot;&gt;Never Lose a Homelab Note Again: A Git + Markdown Vault&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/second-brain-secrets-discipline/&quot;&gt;Never Commit a Secret to Your Knowledge Base&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/second-brain-topic-graph-generator/&quot;&gt;Turn a Pile of Notes Into a Connected Brain&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/second-brain-wikijs-projection/&quot;&gt;Turn Your Git Vault Into a Searchable Wiki.js Site&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/second-brain-controlled-write-lock/&quot;&gt;Why Auto-Syncing Your Notes Repo Will Corrupt It&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/second-brain-ai-begin-end-skills/&quot;&gt;Make Your AI Agents Read the Docs First, Every Time&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/second-brain-local-rag-embeddings/&quot;&gt;Semantic Search for Your Notes, Running Locally&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/second-brain-projection-health/&quot;&gt;Prove Your Homelab Docs Are Actually Up to Date&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/second-brain-repair-rebuild/&quot;&gt;Already Made a Mess of Your Notes? Here’s the Fix&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</content:encoded><category>ai</category><category>self-hosted</category><category>automation</category><category>git</category></item><item><title>Your First Docker Compose Stack: From Zero to a Running Service</title><link>https://peira.dev/blog/first-docker-compose-stack/</link><guid isPermaLink="true">https://peira.dev/blog/first-docker-compose-stack/</guid><description>A true beginner&apos;s guide to Docker Compose. Learn what images, containers, ports, and volumes actually are by deploying your first real service — the foundation every self-hosted app is built on.</description><pubDate>Wed, 15 Jul 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Almost every self-hosted app you’ll ever install — Immich, Homepage, Uptime Kuma, the entire media stack — is deployed the same way: with a short text file and one command. That file format is &lt;strong&gt;&lt;a href=&quot;https://docs.docker.com/compose/&quot;&gt;Docker Compose&lt;/a&gt;&lt;/strong&gt;, and once it clicks, you can deploy virtually anything. This guide is the click.&lt;/p&gt;
&lt;p&gt;We’ll skip the theory-heavy tour and just &lt;em&gt;build something&lt;/em&gt;, explaining each new idea the moment it appears. By the end you’ll have a running service and — more importantly — you’ll understand the four concepts (images, containers, ports, volumes) that every future install reuses.&lt;/p&gt;
&lt;div class=&quot;callout callout-info&quot; data-astro-cid-q2ml7llr&gt;&lt;div class=&quot;callout-header&quot; data-astro-cid-q2ml7llr&gt;&lt;span class=&quot;callout-icon&quot; aria-hidden=&quot;true&quot; data-astro-cid-q2ml7llr&gt;ℹ️&lt;/span&gt;&lt;span class=&quot;callout-label&quot; data-astro-cid-q2ml7llr&gt;What you need first&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;callout-body&quot; data-astro-cid-q2ml7llr&gt;&lt;p&gt;A host with Docker installed. In a homelab that’s usually a &lt;a href=&quot;https://peira.dev/blog/docker-proxmox-lxc-vs-vm/&quot;&gt;Docker LXC or a VM on Proxmox&lt;/a&gt;. Everything below runs from that host’s command line. Confirm Docker is ready with &lt;code&gt;docker –version&lt;/code&gt; and &lt;code&gt;docker compose version&lt;/code&gt;.&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;the-four-ideas-in-one-picture&quot;&gt;The four ideas, in one picture&lt;/h2&gt;
&lt;svg viewBox=&quot;0 0 720 300&quot; xmlns=&quot;http://www.w3.org/2000/svg&quot; style=&quot;width:100%;border-radius:12px;background:#1c1712;margin:2rem 0&quot;&gt;&lt;defs&gt;&lt;pattern id=&quot;gp-firstdockerc0&quot; width=&quot;20&quot; height=&quot;20&quot; patternUnits=&quot;userSpaceOnUse&quot;&gt;&lt;path d=&quot;M 20 0 H 0 V 20&quot; fill=&quot;none&quot; stroke=&quot;#221c15&quot; stroke-width=&quot;1&quot;&gt;&lt;/path&gt;&lt;/pattern&gt;&lt;/defs&gt;&lt;rect x=&quot;0&quot; y=&quot;0&quot; width=&quot;720&quot; height=&quot;300&quot; fill=&quot;url(#gp-firstdockerc0)&quot;&gt;&lt;/rect&gt;&lt;rect x=&quot;6&quot; y=&quot;6&quot; width=&quot;708&quot; height=&quot;288&quot; fill=&quot;none&quot; stroke=&quot;#2e2820&quot; stroke-width=&quot;1&quot;&gt;&lt;/rect&gt;&lt;path d=&quot;M 11,16 H 21 M 16,11 V 21 M 699,16 H 709 M 704,11 V 21 M 11,284 H 21 M 16,279 V 289 M 699,284 H 709 M 704,279 V 289&quot; stroke=&quot;#4a3f2e&quot; stroke-width=&quot;1&quot; fill=&quot;none&quot;&gt;&lt;/path&gt;&lt;text x=&quot;360&quot; y=&quot;28&quot; text-anchor=&quot;middle&quot; fill=&quot;#f2ede3&quot; font-size=&quot;13&quot; font-family=&quot;sans-serif&quot; font-weight=&quot;bold&quot;&gt;How a service actually runs&lt;/text&gt;&lt;rect x=&quot;40&quot; y=&quot;55&quot; width=&quot;150&quot; height=&quot;70&quot; rx=&quot;8&quot; fill=&quot;#16110d&quot; stroke=&quot;#6f9bd1&quot; stroke-width=&quot;1.2&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;115&quot; y=&quot;82&quot; text-anchor=&quot;middle&quot; fill=&quot;#6f9bd1&quot; font-size=&quot;12&quot; font-family=&quot;sans-serif&quot; font-weight=&quot;bold&quot;&gt;Image&lt;/text&gt;&lt;text x=&quot;115&quot; y=&quot;102&quot; text-anchor=&quot;middle&quot; fill=&quot;#aba192&quot; font-size=&quot;10&quot; font-family=&quot;sans-serif&quot;&gt;the template&lt;/text&gt;&lt;text x=&quot;115&quot; y=&quot;116&quot; text-anchor=&quot;middle&quot; fill=&quot;#8a8071&quot; font-size=&quot;9&quot; font-family=&quot;monospace&quot;&gt;nginx:latest&lt;/text&gt;&lt;text x=&quot;205&quot; y=&quot;95&quot; fill=&quot;#8a8071&quot; font-size=&quot;20&quot; font-family=&quot;sans-serif&quot;&gt;→&lt;/text&gt;&lt;rect x=&quot;235&quot; y=&quot;45&quot; width=&quot;200&quot; height=&quot;200&quot; rx=&quot;10&quot; fill=&quot;#2b2113&quot; stroke=&quot;#e8a33e&quot; stroke-width=&quot;1.5&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;335&quot; y=&quot;72&quot; text-anchor=&quot;middle&quot; fill=&quot;#e8a33e&quot; font-size=&quot;12&quot; font-family=&quot;sans-serif&quot; font-weight=&quot;bold&quot;&gt;Container&lt;/text&gt;&lt;text x=&quot;335&quot; y=&quot;90&quot; text-anchor=&quot;middle&quot; fill=&quot;#aba192&quot; font-size=&quot;10&quot; font-family=&quot;sans-serif&quot;&gt;the running instance&lt;/text&gt;&lt;rect x=&quot;260&quot; y=&quot;105&quot; width=&quot;150&quot; height=&quot;34&quot; rx=&quot;6&quot; fill=&quot;#16110d&quot; stroke=&quot;#d9a13d&quot; stroke-width=&quot;1&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;335&quot; y=&quot;127&quot; text-anchor=&quot;middle&quot; fill=&quot;#d9a13d&quot; font-size=&quot;10&quot; font-family=&quot;monospace&quot;&gt;Port  8080 → 80&lt;/text&gt;&lt;rect x=&quot;260&quot; y=&quot;150&quot; width=&quot;150&quot; height=&quot;34&quot; rx=&quot;6&quot; fill=&quot;#16110d&quot; stroke=&quot;#8fb573&quot; stroke-width=&quot;1&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;335&quot; y=&quot;172&quot; text-anchor=&quot;middle&quot; fill=&quot;#8fb573&quot; font-size=&quot;10&quot; font-family=&quot;monospace&quot;&gt;Volume  /data&lt;/text&gt;&lt;text x=&quot;335&quot; y=&quot;215&quot; text-anchor=&quot;middle&quot; fill=&quot;#8a8071&quot; font-size=&quot;9&quot; font-family=&quot;sans-serif&quot;&gt;isolated · disposable · repeatable&lt;/text&gt;&lt;rect x=&quot;480&quot; y=&quot;70&quot; width=&quot;200&quot; height=&quot;60&quot; rx=&quot;8&quot; fill=&quot;#16110d&quot; stroke=&quot;#d9a13d&quot; stroke-width=&quot;1.2&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;580&quot; y=&quot;94&quot; text-anchor=&quot;middle&quot; fill=&quot;#d9a13d&quot; font-size=&quot;11&quot; font-family=&quot;sans-serif&quot; font-weight=&quot;bold&quot;&gt;Port&lt;/text&gt;&lt;text x=&quot;580&quot; y=&quot;114&quot; text-anchor=&quot;middle&quot; fill=&quot;#aba192&quot; font-size=&quot;9&quot; font-family=&quot;sans-serif&quot;&gt;how you reach it in a browser&lt;/text&gt;&lt;rect x=&quot;480&quot; y=&quot;160&quot; width=&quot;200&quot; height=&quot;60&quot; rx=&quot;8&quot; fill=&quot;#16110d&quot; stroke=&quot;#8fb573&quot; stroke-width=&quot;1.2&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;580&quot; y=&quot;184&quot; text-anchor=&quot;middle&quot; fill=&quot;#8fb573&quot; font-size=&quot;11&quot; font-family=&quot;sans-serif&quot; font-weight=&quot;bold&quot;&gt;Volume&lt;/text&gt;&lt;text x=&quot;580&quot; y=&quot;204&quot; text-anchor=&quot;middle&quot; fill=&quot;#aba192&quot; font-size=&quot;9&quot; font-family=&quot;sans-serif&quot;&gt;where its data safely lives&lt;/text&gt;&lt;text x=&quot;710&quot; y=&quot;292&quot; text-anchor=&quot;end&quot; font-size=&quot;10&quot; fill=&quot;#8a8071&quot; opacity=&quot;0.6&quot; font-family=&quot;monospace&quot;&gt;peira.dev&lt;/text&gt;&lt;/svg&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Image&lt;/strong&gt; — the read-only template for an app, downloaded from a registry (Docker Hub). Think &lt;em&gt;recipe&lt;/em&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Container&lt;/strong&gt; — a live, running copy made from an image. Think &lt;em&gt;the finished dish&lt;/em&gt;. Disposable: delete it and start another anytime.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Port&lt;/strong&gt; — a container is sealed off by default; publishing a port pokes one hole so your browser can reach the app.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Volume&lt;/strong&gt; — storage that lives &lt;em&gt;outside&lt;/em&gt; the container so your data survives when the container is replaced.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Hold those four in mind. The compose file is just a tidy way to declare them.&lt;/p&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;build-your-first-stack&quot;&gt;Build your first stack&lt;/h2&gt;
&lt;div class=&quot;step&quot; data-astro-cid-wwaqy5cd&gt;&lt;div class=&quot;step-header&quot; data-astro-cid-wwaqy5cd&gt;&lt;span class=&quot;step-num&quot; data-astro-cid-wwaqy5cd&gt;1&lt;/span&gt;&lt;span class=&quot;step-title&quot; data-astro-cid-wwaqy5cd&gt;Make a folder for the stack&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;step-body&quot; data-astro-cid-wwaqy5cd&gt;&lt;p&gt;Each stack gets its own folder. The folder name becomes the project name, and the compose file lives inside it.&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;
&lt;figure class=&quot;terminal&quot; data-astro-cid-kenkipkp&gt;&lt;figcaption class=&quot;terminal-bar&quot; data-astro-cid-kenkipkp&gt;&lt;span class=&quot;dot red&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot yellow&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot green&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;terminal-title&quot; data-astro-cid-kenkipkp&gt;Create the project&lt;/span&gt;&lt;button class=&quot;terminal-copy&quot; type=&quot;button&quot; aria-label=&quot;Copy commands&quot; data-astro-cid-kenkipkp&gt;&lt;svg width=&quot;13&quot; height=&quot;13&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;none&quot; stroke=&quot;currentColor&quot; stroke-width=&quot;2&quot; stroke-linecap=&quot;round&quot; stroke-linejoin=&quot;round&quot; aria-hidden=&quot;true&quot; data-astro-cid-kenkipkp&gt;&lt;rect x=&quot;9&quot; y=&quot;9&quot; width=&quot;13&quot; height=&quot;13&quot; rx=&quot;2&quot; data-astro-cid-kenkipkp&gt;&lt;/rect&gt;&lt;path d=&quot;M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1&quot; data-astro-cid-kenkipkp&gt;&lt;/path&gt;&lt;/svg&gt;&lt;span class=&quot;terminal-copy-label&quot; data-astro-cid-kenkipkp&gt;Copy&lt;/span&gt;&lt;/button&gt;&lt;/figcaption&gt;&lt;pre class=&quot;terminal-body&quot; data-astro-cid-kenkipkp&gt;&lt;code data-astro-cid-kenkipkp&gt;
mkdir -p ~/stacks/hello &amp;amp;&amp;amp; cd ~/stacks/hello
&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;
&lt;div class=&quot;step&quot; data-astro-cid-wwaqy5cd&gt;&lt;div class=&quot;step-header&quot; data-astro-cid-wwaqy5cd&gt;&lt;span class=&quot;step-num&quot; data-astro-cid-wwaqy5cd&gt;2&lt;/span&gt;&lt;span class=&quot;step-title&quot; data-astro-cid-wwaqy5cd&gt;Write docker-compose.yml&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;step-body&quot; data-astro-cid-wwaqy5cd&gt;&lt;p&gt;Create a file named exactly &lt;code&gt;docker-compose.yml&lt;/code&gt;. We’ll run nginx, a tiny, rock-solid web server, and serve a page from a folder on the host. Every line is labelled.&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;
&lt;figure class=&quot;terminal&quot; data-astro-cid-kenkipkp&gt;&lt;figcaption class=&quot;terminal-bar&quot; data-astro-cid-kenkipkp&gt;&lt;span class=&quot;dot red&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot yellow&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot green&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;terminal-title&quot; data-astro-cid-kenkipkp&gt;~/stacks/hello/docker-compose.yml&lt;/span&gt;&lt;button class=&quot;terminal-copy&quot; type=&quot;button&quot; aria-label=&quot;Copy commands&quot; data-astro-cid-kenkipkp&gt;&lt;svg width=&quot;13&quot; height=&quot;13&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;none&quot; stroke=&quot;currentColor&quot; stroke-width=&quot;2&quot; stroke-linecap=&quot;round&quot; stroke-linejoin=&quot;round&quot; aria-hidden=&quot;true&quot; data-astro-cid-kenkipkp&gt;&lt;rect x=&quot;9&quot; y=&quot;9&quot; width=&quot;13&quot; height=&quot;13&quot; rx=&quot;2&quot; data-astro-cid-kenkipkp&gt;&lt;/rect&gt;&lt;path d=&quot;M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1&quot; data-astro-cid-kenkipkp&gt;&lt;/path&gt;&lt;/svg&gt;&lt;span class=&quot;terminal-copy-label&quot; data-astro-cid-kenkipkp&gt;Copy&lt;/span&gt;&lt;/button&gt;&lt;/figcaption&gt;&lt;pre class=&quot;terminal-body&quot; data-astro-cid-kenkipkp&gt;&lt;code data-astro-cid-kenkipkp&gt;
services:               # everything we want to run
web:                  # a name we pick for this service
  image: nginx:latest # the template to download and run
  container_name: hello-web
  ports:
    - &amp;quot;8080:80&amp;quot;       # host port 8080  -&amp;gt;  container port 80
  volumes:
    - ./site:/usr/share/nginx/html:ro   # serve our folder, read-only
  restart: unless-stopped               # start on boot, restart if it crashes
&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;
&lt;div class=&quot;callout callout-info&quot; data-astro-cid-q2ml7llr&gt;&lt;div class=&quot;callout-header&quot; data-astro-cid-q2ml7llr&gt;&lt;span class=&quot;callout-icon&quot; aria-hidden=&quot;true&quot; data-astro-cid-q2ml7llr&gt;ℹ️&lt;/span&gt;&lt;span class=&quot;callout-label&quot; data-astro-cid-q2ml7llr&gt;Reading the two mappings&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;callout-body&quot; data-astro-cid-q2ml7llr&gt;&lt;p&gt;&lt;strong&gt;Ports&lt;/strong&gt; read host:container. &lt;code&gt;“8080:80”&lt;/code&gt; means “browse to port 8080 on my machine, and Docker forwards it to port 80 inside the container.” &lt;strong&gt;Volumes&lt;/strong&gt; read host:container too. &lt;code&gt;./site:/usr/share/nginx/html&lt;/code&gt; means “the site folder next to this file appears inside the container at nginx’s web root.” Change a file in &lt;code&gt;./site&lt;/code&gt; and the running site changes — the data lives on the host, not trapped in the container.&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;
&lt;div class=&quot;step&quot; data-astro-cid-wwaqy5cd&gt;&lt;div class=&quot;step-header&quot; data-astro-cid-wwaqy5cd&gt;&lt;span class=&quot;step-num&quot; data-astro-cid-wwaqy5cd&gt;3&lt;/span&gt;&lt;span class=&quot;step-title&quot; data-astro-cid-wwaqy5cd&gt;Add a page to serve&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;step-body&quot; data-astro-cid-wwaqy5cd&gt;&lt;p&gt;Create the folder the volume points at, and drop in an index page.&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;
&lt;figure class=&quot;terminal&quot; data-astro-cid-kenkipkp&gt;&lt;figcaption class=&quot;terminal-bar&quot; data-astro-cid-kenkipkp&gt;&lt;span class=&quot;dot red&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot yellow&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot green&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;terminal-title&quot; data-astro-cid-kenkipkp&gt;Create the content&lt;/span&gt;&lt;button class=&quot;terminal-copy&quot; type=&quot;button&quot; aria-label=&quot;Copy commands&quot; data-astro-cid-kenkipkp&gt;&lt;svg width=&quot;13&quot; height=&quot;13&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;none&quot; stroke=&quot;currentColor&quot; stroke-width=&quot;2&quot; stroke-linecap=&quot;round&quot; stroke-linejoin=&quot;round&quot; aria-hidden=&quot;true&quot; data-astro-cid-kenkipkp&gt;&lt;rect x=&quot;9&quot; y=&quot;9&quot; width=&quot;13&quot; height=&quot;13&quot; rx=&quot;2&quot; data-astro-cid-kenkipkp&gt;&lt;/rect&gt;&lt;path d=&quot;M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1&quot; data-astro-cid-kenkipkp&gt;&lt;/path&gt;&lt;/svg&gt;&lt;span class=&quot;terminal-copy-label&quot; data-astro-cid-kenkipkp&gt;Copy&lt;/span&gt;&lt;/button&gt;&lt;/figcaption&gt;&lt;pre class=&quot;terminal-body&quot; data-astro-cid-kenkipkp&gt;&lt;code data-astro-cid-kenkipkp&gt;
mkdir -p site
echo &amp;quot;&amp;lt;h1&amp;gt;It works. I run my own web server now.&amp;lt;/h1&amp;gt;&amp;quot; &amp;gt; site/index.html
&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;
&lt;div class=&quot;step&quot; data-astro-cid-wwaqy5cd&gt;&lt;div class=&quot;step-header&quot; data-astro-cid-wwaqy5cd&gt;&lt;span class=&quot;step-num&quot; data-astro-cid-wwaqy5cd&gt;4&lt;/span&gt;&lt;span class=&quot;step-title&quot; data-astro-cid-wwaqy5cd&gt;Start it&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;step-body&quot; data-astro-cid-wwaqy5cd&gt;&lt;p&gt;One command builds and starts the whole stack in the background. The &lt;code&gt;-d&lt;/code&gt; means “detached” — it runs without holding your terminal.&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;
&lt;figure class=&quot;terminal&quot; data-astro-cid-kenkipkp&gt;&lt;figcaption class=&quot;terminal-bar&quot; data-astro-cid-kenkipkp&gt;&lt;span class=&quot;dot red&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot yellow&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot green&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;terminal-title&quot; data-astro-cid-kenkipkp&gt;Launch the stack&lt;/span&gt;&lt;button class=&quot;terminal-copy&quot; type=&quot;button&quot; aria-label=&quot;Copy commands&quot; data-astro-cid-kenkipkp&gt;&lt;svg width=&quot;13&quot; height=&quot;13&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;none&quot; stroke=&quot;currentColor&quot; stroke-width=&quot;2&quot; stroke-linecap=&quot;round&quot; stroke-linejoin=&quot;round&quot; aria-hidden=&quot;true&quot; data-astro-cid-kenkipkp&gt;&lt;rect x=&quot;9&quot; y=&quot;9&quot; width=&quot;13&quot; height=&quot;13&quot; rx=&quot;2&quot; data-astro-cid-kenkipkp&gt;&lt;/rect&gt;&lt;path d=&quot;M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1&quot; data-astro-cid-kenkipkp&gt;&lt;/path&gt;&lt;/svg&gt;&lt;span class=&quot;terminal-copy-label&quot; data-astro-cid-kenkipkp&gt;Copy&lt;/span&gt;&lt;/button&gt;&lt;/figcaption&gt;&lt;pre class=&quot;terminal-body&quot; data-astro-cid-kenkipkp&gt;&lt;code data-astro-cid-kenkipkp&gt;
docker compose up -d
&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;
&lt;p&gt;Open &lt;code&gt;http://YOUR-HOST-IP:8080&lt;/code&gt; in a browser (replace &lt;code&gt;YOUR-HOST-IP&lt;/code&gt; with the address of your Docker host — the machine you’ve been typing on). There’s your page — served by a container you defined in eight lines. You just deployed a service, and every future deploy will feel like this one.&lt;/p&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;the-five-commands-youll-use-forever&quot;&gt;The five commands you’ll use forever&lt;/h2&gt;
&lt;p&gt;These work in &lt;em&gt;any&lt;/em&gt; stack folder, and they’re 90% of day-to-day Docker:&lt;/p&gt;
&lt;figure class=&quot;terminal&quot; data-astro-cid-kenkipkp&gt;&lt;figcaption class=&quot;terminal-bar&quot; data-astro-cid-kenkipkp&gt;&lt;span class=&quot;dot red&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot yellow&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot green&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;terminal-title&quot; data-astro-cid-kenkipkp&gt;The essentials&lt;/span&gt;&lt;button class=&quot;terminal-copy&quot; type=&quot;button&quot; aria-label=&quot;Copy commands&quot; data-astro-cid-kenkipkp&gt;&lt;svg width=&quot;13&quot; height=&quot;13&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;none&quot; stroke=&quot;currentColor&quot; stroke-width=&quot;2&quot; stroke-linecap=&quot;round&quot; stroke-linejoin=&quot;round&quot; aria-hidden=&quot;true&quot; data-astro-cid-kenkipkp&gt;&lt;rect x=&quot;9&quot; y=&quot;9&quot; width=&quot;13&quot; height=&quot;13&quot; rx=&quot;2&quot; data-astro-cid-kenkipkp&gt;&lt;/rect&gt;&lt;path d=&quot;M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1&quot; data-astro-cid-kenkipkp&gt;&lt;/path&gt;&lt;/svg&gt;&lt;span class=&quot;terminal-copy-label&quot; data-astro-cid-kenkipkp&gt;Copy&lt;/span&gt;&lt;/button&gt;&lt;/figcaption&gt;&lt;pre class=&quot;terminal-body&quot; data-astro-cid-kenkipkp&gt;&lt;code data-astro-cid-kenkipkp&gt;
docker compose up -d       # start (or apply changes to) the stack
docker compose ps          # what&amp;#39;s running, and is it healthy?
docker compose logs -f      # live logs — your first stop when something&amp;#39;s wrong
docker compose pull        # download newer images
docker compose down        # stop and remove the containers (volumes are kept)
&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;
&lt;div class=&quot;callout callout-tip&quot; data-astro-cid-q2ml7llr&gt;&lt;div class=&quot;callout-header&quot; data-astro-cid-q2ml7llr&gt;&lt;span class=&quot;callout-icon&quot; aria-hidden=&quot;true&quot; data-astro-cid-q2ml7llr&gt;💡&lt;/span&gt;&lt;span class=&quot;callout-label&quot; data-astro-cid-q2ml7llr&gt;Updating a service is two commands&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;callout-body&quot; data-astro-cid-q2ml7llr&gt;&lt;p&gt;This is the pattern for updating almost everything you self-host: &lt;code&gt;docker compose pull&lt;/code&gt; to fetch the new image, then &lt;code&gt;docker compose up -d&lt;/code&gt; to recreate the container with it. Because your data is in a volume, it carries straight over. That’s the whole update routine for Immich, Homepage, Uptime Kuma — all of them.&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;named-volumes-vs-bind-mounts-the-one-nuance-worth-knowing&quot;&gt;Named volumes vs. bind mounts (the one nuance worth knowing)&lt;/h2&gt;
&lt;p&gt;You just used a &lt;strong&gt;bind mount&lt;/strong&gt; — a specific host folder (&lt;code&gt;./site&lt;/code&gt;) mapped in. That’s perfect when &lt;em&gt;you&lt;/em&gt; want to see and edit the files. The other kind is a &lt;strong&gt;named volume&lt;/strong&gt;, which Docker manages for you:&lt;/p&gt;
&lt;figure class=&quot;terminal&quot; data-astro-cid-kenkipkp&gt;&lt;figcaption class=&quot;terminal-bar&quot; data-astro-cid-kenkipkp&gt;&lt;span class=&quot;dot red&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot yellow&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot green&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;terminal-title&quot; data-astro-cid-kenkipkp&gt;A named volume instead of a host folder&lt;/span&gt;&lt;button class=&quot;terminal-copy&quot; type=&quot;button&quot; aria-label=&quot;Copy commands&quot; data-astro-cid-kenkipkp&gt;&lt;svg width=&quot;13&quot; height=&quot;13&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;none&quot; stroke=&quot;currentColor&quot; stroke-width=&quot;2&quot; stroke-linecap=&quot;round&quot; stroke-linejoin=&quot;round&quot; aria-hidden=&quot;true&quot; data-astro-cid-kenkipkp&gt;&lt;rect x=&quot;9&quot; y=&quot;9&quot; width=&quot;13&quot; height=&quot;13&quot; rx=&quot;2&quot; data-astro-cid-kenkipkp&gt;&lt;/rect&gt;&lt;path d=&quot;M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1&quot; data-astro-cid-kenkipkp&gt;&lt;/path&gt;&lt;/svg&gt;&lt;span class=&quot;terminal-copy-label&quot; data-astro-cid-kenkipkp&gt;Copy&lt;/span&gt;&lt;/button&gt;&lt;/figcaption&gt;&lt;pre class=&quot;terminal-body&quot; data-astro-cid-kenkipkp&gt;&lt;code data-astro-cid-kenkipkp&gt;
services:
db:
  image: postgres:16
  volumes:
    - db-data:/var/lib/postgresql/data   # named volume, managed by Docker

volumes:
db-data:                                  # declared down here
&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;
&lt;p&gt;Rule of thumb: use a &lt;strong&gt;bind mount&lt;/strong&gt; for things you want to open and edit yourself (config files, a media library on your &lt;a href=&quot;https://peira.dev/blog/homelab-nas-samba-shares/&quot;&gt;NAS share&lt;/a&gt;); use a &lt;strong&gt;named volume&lt;/strong&gt; for opaque application data like databases, where you never touch the files directly. Both survive &lt;code&gt;docker compose down&lt;/code&gt;. Both are how your data outlives the container.&lt;/p&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;you-just-unlocked-the-whole-catalogue&quot;&gt;You just unlocked the whole catalogue&lt;/h2&gt;
&lt;p&gt;That’s it — that’s the skill. Every self-hosted guide from here is a variation on the same four ideas and the same five commands. When you read an app’s install docs and see a &lt;code&gt;docker-compose.yml&lt;/code&gt;, you now know exactly what each line does and how to run it.&lt;/p&gt;
&lt;p&gt;Ready to use it for real? Point this skill at &lt;a href=&quot;https://peira.dev/blog/immich-proxmox-photos/&quot;&gt;Immich for private photo backup&lt;/a&gt;, a &lt;a href=&quot;https://peira.dev/blog/homepage-dashboard/&quot;&gt;Homepage dashboard&lt;/a&gt;, or &lt;a href=&quot;https://peira.dev/blog/uptime-kuma-monitoring/&quot;&gt;Uptime Kuma monitoring&lt;/a&gt; — or head back to the &lt;a href=&quot;https://peira.dev/start/&quot;&gt;full roadmap&lt;/a&gt; and keep building.&lt;/p&gt;
&lt;hr/&gt;
&lt;p&gt;&lt;strong&gt;Related posts:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/docker-proxmox-lxc-vs-vm/&quot;&gt;Docker on Proxmox: LXC vs VM&lt;/a&gt; — where to run your Compose stacks&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/immich-proxmox-photos/&quot;&gt;Immich: Self-Hosted Photos on Proxmox&lt;/a&gt; — a real Compose stack to deploy next&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/homepage-dashboard/&quot;&gt;Homepage Dashboard&lt;/a&gt; — another one-file Compose deploy&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/uptime-kuma-monitoring/&quot;&gt;Uptime Kuma: Dead-Simple Homelab Monitoring&lt;/a&gt; — monitor the stacks you deploy&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/traefik-letsencrypt-setup/&quot;&gt;Traefik + Let’s Encrypt Reverse Proxy&lt;/a&gt; — put your Compose services behind HTTPS&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/arr-stack-docker-deploy/&quot;&gt;Deploy the Arr Stack with Docker Compose&lt;/a&gt; — a larger multi-service Compose example&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/docker-localhost-trap/&quot;&gt;The Docker localhost Trap: 24 Hours of Red, Zero Real Downtime&lt;/a&gt; — what goes wrong when containers call each other &lt;code&gt;localhost&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/demo-lab-screenshots-headless-chrome/&quot;&gt;Real Screenshots, Zero Leaks: My Disposable Demo Lab&lt;/a&gt; — a small Compose stack built purely to be photographed&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Sources: &lt;a href=&quot;https://docs.docker.com/compose/&quot;&gt;Docker Compose documentation&lt;/a&gt;, &lt;a href=&quot;https://docs.docker.com/&quot;&gt;Docker documentation&lt;/a&gt;.&lt;/p&gt;</content:encoded><category>getting-started</category><category>self-hosted</category><category>automation</category></item><item><title>Run Home Assistant on Proxmox the Right Way (HAOS in a VM)</title><link>https://peira.dev/blog/home-assistant-proxmox-vm/</link><guid isPermaLink="true">https://peira.dev/blog/home-assistant-proxmox-vm/</guid><description>The correct way to run Home Assistant in a Proxmox homelab: Home Assistant OS in a dedicated VM, installed with the community helper script. Covers why a VM beats a container, and full onboarding.</description><pubDate>Wed, 15 Jul 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Home Assistant is the reason a huge number of people build a homelab in the first place. It’s the open-source brain for your smart home — local, private, and vendor-neutral, tying together lights, sensors, locks, cameras, and thousands of other devices without shipping your data to anyone’s cloud.&lt;/p&gt;
&lt;p&gt;There’s just one catch that trips up nearly every beginner: &lt;strong&gt;there are four ways to install it, and only one of them is the good one on Proxmox.&lt;/strong&gt; Pick wrong and you’ll spend a weekend fighting a broken Add-on store. Let’s get it right the first time.&lt;/p&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;the-four-installs-and-why-you-want-haos&quot;&gt;The four installs, and why you want HAOS&lt;/h2&gt;
&lt;svg viewBox=&quot;0 0 720 260&quot; xmlns=&quot;http://www.w3.org/2000/svg&quot; style=&quot;width:100%;border-radius:12px;background:#1c1712;margin:2rem 0&quot;&gt;&lt;defs&gt;&lt;pattern id=&quot;gp-homeassistan0&quot; width=&quot;20&quot; height=&quot;20&quot; patternUnits=&quot;userSpaceOnUse&quot;&gt;&lt;path d=&quot;M 20 0 H 0 V 20&quot; fill=&quot;none&quot; stroke=&quot;#221c15&quot; stroke-width=&quot;1&quot;&gt;&lt;/path&gt;&lt;/pattern&gt;&lt;/defs&gt;&lt;rect x=&quot;0&quot; y=&quot;0&quot; width=&quot;720&quot; height=&quot;260&quot; fill=&quot;url(#gp-homeassistan0)&quot;&gt;&lt;/rect&gt;&lt;rect x=&quot;6&quot; y=&quot;6&quot; width=&quot;708&quot; height=&quot;248&quot; fill=&quot;none&quot; stroke=&quot;#2e2820&quot; stroke-width=&quot;1&quot;&gt;&lt;/rect&gt;&lt;path d=&quot;M 11,16 H 21 M 16,11 V 21 M 699,16 H 709 M 704,11 V 21 M 11,244 H 21 M 16,239 V 249 M 699,244 H 709 M 704,239 V 249&quot; stroke=&quot;#4a3f2e&quot; stroke-width=&quot;1&quot; fill=&quot;none&quot;&gt;&lt;/path&gt;&lt;text x=&quot;360&quot; y=&quot;28&quot; text-anchor=&quot;middle&quot; fill=&quot;#f2ede3&quot; font-size=&quot;13&quot; font-family=&quot;sans-serif&quot; font-weight=&quot;bold&quot;&gt;Four ways to install — one right answer for Proxmox&lt;/text&gt;&lt;rect x=&quot;40&quot; y=&quot;55&quot; width=&quot;150&quot; height=&quot;160&quot; rx=&quot;8&quot; fill=&quot;#2b2113&quot; stroke=&quot;#e8a33e&quot; stroke-width=&quot;2&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;115&quot; y=&quot;80&quot; text-anchor=&quot;middle&quot; fill=&quot;#e8a33e&quot; font-size=&quot;12&quot; font-family=&quot;sans-serif&quot; font-weight=&quot;bold&quot;&gt;HA OS (VM)&lt;/text&gt;&lt;text x=&quot;115&quot; y=&quot;110&quot; text-anchor=&quot;middle&quot; fill=&quot;#cfdfae&quot; font-size=&quot;10&quot; font-family=&quot;sans-serif&quot;&gt;Supervisor ✓&lt;/text&gt;&lt;text x=&quot;115&quot; y=&quot;130&quot; text-anchor=&quot;middle&quot; fill=&quot;#cfdfae&quot; font-size=&quot;10&quot; font-family=&quot;sans-serif&quot;&gt;Add-on store ✓&lt;/text&gt;&lt;text x=&quot;115&quot; y=&quot;150&quot; text-anchor=&quot;middle&quot; fill=&quot;#cfdfae&quot; font-size=&quot;10&quot; font-family=&quot;sans-serif&quot;&gt;Full backups ✓&lt;/text&gt;&lt;text x=&quot;115&quot; y=&quot;185&quot; text-anchor=&quot;middle&quot; fill=&quot;#e8a33e&quot; font-size=&quot;11&quot; font-family=&quot;sans-serif&quot; font-weight=&quot;bold&quot;&gt;Use this one&lt;/text&gt;&lt;rect x=&quot;210&quot; y=&quot;55&quot; width=&quot;150&quot; height=&quot;160&quot; rx=&quot;8&quot; fill=&quot;#16110d&quot; stroke=&quot;#d9a13d&quot; stroke-width=&quot;1&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;285&quot; y=&quot;80&quot; text-anchor=&quot;middle&quot; fill=&quot;#d9a13d&quot; font-size=&quot;12&quot; font-family=&quot;sans-serif&quot; font-weight=&quot;bold&quot;&gt;Supervised&lt;/text&gt;&lt;text x=&quot;285&quot; y=&quot;110&quot; text-anchor=&quot;middle&quot; fill=&quot;#aba192&quot; font-size=&quot;10&quot; font-family=&quot;sans-serif&quot;&gt;Add-on store ✓&lt;/text&gt;&lt;text x=&quot;285&quot; y=&quot;130&quot; text-anchor=&quot;middle&quot; fill=&quot;#aba192&quot; font-size=&quot;10&quot; font-family=&quot;sans-serif&quot;&gt;Strict OS rules&lt;/text&gt;&lt;text x=&quot;285&quot; y=&quot;150&quot; text-anchor=&quot;middle&quot; fill=&quot;#aba192&quot; font-size=&quot;10&quot; font-family=&quot;sans-serif&quot;&gt;High maintenance&lt;/text&gt;&lt;text x=&quot;285&quot; y=&quot;185&quot; text-anchor=&quot;middle&quot; fill=&quot;#8a8071&quot; font-size=&quot;10&quot; font-family=&quot;sans-serif&quot;&gt;Advanced only&lt;/text&gt;&lt;rect x=&quot;380&quot; y=&quot;55&quot; width=&quot;150&quot; height=&quot;160&quot; rx=&quot;8&quot; fill=&quot;#16110d&quot; stroke=&quot;#8a8071&quot; stroke-width=&quot;1&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;455&quot; y=&quot;80&quot; text-anchor=&quot;middle&quot; fill=&quot;#aba192&quot; font-size=&quot;12&quot; font-family=&quot;sans-serif&quot; font-weight=&quot;bold&quot;&gt;Container&lt;/text&gt;&lt;text x=&quot;455&quot; y=&quot;110&quot; text-anchor=&quot;middle&quot; fill=&quot;#d4604f&quot; font-size=&quot;10&quot; font-family=&quot;sans-serif&quot;&gt;No Add-on store ✗&lt;/text&gt;&lt;text x=&quot;455&quot; y=&quot;130&quot; text-anchor=&quot;middle&quot; fill=&quot;#d4604f&quot; font-size=&quot;10&quot; font-family=&quot;sans-serif&quot;&gt;No Supervisor ✗&lt;/text&gt;&lt;text x=&quot;455&quot; y=&quot;150&quot; text-anchor=&quot;middle&quot; fill=&quot;#aba192&quot; font-size=&quot;10&quot; font-family=&quot;sans-serif&quot;&gt;Docker only&lt;/text&gt;&lt;text x=&quot;455&quot; y=&quot;185&quot; text-anchor=&quot;middle&quot; fill=&quot;#8a8071&quot; font-size=&quot;10&quot; font-family=&quot;sans-serif&quot;&gt;DIY setups&lt;/text&gt;&lt;rect x=&quot;550&quot; y=&quot;55&quot; width=&quot;150&quot; height=&quot;160&quot; rx=&quot;8&quot; fill=&quot;#16110d&quot; stroke=&quot;#8a8071&quot; stroke-width=&quot;1&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;625&quot; y=&quot;80&quot; text-anchor=&quot;middle&quot; fill=&quot;#aba192&quot; font-size=&quot;12&quot; font-family=&quot;sans-serif&quot; font-weight=&quot;bold&quot;&gt;Core&lt;/text&gt;&lt;text x=&quot;625&quot; y=&quot;110&quot; text-anchor=&quot;middle&quot; fill=&quot;#d4604f&quot; font-size=&quot;10&quot; font-family=&quot;sans-serif&quot;&gt;No Add-on store ✗&lt;/text&gt;&lt;text x=&quot;625&quot; y=&quot;130&quot; text-anchor=&quot;middle&quot; fill=&quot;#aba192&quot; font-size=&quot;10&quot; font-family=&quot;sans-serif&quot;&gt;Manual Python&lt;/text&gt;&lt;text x=&quot;625&quot; y=&quot;150&quot; text-anchor=&quot;middle&quot; fill=&quot;#aba192&quot; font-size=&quot;10&quot; font-family=&quot;sans-serif&quot;&gt;Most fragile&lt;/text&gt;&lt;text x=&quot;625&quot; y=&quot;185&quot; text-anchor=&quot;middle&quot; fill=&quot;#8a8071&quot; font-size=&quot;10&quot; font-family=&quot;sans-serif&quot;&gt;Developers&lt;/text&gt;&lt;text x=&quot;710&quot; y=&quot;252&quot; text-anchor=&quot;end&quot; font-size=&quot;10&quot; fill=&quot;#8a8071&quot; opacity=&quot;0.6&quot; font-family=&quot;monospace&quot;&gt;peira.dev&lt;/text&gt;&lt;/svg&gt;
&lt;p&gt;&lt;strong&gt;Home Assistant OS&lt;/strong&gt; is a complete, purpose-built operating system. When you run it as a VM, you get the &lt;em&gt;full&lt;/em&gt; Home Assistant: the Supervisor that manages the system, the one-click Add-on store (Zigbee2MQTT, Mosquitto, ESPHome, Frigate, and more), and painless whole-system backups.&lt;/p&gt;
&lt;div class=&quot;callout callout-warning&quot; data-astro-cid-q2ml7llr&gt;&lt;div class=&quot;callout-header&quot; data-astro-cid-q2ml7llr&gt;&lt;span class=&quot;callout-icon&quot; aria-hidden=&quot;true&quot; data-astro-cid-q2ml7llr&gt;⚠️&lt;/span&gt;&lt;span class=&quot;callout-label&quot; data-astro-cid-q2ml7llr&gt;Don&amp;#39;t run Home Assistant in an LXC&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;callout-body&quot; data-astro-cid-q2ml7llr&gt;&lt;p&gt;You’ll find guides that shoehorn Home Assistant into a Proxmox LXC container. Resist. Add-ons are themselves Docker containers, and nesting Docker inside an unprivileged LXC is fragile and unsupported by the Home Assistant project. A VM is the officially blessed path, and on modern hardware the overhead is negligible. Read &lt;a href=&quot;https://peira.dev/blog/proxmox-vm-vs-lxc/&quot;&gt;VM vs LXC&lt;/a&gt; if you want the full reasoning.&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;install-it-with-the-community-helper-script&quot;&gt;Install it with the community helper script&lt;/h2&gt;
&lt;p&gt;The official Home Assistant team publishes a ready-made KVM disk image. The &lt;a href=&quot;https://community-scripts.github.io/ProxmoxVE/&quot;&gt;community-scripts helper project&lt;/a&gt; (the actively maintained continuation of the beloved tteck scripts) wraps the whole download-import-configure dance into a single command.&lt;/p&gt;
&lt;div class=&quot;step&quot; data-astro-cid-wwaqy5cd&gt;&lt;div class=&quot;step-header&quot; data-astro-cid-wwaqy5cd&gt;&lt;span class=&quot;step-num&quot; data-astro-cid-wwaqy5cd&gt;1&lt;/span&gt;&lt;span class=&quot;step-title&quot; data-astro-cid-wwaqy5cd&gt;Open a shell on the Proxmox host&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;step-body&quot; data-astro-cid-wwaqy5cd&gt;&lt;p&gt;In the Proxmox web UI, click your node, then &lt;strong&gt;Shell&lt;/strong&gt;. Run the helper command &lt;em&gt;here on the host&lt;/em&gt; — not inside an existing container. It creates a brand-new VM for you.&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;
&lt;figure class=&quot;terminal&quot; data-astro-cid-kenkipkp&gt;&lt;figcaption class=&quot;terminal-bar&quot; data-astro-cid-kenkipkp&gt;&lt;span class=&quot;dot red&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot yellow&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot green&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;terminal-title&quot; data-astro-cid-kenkipkp&gt;Run on the Proxmox node shell&lt;/span&gt;&lt;button class=&quot;terminal-copy&quot; type=&quot;button&quot; aria-label=&quot;Copy commands&quot; data-astro-cid-kenkipkp&gt;&lt;svg width=&quot;13&quot; height=&quot;13&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;none&quot; stroke=&quot;currentColor&quot; stroke-width=&quot;2&quot; stroke-linecap=&quot;round&quot; stroke-linejoin=&quot;round&quot; aria-hidden=&quot;true&quot; data-astro-cid-kenkipkp&gt;&lt;rect x=&quot;9&quot; y=&quot;9&quot; width=&quot;13&quot; height=&quot;13&quot; rx=&quot;2&quot; data-astro-cid-kenkipkp&gt;&lt;/rect&gt;&lt;path d=&quot;M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1&quot; data-astro-cid-kenkipkp&gt;&lt;/path&gt;&lt;/svg&gt;&lt;span class=&quot;terminal-copy-label&quot; data-astro-cid-kenkipkp&gt;Copy&lt;/span&gt;&lt;/button&gt;&lt;/figcaption&gt;&lt;pre class=&quot;terminal-body&quot; data-astro-cid-kenkipkp&gt;&lt;code data-astro-cid-kenkipkp&gt;
bash -c &amp;quot;$(curl -fsSL https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/vm/haos-vm.sh)&amp;quot;
&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;
&lt;div class=&quot;callout callout-tip&quot; data-astro-cid-q2ml7llr&gt;&lt;div class=&quot;callout-header&quot; data-astro-cid-q2ml7llr&gt;&lt;span class=&quot;callout-icon&quot; aria-hidden=&quot;true&quot; data-astro-cid-q2ml7llr&gt;💡&lt;/span&gt;&lt;span class=&quot;callout-label&quot; data-astro-cid-q2ml7llr&gt;Always read a script before you pipe it to bash&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;callout-body&quot; data-astro-cid-q2ml7llr&gt;&lt;p&gt;Running a script straight from the internet deserves a second of caution. The community-scripts project is open-source and widely used — you can read exactly what haos-vm.sh does on its GitHub page before running it. This is good hygiene for &lt;em&gt;any&lt;/em&gt; curl-to-bash command, not just this one.&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;
&lt;div class=&quot;step&quot; data-astro-cid-wwaqy5cd&gt;&lt;div class=&quot;step-header&quot; data-astro-cid-wwaqy5cd&gt;&lt;span class=&quot;step-num&quot; data-astro-cid-wwaqy5cd&gt;2&lt;/span&gt;&lt;span class=&quot;step-title&quot; data-astro-cid-wwaqy5cd&gt;Accept or tweak the defaults&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;step-body&quot; data-astro-cid-wwaqy5cd&gt;&lt;p&gt;The script asks a few questions — storage location, RAM, disk size — then downloads the official Home Assistant OS image, imports it as a disk, attaches it, sets the boot order, and starts the VM. The defaults (2 vCPU, ~4GB RAM) are fine for a first setup. Total time: a couple of minutes.&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;
&lt;div class=&quot;step&quot; data-astro-cid-wwaqy5cd&gt;&lt;div class=&quot;step-header&quot; data-astro-cid-wwaqy5cd&gt;&lt;span class=&quot;step-num&quot; data-astro-cid-wwaqy5cd&gt;3&lt;/span&gt;&lt;span class=&quot;step-title&quot; data-astro-cid-wwaqy5cd&gt;Find the VM&amp;#39;s address and open onboarding&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;step-body&quot; data-astro-cid-wwaqy5cd&gt;&lt;p&gt;When it finishes, the VM boots Home Assistant. Give it a minute, then browse to &lt;code&gt;&lt;a href=&quot;http://homeassistant.local:8123&quot;&gt;http://homeassistant.local:8123&lt;/a&gt;&lt;/code&gt; (or &lt;code&gt;&lt;a href=&quot;http://VM-IP:8123&quot;&gt;http://VM-IP:8123&lt;/a&gt;&lt;/code&gt; if mDNS doesn’t resolve on your network). You’ll be greeted by the onboarding wizard.&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;
&lt;svg viewBox=&quot;0 0 720 300&quot; xmlns=&quot;http://www.w3.org/2000/svg&quot; style=&quot;width:100%;border-radius:12px;background:#0e0b08;margin:2rem 0&quot;&gt;&lt;defs&gt;&lt;pattern id=&quot;gp-homeassistan1&quot; width=&quot;20&quot; height=&quot;20&quot; patternUnits=&quot;userSpaceOnUse&quot;&gt;&lt;path d=&quot;M 20 0 H 0 V 20&quot; fill=&quot;none&quot; stroke=&quot;#221c15&quot; stroke-width=&quot;1&quot;&gt;&lt;/path&gt;&lt;/pattern&gt;&lt;/defs&gt;&lt;rect x=&quot;0&quot; y=&quot;0&quot; width=&quot;720&quot; height=&quot;300&quot; fill=&quot;url(#gp-homeassistan1)&quot;&gt;&lt;/rect&gt;&lt;rect x=&quot;6&quot; y=&quot;6&quot; width=&quot;708&quot; height=&quot;288&quot; fill=&quot;none&quot; stroke=&quot;#2e2820&quot; stroke-width=&quot;1&quot;&gt;&lt;/rect&gt;&lt;path d=&quot;M 11,16 H 21 M 16,11 V 21 M 699,16 H 709 M 704,11 V 21 M 11,284 H 21 M 16,279 V 289 M 699,284 H 709 M 704,279 V 289&quot; stroke=&quot;#4a3f2e&quot; stroke-width=&quot;1&quot; fill=&quot;none&quot;&gt;&lt;/path&gt;&lt;rect x=&quot;0&quot; y=&quot;0&quot; width=&quot;720&quot; height=&quot;46&quot; fill=&quot;#141009&quot;&gt;&lt;/rect&gt;&lt;circle cx=&quot;24&quot; cy=&quot;23&quot; r=&quot;6&quot; fill=&quot;#d4604f&quot;&gt;&lt;/circle&gt;&lt;circle cx=&quot;44&quot; cy=&quot;23&quot; r=&quot;6&quot; fill=&quot;#d9a13d&quot;&gt;&lt;/circle&gt;&lt;circle cx=&quot;64&quot; cy=&quot;23&quot; r=&quot;6&quot; fill=&quot;#8fb573&quot;&gt;&lt;/circle&gt;&lt;text x=&quot;360&quot; y=&quot;28&quot; text-anchor=&quot;middle&quot; fill=&quot;#8a8071&quot; font-size=&quot;11&quot; font-family=&quot;monospace&quot;&gt;&lt;a href=&quot;http://homeassistant.local:8123&quot;&gt;http://homeassistant.local:8123&lt;/a&gt;&lt;/text&gt;&lt;circle cx=&quot;360&quot; cy=&quot;105&quot; r=&quot;26&quot; fill=&quot;none&quot; stroke=&quot;#18bcf2&quot; stroke-width=&quot;4&quot;&gt;&lt;/circle&gt;&lt;path d=&quot;M360 88 L344 104 L344 122 L376 122 L376 104 Z&quot; fill=&quot;#18bcf2&quot;&gt;&lt;/path&gt;&lt;text x=&quot;360&quot; y=&quot;160&quot; text-anchor=&quot;middle&quot; fill=&quot;#f2ede3&quot; font-size=&quot;18&quot; font-family=&quot;sans-serif&quot; font-weight=&quot;bold&quot;&gt;Welcome to Home Assistant&lt;/text&gt;&lt;text x=&quot;360&quot; y=&quot;184&quot; text-anchor=&quot;middle&quot; fill=&quot;#aba192&quot; font-size=&quot;12&quot; font-family=&quot;sans-serif&quot;&gt;Create your account to get started&lt;/text&gt;&lt;rect x=&quot;240&quot; y=&quot;205&quot; width=&quot;240&quot; height=&quot;30&quot; rx=&quot;6&quot; fill=&quot;#1f2937&quot; stroke=&quot;#5c4f3a&quot; stroke-width=&quot;1&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;256&quot; y=&quot;224&quot; fill=&quot;#8a8071&quot; font-size=&quot;11&quot; font-family=&quot;sans-serif&quot;&gt;Name&lt;/text&gt;&lt;rect x=&quot;240&quot; y=&quot;243&quot; width=&quot;240&quot; height=&quot;30&quot; rx=&quot;6&quot; fill=&quot;#18bcf2&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;360&quot; y=&quot;263&quot; text-anchor=&quot;middle&quot; fill=&quot;#062a3a&quot; font-size=&quot;12&quot; font-family=&quot;sans-serif&quot; font-weight=&quot;bold&quot;&gt;Create Account&lt;/text&gt;&lt;/svg&gt;
&lt;div class=&quot;step&quot; data-astro-cid-wwaqy5cd&gt;&lt;div class=&quot;step-header&quot; data-astro-cid-wwaqy5cd&gt;&lt;span class=&quot;step-num&quot; data-astro-cid-wwaqy5cd&gt;4&lt;/span&gt;&lt;span class=&quot;step-title&quot; data-astro-cid-wwaqy5cd&gt;Create your account and let it discover your devices&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;step-body&quot; data-astro-cid-wwaqy5cd&gt;&lt;p&gt;Set up your owner account, name your home, and set your location. Home Assistant immediately scans your network and often finds smart devices (Chromecasts, printers, smart plugs, Philips Hue bridges) automatically. Add them, or skip and add them later — the dashboard is yours now.&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;add-a-zigbee-or-z-wave-stick-the-payoff-move&quot;&gt;Add a Zigbee or Z-Wave stick (the payoff move)&lt;/h2&gt;
&lt;p&gt;The moment Home Assistant becomes magic is when you add a Zigbee coordinator (like a SkyConnect / Home Assistant Connect ZBT-1 or a Sonoff dongle) and start pairing cheap, cloud-free sensors and bulbs. Because you’re running a VM, USB passthrough is clean:&lt;/p&gt;
&lt;div class=&quot;step&quot; data-astro-cid-wwaqy5cd&gt;&lt;div class=&quot;step-header&quot; data-astro-cid-wwaqy5cd&gt;&lt;span class=&quot;step-num&quot; data-astro-cid-wwaqy5cd&gt;1&lt;/span&gt;&lt;span class=&quot;step-title&quot; data-astro-cid-wwaqy5cd&gt;Plug the stick into the Proxmox host&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;step-body&quot; data-astro-cid-wwaqy5cd&gt;&lt;p&gt;Physically insert the USB coordinator into the machine running Proxmox.&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;
&lt;div class=&quot;step&quot; data-astro-cid-wwaqy5cd&gt;&lt;div class=&quot;step-header&quot; data-astro-cid-wwaqy5cd&gt;&lt;span class=&quot;step-num&quot; data-astro-cid-wwaqy5cd&gt;2&lt;/span&gt;&lt;span class=&quot;step-title&quot; data-astro-cid-wwaqy5cd&gt;Pass it through to the VM&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;step-body&quot; data-astro-cid-wwaqy5cd&gt;&lt;p&gt;In the Proxmox UI: select the Home Assistant VM → &lt;strong&gt;Hardware&lt;/strong&gt; → &lt;strong&gt;Add&lt;/strong&gt; → &lt;strong&gt;USB Device&lt;/strong&gt; → pick your coordinator &lt;strong&gt;by its Device ID&lt;/strong&gt; (not the port), so it survives reboots and reordering. Then reboot the VM.&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;
&lt;div class=&quot;step&quot; data-astro-cid-wwaqy5cd&gt;&lt;div class=&quot;step-header&quot; data-astro-cid-wwaqy5cd&gt;&lt;span class=&quot;step-num&quot; data-astro-cid-wwaqy5cd&gt;3&lt;/span&gt;&lt;span class=&quot;step-title&quot; data-astro-cid-wwaqy5cd&gt;Install the ZHA or Zigbee2MQTT integration&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;step-body&quot; data-astro-cid-wwaqy5cd&gt;&lt;p&gt;Inside Home Assistant, the passed-through stick appears as a serial device. Add the built-in &lt;strong&gt;ZHA&lt;/strong&gt; integration (simplest) or the &lt;strong&gt;Zigbee2MQTT&lt;/strong&gt; Add-on (more powerful) and start pairing devices.&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;
&lt;div class=&quot;callout callout-tip&quot; data-astro-cid-q2ml7llr&gt;&lt;div class=&quot;callout-header&quot; data-astro-cid-q2ml7llr&gt;&lt;span class=&quot;callout-icon&quot; aria-hidden=&quot;true&quot; data-astro-cid-q2ml7llr&gt;💡&lt;/span&gt;&lt;span class=&quot;callout-label&quot; data-astro-cid-q2ml7llr&gt;Back it up the easy way&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;callout-body&quot; data-astro-cid-q2ml7llr&gt;&lt;p&gt;Because this is HAOS, Settings → System → Backups gives you a one-click full backup of your entire configuration and Add-ons. Even better, since it’s a Proxmox VM, you also get whole-VM snapshots and scheduled backups from &lt;a href=&quot;https://peira.dev/blog/proxmox-backup-server/&quot;&gt;Proxmox Backup Server&lt;/a&gt; — belt and suspenders for the hub that runs your house.&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;what-to-build-next&quot;&gt;What to build next&lt;/h2&gt;
&lt;p&gt;You’ve now got the single most-loved service in the homelab world running properly. From here, most people:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Put it behind clean HTTPS and reach it from anywhere with &lt;a href=&quot;https://peira.dev/blog/tailscale-subnet-router/&quot;&gt;Tailscale&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;Add it as a tile on their &lt;a href=&quot;https://peira.dev/blog/homepage-dashboard/&quot;&gt;Homepage dashboard&lt;/a&gt; alongside every other service.&lt;/li&gt;
&lt;li&gt;Wire in local voice and AI with &lt;a href=&quot;https://peira.dev/blog/ollama-lxc-cluster/&quot;&gt;Ollama&lt;/a&gt; so the assistant answers without the cloud.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Home Assistant is a rabbit hole all of its own — and now it’s running on a foundation that won’t fight you. Back to the &lt;a href=&quot;https://peira.dev/start/&quot;&gt;full roadmap&lt;/a&gt; whenever you’re ready for the next piece.&lt;/p&gt;
&lt;hr/&gt;
&lt;p&gt;&lt;strong&gt;Related posts:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/proxmox-create-vm/&quot;&gt;Creating Your First Proxmox VM&lt;/a&gt; — the VM workflow HAOS runs in&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/proxmox-vm-vs-lxc/&quot;&gt;Proxmox VM vs LXC: When to Use Each&lt;/a&gt; — why Home Assistant OS wants a full VM&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/proxmox-lxc-networking/&quot;&gt;Proxmox LXC Networking: Bridges, VLANs, and Static IPs&lt;/a&gt; — give Home Assistant a stable address&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/pihole-proxmox-lxc/&quot;&gt;Pi-hole on Proxmox LXC&lt;/a&gt; — local DNS for your smart-home devices&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/ollama-lxc-cluster/&quot;&gt;CPU-Only Ollama Cluster with LXC&lt;/a&gt; — local AI/voice for the assistant&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/tailscale-subnet-router/&quot;&gt;Tailscale Subnet Router&lt;/a&gt; — reach your dashboard away from home&lt;/li&gt;
&lt;/ul&gt;</content:encoded><category>getting-started</category><category>proxmox</category><category>self-hosted</category><category>automation</category></item><item><title>Shared Storage for Your Homelab: Samba and NFS File Shares Done Right</title><link>https://peira.dev/blog/homelab-nas-samba-shares/</link><guid isPermaLink="true">https://peira.dev/blog/homelab-nas-samba-shares/</guid><description>How to set up network file shares in a Proxmox homelab using Samba (for Windows/macOS) and NFS (for Linux). A beginner guide to a central file server your media, photos, and backups can all read from.</description><pubDate>Wed, 15 Jul 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Every homelab hits the same wall around service number three: you’ve got a media server that needs a movie library, a photo backup that needs somewhere to put photos, and a laptop full of files you’d like to reach from the couch. Right now each of those lives on its own little island of storage, and copying between them is a chore.&lt;/p&gt;
&lt;p&gt;The fix is a &lt;strong&gt;central file server&lt;/strong&gt; — one place where files live, that everything else reads from. This guide builds one as a lightweight container in Proxmox, sharing files two ways: &lt;strong&gt;&lt;a href=&quot;https://www.samba.org/&quot;&gt;Samba&lt;/a&gt;&lt;/strong&gt; for your Windows, Mac, phone, and TV, and &lt;strong&gt;NFS&lt;/strong&gt; for other Linux containers and VMs on your network.&lt;/p&gt;
&lt;svg viewBox=&quot;0 0 720 320&quot; xmlns=&quot;http://www.w3.org/2000/svg&quot; style=&quot;width:100%;border-radius:12px;background:#1c1712;margin:2rem 0&quot;&gt;&lt;defs&gt;&lt;pattern id=&quot;gp-homelabnassa0&quot; width=&quot;20&quot; height=&quot;20&quot; patternUnits=&quot;userSpaceOnUse&quot;&gt;&lt;path d=&quot;M 20 0 H 0 V 20&quot; fill=&quot;none&quot; stroke=&quot;#221c15&quot; stroke-width=&quot;1&quot;&gt;&lt;/path&gt;&lt;/pattern&gt;&lt;/defs&gt;&lt;rect x=&quot;0&quot; y=&quot;0&quot; width=&quot;720&quot; height=&quot;320&quot; fill=&quot;url(#gp-homelabnassa0)&quot;&gt;&lt;/rect&gt;&lt;rect x=&quot;6&quot; y=&quot;6&quot; width=&quot;708&quot; height=&quot;308&quot; fill=&quot;none&quot; stroke=&quot;#2e2820&quot; stroke-width=&quot;1&quot;&gt;&lt;/rect&gt;&lt;path d=&quot;M 11,16 H 21 M 16,11 V 21 M 699,16 H 709 M 704,11 V 21 M 11,304 H 21 M 16,299 V 309 M 699,304 H 709 M 704,299 V 309&quot; stroke=&quot;#4a3f2e&quot; stroke-width=&quot;1&quot; fill=&quot;none&quot;&gt;&lt;/path&gt;&lt;text x=&quot;360&quot; y=&quot;28&quot; text-anchor=&quot;middle&quot; fill=&quot;#f2ede3&quot; font-size=&quot;13&quot; font-family=&quot;sans-serif&quot; font-weight=&quot;bold&quot;&gt;One file server, everything else reads from it&lt;/text&gt;&lt;rect x=&quot;270&quot; y=&quot;120&quot; width=&quot;180&quot; height=&quot;90&quot; rx=&quot;10&quot; fill=&quot;#2b2113&quot; stroke=&quot;#e8a33e&quot; stroke-width=&quot;1.5&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;360&quot; y=&quot;150&quot; text-anchor=&quot;middle&quot; fill=&quot;#e8a33e&quot; font-size=&quot;13&quot; font-family=&quot;sans-serif&quot; font-weight=&quot;bold&quot;&gt;File Server (LXC)&lt;/text&gt;&lt;text x=&quot;360&quot; y=&quot;172&quot; text-anchor=&quot;middle&quot; fill=&quot;#aba192&quot; font-size=&quot;10&quot; font-family=&quot;monospace&quot;&gt;Samba + NFS&lt;/text&gt;&lt;text x=&quot;360&quot; y=&quot;192&quot; text-anchor=&quot;middle&quot; fill=&quot;#aba192&quot; font-size=&quot;10&quot; font-family=&quot;monospace&quot;&gt;/tank/media  /tank/photos&lt;/text&gt;&lt;rect x=&quot;40&quot; y=&quot;55&quot; width=&quot;150&quot; height=&quot;55&quot; rx=&quot;8&quot; fill=&quot;#16110d&quot; stroke=&quot;#6f9bd1&quot; stroke-width=&quot;1.2&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;115&quot; y=&quot;78&quot; text-anchor=&quot;middle&quot; fill=&quot;#6f9bd1&quot; font-size=&quot;11&quot; font-family=&quot;sans-serif&quot; font-weight=&quot;bold&quot;&gt;Windows / Mac&lt;/text&gt;&lt;text x=&quot;115&quot; y=&quot;96&quot; text-anchor=&quot;middle&quot; fill=&quot;#8a8071&quot; font-size=&quot;9&quot; font-family=&quot;monospace&quot;&gt;Samba (SMB)&lt;/text&gt;&lt;rect x=&quot;530&quot; y=&quot;55&quot; width=&quot;150&quot; height=&quot;55&quot; rx=&quot;8&quot; fill=&quot;#16110d&quot; stroke=&quot;#c96a52&quot; stroke-width=&quot;1.2&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;605&quot; y=&quot;78&quot; text-anchor=&quot;middle&quot; fill=&quot;#c96a52&quot; font-size=&quot;11&quot; font-family=&quot;sans-serif&quot; font-weight=&quot;bold&quot;&gt;TV / Phone&lt;/text&gt;&lt;text x=&quot;605&quot; y=&quot;96&quot; text-anchor=&quot;middle&quot; fill=&quot;#8a8071&quot; font-size=&quot;9&quot; font-family=&quot;monospace&quot;&gt;Samba (SMB)&lt;/text&gt;&lt;rect x=&quot;40&quot; y=&quot;230&quot; width=&quot;150&quot; height=&quot;55&quot; rx=&quot;8&quot; fill=&quot;#16110d&quot; stroke=&quot;#8fb573&quot; stroke-width=&quot;1.2&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;115&quot; y=&quot;253&quot; text-anchor=&quot;middle&quot; fill=&quot;#8fb573&quot; font-size=&quot;11&quot; font-family=&quot;sans-serif&quot; font-weight=&quot;bold&quot;&gt;Plex / Jellyfin&lt;/text&gt;&lt;text x=&quot;115&quot; y=&quot;271&quot; text-anchor=&quot;middle&quot; fill=&quot;#8a8071&quot; font-size=&quot;9&quot; font-family=&quot;monospace&quot;&gt;NFS&lt;/text&gt;&lt;rect x=&quot;530&quot; y=&quot;230&quot; width=&quot;150&quot; height=&quot;55&quot; rx=&quot;8&quot; fill=&quot;#16110d&quot; stroke=&quot;#92a7bd&quot; stroke-width=&quot;1.2&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;605&quot; y=&quot;253&quot; text-anchor=&quot;middle&quot; fill=&quot;#92a7bd&quot; font-size=&quot;11&quot; font-family=&quot;sans-serif&quot; font-weight=&quot;bold&quot;&gt;*arr stack&lt;/text&gt;&lt;text x=&quot;605&quot; y=&quot;271&quot; text-anchor=&quot;middle&quot; fill=&quot;#8a8071&quot; font-size=&quot;9&quot; font-family=&quot;monospace&quot;&gt;NFS&lt;/text&gt;&lt;line x1=&quot;190&quot; y1=&quot;90&quot; x2=&quot;285&quot; y2=&quot;140&quot; stroke=&quot;#6f9bd1&quot; stroke-width=&quot;1.5&quot;&gt;&lt;/line&gt;&lt;line x1=&quot;530&quot; y1=&quot;90&quot; x2=&quot;435&quot; y2=&quot;140&quot; stroke=&quot;#c96a52&quot; stroke-width=&quot;1.5&quot;&gt;&lt;/line&gt;&lt;line x1=&quot;190&quot; y1=&quot;255&quot; x2=&quot;285&quot; y2=&quot;190&quot; stroke=&quot;#8fb573&quot; stroke-width=&quot;1.5&quot;&gt;&lt;/line&gt;&lt;line x1=&quot;530&quot; y1=&quot;255&quot; x2=&quot;435&quot; y2=&quot;190&quot; stroke=&quot;#92a7bd&quot; stroke-width=&quot;1.5&quot;&gt;&lt;/line&gt;&lt;text x=&quot;710&quot; y=&quot;312&quot; text-anchor=&quot;end&quot; font-size=&quot;10&quot; fill=&quot;#8a8071&quot; opacity=&quot;0.6&quot; font-family=&quot;monospace&quot;&gt;peira.dev&lt;/text&gt;&lt;/svg&gt;
&lt;div class=&quot;callout callout-info&quot; data-astro-cid-q2ml7llr&gt;&lt;div class=&quot;callout-header&quot; data-astro-cid-q2ml7llr&gt;&lt;span class=&quot;callout-icon&quot; aria-hidden=&quot;true&quot; data-astro-cid-q2ml7llr&gt;ℹ️&lt;/span&gt;&lt;span class=&quot;callout-label&quot; data-astro-cid-q2ml7llr&gt;Where the data lives matters more than the container&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;callout-body&quot; data-astro-cid-q2ml7llr&gt;&lt;p&gt;Keep your actual files on a storage pool that is &lt;em&gt;separate&lt;/em&gt; from the container’s own root disk — a dedicated drive or a ZFS pool bind-mounted into the container. That way the files outlive the container: you can rebuild, upgrade, or replace the file server without ever touching the data. See the &lt;a href=&quot;https://peira.dev/blog/proxmox-storage-guide/&quot;&gt;Proxmox storage guide&lt;/a&gt; for setting up that pool first.&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;step-1--create-the-file-server-container&quot;&gt;Step 1 — Create the file-server container&lt;/h2&gt;
&lt;div class=&quot;callout callout-warning&quot; data-astro-cid-q2ml7llr&gt;&lt;div class=&quot;callout-header&quot; data-astro-cid-q2ml7llr&gt;&lt;span class=&quot;callout-icon&quot; aria-hidden=&quot;true&quot; data-astro-cid-q2ml7llr&gt;⚠️&lt;/span&gt;&lt;span class=&quot;callout-label&quot; data-astro-cid-q2ml7llr&gt;First: make these values your own&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;callout-body&quot; data-astro-cid-q2ml7llr&gt;&lt;p&gt;Swap the example values in this guide for your own before copying anything: &lt;code&gt;youradmin&lt;/code&gt; (the username you actually want on the share), &lt;code&gt;SERVER-IP&lt;/code&gt; (your file server’s LAN address), &lt;code&gt;192.168.1.0/24&lt;/code&gt; (your LAN subnet), &lt;code&gt;/tank/...&lt;/code&gt; (wherever your storage pool is mounted), and UID/GID &lt;code&gt;1000&lt;/code&gt; (whatever ID owns your data — check with &lt;code&gt;id&lt;/code&gt;). If a value looks specific to one machine, it’s a placeholder to change — not a literal to copy.&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;
&lt;p&gt;Create a small Debian or Ubuntu LXC in Proxmox (2 CPU, 1GB RAM is plenty — a file server barely works up a sweat). If you want to serve files from a Proxmox ZFS dataset or a second drive, add it as a &lt;strong&gt;bind mount&lt;/strong&gt; to the container so the container sees your storage pool as a normal folder like &lt;code&gt;/tank&lt;/code&gt;.&lt;/p&gt;
&lt;div class=&quot;callout callout-tip&quot; data-astro-cid-q2ml7llr&gt;&lt;div class=&quot;callout-header&quot; data-astro-cid-q2ml7llr&gt;&lt;span class=&quot;callout-icon&quot; aria-hidden=&quot;true&quot; data-astro-cid-q2ml7llr&gt;💡&lt;/span&gt;&lt;span class=&quot;callout-label&quot; data-astro-cid-q2ml7llr&gt;Unprivileged containers and file ownership&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;callout-body&quot; data-astro-cid-q2ml7llr&gt;&lt;p&gt;If you use an unprivileged LXC (recommended), the container’s user IDs are shifted relative to the host. The simplest approach for a beginner file server is to pick one shared UID/GID — for example 1000 — and make sure your bind-mounted data is owned by that ID on the host. Every service that touches the share then runs as the same 1000, and permissions “just work.” Keeping one consistent UID across the media apps is exactly what the &lt;a href=&quot;https://peira.dev/blog/arr-stack-docker-deploy/&quot;&gt;Arr stack guide&lt;/a&gt; relies on.&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;step-2--samba-for-windows-mac-phones-and-tvs&quot;&gt;Step 2 — Samba, for Windows, Mac, phones, and TVs&lt;/h2&gt;
&lt;p&gt;Samba speaks SMB — the protocol every desktop OS already knows. Install it inside the container:&lt;/p&gt;
&lt;figure class=&quot;terminal&quot; data-astro-cid-kenkipkp&gt;&lt;figcaption class=&quot;terminal-bar&quot; data-astro-cid-kenkipkp&gt;&lt;span class=&quot;dot red&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot yellow&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot green&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;terminal-title&quot; data-astro-cid-kenkipkp&gt;Install Samba&lt;/span&gt;&lt;button class=&quot;terminal-copy&quot; type=&quot;button&quot; aria-label=&quot;Copy commands&quot; data-astro-cid-kenkipkp&gt;&lt;svg width=&quot;13&quot; height=&quot;13&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;none&quot; stroke=&quot;currentColor&quot; stroke-width=&quot;2&quot; stroke-linecap=&quot;round&quot; stroke-linejoin=&quot;round&quot; aria-hidden=&quot;true&quot; data-astro-cid-kenkipkp&gt;&lt;rect x=&quot;9&quot; y=&quot;9&quot; width=&quot;13&quot; height=&quot;13&quot; rx=&quot;2&quot; data-astro-cid-kenkipkp&gt;&lt;/rect&gt;&lt;path d=&quot;M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1&quot; data-astro-cid-kenkipkp&gt;&lt;/path&gt;&lt;/svg&gt;&lt;span class=&quot;terminal-copy-label&quot; data-astro-cid-kenkipkp&gt;Copy&lt;/span&gt;&lt;/button&gt;&lt;/figcaption&gt;&lt;pre class=&quot;terminal-body&quot; data-astro-cid-kenkipkp&gt;&lt;code data-astro-cid-kenkipkp&gt;
sudo apt update
sudo apt install -y samba
&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;
&lt;p&gt;Create a folder to share and a user to access it. Samba keeps its own password separate from the Linux login password, so you set it explicitly with smbpasswd:&lt;/p&gt;
&lt;figure class=&quot;terminal&quot; data-astro-cid-kenkipkp&gt;&lt;figcaption class=&quot;terminal-bar&quot; data-astro-cid-kenkipkp&gt;&lt;span class=&quot;dot red&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot yellow&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot green&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;terminal-title&quot; data-astro-cid-kenkipkp&gt;Create a share folder and a Samba user&lt;/span&gt;&lt;button class=&quot;terminal-copy&quot; type=&quot;button&quot; aria-label=&quot;Copy commands&quot; data-astro-cid-kenkipkp&gt;&lt;svg width=&quot;13&quot; height=&quot;13&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;none&quot; stroke=&quot;currentColor&quot; stroke-width=&quot;2&quot; stroke-linecap=&quot;round&quot; stroke-linejoin=&quot;round&quot; aria-hidden=&quot;true&quot; data-astro-cid-kenkipkp&gt;&lt;rect x=&quot;9&quot; y=&quot;9&quot; width=&quot;13&quot; height=&quot;13&quot; rx=&quot;2&quot; data-astro-cid-kenkipkp&gt;&lt;/rect&gt;&lt;path d=&quot;M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1&quot; data-astro-cid-kenkipkp&gt;&lt;/path&gt;&lt;/svg&gt;&lt;span class=&quot;terminal-copy-label&quot; data-astro-cid-kenkipkp&gt;Copy&lt;/span&gt;&lt;/button&gt;&lt;/figcaption&gt;&lt;pre class=&quot;terminal-body&quot; data-astro-cid-kenkipkp&gt;&lt;code data-astro-cid-kenkipkp&gt;
# Point this at your bind-mounted storage
sudo mkdir -p /tank/media
sudo chown -R 1000:1000 /tank/media

# Create a Linux user (no login shell needed) then a Samba password for it
sudo useradd -M -s /usr/sbin/nologin youradmin
sudo smbpasswd -a youradmin
&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;
&lt;p&gt;Now define the share. Open &lt;code&gt;/etc/samba/smb.conf&lt;/code&gt; and add a block at the bottom:&lt;/p&gt;
&lt;figure class=&quot;terminal&quot; data-astro-cid-kenkipkp&gt;&lt;figcaption class=&quot;terminal-bar&quot; data-astro-cid-kenkipkp&gt;&lt;span class=&quot;dot red&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot yellow&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot green&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;terminal-title&quot; data-astro-cid-kenkipkp&gt;/etc/samba/smb.conf (add at the end)&lt;/span&gt;&lt;button class=&quot;terminal-copy&quot; type=&quot;button&quot; aria-label=&quot;Copy commands&quot; data-astro-cid-kenkipkp&gt;&lt;svg width=&quot;13&quot; height=&quot;13&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;none&quot; stroke=&quot;currentColor&quot; stroke-width=&quot;2&quot; stroke-linecap=&quot;round&quot; stroke-linejoin=&quot;round&quot; aria-hidden=&quot;true&quot; data-astro-cid-kenkipkp&gt;&lt;rect x=&quot;9&quot; y=&quot;9&quot; width=&quot;13&quot; height=&quot;13&quot; rx=&quot;2&quot; data-astro-cid-kenkipkp&gt;&lt;/rect&gt;&lt;path d=&quot;M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1&quot; data-astro-cid-kenkipkp&gt;&lt;/path&gt;&lt;/svg&gt;&lt;span class=&quot;terminal-copy-label&quot; data-astro-cid-kenkipkp&gt;Copy&lt;/span&gt;&lt;/button&gt;&lt;/figcaption&gt;&lt;pre class=&quot;terminal-body&quot; data-astro-cid-kenkipkp&gt;&lt;code data-astro-cid-kenkipkp&gt;
[media]
 path = /tank/media
 browseable = yes
 read only = no
 valid users = youradmin
 create mask = 0664
 directory mask = 0775
&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;
&lt;p&gt;Restart Samba and, if you run the Proxmox firewall or ufw, allow SMB (port 445):&lt;/p&gt;
&lt;figure class=&quot;terminal&quot; data-astro-cid-kenkipkp&gt;&lt;figcaption class=&quot;terminal-bar&quot; data-astro-cid-kenkipkp&gt;&lt;span class=&quot;dot red&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot yellow&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot green&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;terminal-title&quot; data-astro-cid-kenkipkp&gt;Apply and open the firewall&lt;/span&gt;&lt;button class=&quot;terminal-copy&quot; type=&quot;button&quot; aria-label=&quot;Copy commands&quot; data-astro-cid-kenkipkp&gt;&lt;svg width=&quot;13&quot; height=&quot;13&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;none&quot; stroke=&quot;currentColor&quot; stroke-width=&quot;2&quot; stroke-linecap=&quot;round&quot; stroke-linejoin=&quot;round&quot; aria-hidden=&quot;true&quot; data-astro-cid-kenkipkp&gt;&lt;rect x=&quot;9&quot; y=&quot;9&quot; width=&quot;13&quot; height=&quot;13&quot; rx=&quot;2&quot; data-astro-cid-kenkipkp&gt;&lt;/rect&gt;&lt;path d=&quot;M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1&quot; data-astro-cid-kenkipkp&gt;&lt;/path&gt;&lt;/svg&gt;&lt;span class=&quot;terminal-copy-label&quot; data-astro-cid-kenkipkp&gt;Copy&lt;/span&gt;&lt;/button&gt;&lt;/figcaption&gt;&lt;pre class=&quot;terminal-body&quot; data-astro-cid-kenkipkp&gt;&lt;code data-astro-cid-kenkipkp&gt;
sudo systemctl restart smbd
# If you use ufw inside the container:
sudo ufw allow samba
&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;
&lt;p&gt;That’s it. From Windows, open File Explorer and type &lt;code&gt;\\SERVER-IP\media&lt;/code&gt;. On macOS, use Finder’s &lt;strong&gt;Go → Connect to Server&lt;/strong&gt; and enter &lt;code&gt;smb://SERVER-IP/media&lt;/code&gt;. Sign in as &lt;code&gt;youradmin&lt;/code&gt; with the Samba password you set.&lt;/p&gt;
&lt;div class=&quot;callout callout-warning&quot; data-astro-cid-q2ml7llr&gt;&lt;div class=&quot;callout-header&quot; data-astro-cid-q2ml7llr&gt;&lt;span class=&quot;callout-icon&quot; aria-hidden=&quot;true&quot; data-astro-cid-q2ml7llr&gt;⚠️&lt;/span&gt;&lt;span class=&quot;callout-label&quot; data-astro-cid-q2ml7llr&gt;Never expose Samba to the internet&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;callout-body&quot; data-astro-cid-q2ml7llr&gt;&lt;p&gt;SMB is a LAN protocol. Do not port-forward 445 through your router — it is a favourite target for ransomware. To reach your files away from home, put them behind &lt;a href=&quot;https://peira.dev/blog/tailscale-subnet-router/&quot;&gt;Tailscale&lt;/a&gt; instead and access the share over the private tunnel.&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;step-3--nfs-for-your-other-linux-containers&quot;&gt;Step 3 — NFS, for your other Linux containers&lt;/h2&gt;
&lt;p&gt;For container-to-container access — say, letting Plex and the Arr stack read the same library — NFS is faster and avoids the login prompts SMB uses. Install the server:&lt;/p&gt;
&lt;figure class=&quot;terminal&quot; data-astro-cid-kenkipkp&gt;&lt;figcaption class=&quot;terminal-bar&quot; data-astro-cid-kenkipkp&gt;&lt;span class=&quot;dot red&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot yellow&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot green&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;terminal-title&quot; data-astro-cid-kenkipkp&gt;Install the NFS server&lt;/span&gt;&lt;button class=&quot;terminal-copy&quot; type=&quot;button&quot; aria-label=&quot;Copy commands&quot; data-astro-cid-kenkipkp&gt;&lt;svg width=&quot;13&quot; height=&quot;13&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;none&quot; stroke=&quot;currentColor&quot; stroke-width=&quot;2&quot; stroke-linecap=&quot;round&quot; stroke-linejoin=&quot;round&quot; aria-hidden=&quot;true&quot; data-astro-cid-kenkipkp&gt;&lt;rect x=&quot;9&quot; y=&quot;9&quot; width=&quot;13&quot; height=&quot;13&quot; rx=&quot;2&quot; data-astro-cid-kenkipkp&gt;&lt;/rect&gt;&lt;path d=&quot;M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1&quot; data-astro-cid-kenkipkp&gt;&lt;/path&gt;&lt;/svg&gt;&lt;span class=&quot;terminal-copy-label&quot; data-astro-cid-kenkipkp&gt;Copy&lt;/span&gt;&lt;/button&gt;&lt;/figcaption&gt;&lt;pre class=&quot;terminal-body&quot; data-astro-cid-kenkipkp&gt;&lt;code data-astro-cid-kenkipkp&gt;
sudo apt install -y nfs-kernel-server
&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;
&lt;p&gt;Declare what to export and to whom in &lt;code&gt;/etc/exports&lt;/code&gt;. Restrict it to your LAN subnet so only local machines can mount it:&lt;/p&gt;
&lt;figure class=&quot;terminal&quot; data-astro-cid-kenkipkp&gt;&lt;figcaption class=&quot;terminal-bar&quot; data-astro-cid-kenkipkp&gt;&lt;span class=&quot;dot red&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot yellow&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot green&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;terminal-title&quot; data-astro-cid-kenkipkp&gt;/etc/exports&lt;/span&gt;&lt;button class=&quot;terminal-copy&quot; type=&quot;button&quot; aria-label=&quot;Copy commands&quot; data-astro-cid-kenkipkp&gt;&lt;svg width=&quot;13&quot; height=&quot;13&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;none&quot; stroke=&quot;currentColor&quot; stroke-width=&quot;2&quot; stroke-linecap=&quot;round&quot; stroke-linejoin=&quot;round&quot; aria-hidden=&quot;true&quot; data-astro-cid-kenkipkp&gt;&lt;rect x=&quot;9&quot; y=&quot;9&quot; width=&quot;13&quot; height=&quot;13&quot; rx=&quot;2&quot; data-astro-cid-kenkipkp&gt;&lt;/rect&gt;&lt;path d=&quot;M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1&quot; data-astro-cid-kenkipkp&gt;&lt;/path&gt;&lt;/svg&gt;&lt;span class=&quot;terminal-copy-label&quot; data-astro-cid-kenkipkp&gt;Copy&lt;/span&gt;&lt;/button&gt;&lt;/figcaption&gt;&lt;pre class=&quot;terminal-body&quot; data-astro-cid-kenkipkp&gt;&lt;code data-astro-cid-kenkipkp&gt;
/tank/media   192.168.1.0/24(rw,sync,no_subtree_check,all_squash,anonuid=1000,anongid=1000)
&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;
&lt;p&gt;The &lt;code&gt;all_squash&lt;/code&gt; with &lt;code&gt;anonuid=1000&lt;/code&gt; maps every client to UID 1000 — the same shared owner from Step 1 — so files created over NFS have consistent ownership. Apply it:&lt;/p&gt;
&lt;figure class=&quot;terminal&quot; data-astro-cid-kenkipkp&gt;&lt;figcaption class=&quot;terminal-bar&quot; data-astro-cid-kenkipkp&gt;&lt;span class=&quot;dot red&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot yellow&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot green&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;terminal-title&quot; data-astro-cid-kenkipkp&gt;Publish the export&lt;/span&gt;&lt;button class=&quot;terminal-copy&quot; type=&quot;button&quot; aria-label=&quot;Copy commands&quot; data-astro-cid-kenkipkp&gt;&lt;svg width=&quot;13&quot; height=&quot;13&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;none&quot; stroke=&quot;currentColor&quot; stroke-width=&quot;2&quot; stroke-linecap=&quot;round&quot; stroke-linejoin=&quot;round&quot; aria-hidden=&quot;true&quot; data-astro-cid-kenkipkp&gt;&lt;rect x=&quot;9&quot; y=&quot;9&quot; width=&quot;13&quot; height=&quot;13&quot; rx=&quot;2&quot; data-astro-cid-kenkipkp&gt;&lt;/rect&gt;&lt;path d=&quot;M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1&quot; data-astro-cid-kenkipkp&gt;&lt;/path&gt;&lt;/svg&gt;&lt;span class=&quot;terminal-copy-label&quot; data-astro-cid-kenkipkp&gt;Copy&lt;/span&gt;&lt;/button&gt;&lt;/figcaption&gt;&lt;pre class=&quot;terminal-body&quot; data-astro-cid-kenkipkp&gt;&lt;code data-astro-cid-kenkipkp&gt;
sudo exportfs -ra
sudo systemctl enable --now nfs-kernel-server
&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;
&lt;p&gt;On a client container, mount it like any other folder:&lt;/p&gt;
&lt;figure class=&quot;terminal&quot; data-astro-cid-kenkipkp&gt;&lt;figcaption class=&quot;terminal-bar&quot; data-astro-cid-kenkipkp&gt;&lt;span class=&quot;dot red&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot yellow&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot green&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;terminal-title&quot; data-astro-cid-kenkipkp&gt;Mount the share on another Linux box&lt;/span&gt;&lt;button class=&quot;terminal-copy&quot; type=&quot;button&quot; aria-label=&quot;Copy commands&quot; data-astro-cid-kenkipkp&gt;&lt;svg width=&quot;13&quot; height=&quot;13&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;none&quot; stroke=&quot;currentColor&quot; stroke-width=&quot;2&quot; stroke-linecap=&quot;round&quot; stroke-linejoin=&quot;round&quot; aria-hidden=&quot;true&quot; data-astro-cid-kenkipkp&gt;&lt;rect x=&quot;9&quot; y=&quot;9&quot; width=&quot;13&quot; height=&quot;13&quot; rx=&quot;2&quot; data-astro-cid-kenkipkp&gt;&lt;/rect&gt;&lt;path d=&quot;M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1&quot; data-astro-cid-kenkipkp&gt;&lt;/path&gt;&lt;/svg&gt;&lt;span class=&quot;terminal-copy-label&quot; data-astro-cid-kenkipkp&gt;Copy&lt;/span&gt;&lt;/button&gt;&lt;/figcaption&gt;&lt;pre class=&quot;terminal-body&quot; data-astro-cid-kenkipkp&gt;&lt;code data-astro-cid-kenkipkp&gt;
sudo apt install -y nfs-common
sudo mkdir -p /mnt/media
sudo mount -t nfs SERVER-IP:/tank/media /mnt/media

# To make it permanent, add to /etc/fstab:
# SERVER-IP:/tank/media  /mnt/media  nfs  defaults,_netdev  0  0
&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;you-now-have-a-foundation-not-just-a-share&quot;&gt;You now have a foundation, not just a share&lt;/h2&gt;
&lt;p&gt;This one container is the storage backbone of almost everything else you’ll build. Your &lt;a href=&quot;https://peira.dev/blog/plex-media-server-nas-lxc/&quot;&gt;media server&lt;/a&gt; reads its library from here. Your &lt;a href=&quot;https://peira.dev/blog/immich-proxmox-photos/&quot;&gt;photo backups&lt;/a&gt; land here. The &lt;a href=&quot;https://peira.dev/blog/arr-stack-automation/&quot;&gt;Arr automation stack&lt;/a&gt; files downloads here — and because every app sees the &lt;em&gt;same&lt;/em&gt; paths on the &lt;em&gt;same&lt;/em&gt; share, it can move files with instant hardlinks instead of slow, space-doubling copies.&lt;/p&gt;
&lt;p&gt;Set up storage once, correctly, and every service you add afterward gets easier. That’s the whole idea behind building a homelab &lt;a href=&quot;https://peira.dev/start/&quot;&gt;in order&lt;/a&gt;.&lt;/p&gt;
&lt;hr/&gt;
&lt;p&gt;&lt;strong&gt;Related posts:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/what-is-a-nas/&quot;&gt;What Is a NAS? (And Which Kind Your Homelab Actually Needs)&lt;/a&gt; — the concept and buying decision behind this build&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/plex-media-server-nas-lxc/&quot;&gt;Install Plex Media Server on a NAS or Proxmox LXC&lt;/a&gt; — a service that reads from these shares&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/immich-proxmox-photos/&quot;&gt;Immich: Self-Hosted Photos on Proxmox&lt;/a&gt; — point photo backups at your file server&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/proxmox-storage-guide/&quot;&gt;Proxmox Storage Guide: LVM, ZFS, NFS, and Ceph&lt;/a&gt; — the storage backing your shares&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/jellyfin-install-nas-lxc/&quot;&gt;Install Jellyfin on a NAS or Proxmox LXC&lt;/a&gt; — another media consumer of these shares&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/homelab-hardware-guide/&quot;&gt;Homelab Hardware Guide&lt;/a&gt; — pick hardware for a file server&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/proxmox-lxc-networking/&quot;&gt;Proxmox LXC Networking&lt;/a&gt; — network the container serving your files&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Sources: &lt;a href=&quot;https://www.samba.org/&quot;&gt;Samba&lt;/a&gt;, &lt;a href=&quot;https://wiki.samba.org/index.php/Main_Page&quot;&gt;Samba wiki&lt;/a&gt;, &lt;a href=&quot;https://learn.microsoft.com/en-us/windows-server/storage/file-server/file-server-smb-overview&quot;&gt;Microsoft SMB file-server overview&lt;/a&gt;.&lt;/p&gt;</content:encoded><category>getting-started</category><category>infrastructure</category><category>self-hosted</category></item><item><title>One Dashboard for Your Whole Homelab: Set Up Homepage</title><link>https://peira.dev/blog/homepage-dashboard/</link><guid isPermaLink="true">https://peira.dev/blog/homepage-dashboard/</guid><description>Stop bookmarking IP addresses. Set up Homepage — a fast, self-hosted dashboard that puts every homelab service, live status, and system stats on one clean start page. Beginner Docker Compose guide.</description><pubDate>Wed, 15 Jul 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;There’s a rite of passage in every homelab. You add a fifth service, go to open the fourth one, and realise you can’t remember whether Sonarr is on port 8989 or 7878, or which of the three IP addresses in your browser history is the wiki. Your bookmarks bar is a graveyard of &lt;code&gt;192.168.1.x:some-port&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href=&quot;https://gethomepage.dev/&quot;&gt;Homepage&lt;/a&gt;&lt;/strong&gt; ends that. It’s a single, fast, self-hosted start page that links to everything you run — and, crucially, shows &lt;em&gt;live&lt;/em&gt; information on each tile: how many ads Pi-hole blocked today, what’s downloading in Sonarr, your Proxmox node’s CPU and RAM, right there on the dashboard.&lt;/p&gt;
&lt;svg viewBox=&quot;0 0 720 360&quot; xmlns=&quot;http://www.w3.org/2000/svg&quot; style=&quot;width:100%;border-radius:12px;background:#0e0b08;margin:2rem 0&quot;&gt;&lt;defs&gt;&lt;pattern id=&quot;gp-homepagedash0&quot; width=&quot;20&quot; height=&quot;20&quot; patternUnits=&quot;userSpaceOnUse&quot;&gt;&lt;path d=&quot;M 20 0 H 0 V 20&quot; fill=&quot;none&quot; stroke=&quot;#221c15&quot; stroke-width=&quot;1&quot;&gt;&lt;/path&gt;&lt;/pattern&gt;&lt;/defs&gt;&lt;rect x=&quot;0&quot; y=&quot;0&quot; width=&quot;720&quot; height=&quot;360&quot; fill=&quot;url(#gp-homepagedash0)&quot;&gt;&lt;/rect&gt;&lt;rect x=&quot;6&quot; y=&quot;6&quot; width=&quot;708&quot; height=&quot;348&quot; fill=&quot;none&quot; stroke=&quot;#2e2820&quot; stroke-width=&quot;1&quot;&gt;&lt;/rect&gt;&lt;path d=&quot;M 11,16 H 21 M 16,11 V 21 M 699,16 H 709 M 704,11 V 21 M 11,344 H 21 M 16,339 V 349 M 699,344 H 709 M 704,339 V 349&quot; stroke=&quot;#4a3f2e&quot; stroke-width=&quot;1&quot; fill=&quot;none&quot;&gt;&lt;/path&gt;&lt;rect x=&quot;0&quot; y=&quot;0&quot; width=&quot;720&quot; height=&quot;42&quot; fill=&quot;#141009&quot;&gt;&lt;/rect&gt;&lt;circle cx=&quot;22&quot; cy=&quot;21&quot; r=&quot;5&quot; fill=&quot;#d4604f&quot;&gt;&lt;/circle&gt;&lt;circle cx=&quot;40&quot; cy=&quot;21&quot; r=&quot;5&quot; fill=&quot;#d9a13d&quot;&gt;&lt;/circle&gt;&lt;circle cx=&quot;58&quot; cy=&quot;21&quot; r=&quot;5&quot; fill=&quot;#8fb573&quot;&gt;&lt;/circle&gt;&lt;text x=&quot;360&quot; y=&quot;26&quot; text-anchor=&quot;middle&quot; fill=&quot;#8a8071&quot; font-size=&quot;11&quot; font-family=&quot;monospace&quot;&gt;&lt;a href=&quot;http://home.lab:3000&quot;&gt;http://home.lab:3000&lt;/a&gt;&lt;/text&gt;&lt;text x=&quot;40&quot; y=&quot;82&quot; fill=&quot;#f2ede3&quot; font-size=&quot;18&quot; font-family=&quot;sans-serif&quot; font-weight=&quot;bold&quot;&gt;Good evening, Josh&lt;/text&gt;&lt;text x=&quot;40&quot; y=&quot;104&quot; fill=&quot;#8a8071&quot; font-size=&quot;11&quot; font-family=&quot;monospace&quot;&gt;4 nodes · 23 containers · all systems green&lt;/text&gt;&lt;text x=&quot;40&quot; y=&quot;140&quot; fill=&quot;#6f9bd1&quot; font-size=&quot;11&quot; font-family=&quot;sans-serif&quot; font-weight=&quot;bold&quot;&gt;INFRASTRUCTURE&lt;/text&gt;&lt;rect x=&quot;40&quot; y=&quot;150&quot; width=&quot;200&quot; height=&quot;56&quot; rx=&quot;8&quot; fill=&quot;#120e0a&quot; stroke=&quot;#1f1913&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;56&quot; y=&quot;172&quot; fill=&quot;#e5ddcd&quot; font-size=&quot;12&quot; font-family=&quot;sans-serif&quot; font-weight=&quot;bold&quot;&gt;Proxmox&lt;/text&gt;&lt;text x=&quot;56&quot; y=&quot;192&quot; fill=&quot;#8fb573&quot; font-size=&quot;10&quot; font-family=&quot;monospace&quot;&gt;CPU 12%  ·  RAM 41%&lt;/text&gt;&lt;rect x=&quot;256&quot; y=&quot;150&quot; width=&quot;200&quot; height=&quot;56&quot; rx=&quot;8&quot; fill=&quot;#120e0a&quot; stroke=&quot;#1f1913&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;272&quot; y=&quot;172&quot; fill=&quot;#e5ddcd&quot; font-size=&quot;12&quot; font-family=&quot;sans-serif&quot; font-weight=&quot;bold&quot;&gt;Pi-hole&lt;/text&gt;&lt;text x=&quot;272&quot; y=&quot;192&quot; fill=&quot;#6f9bd1&quot; font-size=&quot;10&quot; font-family=&quot;monospace&quot;&gt;14,203 blocked today&lt;/text&gt;&lt;rect x=&quot;472&quot; y=&quot;150&quot; width=&quot;208&quot; height=&quot;56&quot; rx=&quot;8&quot; fill=&quot;#120e0a&quot; stroke=&quot;#1f1913&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;488&quot; y=&quot;172&quot; fill=&quot;#e5ddcd&quot; font-size=&quot;12&quot; font-family=&quot;sans-serif&quot; font-weight=&quot;bold&quot;&gt;Uptime Kuma&lt;/text&gt;&lt;text x=&quot;488&quot; y=&quot;192&quot; fill=&quot;#8fb573&quot; font-size=&quot;10&quot; font-family=&quot;monospace&quot;&gt;18/18 up  ·  100%&lt;/text&gt;&lt;text x=&quot;40&quot; y=&quot;238&quot; fill=&quot;#c96a52&quot; font-size=&quot;11&quot; font-family=&quot;sans-serif&quot; font-weight=&quot;bold&quot;&gt;MEDIA&lt;/text&gt;&lt;rect x=&quot;40&quot; y=&quot;248&quot; width=&quot;200&quot; height=&quot;56&quot; rx=&quot;8&quot; fill=&quot;#120e0a&quot; stroke=&quot;#1f1913&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;56&quot; y=&quot;270&quot; fill=&quot;#e5ddcd&quot; font-size=&quot;12&quot; font-family=&quot;sans-serif&quot; font-weight=&quot;bold&quot;&gt;Jellyfin&lt;/text&gt;&lt;text x=&quot;56&quot; y=&quot;290&quot; fill=&quot;#c96a52&quot; font-size=&quot;10&quot; font-family=&quot;monospace&quot;&gt;2 streams playing&lt;/text&gt;&lt;rect x=&quot;256&quot; y=&quot;248&quot; width=&quot;200&quot; height=&quot;56&quot; rx=&quot;8&quot; fill=&quot;#120e0a&quot; stroke=&quot;#1f1913&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;272&quot; y=&quot;270&quot; fill=&quot;#e5ddcd&quot; font-size=&quot;12&quot; font-family=&quot;sans-serif&quot; font-weight=&quot;bold&quot;&gt;Sonarr&lt;/text&gt;&lt;text x=&quot;272&quot; y=&quot;290&quot; fill=&quot;#d9a13d&quot; font-size=&quot;10&quot; font-family=&quot;monospace&quot;&gt;3 downloading&lt;/text&gt;&lt;rect x=&quot;472&quot; y=&quot;248&quot; width=&quot;208&quot; height=&quot;56&quot; rx=&quot;8&quot; fill=&quot;#120e0a&quot; stroke=&quot;#1f1913&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;488&quot; y=&quot;270&quot; fill=&quot;#e5ddcd&quot; font-size=&quot;12&quot; font-family=&quot;sans-serif&quot; font-weight=&quot;bold&quot;&gt;Immich&lt;/text&gt;&lt;text x=&quot;488&quot; y=&quot;290&quot; fill=&quot;#e8a33e&quot; font-size=&quot;10&quot; font-family=&quot;monospace&quot;&gt;24,881 photos&lt;/text&gt;&lt;text x=&quot;360&quot; y=&quot;338&quot; text-anchor=&quot;middle&quot; fill=&quot;#8a8071&quot; font-size=&quot;10&quot; font-family=&quot;sans-serif&quot;&gt;Every tile links to the service — and shows live data from its API&lt;/text&gt;&lt;text x=&quot;710&quot; y=&quot;352&quot; text-anchor=&quot;end&quot; font-size=&quot;10&quot; fill=&quot;#8a8071&quot; opacity=&quot;0.6&quot; font-family=&quot;monospace&quot;&gt;peira.dev&lt;/text&gt;&lt;/svg&gt;
&lt;p&gt;Homepage is configured with plain YAML files (no clunky admin UI to fight), it starts in milliseconds, and it can even &lt;strong&gt;auto-discover&lt;/strong&gt; services straight from your Docker labels. Let’s stand it up.&lt;/p&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;install-homepage&quot;&gt;Install Homepage&lt;/h2&gt;
&lt;p&gt;Homepage runs as a single container. On your Docker host, create a config folder and run it. There’s one gotcha that catches &lt;em&gt;everyone&lt;/em&gt;, so we’ll handle it up front.&lt;/p&gt;
&lt;div class=&quot;callout callout-warning&quot; data-astro-cid-q2ml7llr&gt;&lt;div class=&quot;callout-header&quot; data-astro-cid-q2ml7llr&gt;&lt;span class=&quot;callout-icon&quot; aria-hidden=&quot;true&quot; data-astro-cid-q2ml7llr&gt;⚠️&lt;/span&gt;&lt;span class=&quot;callout-label&quot; data-astro-cid-q2ml7llr&gt;The HOMEPAGE_ALLOWED_HOSTS gotcha — read this first&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;callout-body&quot; data-astro-cid-q2ml7llr&gt;&lt;p&gt;For security, Homepage refuses any request whose address isn’t on an allow-list, and if you skip this you’ll get a blank page or a “host validation failed” error. You &lt;strong&gt;must&lt;/strong&gt; set the &lt;code&gt;HOMEPAGE_ALLOWED_HOSTS&lt;/code&gt; environment variable to exactly how you’ll open the dashboard — including the port. This is the number-one reason a fresh Homepage install “doesn’t work.”&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;
&lt;div class=&quot;step&quot; data-astro-cid-wwaqy5cd&gt;&lt;div class=&quot;step-header&quot; data-astro-cid-wwaqy5cd&gt;&lt;span class=&quot;step-num&quot; data-astro-cid-wwaqy5cd&gt;1&lt;/span&gt;&lt;span class=&quot;step-title&quot; data-astro-cid-wwaqy5cd&gt;Create the compose file&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;step-body&quot; data-astro-cid-wwaqy5cd&gt;&lt;p&gt;Make a folder and a docker-compose.yml. Note the docker socket mount — it’s optional, but it’s what lets Homepage auto-discover containers and show Docker stats.&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;
&lt;figure class=&quot;terminal&quot; data-astro-cid-kenkipkp&gt;&lt;figcaption class=&quot;terminal-bar&quot; data-astro-cid-kenkipkp&gt;&lt;span class=&quot;dot red&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot yellow&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot green&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;terminal-title&quot; data-astro-cid-kenkipkp&gt;docker-compose.yml&lt;/span&gt;&lt;button class=&quot;terminal-copy&quot; type=&quot;button&quot; aria-label=&quot;Copy commands&quot; data-astro-cid-kenkipkp&gt;&lt;svg width=&quot;13&quot; height=&quot;13&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;none&quot; stroke=&quot;currentColor&quot; stroke-width=&quot;2&quot; stroke-linecap=&quot;round&quot; stroke-linejoin=&quot;round&quot; aria-hidden=&quot;true&quot; data-astro-cid-kenkipkp&gt;&lt;rect x=&quot;9&quot; y=&quot;9&quot; width=&quot;13&quot; height=&quot;13&quot; rx=&quot;2&quot; data-astro-cid-kenkipkp&gt;&lt;/rect&gt;&lt;path d=&quot;M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1&quot; data-astro-cid-kenkipkp&gt;&lt;/path&gt;&lt;/svg&gt;&lt;span class=&quot;terminal-copy-label&quot; data-astro-cid-kenkipkp&gt;Copy&lt;/span&gt;&lt;/button&gt;&lt;/figcaption&gt;&lt;pre class=&quot;terminal-body&quot; data-astro-cid-kenkipkp&gt;&lt;code data-astro-cid-kenkipkp&gt;
services:
homepage:
  image: ghcr.io/gethomepage/homepage:latest
  container_name: homepage
  ports:
    - &amp;quot;3000:3000&amp;quot;
  volumes:
    - ./config:/app/config
    - /var/run/docker.sock:/var/run/docker.sock:ro   # optional: auto-discovery
  environment:
    # Set this to how YOU reach the dashboard, port included:
    - HOMEPAGE_ALLOWED_HOSTS=192.168.1.50:3000
  restart: unless-stopped
&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;
&lt;div class=&quot;step&quot; data-astro-cid-wwaqy5cd&gt;&lt;div class=&quot;step-header&quot; data-astro-cid-wwaqy5cd&gt;&lt;span class=&quot;step-num&quot; data-astro-cid-wwaqy5cd&gt;2&lt;/span&gt;&lt;span class=&quot;step-title&quot; data-astro-cid-wwaqy5cd&gt;Start it and let it create its config&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;step-body&quot; data-astro-cid-wwaqy5cd&gt;&lt;p&gt;Bring it up. On first run Homepage writes a set of starter YAML files into your ./config folder.&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;
&lt;figure class=&quot;terminal&quot; data-astro-cid-kenkipkp&gt;&lt;figcaption class=&quot;terminal-bar&quot; data-astro-cid-kenkipkp&gt;&lt;span class=&quot;dot red&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot yellow&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot green&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;terminal-title&quot; data-astro-cid-kenkipkp&gt;Launch Homepage&lt;/span&gt;&lt;button class=&quot;terminal-copy&quot; type=&quot;button&quot; aria-label=&quot;Copy commands&quot; data-astro-cid-kenkipkp&gt;&lt;svg width=&quot;13&quot; height=&quot;13&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;none&quot; stroke=&quot;currentColor&quot; stroke-width=&quot;2&quot; stroke-linecap=&quot;round&quot; stroke-linejoin=&quot;round&quot; aria-hidden=&quot;true&quot; data-astro-cid-kenkipkp&gt;&lt;rect x=&quot;9&quot; y=&quot;9&quot; width=&quot;13&quot; height=&quot;13&quot; rx=&quot;2&quot; data-astro-cid-kenkipkp&gt;&lt;/rect&gt;&lt;path d=&quot;M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1&quot; data-astro-cid-kenkipkp&gt;&lt;/path&gt;&lt;/svg&gt;&lt;span class=&quot;terminal-copy-label&quot; data-astro-cid-kenkipkp&gt;Copy&lt;/span&gt;&lt;/button&gt;&lt;/figcaption&gt;&lt;pre class=&quot;terminal-body&quot; data-astro-cid-kenkipkp&gt;&lt;code data-astro-cid-kenkipkp&gt;
docker compose up -d
&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;
&lt;p&gt;Open &lt;code&gt;http://YOUR-HOST-IP:3000&lt;/code&gt; and you’ll see a default dashboard. Now the fun part — making it yours.&lt;/p&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;configure-it-four-little-yaml-files&quot;&gt;Configure it: four little YAML files&lt;/h2&gt;
&lt;p&gt;Everything Homepage shows comes from files in the config folder. You only need three to start:&lt;/p&gt;
&lt;svg viewBox=&quot;0 0 720 180&quot; xmlns=&quot;http://www.w3.org/2000/svg&quot; style=&quot;width:100%;border-radius:12px;background:#1c1712;margin:2rem 0&quot;&gt;&lt;defs&gt;&lt;pattern id=&quot;gp-homepagedash1&quot; width=&quot;20&quot; height=&quot;20&quot; patternUnits=&quot;userSpaceOnUse&quot;&gt;&lt;path d=&quot;M 20 0 H 0 V 20&quot; fill=&quot;none&quot; stroke=&quot;#221c15&quot; stroke-width=&quot;1&quot;&gt;&lt;/path&gt;&lt;/pattern&gt;&lt;/defs&gt;&lt;rect x=&quot;0&quot; y=&quot;0&quot; width=&quot;720&quot; height=&quot;180&quot; fill=&quot;url(#gp-homepagedash1)&quot;&gt;&lt;/rect&gt;&lt;rect x=&quot;6&quot; y=&quot;6&quot; width=&quot;708&quot; height=&quot;168&quot; fill=&quot;none&quot; stroke=&quot;#2e2820&quot; stroke-width=&quot;1&quot;&gt;&lt;/rect&gt;&lt;path d=&quot;M 11,16 H 21 M 16,11 V 21 M 699,16 H 709 M 704,11 V 21 M 11,164 H 21 M 16,159 V 169 M 699,164 H 709 M 704,159 V 169&quot; stroke=&quot;#4a3f2e&quot; stroke-width=&quot;1&quot; fill=&quot;none&quot;&gt;&lt;/path&gt;&lt;text x=&quot;360&quot; y=&quot;28&quot; text-anchor=&quot;middle&quot; fill=&quot;#f2ede3&quot; font-size=&quot;13&quot; font-family=&quot;sans-serif&quot; font-weight=&quot;bold&quot;&gt;config/ — edit these, Homepage reloads automatically&lt;/text&gt;&lt;rect x=&quot;40&quot; y=&quot;50&quot; width=&quot;200&quot; height=&quot;100&quot; rx=&quot;8&quot; fill=&quot;#16110d&quot; stroke=&quot;#e8a33e&quot; stroke-width=&quot;1.2&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;140&quot; y=&quot;80&quot; text-anchor=&quot;middle&quot; fill=&quot;#e8a33e&quot; font-size=&quot;12&quot; font-family=&quot;monospace&quot; font-weight=&quot;bold&quot;&gt;services.yaml&lt;/text&gt;&lt;text x=&quot;140&quot; y=&quot;105&quot; text-anchor=&quot;middle&quot; fill=&quot;#aba192&quot; font-size=&quot;10&quot; font-family=&quot;sans-serif&quot;&gt;Your service tiles&lt;/text&gt;&lt;text x=&quot;140&quot; y=&quot;123&quot; text-anchor=&quot;middle&quot; fill=&quot;#aba192&quot; font-size=&quot;10&quot; font-family=&quot;sans-serif&quot;&gt;+ live widgets&lt;/text&gt;&lt;rect x=&quot;260&quot; y=&quot;50&quot; width=&quot;200&quot; height=&quot;100&quot; rx=&quot;8&quot; fill=&quot;#16110d&quot; stroke=&quot;#6f9bd1&quot; stroke-width=&quot;1.2&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;360&quot; y=&quot;80&quot; text-anchor=&quot;middle&quot; fill=&quot;#6f9bd1&quot; font-size=&quot;12&quot; font-family=&quot;monospace&quot; font-weight=&quot;bold&quot;&gt;settings.yaml&lt;/text&gt;&lt;text x=&quot;360&quot; y=&quot;105&quot; text-anchor=&quot;middle&quot; fill=&quot;#aba192&quot; font-size=&quot;10&quot; font-family=&quot;sans-serif&quot;&gt;Title, theme,&lt;/text&gt;&lt;text x=&quot;360&quot; y=&quot;123&quot; text-anchor=&quot;middle&quot; fill=&quot;#aba192&quot; font-size=&quot;10&quot; font-family=&quot;sans-serif&quot;&gt;layout, columns&lt;/text&gt;&lt;rect x=&quot;480&quot; y=&quot;50&quot; width=&quot;200&quot; height=&quot;100&quot; rx=&quot;8&quot; fill=&quot;#16110d&quot; stroke=&quot;#d9a13d&quot; stroke-width=&quot;1.2&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;580&quot; y=&quot;80&quot; text-anchor=&quot;middle&quot; fill=&quot;#d9a13d&quot; font-size=&quot;12&quot; font-family=&quot;monospace&quot; font-weight=&quot;bold&quot;&gt;widgets.yaml&lt;/text&gt;&lt;text x=&quot;580&quot; y=&quot;105&quot; text-anchor=&quot;middle&quot; fill=&quot;#aba192&quot; font-size=&quot;10&quot; font-family=&quot;sans-serif&quot;&gt;Top bar: clock,&lt;/text&gt;&lt;text x=&quot;580&quot; y=&quot;123&quot; text-anchor=&quot;middle&quot; fill=&quot;#aba192&quot; font-size=&quot;10&quot; font-family=&quot;sans-serif&quot;&gt;weather, resources&lt;/text&gt;&lt;/svg&gt;
&lt;p&gt;Edit &lt;code&gt;config/services.yaml&lt;/code&gt; to add your own services, grouped however you like. Here’s a real example — notice the &lt;code&gt;widget&lt;/code&gt; block on Pi-hole: that’s what pulls live stats onto the tile.&lt;/p&gt;
&lt;figure class=&quot;terminal&quot; data-astro-cid-kenkipkp&gt;&lt;figcaption class=&quot;terminal-bar&quot; data-astro-cid-kenkipkp&gt;&lt;span class=&quot;dot red&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot yellow&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot green&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;terminal-title&quot; data-astro-cid-kenkipkp&gt;config/services.yaml&lt;/span&gt;&lt;button class=&quot;terminal-copy&quot; type=&quot;button&quot; aria-label=&quot;Copy commands&quot; data-astro-cid-kenkipkp&gt;&lt;svg width=&quot;13&quot; height=&quot;13&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;none&quot; stroke=&quot;currentColor&quot; stroke-width=&quot;2&quot; stroke-linecap=&quot;round&quot; stroke-linejoin=&quot;round&quot; aria-hidden=&quot;true&quot; data-astro-cid-kenkipkp&gt;&lt;rect x=&quot;9&quot; y=&quot;9&quot; width=&quot;13&quot; height=&quot;13&quot; rx=&quot;2&quot; data-astro-cid-kenkipkp&gt;&lt;/rect&gt;&lt;path d=&quot;M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1&quot; data-astro-cid-kenkipkp&gt;&lt;/path&gt;&lt;/svg&gt;&lt;span class=&quot;terminal-copy-label&quot; data-astro-cid-kenkipkp&gt;Copy&lt;/span&gt;&lt;/button&gt;&lt;/figcaption&gt;&lt;pre class=&quot;terminal-body&quot; data-astro-cid-kenkipkp&gt;&lt;code data-astro-cid-kenkipkp&gt;
- Infrastructure:
  - Proxmox:
      href: https://192.168.1.10:8006
      description: Virtualization host
      icon: proxmox.png
  - Pi-hole:
      href: http://192.168.1.20/admin
      description: Network ad-blocker
      icon: pi-hole.png
      widget:
        type: pihole
        url: http://192.168.1.20
        key: YOUR_PIHOLE_API_TOKEN

- Media:
  - Jellyfin:
      href: http://192.168.1.30:8096
      description: Movies and TV
      icon: jellyfin.png
&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;
&lt;div class=&quot;callout callout-tip&quot; data-astro-cid-q2ml7llr&gt;&lt;div class=&quot;callout-header&quot; data-astro-cid-q2ml7llr&gt;&lt;span class=&quot;callout-icon&quot; aria-hidden=&quot;true&quot; data-astro-cid-q2ml7llr&gt;💡&lt;/span&gt;&lt;span class=&quot;callout-label&quot; data-astro-cid-q2ml7llr&gt;Live widgets are the whole point&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;callout-body&quot; data-astro-cid-q2ml7llr&gt;&lt;p&gt;Homepage has built-in widgets for over 100 apps — Proxmox, Sonarr, Radarr, qBittorrent, Immich, Uptime Kuma, Home Assistant, and more. Add a &lt;code&gt;widget&lt;/code&gt; block with the service’s API key and the tile starts showing real data: queue counts, streams, temperatures, blocked-ad totals. Browse the full list in the &lt;a href=&quot;https://gethomepage.dev/&quot;&gt;Homepage docs&lt;/a&gt; and copy the block for each app you run.&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;
&lt;p&gt;For the top-of-page info widgets — a clock, the weather, and a live glance at the host’s own CPU, memory, and disk — edit &lt;code&gt;config/widgets.yaml&lt;/code&gt;:&lt;/p&gt;
&lt;figure class=&quot;terminal&quot; data-astro-cid-kenkipkp&gt;&lt;figcaption class=&quot;terminal-bar&quot; data-astro-cid-kenkipkp&gt;&lt;span class=&quot;dot red&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot yellow&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot green&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;terminal-title&quot; data-astro-cid-kenkipkp&gt;config/widgets.yaml&lt;/span&gt;&lt;button class=&quot;terminal-copy&quot; type=&quot;button&quot; aria-label=&quot;Copy commands&quot; data-astro-cid-kenkipkp&gt;&lt;svg width=&quot;13&quot; height=&quot;13&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;none&quot; stroke=&quot;currentColor&quot; stroke-width=&quot;2&quot; stroke-linecap=&quot;round&quot; stroke-linejoin=&quot;round&quot; aria-hidden=&quot;true&quot; data-astro-cid-kenkipkp&gt;&lt;rect x=&quot;9&quot; y=&quot;9&quot; width=&quot;13&quot; height=&quot;13&quot; rx=&quot;2&quot; data-astro-cid-kenkipkp&gt;&lt;/rect&gt;&lt;path d=&quot;M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1&quot; data-astro-cid-kenkipkp&gt;&lt;/path&gt;&lt;/svg&gt;&lt;span class=&quot;terminal-copy-label&quot; data-astro-cid-kenkipkp&gt;Copy&lt;/span&gt;&lt;/button&gt;&lt;/figcaption&gt;&lt;pre class=&quot;terminal-body&quot; data-astro-cid-kenkipkp&gt;&lt;code data-astro-cid-kenkipkp&gt;
- resources:
  cpu: true
  memory: true
  disk: /

- datetime:
  text_size: xl
  format:
    timeStyle: short
&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;
&lt;p&gt;Save any file and Homepage reloads on its own — no restart, no rebuild. Tweak, refresh, repeat until it’s exactly the cockpit you want. Here’s the result of exactly the YAML above — three groups, nine tiles, live host stats in the top bar:&lt;/p&gt;
&lt;figure class=&quot;shot&quot; data-astro-cid-pmiqfeva&gt;&lt;div class=&quot;shot-bar&quot; data-astro-cid-pmiqfeva&gt;&lt;span class=&quot;dot red&quot; data-astro-cid-pmiqfeva&gt;&lt;/span&gt;&lt;span class=&quot;dot yellow&quot; data-astro-cid-pmiqfeva&gt;&lt;/span&gt;&lt;span class=&quot;dot green&quot; data-astro-cid-pmiqfeva&gt;&lt;/span&gt;&lt;span class=&quot;shot-url&quot; data-astro-cid-pmiqfeva&gt;http://homelab.lan:3000&lt;/span&gt;&lt;/div&gt;&lt;img src=&quot;https://peira.dev/_astro/homepage-dashboard.CQqXaP5S_Z19lRby.webp&quot; alt=&quot;Homepage dashboard in dark mode showing Infrastructure, Media, and Monitoring groups with service tiles, plus live CPU, memory, and disk stats in the top bar&quot; data-astro-cid-pmiqfeva=&quot;true&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot; width=&quot;1400&quot; height=&quot;613&quot; class=&quot;shot-img&quot;&gt;&lt;figcaption class=&quot;shot-caption&quot; data-astro-cid-pmiqfeva&gt;The demo lab&amp;#39;s finished dashboard. The top-bar resources widget reads the host&amp;#39;s real CPU, RAM, and disk — the tiles are one click from anything you self-host.&lt;/figcaption&gt;&lt;/figure&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;make-it-your-browser-home-page&quot;&gt;Make it your browser home page&lt;/h2&gt;
&lt;p&gt;The finishing move: set &lt;code&gt;http://YOUR-HOST-IP:3000&lt;/code&gt; as your browser’s home page and new-tab page on every device. Now every time you open a browser, you land on a live map of your entire homelab — one click to any service, health of everything at a glance.&lt;/p&gt;
&lt;p&gt;Pair it with &lt;a href=&quot;https://peira.dev/blog/uptime-kuma-monitoring/&quot;&gt;Uptime Kuma&lt;/a&gt; for at-a-glance up/down status, reach it from anywhere over &lt;a href=&quot;https://peira.dev/blog/tailscale-subnet-router/&quot;&gt;Tailscale&lt;/a&gt;, and you’ve turned a pile of ports into something that genuinely feels like &lt;em&gt;your&lt;/em&gt; private cloud. Back to the &lt;a href=&quot;https://peira.dev/start/&quot;&gt;roadmap&lt;/a&gt; for what’s next.&lt;/p&gt;
&lt;hr/&gt;
&lt;p&gt;&lt;strong&gt;Related posts:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/uptime-kuma-monitoring/&quot;&gt;Uptime Kuma: Dead-Simple Homelab Monitoring&lt;/a&gt; — add up/down status to your dashboard&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/grafana-prometheus-proxmox/&quot;&gt;Proxmox Monitoring with Prometheus and Grafana&lt;/a&gt; — deep metrics to link from Homepage&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/pihole-proxmox-lxc/&quot;&gt;Pi-hole on Proxmox LXC&lt;/a&gt; — a service tile that shows live ad-block stats&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/tailscale-subnet-router/&quot;&gt;Tailscale Subnet Router&lt;/a&gt; — reach your dashboard from anywhere&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/what-is-a-homelab/&quot;&gt;What Is a Homelab?&lt;/a&gt; — the services this dashboard ties together&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/docker-localhost-trap/&quot;&gt;The Docker localhost Trap: 24 Hours of Red, Zero Real Downtime&lt;/a&gt; — why a monitor pointed at &lt;code&gt;localhost&lt;/code&gt; can’t see this dashboard&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/demo-lab-screenshots-headless-chrome/&quot;&gt;Real Screenshots, Zero Leaks: My Disposable Demo Lab&lt;/a&gt; — the throwaway environment behind this guide’s screenshots&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Sources: &lt;a href=&quot;https://gethomepage.dev/&quot;&gt;Homepage documentation&lt;/a&gt;, &lt;a href=&quot;https://github.com/gethomepage/homepage&quot;&gt;Homepage (GitHub)&lt;/a&gt;.&lt;/p&gt;</content:encoded><category>self-hosted</category><category>automation</category></item><item><title>Self-Host Your Photos with Immich: A Private Google Photos Alternative</title><link>https://peira.dev/blog/immich-proxmox-photos/</link><guid isPermaLink="true">https://peira.dev/blog/immich-proxmox-photos/</guid><description>Replace Google Photos with Immich on your Proxmox homelab. A beginner guide to installing Immich with Docker Compose, automatic phone backup, AI face and object search — all running on hardware you own.</description><pubDate>Wed, 15 Jul 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Of all the reasons to build a homelab, this might be the most emotionally compelling: &lt;strong&gt;your photos are the one irreplaceable thing you own, and right now you’re renting the shelf they sit on.&lt;/strong&gt; Google Photos, iCloud, whichever — the price creeps up, the free tier shrinks, and one day you’re paying monthly rent on your own memories.&lt;/p&gt;
&lt;p&gt;Immich fixes that. It’s a self-hosted photo and video backup that looks and feels like Google Photos — timeline, albums, sharing, face recognition, “show me photos from the beach” search — except it all runs on your homelab, and the photos never leave your house.&lt;/p&gt;
&lt;svg viewBox=&quot;0 0 720 250&quot; xmlns=&quot;http://www.w3.org/2000/svg&quot; style=&quot;width:100%;border-radius:12px;background:#1c1712;margin:2rem 0&quot;&gt;&lt;defs&gt;&lt;pattern id=&quot;gp-immichproxmo0&quot; width=&quot;20&quot; height=&quot;20&quot; patternUnits=&quot;userSpaceOnUse&quot;&gt;&lt;path d=&quot;M 20 0 H 0 V 20&quot; fill=&quot;none&quot; stroke=&quot;#221c15&quot; stroke-width=&quot;1&quot;&gt;&lt;/path&gt;&lt;/pattern&gt;&lt;/defs&gt;&lt;rect x=&quot;0&quot; y=&quot;0&quot; width=&quot;720&quot; height=&quot;250&quot; fill=&quot;url(#gp-immichproxmo0)&quot;&gt;&lt;/rect&gt;&lt;rect x=&quot;6&quot; y=&quot;6&quot; width=&quot;708&quot; height=&quot;238&quot; fill=&quot;none&quot; stroke=&quot;#2e2820&quot; stroke-width=&quot;1&quot;&gt;&lt;/rect&gt;&lt;path d=&quot;M 11,16 H 21 M 16,11 V 21 M 699,16 H 709 M 704,11 V 21 M 11,234 H 21 M 16,229 V 239 M 699,234 H 709 M 704,229 V 239&quot; stroke=&quot;#4a3f2e&quot; stroke-width=&quot;1&quot; fill=&quot;none&quot;&gt;&lt;/path&gt;&lt;text x=&quot;360&quot; y=&quot;28&quot; text-anchor=&quot;middle&quot; fill=&quot;#f2ede3&quot; font-size=&quot;13&quot; font-family=&quot;sans-serif&quot; font-weight=&quot;bold&quot;&gt;Same experience — one of them is yours&lt;/text&gt;&lt;rect x=&quot;60&quot; y=&quot;55&quot; width=&quot;270&quot; height=&quot;160&quot; rx=&quot;10&quot; fill=&quot;#16110d&quot; stroke=&quot;#d4604f&quot; stroke-width=&quot;1&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;195&quot; y=&quot;82&quot; text-anchor=&quot;middle&quot; fill=&quot;#d4604f&quot; font-size=&quot;12&quot; font-family=&quot;sans-serif&quot; font-weight=&quot;bold&quot;&gt;Google Photos&lt;/text&gt;&lt;text x=&quot;195&quot; y=&quot;112&quot; text-anchor=&quot;middle&quot; fill=&quot;#aba192&quot; font-size=&quot;11&quot; font-family=&quot;sans-serif&quot;&gt;Auto-backup, search, albums&lt;/text&gt;&lt;text x=&quot;195&quot; y=&quot;140&quot; text-anchor=&quot;middle&quot; fill=&quot;#e5ddcd&quot; font-size=&quot;11&quot; font-family=&quot;monospace&quot;&gt;$ / month, forever&lt;/text&gt;&lt;text x=&quot;195&quot; y=&quot;162&quot; text-anchor=&quot;middle&quot; fill=&quot;#aba192&quot; font-size=&quot;10&quot; font-family=&quot;sans-serif&quot;&gt;Scanned for ads &amp;amp; training&lt;/text&gt;&lt;text x=&quot;195&quot; y=&quot;182&quot; text-anchor=&quot;middle&quot; fill=&quot;#aba192&quot; font-size=&quot;10&quot; font-family=&quot;sans-serif&quot;&gt;Storage caps &amp;amp; upsells&lt;/text&gt;&lt;text x=&quot;195&quot; y=&quot;202&quot; text-anchor=&quot;middle&quot; fill=&quot;#aba192&quot; font-size=&quot;10&quot; font-family=&quot;sans-serif&quot;&gt;Lock-in by design&lt;/text&gt;&lt;rect x=&quot;390&quot; y=&quot;55&quot; width=&quot;270&quot; height=&quot;160&quot; rx=&quot;10&quot; fill=&quot;#2b2113&quot; stroke=&quot;#e8a33e&quot; stroke-width=&quot;1.5&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;525&quot; y=&quot;82&quot; text-anchor=&quot;middle&quot; fill=&quot;#e8a33e&quot; font-size=&quot;12&quot; font-family=&quot;sans-serif&quot; font-weight=&quot;bold&quot;&gt;Immich (self-hosted)&lt;/text&gt;&lt;text x=&quot;525&quot; y=&quot;112&quot; text-anchor=&quot;middle&quot; fill=&quot;#cfdfae&quot; font-size=&quot;11&quot; font-family=&quot;sans-serif&quot;&gt;Auto-backup, search, albums&lt;/text&gt;&lt;text x=&quot;525&quot; y=&quot;140&quot; text-anchor=&quot;middle&quot; fill=&quot;#e5ddcd&quot; font-size=&quot;11&quot; font-family=&quot;monospace&quot;&gt;free · your hardware&lt;/text&gt;&lt;text x=&quot;525&quot; y=&quot;162&quot; text-anchor=&quot;middle&quot; fill=&quot;#cfdfae&quot; font-size=&quot;10&quot; font-family=&quot;sans-serif&quot;&gt;Private — never leaves home&lt;/text&gt;&lt;text x=&quot;525&quot; y=&quot;182&quot; text-anchor=&quot;middle&quot; fill=&quot;#cfdfae&quot; font-size=&quot;10&quot; font-family=&quot;sans-serif&quot;&gt;Only limit is your disk&lt;/text&gt;&lt;text x=&quot;525&quot; y=&quot;202&quot; text-anchor=&quot;middle&quot; fill=&quot;#cfdfae&quot; font-size=&quot;10&quot; font-family=&quot;sans-serif&quot;&gt;Open-source, no lock-in&lt;/text&gt;&lt;text x=&quot;710&quot; y=&quot;242&quot; text-anchor=&quot;end&quot; font-size=&quot;10&quot; fill=&quot;#8a8071&quot; opacity=&quot;0.6&quot; font-family=&quot;monospace&quot;&gt;peira.dev&lt;/text&gt;&lt;/svg&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;before-you-start&quot;&gt;Before you start&lt;/h2&gt;
&lt;p&gt;Immich runs with &lt;strong&gt;Docker Compose&lt;/strong&gt;, so you need a Docker host. On Proxmox the two clean options are a dedicated &lt;strong&gt;Docker LXC&lt;/strong&gt; or a small &lt;strong&gt;VM&lt;/strong&gt; — see &lt;a href=&quot;https://peira.dev/blog/docker-proxmox-lxc-vs-vm/&quot;&gt;Docker on Proxmox: LXC vs VM&lt;/a&gt; to choose. If you’ve never written a compose file, run through &lt;a href=&quot;https://peira.dev/blog/first-docker-compose-stack/&quot;&gt;Your First Docker Compose Stack&lt;/a&gt; first; this will feel familiar afterward.&lt;/p&gt;
&lt;div class=&quot;callout callout-warning&quot; data-astro-cid-q2ml7llr&gt;&lt;div class=&quot;callout-header&quot; data-astro-cid-q2ml7llr&gt;&lt;span class=&quot;callout-icon&quot; aria-hidden=&quot;true&quot; data-astro-cid-q2ml7llr&gt;⚠️&lt;/span&gt;&lt;span class=&quot;callout-label&quot; data-astro-cid-q2ml7llr&gt;Your library belongs on durable storage, not inside the container&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;callout-body&quot; data-astro-cid-q2ml7llr&gt;&lt;p&gt;The single most important decision here is &lt;em&gt;where the photos physically live&lt;/em&gt;. Point Immich’s upload location at a &lt;a href=&quot;https://peira.dev/blog/homelab-nas-samba-shares/&quot;&gt;shared storage volume&lt;/a&gt; or a dedicated disk — never the container’s own root filesystem. Then, no matter what happens to the Immich container, your originals are safe and independently backup-able.&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;install-immich&quot;&gt;Install Immich&lt;/h2&gt;
&lt;p&gt;Immich ships an official compose file and an example environment file. You download both, change two or three values, and start it.&lt;/p&gt;
&lt;div class=&quot;step&quot; data-astro-cid-wwaqy5cd&gt;&lt;div class=&quot;step-header&quot; data-astro-cid-wwaqy5cd&gt;&lt;span class=&quot;step-num&quot; data-astro-cid-wwaqy5cd&gt;1&lt;/span&gt;&lt;span class=&quot;step-title&quot; data-astro-cid-wwaqy5cd&gt;Grab the official compose and env files&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;step-body&quot; data-astro-cid-wwaqy5cd&gt;&lt;p&gt;On your Docker host, make a folder and pull Immich’s own release files. Using the official files (rather than hand-writing your own) means you always get the correct, matching set of services.&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;
&lt;figure class=&quot;terminal&quot; data-astro-cid-kenkipkp&gt;&lt;figcaption class=&quot;terminal-bar&quot; data-astro-cid-kenkipkp&gt;&lt;span class=&quot;dot red&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot yellow&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot green&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;terminal-title&quot; data-astro-cid-kenkipkp&gt;Download Immich&amp;#39;s official files&lt;/span&gt;&lt;button class=&quot;terminal-copy&quot; type=&quot;button&quot; aria-label=&quot;Copy commands&quot; data-astro-cid-kenkipkp&gt;&lt;svg width=&quot;13&quot; height=&quot;13&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;none&quot; stroke=&quot;currentColor&quot; stroke-width=&quot;2&quot; stroke-linecap=&quot;round&quot; stroke-linejoin=&quot;round&quot; aria-hidden=&quot;true&quot; data-astro-cid-kenkipkp&gt;&lt;rect x=&quot;9&quot; y=&quot;9&quot; width=&quot;13&quot; height=&quot;13&quot; rx=&quot;2&quot; data-astro-cid-kenkipkp&gt;&lt;/rect&gt;&lt;path d=&quot;M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1&quot; data-astro-cid-kenkipkp&gt;&lt;/path&gt;&lt;/svg&gt;&lt;span class=&quot;terminal-copy-label&quot; data-astro-cid-kenkipkp&gt;Copy&lt;/span&gt;&lt;/button&gt;&lt;/figcaption&gt;&lt;pre class=&quot;terminal-body&quot; data-astro-cid-kenkipkp&gt;&lt;code data-astro-cid-kenkipkp&gt;
mkdir -p /opt/immich &amp;amp;&amp;amp; cd /opt/immich
wget -O docker-compose.yml https://github.com/immich-app/immich/releases/latest/download/docker-compose.yml
wget -O .env https://github.com/immich-app/immich/releases/latest/download/example.env
&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;
&lt;div class=&quot;step&quot; data-astro-cid-wwaqy5cd&gt;&lt;div class=&quot;step-header&quot; data-astro-cid-wwaqy5cd&gt;&lt;span class=&quot;step-num&quot; data-astro-cid-wwaqy5cd&gt;2&lt;/span&gt;&lt;span class=&quot;step-title&quot; data-astro-cid-wwaqy5cd&gt;Edit the two values that matter&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;step-body&quot; data-astro-cid-wwaqy5cd&gt;&lt;p&gt;Open &lt;code&gt;.env&lt;/code&gt; and set where photos are stored and a database password. The rest of the defaults are fine to start.&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;
&lt;figure class=&quot;terminal&quot; data-astro-cid-kenkipkp&gt;&lt;figcaption class=&quot;terminal-bar&quot; data-astro-cid-kenkipkp&gt;&lt;span class=&quot;dot red&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot yellow&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot green&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;terminal-title&quot; data-astro-cid-kenkipkp&gt;.env — the lines to change&lt;/span&gt;&lt;button class=&quot;terminal-copy&quot; type=&quot;button&quot; aria-label=&quot;Copy commands&quot; data-astro-cid-kenkipkp&gt;&lt;svg width=&quot;13&quot; height=&quot;13&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;none&quot; stroke=&quot;currentColor&quot; stroke-width=&quot;2&quot; stroke-linecap=&quot;round&quot; stroke-linejoin=&quot;round&quot; aria-hidden=&quot;true&quot; data-astro-cid-kenkipkp&gt;&lt;rect x=&quot;9&quot; y=&quot;9&quot; width=&quot;13&quot; height=&quot;13&quot; rx=&quot;2&quot; data-astro-cid-kenkipkp&gt;&lt;/rect&gt;&lt;path d=&quot;M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1&quot; data-astro-cid-kenkipkp&gt;&lt;/path&gt;&lt;/svg&gt;&lt;span class=&quot;terminal-copy-label&quot; data-astro-cid-kenkipkp&gt;Copy&lt;/span&gt;&lt;/button&gt;&lt;/figcaption&gt;&lt;pre class=&quot;terminal-body&quot; data-astro-cid-kenkipkp&gt;&lt;code data-astro-cid-kenkipkp&gt;
# Where your photos and videos will be stored.
# Point this at your shared storage or a dedicated disk:
UPLOAD_LOCATION=/tank/photos

# Set a strong database password (any long random string):
DB_PASSWORD=change-me-to-something-long-and-random

# Keep the version pinned to the release you downloaded:
IMMICH_VERSION=release
&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;
&lt;div class=&quot;step&quot; data-astro-cid-wwaqy5cd&gt;&lt;div class=&quot;step-header&quot; data-astro-cid-wwaqy5cd&gt;&lt;span class=&quot;step-num&quot; data-astro-cid-wwaqy5cd&gt;3&lt;/span&gt;&lt;span class=&quot;step-title&quot; data-astro-cid-wwaqy5cd&gt;Bring it up&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;step-body&quot; data-astro-cid-wwaqy5cd&gt;&lt;p&gt;Start the whole stack. The first launch pulls several images (server, machine-learning, PostgreSQL with the VectorChord vector extension, and Redis) so give it a few minutes.&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;
&lt;figure class=&quot;terminal&quot; data-astro-cid-kenkipkp&gt;&lt;figcaption class=&quot;terminal-bar&quot; data-astro-cid-kenkipkp&gt;&lt;span class=&quot;dot red&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot yellow&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot green&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;terminal-title&quot; data-astro-cid-kenkipkp&gt;Launch Immich&lt;/span&gt;&lt;button class=&quot;terminal-copy&quot; type=&quot;button&quot; aria-label=&quot;Copy commands&quot; data-astro-cid-kenkipkp&gt;&lt;svg width=&quot;13&quot; height=&quot;13&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;none&quot; stroke=&quot;currentColor&quot; stroke-width=&quot;2&quot; stroke-linecap=&quot;round&quot; stroke-linejoin=&quot;round&quot; aria-hidden=&quot;true&quot; data-astro-cid-kenkipkp&gt;&lt;rect x=&quot;9&quot; y=&quot;9&quot; width=&quot;13&quot; height=&quot;13&quot; rx=&quot;2&quot; data-astro-cid-kenkipkp&gt;&lt;/rect&gt;&lt;path d=&quot;M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1&quot; data-astro-cid-kenkipkp&gt;&lt;/path&gt;&lt;/svg&gt;&lt;span class=&quot;terminal-copy-label&quot; data-astro-cid-kenkipkp&gt;Copy&lt;/span&gt;&lt;/button&gt;&lt;/figcaption&gt;&lt;pre class=&quot;terminal-body&quot; data-astro-cid-kenkipkp&gt;&lt;code data-astro-cid-kenkipkp&gt;
docker compose up -d
&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;
&lt;div class=&quot;callout callout-info&quot; data-astro-cid-q2ml7llr&gt;&lt;div class=&quot;callout-header&quot; data-astro-cid-q2ml7llr&gt;&lt;span class=&quot;callout-icon&quot; aria-hidden=&quot;true&quot; data-astro-cid-q2ml7llr&gt;ℹ️&lt;/span&gt;&lt;span class=&quot;callout-label&quot; data-astro-cid-q2ml7llr&gt;What just started&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;callout-body&quot; data-astro-cid-q2ml7llr&gt;&lt;p&gt;Immich isn’t a single container — it’s a small stack: the main server, a machine-learning service (faces and smart search), a Redis cache, and a PostgreSQL database using the VectorChord extension for fast similarity search. Recent Immich versions migrated to VectorChord from the older pgvecto.rs extension, which is handled automatically by the official compose file. You don’t have to manage any of this — compose does.&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;
&lt;div class=&quot;step&quot; data-astro-cid-wwaqy5cd&gt;&lt;div class=&quot;step-header&quot; data-astro-cid-wwaqy5cd&gt;&lt;span class=&quot;step-num&quot; data-astro-cid-wwaqy5cd&gt;4&lt;/span&gt;&lt;span class=&quot;step-title&quot; data-astro-cid-wwaqy5cd&gt;Open the web UI and create the admin account&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;step-body&quot; data-astro-cid-wwaqy5cd&gt;&lt;p&gt;Browse to &lt;code&gt;&lt;a href=&quot;http://DOCKER-HOST-IP:2283&quot;&gt;http://DOCKER-HOST-IP:2283&lt;/a&gt;&lt;/code&gt;. The first account you create is the administrator. Set it up, and you’re looking at an empty (for now) photo timeline.&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;
&lt;svg viewBox=&quot;0 0 720 320&quot; xmlns=&quot;http://www.w3.org/2000/svg&quot; style=&quot;width:100%;border-radius:12px;background:#0e0b08;margin:2rem 0&quot;&gt;&lt;defs&gt;&lt;pattern id=&quot;gp-immichproxmo1&quot; width=&quot;20&quot; height=&quot;20&quot; patternUnits=&quot;userSpaceOnUse&quot;&gt;&lt;path d=&quot;M 20 0 H 0 V 20&quot; fill=&quot;none&quot; stroke=&quot;#221c15&quot; stroke-width=&quot;1&quot;&gt;&lt;/path&gt;&lt;/pattern&gt;&lt;/defs&gt;&lt;rect x=&quot;0&quot; y=&quot;0&quot; width=&quot;720&quot; height=&quot;320&quot; fill=&quot;url(#gp-immichproxmo1)&quot;&gt;&lt;/rect&gt;&lt;rect x=&quot;6&quot; y=&quot;6&quot; width=&quot;708&quot; height=&quot;308&quot; fill=&quot;none&quot; stroke=&quot;#2e2820&quot; stroke-width=&quot;1&quot;&gt;&lt;/rect&gt;&lt;path d=&quot;M 11,16 H 21 M 16,11 V 21 M 699,16 H 709 M 704,11 V 21 M 11,304 H 21 M 16,299 V 309 M 699,304 H 709 M 704,299 V 309&quot; stroke=&quot;#4a3f2e&quot; stroke-width=&quot;1&quot; fill=&quot;none&quot;&gt;&lt;/path&gt;&lt;rect x=&quot;0&quot; y=&quot;0&quot; width=&quot;720&quot; height=&quot;42&quot; fill=&quot;#141009&quot;&gt;&lt;/rect&gt;&lt;circle cx=&quot;22&quot; cy=&quot;21&quot; r=&quot;5&quot; fill=&quot;#d4604f&quot;&gt;&lt;/circle&gt;&lt;circle cx=&quot;40&quot; cy=&quot;21&quot; r=&quot;5&quot; fill=&quot;#d9a13d&quot;&gt;&lt;/circle&gt;&lt;circle cx=&quot;58&quot; cy=&quot;21&quot; r=&quot;5&quot; fill=&quot;#8fb573&quot;&gt;&lt;/circle&gt;&lt;text x=&quot;360&quot; y=&quot;26&quot; text-anchor=&quot;middle&quot; fill=&quot;#8a8071&quot; font-size=&quot;11&quot; font-family=&quot;monospace&quot;&gt;&lt;a href=&quot;http://immich.local:2283&quot;&gt;http://immich.local:2283&lt;/a&gt;&lt;/text&gt;&lt;rect x=&quot;0&quot; y=&quot;42&quot; width=&quot;150&quot; height=&quot;278&quot; fill=&quot;#120e0a&quot;&gt;&lt;/rect&gt;&lt;circle cx=&quot;30&quot; cy=&quot;72&quot; r=&quot;9&quot; fill=&quot;#52719c&quot;&gt;&lt;/circle&gt;&lt;text x=&quot;50&quot; y=&quot;76&quot; fill=&quot;#e5ddcd&quot; font-size=&quot;12&quot; font-family=&quot;sans-serif&quot; font-weight=&quot;bold&quot;&gt;immich&lt;/text&gt;&lt;text x=&quot;24&quot; y=&quot;118&quot; fill=&quot;#c7d2fe&quot; font-size=&quot;11&quot; font-family=&quot;sans-serif&quot;&gt;Photos&lt;/text&gt;&lt;text x=&quot;24&quot; y=&quot;146&quot; fill=&quot;#aba192&quot; font-size=&quot;11&quot; font-family=&quot;sans-serif&quot;&gt;Explore&lt;/text&gt;&lt;text x=&quot;24&quot; y=&quot;174&quot; fill=&quot;#aba192&quot; font-size=&quot;11&quot; font-family=&quot;sans-serif&quot;&gt;Albums&lt;/text&gt;&lt;text x=&quot;24&quot; y=&quot;202&quot; fill=&quot;#aba192&quot; font-size=&quot;11&quot; font-family=&quot;sans-serif&quot;&gt;People&lt;/text&gt;&lt;text x=&quot;24&quot; y=&quot;230&quot; fill=&quot;#aba192&quot; font-size=&quot;11&quot; font-family=&quot;sans-serif&quot;&gt;Places&lt;/text&gt;&lt;text x=&quot;176&quot; y=&quot;76&quot; fill=&quot;#e5ddcd&quot; font-size=&quot;13&quot; font-family=&quot;sans-serif&quot; font-weight=&quot;bold&quot;&gt;Photos&lt;/text&gt;&lt;text x=&quot;176&quot; y=&quot;96&quot; fill=&quot;#8a8071&quot; font-size=&quot;10&quot; font-family=&quot;sans-serif&quot;&gt;July 2026&lt;/text&gt;&lt;rect x=&quot;176&quot; y=&quot;106&quot; width=&quot;80&quot; height=&quot;80&quot; rx=&quot;4&quot; fill=&quot;#1f1913&quot;&gt;&lt;/rect&gt;&lt;rect x=&quot;264&quot; y=&quot;106&quot; width=&quot;80&quot; height=&quot;80&quot; rx=&quot;4&quot; fill=&quot;#211b14&quot;&gt;&lt;/rect&gt;&lt;rect x=&quot;352&quot; y=&quot;106&quot; width=&quot;80&quot; height=&quot;80&quot; rx=&quot;4&quot; fill=&quot;#1f1913&quot;&gt;&lt;/rect&gt;&lt;rect x=&quot;440&quot; y=&quot;106&quot; width=&quot;80&quot; height=&quot;80&quot; rx=&quot;4&quot; fill=&quot;#251e16&quot;&gt;&lt;/rect&gt;&lt;rect x=&quot;528&quot; y=&quot;106&quot; width=&quot;80&quot; height=&quot;80&quot; rx=&quot;4&quot; fill=&quot;#1f1913&quot;&gt;&lt;/rect&gt;&lt;rect x=&quot;616&quot; y=&quot;106&quot; width=&quot;80&quot; height=&quot;80&quot; rx=&quot;4&quot; fill=&quot;#211b14&quot;&gt;&lt;/rect&gt;&lt;rect x=&quot;176&quot; y=&quot;194&quot; width=&quot;80&quot; height=&quot;80&quot; rx=&quot;4&quot; fill=&quot;#251e16&quot;&gt;&lt;/rect&gt;&lt;rect x=&quot;264&quot; y=&quot;194&quot; width=&quot;80&quot; height=&quot;80&quot; rx=&quot;4&quot; fill=&quot;#1f1913&quot;&gt;&lt;/rect&gt;&lt;rect x=&quot;352&quot; y=&quot;194&quot; width=&quot;80&quot; height=&quot;80&quot; rx=&quot;4&quot; fill=&quot;#211b14&quot;&gt;&lt;/rect&gt;&lt;rect x=&quot;440&quot; y=&quot;194&quot; width=&quot;80&quot; height=&quot;80&quot; rx=&quot;4&quot; fill=&quot;#1f1913&quot;&gt;&lt;/rect&gt;&lt;rect x=&quot;528&quot; y=&quot;194&quot; width=&quot;80&quot; height=&quot;80&quot; rx=&quot;4&quot; fill=&quot;#251e16&quot;&gt;&lt;/rect&gt;&lt;rect x=&quot;616&quot; y=&quot;194&quot; width=&quot;80&quot; height=&quot;80&quot; rx=&quot;4&quot; fill=&quot;#1f1913&quot;&gt;&lt;/rect&gt;&lt;rect x=&quot;176&quot; y=&quot;286&quot; width=&quot;200&quot; height=&quot;22&quot; rx=&quot;11&quot; fill=&quot;#52719c&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;276&quot; y=&quot;301&quot; text-anchor=&quot;middle&quot; fill=&quot;#e0e7ff&quot; font-size=&quot;10&quot; font-family=&quot;sans-serif&quot;&gt;🔍  “beach at sunset”&lt;/text&gt;&lt;/svg&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;the-part-that-hooks-you-phone-auto-backup&quot;&gt;The part that hooks you: phone auto-backup&lt;/h2&gt;
&lt;p&gt;Install the &lt;strong&gt;Immich&lt;/strong&gt; app from the App Store or Google Play. Open it, enter your server URL (&lt;code&gt;http://YOUR-SERVER-IP:2283&lt;/code&gt; on your LAN, or a &lt;a href=&quot;https://peira.dev/blog/tailscale-subnet-router/&quot;&gt;Tailscale&lt;/a&gt; address when you’re out), and log in. Turn on backup, pick which albums to include, and that’s it — from now on every photo you take uploads to &lt;em&gt;your&lt;/em&gt; server in the background.&lt;/p&gt;
&lt;div class=&quot;callout callout-tip&quot; data-astro-cid-q2ml7llr&gt;&lt;div class=&quot;callout-header&quot; data-astro-cid-q2ml7llr&gt;&lt;span class=&quot;callout-icon&quot; aria-hidden=&quot;true&quot; data-astro-cid-q2ml7llr&gt;💡&lt;/span&gt;&lt;span class=&quot;callout-label&quot; data-astro-cid-q2ml7llr&gt;Reach it from anywhere without opening ports&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;callout-body&quot; data-astro-cid-q2ml7llr&gt;&lt;p&gt;Don’t port-forward Immich to the internet. Instead, install &lt;a href=&quot;https://peira.dev/blog/tailscale-subnet-router/&quot;&gt;Tailscale&lt;/a&gt; on your phone and your homelab. Your phone then reaches Immich over an encrypted private tunnel, so background backup works on cellular exactly as it does at home — with none of the exposure of a public port.&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;
&lt;p&gt;Once your library is in, Immich’s AI quietly indexes it: it groups faces so you can name people, recognises objects and scenes so text search actually works, and plots geotagged shots on a map. It runs entirely on your hardware — no cloud, no scanning, no ads.&lt;/p&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;keep-your-memories-safe&quot;&gt;Keep your memories safe&lt;/h2&gt;
&lt;p&gt;Immich holds irreplaceable data, so treat it like it. Because your originals live on a separate volume, they’re easy to protect:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Snapshot and back up the Docker host with &lt;a href=&quot;https://peira.dev/blog/proxmox-backup-server/&quot;&gt;Proxmox Backup Server&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;Keep an independent copy of the &lt;code&gt;UPLOAD_LOCATION&lt;/code&gt; folder — the 3-2-1 rule applies to photos more than anything.&lt;/li&gt;
&lt;li&gt;Read release notes before a major version jump, and update with a simple &lt;code&gt;docker compose pull &amp;amp;&amp;amp; docker compose up -d&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;You’ve just replaced a monthly bill with something you own outright, and your photos are finally home. Add it to your &lt;a href=&quot;https://peira.dev/blog/homepage-dashboard/&quot;&gt;Homepage dashboard&lt;/a&gt; and admire the growing stack — then head back to the &lt;a href=&quot;https://peira.dev/start/&quot;&gt;roadmap&lt;/a&gt; for the next build.&lt;/p&gt;
&lt;hr/&gt;
&lt;p&gt;&lt;strong&gt;Related posts:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/first-docker-compose-stack/&quot;&gt;Your First Docker Compose Stack&lt;/a&gt; — the deployment pattern Immich uses&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/homelab-nas-samba-shares/&quot;&gt;Homelab NAS with Samba Shares&lt;/a&gt; — where to store a growing photo library&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/proxmox-storage-guide/&quot;&gt;Proxmox Storage Guide: LVM, ZFS, NFS, and Ceph&lt;/a&gt; — plan disk space for years of photos&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/proxmox-backup-server/&quot;&gt;Proxmox Backup Server&lt;/a&gt; — back up the library you now own&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/proxmox-create-lxc/&quot;&gt;Creating Your First LXC Container&lt;/a&gt; — an alternative host for Immich&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/tailscale-subnet-router/&quot;&gt;Tailscale Subnet Router&lt;/a&gt; — reach your photos securely from your phone&lt;/li&gt;
&lt;/ul&gt;</content:encoded><category>self-hosted</category><category>automation</category></item><item><title>Make Your AI Agents Read the Docs First, Every Time</title><link>https://peira.dev/blog/second-brain-ai-begin-end-skills/</link><guid isPermaLink="true">https://peira.dev/blog/second-brain-ai-begin-end-skills/</guid><description>Give your AI agents a revision-labeled cold start against your knowledge vault — verify the canonical commit, load only the pages the task needs, fail closed on mismatch, and persist outcomes through the controlled writer.</description><pubDate>Wed, 15 Jul 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Here’s the payoff the whole series has been building toward: an AI agent that starts work &lt;em&gt;already knowing your lab&lt;/em&gt; — not guessing from training data, but reading your current, authoritative brain. The trick is a disciplined cold start, because an agent confidently acting on stale context is worse than no agent at all.&lt;/p&gt;
&lt;div class=&quot;callout callout-tip&quot; data-astro-cid-q2ml7llr&gt;&lt;div class=&quot;callout-header&quot; data-astro-cid-q2ml7llr&gt;&lt;span class=&quot;callout-icon&quot; aria-hidden=&quot;true&quot; data-astro-cid-q2ml7llr&gt;💡&lt;/span&gt;&lt;span class=&quot;callout-label&quot; data-astro-cid-q2ml7llr&gt;The principle&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;callout-body&quot; data-astro-cid-q2ml7llr&gt;&lt;p&gt;An agent gets a &lt;strong&gt;revision-labeled cold start&lt;/strong&gt;: it verifies the vault’s canonical commit, loads only the pages the task needs, and &lt;strong&gt;fails closed&lt;/strong&gt; if it can’t confirm it’s reading current state. It persists outcomes through the same &lt;a href=&quot;https://peira.dev/blog/second-brain-controlled-write-lock/&quot;&gt;controlled-write contract&lt;/a&gt; a human uses. Two skills bracket the work: &lt;code&gt;begin&lt;/code&gt; and &lt;code&gt;end&lt;/code&gt;.&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;
&lt;div class=&quot;callout callout-warning&quot; data-astro-cid-q2ml7llr&gt;&lt;div class=&quot;callout-header&quot; data-astro-cid-q2ml7llr&gt;&lt;span class=&quot;callout-icon&quot; aria-hidden=&quot;true&quot; data-astro-cid-q2ml7llr&gt;⚠️&lt;/span&gt;&lt;span class=&quot;callout-label&quot; data-astro-cid-q2ml7llr&gt;First: make these values your own&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;callout-body&quot; data-astro-cid-q2ml7llr&gt;&lt;p&gt;Paths like &lt;strong&gt;&lt;code&gt;~/wiki&lt;/code&gt;&lt;/strong&gt; are examples — use your own vault location. &lt;code&gt;begin&lt;/code&gt;/&lt;code&gt;end&lt;/code&gt; are procedures you adapt to your own agent tool, not commands to copy blindly.&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;the-begin-flow&quot;&gt;The begin flow&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;begin&lt;/code&gt; is a cold-start procedure, not a data dump. It reads the &lt;em&gt;smallest&lt;/em&gt; current context that gives global awareness for the task.&lt;/p&gt;
&lt;svg viewBox=&quot;0 0 760 240&quot; xmlns=&quot;http://www.w3.org/2000/svg&quot; style=&quot;width:100%;border-radius:12px;background:#1c1712;margin:1.5rem 0;border:1px solid #443a2b&quot;&gt;&lt;defs&gt;&lt;pattern id=&quot;gp-secondbraina0&quot; width=&quot;20&quot; height=&quot;20&quot; patternUnits=&quot;userSpaceOnUse&quot;&gt;&lt;path d=&quot;M 20 0 H 0 V 20&quot; fill=&quot;none&quot; stroke=&quot;#221c15&quot; stroke-width=&quot;1&quot;&gt;&lt;/path&gt;&lt;/pattern&gt;&lt;/defs&gt;&lt;rect x=&quot;0&quot; y=&quot;0&quot; width=&quot;760&quot; height=&quot;240&quot; fill=&quot;url(#gp-secondbraina0)&quot;&gt;&lt;/rect&gt;&lt;rect x=&quot;6&quot; y=&quot;6&quot; width=&quot;748&quot; height=&quot;228&quot; fill=&quot;none&quot; stroke=&quot;#2e2820&quot; stroke-width=&quot;1&quot;&gt;&lt;/rect&gt;&lt;path d=&quot;M 11,16 H 21 M 16,11 V 21 M 739,16 H 749 M 744,11 V 21 M 11,224 H 21 M 16,219 V 229 M 739,224 H 749 M 744,219 V 229&quot; stroke=&quot;#4a3f2e&quot; stroke-width=&quot;1&quot; fill=&quot;none&quot;&gt;&lt;/path&gt;&lt;text x=&quot;380&quot; y=&quot;28&quot; text-anchor=&quot;middle&quot; fill=&quot;#f2ede3&quot; font-size=&quot;13&quot; font-family=&quot;monospace&quot; font-weight=&quot;bold&quot;&gt;begin — a revision-labeled cold start&lt;/text&gt;&lt;rect x=&quot;15&quot; y=&quot;55&quot; width=&quot;110&quot; height=&quot;60&quot; rx=&quot;10&quot; fill=&quot;#262019&quot; stroke=&quot;#e8a33e&quot; stroke-width=&quot;1.8&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;70&quot; y=&quot;81&quot; text-anchor=&quot;middle&quot; fill=&quot;#e8a33e&quot; font-size=&quot;10&quot; font-family=&quot;monospace&quot; font-weight=&quot;bold&quot;&gt;1. status&lt;/text&gt;&lt;text x=&quot;70&quot; y=&quot;98&quot; text-anchor=&quot;middle&quot; fill=&quot;#8a8071&quot; font-size=&quot;8&quot; font-family=&quot;monospace&quot;&gt;read + record&lt;/text&gt;&lt;text x=&quot;70&quot; y=&quot;110&quot; text-anchor=&quot;middle&quot; fill=&quot;#8a8071&quot; font-size=&quot;8&quot; font-family=&quot;monospace&quot;&gt;HEAD commit&lt;/text&gt;&lt;rect x=&quot;145&quot; y=&quot;55&quot; width=&quot;130&quot; height=&quot;60&quot; rx=&quot;10&quot; fill=&quot;#262019&quot; stroke=&quot;#6f9bd1&quot; stroke-width=&quot;1.5&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;210&quot; y=&quot;81&quot; text-anchor=&quot;middle&quot; fill=&quot;#6f9bd1&quot; font-size=&quot;10&quot; font-family=&quot;monospace&quot; font-weight=&quot;bold&quot;&gt;2. digest&lt;/text&gt;&lt;text x=&quot;210&quot; y=&quot;98&quot; text-anchor=&quot;middle&quot; fill=&quot;#8a8071&quot; font-size=&quot;8&quot; font-family=&quot;monospace&quot;&gt;one file: regions,&lt;/text&gt;&lt;text x=&quot;210&quot; y=&quot;110&quot; text-anchor=&quot;middle&quot; fill=&quot;#8a8071&quot; font-size=&quot;8&quot; font-family=&quot;monospace&quot;&gt;catalog + RESUME&lt;/text&gt;&lt;rect x=&quot;295&quot; y=&quot;55&quot; width=&quot;130&quot; height=&quot;60&quot; rx=&quot;10&quot; fill=&quot;#262019&quot; stroke=&quot;#6f9bd1&quot; stroke-width=&quot;1.5&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;360&quot; y=&quot;81&quot; text-anchor=&quot;middle&quot; fill=&quot;#6f9bd1&quot; font-size=&quot;10&quot; font-family=&quot;monospace&quot; font-weight=&quot;bold&quot;&gt;3. delta check&lt;/text&gt;&lt;text x=&quot;360&quot; y=&quot;98&quot; text-anchor=&quot;middle&quot; fill=&quot;#8a8071&quot; font-size=&quot;8&quot; font-family=&quot;monospace&quot;&gt;git diff since last&lt;/text&gt;&lt;text x=&quot;360&quot; y=&quot;110&quot; text-anchor=&quot;middle&quot; fill=&quot;#8a8071&quot; font-size=&quot;8&quot; font-family=&quot;monospace&quot;&gt;session → load changed&lt;/text&gt;&lt;rect x=&quot;445&quot; y=&quot;55&quot; width=&quot;120&quot; height=&quot;60&quot; rx=&quot;10&quot; fill=&quot;#262019&quot; stroke=&quot;#6f9bd1&quot; stroke-width=&quot;1.5&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;505&quot; y=&quot;81&quot; text-anchor=&quot;middle&quot; fill=&quot;#6f9bd1&quot; font-size=&quot;10&quot; font-family=&quot;monospace&quot; font-weight=&quot;bold&quot;&gt;4. topics&lt;/text&gt;&lt;text x=&quot;505&quot; y=&quot;98&quot; text-anchor=&quot;middle&quot; fill=&quot;#8a8071&quot; font-size=&quot;8&quot; font-family=&quot;monospace&quot;&gt;only pages&lt;/text&gt;&lt;text x=&quot;505&quot; y=&quot;110&quot; text-anchor=&quot;middle&quot; fill=&quot;#8a8071&quot; font-size=&quot;8&quot; font-family=&quot;monospace&quot;&gt;task needs&lt;/text&gt;&lt;rect x=&quot;585&quot; y=&quot;55&quot; width=&quot;110&quot; height=&quot;60&quot; rx=&quot;10&quot; fill=&quot;#262019&quot; stroke=&quot;#e8a33e&quot; stroke-width=&quot;1.8&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;640&quot; y=&quot;81&quot; text-anchor=&quot;middle&quot; fill=&quot;#e8a33e&quot; font-size=&quot;10&quot; font-family=&quot;monospace&quot; font-weight=&quot;bold&quot;&gt;5. brief&lt;/text&gt;&lt;text x=&quot;640&quot; y=&quot;98&quot; text-anchor=&quot;middle&quot; fill=&quot;#8a8071&quot; font-size=&quot;8&quot; font-family=&quot;monospace&quot;&gt;commit +&lt;/text&gt;&lt;text x=&quot;640&quot; y=&quot;110&quot; text-anchor=&quot;middle&quot; fill=&quot;#8a8071&quot; font-size=&quot;8&quot; font-family=&quot;monospace&quot;&gt;state + next&lt;/text&gt;&lt;line x1=&quot;125&quot; y1=&quot;85&quot; x2=&quot;145&quot; y2=&quot;85&quot; stroke=&quot;#6f9bd1&quot; stroke-width=&quot;1.5&quot;&gt;&lt;/line&gt;&lt;line x1=&quot;275&quot; y1=&quot;85&quot; x2=&quot;295&quot; y2=&quot;85&quot; stroke=&quot;#6f9bd1&quot; stroke-width=&quot;1.5&quot;&gt;&lt;/line&gt;&lt;line x1=&quot;425&quot; y1=&quot;85&quot; x2=&quot;445&quot; y2=&quot;85&quot; stroke=&quot;#6f9bd1&quot; stroke-width=&quot;1.5&quot;&gt;&lt;/line&gt;&lt;line x1=&quot;565&quot; y1=&quot;85&quot; x2=&quot;585&quot; y2=&quot;85&quot; stroke=&quot;#6f9bd1&quot; stroke-width=&quot;1.5&quot;&gt;&lt;/line&gt;&lt;rect x=&quot;145&quot; y=&quot;155&quot; width=&quot;420&quot; height=&quot;34&quot; rx=&quot;8&quot; fill=&quot;#262019&quot; stroke=&quot;#d4604f&quot; stroke-width=&quot;1.5&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;355&quot; y=&quot;177&quot; text-anchor=&quot;middle&quot; fill=&quot;#d4604f&quot; font-size=&quot;9&quot; font-family=&quot;monospace&quot;&gt;any index ≠ HEAD → FAIL CLOSED, stop&lt;/text&gt;&lt;text x=&quot;750&quot; y=&quot;232&quot; text-anchor=&quot;end&quot; font-size=&quot;10&quot; fill=&quot;#8a8071&quot; opacity=&quot;0.6&quot; font-family=&quot;monospace&quot;&gt;peira.dev&lt;/text&gt;&lt;/svg&gt;
&lt;div class=&quot;step&quot; data-astro-cid-wwaqy5cd&gt;&lt;div class=&quot;step-header&quot; data-astro-cid-wwaqy5cd&gt;&lt;span class=&quot;step-num&quot; data-astro-cid-wwaqy5cd&gt;1&lt;/span&gt;&lt;span class=&quot;step-title&quot; data-astro-cid-wwaqy5cd&gt;Record the canonical commit&lt;/span&gt;&lt;span class=&quot;step-time&quot; data-astro-cid-wwaqy5cd&gt;1 min&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;step-body&quot; data-astro-cid-wwaqy5cd&gt;&lt;p&gt;The first thing &lt;code&gt;begin&lt;/code&gt; does is read and report the vault’s current commit. Everything the agent loads afterward is labeled with it, so its whole working context is pinned to one revision.&lt;/p&gt;&lt;figure class=&quot;terminal&quot; data-astro-cid-kenkipkp&gt;&lt;figcaption class=&quot;terminal-bar&quot; data-astro-cid-kenkipkp&gt;&lt;span class=&quot;dot red&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot yellow&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot green&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;terminal-title&quot; data-astro-cid-kenkipkp&gt;begin — step 1&lt;/span&gt;&lt;button class=&quot;terminal-copy&quot; type=&quot;button&quot; aria-label=&quot;Copy commands&quot; data-astro-cid-kenkipkp&gt;&lt;svg width=&quot;13&quot; height=&quot;13&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;none&quot; stroke=&quot;currentColor&quot; stroke-width=&quot;2&quot; stroke-linecap=&quot;round&quot; stroke-linejoin=&quot;round&quot; aria-hidden=&quot;true&quot; data-astro-cid-kenkipkp&gt;&lt;rect x=&quot;9&quot; y=&quot;9&quot; width=&quot;13&quot; height=&quot;13&quot; rx=&quot;2&quot; data-astro-cid-kenkipkp&gt;&lt;/rect&gt;&lt;path d=&quot;M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1&quot; data-astro-cid-kenkipkp&gt;&lt;/path&gt;&lt;/svg&gt;&lt;span class=&quot;terminal-copy-label&quot; data-astro-cid-kenkipkp&gt;Copy&lt;/span&gt;&lt;/button&gt;&lt;/figcaption&gt;&lt;pre class=&quot;terminal-body&quot; data-astro-cid-kenkipkp&gt;&lt;code data-astro-cid-kenkipkp&gt;
git -C ~/wiki rev-parse HEAD        # record this; label all reads with it
git -C ~/wiki status --porcelain    # note any unexplained working-tree state
&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;&lt;/div&gt;
&lt;div class=&quot;step&quot; data-astro-cid-wwaqy5cd&gt;&lt;div class=&quot;step-header&quot; data-astro-cid-wwaqy5cd&gt;&lt;span class=&quot;step-num&quot; data-astro-cid-wwaqy5cd&gt;2&lt;/span&gt;&lt;span class=&quot;step-title&quot; data-astro-cid-wwaqy5cd&gt;Read the context digest — one file for global orientation&lt;/span&gt;&lt;span class=&quot;step-time&quot; data-astro-cid-wwaqy5cd&gt;2 min&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;step-body&quot; data-astro-cid-wwaqy5cd&gt;&lt;p&gt;Instead of reading AGENTS.md, a manifest, and a home page separately, the current system uses a single &lt;strong&gt;generated&lt;/strong&gt; file: &lt;code&gt;agents/context-digest.md&lt;/code&gt;. It contains region summaries, key guardrails, a RESUME pointer, and a full card-catalog of every vault page — all stamped with the canonical commit it was generated from.&lt;/p&gt;&lt;p&gt;One read gives the agent a map of the whole brain without loading the whole brain. The digest is not a replacement for canonical topic pages; it’s an index that tells you which ones to open next.&lt;/p&gt;&lt;figure class=&quot;terminal&quot; data-astro-cid-kenkipkp&gt;&lt;figcaption class=&quot;terminal-bar&quot; data-astro-cid-kenkipkp&gt;&lt;span class=&quot;dot red&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot yellow&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot green&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;terminal-title&quot; data-astro-cid-kenkipkp&gt;begin — step 2&lt;/span&gt;&lt;button class=&quot;terminal-copy&quot; type=&quot;button&quot; aria-label=&quot;Copy commands&quot; data-astro-cid-kenkipkp&gt;&lt;svg width=&quot;13&quot; height=&quot;13&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;none&quot; stroke=&quot;currentColor&quot; stroke-width=&quot;2&quot; stroke-linecap=&quot;round&quot; stroke-linejoin=&quot;round&quot; aria-hidden=&quot;true&quot; data-astro-cid-kenkipkp&gt;&lt;rect x=&quot;9&quot; y=&quot;9&quot; width=&quot;13&quot; height=&quot;13&quot; rx=&quot;2&quot; data-astro-cid-kenkipkp&gt;&lt;/rect&gt;&lt;path d=&quot;M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1&quot; data-astro-cid-kenkipkp&gt;&lt;/path&gt;&lt;/svg&gt;&lt;span class=&quot;terminal-copy-label&quot; data-astro-cid-kenkipkp&gt;Copy&lt;/span&gt;&lt;/button&gt;&lt;/figcaption&gt;&lt;pre class=&quot;terminal-body&quot; data-astro-cid-kenkipkp&gt;&lt;code data-astro-cid-kenkipkp&gt;
# One file covers what used to require three separate reads
cat ~/wiki/agents/context-digest.md
# Check the &amp;quot;Canonical commit:&amp;quot; line in the digest matches step 1&amp;#39;s HEAD
&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;div class=&quot;callout callout-tip&quot; data-astro-cid-q2ml7llr&gt;&lt;div class=&quot;callout-header&quot; data-astro-cid-q2ml7llr&gt;&lt;span class=&quot;callout-icon&quot; aria-hidden=&quot;true&quot; data-astro-cid-q2ml7llr&gt;💡&lt;/span&gt;&lt;span class=&quot;callout-label&quot; data-astro-cid-q2ml7llr&gt;The digest is generated output, not a source of truth&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;callout-body&quot; data-astro-cid-q2ml7llr&gt;&lt;p&gt;The canonical commit line inside the digest lets you verify it was generated from the same revision you just read. If the digest’s commit doesn’t match HEAD, it’s stale — read it as a discovery aid, but rely on the canonical topic pages for decisions.&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;&lt;/div&gt;&lt;/div&gt;
&lt;div class=&quot;step&quot; data-astro-cid-wwaqy5cd&gt;&lt;div class=&quot;step-header&quot; data-astro-cid-wwaqy5cd&gt;&lt;span class=&quot;step-num&quot; data-astro-cid-wwaqy5cd&gt;3&lt;/span&gt;&lt;span class=&quot;step-title&quot; data-astro-cid-wwaqy5cd&gt;Delta check — only read what changed&lt;/span&gt;&lt;span class=&quot;step-time&quot; data-astro-cid-wwaqy5cd&gt;1 min&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;step-body&quot; data-astro-cid-wwaqy5cd&gt;&lt;p&gt;The digest records the commit from the last &lt;code&gt;/end&lt;/code&gt;. Run a quick &lt;code&gt;git diff&lt;/code&gt; to see what actually changed since then. If nothing changed, you can skip re-reading the pages you already know — only load the changed canonical pages. This keeps cold starts cheap even as the vault grows.&lt;/p&gt;&lt;figure class=&quot;terminal&quot; data-astro-cid-kenkipkp&gt;&lt;figcaption class=&quot;terminal-bar&quot; data-astro-cid-kenkipkp&gt;&lt;span class=&quot;dot red&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot yellow&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot green&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;terminal-title&quot; data-astro-cid-kenkipkp&gt;begin — step 3 (delta check)&lt;/span&gt;&lt;button class=&quot;terminal-copy&quot; type=&quot;button&quot; aria-label=&quot;Copy commands&quot; data-astro-cid-kenkipkp&gt;&lt;svg width=&quot;13&quot; height=&quot;13&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;none&quot; stroke=&quot;currentColor&quot; stroke-width=&quot;2&quot; stroke-linecap=&quot;round&quot; stroke-linejoin=&quot;round&quot; aria-hidden=&quot;true&quot; data-astro-cid-kenkipkp&gt;&lt;rect x=&quot;9&quot; y=&quot;9&quot; width=&quot;13&quot; height=&quot;13&quot; rx=&quot;2&quot; data-astro-cid-kenkipkp&gt;&lt;/rect&gt;&lt;path d=&quot;M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1&quot; data-astro-cid-kenkipkp&gt;&lt;/path&gt;&lt;/svg&gt;&lt;span class=&quot;terminal-copy-label&quot; data-astro-cid-kenkipkp&gt;Copy&lt;/span&gt;&lt;/button&gt;&lt;/figcaption&gt;&lt;pre class=&quot;terminal-body&quot; data-astro-cid-kenkipkp&gt;&lt;code data-astro-cid-kenkipkp&gt;
# Extract last-session commit from the digest&amp;#39;s &amp;quot;Canonical commit:&amp;quot; line
LAST=$(grep &amp;#39;Canonical commit:&amp;#39; ~/wiki/agents/context-digest.md | grep -oE &amp;#39;[0-9a-f]{7,40}&amp;#39;)
git -C ~/wiki diff --stat &amp;quot;$LAST&amp;quot;..HEAD -- . &amp;#39;:!.obsidian&amp;#39;
# If the output is empty, the digest is still current — no re-read needed
&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;&lt;/div&gt;
&lt;div class=&quot;step&quot; data-astro-cid-wwaqy5cd&gt;&lt;div class=&quot;step-header&quot; data-astro-cid-wwaqy5cd&gt;&lt;span class=&quot;step-num&quot; data-astro-cid-wwaqy5cd&gt;4&lt;/span&gt;&lt;span class=&quot;step-title&quot; data-astro-cid-wwaqy5cd&gt;Load only the pages the task needs&lt;/span&gt;&lt;span class=&quot;step-time&quot; data-astro-cid-wwaqy5cd&gt;3 min&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;step-body&quot; data-astro-cid-wwaqy5cd&gt;&lt;p&gt;From the card-catalog in the digest, load the smallest connected set for the task: the relevant hub and canonical topic owner, linked decisions and runbooks that constrain the work, and dated evidence only when the topic page doesn’t settle a question.&lt;/p&gt;&lt;p&gt;If the vault has a semantic search index, use it to discover relevant pages — but use a &lt;strong&gt;two-stage approach&lt;/strong&gt;: first run an unfiltered search to identify the &lt;em&gt;region&lt;/em&gt; (the brain area the task lives in), then re-run scoped to that region for higher-quality hits. A broad unfiltered search across a large vault surfaces noise; knowing the region first cuts it dramatically.&lt;/p&gt;&lt;figure class=&quot;terminal&quot; data-astro-cid-kenkipkp&gt;&lt;figcaption class=&quot;terminal-bar&quot; data-astro-cid-kenkipkp&gt;&lt;span class=&quot;dot red&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot yellow&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot green&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;terminal-title&quot; data-astro-cid-kenkipkp&gt;begin — step 4 (two-stage semantic search)&lt;/span&gt;&lt;button class=&quot;terminal-copy&quot; type=&quot;button&quot; aria-label=&quot;Copy commands&quot; data-astro-cid-kenkipkp&gt;&lt;svg width=&quot;13&quot; height=&quot;13&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;none&quot; stroke=&quot;currentColor&quot; stroke-width=&quot;2&quot; stroke-linecap=&quot;round&quot; stroke-linejoin=&quot;round&quot; aria-hidden=&quot;true&quot; data-astro-cid-kenkipkp&gt;&lt;rect x=&quot;9&quot; y=&quot;9&quot; width=&quot;13&quot; height=&quot;13&quot; rx=&quot;2&quot; data-astro-cid-kenkipkp&gt;&lt;/rect&gt;&lt;path d=&quot;M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1&quot; data-astro-cid-kenkipkp&gt;&lt;/path&gt;&lt;/svg&gt;&lt;span class=&quot;terminal-copy-label&quot; data-astro-cid-kenkipkp&gt;Copy&lt;/span&gt;&lt;/button&gt;&lt;/figcaption&gt;&lt;pre class=&quot;terminal-body&quot; data-astro-cid-kenkipkp&gt;&lt;code data-astro-cid-kenkipkp&gt;
# Stage 1: unfiltered — find the relevant region from top hits
node tools/canonical-vault/embeddings/query.mjs search &amp;quot;recover wiki projection&amp;quot; 5

# Stage 2: scoped — re-run with the winning region tag
node tools/canonical-vault/embeddings/query.mjs search &amp;quot;recover wiki projection&amp;quot; 10 region/second-brain

# Then follow the graph: expand from the best hit to its topic MOC + siblings
node tools/canonical-vault/embeddings/query.mjs graph &amp;quot;runbooks/wiki/import-pages.md&amp;quot;
&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;&lt;/div&gt;
&lt;div class=&quot;step&quot; data-astro-cid-wwaqy5cd&gt;&lt;div class=&quot;step-header&quot; data-astro-cid-wwaqy5cd&gt;&lt;span class=&quot;step-num&quot; data-astro-cid-wwaqy5cd&gt;5&lt;/span&gt;&lt;span class=&quot;step-title&quot; data-astro-cid-wwaqy5cd&gt;Fail closed on any mismatch + produce a start brief&lt;/span&gt;&lt;span class=&quot;step-time&quot; data-astro-cid-wwaqy5cd&gt;—&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;step-body&quot; data-astro-cid-wwaqy5cd&gt;&lt;p&gt;If the vault is unreachable, or a derived index (search, embeddings) reports a &lt;em&gt;different&lt;/em&gt; commit than the status call, &lt;code&gt;begin&lt;/code&gt; stops. It never proceeds on context it can’t prove is current. Once the context passes the check, &lt;code&gt;begin&lt;/code&gt; ends by stating what it knows: the canonical commit and check time, current relevant state, the exact resume point, active guardrails, and the pages it loaded.&lt;/p&gt;&lt;div class=&quot;callout callout-danger&quot; data-astro-cid-q2ml7llr&gt;&lt;div class=&quot;callout-header&quot; data-astro-cid-q2ml7llr&gt;&lt;span class=&quot;callout-icon&quot; aria-hidden=&quot;true&quot; data-astro-cid-q2ml7llr&gt;🚨&lt;/span&gt;&lt;span class=&quot;callout-label&quot; data-astro-cid-q2ml7llr&gt;Fail closed, always&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;callout-body&quot; data-astro-cid-q2ml7llr&gt;&lt;p&gt;Stale-but-confident is the failure mode a knowledge base exists to prevent — don’t reintroduce it at the AI layer. If currency can’t be verified, the correct action is to stop and report the mismatch, not to guess. This is the &lt;a href=&quot;https://web.mit.edu/Saltzer/www/publications/protection/&quot;&gt;fail-safe defaults&lt;/a&gt; principle from Saltzer and Schroeder’s foundational 1975 security paper: when in doubt, deny — base access on explicit permission, not on the absence of a reason to refuse.&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;&lt;/div&gt;&lt;/div&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;the-end-flow-persist-through-the-controlled-writer&quot;&gt;The end flow: persist through the controlled writer&lt;/h2&gt;
&lt;p&gt;When the work produces a durable outcome, &lt;code&gt;end&lt;/code&gt; writes it back — but only through the &lt;a href=&quot;https://peira.dev/blog/second-brain-controlled-write-lock/&quot;&gt;controlled-write contract&lt;/a&gt;. The agent never does a raw &lt;code&gt;git commit&lt;/code&gt; on the authority.&lt;/p&gt;
&lt;p&gt;The current write path has three verbs — &lt;strong&gt;preflight, write, verify&lt;/strong&gt; — that collapse what used to be a multi-step manual procedure into three explicit commands, each returning a structured result:&lt;/p&gt;
&lt;figure class=&quot;terminal&quot; data-astro-cid-kenkipkp&gt;&lt;figcaption class=&quot;terminal-bar&quot; data-astro-cid-kenkipkp&gt;&lt;span class=&quot;dot red&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot yellow&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot green&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;terminal-title&quot; data-astro-cid-kenkipkp&gt;end — streamlined vault.mjs path&lt;/span&gt;&lt;button class=&quot;terminal-copy&quot; type=&quot;button&quot; aria-label=&quot;Copy commands&quot; data-astro-cid-kenkipkp&gt;&lt;svg width=&quot;13&quot; height=&quot;13&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;none&quot; stroke=&quot;currentColor&quot; stroke-width=&quot;2&quot; stroke-linecap=&quot;round&quot; stroke-linejoin=&quot;round&quot; aria-hidden=&quot;true&quot; data-astro-cid-kenkipkp&gt;&lt;rect x=&quot;9&quot; y=&quot;9&quot; width=&quot;13&quot; height=&quot;13&quot; rx=&quot;2&quot; data-astro-cid-kenkipkp&gt;&lt;/rect&gt;&lt;path d=&quot;M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1&quot; data-astro-cid-kenkipkp&gt;&lt;/path&gt;&lt;/svg&gt;&lt;span class=&quot;terminal-copy-label&quot; data-astro-cid-kenkipkp&gt;Copy&lt;/span&gt;&lt;/button&gt;&lt;/figcaption&gt;&lt;pre class=&quot;terminal-body&quot; data-astro-cid-kenkipkp&gt;&lt;code data-astro-cid-kenkipkp&gt;
# 1. Preflight: auto-regenerates the topic map, runs all audits,
#    emits the exact file list to stage. Fix any ok:false before proceeding.
node tools/canonical-vault/vault.mjs preflight

# 2. Write: one command does lock → commit → refresh → mirror → verify
node tools/canonical-vault/vault.mjs write --message &amp;#39;feat: ...&amp;#39; --stage &amp;#39;&amp;lt;preflight stage_list&amp;gt;&amp;#39;

# 3. Verify: confirm all consumers and mirrors report the new HEAD
node tools/canonical-vault/vault.mjs verify

# After a successful write, regenerate the context digest as a follow-on commit
# so the next begin sees an accurate card-catalog and commit stamp
node tools/canonical-vault/generate-digest.mjs . --commit &amp;lt;new-sha&amp;gt; --date &amp;lt;today&amp;gt;
git add agents/context-digest.md
git commit -m &amp;#39;chore: regenerate context digest&amp;#39;
&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;
&lt;div class=&quot;callout callout-warning&quot; data-astro-cid-q2ml7llr&gt;&lt;div class=&quot;callout-header&quot; data-astro-cid-q2ml7llr&gt;&lt;span class=&quot;callout-icon&quot; aria-hidden=&quot;true&quot; data-astro-cid-q2ml7llr&gt;⚠️&lt;/span&gt;&lt;span class=&quot;callout-label&quot; data-astro-cid-q2ml7llr&gt;The --stage argument is load-bearing&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;callout-body&quot; data-astro-cid-q2ml7llr&gt;&lt;p&gt;Pass the exact file list from preflight’s &lt;code&gt;stage_list&lt;/code&gt; output. The &lt;code&gt;--stage&lt;/code&gt; flag does not glob — if you pass a directory or a mismatched list, the write will silently stage nothing or the wrong files. Always use what preflight computed, not a hand-authored list.&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;
&lt;div class=&quot;callout callout-info&quot; data-astro-cid-q2ml7llr&gt;&lt;div class=&quot;callout-header&quot; data-astro-cid-q2ml7llr&gt;&lt;span class=&quot;callout-icon&quot; aria-hidden=&quot;true&quot; data-astro-cid-q2ml7llr&gt;ℹ️&lt;/span&gt;&lt;span class=&quot;callout-label&quot; data-astro-cid-q2ml7llr&gt;A thin adapter per tool&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;callout-body&quot; data-astro-cid-q2ml7llr&gt;&lt;p&gt;&lt;code&gt;begin&lt;/code&gt;/&lt;code&gt;end&lt;/code&gt; are tool-agnostic procedures. Each AI tool (a CLI agent, a chat assistant, a background worker) gets a &lt;em&gt;thin adapter&lt;/em&gt; that invokes the same skills — so every agent cold-starts and persists identically, regardless of which model or harness is driving.&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;
&lt;p&gt;The full &lt;code&gt;begin&lt;/code&gt;/&lt;code&gt;end&lt;/code&gt; skill outline and a thin adapter are in the paired playbook: &lt;strong&gt;AI Begin/End Skill + Adapter&lt;/strong&gt;.&lt;/p&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;the-swap-in-box&quot;&gt;The swap-in box&lt;/h2&gt;
&lt;div class=&quot;callout callout-info&quot; data-astro-cid-q2ml7llr&gt;&lt;div class=&quot;callout-header&quot; data-astro-cid-q2ml7llr&gt;&lt;span class=&quot;callout-icon&quot; aria-hidden=&quot;true&quot; data-astro-cid-q2ml7llr&gt;ℹ️&lt;/span&gt;&lt;span class=&quot;callout-label&quot; data-astro-cid-q2ml7llr&gt;On other tools&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;callout-body&quot; data-astro-cid-q2ml7llr&gt;&lt;p&gt;&lt;strong&gt;Claude Code / Cursor / other agent harnesses:&lt;/strong&gt; implement &lt;code&gt;begin&lt;/code&gt;/&lt;code&gt;end&lt;/code&gt; as a slash-command, skill, or startup hook that runs the commit-check and context-load before the agent acts. &lt;strong&gt;RAG chatbots:&lt;/strong&gt; make the retrieval layer return the source commit with every answer, and refuse to answer if the index is stale. &lt;strong&gt;The durable idea:&lt;/strong&gt; no agent acts on your knowledge without first proving what revision it’s looking at — and no agent writes back except through your validated write path.&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;what-you-have-now&quot;&gt;What you have now&lt;/h2&gt;
&lt;p&gt;Agents that start every task pinned to your current canonical state and persist results without ever corrupting the authority. But an agent on your laptop and an agent on the cluster reach the vault very differently — and getting that wrong reintroduces the split-brain we’ve fought all series. That’s &lt;a href=&quot;https://peira.dev/blog/second-brain-multi-machine-reader/&quot;&gt;multi-machine access without split-brain&lt;/a&gt;.&lt;/p&gt;
&lt;hr/&gt;
&lt;p&gt;&lt;strong&gt;Related posts:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/second-brain-controlled-write-lock/&quot;&gt;Why Auto-Syncing Your Notes Repo Will Corrupt It&lt;/a&gt; — the write path &lt;code&gt;end&lt;/code&gt; uses&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/second-brain-multi-machine-reader/&quot;&gt;Share Your Vault Across Machines Without Split-Brain&lt;/a&gt; — how laptop vs. cluster agents reach the same authority&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/second-brain-local-rag-embeddings/&quot;&gt;Semantic Search for Your Notes, Running Locally&lt;/a&gt; — the index &lt;code&gt;begin&lt;/code&gt; consults to find relevant pages&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/hermes-agent-setup/&quot;&gt;Hermes Agent: Self-Hosted AI Agent with Fallback Model Chains&lt;/a&gt; — a self-hosted agent to wire begin/end into&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/second-brain-homelab-why/&quot;&gt;Your Homelab Needs a Second Brain — Here’s Why&lt;/a&gt; — the payoff this post delivers&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/second-brain-lessons-learned/&quot;&gt;10 Mistakes I Made Building a Homelab Second Brain&lt;/a&gt; — the traps the happy-path docs don’t show&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/homelab-wiki-rag-cited-answers/&quot;&gt;RAG for Your Homelab Wiki: Answers That Cite Their Source&lt;/a&gt; — the same index read by a Q&amp;amp;A endpoint instead of an agent&lt;/li&gt;
&lt;/ul&gt;</content:encoded><category>ai</category><category>self-hosted</category><category>automation</category></item><item><title>Why Auto-Syncing Your Notes Repo Will Corrupt It</title><link>https://peira.dev/blog/second-brain-controlled-write-lock/</link><guid isPermaLink="true">https://peira.dev/blog/second-brain-controlled-write-lock/</guid><description>Blanket auto-sync corrupts a canonical knowledge base. Replace it with a controlled write: lock, validate, commit, refresh projections, verify, unlock — so every change is atomic and every projection stays coherent.</description><pubDate>Wed, 15 Jul 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;There’s a tempting shortcut for keeping a knowledge base “always in sync”: a cron job that commits and pushes everything, everywhere, on a timer. Do not do this to your canonical vault. This is a &lt;code&gt;⚠️&lt;/code&gt; post because blanket sync is the single fastest way to corrupt the one store that’s supposed to be always-right.&lt;/p&gt;
&lt;div class=&quot;callout callout-danger&quot; data-astro-cid-q2ml7llr&gt;&lt;div class=&quot;callout-header&quot; data-astro-cid-q2ml7llr&gt;&lt;span class=&quot;callout-icon&quot; aria-hidden=&quot;true&quot; data-astro-cid-q2ml7llr&gt;🚨&lt;/span&gt;&lt;span class=&quot;callout-label&quot; data-astro-cid-q2ml7llr&gt;The rule&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;callout-body&quot; data-astro-cid-q2ml7llr&gt;&lt;p&gt;Writes to the authority go through a &lt;strong&gt;controlled-write contract&lt;/strong&gt;, never a blanket sync: &lt;strong&gt;lock → validate → commit → refresh projections → verify → unlock&lt;/strong&gt;. Any failure aborts the write and releases the lock, leaving the last good state intact. One writer at a time; every change atomic.&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;
&lt;div class=&quot;callout callout-warning&quot; data-astro-cid-q2ml7llr&gt;&lt;div class=&quot;callout-header&quot; data-astro-cid-q2ml7llr&gt;&lt;span class=&quot;callout-icon&quot; aria-hidden=&quot;true&quot; data-astro-cid-q2ml7llr&gt;⚠️&lt;/span&gt;&lt;span class=&quot;callout-label&quot; data-astro-cid-q2ml7llr&gt;First: make these values your own&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;callout-body&quot; data-astro-cid-q2ml7llr&gt;&lt;p&gt;Adapt the specifics to your setup: the script and lock paths (&lt;strong&gt;&lt;code&gt;/tmp/my-write.sh&lt;/code&gt;&lt;/strong&gt;, &lt;strong&gt;&lt;code&gt;/wiki-data/.canonical-writer.lock&lt;/code&gt;&lt;/strong&gt;, &lt;strong&gt;&lt;code&gt;canonical-writer-lock.py&lt;/code&gt;&lt;/strong&gt;) are examples — point them at your own repo and tooling. The &lt;em&gt;sequence&lt;/em&gt; is what matters, not the exact paths.&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;why-blanket-sync-corrupts&quot;&gt;Why blanket sync corrupts&lt;/h2&gt;
&lt;p&gt;An auto-sync job has no opinion about &lt;em&gt;what&lt;/em&gt; it’s pushing. It publishes broken links, committed secrets, and half-finished edits with equal enthusiasm — and then every projection faithfully rebuilds from that bad state. Worse, two sync jobs (your laptop and a cluster agent) can race and interleave commits.&lt;/p&gt;
&lt;svg viewBox=&quot;0 0 700 230&quot; xmlns=&quot;http://www.w3.org/2000/svg&quot; style=&quot;width:100%;border-radius:12px;background:#1c1712;margin:1.5rem 0;border:1px solid #443a2b&quot;&gt;&lt;defs&gt;&lt;pattern id=&quot;gp-secondbrainc0&quot; width=&quot;20&quot; height=&quot;20&quot; patternUnits=&quot;userSpaceOnUse&quot;&gt;&lt;path d=&quot;M 20 0 H 0 V 20&quot; fill=&quot;none&quot; stroke=&quot;#221c15&quot; stroke-width=&quot;1&quot;&gt;&lt;/path&gt;&lt;/pattern&gt;&lt;/defs&gt;&lt;rect x=&quot;0&quot; y=&quot;0&quot; width=&quot;700&quot; height=&quot;230&quot; fill=&quot;url(#gp-secondbrainc0)&quot;&gt;&lt;/rect&gt;&lt;rect x=&quot;6&quot; y=&quot;6&quot; width=&quot;688&quot; height=&quot;218&quot; fill=&quot;none&quot; stroke=&quot;#2e2820&quot; stroke-width=&quot;1&quot;&gt;&lt;/rect&gt;&lt;path d=&quot;M 11,16 H 21 M 16,11 V 21 M 679,16 H 689 M 684,11 V 21 M 11,214 H 21 M 16,209 V 219 M 679,214 H 689 M 684,209 V 219&quot; stroke=&quot;#4a3f2e&quot; stroke-width=&quot;1&quot; fill=&quot;none&quot;&gt;&lt;/path&gt;&lt;text x=&quot;180&quot; y=&quot;28&quot; text-anchor=&quot;middle&quot; fill=&quot;#d4604f&quot; font-size=&quot;12&quot; font-family=&quot;monospace&quot; font-weight=&quot;bold&quot;&gt;blanket sync&lt;/text&gt;&lt;text x=&quot;520&quot; y=&quot;28&quot; text-anchor=&quot;middle&quot; fill=&quot;#e8a33e&quot; font-size=&quot;12&quot; font-family=&quot;monospace&quot; font-weight=&quot;bold&quot;&gt;controlled write&lt;/text&gt;&lt;line x1=&quot;350&quot; y1=&quot;45&quot; x2=&quot;350&quot; y2=&quot;215&quot; stroke=&quot;#443a2b&quot; stroke-width=&quot;1.5&quot; stroke-dasharray=&quot;4 5&quot;&gt;&lt;/line&gt;&lt;rect x=&quot;40&quot; y=&quot;55&quot; width=&quot;120&quot; height=&quot;34&quot; rx=&quot;6&quot; fill=&quot;#262019&quot; stroke=&quot;#d4604f&quot; stroke-width=&quot;1.3&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;100&quot; y=&quot;77&quot; text-anchor=&quot;middle&quot; fill=&quot;#e5ddcd&quot; font-size=&quot;9&quot; font-family=&quot;monospace&quot;&gt;writer A&lt;/text&gt;&lt;rect x=&quot;200&quot; y=&quot;55&quot; width=&quot;120&quot; height=&quot;34&quot; rx=&quot;6&quot; fill=&quot;#262019&quot; stroke=&quot;#d4604f&quot; stroke-width=&quot;1.3&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;260&quot; y=&quot;77&quot; text-anchor=&quot;middle&quot; fill=&quot;#e5ddcd&quot; font-size=&quot;9&quot; font-family=&quot;monospace&quot;&gt;writer B&lt;/text&gt;&lt;line x1=&quot;100&quot; y1=&quot;89&quot; x2=&quot;170&quot; y2=&quot;130&quot; stroke=&quot;#d4604f&quot; stroke-width=&quot;1.3&quot;&gt;&lt;/line&gt;&lt;line x1=&quot;260&quot; y1=&quot;89&quot; x2=&quot;190&quot; y2=&quot;130&quot; stroke=&quot;#d4604f&quot; stroke-width=&quot;1.3&quot;&gt;&lt;/line&gt;&lt;rect x=&quot;110&quot; y=&quot;130&quot; width=&quot;140&quot; height=&quot;34&quot; rx=&quot;6&quot; fill=&quot;#262019&quot; stroke=&quot;#d4604f&quot; stroke-width=&quot;1.5&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;180&quot; y=&quot;152&quot; text-anchor=&quot;middle&quot; fill=&quot;#d4604f&quot; font-size=&quot;9&quot; font-family=&quot;monospace&quot;&gt;interleaved push&lt;/text&gt;&lt;text x=&quot;180&quot; y=&quot;192&quot; text-anchor=&quot;middle&quot; fill=&quot;#d4604f&quot; font-size=&quot;9&quot; font-family=&quot;monospace&quot;&gt;torn state, no validation&lt;/text&gt;&lt;rect x=&quot;400&quot; y=&quot;55&quot; width=&quot;240&quot; height=&quot;30&quot; rx=&quot;6&quot; fill=&quot;#262019&quot; stroke=&quot;#e8a33e&quot; stroke-width=&quot;1.3&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;520&quot; y=&quot;75&quot; text-anchor=&quot;middle&quot; fill=&quot;#e5ddcd&quot; font-size=&quot;9&quot; font-family=&quot;monospace&quot;&gt;lock (one writer)&lt;/text&gt;&lt;rect x=&quot;400&quot; y=&quot;92&quot; width=&quot;240&quot; height=&quot;30&quot; rx=&quot;6&quot; fill=&quot;#262019&quot; stroke=&quot;#e8a33e&quot; stroke-width=&quot;1.3&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;520&quot; y=&quot;112&quot; text-anchor=&quot;middle&quot; fill=&quot;#e5ddcd&quot; font-size=&quot;9&quot; font-family=&quot;monospace&quot;&gt;validate → commit&lt;/text&gt;&lt;rect x=&quot;400&quot; y=&quot;129&quot; width=&quot;240&quot; height=&quot;30&quot; rx=&quot;6&quot; fill=&quot;#262019&quot; stroke=&quot;#e8a33e&quot; stroke-width=&quot;1.3&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;520&quot; y=&quot;149&quot; text-anchor=&quot;middle&quot; fill=&quot;#e5ddcd&quot; font-size=&quot;9&quot; font-family=&quot;monospace&quot;&gt;refresh → verify&lt;/text&gt;&lt;rect x=&quot;400&quot; y=&quot;166&quot; width=&quot;240&quot; height=&quot;30&quot; rx=&quot;6&quot; fill=&quot;#262019&quot; stroke=&quot;#e8a33e&quot; stroke-width=&quot;1.3&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;520&quot; y=&quot;186&quot; text-anchor=&quot;middle&quot; fill=&quot;#e8a33e&quot; font-size=&quot;9&quot; font-family=&quot;monospace&quot;&gt;unlock — atomic ✓&lt;/text&gt;&lt;text x=&quot;690&quot; y=&quot;222&quot; text-anchor=&quot;end&quot; font-size=&quot;10&quot; fill=&quot;#8a8071&quot; opacity=&quot;0.6&quot; font-family=&quot;monospace&quot;&gt;peira.dev&lt;/text&gt;&lt;/svg&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;the-contract-step-by-step&quot;&gt;The contract, step by step&lt;/h2&gt;
&lt;p&gt;In my lab the whole sequence is a documented runbook — a &lt;strong&gt;canonical write procedure&lt;/strong&gt; — wrapped so the actual edit runs &lt;em&gt;under the lock&lt;/em&gt; as a script.&lt;/p&gt;
&lt;div class=&quot;step&quot; data-astro-cid-wwaqy5cd&gt;&lt;div class=&quot;step-header&quot; data-astro-cid-wwaqy5cd&gt;&lt;span class=&quot;step-num&quot; data-astro-cid-wwaqy5cd&gt;1&lt;/span&gt;&lt;span class=&quot;step-title&quot; data-astro-cid-wwaqy5cd&gt;Acquire the single-writer lock&lt;/span&gt;&lt;span class=&quot;step-time&quot; data-astro-cid-wwaqy5cd&gt;1 min&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;step-body&quot; data-astro-cid-wwaqy5cd&gt;&lt;p&gt;A file lock (&lt;a href=&quot;https://man7.org/linux/man-pages/man1/flock.1.html&quot;&gt;flock&lt;/a&gt;) serializes writers. The write is wrapped so the edit only runs while the lock is held, and the lock is always released — even on abort.&lt;/p&gt;&lt;figure class=&quot;terminal&quot; data-astro-cid-kenkipkp&gt;&lt;figcaption class=&quot;terminal-bar&quot; data-astro-cid-kenkipkp&gt;&lt;span class=&quot;dot red&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot yellow&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot green&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;terminal-title&quot; data-astro-cid-kenkipkp&gt;Wrap the edit under the lock&lt;/span&gt;&lt;button class=&quot;terminal-copy&quot; type=&quot;button&quot; aria-label=&quot;Copy commands&quot; data-astro-cid-kenkipkp&gt;&lt;svg width=&quot;13&quot; height=&quot;13&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;none&quot; stroke=&quot;currentColor&quot; stroke-width=&quot;2&quot; stroke-linecap=&quot;round&quot; stroke-linejoin=&quot;round&quot; aria-hidden=&quot;true&quot; data-astro-cid-kenkipkp&gt;&lt;rect x=&quot;9&quot; y=&quot;9&quot; width=&quot;13&quot; height=&quot;13&quot; rx=&quot;2&quot; data-astro-cid-kenkipkp&gt;&lt;/rect&gt;&lt;path d=&quot;M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1&quot; data-astro-cid-kenkipkp&gt;&lt;/path&gt;&lt;/svg&gt;&lt;span class=&quot;terminal-copy-label&quot; data-astro-cid-kenkipkp&gt;Copy&lt;/span&gt;&lt;/button&gt;&lt;/figcaption&gt;&lt;pre class=&quot;terminal-body&quot; data-astro-cid-kenkipkp&gt;&lt;code data-astro-cid-kenkipkp&gt;
# The edit runs as a /tmp script under the lock — never inline
canonical-writer-lock.py -- bash /tmp/my-write.sh
&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;&lt;/div&gt;
&lt;div class=&quot;step&quot; data-astro-cid-wwaqy5cd&gt;&lt;div class=&quot;step-header&quot; data-astro-cid-wwaqy5cd&gt;&lt;span class=&quot;step-num&quot; data-astro-cid-wwaqy5cd&gt;2&lt;/span&gt;&lt;span class=&quot;step-title&quot; data-astro-cid-wwaqy5cd&gt;Validate BEFORE committing&lt;/span&gt;&lt;span class=&quot;step-time&quot; data-astro-cid-wwaqy5cd&gt;2 min&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;step-body&quot; data-astro-cid-wwaqy5cd&gt;&lt;p&gt;Pre-flight audits reject broken links, stale paths, missing frontmatter, and — critically — anything secret-shaped. If validation fails, nothing is committed.&lt;/p&gt;&lt;div class=&quot;callout callout-warning&quot; data-astro-cid-q2ml7llr&gt;&lt;div class=&quot;callout-header&quot; data-astro-cid-q2ml7llr&gt;&lt;span class=&quot;callout-icon&quot; aria-hidden=&quot;true&quot; data-astro-cid-q2ml7llr&gt;⚠️&lt;/span&gt;&lt;span class=&quot;callout-label&quot; data-astro-cid-q2ml7llr&gt;A hard-won CLI detail: the audit reads stdin&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;callout-body&quot; data-astro-cid-q2ml7llr&gt;&lt;p&gt;The link/secret audit reads the file list from &lt;strong&gt;standard input&lt;/strong&gt;, not an argument: pipe it in with &lt;code&gt;git ls-files -s | node scripts/wiki-audit.mjs .&lt;/code&gt;. Invoked bare, an older version blocked forever waiting on stdin — a “hang” that was misdiagnosed for ages as a slow network mount. The mount was fine; the tool was waiting for input that never came. Build validation CLIs to fail fast with usage when they get no input, and always feed them explicitly.&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;&lt;figure class=&quot;terminal&quot; data-astro-cid-kenkipkp&gt;&lt;figcaption class=&quot;terminal-bar&quot; data-astro-cid-kenkipkp&gt;&lt;span class=&quot;dot red&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot yellow&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot green&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;terminal-title&quot; data-astro-cid-kenkipkp&gt;Pre-flight validation&lt;/span&gt;&lt;button class=&quot;terminal-copy&quot; type=&quot;button&quot; aria-label=&quot;Copy commands&quot; data-astro-cid-kenkipkp&gt;&lt;svg width=&quot;13&quot; height=&quot;13&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;none&quot; stroke=&quot;currentColor&quot; stroke-width=&quot;2&quot; stroke-linecap=&quot;round&quot; stroke-linejoin=&quot;round&quot; aria-hidden=&quot;true&quot; data-astro-cid-kenkipkp&gt;&lt;rect x=&quot;9&quot; y=&quot;9&quot; width=&quot;13&quot; height=&quot;13&quot; rx=&quot;2&quot; data-astro-cid-kenkipkp&gt;&lt;/rect&gt;&lt;path d=&quot;M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1&quot; data-astro-cid-kenkipkp&gt;&lt;/path&gt;&lt;/svg&gt;&lt;span class=&quot;terminal-copy-label&quot; data-astro-cid-kenkipkp&gt;Copy&lt;/span&gt;&lt;/button&gt;&lt;/figcaption&gt;&lt;pre class=&quot;terminal-body&quot; data-astro-cid-kenkipkp&gt;&lt;code data-astro-cid-kenkipkp&gt;
git ls-files -s | node scripts/wiki-audit.mjs .
# link check · stale-path check · frontmatter check · secret scan
&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;&lt;/div&gt;
&lt;div class=&quot;step&quot; data-astro-cid-wwaqy5cd&gt;&lt;div class=&quot;step-header&quot; data-astro-cid-wwaqy5cd&gt;&lt;span class=&quot;step-num&quot; data-astro-cid-wwaqy5cd&gt;3&lt;/span&gt;&lt;span class=&quot;step-title&quot; data-astro-cid-wwaqy5cd&gt;Commit, refresh projections, verify&lt;/span&gt;&lt;span class=&quot;step-time&quot; data-astro-cid-wwaqy5cd&gt;5 min&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;step-body&quot; data-astro-cid-wwaqy5cd&gt;&lt;p&gt;Only after validation passes: commit the enumerated files (never &lt;code&gt;git add -A&lt;/code&gt;), then rebuild the projections and confirm each reports the new commit.&lt;/p&gt;&lt;figure class=&quot;terminal&quot; data-astro-cid-kenkipkp&gt;&lt;figcaption class=&quot;terminal-bar&quot; data-astro-cid-kenkipkp&gt;&lt;span class=&quot;dot red&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot yellow&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot green&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;terminal-title&quot; data-astro-cid-kenkipkp&gt;Commit and refresh derived consumers&lt;/span&gt;&lt;button class=&quot;terminal-copy&quot; type=&quot;button&quot; aria-label=&quot;Copy commands&quot; data-astro-cid-kenkipkp&gt;&lt;svg width=&quot;13&quot; height=&quot;13&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;none&quot; stroke=&quot;currentColor&quot; stroke-width=&quot;2&quot; stroke-linecap=&quot;round&quot; stroke-linejoin=&quot;round&quot; aria-hidden=&quot;true&quot; data-astro-cid-kenkipkp&gt;&lt;rect x=&quot;9&quot; y=&quot;9&quot; width=&quot;13&quot; height=&quot;13&quot; rx=&quot;2&quot; data-astro-cid-kenkipkp&gt;&lt;/rect&gt;&lt;path d=&quot;M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1&quot; data-astro-cid-kenkipkp&gt;&lt;/path&gt;&lt;/svg&gt;&lt;span class=&quot;terminal-copy-label&quot; data-astro-cid-kenkipkp&gt;Copy&lt;/span&gt;&lt;/button&gt;&lt;/figcaption&gt;&lt;pre class=&quot;terminal-body&quot; data-astro-cid-kenkipkp&gt;&lt;code data-astro-cid-kenkipkp&gt;
git add &amp;lt;enumerated files&amp;gt;          # never &amp;#39;git add -A&amp;#39; on the canonical vault
git commit -m &amp;quot;Describe the change&amp;quot;

# refresh projections (server-side), then verify each reports HEAD
refresh-projection &amp;amp;&amp;amp; verify-projection
build-index &amp;amp;&amp;amp; verify-index
&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;&lt;/div&gt;
&lt;div class=&quot;step&quot; data-astro-cid-wwaqy5cd&gt;&lt;div class=&quot;step-header&quot; data-astro-cid-wwaqy5cd&gt;&lt;span class=&quot;step-num&quot; data-astro-cid-wwaqy5cd&gt;4&lt;/span&gt;&lt;span class=&quot;step-title&quot; data-astro-cid-wwaqy5cd&gt;Release the lock — and abort safely if needed&lt;/span&gt;&lt;span class=&quot;step-time&quot; data-astro-cid-wwaqy5cd&gt;1 min&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;step-body&quot; data-astro-cid-wwaqy5cd&gt;&lt;p&gt;The wrapper releases the lock in its cleanup path. If you must abort a wrapped write, send &lt;a href=&quot;https://man7.org/linux/man-pages/man7/signal.7.html&quot;&gt;&lt;strong&gt;SIGINT&lt;/strong&gt;&lt;/a&gt; (not SIGTERM) so the wrapper’s cleanup runs and the lock is freed rather than left held.&lt;/p&gt;&lt;div class=&quot;callout callout-info&quot; data-astro-cid-q2ml7llr&gt;&lt;div class=&quot;callout-header&quot; data-astro-cid-q2ml7llr&gt;&lt;span class=&quot;callout-icon&quot; aria-hidden=&quot;true&quot; data-astro-cid-q2ml7llr&gt;ℹ️&lt;/span&gt;&lt;span class=&quot;callout-label&quot; data-astro-cid-q2ml7llr&gt;Aborts must release the lock&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;callout-body&quot; data-astro-cid-q2ml7llr&gt;&lt;p&gt;A lock that survives a crashed write blocks every future write. Ensure the abort signal triggers the release path, and have a way to confirm the lock is free afterwards. A stuck lock is its own outage.&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;&lt;/div&gt;&lt;/div&gt;
&lt;p&gt;The complete wrapper, the stdin-fed audit, and the SIGINT-safe abort are in the paired playbook: &lt;strong&gt;Canonical Writer Lock + Validation Gates&lt;/strong&gt;.&lt;/p&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;collapsing-the-procedure-into-three-verbs&quot;&gt;Collapsing the procedure into three verbs&lt;/h2&gt;
&lt;p&gt;Once you’ve run the manual steps enough times to trust them, you can collapse the sequence into a thin orchestrator that automates the boilerplate while keeping every gate identical. In my lab, that’s &lt;code&gt;vault.mjs&lt;/code&gt; — three commands that mirror the four manual steps above:&lt;/p&gt;
&lt;figure class=&quot;terminal&quot; data-astro-cid-kenkipkp&gt;&lt;figcaption class=&quot;terminal-bar&quot; data-astro-cid-kenkipkp&gt;&lt;span class=&quot;dot red&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot yellow&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot green&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;terminal-title&quot; data-astro-cid-kenkipkp&gt;vault.mjs — the streamlined write path&lt;/span&gt;&lt;button class=&quot;terminal-copy&quot; type=&quot;button&quot; aria-label=&quot;Copy commands&quot; data-astro-cid-kenkipkp&gt;&lt;svg width=&quot;13&quot; height=&quot;13&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;none&quot; stroke=&quot;currentColor&quot; stroke-width=&quot;2&quot; stroke-linecap=&quot;round&quot; stroke-linejoin=&quot;round&quot; aria-hidden=&quot;true&quot; data-astro-cid-kenkipkp&gt;&lt;rect x=&quot;9&quot; y=&quot;9&quot; width=&quot;13&quot; height=&quot;13&quot; rx=&quot;2&quot; data-astro-cid-kenkipkp&gt;&lt;/rect&gt;&lt;path d=&quot;M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1&quot; data-astro-cid-kenkipkp&gt;&lt;/path&gt;&lt;/svg&gt;&lt;span class=&quot;terminal-copy-label&quot; data-astro-cid-kenkipkp&gt;Copy&lt;/span&gt;&lt;/button&gt;&lt;/figcaption&gt;&lt;pre class=&quot;terminal-body&quot; data-astro-cid-kenkipkp&gt;&lt;code data-astro-cid-kenkipkp&gt;
# 1. preflight: regenerates topic-map, runs all audits, emits the stage list
node tools/canonical-vault/vault.mjs preflight

# 2. write: lock → commit → refresh → mirror → verify (one command, one JSON result)
node tools/canonical-vault/vault.mjs write --message &amp;#39;feat: ...&amp;#39; --stage &amp;#39;&amp;lt;stage_list&amp;gt;&amp;#39;

# 3. verify: independent confirmation — all consumers and mirrors at HEAD
node tools/canonical-vault/vault.mjs verify
&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;
&lt;div class=&quot;callout callout-info&quot; data-astro-cid-q2ml7llr&gt;&lt;div class=&quot;callout-header&quot; data-astro-cid-q2ml7llr&gt;&lt;span class=&quot;callout-icon&quot; aria-hidden=&quot;true&quot; data-astro-cid-q2ml7llr&gt;ℹ️&lt;/span&gt;&lt;span class=&quot;callout-label&quot; data-astro-cid-q2ml7llr&gt;The manual path stays as the fallback&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;callout-body&quot; data-astro-cid-q2ml7llr&gt;&lt;p&gt;If &lt;code&gt;vault.mjs&lt;/code&gt; itself is broken or the environment is degraded, you still need to be able to execute each step by hand. Keep the manual procedure documented and drilled — the orchestrator saves time on the normal path; the manual path gets you out of a stuck state.&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;why-a-documented-procedure-beats-a-clever-one-liner&quot;&gt;Why a documented procedure beats a clever one-liner&lt;/h2&gt;
&lt;p&gt;The write sequence lived, for a while, only in someone’s shell history — and got mis-run twice (the wrong stdin, nested quoting inside a wrapped &lt;code&gt;bash -c&lt;/code&gt;). The fix wasn’t a smarter command; it was writing the procedure down as a runbook and running edits as a &lt;code&gt;/tmp&lt;/code&gt; script under the lock, never as inline nested quoting. A canonical write is exactly the operation you want boring, explicit, and identical every time.&lt;/p&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;the-swap-in-box&quot;&gt;The swap-in box&lt;/h2&gt;
&lt;div class=&quot;callout callout-info&quot; data-astro-cid-q2ml7llr&gt;&lt;div class=&quot;callout-header&quot; data-astro-cid-q2ml7llr&gt;&lt;span class=&quot;callout-icon&quot; aria-hidden=&quot;true&quot; data-astro-cid-q2ml7llr&gt;ℹ️&lt;/span&gt;&lt;span class=&quot;callout-label&quot; data-astro-cid-q2ml7llr&gt;On other tools&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;callout-body&quot; data-astro-cid-q2ml7llr&gt;&lt;p&gt;&lt;strong&gt;Git hosting (GitHub/GitLab):&lt;/strong&gt; a protected branch + required CI checks &lt;em&gt;is&lt;/em&gt; a controlled-write contract — validation gates the merge, and one merge lands at a time. &lt;strong&gt;CI/CD generally:&lt;/strong&gt; model it as lock (the pipeline is serialized) → validate (test stage) → commit/deploy → verify (smoke test). &lt;strong&gt;Databases:&lt;/strong&gt; this is just a transaction with post-commit consistency checks. The pattern is universal: never mutate the authority without validating first and verifying after.&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;what-you-have-now&quot;&gt;What you have now&lt;/h2&gt;
&lt;p&gt;Writes that are atomic, validated, serialized, and always leave projections coherent — the opposite of fire-and-forget sync. That safety is the foundation for letting &lt;em&gt;automated&lt;/em&gt; writers touch the vault at all, which is where the series turns next: &lt;a href=&quot;https://peira.dev/blog/second-brain-ai-begin-end-skills/&quot;&gt;cold-starting your AI&lt;/a&gt; so agents read and write the brain under the same contract.&lt;/p&gt;
&lt;hr/&gt;
&lt;p&gt;&lt;strong&gt;Related posts:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/second-brain-wikijs-projection/&quot;&gt;Turn Your Git Vault Into a Searchable Wiki.js Site&lt;/a&gt; — the projections this contract keeps coherent&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/second-brain-ai-begin-end-skills/&quot;&gt;Make Your AI Agents Read the Docs First, Every Time&lt;/a&gt; — agents that write through this same contract&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/second-brain-projection-health/&quot;&gt;Prove Your Homelab Docs Are Actually Up to Date&lt;/a&gt; — verifying the refresh step actually landed&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/second-brain-secrets-discipline/&quot;&gt;Never Commit a Secret to Your Knowledge Base&lt;/a&gt; — what the validation gate’s secret scan enforces&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/second-brain-repair-rebuild/&quot;&gt;Already Made a Mess of Your Notes? Here’s the Fix&lt;/a&gt; — cleaning up after blanket sync did the damage&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/second-brain-lessons-learned/&quot;&gt;10 Mistakes I Made Building a Homelab Second Brain&lt;/a&gt; — the silent-success failures this post’s contract prevents&lt;/li&gt;
&lt;/ul&gt;</content:encoded><category>self-hosted</category><category>automation</category><category>security</category></item><item><title>Never Lose a Homelab Note Again: A Git + Markdown Vault</title><link>https://peira.dev/blog/second-brain-git-markdown-vault/</link><guid isPermaLink="true">https://peira.dev/blog/second-brain-git-markdown-vault/</guid><description>Build a homelab knowledge base on Git and Markdown — the substrate that gives you version history, plain-text durability, and one authoritative copy. Includes the SSHFS-over-bashrc mount pattern.</description><pubDate>Wed, 15 Jul 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;The &lt;a href=&quot;https://peira.dev/blog/second-brain-homelab-why/&quot;&gt;previous post&lt;/a&gt; argued &lt;em&gt;why&lt;/em&gt; you want a second brain and gave you the one rule: every durable fact has exactly one home. This post builds the home. The substrate is deliberately boring — &lt;a href=&quot;https://git-scm.com&quot;&gt;Git&lt;/a&gt; plus &lt;a href=&quot;https://commonmark.org/&quot;&gt;Markdown&lt;/a&gt; — and boring is the point. Boring outlives every hosted note app that will shut down in the next decade.&lt;/p&gt;
&lt;div class=&quot;callout callout-tip&quot; data-astro-cid-q2ml7llr&gt;&lt;div class=&quot;callout-header&quot; data-astro-cid-q2ml7llr&gt;&lt;span class=&quot;callout-icon&quot; aria-hidden=&quot;true&quot; data-astro-cid-q2ml7llr&gt;💡&lt;/span&gt;&lt;span class=&quot;callout-label&quot; data-astro-cid-q2ml7llr&gt;The principle&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;callout-body&quot; data-astro-cid-q2ml7llr&gt;&lt;p&gt;Your knowledge base should be plain text under version control. Plain text is readable by any tool forever; version control gives you history, blame, and a single authoritative copy. Everything fancier is a &lt;em&gt;view&lt;/em&gt; built on top of this — never a replacement for it.&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;
&lt;div class=&quot;callout callout-warning&quot; data-astro-cid-q2ml7llr&gt;&lt;div class=&quot;callout-header&quot; data-astro-cid-q2ml7llr&gt;&lt;span class=&quot;callout-icon&quot; aria-hidden=&quot;true&quot; data-astro-cid-q2ml7llr&gt;⚠️&lt;/span&gt;&lt;span class=&quot;callout-label&quot; data-astro-cid-q2ml7llr&gt;First: make these values your own&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;callout-body&quot; data-astro-cid-q2ml7llr&gt;&lt;p&gt;The commands below use example stand-ins from my lab — &lt;strong&gt;don’t paste them verbatim.&lt;/strong&gt; Substitute your own values first: &lt;strong&gt;&lt;code&gt;10.0.0.76&lt;/code&gt;&lt;/strong&gt; → your vault host’s IP address; &lt;strong&gt;&lt;code&gt;vaultwriter&lt;/code&gt;&lt;/strong&gt; → the username you create; &lt;strong&gt;&lt;code&gt;/wiki-data/git&lt;/code&gt;&lt;/strong&gt; → wherever your repo lives; &lt;strong&gt;&lt;code&gt;~/wiki&lt;/code&gt;&lt;/strong&gt; → your chosen local mount point; &lt;strong&gt;&lt;code&gt;CT 106&lt;/code&gt;&lt;/strong&gt; is just my container’s label, yours will differ. Rule of thumb: if a value looks specific to one machine, it’s a placeholder to change — not a literal to copy.&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;why-this-substrate-wins&quot;&gt;Why this substrate wins&lt;/h2&gt;
&lt;p&gt;Three properties, and every other choice in this series depends on them:&lt;/p&gt;
&lt;svg viewBox=&quot;0 0 700 200&quot; xmlns=&quot;http://www.w3.org/2000/svg&quot; style=&quot;width:100%;border-radius:12px;background:#1c1712;margin:1.5rem 0;border:1px solid #443a2b&quot;&gt;&lt;defs&gt;&lt;pattern id=&quot;gp-secondbraing0&quot; width=&quot;20&quot; height=&quot;20&quot; patternUnits=&quot;userSpaceOnUse&quot;&gt;&lt;path d=&quot;M 20 0 H 0 V 20&quot; fill=&quot;none&quot; stroke=&quot;#221c15&quot; stroke-width=&quot;1&quot;&gt;&lt;/path&gt;&lt;/pattern&gt;&lt;/defs&gt;&lt;rect x=&quot;0&quot; y=&quot;0&quot; width=&quot;700&quot; height=&quot;200&quot; fill=&quot;url(#gp-secondbraing0)&quot;&gt;&lt;/rect&gt;&lt;rect x=&quot;6&quot; y=&quot;6&quot; width=&quot;688&quot; height=&quot;188&quot; fill=&quot;none&quot; stroke=&quot;#2e2820&quot; stroke-width=&quot;1&quot;&gt;&lt;/rect&gt;&lt;path d=&quot;M 11,16 H 21 M 16,11 V 21 M 679,16 H 689 M 684,11 V 21 M 11,184 H 21 M 16,179 V 189 M 679,184 H 689 M 684,179 V 189&quot; stroke=&quot;#4a3f2e&quot; stroke-width=&quot;1&quot; fill=&quot;none&quot;&gt;&lt;/path&gt;&lt;text x=&quot;350&quot; y=&quot;30&quot; text-anchor=&quot;middle&quot; fill=&quot;#f2ede3&quot; font-size=&quot;13&quot; font-family=&quot;monospace&quot; font-weight=&quot;bold&quot;&gt;Git + Markdown vault&lt;/text&gt;&lt;rect x=&quot;40&quot; y=&quot;60&quot; width=&quot;180&quot; height=&quot;100&quot; rx=&quot;10&quot; fill=&quot;#262019&quot; stroke=&quot;#e8a33e&quot; stroke-width=&quot;1.5&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;130&quot; y=&quot;95&quot; text-anchor=&quot;middle&quot; fill=&quot;#e8a33e&quot; font-size=&quot;12&quot; font-family=&quot;monospace&quot; font-weight=&quot;bold&quot;&gt;Durable&lt;/text&gt;&lt;text x=&quot;130&quot; y=&quot;120&quot; text-anchor=&quot;middle&quot; fill=&quot;#aba192&quot; font-size=&quot;9&quot; font-family=&quot;monospace&quot;&gt;plain text, no&lt;/text&gt;&lt;text x=&quot;130&quot; y=&quot;134&quot; text-anchor=&quot;middle&quot; fill=&quot;#aba192&quot; font-size=&quot;9&quot; font-family=&quot;monospace&quot;&gt;renderer required&lt;/text&gt;&lt;rect x=&quot;260&quot; y=&quot;60&quot; width=&quot;180&quot; height=&quot;100&quot; rx=&quot;10&quot; fill=&quot;#262019&quot; stroke=&quot;#6f9bd1&quot; stroke-width=&quot;1.5&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;350&quot; y=&quot;95&quot; text-anchor=&quot;middle&quot; fill=&quot;#6f9bd1&quot; font-size=&quot;12&quot; font-family=&quot;monospace&quot; font-weight=&quot;bold&quot;&gt;Versioned&lt;/text&gt;&lt;text x=&quot;350&quot; y=&quot;120&quot; text-anchor=&quot;middle&quot; fill=&quot;#aba192&quot; font-size=&quot;9&quot; font-family=&quot;monospace&quot;&gt;full history,&lt;/text&gt;&lt;text x=&quot;350&quot; y=&quot;134&quot; text-anchor=&quot;middle&quot; fill=&quot;#aba192&quot; font-size=&quot;9&quot; font-family=&quot;monospace&quot;&gt;every change traceable&lt;/text&gt;&lt;rect x=&quot;480&quot; y=&quot;60&quot; width=&quot;180&quot; height=&quot;100&quot; rx=&quot;10&quot; fill=&quot;#262019&quot; stroke=&quot;#8fb573&quot; stroke-width=&quot;1.5&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;570&quot; y=&quot;95&quot; text-anchor=&quot;middle&quot; fill=&quot;#8fb573&quot; font-size=&quot;12&quot; font-family=&quot;monospace&quot; font-weight=&quot;bold&quot;&gt;Single copy&lt;/text&gt;&lt;text x=&quot;570&quot; y=&quot;120&quot; text-anchor=&quot;middle&quot; fill=&quot;#aba192&quot; font-size=&quot;9&quot; font-family=&quot;monospace&quot;&gt;one authoritative&lt;/text&gt;&lt;text x=&quot;570&quot; y=&quot;134&quot; text-anchor=&quot;middle&quot; fill=&quot;#aba192&quot; font-size=&quot;9&quot; font-family=&quot;monospace&quot;&gt;repo, many views&lt;/text&gt;&lt;text x=&quot;690&quot; y=&quot;192&quot; text-anchor=&quot;end&quot; font-size=&quot;10&quot; fill=&quot;#8a8071&quot; opacity=&quot;0.6&quot; font-family=&quot;monospace&quot;&gt;peira.dev&lt;/text&gt;&lt;/svg&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Durable:&lt;/strong&gt; a Markdown file is legible in &lt;code&gt;cat&lt;/code&gt;, in your editor, in a browser, and in whatever tool exists in fifteen years. No proprietary database, no export dance.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Versioned:&lt;/strong&gt; &lt;code&gt;git log&lt;/code&gt; on a note tells you what changed, when, and — if you write good commit messages — why. You can revert a bad edit and blame a line to a date. (If Git is new to you, the free &lt;a href=&quot;https://git-scm.com/book/en/v2&quot;&gt;Pro Git book&lt;/a&gt; is the authoritative reference.)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Single copy:&lt;/strong&gt; the repository &lt;em&gt;is&lt;/em&gt; the authority. Obsidian, a published wiki, and a search index are all downstream views of this one repo, never independent forks of the truth.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;where-the-vault-lives&quot;&gt;Where the vault lives&lt;/h2&gt;
&lt;p&gt;The authoritative repository lives on an always-on machine. In my lab that’s a &lt;a href=&quot;https://peira.dev/blog/proxmox-create-lxc/&quot;&gt;Proxmox LXC&lt;/a&gt; container (call it CT 106) holding a bare Git repo at &lt;code&gt;/wiki-data/git&lt;/code&gt;. My laptop never holds the &lt;em&gt;only&lt;/em&gt; copy — it mounts the container’s repo over the network and edits the real files in place.&lt;/p&gt;
&lt;div class=&quot;callout callout-info&quot; data-astro-cid-q2ml7llr&gt;&lt;div class=&quot;callout-header&quot; data-astro-cid-q2ml7llr&gt;&lt;span class=&quot;callout-icon&quot; aria-hidden=&quot;true&quot; data-astro-cid-q2ml7llr&gt;ℹ️&lt;/span&gt;&lt;span class=&quot;callout-label&quot; data-astro-cid-q2ml7llr&gt;Why a container, not the laptop&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;callout-body&quot; data-astro-cid-q2ml7llr&gt;&lt;p&gt;If the vault’s only home is your laptop, the vault is offline whenever the laptop is. Putting the authority on an always-on container means your cluster services, your phone, and later your AI agents can all reach the same single copy — and your laptop becomes just one editing client among several.&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;
&lt;svg viewBox=&quot;0 0 700 280&quot; xmlns=&quot;http://www.w3.org/2000/svg&quot; style=&quot;width:100%;border-radius:12px;background:#1c1712;margin:1.75rem 0;border:1px solid #443a2b&quot;&gt;&lt;defs&gt;&lt;pattern id=&quot;gp-secondbraing1&quot; width=&quot;20&quot; height=&quot;20&quot; patternUnits=&quot;userSpaceOnUse&quot;&gt;&lt;path d=&quot;M 20 0 H 0 V 20&quot; fill=&quot;none&quot; stroke=&quot;#221c15&quot; stroke-width=&quot;1&quot;&gt;&lt;/path&gt;&lt;/pattern&gt;&lt;/defs&gt;&lt;rect x=&quot;0&quot; y=&quot;0&quot; width=&quot;700&quot; height=&quot;280&quot; fill=&quot;url(#gp-secondbraing1)&quot;&gt;&lt;/rect&gt;&lt;rect x=&quot;6&quot; y=&quot;6&quot; width=&quot;688&quot; height=&quot;268&quot; fill=&quot;none&quot; stroke=&quot;#2e2820&quot; stroke-width=&quot;1&quot;&gt;&lt;/rect&gt;&lt;path d=&quot;M 11,16 H 21 M 16,11 V 21 M 679,16 H 689 M 684,11 V 21 M 11,264 H 21 M 16,259 V 269 M 679,264 H 689 M 684,259 V 269&quot; stroke=&quot;#4a3f2e&quot; stroke-width=&quot;1&quot; fill=&quot;none&quot;&gt;&lt;/path&gt;&lt;text x=&quot;350&quot; y=&quot;28&quot; text-anchor=&quot;middle&quot; fill=&quot;#f2ede3&quot; font-size=&quot;13&quot; font-family=&quot;monospace&quot; font-weight=&quot;bold&quot;&gt;One canonical copy, many clients&lt;/text&gt;&lt;rect x=&quot;250&quot; y=&quot;55&quot; width=&quot;200&quot; height=&quot;66&quot; rx=&quot;10&quot; fill=&quot;#262019&quot; stroke=&quot;#e8a33e&quot; stroke-width=&quot;2&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;350&quot; y=&quot;82&quot; text-anchor=&quot;middle&quot; fill=&quot;#e8a33e&quot; font-size=&quot;11&quot; font-family=&quot;monospace&quot; font-weight=&quot;bold&quot;&gt;always-on host (CT)&lt;/text&gt;&lt;text x=&quot;350&quot; y=&quot;101&quot; text-anchor=&quot;middle&quot; fill=&quot;#aba192&quot; font-size=&quot;9&quot; font-family=&quot;monospace&quot;&gt;/wiki-data/git — the authority&lt;/text&gt;&lt;rect x=&quot;60&quot; y=&quot;70&quot; width=&quot;140&quot; height=&quot;52&quot; rx=&quot;10&quot; fill=&quot;#262019&quot; stroke=&quot;#6f9bd1&quot; stroke-width=&quot;1.5&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;130&quot; y=&quot;92&quot; text-anchor=&quot;middle&quot; fill=&quot;#6f9bd1&quot; font-size=&quot;10&quot; font-family=&quot;monospace&quot; font-weight=&quot;bold&quot;&gt;laptop ~/wiki&lt;/text&gt;&lt;text x=&quot;130&quot; y=&quot;108&quot; text-anchor=&quot;middle&quot; fill=&quot;#8a8071&quot; font-size=&quot;8&quot; font-family=&quot;monospace&quot;&gt;Obsidian, edits live&lt;/text&gt;&lt;line x1=&quot;200&quot; y1=&quot;96&quot; x2=&quot;250&quot; y2=&quot;90&quot; stroke=&quot;#6f9bd1&quot; stroke-width=&quot;1.5&quot;&gt;&lt;/line&gt;&lt;text x=&quot;225&quot; y=&quot;80&quot; text-anchor=&quot;middle&quot; fill=&quot;#6f9bd1&quot; font-size=&quot;8&quot; font-family=&quot;monospace&quot;&gt;SSHFS&lt;/text&gt;&lt;text x=&quot;225&quot; y=&quot;112&quot; text-anchor=&quot;middle&quot; fill=&quot;#8a8071&quot; font-size=&quot;8&quot; font-family=&quot;monospace&quot;&gt;no sync step&lt;/text&gt;&lt;text x=&quot;350&quot; y=&quot;150&quot; text-anchor=&quot;middle&quot; fill=&quot;#8a8071&quot; font-size=&quot;9&quot; font-family=&quot;monospace&quot;&gt;downstream views (built later in this series)&lt;/text&gt;&lt;line x1=&quot;350&quot; y1=&quot;121&quot; x2=&quot;150&quot; y2=&quot;185&quot; stroke=&quot;#5c4f3a&quot; stroke-width=&quot;1.3&quot; stroke-dasharray=&quot;4 4&quot;&gt;&lt;/line&gt;&lt;line x1=&quot;350&quot; y1=&quot;121&quot; x2=&quot;350&quot; y2=&quot;185&quot; stroke=&quot;#5c4f3a&quot; stroke-width=&quot;1.3&quot; stroke-dasharray=&quot;4 4&quot;&gt;&lt;/line&gt;&lt;line x1=&quot;350&quot; y1=&quot;121&quot; x2=&quot;550&quot; y2=&quot;185&quot; stroke=&quot;#5c4f3a&quot; stroke-width=&quot;1.3&quot; stroke-dasharray=&quot;4 4&quot;&gt;&lt;/line&gt;&lt;rect x=&quot;80&quot; y=&quot;185&quot; width=&quot;140&quot; height=&quot;46&quot; rx=&quot;8&quot; fill=&quot;#221c16&quot; stroke=&quot;#443a2b&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;150&quot; y=&quot;205&quot; text-anchor=&quot;middle&quot; fill=&quot;#aba192&quot; font-size=&quot;9&quot; font-family=&quot;monospace&quot;&gt;published wiki&lt;/text&gt;&lt;text x=&quot;150&quot; y=&quot;220&quot; text-anchor=&quot;middle&quot; fill=&quot;#8a8071&quot; font-size=&quot;8&quot; font-family=&quot;monospace&quot;&gt;a projection&lt;/text&gt;&lt;rect x=&quot;280&quot; y=&quot;185&quot; width=&quot;140&quot; height=&quot;46&quot; rx=&quot;8&quot; fill=&quot;#221c16&quot; stroke=&quot;#443a2b&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;350&quot; y=&quot;205&quot; text-anchor=&quot;middle&quot; fill=&quot;#aba192&quot; font-size=&quot;9&quot; font-family=&quot;monospace&quot;&gt;search index&lt;/text&gt;&lt;text x=&quot;350&quot; y=&quot;220&quot; text-anchor=&quot;middle&quot; fill=&quot;#8a8071&quot; font-size=&quot;8&quot; font-family=&quot;monospace&quot;&gt;a projection&lt;/text&gt;&lt;rect x=&quot;480&quot; y=&quot;185&quot; width=&quot;140&quot; height=&quot;46&quot; rx=&quot;8&quot; fill=&quot;#221c16&quot; stroke=&quot;#443a2b&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;550&quot; y=&quot;205&quot; text-anchor=&quot;middle&quot; fill=&quot;#aba192&quot; font-size=&quot;9&quot; font-family=&quot;monospace&quot;&gt;backup mirror&lt;/text&gt;&lt;text x=&quot;550&quot; y=&quot;220&quot; text-anchor=&quot;middle&quot; fill=&quot;#8a8071&quot; font-size=&quot;8&quot; font-family=&quot;monospace&quot;&gt;a projection&lt;/text&gt;&lt;text x=&quot;350&quot; y=&quot;262&quot; text-anchor=&quot;middle&quot; fill=&quot;#e8a33e&quot; font-size=&quot;10&quot; font-family=&quot;monospace&quot;&gt;every view derives from the one repo — never forks it&lt;/text&gt;&lt;/svg&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;folder-taxonomy-para-with-stable-urls&quot;&gt;Folder taxonomy: PARA with stable URLs&lt;/h2&gt;
&lt;p&gt;Structure the vault so a page’s location reflects &lt;em&gt;what it is&lt;/em&gt;, not &lt;em&gt;what state it’s in&lt;/em&gt;. I use a lightly-adapted &lt;a href=&quot;https://fortelabs.com/blog/para/&quot;&gt;PARA&lt;/a&gt; layout:&lt;/p&gt;
&lt;figure class=&quot;terminal&quot; data-astro-cid-kenkipkp&gt;&lt;figcaption class=&quot;terminal-bar&quot; data-astro-cid-kenkipkp&gt;&lt;span class=&quot;dot red&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot yellow&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot green&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;terminal-title&quot; data-astro-cid-kenkipkp&gt;Vault top-level layout&lt;/span&gt;&lt;button class=&quot;terminal-copy&quot; type=&quot;button&quot; aria-label=&quot;Copy commands&quot; data-astro-cid-kenkipkp&gt;&lt;svg width=&quot;13&quot; height=&quot;13&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;none&quot; stroke=&quot;currentColor&quot; stroke-width=&quot;2&quot; stroke-linecap=&quot;round&quot; stroke-linejoin=&quot;round&quot; aria-hidden=&quot;true&quot; data-astro-cid-kenkipkp&gt;&lt;rect x=&quot;9&quot; y=&quot;9&quot; width=&quot;13&quot; height=&quot;13&quot; rx=&quot;2&quot; data-astro-cid-kenkipkp&gt;&lt;/rect&gt;&lt;path d=&quot;M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1&quot; data-astro-cid-kenkipkp&gt;&lt;/path&gt;&lt;/svg&gt;&lt;span class=&quot;terminal-copy-label&quot; data-astro-cid-kenkipkp&gt;Copy&lt;/span&gt;&lt;/button&gt;&lt;/figcaption&gt;&lt;pre class=&quot;terminal-body&quot; data-astro-cid-kenkipkp&gt;&lt;code data-astro-cid-kenkipkp&gt;
wiki/
home.md              # the map — links out to every hub
infrastructure.md    # hub: hardware, network, storage
services.md          # hub: deployed apps
projects.md          # hub: finite outcomes in progress
runbooks/            # executable procedures
decisions/           # durable choices + rationale
reference/           # reusable domain knowledge
journal/             # dated evidence, session records
&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;
&lt;div class=&quot;callout callout-warning&quot; data-astro-cid-q2ml7llr&gt;&lt;div class=&quot;callout-header&quot; data-astro-cid-q2ml7llr&gt;&lt;span class=&quot;callout-icon&quot; aria-hidden=&quot;true&quot; data-astro-cid-q2ml7llr&gt;⚠️&lt;/span&gt;&lt;span class=&quot;callout-label&quot; data-astro-cid-q2ml7llr&gt;Identity over status — the trap to avoid now&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;callout-body&quot; data-astro-cid-q2ml7llr&gt;&lt;p&gt;Do not organize by lifecycle. It is tempting to move a page from &lt;code&gt;projects/&lt;/code&gt; to &lt;code&gt;archive/&lt;/code&gt; when a project finishes — don’t. Moving a page changes its URL and breaks every link and bookmark pointing at it. A page’s folder should describe its &lt;em&gt;kind&lt;/em&gt;, permanently; its &lt;em&gt;status&lt;/em&gt; belongs in frontmatter. We devote a whole post to this later, but adopt the discipline from your first commit.&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;frontmatter-structured-metadata-on-every-page&quot;&gt;Frontmatter: structured metadata on every page&lt;/h2&gt;
&lt;p&gt;Every note opens with a YAML frontmatter block. This is what lets tools reason about your notes — filter by status, build indexes, and (later in this series) generate the graph.&lt;/p&gt;
&lt;figure class=&quot;terminal&quot; data-astro-cid-kenkipkp&gt;&lt;figcaption class=&quot;terminal-bar&quot; data-astro-cid-kenkipkp&gt;&lt;span class=&quot;dot red&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot yellow&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot green&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;terminal-title&quot; data-astro-cid-kenkipkp&gt;Standard frontmatter schema&lt;/span&gt;&lt;button class=&quot;terminal-copy&quot; type=&quot;button&quot; aria-label=&quot;Copy commands&quot; data-astro-cid-kenkipkp&gt;&lt;svg width=&quot;13&quot; height=&quot;13&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;none&quot; stroke=&quot;currentColor&quot; stroke-width=&quot;2&quot; stroke-linecap=&quot;round&quot; stroke-linejoin=&quot;round&quot; aria-hidden=&quot;true&quot; data-astro-cid-kenkipkp&gt;&lt;rect x=&quot;9&quot; y=&quot;9&quot; width=&quot;13&quot; height=&quot;13&quot; rx=&quot;2&quot; data-astro-cid-kenkipkp&gt;&lt;/rect&gt;&lt;path d=&quot;M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1&quot; data-astro-cid-kenkipkp&gt;&lt;/path&gt;&lt;/svg&gt;&lt;span class=&quot;terminal-copy-label&quot; data-astro-cid-kenkipkp&gt;Copy&lt;/span&gt;&lt;/button&gt;&lt;/figcaption&gt;&lt;pre class=&quot;terminal-body&quot; data-astro-cid-kenkipkp&gt;&lt;code data-astro-cid-kenkipkp&gt;
---
title: &amp;quot;Proxmox Cluster&amp;quot;
description: &amp;quot;The 4-node cluster: hosts, quorum, and shared storage.&amp;quot;
type: topic
status: active
tags: [infrastructure, proxmox, region/infrastructure]
topic: proxmox-cluster
relates: [storage-layout, cluster-networking]
created: 2026-06-17
updated: 2026-07-15
---
&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;
&lt;p&gt;The fields that earn their keep: &lt;code&gt;status&lt;/code&gt; (so lifecycle lives in metadata, not the path), &lt;code&gt;topic&lt;/code&gt; (the canonical owner id for this subject), and &lt;code&gt;relates&lt;/code&gt; (the links that later become the graph’s synapses). We build the generator that consumes &lt;code&gt;topic&lt;/code&gt; and &lt;code&gt;relates&lt;/code&gt; in &lt;a href=&quot;https://peira.dev/blog/second-brain-topic-graph-generator/&quot;&gt;a later post&lt;/a&gt;.&lt;/p&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;editing-the-vault-from-your-laptop&quot;&gt;Editing the vault from your laptop&lt;/h2&gt;
&lt;p&gt;Here’s the exact pattern I use. The canonical repo lives on the container; the laptop mounts it over &lt;a href=&quot;https://github.com/libfuse/sshfs&quot;&gt;SSHFS&lt;/a&gt; so &lt;a href=&quot;https://obsidian.md&quot;&gt;Obsidian&lt;/a&gt; edits the real files directly — no sync step, no second copy.&lt;/p&gt;
&lt;div class=&quot;step&quot; data-astro-cid-wwaqy5cd&gt;&lt;div class=&quot;step-header&quot; data-astro-cid-wwaqy5cd&gt;&lt;span class=&quot;step-num&quot; data-astro-cid-wwaqy5cd&gt;1&lt;/span&gt;&lt;span class=&quot;step-title&quot; data-astro-cid-wwaqy5cd&gt;Install SSHFS and set up key-based SSH&lt;/span&gt;&lt;span class=&quot;step-time&quot; data-astro-cid-wwaqy5cd&gt;5 min&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;step-body&quot; data-astro-cid-wwaqy5cd&gt;&lt;figure class=&quot;terminal&quot; data-astro-cid-kenkipkp&gt;&lt;figcaption class=&quot;terminal-bar&quot; data-astro-cid-kenkipkp&gt;&lt;span class=&quot;dot red&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot yellow&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot green&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;terminal-title&quot; data-astro-cid-kenkipkp&gt;On your laptop&lt;/span&gt;&lt;button class=&quot;terminal-copy&quot; type=&quot;button&quot; aria-label=&quot;Copy commands&quot; data-astro-cid-kenkipkp&gt;&lt;svg width=&quot;13&quot; height=&quot;13&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;none&quot; stroke=&quot;currentColor&quot; stroke-width=&quot;2&quot; stroke-linecap=&quot;round&quot; stroke-linejoin=&quot;round&quot; aria-hidden=&quot;true&quot; data-astro-cid-kenkipkp&gt;&lt;rect x=&quot;9&quot; y=&quot;9&quot; width=&quot;13&quot; height=&quot;13&quot; rx=&quot;2&quot; data-astro-cid-kenkipkp&gt;&lt;/rect&gt;&lt;path d=&quot;M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1&quot; data-astro-cid-kenkipkp&gt;&lt;/path&gt;&lt;/svg&gt;&lt;span class=&quot;terminal-copy-label&quot; data-astro-cid-kenkipkp&gt;Copy&lt;/span&gt;&lt;/button&gt;&lt;/figcaption&gt;&lt;pre class=&quot;terminal-body&quot; data-astro-cid-kenkipkp&gt;&lt;code data-astro-cid-kenkipkp&gt;
# Debian/Ubuntu/openSUSE
sudo zypper install sshfs   # or: sudo apt-get install -y sshfs

# Key-based auth to the vault host (no password prompts on mount)
ssh-copy-id vaultwriter@10.0.0.76
&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;&lt;/div&gt;
&lt;div class=&quot;step&quot; data-astro-cid-wwaqy5cd&gt;&lt;div class=&quot;step-header&quot; data-astro-cid-wwaqy5cd&gt;&lt;span class=&quot;step-num&quot; data-astro-cid-wwaqy5cd&gt;2&lt;/span&gt;&lt;span class=&quot;step-title&quot; data-astro-cid-wwaqy5cd&gt;Create a mountpoint and mount the vault&lt;/span&gt;&lt;span class=&quot;step-time&quot; data-astro-cid-wwaqy5cd&gt;2 min&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;step-body&quot; data-astro-cid-wwaqy5cd&gt;&lt;figure class=&quot;terminal&quot; data-astro-cid-kenkipkp&gt;&lt;figcaption class=&quot;terminal-bar&quot; data-astro-cid-kenkipkp&gt;&lt;span class=&quot;dot red&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot yellow&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot green&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;terminal-title&quot; data-astro-cid-kenkipkp&gt;On your laptop&lt;/span&gt;&lt;button class=&quot;terminal-copy&quot; type=&quot;button&quot; aria-label=&quot;Copy commands&quot; data-astro-cid-kenkipkp&gt;&lt;svg width=&quot;13&quot; height=&quot;13&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;none&quot; stroke=&quot;currentColor&quot; stroke-width=&quot;2&quot; stroke-linecap=&quot;round&quot; stroke-linejoin=&quot;round&quot; aria-hidden=&quot;true&quot; data-astro-cid-kenkipkp&gt;&lt;rect x=&quot;9&quot; y=&quot;9&quot; width=&quot;13&quot; height=&quot;13&quot; rx=&quot;2&quot; data-astro-cid-kenkipkp&gt;&lt;/rect&gt;&lt;path d=&quot;M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1&quot; data-astro-cid-kenkipkp&gt;&lt;/path&gt;&lt;/svg&gt;&lt;span class=&quot;terminal-copy-label&quot; data-astro-cid-kenkipkp&gt;Copy&lt;/span&gt;&lt;/button&gt;&lt;/figcaption&gt;&lt;pre class=&quot;terminal-body&quot; data-astro-cid-kenkipkp&gt;&lt;code data-astro-cid-kenkipkp&gt;
mkdir -p ~/wiki
sshfs vaultwriter@10.0.0.76:/wiki-data/git ~/wiki \
-o reconnect,ServerAliveInterval=15,ServerAliveCountMax=3
&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;p&gt;The &lt;code&gt;reconnect&lt;/code&gt; and &lt;code&gt;ServerAlive&lt;/code&gt; options keep the mount alive across brief network drops instead of leaving a dead handle.&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;
&lt;div class=&quot;step&quot; data-astro-cid-wwaqy5cd&gt;&lt;div class=&quot;step-header&quot; data-astro-cid-wwaqy5cd&gt;&lt;span class=&quot;step-num&quot; data-astro-cid-wwaqy5cd&gt;3&lt;/span&gt;&lt;span class=&quot;step-title&quot; data-astro-cid-wwaqy5cd&gt;Auto-mount on shell start (the bashrc pattern)&lt;/span&gt;&lt;span class=&quot;step-time&quot; data-astro-cid-wwaqy5cd&gt;3 min&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;step-body&quot; data-astro-cid-wwaqy5cd&gt;&lt;p&gt;Rather than a fragile fstab entry, mount lazily from your shell rc so it’s there whenever you open a terminal:&lt;/p&gt;&lt;figure class=&quot;terminal&quot; data-astro-cid-kenkipkp&gt;&lt;figcaption class=&quot;terminal-bar&quot; data-astro-cid-kenkipkp&gt;&lt;span class=&quot;dot red&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot yellow&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot green&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;terminal-title&quot; data-astro-cid-kenkipkp&gt;Append to ~/.bashrc&lt;/span&gt;&lt;button class=&quot;terminal-copy&quot; type=&quot;button&quot; aria-label=&quot;Copy commands&quot; data-astro-cid-kenkipkp&gt;&lt;svg width=&quot;13&quot; height=&quot;13&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;none&quot; stroke=&quot;currentColor&quot; stroke-width=&quot;2&quot; stroke-linecap=&quot;round&quot; stroke-linejoin=&quot;round&quot; aria-hidden=&quot;true&quot; data-astro-cid-kenkipkp&gt;&lt;rect x=&quot;9&quot; y=&quot;9&quot; width=&quot;13&quot; height=&quot;13&quot; rx=&quot;2&quot; data-astro-cid-kenkipkp&gt;&lt;/rect&gt;&lt;path d=&quot;M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1&quot; data-astro-cid-kenkipkp&gt;&lt;/path&gt;&lt;/svg&gt;&lt;span class=&quot;terminal-copy-label&quot; data-astro-cid-kenkipkp&gt;Copy&lt;/span&gt;&lt;/button&gt;&lt;/figcaption&gt;&lt;pre class=&quot;terminal-body&quot; data-astro-cid-kenkipkp&gt;&lt;code data-astro-cid-kenkipkp&gt;
# Mount the canonical vault if it isn&amp;#39;t already
if ! mountpoint -q &amp;quot;$HOME/wiki&amp;quot;; then
sshfs vaultwriter@10.0.0.76:/wiki-data/git &amp;quot;$HOME/wiki&amp;quot; \
  -o reconnect,ServerAliveInterval=15,ServerAliveCountMax=3 2&amp;gt;/dev/null
fi
&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;div class=&quot;callout callout-tip&quot; data-astro-cid-q2ml7llr&gt;&lt;div class=&quot;callout-header&quot; data-astro-cid-q2ml7llr&gt;&lt;span class=&quot;callout-icon&quot; aria-hidden=&quot;true&quot; data-astro-cid-q2ml7llr&gt;💡&lt;/span&gt;&lt;span class=&quot;callout-label&quot; data-astro-cid-q2ml7llr&gt;Point Obsidian at the mount&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;callout-body&quot; data-astro-cid-q2ml7llr&gt;&lt;p&gt;Open &lt;code&gt;~/wiki&lt;/code&gt; as an Obsidian vault. Obsidian now reads and writes the canonical files directly — graph view, backlinks, and daily notes all operate on the one true copy.&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;&lt;/div&gt;&lt;/div&gt;
&lt;div class=&quot;step&quot; data-astro-cid-wwaqy5cd&gt;&lt;div class=&quot;step-header&quot; data-astro-cid-wwaqy5cd&gt;&lt;span class=&quot;step-num&quot; data-astro-cid-wwaqy5cd&gt;4&lt;/span&gt;&lt;span class=&quot;step-title&quot; data-astro-cid-wwaqy5cd&gt;Commit your first note&lt;/span&gt;&lt;span class=&quot;step-time&quot; data-astro-cid-wwaqy5cd&gt;2 min&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;step-body&quot; data-astro-cid-wwaqy5cd&gt;&lt;figure class=&quot;terminal&quot; data-astro-cid-kenkipkp&gt;&lt;figcaption class=&quot;terminal-bar&quot; data-astro-cid-kenkipkp&gt;&lt;span class=&quot;dot red&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot yellow&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot green&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;terminal-title&quot; data-astro-cid-kenkipkp&gt;On your laptop, inside ~/wiki&lt;/span&gt;&lt;button class=&quot;terminal-copy&quot; type=&quot;button&quot; aria-label=&quot;Copy commands&quot; data-astro-cid-kenkipkp&gt;&lt;svg width=&quot;13&quot; height=&quot;13&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;none&quot; stroke=&quot;currentColor&quot; stroke-width=&quot;2&quot; stroke-linecap=&quot;round&quot; stroke-linejoin=&quot;round&quot; aria-hidden=&quot;true&quot; data-astro-cid-kenkipkp&gt;&lt;rect x=&quot;9&quot; y=&quot;9&quot; width=&quot;13&quot; height=&quot;13&quot; rx=&quot;2&quot; data-astro-cid-kenkipkp&gt;&lt;/rect&gt;&lt;path d=&quot;M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1&quot; data-astro-cid-kenkipkp&gt;&lt;/path&gt;&lt;/svg&gt;&lt;span class=&quot;terminal-copy-label&quot; data-astro-cid-kenkipkp&gt;Copy&lt;/span&gt;&lt;/button&gt;&lt;/figcaption&gt;&lt;pre class=&quot;terminal-body&quot; data-astro-cid-kenkipkp&gt;&lt;code data-astro-cid-kenkipkp&gt;
cd ~/wiki
$EDITOR home.md          # write your map page
git add home.md
git commit -m &amp;quot;Add vault home page&amp;quot;
&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;&lt;/div&gt;
&lt;p&gt;For the full copy-paste version with the interface-detection and teardown steps, use the paired playbook: &lt;strong&gt;Stand Up a Git + Markdown Vault&lt;/strong&gt;.&lt;/p&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;the-swap-in-box&quot;&gt;The swap-in box&lt;/h2&gt;
&lt;p&gt;The principle is tool-agnostic — only the worked example above is mine:&lt;/p&gt;
&lt;div class=&quot;callout callout-info&quot; data-astro-cid-q2ml7llr&gt;&lt;div class=&quot;callout-header&quot; data-astro-cid-q2ml7llr&gt;&lt;span class=&quot;callout-icon&quot; aria-hidden=&quot;true&quot; data-astro-cid-q2ml7llr&gt;ℹ️&lt;/span&gt;&lt;span class=&quot;callout-label&quot; data-astro-cid-q2ml7llr&gt;On other tools&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;callout-body&quot; data-astro-cid-q2ml7llr&gt;&lt;p&gt;&lt;strong&gt;Logseq / Dendron:&lt;/strong&gt; both are Markdown-on-Git already — point them at the same mounted repo. &lt;strong&gt;Notion / Confluence:&lt;/strong&gt; you can start there, but you don’t own the files; plan an export-to-Markdown escape hatch early. &lt;strong&gt;Plain Git, no editor:&lt;/strong&gt; everything here works with &lt;code&gt;git&lt;/code&gt; and &lt;code&gt;$EDITOR&lt;/code&gt; alone — Obsidian is a convenience, not a requirement. &lt;strong&gt;No always-on host yet:&lt;/strong&gt; a free-tier VPS or even a Raspberry Pi is enough to hold the canonical repo until you have a cluster.&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;what-you-have-now&quot;&gt;What you have now&lt;/h2&gt;
&lt;p&gt;One authoritative, versioned, plain-text vault that you edit from your laptop as if it were local. That’s the substrate. Everything else in this series — the graph, the published wiki, the AI integration — is a view or a guard built on top of it.&lt;/p&gt;
&lt;p&gt;The very next thing to get right, before the vault has anything valuable in it, is making sure you never commit a secret to it. That’s &lt;a href=&quot;https://peira.dev/blog/second-brain-secrets-discipline/&quot;&gt;the next post&lt;/a&gt;.&lt;/p&gt;
&lt;hr/&gt;
&lt;p&gt;&lt;strong&gt;Related posts:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/second-brain-homelab-why/&quot;&gt;Your Homelab Needs a Second Brain — Here’s Why&lt;/a&gt; — the case for building this in the first place&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/second-brain-secrets-discipline/&quot;&gt;Never Commit a Secret to Your Knowledge Base&lt;/a&gt; — the next step: keep credentials out of the vault permanently&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/second-brain-topic-graph-generator/&quot;&gt;Turn a Pile of Notes Into a Connected Brain&lt;/a&gt; — turn the flat vault into a connected graph&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/second-brain-identity-over-status/&quot;&gt;Never Move a Note When a Project Ends&lt;/a&gt; — how the folder taxonomy stays stable over time&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/second-brain-wikijs-projection/&quot;&gt;Turn Your Git Vault Into a Searchable Wiki.js Site&lt;/a&gt; — turn this substrate into a published site&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/second-brain-controlled-write-lock/&quot;&gt;Why Auto-Syncing Your Notes Repo Will Corrupt It&lt;/a&gt; — how to write to this authority safely&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/proxmox-create-lxc/&quot;&gt;How to Create an LXC Container on Proxmox&lt;/a&gt; — stand up the always-on host for the canonical repo&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/tailscale-subnet-router/&quot;&gt;Tailscale Subnet Router: Remote Access to Your Entire Homelab&lt;/a&gt; — reach the vault mount from anywhere&lt;/li&gt;
&lt;/ul&gt;</content:encoded><category>self-hosted</category><category>getting-started</category><category>automation</category></item><item><title>Your Homelab Needs a Second Brain — Here&apos;s Why</title><link>https://peira.dev/blog/second-brain-homelab-why/</link><guid isPermaLink="true">https://peira.dev/blog/second-brain-homelab-why/</guid><description>You&apos;ve solved the same homelab problem three times because you forgot how you fixed it. Here&apos;s the case for a single source of truth — and the payoff that compounds over years.</description><pubDate>Wed, 15 Jul 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;It’s 11pm. A container won’t boot. You &lt;em&gt;know&lt;/em&gt; you hit this exact error eight months ago, and you &lt;em&gt;know&lt;/em&gt; you fixed it — but the fix is gone. It lived in a terminal scrollback that’s long closed, or a Discord message you can’t find, or the part of your memory that got overwritten by everything since. So you debug it from scratch. Again.&lt;/p&gt;
&lt;p&gt;This post is about never doing that again.&lt;/p&gt;
&lt;div class=&quot;callout callout-tip&quot; data-astro-cid-q2ml7llr&gt;&lt;div class=&quot;callout-header&quot; data-astro-cid-q2ml7llr&gt;&lt;span class=&quot;callout-icon&quot; aria-hidden=&quot;true&quot; data-astro-cid-q2ml7llr&gt;💡&lt;/span&gt;&lt;span class=&quot;callout-label&quot; data-astro-cid-q2ml7llr&gt;The one-sentence version&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;callout-body&quot; data-astro-cid-q2ml7llr&gt;&lt;p&gt;A second brain is a single place that is always right about your lab — and building one is the difference between a homelab that gets &lt;em&gt;easier&lt;/em&gt; to run over time and one that quietly gets harder.&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;the-failure-mode-has-a-name&quot;&gt;The failure mode has a name&lt;/h2&gt;
&lt;p&gt;Most homelabbers don’t have &lt;em&gt;zero&lt;/em&gt; notes. They have notes in nine places: a &lt;code&gt;notes.txt&lt;/code&gt; on the desktop, some Obsidian vault they abandoned, comments in a couple of configs, a private GitHub gist, three browser bookmarks, and a memory that insists it’ll remember (it won’t).&lt;/p&gt;
&lt;p&gt;The problem isn’t the &lt;em&gt;amount&lt;/em&gt; of knowledge. It’s that no copy is authoritative. When two of them disagree — and they always eventually disagree — you don’t know which is right, so you trust none of them and re-derive the answer live. That’s the &lt;strong&gt;split-brain&lt;/strong&gt; failure mode, and it’s the single thing a second brain exists to kill.&lt;/p&gt;
&lt;svg viewBox=&quot;0 0 700 320&quot; xmlns=&quot;http://www.w3.org/2000/svg&quot; style=&quot;width:100%;border-radius:12px;background:#1c1712;margin:1.75rem 0;border:1px solid #443a2b&quot;&gt;&lt;defs&gt;&lt;pattern id=&quot;gp-secondbrainh0&quot; width=&quot;20&quot; height=&quot;20&quot; patternUnits=&quot;userSpaceOnUse&quot;&gt;&lt;path d=&quot;M 20 0 H 0 V 20&quot; fill=&quot;none&quot; stroke=&quot;#221c15&quot; stroke-width=&quot;1&quot;&gt;&lt;/path&gt;&lt;/pattern&gt;&lt;/defs&gt;&lt;rect x=&quot;0&quot; y=&quot;0&quot; width=&quot;700&quot; height=&quot;320&quot; fill=&quot;url(#gp-secondbrainh0)&quot;&gt;&lt;/rect&gt;&lt;rect x=&quot;6&quot; y=&quot;6&quot; width=&quot;688&quot; height=&quot;308&quot; fill=&quot;none&quot; stroke=&quot;#2e2820&quot; stroke-width=&quot;1&quot;&gt;&lt;/rect&gt;&lt;path d=&quot;M 11,16 H 21 M 16,11 V 21 M 679,16 H 689 M 684,11 V 21 M 11,304 H 21 M 16,299 V 309 M 679,304 H 689 M 684,299 V 309&quot; stroke=&quot;#4a3f2e&quot; stroke-width=&quot;1&quot; fill=&quot;none&quot;&gt;&lt;/path&gt;&lt;text x=&quot;350&quot; y=&quot;30&quot; text-anchor=&quot;middle&quot; fill=&quot;#f2ede3&quot; font-size=&quot;13&quot; font-family=&quot;monospace&quot; font-weight=&quot;bold&quot;&gt;Split brain vs. one source of truth&lt;/text&gt;&lt;line x1=&quot;350&quot; y1=&quot;50&quot; x2=&quot;350&quot; y2=&quot;298&quot; stroke=&quot;#443a2b&quot; stroke-width=&quot;1.5&quot; stroke-dasharray=&quot;4 5&quot;&gt;&lt;/line&gt;&lt;text x=&quot;180&quot; y=&quot;60&quot; text-anchor=&quot;middle&quot; fill=&quot;#d9a13d&quot; font-size=&quot;11&quot; font-family=&quot;monospace&quot; font-weight=&quot;bold&quot;&gt;scattered, out of sync&lt;/text&gt;&lt;path d=&quot;M95 110 L250 132 M150 178 L96 236 M150 178 L233 226 M250 132 L150 178&quot; stroke=&quot;#d4604f&quot; stroke-width=&quot;1.2&quot; fill=&quot;none&quot; opacity=&quot;0.65&quot;&gt;&lt;/path&gt;&lt;text x=&quot;200&quot; y=&quot;120&quot; fill=&quot;#d4604f&quot; font-size=&quot;13&quot; font-family=&quot;monospace&quot; opacity=&quot;0.8&quot;&gt;≠&lt;/text&gt;&lt;text x=&quot;120&quot; y=&quot;210&quot; fill=&quot;#d4604f&quot; font-size=&quot;13&quot; font-family=&quot;monospace&quot; opacity=&quot;0.8&quot;&gt;≠&lt;/text&gt;&lt;g transform=&quot;rotate(-8 95 110)&quot;&gt;&lt;rect x=&quot;45&quot; y=&quot;90&quot; width=&quot;100&quot; height=&quot;40&quot; rx=&quot;6&quot; fill=&quot;#262019&quot; stroke=&quot;#d4604f&quot; stroke-width=&quot;1.2&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;95&quot; y=&quot;114&quot; text-anchor=&quot;middle&quot; fill=&quot;#e5ddcd&quot; font-size=&quot;9&quot; font-family=&quot;monospace&quot;&gt;notes.txt&lt;/text&gt;&lt;/g&gt;&lt;g transform=&quot;rotate(7 252 132)&quot;&gt;&lt;rect x=&quot;205&quot; y=&quot;112&quot; width=&quot;95&quot; height=&quot;40&quot; rx=&quot;6&quot; fill=&quot;#262019&quot; stroke=&quot;#443a2b&quot; stroke-width=&quot;1.2&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;252&quot; y=&quot;136&quot; text-anchor=&quot;middle&quot; fill=&quot;#e5ddcd&quot; font-size=&quot;9&quot; font-family=&quot;monospace&quot;&gt;Discord&lt;/text&gt;&lt;/g&gt;&lt;g transform=&quot;rotate(-4 150 178)&quot;&gt;&lt;rect x=&quot;105&quot; y=&quot;160&quot; width=&quot;90&quot; height=&quot;36&quot; rx=&quot;6&quot; fill=&quot;#262019&quot; stroke=&quot;#443a2b&quot; stroke-width=&quot;1.2&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;150&quot; y=&quot;182&quot; text-anchor=&quot;middle&quot; fill=&quot;#e5ddcd&quot; font-size=&quot;9&quot; font-family=&quot;monospace&quot;&gt;old gist&lt;/text&gt;&lt;/g&gt;&lt;g transform=&quot;rotate(6 96 236)&quot;&gt;&lt;rect x=&quot;49&quot; y=&quot;218&quot; width=&quot;95&quot; height=&quot;38&quot; rx=&quot;6&quot; fill=&quot;#262019&quot; stroke=&quot;#443a2b&quot; stroke-width=&quot;1.2&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;96&quot; y=&quot;241&quot; text-anchor=&quot;middle&quot; fill=&quot;#e5ddcd&quot; font-size=&quot;9&quot; font-family=&quot;monospace&quot;&gt;memory&lt;/text&gt;&lt;/g&gt;&lt;g transform=&quot;rotate(-7 237 226)&quot;&gt;&lt;rect x=&quot;188&quot; y=&quot;208&quot; width=&quot;98&quot; height=&quot;38&quot; rx=&quot;6&quot; fill=&quot;#262019&quot; stroke=&quot;#443a2b&quot; stroke-width=&quot;1.2&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;237&quot; y=&quot;231&quot; text-anchor=&quot;middle&quot; fill=&quot;#e5ddcd&quot; font-size=&quot;9&quot; font-family=&quot;monospace&quot;&gt;bookmarks&lt;/text&gt;&lt;/g&gt;&lt;text x=&quot;180&quot; y=&quot;290&quot; text-anchor=&quot;middle&quot; fill=&quot;#d4604f&quot; font-size=&quot;10&quot; font-family=&quot;monospace&quot;&gt;which one is right?&lt;/text&gt;&lt;text x=&quot;520&quot; y=&quot;60&quot; text-anchor=&quot;middle&quot; fill=&quot;#e8a33e&quot; font-size=&quot;11&quot; font-family=&quot;monospace&quot; font-weight=&quot;bold&quot;&gt;one authoritative brain&lt;/text&gt;&lt;line x1=&quot;430&quot; y1=&quot;120&quot; x2=&quot;505&quot; y2=&quot;166&quot; stroke=&quot;#e8a33e&quot; stroke-width=&quot;1.2&quot;&gt;&lt;/line&gt;&lt;line x1=&quot;610&quot; y1=&quot;120&quot; x2=&quot;535&quot; y2=&quot;166&quot; stroke=&quot;#e8a33e&quot; stroke-width=&quot;1.2&quot;&gt;&lt;/line&gt;&lt;line x1=&quot;430&quot; y1=&quot;237&quot; x2=&quot;505&quot; y2=&quot;186&quot; stroke=&quot;#e8a33e&quot; stroke-width=&quot;1.2&quot;&gt;&lt;/line&gt;&lt;line x1=&quot;610&quot; y1=&quot;237&quot; x2=&quot;535&quot; y2=&quot;186&quot; stroke=&quot;#e8a33e&quot; stroke-width=&quot;1.2&quot;&gt;&lt;/line&gt;&lt;rect x=&quot;392&quot; y=&quot;105&quot; width=&quot;76&quot; height=&quot;30&quot; rx=&quot;6&quot; fill=&quot;#221c16&quot; stroke=&quot;#443a2b&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;430&quot; y=&quot;124&quot; text-anchor=&quot;middle&quot; fill=&quot;#aba192&quot; font-size=&quot;8&quot; font-family=&quot;monospace&quot;&gt;runbook&lt;/text&gt;&lt;rect x=&quot;572&quot; y=&quot;105&quot; width=&quot;76&quot; height=&quot;30&quot; rx=&quot;6&quot; fill=&quot;#221c16&quot; stroke=&quot;#443a2b&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;610&quot; y=&quot;124&quot; text-anchor=&quot;middle&quot; fill=&quot;#aba192&quot; font-size=&quot;8&quot; font-family=&quot;monospace&quot;&gt;config&lt;/text&gt;&lt;rect x=&quot;392&quot; y=&quot;222&quot; width=&quot;76&quot; height=&quot;30&quot; rx=&quot;6&quot; fill=&quot;#221c16&quot; stroke=&quot;#443a2b&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;430&quot; y=&quot;241&quot; text-anchor=&quot;middle&quot; fill=&quot;#aba192&quot; font-size=&quot;8&quot; font-family=&quot;monospace&quot;&gt;note&lt;/text&gt;&lt;rect x=&quot;572&quot; y=&quot;222&quot; width=&quot;76&quot; height=&quot;30&quot; rx=&quot;6&quot; fill=&quot;#221c16&quot; stroke=&quot;#443a2b&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;610&quot; y=&quot;241&quot; text-anchor=&quot;middle&quot; fill=&quot;#aba192&quot; font-size=&quot;8&quot; font-family=&quot;monospace&quot;&gt;index&lt;/text&gt;&lt;circle cx=&quot;520&quot; cy=&quot;176&quot; r=&quot;40&quot; fill=&quot;#262019&quot; stroke=&quot;#6f9bd1&quot; stroke-width=&quot;2&quot;&gt;&lt;/circle&gt;&lt;text x=&quot;520&quot; y=&quot;173&quot; text-anchor=&quot;middle&quot; fill=&quot;#6f9bd1&quot; font-size=&quot;9&quot; font-family=&quot;monospace&quot; font-weight=&quot;bold&quot;&gt;canonical&lt;/text&gt;&lt;text x=&quot;520&quot; y=&quot;187&quot; text-anchor=&quot;middle&quot; fill=&quot;#8a8071&quot; font-size=&quot;8&quot; font-family=&quot;monospace&quot;&gt;one home&lt;/text&gt;&lt;text x=&quot;520&quot; y=&quot;290&quot; text-anchor=&quot;middle&quot; fill=&quot;#e8a33e&quot; font-size=&quot;10&quot; font-family=&quot;monospace&quot;&gt;always the truth&lt;/text&gt;&lt;text x=&quot;690&quot; y=&quot;312&quot; text-anchor=&quot;end&quot; font-size=&quot;10&quot; fill=&quot;#8a8071&quot; opacity=&quot;0.6&quot; font-family=&quot;monospace&quot;&gt;peira.dev&lt;/text&gt;&lt;/svg&gt;
&lt;p&gt;The rule that fixes it is almost insultingly simple:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Every durable fact has exactly one home. Everything else points at it.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;That’s it. That’s the whole religion. The rest of this series is just the engineering that makes the rule hold up under real use — across your laptop, your cluster, and eventually your AI agents.&lt;/p&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;what-it-actually-buys-you&quot;&gt;What it actually buys you&lt;/h2&gt;
&lt;div style=&quot;display:grid;grid-template-columns:1fr 1fr;gap:1rem;margin:1.5rem 0&quot;&gt;&lt;div style=&quot;border:1px solid var(--border);border-radius:12px;padding:1rem&quot;&gt;&lt;p&gt;&lt;strong&gt;Month 1&lt;/strong&gt;&lt;br/&gt;
Mildly annoying. You’re writing things down that you “already know.”&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;border:1px solid var(--border);border-radius:12px;padding:1rem&quot;&gt;&lt;p&gt;&lt;strong&gt;Month 6&lt;/strong&gt;&lt;br/&gt;
You fix a problem in 5 minutes because past-you wrote the procedure. First real dividend.&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;border:1px solid var(--border);border-radius:12px;padding:1rem&quot;&gt;&lt;p&gt;&lt;strong&gt;Year 1&lt;/strong&gt;&lt;br/&gt;
You rebuild a dead node from your own runbooks without re-researching anything.&lt;/p&gt;&lt;/div&gt;&lt;div style=&quot;border:1px solid var(--border);border-radius:12px;padding:1rem&quot;&gt;&lt;p&gt;&lt;strong&gt;Year 2&lt;/strong&gt;&lt;br/&gt;
Your AI agents cold-start already knowing your lab, because the brain is their context.&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;
&lt;p&gt;The knowledge compounds because it’s &lt;em&gt;connected&lt;/em&gt;. One good note on why you chose &lt;a href=&quot;https://www.proxmox.com/en/proxmox-virtual-environment&quot;&gt;Proxmox VE&lt;/a&gt; over bare Docker links to the note on your storage layout, which links to the runbook for expanding it — and six months later that little web of links answers a question you didn’t know you’d have.&lt;/p&gt;
&lt;p&gt;That “web” is not a metaphor I’m reaching for. It’s the organizing model for the whole series.&lt;/p&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;thinking-of-it-as-a-brain&quot;&gt;Thinking of it as a brain&lt;/h2&gt;
&lt;p&gt;Throughout this series we’ll model the knowledge base as an actual brain — the idea of an external, associatively-linked memory is old and well-pedigreed, going back to Vannevar Bush’s 1945 &lt;a href=&quot;https://www.theatlantic.com/magazine/archive/1945/07/as-we-may-think/303881/&quot;&gt;memex&lt;/a&gt; — because the analogy is genuinely useful:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Neurons&lt;/strong&gt; are your topic pages — one per durable subject (one home per fact).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Axons and synapses&lt;/strong&gt; are the links between them — the connections that make knowledge more than a pile of files.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Glia&lt;/strong&gt; are the connective scaffolding — the generated maps and indexes that hold regions together so you don’t wire every link by hand.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Regions&lt;/strong&gt; are clusters of related topics — your networking brain, your storage brain, your AI brain.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;A pile of disconnected notes is a coma. A &lt;em&gt;brain&lt;/em&gt; is notes plus the connections between them. Everything we build — the vault, the graph, the projections, the AI integration — is in service of keeping those connections healthy and honest.&lt;/p&gt;
&lt;svg viewBox=&quot;0 0 700 300&quot; xmlns=&quot;http://www.w3.org/2000/svg&quot; style=&quot;width:100%;border-radius:12px;background:#1c1712;margin:1.75rem 0;border:1px solid #443a2b&quot;&gt;&lt;defs&gt;&lt;pattern id=&quot;gp-secondbrainh1&quot; width=&quot;20&quot; height=&quot;20&quot; patternUnits=&quot;userSpaceOnUse&quot;&gt;&lt;path d=&quot;M 20 0 H 0 V 20&quot; fill=&quot;none&quot; stroke=&quot;#221c15&quot; stroke-width=&quot;1&quot;&gt;&lt;/path&gt;&lt;/pattern&gt;&lt;/defs&gt;&lt;rect x=&quot;0&quot; y=&quot;0&quot; width=&quot;700&quot; height=&quot;300&quot; fill=&quot;url(#gp-secondbrainh1)&quot;&gt;&lt;/rect&gt;&lt;rect x=&quot;6&quot; y=&quot;6&quot; width=&quot;688&quot; height=&quot;288&quot; fill=&quot;none&quot; stroke=&quot;#2e2820&quot; stroke-width=&quot;1&quot;&gt;&lt;/rect&gt;&lt;path d=&quot;M 11,16 H 21 M 16,11 V 21 M 679,16 H 689 M 684,11 V 21 M 11,284 H 21 M 16,279 V 289 M 679,284 H 689 M 684,279 V 289&quot; stroke=&quot;#4a3f2e&quot; stroke-width=&quot;1&quot; fill=&quot;none&quot;&gt;&lt;/path&gt;&lt;text x=&quot;350&quot; y=&quot;28&quot; text-anchor=&quot;middle&quot; fill=&quot;#f2ede3&quot; font-size=&quot;13&quot; font-family=&quot;monospace&quot; font-weight=&quot;bold&quot;&gt;Notes, plus the connections between them&lt;/text&gt;&lt;rect x=&quot;40&quot; y=&quot;52&quot; width=&quot;285&quot; height=&quot;180&quot; rx=&quot;16&quot; fill=&quot;none&quot; stroke=&quot;#443a2b&quot; stroke-width=&quot;1.3&quot; stroke-dasharray=&quot;5 5&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;58&quot; y=&quot;74&quot; fill=&quot;#8a8071&quot; font-size=&quot;9&quot; font-family=&quot;monospace&quot;&gt;region: networking&lt;/text&gt;&lt;rect x=&quot;375&quot; y=&quot;52&quot; width=&quot;285&quot; height=&quot;180&quot; rx=&quot;16&quot; fill=&quot;none&quot; stroke=&quot;#443a2b&quot; stroke-width=&quot;1.3&quot; stroke-dasharray=&quot;5 5&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;393&quot; y=&quot;74&quot; fill=&quot;#8a8071&quot; font-size=&quot;9&quot; font-family=&quot;monospace&quot;&gt;region: storage&lt;/text&gt;&lt;line x1=&quot;110&quot; y1=&quot;130&quot; x2=&quot;200&quot; y2=&quot;108&quot; stroke=&quot;#e8a33e&quot; stroke-width=&quot;1.3&quot;&gt;&lt;/line&gt;&lt;line x1=&quot;200&quot; y1=&quot;108&quot; x2=&quot;255&quot; y2=&quot;185&quot; stroke=&quot;#e8a33e&quot; stroke-width=&quot;1.3&quot;&gt;&lt;/line&gt;&lt;line x1=&quot;255&quot; y1=&quot;185&quot; x2=&quot;140&quot; y2=&quot;200&quot; stroke=&quot;#e8a33e&quot; stroke-width=&quot;1.3&quot;&gt;&lt;/line&gt;&lt;line x1=&quot;110&quot; y1=&quot;130&quot; x2=&quot;140&quot; y2=&quot;200&quot; stroke=&quot;#e8a33e&quot; stroke-width=&quot;1.3&quot;&gt;&lt;/line&gt;&lt;line x1=&quot;450&quot; y1=&quot;120&quot; x2=&quot;545&quot; y2=&quot;150&quot; stroke=&quot;#e8a33e&quot; stroke-width=&quot;1.3&quot;&gt;&lt;/line&gt;&lt;line x1=&quot;545&quot; y1=&quot;150&quot; x2=&quot;600&quot; y2=&quot;205&quot; stroke=&quot;#e8a33e&quot; stroke-width=&quot;1.3&quot;&gt;&lt;/line&gt;&lt;line x1=&quot;545&quot; y1=&quot;150&quot; x2=&quot;475&quot; y2=&quot;200&quot; stroke=&quot;#e8a33e&quot; stroke-width=&quot;1.3&quot;&gt;&lt;/line&gt;&lt;line x1=&quot;255&quot; y1=&quot;185&quot; x2=&quot;450&quot; y2=&quot;120&quot; stroke=&quot;#6f9bd1&quot; stroke-width=&quot;1.3&quot; stroke-dasharray=&quot;3 3&quot;&gt;&lt;/line&gt;&lt;circle cx=&quot;110&quot; cy=&quot;130&quot; r=&quot;16&quot; fill=&quot;#262019&quot; stroke=&quot;#6f9bd1&quot; stroke-width=&quot;2&quot;&gt;&lt;/circle&gt;&lt;circle cx=&quot;200&quot; cy=&quot;108&quot; r=&quot;16&quot; fill=&quot;#262019&quot; stroke=&quot;#6f9bd1&quot; stroke-width=&quot;2&quot;&gt;&lt;/circle&gt;&lt;circle cx=&quot;255&quot; cy=&quot;185&quot; r=&quot;16&quot; fill=&quot;#262019&quot; stroke=&quot;#6f9bd1&quot; stroke-width=&quot;2&quot;&gt;&lt;/circle&gt;&lt;circle cx=&quot;140&quot; cy=&quot;200&quot; r=&quot;16&quot; fill=&quot;#262019&quot; stroke=&quot;#6f9bd1&quot; stroke-width=&quot;2&quot;&gt;&lt;/circle&gt;&lt;circle cx=&quot;450&quot; cy=&quot;120&quot; r=&quot;16&quot; fill=&quot;#262019&quot; stroke=&quot;#6f9bd1&quot; stroke-width=&quot;2&quot;&gt;&lt;/circle&gt;&lt;circle cx=&quot;545&quot; cy=&quot;150&quot; r=&quot;16&quot; fill=&quot;#262019&quot; stroke=&quot;#6f9bd1&quot; stroke-width=&quot;2&quot;&gt;&lt;/circle&gt;&lt;circle cx=&quot;600&quot; cy=&quot;205&quot; r=&quot;16&quot; fill=&quot;#262019&quot; stroke=&quot;#6f9bd1&quot; stroke-width=&quot;2&quot;&gt;&lt;/circle&gt;&lt;circle cx=&quot;475&quot; cy=&quot;200&quot; r=&quot;16&quot; fill=&quot;#262019&quot; stroke=&quot;#6f9bd1&quot; stroke-width=&quot;2&quot;&gt;&lt;/circle&gt;&lt;circle cx=&quot;200&quot; cy=&quot;108&quot; r=&quot;16&quot; fill=&quot;none&quot; stroke=&quot;#8fb573&quot; stroke-width=&quot;2&quot;&gt;&lt;/circle&gt;&lt;text x=&quot;200&quot; y=&quot;88&quot; text-anchor=&quot;middle&quot; fill=&quot;#8fb573&quot; font-size=&quot;8&quot; font-family=&quot;monospace&quot;&gt;neuron = topic page&lt;/text&gt;&lt;text x=&quot;352&quot; y=&quot;152&quot; text-anchor=&quot;middle&quot; fill=&quot;#6f9bd1&quot; font-size=&quot;8&quot; font-family=&quot;monospace&quot;&gt;cross-region link&lt;/text&gt;&lt;circle cx=&quot;70&quot; cy=&quot;272&quot; r=&quot;7&quot; fill=&quot;#262019&quot; stroke=&quot;#6f9bd1&quot; stroke-width=&quot;2&quot;&gt;&lt;/circle&gt;&lt;text x=&quot;84&quot; y=&quot;275&quot; fill=&quot;#aba192&quot; font-size=&quot;9&quot; font-family=&quot;monospace&quot;&gt;neuron&lt;/text&gt;&lt;line x1=&quot;200&quot; y1=&quot;272&quot; x2=&quot;240&quot; y2=&quot;272&quot; stroke=&quot;#e8a33e&quot; stroke-width=&quot;1.5&quot;&gt;&lt;/line&gt;&lt;text x=&quot;250&quot; y=&quot;275&quot; fill=&quot;#aba192&quot; font-size=&quot;9&quot; font-family=&quot;monospace&quot;&gt;synapse (link)&lt;/text&gt;&lt;rect x=&quot;400&quot; y=&quot;264&quot; width=&quot;18&quot; height=&quot;16&quot; rx=&quot;4&quot; fill=&quot;none&quot; stroke=&quot;#443a2b&quot; stroke-width=&quot;1.3&quot; stroke-dasharray=&quot;3 3&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;426&quot; y=&quot;275&quot; fill=&quot;#aba192&quot; font-size=&quot;9&quot; font-family=&quot;monospace&quot;&gt;region + glia (scaffolding)&lt;/text&gt;&lt;/svg&gt;
&lt;div class=&quot;callout callout-info&quot; data-astro-cid-q2ml7llr&gt;&lt;div class=&quot;callout-header&quot; data-astro-cid-q2ml7llr&gt;&lt;span class=&quot;callout-icon&quot; aria-hidden=&quot;true&quot; data-astro-cid-q2ml7llr&gt;ℹ️&lt;/span&gt;&lt;span class=&quot;callout-label&quot; data-astro-cid-q2ml7llr&gt;Where this series goes&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;callout-body&quot; data-astro-cid-q2ml7llr&gt;&lt;p&gt;We start with foundations any beginner can stand up in an afternoon (a Git-backed Markdown vault), then build outward: the graph structure, the published views, and finally AI agents that read the brain to work on your behalf. Each post teaches the durable idea first, then shows the exact stack I run — and drops a short “swap-in” box for readers on other tools.&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;the-honest-catch&quot;&gt;The honest catch&lt;/h2&gt;
&lt;p&gt;A second brain is a habit with a tooling assist, not a tool with a habit assist. The tooling in this series makes the habit &lt;em&gt;cheap&lt;/em&gt; — plain Markdown you already know how to write, a graph that builds itself, publishing that happens automatically — but you still have to write the note. The trick is to make writing it take thirty seconds and make &lt;em&gt;finding&lt;/em&gt; it later take five, so the trade is always obviously worth it.&lt;/p&gt;
&lt;p&gt;That trade is what the rest of this series builds. Next up: the vault itself — &lt;a href=&quot;https://peira.dev/blog/second-brain-git-markdown-vault/&quot;&gt;Git plus Markdown as one source of truth&lt;/a&gt; — the substrate everything else sits on. If you want the version that stays out of trouble from day one, the very next thing after that is &lt;a href=&quot;https://peira.dev/blog/second-brain-secrets-discipline/&quot;&gt;secrets discipline&lt;/a&gt;, because the fastest way to ruin a public-ish knowledge base is to commit a password to it once.&lt;/p&gt;
&lt;hr/&gt;
&lt;p&gt;&lt;strong&gt;Related posts:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/second-brain-git-markdown-vault/&quot;&gt;Never Lose a Homelab Note Again: A Git + Markdown Vault&lt;/a&gt; — the next step: stand up the substrate everything else sits on&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/second-brain-secrets-discipline/&quot;&gt;Never Commit a Secret to Your Knowledge Base&lt;/a&gt; — never commit a credential to your brain, and what to do if you already have&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/second-brain-topic-graph-generator/&quot;&gt;Turn a Pile of Notes Into a Connected Brain&lt;/a&gt; — the graph structure that makes notes compound&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/second-brain-identity-over-status/&quot;&gt;Never Move a Note When a Project Ends&lt;/a&gt; — the discipline that keeps the graph intact for years&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/second-brain-ai-begin-end-skills/&quot;&gt;Make Your AI Agents Read the Docs First, Every Time&lt;/a&gt; — the year-two payoff: agents that read your brain as context&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/second-brain-repair-rebuild/&quot;&gt;Already Made a Mess of Your Notes? Here’s the Fix&lt;/a&gt; — the recovery path if you skip the rules&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/hermes-agent-setup/&quot;&gt;Hermes Agent: Self-Hosted AI Agent with Fallback Model Chains&lt;/a&gt; — the kind of agent that later reads your brain as context&lt;/li&gt;
&lt;/ul&gt;</content:encoded><category>self-hosted</category><category>getting-started</category><category>infrastructure</category></item><item><title>Never Move a Note When a Project Ends</title><link>https://peira.dev/blog/second-brain-identity-over-status/</link><guid isPermaLink="true">https://peira.dev/blog/second-brain-identity-over-status/</guid><description>When a project ends, don&apos;t move its notes to an archive folder — you break every link and bookmark pointing at them. Put lifecycle in frontmatter, not the file path.</description><pubDate>Wed, 15 Jul 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;This is a short one, because the rule is short — but skipping it quietly rots a knowledge base over years. It’s one of the &lt;code&gt;⚠️&lt;/code&gt; mistakes this series flags because it &lt;em&gt;feels&lt;/em&gt; like tidying up while it’s actually breaking things.&lt;/p&gt;
&lt;div class=&quot;callout callout-warning&quot; data-astro-cid-q2ml7llr&gt;&lt;div class=&quot;callout-header&quot; data-astro-cid-q2ml7llr&gt;&lt;span class=&quot;callout-icon&quot; aria-hidden=&quot;true&quot; data-astro-cid-q2ml7llr&gt;⚠️&lt;/span&gt;&lt;span class=&quot;callout-label&quot; data-astro-cid-q2ml7llr&gt;The rule&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;callout-body&quot; data-astro-cid-q2ml7llr&gt;&lt;p&gt;A page’s &lt;strong&gt;location is its identity&lt;/strong&gt; — never move it because its &lt;strong&gt;status&lt;/strong&gt; changed. Lifecycle (active, paused, archived) lives in frontmatter. The moment you drag a finished project’s notes into &lt;code&gt;archive/&lt;/code&gt;, you break every link and bookmark that pointed at the old path.&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;
&lt;div class=&quot;callout callout-warning&quot; data-astro-cid-q2ml7llr&gt;&lt;div class=&quot;callout-header&quot; data-astro-cid-q2ml7llr&gt;&lt;span class=&quot;callout-icon&quot; aria-hidden=&quot;true&quot; data-astro-cid-q2ml7llr&gt;⚠️&lt;/span&gt;&lt;span class=&quot;callout-label&quot; data-astro-cid-q2ml7llr&gt;First: make these values your own&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;callout-body&quot; data-astro-cid-q2ml7llr&gt;&lt;p&gt;The &lt;code&gt;title&lt;/code&gt; and &lt;code&gt;status&lt;/code&gt; in the frontmatter examples are just an illustration — &lt;strong&gt;use your own page’s values.&lt;/strong&gt; The point is the &lt;em&gt;field&lt;/em&gt; (&lt;code&gt;status:&lt;/code&gt; living in metadata), not the example text.&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;why-moving-a-page-is-destructive&quot;&gt;Why moving a page is destructive&lt;/h2&gt;
&lt;p&gt;In the &lt;a href=&quot;https://peira.dev/blog/second-brain-topic-graph-generator/&quot;&gt;previous post&lt;/a&gt; we turned the vault into a graph of links. A link is an address. Move a page and every address pointing at it goes stale at once:&lt;/p&gt;
&lt;svg viewBox=&quot;0 0 700 210&quot; xmlns=&quot;http://www.w3.org/2000/svg&quot; style=&quot;width:100%;border-radius:12px;background:#1c1712;margin:1.5rem 0;border:1px solid #443a2b&quot;&gt;&lt;defs&gt;&lt;pattern id=&quot;gp-secondbraini0&quot; width=&quot;20&quot; height=&quot;20&quot; patternUnits=&quot;userSpaceOnUse&quot;&gt;&lt;path d=&quot;M 20 0 H 0 V 20&quot; fill=&quot;none&quot; stroke=&quot;#221c15&quot; stroke-width=&quot;1&quot;&gt;&lt;/path&gt;&lt;/pattern&gt;&lt;/defs&gt;&lt;rect x=&quot;0&quot; y=&quot;0&quot; width=&quot;700&quot; height=&quot;210&quot; fill=&quot;url(#gp-secondbraini0)&quot;&gt;&lt;/rect&gt;&lt;rect x=&quot;6&quot; y=&quot;6&quot; width=&quot;688&quot; height=&quot;198&quot; fill=&quot;none&quot; stroke=&quot;#2e2820&quot; stroke-width=&quot;1&quot;&gt;&lt;/rect&gt;&lt;path d=&quot;M 11,16 H 21 M 16,11 V 21 M 679,16 H 689 M 684,11 V 21 M 11,194 H 21 M 16,189 V 199 M 679,194 H 689 M 684,189 V 199&quot; stroke=&quot;#4a3f2e&quot; stroke-width=&quot;1&quot; fill=&quot;none&quot;&gt;&lt;/path&gt;&lt;text x=&quot;350&quot; y=&quot;28&quot; text-anchor=&quot;middle&quot; fill=&quot;#f2ede3&quot; font-size=&quot;13&quot; font-family=&quot;monospace&quot; font-weight=&quot;bold&quot;&gt;Move a page, break every reference to it&lt;/text&gt;&lt;rect x=&quot;40&quot; y=&quot;60&quot; width=&quot;150&quot; height=&quot;34&quot; rx=&quot;6&quot; fill=&quot;#221c16&quot; stroke=&quot;#443a2b&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;115&quot; y=&quot;82&quot; text-anchor=&quot;middle&quot; fill=&quot;#aba192&quot; font-size=&quot;9&quot; font-family=&quot;monospace&quot;&gt;another note&lt;/text&gt;&lt;rect x=&quot;40&quot; y=&quot;105&quot; width=&quot;150&quot; height=&quot;34&quot; rx=&quot;6&quot; fill=&quot;#221c16&quot; stroke=&quot;#443a2b&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;115&quot; y=&quot;127&quot; text-anchor=&quot;middle&quot; fill=&quot;#aba192&quot; font-size=&quot;9&quot; font-family=&quot;monospace&quot;&gt;published URL&lt;/text&gt;&lt;rect x=&quot;40&quot; y=&quot;150&quot; width=&quot;150&quot; height=&quot;34&quot; rx=&quot;6&quot; fill=&quot;#221c16&quot; stroke=&quot;#443a2b&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;115&quot; y=&quot;172&quot; text-anchor=&quot;middle&quot; fill=&quot;#aba192&quot; font-size=&quot;9&quot; font-family=&quot;monospace&quot;&gt;your bookmark&lt;/text&gt;&lt;line x1=&quot;190&quot; y1=&quot;77&quot; x2=&quot;300&quot; y2=&quot;118&quot; stroke=&quot;#d4604f&quot; stroke-width=&quot;1.3&quot; stroke-dasharray=&quot;4 4&quot;&gt;&lt;/line&gt;&lt;line x1=&quot;190&quot; y1=&quot;122&quot; x2=&quot;300&quot; y2=&quot;122&quot; stroke=&quot;#d4604f&quot; stroke-width=&quot;1.3&quot; stroke-dasharray=&quot;4 4&quot;&gt;&lt;/line&gt;&lt;line x1=&quot;190&quot; y1=&quot;167&quot; x2=&quot;300&quot; y2=&quot;126&quot; stroke=&quot;#d4604f&quot; stroke-width=&quot;1.3&quot; stroke-dasharray=&quot;4 4&quot;&gt;&lt;/line&gt;&lt;text x=&quot;245&quot; y=&quot;105&quot; text-anchor=&quot;middle&quot; fill=&quot;#d4604f&quot; font-size=&quot;14&quot; font-family=&quot;monospace&quot;&gt;✕&lt;/text&gt;&lt;rect x=&quot;300&quot; y=&quot;104&quot; width=&quot;180&quot; height=&quot;40&quot; rx=&quot;8&quot; fill=&quot;#262019&quot; stroke=&quot;#d4604f&quot; stroke-width=&quot;1.5&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;390&quot; y=&quot;122&quot; text-anchor=&quot;middle&quot; fill=&quot;#d4604f&quot; font-size=&quot;9&quot; font-family=&quot;monospace&quot;&gt;projects/thing.md&lt;/text&gt;&lt;text x=&quot;390&quot; y=&quot;136&quot; text-anchor=&quot;middle&quot; fill=&quot;#8a8071&quot; font-size=&quot;8&quot; font-family=&quot;monospace&quot;&gt;→ moved to archive/&lt;/text&gt;&lt;line x1=&quot;480&quot; y1=&quot;124&quot; x2=&quot;540&quot; y2=&quot;124&quot; stroke=&quot;#8a8071&quot; stroke-width=&quot;1.3&quot;&gt;&lt;/line&gt;&lt;rect x=&quot;540&quot; y=&quot;104&quot; width=&quot;130&quot; height=&quot;40&quot; rx=&quot;8&quot; fill=&quot;#262019&quot; stroke=&quot;#e8a33e&quot; stroke-width=&quot;1.5&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;605&quot; y=&quot;122&quot; text-anchor=&quot;middle&quot; fill=&quot;#e8a33e&quot; font-size=&quot;9&quot; font-family=&quot;monospace&quot;&gt;same path,&lt;/text&gt;&lt;text x=&quot;605&quot; y=&quot;136&quot; text-anchor=&quot;middle&quot; fill=&quot;#e8a33e&quot; font-size=&quot;9&quot; font-family=&quot;monospace&quot;&gt;status: archived&lt;/text&gt;&lt;text x=&quot;690&quot; y=&quot;202&quot; text-anchor=&quot;end&quot; font-size=&quot;10&quot; fill=&quot;#8a8071&quot; opacity=&quot;0.6&quot; font-family=&quot;monospace&quot;&gt;peira.dev&lt;/text&gt;&lt;/svg&gt;
&lt;p&gt;The published wiki gives that page a stable URL derived from its path; a search index keys results by path; your browser bookmark &lt;em&gt;is&lt;/em&gt; the path. This is exactly the point the W3C’s &lt;a href=&quot;https://www.w3.org/Provider/Style/URI&quot;&gt;Cool URIs Don’t Change&lt;/a&gt; has made since 1998: the address you publish is a promise, and moving it breaks that promise for everyone who kept it. One “cleanup” move invalidates all three silently — nothing errors, links just quietly rot.&lt;/p&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;do-this-instead&quot;&gt;Do this instead&lt;/h2&gt;
&lt;p&gt;Leave the file where it is and change one frontmatter field:&lt;/p&gt;
&lt;pre class=&quot;astro-code gruvbox-dark-hard&quot; style=&quot;background-color:#1d2021;color:#ebdbb2;overflow-x:auto;white-space:pre-wrap;word-wrap:break-word&quot; tabindex=&quot;0&quot; data-language=&quot;yaml&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#EBDBB2&quot;&gt;---&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#8EC07C&quot;&gt;title&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt;:&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt; &amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#B8BB26&quot;&gt;Old Minecraft Server Project&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt;&amp;quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#8EC07C&quot;&gt;type&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt;:&lt;/span&gt;&lt;span style=&quot;color:#B8BB26&quot;&gt; project&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#8EC07C&quot;&gt;status&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt;:&lt;/span&gt;&lt;span style=&quot;color:#B8BB26&quot;&gt; archived&lt;/span&gt;&lt;span style=&quot;color:#928374;font-style:italic&quot;&gt;        # was: active&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#8EC07C&quot;&gt;updated&lt;/span&gt;&lt;span style=&quot;color:#A89984&quot;&gt;:&lt;/span&gt;&lt;span style=&quot;color:#D3869B&quot;&gt; 2026-07-15&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#EBDBB2&quot;&gt;---&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Now every dashboard, index, and MOC that filters on &lt;code&gt;status&lt;/code&gt; can dim, badge, or hide the page — while its address, and every link to it, stays valid forever. Lifecycle became a &lt;em&gt;view&lt;/em&gt;, not a &lt;em&gt;move&lt;/em&gt;.&lt;/p&gt;
&lt;div class=&quot;callout callout-tip&quot; data-astro-cid-q2ml7llr&gt;&lt;div class=&quot;callout-header&quot; data-astro-cid-q2ml7llr&gt;&lt;span class=&quot;callout-icon&quot; aria-hidden=&quot;true&quot; data-astro-cid-q2ml7llr&gt;💡&lt;/span&gt;&lt;span class=&quot;callout-label&quot; data-astro-cid-q2ml7llr&gt;Kind is the folder; status is the field&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;callout-body&quot; data-astro-cid-q2ml7llr&gt;&lt;p&gt;Ask “what &lt;em&gt;is&lt;/em&gt; this page?” not “what &lt;em&gt;state&lt;/em&gt; is it in?” A runbook is always a runbook. A decision is always a decision. The answer to “what is it” picks the folder once and never changes; the answer to “what state” goes in &lt;code&gt;status&lt;/code&gt; and changes freely.&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;the-swap-in-box&quot;&gt;The swap-in box&lt;/h2&gt;
&lt;div class=&quot;callout callout-info&quot; data-astro-cid-q2ml7llr&gt;&lt;div class=&quot;callout-header&quot; data-astro-cid-q2ml7llr&gt;&lt;span class=&quot;callout-icon&quot; aria-hidden=&quot;true&quot; data-astro-cid-q2ml7llr&gt;ℹ️&lt;/span&gt;&lt;span class=&quot;callout-label&quot; data-astro-cid-q2ml7llr&gt;On other tools&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;callout-body&quot; data-astro-cid-q2ml7llr&gt;&lt;p&gt;&lt;strong&gt;Any static-site or wiki generator&lt;/strong&gt; (Astro, Hugo, Wiki.js, MkDocs) derives URLs from file paths — so this rule is universal, not specific to my stack. &lt;strong&gt;Notion/Confluence:&lt;/strong&gt; use a Status property and a filtered view; never change a page’s parent to represent lifecycle. &lt;strong&gt;If you must reorganize&lt;/strong&gt; (a genuinely wrong original location, not a status change), add a redirect from the old path so existing links survive the move.&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;why-this-earns-its-own-post&quot;&gt;Why this earns its own post&lt;/h2&gt;
&lt;p&gt;Because it’s the mistake you make &lt;em&gt;while feeling productive&lt;/em&gt;. Tidying finished projects into an archive folder looks like good hygiene and is actually link rot in slow motion. Adopt “identity over status” from your first project and the graph you built in the last post stays intact for years.&lt;/p&gt;
&lt;p&gt;Next we move from structure to &lt;strong&gt;projection&lt;/strong&gt; — deriving a published &lt;a href=&quot;https://js.wiki&quot;&gt;Wiki.js&lt;/a&gt; site from the one authoritative vault, where these stable paths become stable public URLs: &lt;a href=&quot;https://peira.dev/blog/second-brain-wikijs-projection/&quot;&gt;one authority, many views&lt;/a&gt;.&lt;/p&gt;
&lt;hr/&gt;
&lt;p&gt;&lt;strong&gt;Related posts:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/second-brain-topic-graph-generator/&quot;&gt;Turn a Pile of Notes Into a Connected Brain&lt;/a&gt; — the graph of links this rule protects&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/second-brain-git-markdown-vault/&quot;&gt;Never Lose a Homelab Note Again: A Git + Markdown Vault&lt;/a&gt; — where the folder taxonomy is set up&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/second-brain-wikijs-projection/&quot;&gt;Turn Your Git Vault Into a Searchable Wiki.js Site&lt;/a&gt; — where stable paths become stable public URLs&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/second-brain-homelab-why/&quot;&gt;Your Homelab Needs a Second Brain — Here’s Why&lt;/a&gt; — the single-authority principle behind all of this&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/second-brain-repair-rebuild/&quot;&gt;Already Made a Mess of Your Notes? Here’s the Fix&lt;/a&gt; — undoing the damage when the rules were broken&lt;/li&gt;
&lt;/ul&gt;</content:encoded><category>self-hosted</category><category>getting-started</category><category>automation</category></item><item><title>Semantic Search for Your Notes, Running Locally</title><link>https://peira.dev/blog/second-brain-local-rag-embeddings/</link><guid isPermaLink="true">https://peira.dev/blog/second-brain-local-rag-embeddings/</guid><description>Give your knowledge vault semantic search with a local embeddings index — built on Ollama and nomic-embed-text, cached by content hash, and labeled with the commit it was built from so staleness is always visible.</description><pubDate>Wed, 15 Jul 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Keyword search finds the page with the right words. Semantic search finds the page with the right &lt;em&gt;meaning&lt;/em&gt; — the one you’d recognize but can’t quite grep for. This post adds that to the vault with a local embeddings index — the retrieval half of retrieval-augmented generation, the technique introduced by &lt;a href=&quot;https://arxiv.org/abs/2005.11401&quot;&gt;Lewis et al. (2020)&lt;/a&gt; — built as a proper derived consumer that always knows which revision it represents.&lt;/p&gt;
&lt;div class=&quot;callout callout-tip&quot; data-astro-cid-q2ml7llr&gt;&lt;div class=&quot;callout-header&quot; data-astro-cid-q2ml7llr&gt;&lt;span class=&quot;callout-icon&quot; aria-hidden=&quot;true&quot; data-astro-cid-q2ml7llr&gt;💡&lt;/span&gt;&lt;span class=&quot;callout-label&quot; data-astro-cid-q2ml7llr&gt;The principle&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;callout-body&quot; data-astro-cid-q2ml7llr&gt;&lt;p&gt;Semantic search is another &lt;strong&gt;projection&lt;/strong&gt; — a derived, revision-labeled consumer, never a second authority. Build it locally, cache by content hash so rebuilds are cheap, stamp it with the vault commit, and confirm its hits against the canonical page. Stale is allowed; stale-and-hidden is not.&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;
&lt;div class=&quot;callout callout-warning&quot; data-astro-cid-q2ml7llr&gt;&lt;div class=&quot;callout-header&quot; data-astro-cid-q2ml7llr&gt;&lt;span class=&quot;callout-icon&quot; aria-hidden=&quot;true&quot; data-astro-cid-q2ml7llr&gt;⚠️&lt;/span&gt;&lt;span class=&quot;callout-label&quot; data-astro-cid-q2ml7llr&gt;First: make these values your own&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;callout-body&quot; data-astro-cid-q2ml7llr&gt;&lt;p&gt;Replace the stand-ins: &lt;strong&gt;&lt;code&gt;10.0.0.65&lt;/code&gt;/&lt;code&gt;.66&lt;/code&gt;/&lt;code&gt;.67&lt;/code&gt;&lt;/strong&gt; → your own Ollama node IPs (running a single machine? point every node reference at that one host); &lt;strong&gt;&lt;code&gt;11434&lt;/code&gt;&lt;/strong&gt; is Ollama’s default port; the commit &lt;strong&gt;&lt;code&gt;64dc195&lt;/code&gt;&lt;/strong&gt; is illustrative.&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;the-index-pipeline&quot;&gt;The index pipeline&lt;/h2&gt;
&lt;svg viewBox=&quot;0 0 700 230&quot; xmlns=&quot;http://www.w3.org/2000/svg&quot; style=&quot;width:100%;border-radius:12px;background:#1c1712;margin:1.5rem 0;border:1px solid #443a2b&quot;&gt;&lt;defs&gt;&lt;pattern id=&quot;gp-secondbrainl0&quot; width=&quot;20&quot; height=&quot;20&quot; patternUnits=&quot;userSpaceOnUse&quot;&gt;&lt;path d=&quot;M 20 0 H 0 V 20&quot; fill=&quot;none&quot; stroke=&quot;#221c15&quot; stroke-width=&quot;1&quot;&gt;&lt;/path&gt;&lt;/pattern&gt;&lt;/defs&gt;&lt;rect x=&quot;0&quot; y=&quot;0&quot; width=&quot;700&quot; height=&quot;230&quot; fill=&quot;url(#gp-secondbrainl0)&quot;&gt;&lt;/rect&gt;&lt;rect x=&quot;6&quot; y=&quot;6&quot; width=&quot;688&quot; height=&quot;218&quot; fill=&quot;none&quot; stroke=&quot;#2e2820&quot; stroke-width=&quot;1&quot;&gt;&lt;/rect&gt;&lt;path d=&quot;M 11,16 H 21 M 16,11 V 21 M 679,16 H 689 M 684,11 V 21 M 11,214 H 21 M 16,209 V 219 M 679,214 H 689 M 684,209 V 219&quot; stroke=&quot;#4a3f2e&quot; stroke-width=&quot;1&quot; fill=&quot;none&quot;&gt;&lt;/path&gt;&lt;text x=&quot;350&quot; y=&quot;28&quot; text-anchor=&quot;middle&quot; fill=&quot;#f2ede3&quot; font-size=&quot;13&quot; font-family=&quot;monospace&quot; font-weight=&quot;bold&quot;&gt;Build a revision-labeled embeddings index&lt;/text&gt;&lt;rect x=&quot;30&quot; y=&quot;65&quot; width=&quot;120&quot; height=&quot;54&quot; rx=&quot;9&quot; fill=&quot;#262019&quot; stroke=&quot;#6f9bd1&quot; stroke-width=&quot;1.5&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;90&quot; y=&quot;88&quot; text-anchor=&quot;middle&quot; fill=&quot;#6f9bd1&quot; font-size=&quot;10&quot; font-family=&quot;monospace&quot; font-weight=&quot;bold&quot;&gt;chunk&lt;/text&gt;&lt;text x=&quot;90&quot; y=&quot;104&quot; text-anchor=&quot;middle&quot; fill=&quot;#8a8071&quot; font-size=&quot;8&quot; font-family=&quot;monospace&quot;&gt;by section&lt;/text&gt;&lt;rect x=&quot;175&quot; y=&quot;65&quot; width=&quot;130&quot; height=&quot;54&quot; rx=&quot;9&quot; fill=&quot;#262019&quot; stroke=&quot;#6f9bd1&quot; stroke-width=&quot;1.5&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;240&quot; y=&quot;84&quot; text-anchor=&quot;middle&quot; fill=&quot;#6f9bd1&quot; font-size=&quot;10&quot; font-family=&quot;monospace&quot; font-weight=&quot;bold&quot;&gt;hash cache&lt;/text&gt;&lt;text x=&quot;240&quot; y=&quot;100&quot; text-anchor=&quot;middle&quot; fill=&quot;#8a8071&quot; font-size=&quot;8&quot; font-family=&quot;monospace&quot;&gt;skip unchanged&lt;/text&gt;&lt;text x=&quot;240&quot; y=&quot;112&quot; text-anchor=&quot;middle&quot; fill=&quot;#8a8071&quot; font-size=&quot;8&quot; font-family=&quot;monospace&quot;&gt;chunks&lt;/text&gt;&lt;rect x=&quot;330&quot; y=&quot;65&quot; width=&quot;150&quot; height=&quot;54&quot; rx=&quot;9&quot; fill=&quot;#262019&quot; stroke=&quot;#e8a33e&quot; stroke-width=&quot;1.8&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;405&quot; y=&quot;84&quot; text-anchor=&quot;middle&quot; fill=&quot;#e8a33e&quot; font-size=&quot;10&quot; font-family=&quot;monospace&quot; font-weight=&quot;bold&quot;&gt;embed (Ollama)&lt;/text&gt;&lt;text x=&quot;405&quot; y=&quot;100&quot; text-anchor=&quot;middle&quot; fill=&quot;#8a8071&quot; font-size=&quot;8&quot; font-family=&quot;monospace&quot;&gt;nomic-embed-text&lt;/text&gt;&lt;text x=&quot;405&quot; y=&quot;112&quot; text-anchor=&quot;middle&quot; fill=&quot;#8a8071&quot; font-size=&quot;8&quot; font-family=&quot;monospace&quot;&gt;nodes .65/.66/.67&lt;/text&gt;&lt;rect x=&quot;505&quot; y=&quot;65&quot; width=&quot;165&quot; height=&quot;54&quot; rx=&quot;9&quot; fill=&quot;#262019&quot; stroke=&quot;#8fb573&quot; stroke-width=&quot;1.8&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;587&quot; y=&quot;84&quot; text-anchor=&quot;middle&quot; fill=&quot;#8fb573&quot; font-size=&quot;10&quot; font-family=&quot;monospace&quot; font-weight=&quot;bold&quot;&gt;atomic swap&lt;/text&gt;&lt;text x=&quot;587&quot; y=&quot;100&quot; text-anchor=&quot;middle&quot; fill=&quot;#8a8071&quot; font-size=&quot;8&quot; font-family=&quot;monospace&quot;&gt;stamp commit&lt;/text&gt;&lt;text x=&quot;587&quot; y=&quot;112&quot; text-anchor=&quot;middle&quot; fill=&quot;#8a8071&quot; font-size=&quot;8&quot; font-family=&quot;monospace&quot;&gt;64dc195&lt;/text&gt;&lt;line x1=&quot;150&quot; y1=&quot;92&quot; x2=&quot;175&quot; y2=&quot;92&quot; stroke=&quot;#e8a33e&quot; stroke-width=&quot;1.5&quot;&gt;&lt;/line&gt;&lt;line x1=&quot;305&quot; y1=&quot;92&quot; x2=&quot;330&quot; y2=&quot;92&quot; stroke=&quot;#e8a33e&quot; stroke-width=&quot;1.5&quot;&gt;&lt;/line&gt;&lt;line x1=&quot;480&quot; y1=&quot;92&quot; x2=&quot;505&quot; y2=&quot;92&quot; stroke=&quot;#e8a33e&quot; stroke-width=&quot;1.5&quot;&gt;&lt;/line&gt;&lt;rect x=&quot;230&quot; y=&quot;160&quot; width=&quot;240&quot; height=&quot;40&quot; rx=&quot;8&quot; fill=&quot;#262019&quot; stroke=&quot;#6f9bd1&quot; stroke-width=&quot;1.5&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;350&quot; y=&quot;178&quot; text-anchor=&quot;middle&quot; fill=&quot;#6f9bd1&quot; font-size=&quot;9&quot; font-family=&quot;monospace&quot;&gt;query → top-k chunks + their commit&lt;/text&gt;&lt;text x=&quot;350&quot; y=&quot;192&quot; text-anchor=&quot;middle&quot; fill=&quot;#8a8071&quot; font-size=&quot;8&quot; font-family=&quot;monospace&quot;&gt;confirm each hit against the canonical page&lt;/text&gt;&lt;line x1=&quot;587&quot; y1=&quot;119&quot; x2=&quot;470&quot; y2=&quot;165&quot; stroke=&quot;#6f9bd1&quot; stroke-width=&quot;1.3&quot; stroke-dasharray=&quot;3 3&quot;&gt;&lt;/line&gt;&lt;text x=&quot;690&quot; y=&quot;222&quot; text-anchor=&quot;end&quot; font-size=&quot;10&quot; fill=&quot;#8a8071&quot; opacity=&quot;0.6&quot; font-family=&quot;monospace&quot;&gt;peira.dev&lt;/text&gt;&lt;/svg&gt;
&lt;div class=&quot;step&quot; data-astro-cid-wwaqy5cd&gt;&lt;div class=&quot;step-header&quot; data-astro-cid-wwaqy5cd&gt;&lt;span class=&quot;step-num&quot; data-astro-cid-wwaqy5cd&gt;1&lt;/span&gt;&lt;span class=&quot;step-title&quot; data-astro-cid-wwaqy5cd&gt;Chunk pages by section&lt;/span&gt;&lt;span class=&quot;step-time&quot; data-astro-cid-wwaqy5cd&gt;—&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;step-body&quot; data-astro-cid-wwaqy5cd&gt;&lt;p&gt;Split each page on its headings rather than fixed byte windows, so a chunk is a coherent unit of meaning (a whole “## Recovery” section, say). Section chunks retrieve far better than arbitrary slices.&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;
&lt;div class=&quot;step&quot; data-astro-cid-wwaqy5cd&gt;&lt;div class=&quot;step-header&quot; data-astro-cid-wwaqy5cd&gt;&lt;span class=&quot;step-num&quot; data-astro-cid-wwaqy5cd&gt;2&lt;/span&gt;&lt;span class=&quot;step-title&quot; data-astro-cid-wwaqy5cd&gt;Embed with a local model on Ollama&lt;/span&gt;&lt;span class=&quot;step-time&quot; data-astro-cid-wwaqy5cd&gt;10 min&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;step-body&quot; data-astro-cid-wwaqy5cd&gt;&lt;p&gt;&lt;a href=&quot;https://ollama.com&quot;&gt;Ollama&lt;/a&gt; serves the embedding model on your own nodes; &lt;a href=&quot;https://ollama.com/library/nomic-embed-text&quot;&gt;nomic-embed-text&lt;/a&gt; is a strong, compact choice — its &lt;a href=&quot;https://arxiv.org/abs/2402.01613&quot;&gt;technical report&lt;/a&gt; documents a model that matches or beats comparable closed embedders on standard benchmarks. Spread the work across multiple nodes for throughput.&lt;/p&gt;&lt;figure class=&quot;terminal&quot; data-astro-cid-kenkipkp&gt;&lt;figcaption class=&quot;terminal-bar&quot; data-astro-cid-kenkipkp&gt;&lt;span class=&quot;dot red&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot yellow&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot green&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;terminal-title&quot; data-astro-cid-kenkipkp&gt;On each Ollama node&lt;/span&gt;&lt;button class=&quot;terminal-copy&quot; type=&quot;button&quot; aria-label=&quot;Copy commands&quot; data-astro-cid-kenkipkp&gt;&lt;svg width=&quot;13&quot; height=&quot;13&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;none&quot; stroke=&quot;currentColor&quot; stroke-width=&quot;2&quot; stroke-linecap=&quot;round&quot; stroke-linejoin=&quot;round&quot; aria-hidden=&quot;true&quot; data-astro-cid-kenkipkp&gt;&lt;rect x=&quot;9&quot; y=&quot;9&quot; width=&quot;13&quot; height=&quot;13&quot; rx=&quot;2&quot; data-astro-cid-kenkipkp&gt;&lt;/rect&gt;&lt;path d=&quot;M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1&quot; data-astro-cid-kenkipkp&gt;&lt;/path&gt;&lt;/svg&gt;&lt;span class=&quot;terminal-copy-label&quot; data-astro-cid-kenkipkp&gt;Copy&lt;/span&gt;&lt;/button&gt;&lt;/figcaption&gt;&lt;pre class=&quot;terminal-body&quot; data-astro-cid-kenkipkp&gt;&lt;code data-astro-cid-kenkipkp&gt;
ollama pull nomic-embed-text

# embed one chunk (the indexer loops this over all chunks, across nodes)
curl -s http://10.0.0.65:11434/api/embeddings \
-d &amp;#39;{&amp;quot;model&amp;quot;:&amp;quot;nomic-embed-text&amp;quot;,&amp;quot;prompt&amp;quot;:&amp;quot;the chunk text here&amp;quot;}&amp;#39;
&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;&lt;/div&gt;
&lt;div class=&quot;step&quot; data-astro-cid-wwaqy5cd&gt;&lt;div class=&quot;step-header&quot; data-astro-cid-wwaqy5cd&gt;&lt;span class=&quot;step-num&quot; data-astro-cid-wwaqy5cd&gt;3&lt;/span&gt;&lt;span class=&quot;step-title&quot; data-astro-cid-wwaqy5cd&gt;Cache by content hash; swap atomically; stamp the commit&lt;/span&gt;&lt;span class=&quot;step-time&quot; data-astro-cid-wwaqy5cd&gt;5 min&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;step-body&quot; data-astro-cid-wwaqy5cd&gt;&lt;p&gt;Hash each chunk; only re-embed chunks whose hash changed since last build (most rebuilds touch a handful). Build the new index beside the old one and swap it in atomically, recording the vault commit so consumers can detect staleness.&lt;/p&gt;&lt;figure class=&quot;terminal&quot; data-astro-cid-kenkipkp&gt;&lt;figcaption class=&quot;terminal-bar&quot; data-astro-cid-kenkipkp&gt;&lt;span class=&quot;dot red&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot yellow&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot green&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;terminal-title&quot; data-astro-cid-kenkipkp&gt;Build + verify&lt;/span&gt;&lt;button class=&quot;terminal-copy&quot; type=&quot;button&quot; aria-label=&quot;Copy commands&quot; data-astro-cid-kenkipkp&gt;&lt;svg width=&quot;13&quot; height=&quot;13&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;none&quot; stroke=&quot;currentColor&quot; stroke-width=&quot;2&quot; stroke-linecap=&quot;round&quot; stroke-linejoin=&quot;round&quot; aria-hidden=&quot;true&quot; data-astro-cid-kenkipkp&gt;&lt;rect x=&quot;9&quot; y=&quot;9&quot; width=&quot;13&quot; height=&quot;13&quot; rx=&quot;2&quot; data-astro-cid-kenkipkp&gt;&lt;/rect&gt;&lt;path d=&quot;M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1&quot; data-astro-cid-kenkipkp&gt;&lt;/path&gt;&lt;/svg&gt;&lt;span class=&quot;terminal-copy-label&quot; data-astro-cid-kenkipkp&gt;Copy&lt;/span&gt;&lt;/button&gt;&lt;/figcaption&gt;&lt;pre class=&quot;terminal-body&quot; data-astro-cid-kenkipkp&gt;&lt;code data-astro-cid-kenkipkp&gt;
node tools/embeddings/build-index.mjs .     # re-embeds only changed chunks
node tools/embeddings/verify-index.mjs .    # index commit == vault HEAD ?
&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;&lt;/div&gt;
&lt;div class=&quot;callout callout-info&quot; data-astro-cid-q2ml7llr&gt;&lt;div class=&quot;callout-header&quot; data-astro-cid-q2ml7llr&gt;&lt;span class=&quot;callout-icon&quot; aria-hidden=&quot;true&quot; data-astro-cid-q2ml7llr&gt;ℹ️&lt;/span&gt;&lt;span class=&quot;callout-label&quot; data-astro-cid-q2ml7llr&gt;A derived consumer, treated like one&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;callout-body&quot; data-astro-cid-q2ml7llr&gt;&lt;p&gt;The index is not authority. An agent’s &lt;a href=&quot;https://peira.dev/blog/second-brain-ai-begin-end-skills/&quot;&gt;begin step&lt;/a&gt; consults it &lt;em&gt;read-only&lt;/em&gt; to find candidate pages, then confirms each hit against the canonical topic page. If the index commit lags &lt;code&gt;HEAD&lt;/code&gt;, its hits are labeled stale — useful for discovery, never quoted as current truth.&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;
&lt;p&gt;The full chunker, multi-node embed loop, hash cache, and query tool are in the paired playbook: &lt;strong&gt;Build and Query the Embeddings Index&lt;/strong&gt;.&lt;/p&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;querying--and-why-to-go-region-first&quot;&gt;Querying — and why to go region-first&lt;/h2&gt;
&lt;figure class=&quot;terminal&quot; data-astro-cid-kenkipkp&gt;&lt;figcaption class=&quot;terminal-bar&quot; data-astro-cid-kenkipkp&gt;&lt;span class=&quot;dot red&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot yellow&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot green&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;terminal-title&quot; data-astro-cid-kenkipkp&gt;Semantic search over the vault&lt;/span&gt;&lt;button class=&quot;terminal-copy&quot; type=&quot;button&quot; aria-label=&quot;Copy commands&quot; data-astro-cid-kenkipkp&gt;&lt;svg width=&quot;13&quot; height=&quot;13&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;none&quot; stroke=&quot;currentColor&quot; stroke-width=&quot;2&quot; stroke-linecap=&quot;round&quot; stroke-linejoin=&quot;round&quot; aria-hidden=&quot;true&quot; data-astro-cid-kenkipkp&gt;&lt;rect x=&quot;9&quot; y=&quot;9&quot; width=&quot;13&quot; height=&quot;13&quot; rx=&quot;2&quot; data-astro-cid-kenkipkp&gt;&lt;/rect&gt;&lt;path d=&quot;M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1&quot; data-astro-cid-kenkipkp&gt;&lt;/path&gt;&lt;/svg&gt;&lt;span class=&quot;terminal-copy-label&quot; data-astro-cid-kenkipkp&gt;Copy&lt;/span&gt;&lt;/button&gt;&lt;/figcaption&gt;&lt;pre class=&quot;terminal-body&quot; data-astro-cid-kenkipkp&gt;&lt;code data-astro-cid-kenkipkp&gt;
node tools/embeddings/query.mjs &amp;quot;recover a locked keyring after reboot&amp;quot;
# → ranked chunks, each with its source page + the index commit
&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;
&lt;p&gt;Because the query returns the commit alongside the hits, the caller always knows whether it’s searching current knowledge or a stale snapshot.&lt;/p&gt;
&lt;p&gt;For AI agents using the index during a &lt;code&gt;begin&lt;/code&gt; step, a &lt;strong&gt;two-stage approach&lt;/strong&gt; produces much sharper results than a single broad query:&lt;/p&gt;
&lt;figure class=&quot;terminal&quot; data-astro-cid-kenkipkp&gt;&lt;figcaption class=&quot;terminal-bar&quot; data-astro-cid-kenkipkp&gt;&lt;span class=&quot;dot red&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot yellow&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot green&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;terminal-title&quot; data-astro-cid-kenkipkp&gt;Two-stage search: region-first&lt;/span&gt;&lt;button class=&quot;terminal-copy&quot; type=&quot;button&quot; aria-label=&quot;Copy commands&quot; data-astro-cid-kenkipkp&gt;&lt;svg width=&quot;13&quot; height=&quot;13&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;none&quot; stroke=&quot;currentColor&quot; stroke-width=&quot;2&quot; stroke-linecap=&quot;round&quot; stroke-linejoin=&quot;round&quot; aria-hidden=&quot;true&quot; data-astro-cid-kenkipkp&gt;&lt;rect x=&quot;9&quot; y=&quot;9&quot; width=&quot;13&quot; height=&quot;13&quot; rx=&quot;2&quot; data-astro-cid-kenkipkp&gt;&lt;/rect&gt;&lt;path d=&quot;M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1&quot; data-astro-cid-kenkipkp&gt;&lt;/path&gt;&lt;/svg&gt;&lt;span class=&quot;terminal-copy-label&quot; data-astro-cid-kenkipkp&gt;Copy&lt;/span&gt;&lt;/button&gt;&lt;/figcaption&gt;&lt;pre class=&quot;terminal-body&quot; data-astro-cid-kenkipkp&gt;&lt;code data-astro-cid-kenkipkp&gt;
# Stage 1: unfiltered — just enough hits to identify the relevant brain region
node tools/canonical-vault/embeddings/query.mjs search &amp;quot;recover a locked keyring&amp;quot; 5
# Check the &amp;#39;region&amp;#39; field on the top hits — e.g. region/laptop

# Stage 2: scoped — re-run with the winning region tag for high-quality hits
node tools/canonical-vault/embeddings/query.mjs search &amp;quot;recover a locked keyring&amp;quot; 10 region/laptop

# Optional: expand from the best hit to its topic MOC and brain-region siblings
node tools/canonical-vault/embeddings/query.mjs graph &amp;quot;journal/2026-06-28-elitebook-keyring-grub-fix.md&amp;quot;
&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;
&lt;p&gt;The reason: a large vault has many regions (proxmox, nas, second-brain, blog, …) and an unscoped query surfaces noise from all of them. Stage 1 identifies the region cheaply; Stage 2 uses it as a filter. The graph step then pulls in the topic MOC and sibling pages that an unfiltered ranking would bury. This is the pattern the &lt;code&gt;begin&lt;/code&gt; skill uses internally — it’s worth adopting in any agent that queries the index.&lt;/p&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;the-swap-in-box&quot;&gt;The swap-in box&lt;/h2&gt;
&lt;div class=&quot;callout callout-info&quot; data-astro-cid-q2ml7llr&gt;&lt;div class=&quot;callout-header&quot; data-astro-cid-q2ml7llr&gt;&lt;span class=&quot;callout-icon&quot; aria-hidden=&quot;true&quot; data-astro-cid-q2ml7llr&gt;ℹ️&lt;/span&gt;&lt;span class=&quot;callout-label&quot; data-astro-cid-q2ml7llr&gt;On other tools&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;callout-body&quot; data-astro-cid-q2ml7llr&gt;&lt;p&gt;&lt;strong&gt;Vector stores:&lt;/strong&gt; any of Chroma, Qdrant, or &lt;code&gt;sqlite-vec&lt;/code&gt; works as the backing store — the discipline (content-hash cache, atomic swap, commit stamp) matters more than the engine. &lt;strong&gt;Cloud embeddings:&lt;/strong&gt; if privacy allows, a hosted embedding API drops in where Ollama is — keep everything else. &lt;strong&gt;Off-the-shelf RAG:&lt;/strong&gt; whatever framework you use, make retrieval return the source revision and confirm hits against the source. The universal rule: the index is a derived, revision-labeled consumer, not a second brain.&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;what-you-have-now&quot;&gt;What you have now&lt;/h2&gt;
&lt;p&gt;Concept-level search over your entire vault, running on your own hardware, that never pretends a stale snapshot is current. That completes the derived-consumer set — wiki, reader, embeddings. The next post steps back to the operations discipline that keeps &lt;em&gt;all&lt;/em&gt; of them honest at once: &lt;a href=&quot;https://peira.dev/blog/second-brain-projection-health/&quot;&gt;keeping projections honest&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Since writing this, the index grew the &lt;em&gt;generation&lt;/em&gt; half of RAG: a small retrieve-then-generate endpoint over the same chunks, surfaced as an Ask tab in my &lt;a href=&quot;https://peira.dev/blog/flutter-proxmox-dashboard/&quot;&gt;homelab dashboard app&lt;/a&gt;. The discipline carries straight through — every answer cites its vault sources by page and heading, carries the index revision it was built from, and wears a stale badge whenever that revision lags the vault:&lt;/p&gt;
&lt;figure class=&quot;shot&quot; data-astro-cid-pmiqfeva&gt;&lt;div class=&quot;shot-bar&quot; data-astro-cid-pmiqfeva&gt;&lt;span class=&quot;dot red&quot; data-astro-cid-pmiqfeva&gt;&lt;/span&gt;&lt;span class=&quot;dot yellow&quot; data-astro-cid-pmiqfeva&gt;&lt;/span&gt;&lt;span class=&quot;dot green&quot; data-astro-cid-pmiqfeva&gt;&lt;/span&gt;&lt;/div&gt;&lt;img src=&quot;https://peira.dev/_astro/app-ask-vault.CR_jmO-a_Z23zamQ.webp&quot; alt=&quot;The Ask tab in the HomeLab app: a chat where answers about a paging alert and node RAM headroom carry numbered citation chips linking to vault pages, plus a footer with the vault revision and an index-stale badge&quot; data-astro-cid-pmiqfeva=&quot;true&quot; loading=&quot;lazy&quot; decoding=&quot;async&quot; width=&quot;1400&quot; height=&quot;933&quot; class=&quot;shot-img&quot;&gt;&lt;figcaption class=&quot;shot-caption&quot; data-astro-cid-pmiqfeva&gt;Ask the vault: numbered citations resolve to page + heading chips, and the second answer&amp;#39;s amber badge marks it as generated from a stale index — labeled, never silently current. (Demo transcript; the addresses and revision are stand-ins.)&lt;/figcaption&gt;&lt;/figure&gt;
&lt;hr/&gt;
&lt;p&gt;&lt;strong&gt;Related posts:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/second-brain-ai-begin-end-skills/&quot;&gt;Make Your AI Agents Read the Docs First, Every Time&lt;/a&gt; — the begin step that consults this index read-only&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/second-brain-multi-machine-reader/&quot;&gt;Share Your Vault Across Machines Without Split-Brain&lt;/a&gt; — the read-only consumer pattern this follows&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/second-brain-projection-health/&quot;&gt;Prove Your Homelab Docs Are Actually Up to Date&lt;/a&gt; — verifying this index against vault HEAD&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/ollama-lxc-cluster/&quot;&gt;Running Ollama on a 3-Node Proxmox LXC Cluster&lt;/a&gt; — the local inference backend embedding runs on&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/homelab-wiki-rag-cited-answers/&quot;&gt;RAG for Your Homelab Wiki: Answers That Cite Their Source&lt;/a&gt; — the generation half built on top of this index&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/open-webui-advanced/&quot;&gt;Open WebUI Advanced Configuration&lt;/a&gt; — another consumer of the same Ollama cluster&lt;/li&gt;
&lt;/ul&gt;</content:encoded><category>ai</category><category>ollama</category><category>self-hosted</category></item><item><title>Share Your Vault Across Machines Without Split-Brain</title><link>https://peira.dev/blog/second-brain-multi-machine-reader/</link><guid isPermaLink="true">https://peira.dev/blog/second-brain-multi-machine-reader/</guid><description>Your laptop, your cluster, and your agents all need the vault — but only one copy can be authoritative. Use a direct mount for editing and a read-only forced-command SSH reader for everyone else.</description><pubDate>Wed, 15 Jul 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;The vault is on an always-on host, your laptop edits it, and now the cluster and your &lt;a href=&quot;https://peira.dev/blog/second-brain-ai-begin-end-skills/&quot;&gt;AI agents&lt;/a&gt; want it too. The naive answer — clone it everywhere — quietly recreates the exact &lt;code&gt;⚠️&lt;/code&gt; split-brain we’ve fought all series: multiple writable copies, each drifting. This post gives everyone access without a second authority.&lt;/p&gt;
&lt;div class=&quot;callout callout-tip&quot; data-astro-cid-q2ml7llr&gt;&lt;div class=&quot;callout-header&quot; data-astro-cid-q2ml7llr&gt;&lt;span class=&quot;callout-icon&quot; aria-hidden=&quot;true&quot; data-astro-cid-q2ml7llr&gt;💡&lt;/span&gt;&lt;span class=&quot;callout-label&quot; data-astro-cid-q2ml7llr&gt;The principle&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;callout-body&quot; data-astro-cid-q2ml7llr&gt;&lt;p&gt;&lt;strong&gt;One editing client, many readers.&lt;/strong&gt; The machine you edit from gets a direct read-write mount; everything else — cluster services, agents, other machines — reaches the vault through a &lt;strong&gt;read-only forced-command SSH interface&lt;/strong&gt; that can only status, read, and search, and reports the commit on every read.&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;
&lt;div class=&quot;callout callout-warning&quot; data-astro-cid-q2ml7llr&gt;&lt;div class=&quot;callout-header&quot; data-astro-cid-q2ml7llr&gt;&lt;span class=&quot;callout-icon&quot; aria-hidden=&quot;true&quot; data-astro-cid-q2ml7llr&gt;⚠️&lt;/span&gt;&lt;span class=&quot;callout-label&quot; data-astro-cid-q2ml7llr&gt;First: make these values your own&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;callout-body&quot; data-astro-cid-q2ml7llr&gt;&lt;p&gt;Replace the stand-ins with your own: &lt;strong&gt;&lt;code&gt;10.0.0.76&lt;/code&gt;&lt;/strong&gt; → your vault host’s IP; &lt;strong&gt;&lt;code&gt;vaultreader&lt;/code&gt;&lt;/strong&gt; → the account you create for readers; the &lt;strong&gt;&lt;code&gt;ssh-ed25519 AAAA…&lt;/code&gt;&lt;/strong&gt; key → your own generated public key; &lt;strong&gt;&lt;code&gt;/wiki-data/git&lt;/code&gt;&lt;/strong&gt; → your repo path; the commit &lt;strong&gt;&lt;code&gt;64dc195&lt;/code&gt;&lt;/strong&gt; is just an example.&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;the-access-topology&quot;&gt;The access topology&lt;/h2&gt;
&lt;svg viewBox=&quot;0 0 700 240&quot; xmlns=&quot;http://www.w3.org/2000/svg&quot; style=&quot;width:100%;border-radius:12px;background:#1c1712;margin:1.5rem 0;border:1px solid #443a2b&quot;&gt;&lt;defs&gt;&lt;pattern id=&quot;gp-secondbrainm0&quot; width=&quot;20&quot; height=&quot;20&quot; patternUnits=&quot;userSpaceOnUse&quot;&gt;&lt;path d=&quot;M 20 0 H 0 V 20&quot; fill=&quot;none&quot; stroke=&quot;#221c15&quot; stroke-width=&quot;1&quot;&gt;&lt;/path&gt;&lt;/pattern&gt;&lt;/defs&gt;&lt;rect x=&quot;0&quot; y=&quot;0&quot; width=&quot;700&quot; height=&quot;240&quot; fill=&quot;url(#gp-secondbrainm0)&quot;&gt;&lt;/rect&gt;&lt;rect x=&quot;6&quot; y=&quot;6&quot; width=&quot;688&quot; height=&quot;228&quot; fill=&quot;none&quot; stroke=&quot;#2e2820&quot; stroke-width=&quot;1&quot;&gt;&lt;/rect&gt;&lt;path d=&quot;M 11,16 H 21 M 16,11 V 21 M 679,16 H 689 M 684,11 V 21 M 11,224 H 21 M 16,219 V 229 M 679,224 H 689 M 684,219 V 229&quot; stroke=&quot;#4a3f2e&quot; stroke-width=&quot;1&quot; fill=&quot;none&quot;&gt;&lt;/path&gt;&lt;text x=&quot;350&quot; y=&quot;28&quot; text-anchor=&quot;middle&quot; fill=&quot;#f2ede3&quot; font-size=&quot;13&quot; font-family=&quot;monospace&quot; font-weight=&quot;bold&quot;&gt;One writer, many read-only readers&lt;/text&gt;&lt;rect x=&quot;270&quot; y=&quot;90&quot; width=&quot;160&quot; height=&quot;60&quot; rx=&quot;10&quot; fill=&quot;#262019&quot; stroke=&quot;#e8a33e&quot; stroke-width=&quot;2&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;350&quot; y=&quot;116&quot; text-anchor=&quot;middle&quot; fill=&quot;#e8a33e&quot; font-size=&quot;11&quot; font-family=&quot;monospace&quot; font-weight=&quot;bold&quot;&gt;canonical vault&lt;/text&gt;&lt;text x=&quot;350&quot; y=&quot;134&quot; text-anchor=&quot;middle&quot; fill=&quot;#aba192&quot; font-size=&quot;9&quot; font-family=&quot;monospace&quot;&gt;the one authority&lt;/text&gt;&lt;rect x=&quot;40&quot; y=&quot;95&quot; width=&quot;150&quot; height=&quot;50&quot; rx=&quot;10&quot; fill=&quot;#262019&quot; stroke=&quot;#6f9bd1&quot; stroke-width=&quot;1.8&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;115&quot; y=&quot;116&quot; text-anchor=&quot;middle&quot; fill=&quot;#6f9bd1&quot; font-size=&quot;10&quot; font-family=&quot;monospace&quot; font-weight=&quot;bold&quot;&gt;your laptop&lt;/text&gt;&lt;text x=&quot;115&quot; y=&quot;132&quot; text-anchor=&quot;middle&quot; fill=&quot;#8a8071&quot; font-size=&quot;8&quot; font-family=&quot;monospace&quot;&gt;direct mount · read-write&lt;/text&gt;&lt;line x1=&quot;190&quot; y1=&quot;120&quot; x2=&quot;270&quot; y2=&quot;120&quot; stroke=&quot;#6f9bd1&quot; stroke-width=&quot;2&quot;&gt;&lt;/line&gt;&lt;text x=&quot;230&quot; y=&quot;112&quot; text-anchor=&quot;middle&quot; fill=&quot;#6f9bd1&quot; font-size=&quot;8&quot; font-family=&quot;monospace&quot;&gt;rw&lt;/text&gt;&lt;rect x=&quot;510&quot; y=&quot;55&quot; width=&quot;150&quot; height=&quot;46&quot; rx=&quot;10&quot; fill=&quot;#262019&quot; stroke=&quot;#443a2b&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;585&quot; y=&quot;74&quot; text-anchor=&quot;middle&quot; fill=&quot;#e5ddcd&quot; font-size=&quot;9&quot; font-family=&quot;monospace&quot;&gt;cluster service&lt;/text&gt;&lt;text x=&quot;585&quot; y=&quot;90&quot; text-anchor=&quot;middle&quot; fill=&quot;#8a8071&quot; font-size=&quot;8&quot; font-family=&quot;monospace&quot;&gt;read-only&lt;/text&gt;&lt;rect x=&quot;510&quot; y=&quot;110&quot; width=&quot;150&quot; height=&quot;46&quot; rx=&quot;10&quot; fill=&quot;#262019&quot; stroke=&quot;#443a2b&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;585&quot; y=&quot;129&quot; text-anchor=&quot;middle&quot; fill=&quot;#e5ddcd&quot; font-size=&quot;9&quot; font-family=&quot;monospace&quot;&gt;AI agent&lt;/text&gt;&lt;text x=&quot;585&quot; y=&quot;145&quot; text-anchor=&quot;middle&quot; fill=&quot;#8a8071&quot; font-size=&quot;8&quot; font-family=&quot;monospace&quot;&gt;read-only&lt;/text&gt;&lt;rect x=&quot;510&quot; y=&quot;165&quot; width=&quot;150&quot; height=&quot;46&quot; rx=&quot;10&quot; fill=&quot;#262019&quot; stroke=&quot;#443a2b&quot;&gt;&lt;/rect&gt;&lt;text x=&quot;585&quot; y=&quot;184&quot; text-anchor=&quot;middle&quot; fill=&quot;#e5ddcd&quot; font-size=&quot;9&quot; font-family=&quot;monospace&quot;&gt;other machine&lt;/text&gt;&lt;text x=&quot;585&quot; y=&quot;200&quot; text-anchor=&quot;middle&quot; fill=&quot;#8a8071&quot; font-size=&quot;8&quot; font-family=&quot;monospace&quot;&gt;read-only&lt;/text&gt;&lt;line x1=&quot;430&quot; y1=&quot;112&quot; x2=&quot;510&quot; y2=&quot;78&quot; stroke=&quot;#8fb573&quot; stroke-width=&quot;1.5&quot;&gt;&lt;/line&gt;&lt;line x1=&quot;430&quot; y1=&quot;120&quot; x2=&quot;510&quot; y2=&quot;133&quot; stroke=&quot;#8fb573&quot; stroke-width=&quot;1.5&quot;&gt;&lt;/line&gt;&lt;line x1=&quot;430&quot; y1=&quot;128&quot; x2=&quot;510&quot; y2=&quot;188&quot; stroke=&quot;#8fb573&quot; stroke-width=&quot;1.5&quot;&gt;&lt;/line&gt;&lt;text x=&quot;470&quot; y=&quot;150&quot; text-anchor=&quot;middle&quot; fill=&quot;#8fb573&quot; font-size=&quot;8&quot; font-family=&quot;monospace&quot;&gt;forced-command&lt;/text&gt;&lt;text x=&quot;470&quot; y=&quot;162&quot; text-anchor=&quot;middle&quot; fill=&quot;#8fb573&quot; font-size=&quot;8&quot; font-family=&quot;monospace&quot;&gt;SSH (ro)&lt;/text&gt;&lt;text x=&quot;690&quot; y=&quot;232&quot; text-anchor=&quot;end&quot; font-size=&quot;10&quot; fill=&quot;#8a8071&quot; opacity=&quot;0.6&quot; font-family=&quot;monospace&quot;&gt;peira.dev&lt;/text&gt;&lt;/svg&gt;
&lt;p&gt;The laptop writes; everyone else reads. Because the readers &lt;em&gt;cannot&lt;/em&gt; write, none of them can become a competing authority — the split-brain is structurally impossible, not merely discouraged.&lt;/p&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;building-the-read-only-reader&quot;&gt;Building the read-only reader&lt;/h2&gt;
&lt;p&gt;An SSH &lt;code&gt;command=&lt;/code&gt; restriction (&lt;a href=&quot;https://man.openbsd.org/sshd.8#AUTHORIZED_KEYS_FILE_FORMAT&quot;&gt;authorized_keys forced command&lt;/a&gt;) pins a key to exactly one program. Connect with that key and you get the reader, never a shell.&lt;/p&gt;
&lt;div class=&quot;step&quot; data-astro-cid-wwaqy5cd&gt;&lt;div class=&quot;step-header&quot; data-astro-cid-wwaqy5cd&gt;&lt;span class=&quot;step-num&quot; data-astro-cid-wwaqy5cd&gt;1&lt;/span&gt;&lt;span class=&quot;step-title&quot; data-astro-cid-wwaqy5cd&gt;Write a restricted reader program&lt;/span&gt;&lt;span class=&quot;step-time&quot; data-astro-cid-wwaqy5cd&gt;10 min&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;step-body&quot; data-astro-cid-wwaqy5cd&gt;&lt;p&gt;The reader accepts a small verb set — &lt;code&gt;status&lt;/code&gt;, &lt;code&gt;read &amp;lt;path&amp;gt;&lt;/code&gt;, &lt;code&gt;search &amp;lt;query&amp;gt;&lt;/code&gt; — and &lt;strong&gt;always prints the current commit&lt;/strong&gt;. It refuses anything else.&lt;/p&gt;&lt;figure class=&quot;terminal&quot; data-astro-cid-kenkipkp&gt;&lt;figcaption class=&quot;terminal-bar&quot; data-astro-cid-kenkipkp&gt;&lt;span class=&quot;dot red&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot yellow&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot green&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;terminal-title&quot; data-astro-cid-kenkipkp&gt;/usr/local/bin/vault-reader (on the vault host)&lt;/span&gt;&lt;button class=&quot;terminal-copy&quot; type=&quot;button&quot; aria-label=&quot;Copy commands&quot; data-astro-cid-kenkipkp&gt;&lt;svg width=&quot;13&quot; height=&quot;13&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;none&quot; stroke=&quot;currentColor&quot; stroke-width=&quot;2&quot; stroke-linecap=&quot;round&quot; stroke-linejoin=&quot;round&quot; aria-hidden=&quot;true&quot; data-astro-cid-kenkipkp&gt;&lt;rect x=&quot;9&quot; y=&quot;9&quot; width=&quot;13&quot; height=&quot;13&quot; rx=&quot;2&quot; data-astro-cid-kenkipkp&gt;&lt;/rect&gt;&lt;path d=&quot;M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1&quot; data-astro-cid-kenkipkp&gt;&lt;/path&gt;&lt;/svg&gt;&lt;span class=&quot;terminal-copy-label&quot; data-astro-cid-kenkipkp&gt;Copy&lt;/span&gt;&lt;/button&gt;&lt;/figcaption&gt;&lt;pre class=&quot;terminal-body&quot; data-astro-cid-kenkipkp&gt;&lt;code data-astro-cid-kenkipkp&gt;
#!/bin/bash
# Invoked via SSH forced command. $SSH_ORIGINAL_COMMAND holds the request.
set -euo pipefail
VAULT=/wiki-data/git
COMMIT=$(git -C &amp;quot;$VAULT&amp;quot; rev-parse HEAD)
read -r verb arg &amp;lt;&amp;lt;&amp;lt; &amp;quot;$SSH_ORIGINAL_COMMAND&amp;quot;
case &amp;quot;$verb&amp;quot; in
status) echo &amp;quot;commit $COMMIT&amp;quot; ;;
read)   echo &amp;quot;commit $COMMIT&amp;quot;; git -C &amp;quot;$VAULT&amp;quot; show &amp;quot;HEAD:$arg&amp;quot; ;;
search) echo &amp;quot;commit $COMMIT&amp;quot;; git -C &amp;quot;$VAULT&amp;quot; grep -n &amp;quot;$arg&amp;quot; -- &amp;#39;*.md&amp;#39; ;;
*)      echo &amp;quot;denied: read-only reader (status|read|search)&amp;quot;; exit 1 ;;
esac
&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;&lt;/div&gt;
&lt;div class=&quot;step&quot; data-astro-cid-wwaqy5cd&gt;&lt;div class=&quot;step-header&quot; data-astro-cid-wwaqy5cd&gt;&lt;span class=&quot;step-num&quot; data-astro-cid-wwaqy5cd&gt;2&lt;/span&gt;&lt;span class=&quot;step-title&quot; data-astro-cid-wwaqy5cd&gt;Pin a key to the reader with a forced command&lt;/span&gt;&lt;span class=&quot;step-time&quot; data-astro-cid-wwaqy5cd&gt;5 min&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;step-body&quot; data-astro-cid-wwaqy5cd&gt;&lt;figure class=&quot;terminal&quot; data-astro-cid-kenkipkp&gt;&lt;figcaption class=&quot;terminal-bar&quot; data-astro-cid-kenkipkp&gt;&lt;span class=&quot;dot red&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot yellow&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot green&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;terminal-title&quot; data-astro-cid-kenkipkp&gt;~/.ssh/authorized_keys on the vault host&lt;/span&gt;&lt;button class=&quot;terminal-copy&quot; type=&quot;button&quot; aria-label=&quot;Copy commands&quot; data-astro-cid-kenkipkp&gt;&lt;svg width=&quot;13&quot; height=&quot;13&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;none&quot; stroke=&quot;currentColor&quot; stroke-width=&quot;2&quot; stroke-linecap=&quot;round&quot; stroke-linejoin=&quot;round&quot; aria-hidden=&quot;true&quot; data-astro-cid-kenkipkp&gt;&lt;rect x=&quot;9&quot; y=&quot;9&quot; width=&quot;13&quot; height=&quot;13&quot; rx=&quot;2&quot; data-astro-cid-kenkipkp&gt;&lt;/rect&gt;&lt;path d=&quot;M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1&quot; data-astro-cid-kenkipkp&gt;&lt;/path&gt;&lt;/svg&gt;&lt;span class=&quot;terminal-copy-label&quot; data-astro-cid-kenkipkp&gt;Copy&lt;/span&gt;&lt;/button&gt;&lt;/figcaption&gt;&lt;pre class=&quot;terminal-body&quot; data-astro-cid-kenkipkp&gt;&lt;code data-astro-cid-kenkipkp&gt;
command=&amp;quot;/usr/local/bin/vault-reader&amp;quot;,no-pty,no-port-forwarding,no-agent-forwarding,no-X11-forwarding ssh-ed25519 AAAA... vault-reader-key
&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;p&gt;Now that key can only run the reader. A cluster agent using it can read and search the vault — and learns the commit on every call — but cannot write, cannot get a shell, cannot forward anything. That’s &lt;a href=&quot;https://csrc.nist.gov/glossary/term/least_privilege&quot;&gt;least privilege&lt;/a&gt; (NIST’s term) made concrete: each consumer gets exactly the access its job requires and nothing more.&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;
&lt;div class=&quot;step&quot; data-astro-cid-wwaqy5cd&gt;&lt;div class=&quot;step-header&quot; data-astro-cid-wwaqy5cd&gt;&lt;span class=&quot;step-num&quot; data-astro-cid-wwaqy5cd&gt;3&lt;/span&gt;&lt;span class=&quot;step-title&quot; data-astro-cid-wwaqy5cd&gt;Consume it from a reader machine&lt;/span&gt;&lt;span class=&quot;step-time&quot; data-astro-cid-wwaqy5cd&gt;3 min&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;step-body&quot; data-astro-cid-wwaqy5cd&gt;&lt;figure class=&quot;terminal&quot; data-astro-cid-kenkipkp&gt;&lt;figcaption class=&quot;terminal-bar&quot; data-astro-cid-kenkipkp&gt;&lt;span class=&quot;dot red&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot yellow&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;dot green&quot; data-astro-cid-kenkipkp&gt;&lt;/span&gt;&lt;span class=&quot;terminal-title&quot; data-astro-cid-kenkipkp&gt;From a cluster host or agent&lt;/span&gt;&lt;button class=&quot;terminal-copy&quot; type=&quot;button&quot; aria-label=&quot;Copy commands&quot; data-astro-cid-kenkipkp&gt;&lt;svg width=&quot;13&quot; height=&quot;13&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;none&quot; stroke=&quot;currentColor&quot; stroke-width=&quot;2&quot; stroke-linecap=&quot;round&quot; stroke-linejoin=&quot;round&quot; aria-hidden=&quot;true&quot; data-astro-cid-kenkipkp&gt;&lt;rect x=&quot;9&quot; y=&quot;9&quot; width=&quot;13&quot; height=&quot;13&quot; rx=&quot;2&quot; data-astro-cid-kenkipkp&gt;&lt;/rect&gt;&lt;path d=&quot;M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1&quot; data-astro-cid-kenkipkp&gt;&lt;/path&gt;&lt;/svg&gt;&lt;span class=&quot;terminal-copy-label&quot; data-astro-cid-kenkipkp&gt;Copy&lt;/span&gt;&lt;/button&gt;&lt;/figcaption&gt;&lt;pre class=&quot;terminal-body&quot; data-astro-cid-kenkipkp&gt;&lt;code data-astro-cid-kenkipkp&gt;
ssh -i ~/.ssh/vault-reader vaultreader@10.0.0.76 &amp;quot;status&amp;quot;
# → commit 64dc195

ssh -i ~/.ssh/vault-reader vaultreader@10.0.0.76 &amp;quot;read runbooks/example.md&amp;quot;
# → commit 64dc195  (then the file contents)
&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;&lt;/div&gt;
&lt;div class=&quot;callout callout-warning&quot; data-astro-cid-q2ml7llr&gt;&lt;div class=&quot;callout-header&quot; data-astro-cid-q2ml7llr&gt;&lt;span class=&quot;callout-icon&quot; aria-hidden=&quot;true&quot; data-astro-cid-q2ml7llr&gt;⚠️&lt;/span&gt;&lt;span class=&quot;callout-label&quot; data-astro-cid-q2ml7llr&gt;Every read returns the commit — so callers can fail closed&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;callout-body&quot; data-astro-cid-q2ml7llr&gt;&lt;p&gt;The reader prints the commit first on purpose. A consumer (like an agent’s &lt;a href=&quot;https://peira.dev/blog/second-brain-ai-begin-end-skills/&quot;&gt;begin step&lt;/a&gt;) compares it to the expected &lt;code&gt;HEAD&lt;/code&gt; and stops if they differ. A read interface that hides its revision turns a stale cache into invisible misinformation.&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;
&lt;p&gt;The full reader, key restrictions, and a consumer wrapper are in the paired playbook: &lt;strong&gt;Canonical Vault Restricted Reader&lt;/strong&gt;.&lt;/p&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;the-swap-in-box&quot;&gt;The swap-in box&lt;/h2&gt;
&lt;div class=&quot;callout callout-info&quot; data-astro-cid-q2ml7llr&gt;&lt;div class=&quot;callout-header&quot; data-astro-cid-q2ml7llr&gt;&lt;span class=&quot;callout-icon&quot; aria-hidden=&quot;true&quot; data-astro-cid-q2ml7llr&gt;ℹ️&lt;/span&gt;&lt;span class=&quot;callout-label&quot; data-astro-cid-q2ml7llr&gt;On other tools&lt;/span&gt;&lt;/div&gt;&lt;div class=&quot;callout-body&quot; data-astro-cid-q2ml7llr&gt;&lt;p&gt;&lt;strong&gt;Git natively:&lt;/strong&gt; a bare repo with read-only deploy keys gives readers a clone they can pull but not push — pair it with a commit-reporting wrapper. &lt;strong&gt;HTTP:&lt;/strong&gt; a read-only API in front of the repo (returning content plus the commit header) is the same idea over a different transport. &lt;strong&gt;The rule:&lt;/strong&gt; exactly one writable path to the authority; every other consumer is read-only and reveals its revision.&lt;/p&gt;&lt;/div&gt;&lt;/div&gt;
&lt;hr/&gt;
&lt;h2 id=&quot;what-you-have-now&quot;&gt;What you have now&lt;/h2&gt;
&lt;p&gt;Every machine and agent can read current vault state, and not one of them can fork it — the split-brain failure mode is closed off by construction. The readers above return raw pages; the next post gives them &lt;em&gt;semantic&lt;/em&gt; search that still knows its own version: &lt;a href=&quot;https://peira.dev/blog/second-brain-local-rag-embeddings/&quot;&gt;local RAG that knows its own version&lt;/a&gt;.&lt;/p&gt;
&lt;hr/&gt;
&lt;p&gt;&lt;strong&gt;Related posts:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/second-brain-ai-begin-end-skills/&quot;&gt;Make Your AI Agents Read the Docs First, Every Time&lt;/a&gt; — the agents that consume this read-only reader&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/second-brain-local-rag-embeddings/&quot;&gt;Semantic Search for Your Notes, Running Locally&lt;/a&gt; — semantic search built as another read-only consumer&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/second-brain-controlled-write-lock/&quot;&gt;Why Auto-Syncing Your Notes Repo Will Corrupt It&lt;/a&gt; — the single writable path this preserves&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/tailscale-subnet-router/&quot;&gt;Tailscale Subnet Router: Remote Access to Your Entire Homelab&lt;/a&gt; — the network layer readers reach the vault over&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://peira.dev/blog/second-brain-repair-rebuild/&quot;&gt;Already Made a Mess of Your Notes? Here’s the Fix&lt;/a&gt; — fixing split-brain once it has already happened&lt;/li&gt;
&lt;/ul&gt;</content:encoded><category>self-hosted</category><category>security</category><category>automation</category></item></channel></rss>