<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Minor Keith</title>
    <description>The latest articles on DEV Community by Minor Keith (@peculiarengineer).</description>
    <link>https://dev.to/peculiarengineer</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3650865%2F3759f803-6cf3-4025-88c5-2130f947ae79.png</url>
      <title>DEV Community: Minor Keith</title>
      <link>https://dev.to/peculiarengineer</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/peculiarengineer"/>
    <language>en</language>
    <item>
      <title>Prometheus node exporter on Ubuntu 26.04, and it scrapes over the private network</title>
      <dc:creator>Minor Keith</dc:creator>
      <pubDate>Mon, 21 Sep 2026 13:37:39 +0000</pubDate>
      <link>https://dev.to/peculiarengineer/prometheus-node-exporter-on-ubuntu-2604-and-it-scrapes-over-the-private-network-1a98</link>
      <guid>https://dev.to/peculiarengineer/prometheus-node-exporter-on-ubuntu-2604-and-it-scrapes-over-the-private-network-1a98</guid>
      <description>&lt;p&gt;Every server I look after eventually gets the same question asked of it: how full is the disk, is it about to run out of memory, and does it need a reboot that nobody has got round to. Node exporter is the standard answer. It is one binary that exposes a few thousand numbers about the machine on port 9100, and Prometheus pulls them every fifteen seconds and keeps them. Ubuntu 26.04 has both in the archive, which is where this post starts and where most guides do not.&lt;/p&gt;

&lt;p&gt;Most node exporter guides download a tarball from GitHub, make a user, and write a unit file. That works, and section 7 does it, but on 26.04 &lt;code&gt;apt install prometheus-node-exporter&lt;/code&gt; gives you a unit, a dedicated user, a config file for the flags, and a set of extra collectors that report pending apt upgrades and whether the box wants a reboot. The reason for the post is the two things the package does that I did not want (it drags in twenty one other packages by default, and it listens on every interface), the two failure messages that look alike from the Prometheus side and mean different things, and the alert rules that turn "I have metrics" into "I get told".&lt;/p&gt;

&lt;p&gt;The one thing to get straight: Prometheus scrapes. The exporter never sends anything anywhere. It sits there answering &lt;code&gt;GET /metrics&lt;/code&gt;, and whoever can reach port 9100 can read every mount point, every network interface, the memory and load figures and, with the collectors package, the list of pending updates on the box. So the exporter's listen address and the firewall rule in front of it are part of the install, not an afterthought.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR.&lt;/strong&gt; On the server: &lt;code&gt;sudo apt update &amp;amp;&amp;amp; sudo apt install prometheus-node-exporter&lt;/code&gt; (add &lt;code&gt;--no-install-recommends&lt;/code&gt; on a cloud VM where you do not want the hardware collectors, and lose the apt and reboot metrics with them), set &lt;code&gt;ARGS="--web.listen-address=10.20.1.10:9100"&lt;/code&gt; in &lt;code&gt;/etc/default/prometheus-node-exporter&lt;/code&gt; to bind it to the private interface, &lt;code&gt;sudo ufw allow from &amp;lt;prometheus ip&amp;gt; to any port 9100 proto tcp&lt;/code&gt;. On the Prometheus box: &lt;code&gt;sudo apt install prometheus&lt;/code&gt;, add a &lt;code&gt;job_name&lt;/code&gt; with the server's private address to &lt;code&gt;/etc/prometheus/prometheus.yml&lt;/code&gt;, &lt;code&gt;promtool check config&lt;/code&gt;, &lt;code&gt;sudo systemctl reload prometheus&lt;/code&gt;, and confirm with &lt;code&gt;up&lt;/code&gt; in the query API. Keep the recommends on real hardware; the &lt;code&gt;apt_upgrades_pending&lt;/code&gt; and &lt;code&gt;node_reboot_required&lt;/code&gt; metrics come from that package.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Prerequisites
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Two Ubuntu 26.04 machines that can reach each other on a private network: the server you want metrics from, and the one that will run Prometheus. Mine are two cloud VMs with a private network between them, &lt;code&gt;10.20.1.10&lt;/code&gt; for the server and &lt;code&gt;10.20.1.20&lt;/code&gt; for Prometheus. A tailnet works the same way; use the &lt;code&gt;100.x&lt;/code&gt; addresses.&lt;/li&gt;
&lt;li&gt;A sudo user on both.&lt;/li&gt;
&lt;li&gt;Nothing else. Grafana is a separate afternoon and this post stops at Prometheus answering queries.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  1. Install the exporter from the archive
&lt;/h2&gt;

&lt;p&gt;One decision before the command. The package recommends a collectors package that brings twenty one more packages and the apt and reboot metrics with them; section 2 shows both sides. I installed the default first, because the rest of this post uses those metrics, and section 2 tells you how to go the small route instead.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;apt update
&lt;span class="nb"&gt;sudo &lt;/span&gt;apt &lt;span class="nb"&gt;install &lt;/span&gt;prometheus-node-exporter
prometheus-node-exporter &lt;span class="nt"&gt;--version&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On 26.04 this is &lt;code&gt;node_exporter, version 1.10.2&lt;/code&gt;, from &lt;code&gt;universe&lt;/code&gt;, and the package does the parts the GitHub tarball leaves to you: a &lt;code&gt;prometheus&lt;/code&gt; system user, a unit called &lt;code&gt;prometheus-node-exporter.service&lt;/code&gt; that is enabled and running when apt returns, and &lt;code&gt;/etc/default/prometheus-node-exporter&lt;/code&gt; with an &lt;code&gt;ARGS=""&lt;/code&gt; line where every flag goes. The unit is short and worth reading once:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;systemctl &lt;span class="nb"&gt;cat &lt;/span&gt;prometheus-node-exporter
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[Service]
Restart=on-failure
User=prometheus
EnvironmentFile=/etc/default/prometheus-node-exporter
ExecStart=/usr/bin/prometheus-node-exporter $ARGS
ExecReload=/bin/kill -HUP $MAINPID
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Check it is answering:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; http://127.0.0.1:9100/metrics | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-cE&lt;/span&gt; &lt;span class="s1"&gt;'^[a-z]'&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; http://127.0.0.1:9100/metrics | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s1"&gt;'^node_load1 |^node_memory_MemAvailable_bytes'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Just under two thousand metric lines on my box. &lt;code&gt;node_load1&lt;/code&gt;, &lt;code&gt;node_memory_MemAvailable_bytes&lt;/code&gt;, &lt;code&gt;node_filesystem_avail_bytes{mountpoint="/"}&lt;/code&gt; are the three you will query first.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flyx15eyzeahqsk4g4jyt.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flyx15eyzeahqsk4g4jyt.png" alt="apt simulation counts 22 packages by default and 1 with --no-install-recommends, node_exporter 1.10.2 active, the unit's User, EnvironmentFile and ExecStart lines, listening on *:9100, 1972 metric lines" width="800" height="483"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The screenshots in this post come from a second pair of fresh 26.04 boxes, so timestamps and the odd number differ from the prose.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. What the collectors package adds, and what it costs
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;prometheus-node-exporter&lt;/code&gt; recommends &lt;code&gt;prometheus-node-exporter-collectors&lt;/code&gt;, and apt installs recommends by default. On my fresh VM that one recommendation turned a one package install into twenty two: &lt;code&gt;ipmitool&lt;/code&gt;, &lt;code&gt;smartmontools&lt;/code&gt;, &lt;code&gt;nvme-cli&lt;/code&gt;, &lt;code&gt;freeipmi&lt;/code&gt;, &lt;code&gt;openipmi&lt;/code&gt;, &lt;code&gt;moreutils&lt;/code&gt;, &lt;code&gt;jq&lt;/code&gt;, the SNMP libraries and a Python Prometheus client. What you get for it is a set of systemd timers that run collector scripts (every fifteen minutes for the three that run on this VM) and drop their output into &lt;code&gt;/var/lib/prometheus/node-exporter/&lt;/code&gt; as &lt;code&gt;.prom&lt;/code&gt; files, which the exporter's textfile collector picks up on the next scrape:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;systemctl list-timers &lt;span class="nt"&gt;--all&lt;/span&gt; | &lt;span class="nb"&gt;grep &lt;/span&gt;prometheus
&lt;span class="nb"&gt;ls&lt;/span&gt; /var/lib/prometheus/node-exporter/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;prometheus-node-exporter-apt.timer
prometheus-node-exporter-nvme.timer
prometheus-node-exporter-smartmon.timer
prometheus-node-exporter-ipmitool-sensor.timer     (inactive, no IPMI here)
prometheus-node-exporter-mellanox-hca-temp.timer   (inactive)

apt.prom  nvme.prom  smartmon.prom
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The one that earns its keep on any Ubuntu box is &lt;code&gt;apt.prom&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; http://127.0.0.1:9100/metrics | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s1"&gt;'^(apt_|node_reboot_required)'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;apt_autoremove_pending 0
apt_package_cache_timestamp_seconds 1.7899594604788072e+09
apt_upgrades_pending{arch="amd64",origin="Ubuntu:resolute/resolute-security,Ubuntu:resolute/resolute-updates"} 70
apt_upgrades_pending{arch="all",origin="Ubuntu:resolute/resolute-security,Ubuntu:resolute/resolute-updates"} 12
apt_upgrades_pending{arch="amd64",origin="Ubuntu:resolute/resolute-updates"} 8
apt_upgrades_pending{arch="all",origin="Ubuntu:resolute/resolute-updates"} 3
node_reboot_required 0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Ninety three pending upgrades on an image that was a few minutes old, eighty two of them from the security pocket, and a &lt;code&gt;node_reboot_required&lt;/code&gt; gauge that goes to &lt;code&gt;1&lt;/code&gt; on the apt collector's next run after &lt;code&gt;/run/reboot-required&lt;/code&gt; appears, so within about fifteen minutes. Those two metrics are the reason I would keep the collectors package on real hardware, where &lt;code&gt;smartmon.prom&lt;/code&gt; and &lt;code&gt;nvme.prom&lt;/code&gt; also mean something.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpb2nuq1edfcjflq8tywb.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpb2nuq1edfcjflq8tywb.png" alt="the five collector timers, three .prom files in the textfile directory, and the apt_upgrades_pending series adding up to 93 with node_reboot_required 0" width="800" height="508"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;On these VMs there is no SMART data and no IPMI, so the trade is &lt;code&gt;ipmitool&lt;/code&gt; and friends for one useful script. If you decide that before installing, the smaller install is one flag:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;apt &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;--no-install-recommends&lt;/span&gt; prometheus-node-exporter
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That installs exactly one package. No timers, an empty textfile directory, the same exporter. If you already did the default install in section 1, the flag does nothing on its own (apt reports nothing new to install); purge &lt;code&gt;prometheus-node-exporter prometheus-node-exporter-collectors&lt;/code&gt;, run &lt;code&gt;apt autoremove&lt;/code&gt;, and install again with the flag. If you want &lt;code&gt;apt_upgrades_pending&lt;/code&gt; without the rest, the script is &lt;a href="https://github.com/prometheus-community/node-exporter-textfile-collector-scripts/blob/master/apt_info.py" rel="noopener noreferrer"&gt;&lt;code&gt;apt_info.py&lt;/code&gt;&lt;/a&gt; from the community collector repo; it needs &lt;code&gt;python3-apt&lt;/code&gt; and &lt;code&gt;python3-prometheus-client&lt;/code&gt;, a timer of your own, and section 4 shows where its output goes.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Bind it to the private interface and fence it with ufw
&lt;/h2&gt;

&lt;p&gt;Out of the box the exporter listens on &lt;code&gt;*:9100&lt;/code&gt;, every address the machine has. On a server with a public IP that is the whole machine's inventory on an unauthenticated port. Two layers fix it. First, tell the exporter to listen only on the private address:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;nano /etc/default/prometheus-node-exporter
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ARGS="--web.listen-address=10.20.1.10:9100"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl restart prometheus-node-exporter
&lt;span class="nb"&gt;sleep &lt;/span&gt;1&lt;span class="p"&gt;;&lt;/span&gt; ss &lt;span class="nt"&gt;-tlnp&lt;/span&gt; | &lt;span class="nb"&gt;grep &lt;/span&gt;9100
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The socket line now shows &lt;code&gt;10.20.1.10:9100&lt;/code&gt; instead of &lt;code&gt;*:9100&lt;/code&gt;. The &lt;code&gt;sleep&lt;/code&gt; is there because the exporter takes a moment to bind after the restart; without it the &lt;code&gt;grep&lt;/code&gt; can come back empty and send you looking for a problem that does not exist.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;curl http://127.0.0.1:9100/metrics&lt;/code&gt; now fails with &lt;code&gt;connection refused&lt;/code&gt;. That is correct; the exporter is no longer on loopback. Use the private address for local checks from here on.&lt;/p&gt;

&lt;p&gt;Second, allow only the Prometheus box through the firewall. If ufw is not on yet, let SSH in before enabling it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;ufw allow OpenSSH
&lt;span class="nb"&gt;sudo &lt;/span&gt;ufw allow from 10.20.1.20 to any port 9100 proto tcp
&lt;span class="nb"&gt;sudo &lt;/span&gt;ufw &lt;span class="nb"&gt;enable
sudo &lt;/span&gt;ufw status
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Belt and braces. If someone later changes &lt;code&gt;ARGS&lt;/code&gt; back, or the private interface picks up a second address, the firewall still only admits one client. If your two machines talk over a tailnet instead, bind to the &lt;code&gt;100.x&lt;/code&gt; address and allow from the Prometheus node's &lt;code&gt;100.x&lt;/code&gt; address; nothing else changes.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6dlifkxuh3r61vb42pmb.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6dlifkxuh3r61vb42pmb.png" alt="the two interfaces, ARGS set to the private address, ss showing 10.20.1.10:9100, curl to loopback exiting 7, curl to the private address 200, and the ufw rule allowing 9100 from 10.20.1.20" width="800" height="461"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  4. A metric of your own with the textfile collector
&lt;/h2&gt;

&lt;p&gt;The textfile collector is on by default in the Ubuntu package, pointed at &lt;code&gt;/var/lib/prometheus/node-exporter/&lt;/code&gt;. Any &lt;code&gt;*.prom&lt;/code&gt; file in there, in the exposition format, is served alongside the built in metrics. This is how a deploy script reports when it last ran:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'# HELP deploy_timestamp_seconds Unix time of the last deploy.\n# TYPE deploy_timestamp_seconds gauge\ndeploy_timestamp_seconds %s\n'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%s&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="nb"&gt;sudo tee&lt;/span&gt; /var/lib/prometheus/node-exporter/deploy.prom.tmp &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /dev/null
&lt;span class="nb"&gt;sudo mv&lt;/span&gt; /var/lib/prometheus/node-exporter/deploy.prom.tmp /var/lib/prometheus/node-exporter/deploy.prom
curl &lt;span class="nt"&gt;-s&lt;/span&gt; http://10.20.1.10:9100/metrics | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s1"&gt;'^deploy_timestamp_seconds|^node_textfile_scrape_error'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;deploy_timestamp_seconds 1.789959553e+09
node_textfile_scrape_error 0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Write to a temporary name and &lt;code&gt;mv&lt;/code&gt; into place. The exporter can read the directory at any moment. I dropped a file of garbage in there to see what happens: &lt;code&gt;node_textfile_scrape_error&lt;/code&gt; went to &lt;code&gt;1&lt;/code&gt;, the journal got &lt;code&gt;failed to parse textfile data&lt;/code&gt;, that file's metrics were skipped, and every other file was still served. A half written file that happens to still parse is worse: it is served as if complete, with no error. Hence the rename. The &lt;code&gt;mv&lt;/code&gt; is atomic on the same filesystem, so the exporter sees either the old file or the whole new one.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fky467o8u2vmwvhcoa3gu.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fky467o8u2vmwvhcoa3gu.png" alt="deploy.prom written to a temp name and moved into place, then deploy_timestamp_seconds served with node_textfile_scrape_error 0" width="800" height="268"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Prometheus from the archive, scraping it
&lt;/h2&gt;

&lt;p&gt;On the second machine:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;apt update
&lt;span class="nb"&gt;sudo &lt;/span&gt;apt &lt;span class="nb"&gt;install &lt;/span&gt;prometheus
prometheus &lt;span class="nt"&gt;--version&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is Prometheus &lt;code&gt;2.53.5&lt;/code&gt; from &lt;code&gt;universe&lt;/code&gt;. Upstream is on 3.x, and 2.53 was upstream's 2.x long term support line, whose upstream support ended in July 2025; Ubuntu maintains the package on its own from here. For a home or small fleet server that is fine: the scrape config, the query language and the alert rules in this post are identical on both. If you want 3.x, the upstream tarball installs the same way as section 7 does for the exporter. Two things the package does that you should know about: it recommends &lt;code&gt;prometheus-node-exporter&lt;/code&gt;, so the Prometheus box gets its own exporter and a &lt;code&gt;job_name: node&lt;/code&gt; for &lt;code&gt;localhost:9100&lt;/code&gt; already in the config, and it ships &lt;code&gt;/etc/default/prometheus&lt;/code&gt; with &lt;code&gt;ARGS=""&lt;/code&gt; for flags such as &lt;code&gt;--storage.tsdb.retention.time&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Before touching the config, confirm the network path works from this side:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;--max-time&lt;/span&gt; 5 &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s1"&gt;'%{http_code}\n'&lt;/span&gt; http://10.20.1.10:9100/metrics
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A &lt;code&gt;200&lt;/code&gt; here means the bind address is right and the firewall lets this box through. Anything else, fix that first; Prometheus will only tell you the same thing more slowly. Then add the job to the end of &lt;code&gt;/etc/prometheus/prometheus.yml&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;job_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;nodex'&lt;/span&gt;
    &lt;span class="na"&gt;static_configs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;targets&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;10.20.1.10:9100'&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
        &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;instance&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;nodex'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Setting &lt;code&gt;instance&lt;/code&gt; yourself is optional. Without it, Prometheus uses the target address as the instance label, and every alert reads &lt;code&gt;10.20.1.10:9100 needs a reboot&lt;/code&gt;. With it, alerts and queries say the machine's name. Check and reload:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;promtool check config /etc/prometheus/prometheus.yml
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl reload prometheus
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The package's unit has an &lt;code&gt;ExecReload&lt;/code&gt; that sends &lt;code&gt;HUP&lt;/code&gt;, so &lt;code&gt;reload&lt;/code&gt; re reads the config without dropping the database. Give it a scrape interval and ask:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s1"&gt;'http://127.0.0.1:9090/api/v1/query?query=up'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Prometheus has a web UI on port 9090 that is easier to read, but the API is what works from a headless box over SSH, and the shape is simple: &lt;code&gt;data.result[].metric&lt;/code&gt; is the label set and &lt;code&gt;data.result[].value[1]&lt;/code&gt; is the number. &lt;code&gt;up&lt;/code&gt; is &lt;code&gt;1&lt;/code&gt; for every target Prometheus could scrape on its last attempt and &lt;code&gt;0&lt;/code&gt; for every one it could not. Mine showed three targets at &lt;code&gt;1&lt;/code&gt;: Prometheus itself, its local exporter, and &lt;code&gt;nodex&lt;/code&gt;. For anything longer than &lt;code&gt;up&lt;/code&gt;, a small shell function saves quoting the URL by hand:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;q&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; curl &lt;span class="nt"&gt;-s&lt;/span&gt; http://127.0.0.1:9090/api/v1/query &lt;span class="nt"&gt;--data-urlencode&lt;/span&gt; &lt;span class="s2"&gt;"query=&lt;/span&gt;&lt;span class="nv"&gt;$1&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | python3 &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"import sys,json; [print(r['metric'].get('instance'), r['value'][1]) for r in json.load(sys.stdin)['data']['result']]"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;
q &lt;span class="s1"&gt;'node_load1'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That prints one line per series, instance then value. From there, the numbers you came for, as expression and value:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;node_load1{instance="nodex"}                                   0.23
node_filesystem_avail_bytes{mountpoint="/",instance="nodex"}
  / node_filesystem_size_bytes{mountpoint="/",instance="nodex"} * 100   91.4
deploy_timestamp_seconds{instance="nodex"}                     1789959553
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The textfile metric from section 4 shows up like any other, which is the point of it.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftavbcpof97o2mbp12f2q.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftavbcpof97o2mbp12f2q.png" alt="Prometheus 2.53.5, the private curl answering 200, the appended job, promtool SUCCESS, and up at 1 for prometheus, node and nodex" width="800" height="608"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The rules directory from section 6 was already in place when this shot was taken, which is why &lt;code&gt;promtool&lt;/code&gt; counts a rule file here; on a first run it prints &lt;code&gt;SUCCESS: /etc/prometheus/prometheus.yml is valid prometheus config file syntax&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffc3eimn2zd4du1utnqzj.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffc3eimn2zd4du1utnqzj.png" alt="node_load1, root filesystem percent free, deploy_timestamp_seconds and pending upgrades per instance, for both the local exporter and nodex" width="800" height="368"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Three alert rules worth having
&lt;/h2&gt;

&lt;p&gt;Metrics nobody looks at are a database. I put these three rules in &lt;code&gt;/etc/prometheus/rules/node.yml&lt;/code&gt; on the Prometheus VM (the directory does not exist after the install, so &lt;code&gt;sudo mkdir -p /etc/prometheus/rules&lt;/code&gt; first):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;groups&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;node&lt;/span&gt;
    &lt;span class="na"&gt;rules&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;alert&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;RebootRequired&lt;/span&gt;
        &lt;span class="na"&gt;expr&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;node_reboot_required == &lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;
        &lt;span class="na"&gt;for&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;1h&lt;/span&gt;
        &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;severity&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;info&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
        &lt;span class="na"&gt;annotations&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;summary&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;$labels.instance&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;needs&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;a&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;reboot"&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;alert&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;RootDiskLow&lt;/span&gt;
        &lt;span class="na"&gt;expr&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;node_filesystem_avail_bytes{mountpoint="/"} / node_filesystem_size_bytes{mountpoint="/"} &amp;lt; &lt;/span&gt;&lt;span class="m"&gt;0.10&lt;/span&gt;
        &lt;span class="na"&gt;for&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;15m&lt;/span&gt;
        &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;severity&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;warning&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
        &lt;span class="na"&gt;annotations&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;summary&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;$labels.instance&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;root&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;filesystem&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;under&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;10%&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;free"&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;alert&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;PendingSecurityUpdates&lt;/span&gt;
        &lt;span class="na"&gt;expr&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;sum by (instance) (apt_upgrades_pending{origin=~".*security.*"}) &amp;gt; &lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;
        &lt;span class="na"&gt;for&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;1d&lt;/span&gt;
        &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;severity&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;info&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
        &lt;span class="na"&gt;annotations&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;summary&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;$labels.instance&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;has&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;security&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;updates&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;pending"&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Point the config at the directory by replacing the empty &lt;code&gt;rule_files:&lt;/code&gt; line near the top of &lt;code&gt;prometheus.yml&lt;/code&gt;, then check both files and reload:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;rule_files&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/etc/prometheus/rules/*.yml&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;promtool check rules /etc/prometheus/rules/node.yml
promtool check config /etc/prometheus/prometheus.yml
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl reload prometheus
curl &lt;span class="nt"&gt;-s&lt;/span&gt; http://127.0.0.1:9090/api/v1/rules
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;promtool check rules&lt;/code&gt; reported &lt;code&gt;SUCCESS: 3 rules found&lt;/code&gt;, and within fifteen seconds of the reload, once the first evaluation had run, the rules API listed all three as &lt;code&gt;health: ok&lt;/code&gt; (before that they show &lt;code&gt;unknown&lt;/code&gt;). Within a minute &lt;code&gt;PendingSecurityUpdates&lt;/code&gt; was &lt;code&gt;pending&lt;/code&gt; for both of my boxes, which is what images with eighty two security updates waiting should produce, and it will fire after the &lt;code&gt;for: 1d&lt;/code&gt; if nobody runs &lt;code&gt;apt upgrade&lt;/code&gt;. &lt;code&gt;RebootRequired&lt;/code&gt; and &lt;code&gt;PendingSecurityUpdates&lt;/code&gt; both come from the apt collector, so they need section 2's collectors package (or &lt;code&gt;apt_info.py&lt;/code&gt;) on each target. &lt;code&gt;RootDiskLow&lt;/code&gt; uses the built in filesystem collector and works everywhere.&lt;/p&gt;

&lt;p&gt;Where the alerts go (email, a chat webhook, a pager) is Alertmanager's job, which is &lt;code&gt;prometheus-alertmanager&lt;/code&gt; in the same archive and a post of its own. Until then the &lt;code&gt;/api/v1/alerts&lt;/code&gt; endpoint and the Alerts page on 9090 show what is pending and firing.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fohjpb9tib86fycsvom0b.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fohjpb9tib86fycsvom0b.png" alt="promtool check rules reports 3 rules found, the rule_files entry, the rules API listing RebootRequired inactive, RootDiskLow inactive and PendingSecurityUpdates pending, and the alerts API showing that alert pending on both instances with value 82" width="799" height="465"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  7. The upstream binary, when the package is too old
&lt;/h2&gt;

&lt;p&gt;Back on the target for this section. The archive's 1.10.2 is from late 2025; upstream was at 1.12.1 when I wrote this. If you need a collector or a fix from a newer release, the upstream binary is a single file and the unit is the same shape as the packaged one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;V&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1.12.1
curl &lt;span class="nt"&gt;-sLO&lt;/span&gt; https://github.com/prometheus/node_exporter/releases/download/v&lt;span class="nv"&gt;$V&lt;/span&gt;/node_exporter-&lt;span class="nv"&gt;$V&lt;/span&gt;.linux-amd64.tar.gz
&lt;span class="nb"&gt;tar &lt;/span&gt;xzf node_exporter-&lt;span class="nv"&gt;$V&lt;/span&gt;.linux-amd64.tar.gz
&lt;span class="nb"&gt;sudo install&lt;/span&gt; &lt;span class="nt"&gt;-m&lt;/span&gt; 0755 node_exporter-&lt;span class="nv"&gt;$V&lt;/span&gt;.linux-amd64/node_exporter /usr/local/bin/node_exporter
&lt;span class="nb"&gt;sudo &lt;/span&gt;useradd &lt;span class="nt"&gt;--system&lt;/span&gt; &lt;span class="nt"&gt;--no-create-home&lt;/span&gt; &lt;span class="nt"&gt;--shell&lt;/span&gt; /usr/sbin/nologin node_exporter
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="c"&gt;# /etc/systemd/system/node_exporter.service
&lt;/span&gt;&lt;span class="nn"&gt;[Unit]&lt;/span&gt;
&lt;span class="py"&gt;Description&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;Prometheus node exporter (upstream binary)&lt;/span&gt;
&lt;span class="py"&gt;After&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;network-online.target&lt;/span&gt;
&lt;span class="py"&gt;Wants&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;network-online.target&lt;/span&gt;

&lt;span class="nn"&gt;[Service]&lt;/span&gt;
&lt;span class="py"&gt;User&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;node_exporter&lt;/span&gt;
&lt;span class="py"&gt;ExecStart&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;/usr/local/bin/node_exporter --web.listen-address=10.20.1.10:9100 --collector.textfile.directory=/var/lib/prometheus/node-exporter&lt;/span&gt;
&lt;span class="py"&gt;Restart&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;on-failure&lt;/span&gt;

&lt;span class="nn"&gt;[Install]&lt;/span&gt;
&lt;span class="py"&gt;WantedBy&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;multi-user.target&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl disable &lt;span class="nt"&gt;--now&lt;/span&gt; prometheus-node-exporter
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl daemon-reload
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl &lt;span class="nb"&gt;enable&lt;/span&gt; &lt;span class="nt"&gt;--now&lt;/span&gt; node_exporter
&lt;span class="nb"&gt;sleep &lt;/span&gt;1&lt;span class="p"&gt;;&lt;/span&gt; curl &lt;span class="nt"&gt;-s&lt;/span&gt; http://10.20.1.10:9100/metrics | &lt;span class="nb"&gt;grep&lt;/span&gt; ^node_exporter_build_info
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both want port 9100, so the packaged one has to be stopped and disabled first, and &lt;code&gt;--collector.textfile.directory&lt;/code&gt; has to be passed explicitly; the upstream binary has no default for it. On my box &lt;code&gt;node_exporter_build_info&lt;/code&gt; switched from &lt;code&gt;version="1.10.2"&lt;/code&gt; to &lt;code&gt;version="1.12.1"&lt;/code&gt; and Prometheus did not notice anything but the label. The cost is that apt no longer upgrades it; that is now a &lt;code&gt;curl&lt;/code&gt; you run yourself.&lt;/p&gt;

&lt;h2&gt;
  
  
  8. Reading a down target
&lt;/h2&gt;

&lt;p&gt;The targets endpoint on the Prometheus box says why a scrape failed, and the two messages you will see most often look similar and are not. I broke the target on purpose both ways, from the target's own shell, and read the result from the Prometheus box:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; http://127.0.0.1:9090/api/v1/targets | python3 &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"import sys,json; [print(t['labels']['job'], t['health'], t['lastError']) for t in json.load(sys.stdin)['data']['activeTargets']]"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;First, &lt;code&gt;sudo systemctl stop prometheus-node-exporter&lt;/code&gt; on the target (or &lt;code&gt;node_exporter&lt;/code&gt; if you switched in section 7). Within twenty seconds Prometheus reported:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;nodex down Get "http://10.20.1.10:9100/metrics": dial tcp 10.20.1.10:9100: connect: connection refused
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;&lt;code&gt;connection refused&lt;/code&gt;&lt;/strong&gt; almost always means the packet arrived and nothing was listening: the exporter is stopped, crashed, or bound to a different address than the one in the job. (A firewall that rejects rather than drops produces it too.) Check the exporter's unit status and &lt;code&gt;ss -tlnp | grep 9100&lt;/code&gt; on the target.&lt;/p&gt;

&lt;p&gt;Then I started it again and deleted the ufw rule instead: &lt;code&gt;sudo ufw delete allow from 10.20.1.20 to any port 9100 proto tcp&lt;/code&gt; on the target. One thing to know before you try this: Prometheus keeps the HTTP connection to a target open between scrapes, and ufw does not cut an established connection, so &lt;code&gt;up&lt;/code&gt; can stay at &lt;code&gt;1&lt;/code&gt; for a while after the rule is gone. Restarting the exporter forces a new connection, and the next scrape reported:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;nodex down Get "http://10.20.1.10:9100/metrics": context deadline exceeded
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;&lt;code&gt;context deadline exceeded&lt;/code&gt;&lt;/strong&gt; means the scrape hit its timeout, ten seconds by default, without an answer: a firewall dropping packets, a wrong IP, a route that does not exist, or an exporter that is stalled. The exporter can be perfectly healthy. Check the ufw rule on the target and &lt;code&gt;curl --max-time 5&lt;/code&gt; the address from the Prometheus box (without the limit, a dropped packet leaves &lt;code&gt;curl&lt;/code&gt; sitting there for a couple of minutes). Put the rule back with the same &lt;code&gt;ufw allow&lt;/code&gt; from section 3 and the target was &lt;code&gt;up&lt;/code&gt; on the next scrape.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;up&lt;/code&gt; going to &lt;code&gt;0&lt;/code&gt; is the same for both, which is why it is worth having the &lt;code&gt;lastError&lt;/code&gt; text to hand before deciding which machine to log into.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwa6kh5ot5e46tv84ocds.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwa6kh5ot5e46tv84ocds.png" alt="targets API: connection refused after stopping the exporter, context deadline exceeded after deleting the ufw rule and restarting it, up again once the rule is back" width="799" height="453"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Gotchas I hit
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;apt install prometheus-node-exporter&lt;/code&gt; installed twenty two packages. &lt;code&gt;--no-install-recommends&lt;/code&gt; gets you one, at the price of the apt and reboot metrics, and it only helps if you pass it on the first install.&lt;/li&gt;
&lt;li&gt;The exporter listens on every interface by default. &lt;code&gt;--web.listen-address&lt;/code&gt; in &lt;code&gt;/etc/default/prometheus-node-exporter&lt;/code&gt; plus a ufw &lt;code&gt;allow from&lt;/code&gt; rule. After that, &lt;code&gt;curl 127.0.0.1:9100&lt;/code&gt; is refused on purpose.&lt;/li&gt;
&lt;li&gt;Deleting the ufw rule produced &lt;code&gt;context deadline exceeded&lt;/code&gt; while the exporter was still running. That message is about the path, not the process. And it only shows up once the scrape opens a new connection; the established one keeps working until something closes it.&lt;/li&gt;
&lt;li&gt;Two exporters cannot bind the same address and port; the second one fails with &lt;code&gt;address already in use&lt;/code&gt;. Disable the packaged unit before enabling an upstream one on the same address.&lt;/li&gt;
&lt;li&gt;The upstream binary has no default textfile directory. Pass &lt;code&gt;--collector.textfile.directory&lt;/code&gt; or the &lt;code&gt;.prom&lt;/code&gt; files silently stop appearing.&lt;/li&gt;
&lt;li&gt;Write &lt;code&gt;.prom&lt;/code&gt; files to a temp name and &lt;code&gt;mv&lt;/code&gt; them, or &lt;code&gt;node_textfile_scrape_error&lt;/code&gt; will occasionally be &lt;code&gt;1&lt;/code&gt; and the metrics in that file will be missing for a scrape.&lt;/li&gt;
&lt;li&gt;The archive's Prometheus is 2.53, a major version behind upstream. Everything in this post works on both, but read the upstream docs with that in mind.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Quick reference
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# target&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;apt update
&lt;span class="nb"&gt;sudo &lt;/span&gt;apt &lt;span class="nb"&gt;install &lt;/span&gt;prometheus-node-exporter          &lt;span class="c"&gt;# add --no-install-recommends to skip the collectors package and everything it pulls in&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;nano /etc/default/prometheus-node-exporter    &lt;span class="c"&gt;# ARGS="--web.listen-address=10.20.1.10:9100"&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl restart prometheus-node-exporter    &lt;span class="c"&gt;# node_exporter instead, if you moved to the upstream unit in section 7&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;ufw allow from 10.20.1.20 to any port 9100 proto tcp
curl &lt;span class="nt"&gt;-s&lt;/span&gt; http://10.20.1.10:9100/metrics | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s1"&gt;'^node_load1 |^node_reboot_required'&lt;/span&gt;

&lt;span class="c"&gt;# prometheus box&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;apt update &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;sudo &lt;/span&gt;apt &lt;span class="nb"&gt;install &lt;/span&gt;prometheus
&lt;span class="nb"&gt;sudo &lt;/span&gt;nano /etc/prometheus/prometheus.yml           &lt;span class="c"&gt;# add the job under scrape_configs, rule_files: - /etc/prometheus/rules/*.yml&lt;/span&gt;
promtool check config /etc/prometheus/prometheus.yml
promtool check rules /etc/prometheus/rules/node.yml
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl reload prometheus
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s1"&gt;'http://127.0.0.1:9090/api/v1/query?query=up'&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; http://127.0.0.1:9090/api/v1/targets      &lt;span class="c"&gt;# lastError says which machine to look at&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;--max-time&lt;/span&gt; 5 http://10.20.1.10:9100/metrics | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; 3   &lt;span class="c"&gt;# from here, is the path open&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; http://127.0.0.1:9090/api/v1/alerts
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Ninety three pending updates on a box that was five minutes old. The exporter did not fix that, but at least now something is counting.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://peculiarengineer.com/blog/prometheus-node-exporter-ubuntu-26-04/" rel="noopener noreferrer"&gt;peculiarengineer.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>prometheus</category>
      <category>monitoring</category>
      <category>ubuntu</category>
      <category>linux</category>
    </item>
    <item>
      <title>How To - Nginx reverse proxy on Ubuntu 26.04 without Docker</title>
      <dc:creator>Minor Keith</dc:creator>
      <pubDate>Mon, 21 Sep 2026 13:35:36 +0000</pubDate>
      <link>https://dev.to/peculiarengineer/how-to-nginx-reverse-proxy-on-ubuntu-2604-without-docker-2ece</link>
      <guid>https://dev.to/peculiarengineer/how-to-nginx-reverse-proxy-on-ubuntu-2604-without-docker-2ece</guid>
      <description>&lt;p&gt;The app is running. It listens on &lt;code&gt;127.0.0.1:8080&lt;/code&gt;, it has a systemd unit, and it works fine over an SSH tunnel. Now it needs a hostname, a certificate, and to stop being reachable only by yourself. The &lt;a href="https://peculiarengineer.com/blog/caddy-reverse-proxy-docker-compose-ubuntu-26-04/" rel="noopener noreferrer"&gt;Caddy post&lt;/a&gt; covers this for containers. This is the version for a plain Ubuntu 26.04 box where the app is a process and the proxy is the nginx that has been in the archive forever.&lt;/p&gt;

&lt;p&gt;Nginx needs a dozen lines for this. The reason it gets a post is what happened on my test box between the dozen lines and the working site: the default site kept answering just after a reload, the app saw &lt;code&gt;127.0.0.1&lt;/code&gt; as every client's address until the forwarding headers went in, a websocket endpoint came back &lt;code&gt;502&lt;/code&gt; until two more headers went in, and the HTTP/2 syntax every older guide still shows made nginx 1.28 print a deprecation warning. Each of those is one line once you know it, and none of them is in the nginx welcome page.&lt;/p&gt;

&lt;p&gt;In this setup nginx is the only thing facing the internet. It accepts the connection on 80 and 443, and opens a second connection from itself to &lt;code&gt;127.0.0.1:8080&lt;/code&gt;. Your app sees nginx as the client. Every &lt;code&gt;proxy_set_header&lt;/code&gt; line in this post exists to pass along something the app would otherwise never learn about the real request.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR.&lt;/strong&gt; &lt;code&gt;sudo apt install nginx&lt;/code&gt;, write &lt;code&gt;/etc/nginx/sites-available/app.conf&lt;/code&gt; with a &lt;code&gt;server_name&lt;/code&gt; and a &lt;code&gt;location /&lt;/code&gt; that does &lt;code&gt;proxy_pass http://127.0.0.1:8080;&lt;/code&gt; plus the &lt;code&gt;Host&lt;/code&gt;, &lt;code&gt;X-Real-IP&lt;/code&gt;, &lt;code&gt;X-Forwarded-For&lt;/code&gt; and &lt;code&gt;X-Forwarded-Proto&lt;/code&gt; headers, symlink it into &lt;code&gt;sites-enabled&lt;/code&gt;, remove the &lt;code&gt;default&lt;/code&gt; symlink, &lt;code&gt;sudo nginx -t &amp;amp;&amp;amp; sudo systemctl reload nginx&lt;/code&gt;. For HTTPS, &lt;code&gt;sudo apt install certbot python3-certbot-nginx&lt;/code&gt; and &lt;code&gt;sudo certbot --nginx -d app.example.com&lt;/code&gt;; on 26.04 that is certbot 4.0 from the archive and no snap is involved. Then add &lt;code&gt;http2 on;&lt;/code&gt; next to the &lt;code&gt;listen 443 ssl;&lt;/code&gt; line certbot wrote.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Prerequisites
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Ubuntu 26.04 with a sudo user. Everything here ran on a fresh 26.04.1 server image.&lt;/li&gt;
&lt;li&gt;An app already listening on a loopback port. Mine is a small Python HTTP server on &lt;code&gt;127.0.0.1:8080&lt;/code&gt; that prints back the headers it receives, run from a systemd unit, because that makes section 4 visible. Yours is whatever you are fronting. If it listens on &lt;code&gt;0.0.0.0&lt;/code&gt;, change that to &lt;code&gt;127.0.0.1&lt;/code&gt; first, or the proxy is decorative.&lt;/li&gt;
&lt;li&gt;A DNS &lt;code&gt;A&lt;/code&gt; record for the hostname pointing at the box, and ports 80 and 443 reachable from the internet. If a cloud firewall sits in front of the server, open both there too. The HTTP challenge certbot uses in section 6 needs 80 reachable from Let's Encrypt.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  1. Install nginx and open the firewall
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;apt update
&lt;span class="nb"&gt;sudo &lt;/span&gt;apt &lt;span class="nb"&gt;install &lt;/span&gt;nginx
nginx &lt;span class="nt"&gt;-v&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On 26.04 that is &lt;code&gt;nginx/1.28.3 (Ubuntu)&lt;/code&gt;, from &lt;code&gt;main&lt;/code&gt;, started and enabled by the package. It comes with HTTP/2, HTTP/3 and the real IP module compiled in, so nothing from a PPA is needed for anything in this post. (The stream module is a separate &lt;code&gt;libnginx-mod-stream&lt;/code&gt; package; this post does not need it.) Browse to the box's IP and you get the "Welcome to nginx!" page, served by &lt;code&gt;/etc/nginx/sites-enabled/default&lt;/code&gt;. That is the default site I remove in section 3.&lt;/p&gt;

&lt;p&gt;The package also registers ufw application profiles:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;ufw allow OpenSSH
&lt;span class="nb"&gt;sudo &lt;/span&gt;ufw allow &lt;span class="s1"&gt;'Nginx Full'&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;ufw &lt;span class="nb"&gt;enable&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;Nginx Full&lt;/code&gt; is 80 and 443. There are also &lt;code&gt;Nginx HTTP&lt;/code&gt;, &lt;code&gt;Nginx HTTPS&lt;/code&gt;, and &lt;code&gt;Nginx QUIC&lt;/code&gt;, which is 80 and 443 on TCP plus 443 on UDP. If ufw was already on, just add the second rule. If you have never touched ufw on this box, the &lt;a href="https://peculiarengineer.com/blog/ufw-firewall-basics-ubuntu/" rel="noopener noreferrer"&gt;ufw basics post&lt;/a&gt; explains why &lt;code&gt;OpenSSH&lt;/code&gt; goes in before &lt;code&gt;enable&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1qzuo3jra4ld416oc8nn.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1qzuo3jra4ld416oc8nn.png" alt="nginx 1.28.3 installed and active, the default site is the only one enabled, ufw allows OpenSSH and Nginx Full" width="800" height="487"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The screenshots in this post are from a second fresh 26.04 box, so the hostname in them is that box's reverse DNS name where the prose says app.example.com.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. The site file
&lt;/h2&gt;

&lt;p&gt;Nginx on Ubuntu reads &lt;code&gt;/etc/nginx/nginx.conf&lt;/code&gt;, which includes every file in &lt;code&gt;/etc/nginx/sites-enabled/&lt;/code&gt;. The convention is one file per site in &lt;code&gt;sites-available&lt;/code&gt; and a symlink in &lt;code&gt;sites-enabled&lt;/code&gt; to turn it on. Create the site with &lt;code&gt;sudo nano /etc/nginx/sites-available/app.conf&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="c1"&gt;# /etc/nginx/sites-available/app.conf&lt;/span&gt;
&lt;span class="k"&gt;server&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;listen&lt;/span&gt; &lt;span class="mi"&gt;80&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;listen&lt;/span&gt; &lt;span class="s"&gt;[::]:80&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;server_name&lt;/span&gt; &lt;span class="s"&gt;app.example.com&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="kn"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_pass&lt;/span&gt; &lt;span class="s"&gt;http://127.0.0.1:8080&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;Host&lt;/span&gt; &lt;span class="nv"&gt;$host&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;X-Real-IP&lt;/span&gt; &lt;span class="nv"&gt;$remote_addr&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;X-Forwarded-For&lt;/span&gt; &lt;span class="nv"&gt;$proxy_add_x_forwarded_for&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;X-Forwarded-Proto&lt;/span&gt; &lt;span class="nv"&gt;$scheme&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Enable it, test the whole configuration, and reload:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo ln&lt;/span&gt; &lt;span class="nt"&gt;-s&lt;/span&gt; /etc/nginx/sites-available/app.conf /etc/nginx/sites-enabled/
&lt;span class="nb"&gt;sudo &lt;/span&gt;nginx &lt;span class="nt"&gt;-t&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl reload nginx
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;nginx -t&lt;/code&gt; parses everything nginx would load and refuses if any file is broken, which is why it goes before every reload. A broken config with a plain &lt;code&gt;restart&lt;/code&gt; leaves you with no web server at all. With a &lt;code&gt;reload&lt;/code&gt;, nginx keeps serving the old config until the new one parses, so the worst case is the change not taking.&lt;/p&gt;

&lt;p&gt;Now &lt;code&gt;curl http://app.example.com/&lt;/code&gt;. Mine came back with the welcome page on the first try and the app on the second, and the next section is about why. Read it even if you got the app first time, because it ends with removing the default site, and every edit from here on gets the same &lt;code&gt;sudo nginx -t &amp;amp;&amp;amp; sudo systemctl reload nginx&lt;/code&gt; before you test it.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Why the default site keeps winning
&lt;/h2&gt;

&lt;p&gt;Nginx picks a &lt;code&gt;server&lt;/code&gt; block by matching the request's &lt;code&gt;Host&lt;/code&gt; header against every &lt;code&gt;server_name&lt;/code&gt;. When nothing matches, it uses the block marked &lt;code&gt;default_server&lt;/code&gt;, and if none is marked, the first one it loaded. &lt;code&gt;sites-enabled/default&lt;/code&gt; has both &lt;code&gt;listen&lt;/code&gt; lines marked &lt;code&gt;default_server&lt;/code&gt;, so any request for a name nginx does not recognise lands there:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s1"&gt;'Host: nothere.example.com'&lt;/span&gt; http://127.0.0.1/ | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="s1"&gt;'&amp;lt;title&amp;gt;.*&amp;lt;/title&amp;gt;'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;&amp;lt;title&amp;gt;Welcome to nginx!&amp;lt;/title&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is expected and harmless. What bit me was the request for my real hostname also landing on the welcome page, and the cause was not nginx at all. It was me running &lt;code&gt;curl&lt;/code&gt; in the same second as the &lt;code&gt;reload&lt;/code&gt;. A reload is graceful: the master process parses the new config, starts new workers, and tells the old ones to finish their connections and exit. &lt;code&gt;systemctl reload nginx&lt;/code&gt; returns as soon as the signal is sent, not when the swap is done, and on my box a request in that first second was still answered by an old worker running the old config. One second later the same request hit the app. If your first &lt;code&gt;curl&lt;/code&gt; after a reload shows the previous config, run it again before you touch anything.&lt;/p&gt;

&lt;p&gt;Then decide what should answer for hostnames you did not configure. I remove the default site, so that an unknown name falls through to the first block, which is the app:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo rm&lt;/span&gt; /etc/nginx/sites-enabled/default
&lt;span class="nb"&gt;sudo &lt;/span&gt;nginx &lt;span class="nt"&gt;-t&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl reload nginx
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you would rather unknown names get nothing, keep a catch all block with &lt;code&gt;return 444;&lt;/code&gt;, which closes the connection without a response. Either is fine. Leaving the welcome page up is the choice that looks unfinished to anyone who scans your IP.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcgpw6r0ea4pjmbg9rhns.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcgpw6r0ea4pjmbg9rhns.png" alt="enabling the site, an unknown Host header still gets the welcome page, both default listen lines carry default_server, and after removing the default site the unknown name reaches the app" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  4. The headers your app actually needs
&lt;/h2&gt;

&lt;p&gt;Take the four &lt;code&gt;proxy_set_header&lt;/code&gt; lines out, test and reload, and look at what the app receives. Mine prints them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Host: 127.0.0.1:8080
X-Forwarded-For: None
X-Forwarded-Proto: None
X-Real-IP: None
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without the headers, the app thinks its own name is &lt;code&gt;127.0.0.1:8080&lt;/code&gt;, so any absolute link it builds from &lt;code&gt;Host&lt;/code&gt; points at loopback. (Nginx rewrites the &lt;code&gt;Location&lt;/code&gt; header of a redirect back to the public name by default; it does nothing about links inside page bodies.) The app sees every visitor as &lt;code&gt;127.0.0.1&lt;/code&gt;, so rate limits and audit logs are wrong. And it has no way to know the request arrived over HTTPS once section 6 is done, which is where secure cookie and redirect loop problems come from. Put the four lines back and the same request shows, with my hostname and address in place of the placeholders:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Host: app.example.com
X-Forwarded-For: 203.0.113.7
X-Forwarded-Proto: http
X-Real-IP: 203.0.113.7
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;$proxy_add_x_forwarded_for&lt;/code&gt; appends the connecting address to whatever &lt;code&gt;X-Forwarded-For&lt;/code&gt; already came in, so with a CDN in front the chain ends with the CDN's address and the app has to know how many hops to trust. &lt;code&gt;X-Real-IP&lt;/code&gt; is always the address nginx saw. Which header your app trusts is the app's business. Web frameworks have a setting for "I am behind a proxy, believe these headers", and it is off by default because a client can send &lt;code&gt;X-Forwarded-For: 198.51.100.66&lt;/code&gt; itself; on a test box that forged value arrived at the app as &lt;code&gt;198.51.100.66, &amp;lt;the client address&amp;gt;&lt;/code&gt;. Trust the headers only on requests that came from &lt;code&gt;127.0.0.1&lt;/code&gt;, and only as many hops of the chain as you actually have.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzof11cp2256gq6rlm9is.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzof11cp2256gq6rlm9is.png" alt="the same curl before and after deleting the proxy_set_header lines: the app first sees the real hostname and client address, then Host 127.0.0.1:8080 and None for the other three" width="800" height="403"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Websockets
&lt;/h2&gt;

&lt;p&gt;Anything that opens a websocket (a web terminal, a chat, most dashboards that update without a refresh) needs three more lines, and the failure without them is worth seeing once. My websocket app is a small echo server on &lt;code&gt;127.0.0.1:8081&lt;/code&gt; (Python's &lt;code&gt;websockets&lt;/code&gt; package, &lt;code&gt;python3-websockets&lt;/code&gt; in the archive, and a client from the same package). I gave it a &lt;code&gt;location /ws/&lt;/code&gt; with &lt;code&gt;proxy_pass http://127.0.0.1:8081/;&lt;/code&gt; and the &lt;code&gt;Host&lt;/code&gt; header and nothing else. The client got &lt;code&gt;server rejected WebSocket connection: HTTP 502&lt;/code&gt; and nginx logged &lt;code&gt;upstream prematurely closed connection while reading response header from upstream&lt;/code&gt;. The app was fine. Nginx had spoken HTTP/1.0 to it and dropped the &lt;code&gt;Upgrade&lt;/code&gt; header, so the app closed a handshake it never saw. (If your websocket lives on the same port as the rest of the app, a bare &lt;code&gt;location /&lt;/code&gt; sends the request to the app as an ordinary HTTP request and you get whatever the app answers with, a 200 or a 400, and the same fix applies.)&lt;/p&gt;

&lt;p&gt;Two things fix it. A &lt;code&gt;map&lt;/code&gt; outside the &lt;code&gt;server&lt;/code&gt; block that turns the &lt;code&gt;Connection&lt;/code&gt; header into &lt;code&gt;upgrade&lt;/code&gt; only when the client asked for one, and a &lt;code&gt;location&lt;/code&gt; for the websocket path that speaks HTTP/1.1 and passes both headers through:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;map&lt;/span&gt; &lt;span class="nv"&gt;$http_upgrade&lt;/span&gt; &lt;span class="nv"&gt;$connection_upgrade&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;default&lt;/span&gt; &lt;span class="s"&gt;upgrade&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;''&lt;/span&gt;      &lt;span class="s"&gt;close&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;server&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;listen&lt;/span&gt; &lt;span class="mi"&gt;80&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;listen&lt;/span&gt; &lt;span class="s"&gt;[::]:80&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;server_name&lt;/span&gt; &lt;span class="s"&gt;app.example.com&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="kn"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_pass&lt;/span&gt; &lt;span class="s"&gt;http://127.0.0.1:8080&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;Host&lt;/span&gt; &lt;span class="nv"&gt;$host&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;X-Real-IP&lt;/span&gt; &lt;span class="nv"&gt;$remote_addr&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;X-Forwarded-For&lt;/span&gt; &lt;span class="nv"&gt;$proxy_add_x_forwarded_for&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;X-Forwarded-Proto&lt;/span&gt; &lt;span class="nv"&gt;$scheme&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="kn"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/ws/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_pass&lt;/span&gt; &lt;span class="s"&gt;http://127.0.0.1:8081/&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_http_version&lt;/span&gt; &lt;span class="mf"&gt;1.1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;Upgrade&lt;/span&gt; &lt;span class="nv"&gt;$http_upgrade&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;Connection&lt;/span&gt; &lt;span class="nv"&gt;$connection_upgrade&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;Host&lt;/span&gt; &lt;span class="nv"&gt;$host&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;map&lt;/code&gt; has to sit at the &lt;code&gt;http&lt;/code&gt; level, which in a &lt;code&gt;sites-available&lt;/code&gt; file means above &lt;code&gt;server&lt;/code&gt;, not inside it. Test, reload, and my echo server answered &lt;code&gt;echo: ping&lt;/code&gt; through the proxy. If the app serves the websocket on the same port and path as everything else, put the three extra lines in &lt;code&gt;location /&lt;/code&gt; instead of a second location; they do no harm to ordinary requests.&lt;/p&gt;

&lt;p&gt;The trailing slash on &lt;code&gt;proxy_pass http://127.0.0.1:8081/&lt;/code&gt; matters. With it, nginx strips the &lt;code&gt;/ws/&lt;/code&gt; prefix and the app sees &lt;code&gt;/&lt;/code&gt;. Without it, the app would receive &lt;code&gt;/ws/&lt;/code&gt; and would need a route for that path. Either way, be deliberate.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0tby2tsa0xf6wuidczoo.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0tby2tsa0xf6wuidczoo.png" alt="websocket client rejected with HTTP 502 and the upstream prematurely closed connection log line, then echo: ping once the three lines are in the location" width="800" height="369"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  6. HTTPS with certbot from the archive
&lt;/h2&gt;

&lt;p&gt;Certbot's own instructions still send Ubuntu users to the snap. On 26.04 the archive has certbot 4.0.0 and its nginx plugin in &lt;code&gt;universe&lt;/code&gt;, current enough that I see no reason to add snapd for it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;apt &lt;span class="nb"&gt;install &lt;/span&gt;certbot python3-certbot-nginx
&lt;span class="nb"&gt;sudo &lt;/span&gt;certbot &lt;span class="nt"&gt;--nginx&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; app.example.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Certbot answers the HTTP challenge through the running nginx on port 80, gets the certificate, and rewrites &lt;code&gt;app.conf&lt;/code&gt;: it turns your existing &lt;code&gt;server&lt;/code&gt; block into the HTTPS one, with &lt;code&gt;listen 443 ssl&lt;/code&gt; and the &lt;code&gt;ssl_certificate&lt;/code&gt; lines pointing into &lt;code&gt;/etc/letsencrypt/live/app.example.com/&lt;/code&gt;, and adds a separate port 80 block that redirects to it. Renewal is a systemd timer the package installs, &lt;code&gt;certbot.timer&lt;/code&gt;, which runs at midnight and noon with a random delay of up to twelve hours; &lt;code&gt;systemctl list-timers certbot.timer&lt;/code&gt; shows the next run, and &lt;code&gt;sudo certbot renew --dry-run&lt;/code&gt; proves it can renew without waiting sixty days to find out.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;I could not run this step on the box used for this post, because its provider firewall only opens SSH and Let's Encrypt has to reach port 80. The commands above are certbot's documented flow, and the package versions are what 26.04 installs. Everything below in this section I did run with a self signed certificate standing in for the real one, which you can do too if your 80 is closed for now:&lt;/em&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;openssl req &lt;span class="nt"&gt;-x509&lt;/span&gt; &lt;span class="nt"&gt;-newkey&lt;/span&gt; rsa:2048 &lt;span class="nt"&gt;-nodes&lt;/span&gt; &lt;span class="nt"&gt;-days&lt;/span&gt; 30 &lt;span class="nt"&gt;-subj&lt;/span&gt; /CN&lt;span class="o"&gt;=&lt;/span&gt;app.example.com &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-keyout&lt;/span&gt; /etc/ssl/private/app.key &lt;span class="nt"&gt;-out&lt;/span&gt; /etc/ssl/certs/app.crt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="c1"&gt;# a second server block in app.conf, the shape certbot produces&lt;/span&gt;
&lt;span class="k"&gt;server&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;listen&lt;/span&gt; &lt;span class="mi"&gt;443&lt;/span&gt; &lt;span class="s"&gt;ssl&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;http2&lt;/span&gt; &lt;span class="no"&gt;on&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;server_name&lt;/span&gt; &lt;span class="s"&gt;app.example.com&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;ssl_certificate&lt;/span&gt; &lt;span class="n"&gt;/etc/ssl/certs/app.crt&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;ssl_certificate_key&lt;/span&gt; &lt;span class="n"&gt;/etc/ssl/private/app.key&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_pass&lt;/span&gt; &lt;span class="s"&gt;http://127.0.0.1:8080&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;Host&lt;/span&gt; &lt;span class="nv"&gt;$host&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;X-Real-IP&lt;/span&gt; &lt;span class="nv"&gt;$remote_addr&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;X-Forwarded-For&lt;/span&gt; &lt;span class="nv"&gt;$proxy_add_x_forwarded_for&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;X-Forwarded-Proto&lt;/span&gt; &lt;span class="nv"&gt;$scheme&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;em&gt;Test with &lt;code&gt;curl -k&lt;/code&gt;, since the certificate is self signed, and swap the two &lt;code&gt;ssl_&lt;/code&gt; lines for certbot's when the port opens.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqq7bisgl372lmbosls4k.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqq7bisgl372lmbosls4k.png" alt="certbot 4.0.0 from universe, certbot.timer listed with its next run, OnCalendar at midnight and noon with a 43200 second randomized delay" width="800" height="487"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Certbot writes &lt;code&gt;listen 443 ssl;&lt;/code&gt; and stops there. To turn HTTP/2 on, add one line inside that block:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;listen&lt;/span&gt; &lt;span class="mi"&gt;443&lt;/span&gt; &lt;span class="s"&gt;ssl&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;http2&lt;/span&gt; &lt;span class="no"&gt;on&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Do not write &lt;code&gt;listen 443 ssl http2;&lt;/code&gt;. Older configs and most guides still have that syntax. On nginx 1.28 it parses, but &lt;code&gt;nginx -t&lt;/code&gt; prints:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[warn] the "listen ... http2" directive is deprecated, use the "http2" directive instead
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With &lt;code&gt;http2 on;&lt;/code&gt; in place, &lt;code&gt;curl --http2 -I https://app.example.com/&lt;/code&gt; comes back as &lt;code&gt;HTTP/2 200&lt;/code&gt; (add &lt;code&gt;-k&lt;/code&gt; for the self signed stand in). Also add &lt;code&gt;X-Forwarded-Proto $scheme&lt;/code&gt; to the 443 block if certbot's rewrite did not carry it over. In that block &lt;code&gt;$scheme&lt;/code&gt; is &lt;code&gt;https&lt;/code&gt;, which is what tells your app to issue secure cookies.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fv88ib28smtjjp3lnoi1m.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fv88ib28smtjjp3lnoi1m.png" alt="nginx -t warning that listen ... http2 is deprecated, the two line replacement, and curl reporting HTTP version 2 with a 200" width="800" height="415"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  7. Reading the errors: 502, 413, and a reload that has not landed yet
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;502 Bad Gateway&lt;/code&gt;&lt;/strong&gt; means nginx accepted the request and could not get an answer from the app. I stopped the app (&lt;code&gt;sudo systemctl stop app&lt;/code&gt;, mine is a unit called &lt;code&gt;app&lt;/code&gt;, then &lt;code&gt;curl -i http://app.example.com/&lt;/code&gt;) and got exactly this, with the cause in the log:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;connect() failed (111: Connection refused) while connecting to upstream
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The line to read is the most recent one in &lt;code&gt;/var/log/nginx/error.log&lt;/code&gt; that matches your request. &lt;code&gt;Connection refused&lt;/code&gt; is the app not listening (crashed, wrong port, bound to a different address). &lt;code&gt;upstream prematurely closed connection&lt;/code&gt; is the app closing mid request, which is the websocket case from section 5, or an app that crashed on that specific request. A &lt;code&gt;504&lt;/code&gt; after sixty seconds with &lt;code&gt;upstream timed out&lt;/code&gt; in the log is &lt;code&gt;proxy_read_timeout&lt;/code&gt;, whose default is &lt;code&gt;60s&lt;/code&gt;. It is the wait between two reads from the app, not a total budget, so a slow first byte is what trips it. Raise it in the &lt;code&gt;location&lt;/code&gt; if the app legitimately takes longer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;413 Request Entity Too Large&lt;/code&gt;&lt;/strong&gt; is nginx refusing an upload before the app ever sees it. The default &lt;code&gt;client_max_body_size&lt;/code&gt; is &lt;code&gt;1m&lt;/code&gt;. A 2 MB &lt;code&gt;POST&lt;/code&gt; to my proxy (&lt;code&gt;head -c 2000000 /dev/zero &amp;gt; 2mb.bin; curl -i -X POST --data-binary @2mb.bin http://app.example.com/&lt;/code&gt;) got the 413 page from nginx with nothing in the app's log. Set &lt;code&gt;client_max_body_size 100m;&lt;/code&gt; (or whatever fits) in the &lt;code&gt;server&lt;/code&gt; or &lt;code&gt;location&lt;/code&gt; block.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The reload that has not landed&lt;/strong&gt; is section 3: a request in the same second as &lt;code&gt;systemctl reload nginx&lt;/code&gt; can still hit an old worker. Wait a second and try again before debugging a config that is actually fine.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fp5s4ptk4ffpbdb1gdpsf.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fp5s4ptk4ffpbdb1gdpsf.png" alt="app stopped: curl gets 502 and error.log shows connect() failed (111: Connection refused) while connecting to upstream; a 2 MB POST gets 413 Request Entity Too Large" width="799" height="351"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Gotchas I hit
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;The default site answered for my hostname after a reload. It was the graceful reload still swapping workers, not a config problem. Run the &lt;code&gt;curl&lt;/code&gt; twice.&lt;/li&gt;
&lt;li&gt;Websockets came back &lt;code&gt;502&lt;/code&gt; with &lt;code&gt;upstream prematurely closed connection&lt;/code&gt; in the log. The &lt;code&gt;map&lt;/code&gt; block plus &lt;code&gt;proxy_http_version 1.1&lt;/code&gt;, &lt;code&gt;Upgrade&lt;/code&gt; and &lt;code&gt;Connection&lt;/code&gt; in the location fixed it.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;nginx -t&lt;/code&gt; warned &lt;code&gt;the "listen ... http2" directive is deprecated&lt;/code&gt;. Copying a 22.04 era config onto 26.04 does this. Use &lt;code&gt;listen 443 ssl;&lt;/code&gt; and &lt;code&gt;http2 on;&lt;/code&gt; on its own line.&lt;/li&gt;
&lt;li&gt;A 2 MB upload got &lt;code&gt;413&lt;/code&gt;. &lt;code&gt;client_max_body_size&lt;/code&gt; defaults to &lt;code&gt;1m&lt;/code&gt; and the app never sees the request.&lt;/li&gt;
&lt;li&gt;Without &lt;code&gt;proxy_set_header Host $host&lt;/code&gt; my test app received &lt;code&gt;127.0.0.1:8080&lt;/code&gt; as the &lt;code&gt;Host&lt;/code&gt; header. Without &lt;code&gt;X-Forwarded-For&lt;/code&gt; it had no idea who the visitor was.&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;map&lt;/code&gt; for websockets goes above the &lt;code&gt;server&lt;/code&gt; block, not inside it. Inside, &lt;code&gt;nginx -t&lt;/code&gt; fails with &lt;code&gt;"map" directive is not allowed here&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Certbot 4.0 comes from apt on 26.04. If you already have certbot from snap on the box, pick one installation and remove the other, so only one of them manages renewal.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Quick reference
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# on a fresh box, in this order&lt;/span&gt;
&lt;span class="c"&gt;# install and firewall&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;apt &lt;span class="nb"&gt;install &lt;/span&gt;nginx
&lt;span class="nb"&gt;sudo &lt;/span&gt;ufw allow &lt;span class="s1"&gt;'Nginx Full'&lt;/span&gt;

&lt;span class="c"&gt;# site&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;nano /etc/nginx/sites-available/app.conf
&lt;span class="nb"&gt;sudo ln&lt;/span&gt; &lt;span class="nt"&gt;-s&lt;/span&gt; /etc/nginx/sites-available/app.conf /etc/nginx/sites-enabled/
&lt;span class="nb"&gt;sudo rm&lt;/span&gt; /etc/nginx/sites-enabled/default
&lt;span class="nb"&gt;sudo &lt;/span&gt;nginx &lt;span class="nt"&gt;-t&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl reload nginx

&lt;span class="c"&gt;# https&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;apt &lt;span class="nb"&gt;install &lt;/span&gt;certbot python3-certbot-nginx
&lt;span class="nb"&gt;sudo &lt;/span&gt;certbot &lt;span class="nt"&gt;--nginx&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; app.example.com
&lt;span class="nb"&gt;sudo &lt;/span&gt;certbot renew &lt;span class="nt"&gt;--dry-run&lt;/span&gt;

&lt;span class="c"&gt;# when it breaks&lt;/span&gt;
&lt;span class="nb"&gt;sudo tail&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; 20 /var/log/nginx/error.log
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s1"&gt;'%{http_code}\n'&lt;/span&gt; http://127.0.0.1:8080/   &lt;span class="c"&gt;# is the app even there&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="c1"&gt;# app.conf: the map goes above the server block, and this location replaces the one in section 2&lt;/span&gt;
&lt;span class="k"&gt;map&lt;/span&gt; &lt;span class="nv"&gt;$http_upgrade&lt;/span&gt; &lt;span class="nv"&gt;$connection_upgrade&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;default&lt;/span&gt; &lt;span class="s"&gt;upgrade&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;''&lt;/span&gt;      &lt;span class="s"&gt;close&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;# the location block, complete&lt;/span&gt;
&lt;span class="k"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;proxy_pass&lt;/span&gt; &lt;span class="s"&gt;http://127.0.0.1:8080&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;proxy_http_version&lt;/span&gt; &lt;span class="mf"&gt;1.1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;Upgrade&lt;/span&gt; &lt;span class="nv"&gt;$http_upgrade&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;Connection&lt;/span&gt; &lt;span class="nv"&gt;$connection_upgrade&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;Host&lt;/span&gt; &lt;span class="nv"&gt;$host&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;X-Real-IP&lt;/span&gt; &lt;span class="nv"&gt;$remote_addr&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;X-Forwarded-For&lt;/span&gt; &lt;span class="nv"&gt;$proxy_add_x_forwarded_for&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;X-Forwarded-Proto&lt;/span&gt; &lt;span class="nv"&gt;$scheme&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;client_max_body_size&lt;/span&gt; &lt;span class="mi"&gt;100m&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;    &lt;span class="c1"&gt;# uploads; default is 1m&lt;/span&gt;
    &lt;span class="kn"&gt;proxy_read_timeout&lt;/span&gt; &lt;span class="s"&gt;300s&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;      &lt;span class="c1"&gt;# only if the app has slow responses; default is 60s&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The app still only listens on loopback. It just has a front door now.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://peculiarengineer.com/blog/nginx-reverse-proxy-ubuntu-26-04/" rel="noopener noreferrer"&gt;peculiarengineer.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>nginx</category>
      <category>ubuntu</category>
      <category>linux</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Guide to DNS on Ubuntu 26.04</title>
      <dc:creator>Minor Keith</dc:creator>
      <pubDate>Fri, 04 Sep 2026 02:36:06 +0000</pubDate>
      <link>https://dev.to/peculiarengineer/guide-to-dns-on-ubuntu-2604-1fk</link>
      <guid>https://dev.to/peculiarengineer/guide-to-dns-on-ubuntu-2604-1fk</guid>
      <description>&lt;p&gt;For years my understanding of DNS on a Linux box was one file. &lt;code&gt;/etc/resolv.conf&lt;/code&gt; has a &lt;code&gt;nameserver&lt;/code&gt; line, that is where lookups go, done. Then at some point I opened it on an Ubuntu server and it said &lt;code&gt;nameserver 127.0.0.53&lt;/code&gt;, which is a DNS server I never installed, running on an address I never configured, and I closed the file and got on with my day.&lt;/p&gt;

&lt;p&gt;I have since written a &lt;a href="https://peculiarengineer.com/blog/set-static-ip-ubuntu-26-04-netplan/" rel="noopener noreferrer"&gt;Netplan post&lt;/a&gt; and &lt;a href="https://peculiarengineer.com/blog/install-tailscale-ubuntu-26-04/" rel="noopener noreferrer"&gt;three Tailscale posts&lt;/a&gt; that each wave at that address for a paragraph and move on. This is me finally sitting down to understand it. I took a fresh Ubuntu 26.04 server and followed one name lookup from the application all the way down to the server that answered, and then broke it in the ways I have seen it broken. Everything below is what the box printed.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR.&lt;/strong&gt; Two layers. First the C library reads &lt;code&gt;/etc/nsswitch.conf&lt;/code&gt;, which says &lt;code&gt;hosts: files dns&lt;/code&gt;: check &lt;code&gt;/etc/hosts&lt;/code&gt;, then ask DNS. "Ask DNS" means the &lt;code&gt;nameserver&lt;/code&gt; in &lt;code&gt;/etc/resolv.conf&lt;/code&gt;, which is a symlink to a file systemd-resolved writes, and it says &lt;code&gt;127.0.0.53&lt;/code&gt;. That is systemd-resolved itself, a local caching resolver, and it forwards to the real upstream servers it learned per network interface from DHCP or Netplan. &lt;code&gt;resolvectl status&lt;/code&gt; shows those servers, &lt;code&gt;resolvectl query&lt;/code&gt; shows which one answered and whether it came from cache. &lt;code&gt;dig&lt;/code&gt; skips the first layer and talks to the stub directly, which is why &lt;code&gt;dig&lt;/code&gt; and &lt;code&gt;ping&lt;/code&gt; can disagree. Change upstreams in Netplan, not by editing resolv.conf, which systemd-resolved rewrites. Split DNS is one server per domain per interface, and it is how Tailscale's MagicDNS works.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The one idea worth holding onto
&lt;/h2&gt;

&lt;p&gt;There are two layers, and most confusion about DNS on Linux comes from not knowing which one you are looking at.&lt;/p&gt;

&lt;p&gt;The first layer is not DNS at all. It is the C library's name lookup, the code linked into every program on the box that turns a name into an address, and it consults a list of sources in order. On Ubuntu that list is &lt;code&gt;/etc/hosts&lt;/code&gt; first, then DNS. Every normal program, &lt;code&gt;ping&lt;/code&gt;, &lt;code&gt;curl&lt;/code&gt;, &lt;code&gt;ssh&lt;/code&gt;, your Python script, goes through this layer, and a name that &lt;code&gt;/etc/hosts&lt;/code&gt; answers never becomes a DNS query.&lt;/p&gt;

&lt;p&gt;The second layer is the resolver. On Ubuntu 26.04 that is systemd-resolved, listening on &lt;code&gt;127.0.0.53&lt;/code&gt;. It caches, it knows which upstream servers belong to which network interface, and it forwards your question to the right one. &lt;code&gt;dig&lt;/code&gt;, &lt;code&gt;host&lt;/code&gt; and &lt;code&gt;nslookup&lt;/code&gt; skip the first layer entirely and talk to this one.&lt;/p&gt;

&lt;p&gt;Hold onto that and the rest is detail. When something resolves in the browser but not in &lt;code&gt;dig&lt;/code&gt;, or in &lt;code&gt;dig&lt;/code&gt; but not in &lt;code&gt;ping&lt;/code&gt;, the two layers disagree, and Section 5 is about exactly that.&lt;/p&gt;

&lt;p&gt;Everything below was run as root on Ubuntu 26.04.1 with systemd 259. The box is a Hetzner server, which matters only because it ends up with four DNS servers from two different sources, and that turns out to be one too many for one of the files.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Start where the applications start
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="s1"&gt;'^hosts:'&lt;/span&gt; /etc/nsswitch.conf
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;hosts:          files dns
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the entire first layer on a 26.04 server. &lt;code&gt;files&lt;/code&gt; is &lt;code&gt;/etc/hosts&lt;/code&gt;. &lt;code&gt;dns&lt;/code&gt; is the resolver library, which reads &lt;code&gt;/etc/resolv.conf&lt;/code&gt;. Desktop installs usually carry an extra &lt;code&gt;mdns4_minimal&lt;/code&gt; entry in there for &lt;code&gt;.local&lt;/code&gt; names. The server image does not.&lt;/p&gt;

&lt;p&gt;The tool for asking this layer a question directly, without a network round trip you did not ask for, is &lt;code&gt;getent&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;getent hosts example.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;2606:4700:10::6814:179a example.com
2606:4700:10::ac42:93f3 example.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;getent hosts&lt;/code&gt; does exactly what &lt;code&gt;ping&lt;/code&gt; does to turn a name into an address, and nothing else. When a program cannot resolve a name, this is the first command to run, because it answers "is it the program or is it the box".&lt;/p&gt;

&lt;h2&gt;
  
  
  2. resolv.conf is a symlink, and there are two of them
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;ls&lt;/span&gt; &lt;span class="nt"&gt;-la&lt;/span&gt; /etc/resolv.conf
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; &lt;span class="s1"&gt;'^#'&lt;/span&gt; /etc/resolv.conf
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;lrwxrwxrwx 1 root root 39 Apr 20 18:07 /etc/resolv.conf -&amp;gt; ../run/systemd/resolve/stub-resolv.conf
nameserver 127.0.0.53
options edns0 trust-ad
search .
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So &lt;code&gt;/etc/resolv.conf&lt;/code&gt; is not a file you own. It points at a file systemd-resolved regenerates, and that file names one server: the stub on &lt;code&gt;127.0.0.53&lt;/code&gt;. A stub resolver is one that owns no DNS data of its own. It takes questions from local programs and forwards them to servers that do. The &lt;code&gt;options&lt;/code&gt; line turns on EDNS0, the extension that lets answers be bigger than the original 512 byte limit, and &lt;code&gt;trust-ad&lt;/code&gt;, which lets the library trust a DNSSEC flag the stub sets. Both can stay unread until Section 11. The comment block at the top of it, which I cut here, says all of this in so many words, and ends with the line that matters: run &lt;code&gt;resolvectl status&lt;/code&gt; to see the real upstream servers.&lt;/p&gt;

&lt;p&gt;There is a second generated file next to it, and knowing it exists solves a whole category of problems later:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; &lt;span class="s1"&gt;'^#'&lt;/span&gt; /run/systemd/resolve/resolv.conf
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;nameserver 2a01:4ff:ff00::add:2
nameserver 2a01:4ff:ff00::add:1
nameserver 185.12.64.1
# Too many DNS servers configured, the following entries may be ignored.
nameserver 185.12.64.2
search .
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same format, but listing the actual upstream servers instead of the stub. This is the file you point at when a program cannot use &lt;code&gt;127.0.0.53&lt;/code&gt;, and Docker is the usual example, in Section 10. That warning comment is real and worth knowing about: the classic C library resolver only reads the first three &lt;code&gt;nameserver&lt;/code&gt; lines, so systemd-resolved writes the fourth one with a note that it will probably be ignored. The stub has no such limit, which is one small reason to leave the symlink pointing where it is.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Who is 127.0.0.53
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ss &lt;span class="nt"&gt;-ulnp&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="s1"&gt;':53 '&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;UNCONN 0 0    127.0.0.54:53     0.0.0.0:*  users:(("systemd-resolve",pid=485,fd=18))
UNCONN 0 0 127.0.0.53%lo:53     0.0.0.0:*  users:(("systemd-resolve",pid=485,fd=16))
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;systemd-resolved, bound to loopback only, so nothing outside the box can ask it anything. It also listens on &lt;code&gt;127.0.0.54&lt;/code&gt;, which the documentation describes as a second stub with different forwarding rules. Nothing in &lt;code&gt;resolv.conf&lt;/code&gt; points at it and I did not test it. &lt;code&gt;.53&lt;/code&gt; is the one this post is about.&lt;/p&gt;

&lt;p&gt;Now the command the comment told you to run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;resolvectl status
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Global
         Protocols: -LLMNR -mDNS -DNSOverTLS DNSSEC=no/unsupported
  resolv.conf mode: stub

Link 2 (eth0)
    Current Scopes: DNS
         Protocols: +DefaultRoute -LLMNR -mDNS -DNSOverTLS DNSSEC=no/unsupported
Current DNS Server: 2a01:4ff:ff00::add:2
       DNS Servers: 2a01:4ff:ff00::add:2 2a01:4ff:ff00::add:1 185.12.64.1
                    185.12.64.2
     Default Route: yes
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read it top to bottom. &lt;code&gt;Global&lt;/code&gt; is settings that apply everywhere, and on a fresh box there is nothing in it except a list of protocols that are all switched off, which is the correct default for a server. Then one block per network interface. &lt;code&gt;eth0&lt;/code&gt; knows four servers, is currently using the first, and has &lt;code&gt;Default Route: yes&lt;/code&gt;, meaning any name that no other interface claims goes here. Section 7 is about interfaces that claim names.&lt;/p&gt;

&lt;p&gt;Where did those four servers come from? Not from a file you edited. The two IPv4 addresses arrived with the DHCPv4 lease, and the cloud image's Netplan file supplies the IPv6 pair statically:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-A3&lt;/span&gt; nameservers /etc/netplan/50-cloud-init.yaml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;      &lt;span class="na"&gt;nameservers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;addresses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;2a01:4ff:ff00::add:2&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;2a01:4ff:ff00::add:1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the shape to remember. Per interface, DNS servers come in from whatever configured the interface, DHCP or Netplan, systemd-networkd hands them to systemd-resolved, and systemd-resolved writes the two &lt;code&gt;resolv.conf&lt;/code&gt; files to match. You never edit the files. You edit what feeds them.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Watch a lookup happen
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;resolvectl query&lt;/code&gt; asks the stub the same way an application would, and then tells you what it did:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;resolvectl flush-caches
resolvectl query example.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;example.com: 2606:4700:10::ac42:93f3                        -- link: eth0
             2606:4700:10::6814:179a                        -- link: eth0
             104.20.23.154                                  -- link: eth0
             172.66.147.243                                 -- link: eth0

-- Information acquired via protocol DNS in 5.9ms.
-- Data is authenticated: no; Data was acquired via local or encrypted transport: no
-- Data from: network
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Which interface it went out of, how long it took, and where the answer came from. Ask again:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;-- Information acquired via protocol DNS in 1.0ms.
-- Data from: cache
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That cache is the main practical thing the stub gives you over a bare &lt;code&gt;resolv.conf&lt;/code&gt; pointing at a remote server. Every repeated lookup on the box, and a busy box does thousands, is answered locally. You can see what is in it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;resolvectl show-cache
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Scope protocol=dns ifindex=2 ifname=eth0 DNSSEC=no DNSOverTLS=no
example.com IN A 172.66.147.243
example.com IN A 104.20.23.154
example.com IN AAAA 2606:4700:10::6814:179a
example.com IN AAAA 2606:4700:10::ac42:93f3
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and how it is doing overall with &lt;code&gt;resolvectl statistics&lt;/code&gt;, which counts transactions, cache hits and misses, and timeouts. Mine showed 10 hits to 30 misses a few minutes into a fresh boot, which is what you expect before the cache warms up.&lt;/p&gt;

&lt;p&gt;The tool I wish I had known about earlier is &lt;code&gt;resolvectl monitor&lt;/code&gt;. It prints every question the stub receives and every answer it gives, live. Leave it running in one terminal and do anything in another:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;→ Q: peculiarengineer.com IN AAAA
← S: success
← A: peculiarengineer.com IN AAAA 2606:4700:3031::6815:467a
← A: peculiarengineer.com IN AAAA 2606:4700:3036::ac43:df66
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When you are not sure whether a program is even asking DNS, or what name it is asking for, this answers it in one line.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;dig&lt;/code&gt; is the same lookup seen from the other side. It reads &lt;code&gt;resolv.conf&lt;/code&gt;, so by default it asks the stub too, and it shows you the raw answer including the TTL, the lifetime in seconds the upstream stamped on the answer, counting down as the cache ages:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dig example.com +noall +answer
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;example.com.        155 IN  A   104.20.23.154
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;dig @1.1.1.1 example.com&lt;/code&gt; skips the stub and asks a server of your choosing, which is how you find out whether a wrong answer is coming from your box or from upstream. &lt;code&gt;dig&lt;/code&gt; is preinstalled on 26.04 server, which it was not always.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Why dig and ping disagree
&lt;/h2&gt;

&lt;p&gt;Put a name in &lt;code&gt;/etc/hosts&lt;/code&gt; and ask each layer about it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"10.99.99.9 mybox"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; /etc/hosts
getent hosts mybox
dig mybox +noall +answer
resolvectl query mybox
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;10.99.99.9      mybox
mybox.          0   IN  A   10.99.99.9
mybox: 10.99.99.9
-- Data from: synthetic
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I expected &lt;code&gt;dig&lt;/code&gt; to fail here, because &lt;code&gt;dig&lt;/code&gt; skips &lt;code&gt;/etc/hosts&lt;/code&gt;. It did not, because on Ubuntu the stub it asks is systemd-resolved, and systemd-resolved reads &lt;code&gt;/etc/hosts&lt;/code&gt; itself and serves those entries with a TTL of zero. &lt;code&gt;resolvectl&lt;/code&gt; is honest about it: &lt;code&gt;Data from: synthetic&lt;/code&gt;, meaning the resolver made the answer up from local knowledge and never sent a packet. So on 26.04 the two layers agree about &lt;code&gt;/etc/hosts&lt;/code&gt;. The disagreement is elsewhere.&lt;/p&gt;

&lt;p&gt;Ask for a &lt;code&gt;.local&lt;/code&gt; name:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;resolvectl query printer.local
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;printer.local: resolve call failed: No appropriate name servers or networks for name found
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;.local&lt;/code&gt; is reserved for multicast DNS, and mDNS is off on a server (&lt;code&gt;-mDNS&lt;/code&gt; in the status output). systemd-resolved refuses to send a &lt;code&gt;.local&lt;/code&gt; name to a real DNS server, by design, so it fails instantly with that message. A desktop with &lt;code&gt;mdns4_minimal&lt;/code&gt; in &lt;code&gt;nsswitch.conf&lt;/code&gt; would have answered from the first layer without DNS being involved, which is why the printer resolves on your laptop and not on the server.&lt;/p&gt;

&lt;p&gt;And the box's own name:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;getent hosts &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;hostname&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;127.0.1.1       dns-post dns-post
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is an &lt;code&gt;/etc/hosts&lt;/code&gt; line the installer wrote, pointing the hostname at &lt;code&gt;127.0.1.1&lt;/code&gt;, the second loopback address. It is why &lt;code&gt;ping $(hostname)&lt;/code&gt; works on a box with no DNS at all, and why a service that binds to "the hostname" sometimes ends up listening only on loopback.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Changing the upstream, and what survives a reboot
&lt;/h2&gt;

&lt;p&gt;There are two ways, and the difference is what happens tomorrow.&lt;/p&gt;

&lt;p&gt;The quick way is &lt;code&gt;resolvectl&lt;/code&gt;, and it takes effect immediately:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;resolvectl dns eth0 1.1.1.1 9.9.9.9
resolvectl dns eth0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Link 2 (eth0): 1.1.1.1 9.9.9.9
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I wanted to know exactly how temporary that is, so I tried to knock it out. &lt;code&gt;netplan apply&lt;/code&gt; did not touch it. &lt;code&gt;networkctl reconfigure eth0&lt;/code&gt; did not touch it either, which surprised me. A reboot did: the box came back with Hetzner's four servers and no memory of mine. So a &lt;code&gt;resolvectl dns&lt;/code&gt; change lasts until the next boot, and no less, which makes it fine for "try this server for an hour" and wrong for anything else.&lt;/p&gt;

&lt;p&gt;The permanent way is Netplan. Add a file, and let it feed the chain from the top:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# /etc/netplan/60-dns.yaml&lt;/span&gt;
&lt;span class="na"&gt;network&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt;
  &lt;span class="na"&gt;ethernets&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;eth0&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;nameservers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;addresses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;1.1.1.1&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;9.9.9.9&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;chmod &lt;/span&gt;600 /etc/netplan/60-dns.yaml
netplan apply
resolvectl status eth0 | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="s1"&gt;'DNS Server'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Current DNS Server: 1.1.1.1
       DNS Servers: 1.1.1.1 9.9.9.9
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And now look at the two files from Section 2. &lt;code&gt;/etc/resolv.conf&lt;/code&gt; still says &lt;code&gt;nameserver 127.0.0.53&lt;/code&gt;, unchanged, because the stub did not move. &lt;code&gt;/run/systemd/resolve/resolv.conf&lt;/code&gt; now lists &lt;code&gt;1.1.1.1&lt;/code&gt; and &lt;code&gt;9.9.9.9&lt;/code&gt;, because the upstreams did. That is the whole model working as intended: you changed the input, both outputs followed, and no program on the box noticed anything.&lt;/p&gt;

&lt;p&gt;Netplan merges every file in &lt;code&gt;/etc/netplan/&lt;/code&gt; by filename order, so a &lt;code&gt;60-&lt;/code&gt; file layers over the cloud image's &lt;code&gt;50-cloud-init.yaml&lt;/code&gt; without you editing a file that cloud-init might regenerate. Note what the merge did to the list, though. eth0 went from four servers to exactly two, so the &lt;code&gt;nameservers&lt;/code&gt; list is replaced, not appended, and the IPv6 pair from the cloud image is gone. If you want them, put them in your file too. The Netplan post goes into that ordering.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. Split DNS: a different server for one domain
&lt;/h2&gt;

&lt;p&gt;This is the part that made the design click for me, because it is the thing a single &lt;code&gt;resolv.conf&lt;/code&gt; fundamentally cannot do.&lt;/p&gt;

&lt;p&gt;The situation: your normal DNS is fine for the internet, but names under &lt;code&gt;corp.example&lt;/code&gt; only exist on a private server. In the old world you either pointed the whole box at the private server and hoped it forwarded everything else, or you gave up. With per interface servers and routing domains, you attach the private server to an interface and tell systemd-resolved which names belong to it. A routing domain is written with a leading &lt;code&gt;~&lt;/code&gt;: &lt;code&gt;~corp.example&lt;/code&gt; means "send questions about this domain to this interface's servers", and &lt;code&gt;~.&lt;/code&gt; means "send everything else here", which is what &lt;code&gt;Default Route: yes&lt;/code&gt; in Section 3 was showing you.&lt;/p&gt;

&lt;p&gt;To try it without a corporate network I ran a tiny DNS server on the box that invents answers for one domain. dnsmasq will do that in four lines of config. Installing it produced the first lesson for free:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;dnsmasq: failed to create listening socket for port 53: Address already in use
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The stub already has port 53 on loopback, and dnsmasq's default is to bind everything. Pin it to a different loopback address and give it the fake zone:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="c"&gt;# /etc/dnsmasq.d/corp.conf
&lt;/span&gt;&lt;span class="py"&gt;listen-address&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;127.0.0.2&lt;/span&gt;
&lt;span class="err"&gt;bind-interfaces&lt;/span&gt;
&lt;span class="py"&gt;port&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;53&lt;/span&gt;
&lt;span class="err"&gt;no-resolv&lt;/span&gt;
&lt;span class="py"&gt;address&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;/corp.example/10.7.7.7&lt;/span&gt;
&lt;span class="py"&gt;address&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;/git.corp.example/10.7.7.10&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then an interface to hang it on. A dummy interface is fine for the demonstration, a VPN interface is what you would have in real life:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ip &lt;span class="nb"&gt;link &lt;/span&gt;add corp0 &lt;span class="nb"&gt;type &lt;/span&gt;dummy &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; ip &lt;span class="nb"&gt;link set &lt;/span&gt;corp0 up
ip addr add 10.7.7.1/24 dev corp0
resolvectl dns corp0 127.0.0.2
resolvectl domain corp0 &lt;span class="s1"&gt;'~corp.example'&lt;/span&gt;
resolvectl status corp0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Link 3 (corp0)
    Current Scopes: DNS
         Protocols: -DefaultRoute -LLMNR -mDNS -DNSOverTLS DNSSEC=no/unsupported
       DNS Servers: 127.0.0.2
        DNS Domain: ~corp.example
     Default Route: no
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;Default Route: no&lt;/code&gt;. This interface's server only ever sees names under &lt;code&gt;corp.example&lt;/code&gt;. Now query one of each:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;resolvectl query git.corp.example
resolvectl query example.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;git.corp.example: 10.7.7.10
-- Information acquired via protocol DNS in 3.1ms.

example.com: 104.20.23.154 ...                              -- link: eth0
-- Information acquired via protocol DNS in 18.1ms.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The private name went to &lt;code&gt;127.0.0.2&lt;/code&gt; on &lt;code&gt;corp0&lt;/code&gt;. The public name went out &lt;code&gt;eth0&lt;/code&gt; as before. Neither server saw the other's traffic. And &lt;code&gt;/etc/resolv.conf&lt;/code&gt; still says &lt;code&gt;nameserver 127.0.0.53&lt;/code&gt; and nothing else, so every program on the box gets this behaviour without knowing it exists. &lt;code&gt;dig git.corp.example&lt;/code&gt; returns &lt;code&gt;10.7.7.10&lt;/code&gt; too, through the stub.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;resolvectl domain
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Link 2 (eth0): ~.
Link 3 (corp0): ~corp.example
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That two line table is the routing policy for every name on the machine. If you have ever run &lt;code&gt;resolvectl status&lt;/code&gt; on a box with Tailscale and wondered what the &lt;code&gt;tailscale0&lt;/code&gt; block with its own DNS server and a &lt;code&gt;~&lt;/code&gt; domain was for, this is it. MagicDNS is exactly this shape: Tailscale attaches its resolver to its interface, claims the tailnet's domain, and leaves &lt;code&gt;~.&lt;/code&gt; alone.&lt;/p&gt;

&lt;h2&gt;
  
  
  8. Search domains and single labels
&lt;/h2&gt;

&lt;p&gt;A search domain is what turns &lt;code&gt;git&lt;/code&gt; into &lt;code&gt;git.corp.example&lt;/code&gt; so you can type the short name. It is per interface, like everything else here, and it is wired into both layers. Using the &lt;code&gt;corp0&lt;/code&gt; interface from Section 7, with the domain written without the &lt;code&gt;~&lt;/code&gt; this time:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;resolvectl domain corp0 corp.example
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="s1"&gt;'^search'&lt;/span&gt; /etc/resolv.conf
getent hosts git
resolvectl query git
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;search corp.example
10.7.7.10       git.corp.example
git: 10.7.7.10
     (git.corp.example)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;systemd-resolved wrote the domain into the &lt;code&gt;search&lt;/code&gt; line of &lt;code&gt;resolv.conf&lt;/code&gt;, so the C library appends it, and it also applies it itself when asked directly, which is why &lt;code&gt;resolvectl query git&lt;/code&gt; shows the name it actually looked up in brackets. &lt;code&gt;dig&lt;/code&gt; needs to be told: &lt;code&gt;dig +search git&lt;/code&gt; works, plain &lt;code&gt;dig git&lt;/code&gt; gets &lt;code&gt;status: REFUSED&lt;/code&gt; from the stub.&lt;/p&gt;

&lt;p&gt;That refusal is deliberate and it will bite you once. systemd-resolved does not send single label names, a bare &lt;code&gt;git&lt;/code&gt; or &lt;code&gt;mybox&lt;/code&gt; with no dots, to a DNS server at all unless a search domain completes them. Without one you get:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;mybox: resolve call failed: No appropriate name servers or networks for name found
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The setting behind it is &lt;code&gt;ResolveUnicastSingleLabel=no&lt;/code&gt; in &lt;code&gt;resolved.conf&lt;/code&gt;, and the default is right: a single label leaking to the internet is how a typo becomes a query to somebody else's server.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;~&lt;/code&gt; form from Section 7 is the other kind. &lt;code&gt;~corp.example&lt;/code&gt; routes queries for the domain to this interface's servers and nothing more: it does not complete short names. Drop the &lt;code&gt;~&lt;/code&gt; and you get both routing and completion, which is what this section just used.&lt;/p&gt;

&lt;h2&gt;
  
  
  9. DNS over TLS, and the setting that takes the box offline
&lt;/h2&gt;

&lt;p&gt;Everything so far went over plain UDP. systemd-resolved can encrypt the leg to the upstream, and the configuration lives in a drop-in, a small file under &lt;code&gt;resolved.conf.d&lt;/code&gt; that overrides the main &lt;code&gt;resolved.conf&lt;/code&gt; without editing it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="c"&gt;# /etc/systemd/resolved.conf.d/dot.conf
&lt;/span&gt;&lt;span class="nn"&gt;[Resolve]&lt;/span&gt;
&lt;span class="py"&gt;DNS&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;1.1.1.1#cloudflare-dns.com 1.0.0.1#cloudflare-dns.com&lt;/span&gt;
&lt;span class="py"&gt;DNSOverTLS&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;yes&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;systemctl restart systemd-resolved
resolvectl query example.com | &lt;span class="nb"&gt;tail&lt;/span&gt; &lt;span class="nt"&gt;-2&lt;/span&gt;
ss &lt;span class="nt"&gt;-tnp&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="s1"&gt;':853'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;-- Data is authenticated: no; Data was acquired via local or encrypted transport: yes
ESTAB 0 0 178.105.172.189:53968 1.1.1.1:853 users:(("systemd-resolve",pid=2934,fd=24))
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An open TLS connection to port 853, and &lt;code&gt;resolvectl&lt;/code&gt; reports the encrypted transport. The &lt;code&gt;#hostname&lt;/code&gt; after each address is the certificate name to verify, and you want it there.&lt;/p&gt;

&lt;p&gt;Here is the trap, and I walked into it on purpose. &lt;code&gt;DNSOverTLS=yes&lt;/code&gt; is strict mode, and it applies to every server on every interface, including the ones DHCP handed you. I removed the &lt;code&gt;DNS=&lt;/code&gt; line so the only servers left were Hetzner's, which do not speak TLS, and asked for a name:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;(nothing. the twenty second timeout I had wrapped it in gave up, exit 124)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;DNS on the box was simply gone. &lt;code&gt;resolvectl status eth0&lt;/code&gt; showed &lt;code&gt;+DNSOverTLS&lt;/code&gt; against servers that would never answer on 853. So strict mode is only correct when you have also set the upstreams to servers you know support it, in the same drop-in. &lt;code&gt;DNSOverTLS=opportunistic&lt;/code&gt; is the softer setting, it tries TLS and falls back to plain, and against the same Hetzner servers it worked immediately, unencrypted, with no connection to 853 at all. Opportunistic is honest about what it gives you, which is not much. Either set strict with known servers, or leave it off.&lt;/p&gt;

&lt;h2&gt;
  
  
  10. Three ways it gets broken
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Someone replaces the symlink.&lt;/strong&gt; The most common one, usually from a guide written for a distribution without systemd-resolved, or a VPN client from years ago. I did it the way they do it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cp&lt;/span&gt; &lt;span class="nt"&gt;--remove-destination&lt;/span&gt; /run/systemd/resolve/resolv.conf /etc/resolv.conf
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now &lt;code&gt;/etc/resolv.conf&lt;/code&gt; is a regular file listing the upstreams directly. Everything still resolves, so nobody notices. What you lost: &lt;code&gt;resolvectl statistics&lt;/code&gt; shows a cache size of zero and stays there, &lt;code&gt;resolvectl monitor&lt;/code&gt; sees nothing when programs look names up, split DNS and search domains stop applying to anything except &lt;code&gt;resolvectl&lt;/code&gt; itself, and the next DHCP change never reaches the file. Put it back:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;ln&lt;/span&gt; &lt;span class="nt"&gt;-sf&lt;/span&gt; ../run/systemd/resolve/stub-resolv.conf /etc/resolv.conf
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Docker containers.&lt;/strong&gt; A container cannot use &lt;code&gt;127.0.0.53&lt;/code&gt;, because inside the container loopback is the container's own. So Docker does not copy &lt;code&gt;/etc/resolv.conf&lt;/code&gt;; it copies the other file, the one with the real upstreams:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker run &lt;span class="nt"&gt;--rm&lt;/span&gt; alpine:3.20 &lt;span class="nb"&gt;cat&lt;/span&gt; /etc/resolv.conf
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;nameserver 1.1.1.1
nameserver 9.9.9.9
search .
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Those are the servers from Section 6, taken from &lt;code&gt;/run/systemd/resolve/resolv.conf&lt;/code&gt;, with no cache and no split DNS. On a user defined network Docker instead gives the container &lt;code&gt;nameserver 127.0.0.11&lt;/code&gt;, its own embedded resolver, which forwards to the same upstreams and adds container name resolution on top. Either way, a private name that resolves on the host through a routing domain will not resolve inside a container unless you pass &lt;code&gt;--dns&lt;/code&gt; or configure the daemon.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Something else wants port 53.&lt;/strong&gt; dnsmasq did it in Section 7, Pi-hole does it, so does any local DNS server installed with defaults. The failure is &lt;code&gt;Address already in use&lt;/code&gt; and the service does not start. Either bind the new server somewhere other than &lt;code&gt;127.0.0.53&lt;/code&gt;, as I did, or if the new server is meant to replace the stub entirely, stop and disable systemd-resolved and manage &lt;code&gt;resolv.conf&lt;/code&gt; yourself. Do one or the other. Running both on the same address is not a state the box can be in.&lt;/p&gt;

&lt;h2&gt;
  
  
  11. Debugging it
&lt;/h2&gt;

&lt;p&gt;The commands, in the order I now reach for them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;getent hosts NAME
resolvectl query NAME
resolvectl status
resolvectl monitor
dig @UPSTREAM NAME
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;getent&lt;/code&gt; for what a program sees, &lt;code&gt;query&lt;/code&gt; for which link and source answered, &lt;code&gt;status&lt;/code&gt; for who owns the default route, &lt;code&gt;monitor&lt;/code&gt; when you are not sure a program is asking at all, and &lt;code&gt;dig&lt;/code&gt; at an upstream to take the box out of the picture.&lt;/p&gt;

&lt;p&gt;When those do not explain it, turn the resolver's own logging up for a minute:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;resolvectl log-level debug
resolvectl query example.com &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null
journalctl &lt;span class="nt"&gt;-u&lt;/span&gt; systemd-resolved &lt;span class="nt"&gt;--since&lt;/span&gt; &lt;span class="nt"&gt;-1min&lt;/span&gt; | &lt;span class="nb"&gt;grep &lt;/span&gt;example.com
resolvectl log-level info
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Cache miss for example.com IN AAAA
Firing regular transaction 19300 for &amp;lt;example.com IN AAAA&amp;gt; scope dns on eth0/* (validate=yes).
Cache miss for example.com IN A
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That tells you the exact scope and interface a query was sent on, which is the question split DNS problems come down to. And &lt;code&gt;resolvectl show-server-state&lt;/code&gt; shows what the resolver has learned about each upstream, such as whether it supports EDNS0 and how many UDP and TCP attempts have failed against it. A server with &lt;code&gt;Failed UDP attempts&lt;/code&gt; climbing is a server on the way out, and &lt;code&gt;resolvectl reset-server-features&lt;/code&gt; makes systemd-resolved forget and re-probe.&lt;/p&gt;

&lt;p&gt;A name that does not exist looks like this, and exits 1, which is useful in scripts:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;does-not-exist.peculiarengineer.com: Name 'does-not-exist.peculiarengineer.com' not found
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Gotchas worth knowing
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;/etc/resolv.conf&lt;/code&gt; is output, not input.&lt;/strong&gt; Edit what feeds it: Netplan for permanent, &lt;code&gt;resolvectl dns&lt;/code&gt; for the rest of today. If it is a regular file instead of a symlink, somebody broke it, and &lt;code&gt;ln -sf ../run/systemd/resolve/stub-resolv.conf /etc/resolv.conf&lt;/code&gt; fixes it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A &lt;code&gt;resolvectl dns&lt;/code&gt; change survives &lt;code&gt;netplan apply&lt;/code&gt; and &lt;code&gt;networkctl reconfigure&lt;/code&gt; and dies at reboot.&lt;/strong&gt; Longer lived than I assumed and shorter than you need.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Four DHCP servers is one too many for the classic resolver.&lt;/strong&gt; Only the stub reads all four. Anything reading &lt;code&gt;/run/systemd/resolve/resolv.conf&lt;/code&gt; directly, Docker included, ignores the fourth, and the file says so in a comment.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Single label names never leave the box.&lt;/strong&gt; No search domain, no query. The error is &lt;code&gt;No appropriate name servers or networks for name found&lt;/code&gt;, and it is not a network problem.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;.local&lt;/code&gt; fails on a server on purpose.&lt;/strong&gt; mDNS is off and systemd-resolved will not send &lt;code&gt;.local&lt;/code&gt; to unicast DNS. Turn on mDNS or use a real name.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;DNSOverTLS=yes&lt;/code&gt; with DHCP servers is an outage.&lt;/strong&gt; Strict mode applies to every server on every link. Set &lt;code&gt;DNS=&lt;/code&gt; to known TLS servers in the same drop-in, or use &lt;code&gt;opportunistic&lt;/code&gt; and accept that it will mostly be plaintext.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;dig&lt;/code&gt; sees &lt;code&gt;/etc/hosts&lt;/code&gt; on Ubuntu.&lt;/strong&gt; Because the stub it talks to reads the file. &lt;code&gt;Data from: synthetic&lt;/code&gt; in &lt;code&gt;resolvectl query&lt;/code&gt; is how you tell.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Docker gets the upstreams, not the stub.&lt;/strong&gt; No cache, no split DNS, no search domains inside containers unless you configure them. Private names that work on the host will not work in a container by default.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Port 53 is taken.&lt;/strong&gt; By the stub, on &lt;code&gt;127.0.0.53&lt;/code&gt; and &lt;code&gt;127.0.0.54&lt;/code&gt;. A local DNS server has to bind elsewhere or replace systemd-resolved outright.&lt;/p&gt;

&lt;h2&gt;
  
  
  Quick reference
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Task&lt;/th&gt;
&lt;th&gt;Command&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Resolve a name the way programs do&lt;/td&gt;
&lt;td&gt;&lt;code&gt;getent hosts NAME&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Resolve with details: link, cache, timing&lt;/td&gt;
&lt;td&gt;&lt;code&gt;resolvectl query NAME&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Servers and routing per interface&lt;/td&gt;
&lt;td&gt;&lt;code&gt;resolvectl status&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Just the servers&lt;/td&gt;
&lt;td&gt;&lt;code&gt;resolvectl dns&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Just the domains&lt;/td&gt;
&lt;td&gt;&lt;code&gt;resolvectl domain&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Watch every query live&lt;/td&gt;
&lt;td&gt;&lt;code&gt;resolvectl monitor&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;What is cached&lt;/td&gt;
&lt;td&gt;&lt;code&gt;resolvectl show-cache&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hit and miss counts&lt;/td&gt;
&lt;td&gt;&lt;code&gt;resolvectl statistics&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Empty the cache&lt;/td&gt;
&lt;td&gt;&lt;code&gt;resolvectl flush-caches&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Change servers until reboot&lt;/td&gt;
&lt;td&gt;&lt;code&gt;resolvectl dns eth0 1.1.1.1 9.9.9.9&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Change servers permanently&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;nameservers:&lt;/code&gt; in a &lt;code&gt;/etc/netplan/*.yaml&lt;/code&gt;, then &lt;code&gt;netplan apply&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Add a search domain until reboot&lt;/td&gt;
&lt;td&gt;&lt;code&gt;resolvectl domain eth0 corp.example&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Route one domain to one link&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;resolvectl domain IFACE '~corp.example'&lt;/code&gt; plus &lt;code&gt;resolvectl dns IFACE SERVER&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ask an upstream directly&lt;/td&gt;
&lt;td&gt;&lt;code&gt;dig @1.1.1.1 NAME&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Per upstream health&lt;/td&gt;
&lt;td&gt;&lt;code&gt;resolvectl show-server-state&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Verbose resolver log&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;resolvectl log-level debug&lt;/code&gt;, then &lt;code&gt;journalctl -u systemd-resolved -f&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Restore the symlink&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ln -sf ../run/systemd/resolve/stub-resolv.conf /etc/resolv.conf&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The upstream list for things that cannot use the stub&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/run/systemd/resolve/resolv.conf&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The next time &lt;code&gt;/etc/resolv.conf&lt;/code&gt; says &lt;code&gt;127.0.0.53&lt;/code&gt;, that is not a mystery, it is a table of contents. &lt;code&gt;resolvectl status&lt;/code&gt; is the book. &lt;code&gt;[ 127.0.0.53 ✓ ]&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://peculiarengineer.com/blog/how-dns-works-ubuntu-26-04-systemd-resolved/" rel="noopener noreferrer"&gt;peculiarengineer.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>linux</category>
      <category>ubuntu</category>
      <category>networking</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Systemd units and timers on Ubuntu 26.04, helpful guide</title>
      <dc:creator>Minor Keith</dc:creator>
      <pubDate>Fri, 04 Sep 2026 01:49:06 +0000</pubDate>
      <link>https://dev.to/peculiarengineer/systemd-units-and-timers-on-ubuntu-2604-helpful-guide-211i</link>
      <guid>https://dev.to/peculiarengineer/systemd-units-and-timers-on-ubuntu-2604-helpful-guide-211i</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F81v1leg0fbpr7rm86lcd.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F81v1leg0fbpr7rm86lcd.png" alt=" " width="800" height="336"&gt;&lt;/a&gt;I have now written "run it from a systemd timer" in three posts, pointed at a timer each time, and never once explained what one is. The &lt;a href="https://peculiarengineer.com/blog/backup-homelab-restic-object-storage/" rel="noopener noreferrer"&gt;restic post&lt;/a&gt; has a timer in it that I asked you to copy on faith. This is the post those should have linked to.&lt;/p&gt;

&lt;p&gt;It is written for someone who has never opened a unit file. If you have used cron, you already know the job: run this script at that time. What is different is everything around the job. Where the output goes, what happens when the box was off at three in the morning, what happens when the script fails, and why it fires a few seconds late. I built one job on a fresh Ubuntu 26.04 server and then spent an evening measuring those things instead of assuming them.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR.&lt;/strong&gt; Two files in &lt;code&gt;/etc/systemd/system/&lt;/code&gt;. &lt;code&gt;job.service&lt;/code&gt; says what to run (&lt;code&gt;Type=oneshot&lt;/code&gt;, &lt;code&gt;ExecStart=/absolute/path&lt;/code&gt;). &lt;code&gt;job.timer&lt;/code&gt; says when (&lt;code&gt;OnCalendar=&lt;/code&gt;), and it starts the service with the same name. &lt;code&gt;systemctl daemon-reload&lt;/code&gt; after every edit, then &lt;code&gt;systemctl enable --now job.timer&lt;/code&gt;, the timer, never the service. Add &lt;code&gt;Persistent=true&lt;/code&gt; so a run missed while the box was off happens at boot, and &lt;code&gt;AccuracySec=1s&lt;/code&gt; if "03:30" has to mean 03:30. Test with &lt;code&gt;systemctl start job.service&lt;/code&gt; instead of waiting. Your script runs with no shell, no &lt;code&gt;HOME&lt;/code&gt;, a five entry &lt;code&gt;PATH&lt;/code&gt;, and every &lt;code&gt;%&lt;/code&gt; and &lt;code&gt;$&lt;/code&gt; has a meaning to systemd before your script ever sees it.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The one idea worth holding onto
&lt;/h2&gt;

&lt;p&gt;systemd manages things called units. A service unit is a thing to run. A timer unit is a clock that starts some other unit when it goes off. That is the whole model, and cron has no equivalent to it: in cron the schedule and the command live on one line, and the moment the command runs, cron forgets about it.&lt;/p&gt;

&lt;p&gt;Here they are two files with the same name and different extensions. &lt;code&gt;backup.service&lt;/code&gt; is what. &lt;code&gt;backup.timer&lt;/code&gt; is when. The timer activates the service by name, and because the service is a real unit in its own right, you can start it by hand, read its last exit code, and see its output, all without the timer being involved. Most of what makes timers better than cron falls out of that split.&lt;/p&gt;

&lt;p&gt;Everything below was run on Ubuntu 26.04.1 with systemd 259, as root unless I say otherwise. The box is on UTC, which matters in Section 5.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is already running on your box
&lt;/h2&gt;

&lt;p&gt;You do not have to write a timer to see one. A fresh 26.04 server ships with eighteen:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;systemctl list-timers &lt;span class="nt"&gt;--all&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;NEXT                                LEFT LAST PASSED UNIT                           ACTIVATES
Thu 2026-09-03 22:50:00 UTC     2min 57s -         - sysstat-collect.timer          sysstat-collect.service
Thu 2026-09-03 23:01:21 UTC        14min -         - systemd-tmpfiles-clean.timer   systemd-tmpfiles-clean.service
Fri 2026-09-04 00:00:00 UTC     1h 12min -         - dpkg-db-backup.timer           dpkg-db-backup.service
Fri 2026-09-04 00:12:59 UTC     1h 25min -         - logrotate.timer                logrotate.service
Fri 2026-09-04 06:41:12 UTC           7h -         - apt-daily-upgrade.timer        apt-daily-upgrade.service
Fri 2026-09-04 10:09:56 UTC          11h -         - apt-daily.timer                apt-daily.service
Mon 2026-09-07 01:02:03 UTC       3 days -         - fstrim.timer                   fstrim.service
-                                      - -         - snapd.snap-repair.timer        snapd.snap-repair.service
...
18 timers listed.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Log rotation, the apt update check, filesystem trimming, the &lt;code&gt;man&lt;/code&gt; database: on Ubuntu none of these are cron jobs any more. Each is a timer starting a service. The &lt;code&gt;-&lt;/code&gt; rows at the bottom are timers that are loaded but will never fire, often because a condition in the unit is false on this machine.&lt;/p&gt;

&lt;p&gt;The fastest way to learn the format is to read one Ubuntu wrote:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;systemctl &lt;span class="nb"&gt;cat &lt;/span&gt;fstrim.timer
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="c"&gt;# /usr/lib/systemd/system/fstrim.timer
&lt;/span&gt;&lt;span class="nn"&gt;[Unit]&lt;/span&gt;
&lt;span class="py"&gt;Description&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;Discard unused filesystem blocks once a week&lt;/span&gt;
&lt;span class="py"&gt;Documentation&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;man:fstrim&lt;/span&gt;
&lt;span class="py"&gt;ConditionVirtualization&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;!container&lt;/span&gt;
&lt;span class="py"&gt;ConditionPathExists&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;!/etc/initrd-release&lt;/span&gt;

&lt;span class="nn"&gt;[Timer]&lt;/span&gt;
&lt;span class="py"&gt;OnCalendar&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;weekly&lt;/span&gt;
&lt;span class="py"&gt;AccuracySec&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;1h&lt;/span&gt;
&lt;span class="py"&gt;Persistent&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;true&lt;/span&gt;
&lt;span class="py"&gt;RandomizedDelaySec&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;100min&lt;/span&gt;

&lt;span class="nn"&gt;[Install]&lt;/span&gt;
&lt;span class="py"&gt;WantedBy&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;timers.target&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every line in that &lt;code&gt;[Timer]&lt;/code&gt; block gets its own section below, because Ubuntu's choices there are good ones and it is worth knowing why. The two &lt;code&gt;Condition&lt;/code&gt; lines are why this timer does nothing inside a container: a condition that is false makes the unit load and then quietly never run. Note the path too. Ubuntu's units live in &lt;code&gt;/usr/lib/systemd/system/&lt;/code&gt;. Yours go in &lt;code&gt;/etc/systemd/system/&lt;/code&gt;, and if both places hold a file with the same name, &lt;code&gt;/etc&lt;/code&gt; wins. Do not edit anything under &lt;code&gt;/usr/lib&lt;/code&gt;; Section 14 shows how to change a vendor timer without touching it.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. The job
&lt;/h2&gt;

&lt;p&gt;Start with something that prints what it can see, because what it can see is the surprising part.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /usr/local/bin/hello-timer.sh &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;'
#!/usr/bin/env bash
set -euo pipefail
echo "hello from &lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;hostname&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="sh"&gt; at &lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; &lt;span class="s1"&gt;'+%F %T'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="sh"&gt;, pid &lt;/span&gt;&lt;span class="nv"&gt;$$&lt;/span&gt;&lt;span class="sh"&gt;, user &lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt; &lt;span class="nt"&gt;-un&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="sh"&gt;"
echo "PATH is: &lt;/span&gt;&lt;span class="nv"&gt;$PATH&lt;/span&gt;&lt;span class="sh"&gt;"
echo "HOME is: &lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;HOME&lt;/span&gt;&lt;span class="k"&gt;:-&lt;/span&gt;&lt;span class="nv"&gt;unset&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"
&lt;/span&gt;&lt;span class="no"&gt;EOF
&lt;/span&gt;&lt;span class="nb"&gt;chmod&lt;/span&gt; +x /usr/local/bin/hello-timer.sh
/usr/local/bin/hello-timer.sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;hello from systemd-timers-post at 2026-09-03 22:47:04, pid 1741, user root
PATH is: /usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/games:/usr/local/games:/snap/bin
HOME is: /root
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Remember those last two lines. They are about to change.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. The service unit
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="c"&gt;# /etc/systemd/system/hello.service
&lt;/span&gt;&lt;span class="nn"&gt;[Unit]&lt;/span&gt;
&lt;span class="py"&gt;Description&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;Say hello (demo job)&lt;/span&gt;

&lt;span class="nn"&gt;[Service]&lt;/span&gt;
&lt;span class="py"&gt;Type&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;oneshot&lt;/span&gt;
&lt;span class="py"&gt;ExecStart&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;/usr/local/bin/hello-timer.sh&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three things to notice. &lt;code&gt;Type=oneshot&lt;/code&gt; tells systemd this is a job that runs and exits, not a daemon that stays up, so "started successfully" means "exited zero" rather than "the process is alive". &lt;code&gt;ExecStart&lt;/code&gt; is the command. And there is no &lt;code&gt;[Install]&lt;/code&gt; section, on purpose: this unit is started by the timer, not at boot, so it has no place to be enabled into. Section 4 shows what happens when you try.&lt;/p&gt;

&lt;p&gt;Tell systemd the file exists, run it once by hand, and look:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;systemctl daemon-reload
systemctl start hello.service
systemctl status hello.service
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;○ hello.service - Say hello (demo job)
     Loaded: loaded (/etc/systemd/system/hello.service; static)
     Active: inactive (dead)

Sep 03 22:47:05 systemd-timers-post systemd[1]: Starting hello.service - Say hello (demo job)...
Sep 03 22:47:05 systemd-timers-post hello-timer.sh[1873]: hello from systemd-timers-post at 2026-09-03 22:47:05, pid 1873, user root
Sep 03 22:47:05 systemd-timers-post hello-timer.sh[1873]: PATH is: /usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/snap/bin
Sep 03 22:47:05 systemd-timers-post hello-timer.sh[1873]: HOME is: unset
Sep 03 22:47:05 systemd-timers-post systemd[1]: hello.service: Deactivated successfully.
Sep 03 22:47:05 systemd-timers-post systemd[1]: Finished hello.service - Say hello (demo job).
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;inactive (dead)&lt;/code&gt; is the correct resting state for a oneshot. It ran, it finished, it is not running now. The script's output went straight into the journal with no redirection on your part, which is the first thing cron never gave you. The same lines are there whenever you want them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;journalctl &lt;span class="nt"&gt;-u&lt;/span&gt; hello.service &lt;span class="nt"&gt;-o&lt;/span&gt; short-iso
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And there are the two changed lines. &lt;code&gt;PATH&lt;/code&gt; has five entries instead of nine, and &lt;code&gt;HOME&lt;/code&gt; is not set at all. Your script did not run in your shell. It ran in systemd's idea of a clean environment, and Section 10 is about how far that goes.&lt;/p&gt;

&lt;p&gt;One thing I expected to be a gotcha and was not. Every guide says &lt;code&gt;ExecStart&lt;/code&gt; needs an absolute path. I tried &lt;code&gt;ExecStart=hello-timer.sh&lt;/code&gt; and it ran, because systemd has looked bare command names up in its own &lt;code&gt;PATH&lt;/code&gt; for years now. Write the absolute path anyway. It costs nothing and it means the unit does not depend on where &lt;code&gt;PATH&lt;/code&gt; points.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. daemon-reload, and why your edit did nothing
&lt;/h2&gt;

&lt;p&gt;systemd reads unit files once and keeps them in memory. Edit the file on disk and nothing happens until you say &lt;code&gt;daemon-reload&lt;/code&gt;. Everyone gets bitten by this exactly once, so here is what it looks like. I changed the &lt;code&gt;Description=&lt;/code&gt; line and then asked:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;systemctl status hello.service
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Warning: The unit file, source configuration file or drop-ins of hello.service changed on disk. Run 'systemctl daemon-reload' to reload units.
○ hello.service - Say hello (demo job)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The warning is loud, but only if you run &lt;code&gt;status&lt;/code&gt;. &lt;code&gt;systemctl start&lt;/code&gt; prints it too and then starts the &lt;strong&gt;old&lt;/strong&gt; definition regardless:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;systemctl start hello.service
systemctl show hello.service &lt;span class="nt"&gt;-p&lt;/span&gt; Description
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Warning: The unit file, source configuration file or drop-ins of hello.service changed on disk. Run 'systemctl daemon-reload' to reload units.
Description=Say hello (demo job)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Still the old text. After &lt;code&gt;systemctl daemon-reload&lt;/code&gt; it reads &lt;code&gt;Description=Say hello (edited on disk)&lt;/code&gt; and the warning goes away. The habit to build is that &lt;code&gt;daemon-reload&lt;/code&gt; is part of saving the file, not a separate step you do when something looks wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. The timer unit
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="c"&gt;# /etc/systemd/system/hello.timer
&lt;/span&gt;&lt;span class="nn"&gt;[Unit]&lt;/span&gt;
&lt;span class="py"&gt;Description&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;Run hello every minute (demo)&lt;/span&gt;

&lt;span class="nn"&gt;[Timer]&lt;/span&gt;
&lt;span class="py"&gt;OnCalendar&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;*-*-* *:*:00&lt;/span&gt;
&lt;span class="py"&gt;Unit&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;hello.service&lt;/span&gt;

&lt;span class="nn"&gt;[Install]&lt;/span&gt;
&lt;span class="py"&gt;WantedBy&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;timers.target&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;OnCalendar&lt;/code&gt; is the schedule, here every minute on the minute, which is a terrible schedule for anything real and a very good one for watching it work. &lt;code&gt;Unit=&lt;/code&gt; names what to start, and you can leave it out when the service has the same name as the timer, which it does here and should in general. The &lt;code&gt;[Install]&lt;/code&gt; section is what makes &lt;code&gt;enable&lt;/code&gt; mean something: &lt;code&gt;timers.target&lt;/code&gt; is the unit that starts all timers at boot, and enabling this timer hooks it in there.&lt;/p&gt;

&lt;p&gt;This is also the moment to see what enabling the service instead would have done, because it is the most common first mistake:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;systemctl &lt;span class="nb"&gt;enable &lt;/span&gt;hello.service
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;The unit files have no installation config (WantedBy=, RequiredBy=, UpheldBy=,
Also=, or Alias= settings in the [Install] section, and DefaultInstance= for
template units). This means they are not meant to be enabled or disabled using systemctl.
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Correct, and that is exactly why the service has no &lt;code&gt;[Install]&lt;/code&gt; section. If it had one with &lt;code&gt;WantedBy=multi-user.target&lt;/code&gt;, enabling it would make the job run once at every boot, which for a backup or a cleanup is a surprise nobody wants. Enable the timer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;systemctl daemon-reload
systemctl &lt;span class="nb"&gt;enable&lt;/span&gt; &lt;span class="nt"&gt;--now&lt;/span&gt; hello.timer
systemctl list-timers hello.timer
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Created symlink '/etc/systemd/system/timers.target.wants/hello.timer' → '/etc/systemd/system/hello.timer'.
NEXT                        LEFT LAST PASSED UNIT        ACTIVATES
Thu 2026-09-03 22:48:00 UTC  51s -         - hello.timer hello.service
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;--now&lt;/code&gt; starts it as well as enabling it, and &lt;code&gt;list-timers&lt;/code&gt; is the command you will run more than any other in this post. &lt;code&gt;systemctl status hello.timer&lt;/code&gt; shows the same in prose, with a &lt;code&gt;Trigger:&lt;/code&gt; line and a &lt;code&gt;Triggers: ● hello.service&lt;/code&gt; line that ties the two files together.&lt;/p&gt;

&lt;p&gt;Wait two minutes and read the service's journal, and the job is running itself:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;2026-09-03T22:48:27+00:00 systemd-timers-post hello-timer.sh[2109]: hello from systemd-timers-post at 2026-09-03 22:48:27, pid 2109, user root
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Look at that timestamp though. The timer was for 22:48:00 and the job ran at 22:48:27. Section 6.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Reading and writing OnCalendar
&lt;/h2&gt;

&lt;p&gt;The format is &lt;code&gt;DayOfWeek Year-Month-Day Hour:Minute:Second&lt;/code&gt;, every part optional, &lt;code&gt;*&lt;/code&gt; for any, &lt;code&gt;..&lt;/code&gt; for ranges, &lt;code&gt;,&lt;/code&gt; for lists, &lt;code&gt;/&lt;/code&gt; for steps. You do not have to remember that, because &lt;code&gt;systemd-analyze calendar&lt;/code&gt; will tell you what any expression means and when it next fires, and it is the tool to reach for before you put anything in a unit file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;systemd-analyze calendar &lt;span class="s2"&gt;"Mon..Fri 09:00"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  Original form: Mon..Fri 09:00
Normalized form: Mon..Fri *-*-* 09:00:00
    Next elapse: Fri 2026-09-04 09:00:00 UTC
       From now: 10h left
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here is a table I built by feeding it the expressions I actually use, so I stop working them out from the man page:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;You write&lt;/th&gt;
&lt;th&gt;systemd reads it as&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;&lt;code&gt;daily&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;*-*-* 00:00:00&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;midnight every day&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;03:30&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;*-*-* 03:30:00&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;03:30 every day&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;hourly&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;*-*-* *:00:00&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;top of every hour&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;*:0/15&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;*-*-* *:00/15:00&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;every 15 minutes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;weekly&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Mon *-*-* 00:00:00&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Monday midnight&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Mon..Fri 09:00&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Mon..Fri *-*-* 09:00:00&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;weekday mornings&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Mon,Wed,Fri 18:00&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Mon,Wed,Fri *-*-* 18:00:00&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;three evenings a week&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;*-*-01 00:00:00&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;*-*-01 00:00:00&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;first of the month&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;quarterly&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;*-01,04,07,10-01 00:00:00&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;first of Jan, Apr, Jul, Oct&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;2026-12-25 08:00&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;2026-12-25 08:00:00&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;once, on that date&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two things worth knowing. &lt;code&gt;--iterations=3&lt;/code&gt; shows the next three firings instead of one, which is how you catch that &lt;code&gt;weekly&lt;/code&gt; means Monday and not "seven days from now". And a real typo is rejected rather than guessed at:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;systemd-analyze calendar &lt;span class="s2"&gt;"every day at 3"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Failed to parse calendar specification 'every day at 3': Invalid argument
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That same rejection is what you get if the typo makes it into a timer unit, except there it is quieter. &lt;code&gt;systemd-analyze verify /etc/systemd/system/bad.timer&lt;/code&gt; prints &lt;code&gt;Failed to parse calendar specification, ignoring&lt;/code&gt; and &lt;code&gt;Timer unit lacks value setting. Refusing&lt;/code&gt;, and &lt;code&gt;systemctl start&lt;/code&gt; fails with &lt;code&gt;bad unit file setting&lt;/code&gt;. Run &lt;code&gt;verify&lt;/code&gt; on anything new before you enable it. One thing it forgives: I typed &lt;code&gt;Mon-Fri 09:00&lt;/code&gt; with a single hyphen, and it silently normalised it to &lt;code&gt;Mon..Fri&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Timezones.&lt;/strong&gt; &lt;code&gt;OnCalendar&lt;/code&gt; is read in the box's local timezone, which on a fresh server is almost always UTC. Check with &lt;code&gt;timedatectl&lt;/code&gt;. If the box is on local time and you want a job in UTC anyway, append it: &lt;code&gt;*-*-* 03:30:00 UTC&lt;/code&gt; is valid and &lt;code&gt;systemd-analyze calendar&lt;/code&gt; shows the &lt;code&gt;UTC&lt;/code&gt; in the normalised form.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Why it fired late
&lt;/h2&gt;

&lt;p&gt;The 22:48:00 job ran at 22:48:27. The next one, scheduled for 22:49:00, ran at 22:49:18. Nothing is wrong. Timers have a setting called &lt;code&gt;AccuracySec&lt;/code&gt;, and its default is one minute:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;systemctl show hello.timer &lt;span class="nt"&gt;-p&lt;/span&gt; AccuracyUSec
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;AccuracyUSec=1min
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Which means: fire at any point in the minute after the scheduled time. systemd does this so it can batch timers together and wake the machine up less often, and for a nightly backup a few seconds of drift is nothing. For anything where the exact minute matters, say a job that has to land before another system polls at :05, set it explicitly. I ran the same minute timer twice, once with the default and once with &lt;code&gt;AccuracySec=1s&lt;/code&gt;, and logged the moment the job started:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;default:           fired at 22:50:03.959   fired at 22:51:08.954
AccuracySec=1s:    fired at 22:52:00.147   fired at 22:53:00.147
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With the default I saw the job land anywhere from four to twenty seven seconds after the minute. With &lt;code&gt;AccuracySec=1s&lt;/code&gt; it landed 150 milliseconds after, both times. Ubuntu's own &lt;code&gt;fstrim.timer&lt;/code&gt; goes the other way and sets &lt;code&gt;AccuracySec=1h&lt;/code&gt;, because nobody cares which hour of Monday the disks get trimmed. Pick the one that matches how much you care.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. The box was off at 03:30
&lt;/h2&gt;

&lt;p&gt;This is the single best reason to use a timer over cron for anything on a machine that is ever switched off or rebooted. Cron skips a missed run and says nothing. A timer with &lt;code&gt;Persistent=true&lt;/code&gt; remembers.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="c"&gt;# /etc/systemd/system/daily.timer
&lt;/span&gt;&lt;span class="nn"&gt;[Timer]&lt;/span&gt;
&lt;span class="py"&gt;OnCalendar&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;daily&lt;/span&gt;
&lt;span class="py"&gt;Persistent&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;true&lt;/span&gt;

&lt;span class="nn"&gt;[Install]&lt;/span&gt;
&lt;span class="py"&gt;WantedBy&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;timers.target&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The mechanism is a stamp file. Every time the timer fires it touches &lt;code&gt;/var/lib/systemd/timers/stamp-daily.timer&lt;/code&gt;, and when the timer is next activated, systemd compares that stamp to the schedule. If a run should have happened in between, it runs the job immediately.&lt;/p&gt;

&lt;p&gt;Two things I wanted to know before trusting it. First, does a brand new persistent timer fire the moment you enable it, on the theory that it has "missed" every run since the beginning of time? No. On first enable it created the stamp and waited for midnight like any other timer. Second, does it really catch up? I made it look as if the last run was two days ago and started the timer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;systemctl stop daily.timer
&lt;span class="nb"&gt;touch&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"2 days ago"&lt;/span&gt; /var/lib/systemd/timers/stamp-daily.timer
systemctl start daily.timer
journalctl &lt;span class="nt"&gt;-u&lt;/span&gt; daily.service &lt;span class="nt"&gt;-o&lt;/span&gt; short-iso &lt;span class="nt"&gt;--since&lt;/span&gt; &lt;span class="s2"&gt;"-1min"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;2026-09-03T23:00:43+00:00 systemd-timers-post sh[4482]: daily job ran at 23:00:43
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Immediately. The same test with the &lt;code&gt;Persistent=true&lt;/code&gt; line removed did nothing, and &lt;code&gt;list-timers&lt;/code&gt; showed it calmly waiting for midnight.&lt;/p&gt;

&lt;p&gt;Then I did it properly. I set the timer for 23:10, powered the server off at 23:07, and turned it back on through the Hetzner API at 23:11:48, so the slot passed with the machine dark:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;uptime -s            2026-09-03 23:12:01
2026-09-03T23:12:11+00:00 systemd-timers-post systemd[1]: Starting daily.service...
2026-09-03T23:12:11+00:00 systemd-timers-post sh[964]: daily job ran at 23:12:11
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Ten seconds after boot, the missed run happened. A cron job at 23:10 would have been silently skipped and you would have found out when you went looking for last night's backup. Every timer Ubuntu ships that does real work, &lt;code&gt;fstrim&lt;/code&gt;, &lt;code&gt;apt-daily&lt;/code&gt;, &lt;code&gt;logrotate&lt;/code&gt;, has &lt;code&gt;Persistent=true&lt;/code&gt;. Yours should too.&lt;/p&gt;

&lt;h2&gt;
  
  
  8. Timers that count from boot, and jitter
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;OnCalendar&lt;/code&gt; is wall clock time. There is a second family that counts durations instead, and they are useful for "a while after boot" and "every so often" without caring what time it is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="nn"&gt;[Timer]&lt;/span&gt;
&lt;span class="py"&gt;OnBootSec&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;15min&lt;/span&gt;
&lt;span class="py"&gt;OnUnitActiveSec&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;1h&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;OnBootSec=15min&lt;/code&gt; fires fifteen minutes after the machine came up. &lt;code&gt;OnUnitActiveSec=1h&lt;/code&gt; fires an hour after the service last ran. Together they mean "first run fifteen minutes after boot, then hourly", which is a better shape for a health check than &lt;code&gt;hourly&lt;/code&gt;, because it does not pile onto whatever else runs at the top of the hour. &lt;code&gt;list-timers&lt;/code&gt; showed mine for 23:01:21 on a box that booted at 22:46:21, which is the arithmetic you would expect.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;RandomizedDelaySec&lt;/code&gt; spreads a run out over a window. Ubuntu's &lt;code&gt;apt-daily.timer&lt;/code&gt; uses &lt;code&gt;RandomizedDelaySec=12h&lt;/code&gt; so that every Ubuntu machine on earth does not hit the mirrors at the same second. I put &lt;code&gt;RandomizedDelaySec=30min&lt;/code&gt; on a 03:30 timer and restarted it three times:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Fri 2026-09-04 03:49:13 UTC
Fri 2026-09-04 03:44:33 UTC
Fri 2026-09-04 03:44:47 UTC
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A fresh point in the window each time. This is why the &lt;a href="https://peculiarengineer.com/blog/backup-homelab-restic-object-storage/" rel="noopener noreferrer"&gt;restic post&lt;/a&gt; has &lt;code&gt;RandomizedDelaySec=30m&lt;/code&gt; on its backup: three homelab boxes all backing up to the same bucket at exactly 03:30 is a small self inflicted thundering herd.&lt;/p&gt;

&lt;h2&gt;
  
  
  9. When the job fails
&lt;/h2&gt;

&lt;p&gt;A script that exits nonzero fails the service, and systemd is not shy about it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;systemctl start flaky.service
systemctl status flaky.service
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Job for flaky.service failed because the control process exited with error code.
See "systemctl status flaky.service" and "journalctl -xeu flaky.service" for details.

× flaky.service - A job that fails
     Active: failed (Result: exit-code) since Thu 2026-09-03 22:54:41 UTC; 20ms ago
    Process: 3305 ExecStart=/bin/sh -c echo "about to fail"; exit 3 (code=exited, status=3)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;×&lt;/code&gt; and &lt;code&gt;failed&lt;/code&gt; stick until you clear them, and &lt;code&gt;systemctl list-units --failed&lt;/code&gt; lists every unit on the box in that state, which is a decent morning check on any server. &lt;code&gt;systemctl reset-failed flaky.service&lt;/code&gt; clears it. One thing that will confuse you the first time: the journal line reads &lt;code&gt;status=3/NOTIMPLEMENTED&lt;/code&gt;. systemd keeps a table of names for exit codes, inherited from the old LSB init script convention where 3 meant "unimplemented feature", and it prints the name next to any code it recognises. Your script exited 3 and that is all it means.&lt;/p&gt;

&lt;p&gt;Nothing emails you, and that is the honest gap compared to cron's &lt;code&gt;MAILTO&lt;/code&gt;. The building block systemd gives you instead is &lt;code&gt;OnFailure=&lt;/code&gt;, which starts another unit when this one fails. Here is the smallest useful version. It uses a template unit, which is worth a sentence because the filename looks odd: a unit named &lt;code&gt;something@.service&lt;/code&gt; is a reusable definition you start with an argument after the &lt;code&gt;@&lt;/code&gt;, and inside the file &lt;code&gt;%i&lt;/code&gt; stands for that argument. So &lt;code&gt;notify-failed@flaky.service&lt;/code&gt; is the template run with &lt;code&gt;flaky.service&lt;/code&gt; as its argument. The alert goes into the journal at error priority, where anything you already use to watch logs will see it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="c"&gt;# /etc/systemd/system/flaky.service
&lt;/span&gt;&lt;span class="nn"&gt;[Unit]&lt;/span&gt;
&lt;span class="py"&gt;Description&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;A job that fails&lt;/span&gt;
&lt;span class="py"&gt;OnFailure&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;notify-failed@%n.service&lt;/span&gt;

&lt;span class="nn"&gt;[Service]&lt;/span&gt;
&lt;span class="py"&gt;Type&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;oneshot&lt;/span&gt;
&lt;span class="py"&gt;ExecStart&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;/bin/sh -c 'echo "about to fail"; exit 3'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="c"&gt;# /etc/systemd/system/notify-failed@.service
&lt;/span&gt;&lt;span class="nn"&gt;[Unit]&lt;/span&gt;
&lt;span class="py"&gt;Description&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;Log a failure for %i&lt;/span&gt;

&lt;span class="nn"&gt;[Service]&lt;/span&gt;
&lt;span class="py"&gt;Type&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;oneshot&lt;/span&gt;
&lt;span class="py"&gt;ExecStart&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;/bin/sh -c 'echo "ALERT: %i failed on $(hostname), see: journalctl -u %i" | systemd-cat -t failure-alert -p err'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;%n&lt;/code&gt; in the first file expands to the failing unit's own name, which becomes the argument, and &lt;code&gt;%i&lt;/code&gt; in the template picks it up, so one &lt;code&gt;notify-failed@.service&lt;/code&gt; serves every job on the box. On the &lt;code&gt;systemd-cat&lt;/code&gt; line, &lt;code&gt;-t&lt;/code&gt; tags the message so you can find it and &lt;code&gt;-p err&lt;/code&gt; sets the priority. The result:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;journalctl &lt;span class="nt"&gt;-t&lt;/span&gt; failure-alert &lt;span class="nt"&gt;-o&lt;/span&gt; short-iso
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;2026-09-03T22:54:41+00:00 systemd-timers-post failure-alert[3311]: ALERT: flaky.service failed on systemd-timers-post, see: journalctl -u flaky.service
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Swap the &lt;code&gt;echo | systemd-cat&lt;/code&gt; for a &lt;code&gt;curl&lt;/code&gt; to whatever pings your phone and you have cron's &lt;code&gt;MAILTO&lt;/code&gt;, but per job and without a mail server.&lt;/p&gt;

&lt;h2&gt;
  
  
  10. Your script is not running in your shell
&lt;/h2&gt;

&lt;p&gt;Section 2 showed &lt;code&gt;HOME&lt;/code&gt; unset and a short &lt;code&gt;PATH&lt;/code&gt;. It goes further than that, and every item below is something I have seen in a real unit file that did not do what its author thought.&lt;/p&gt;

&lt;h3&gt;
  
  
  There is no shell
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;ExecStart&lt;/code&gt; is split into words by systemd and executed directly. Pipes, redirects and &lt;code&gt;&amp;amp;&amp;amp;&lt;/code&gt; are just arguments:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="py"&gt;ExecStart&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;/bin/echo hello | /usr/bin/tr a-z A-Z&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;hello | /usr/bin/tr a-z A-Z
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That printed the pipe. If you need shell syntax, ask for a shell: &lt;code&gt;ExecStart=/bin/sh -c 'echo hello | tr a-z A-Z'&lt;/code&gt; printed &lt;code&gt;HELLO&lt;/code&gt;. Better still, put anything longer than one command in a script file and point &lt;code&gt;ExecStart&lt;/code&gt; at the script, which is what Section 1 did.&lt;/p&gt;

&lt;h3&gt;
  
  
  No tilde, no globs
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;~&lt;/code&gt; stays a literal &lt;code&gt;~&lt;/code&gt; and &lt;code&gt;*&lt;/code&gt; stays a literal &lt;code&gt;*&lt;/code&gt;, for the same reason: those are shell features.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;$&lt;/code&gt; means something to systemd first
&lt;/h3&gt;

&lt;p&gt;systemd does its own variable expansion on &lt;code&gt;ExecStart&lt;/code&gt; before your command runs, and it has rules that are almost, but not quite, the shell's. I tested every spelling against &lt;code&gt;Environment=NAME=keith&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ExecStart=/bin/echo bare: $NAME          -&amp;gt;  bare: keith
ExecStart=/bin/echo braces: ${NAME}      -&amp;gt;  braces: keith
ExecStart=/bin/echo quoted: "$NAME"      -&amp;gt;  quoted: keith
ExecStart=/bin/echo doubled: $$NAME      -&amp;gt;  doubled: $NAME
ExecStart=/bin/sh -c 'echo shell: $NAME' -&amp;gt;  shell: keith
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;$$&lt;/code&gt; is how you get a literal dollar through to your command. Where this bites is shell constructs that systemd half understands: I wrote &lt;code&gt;${#SECRET_TOKEN}&lt;/code&gt; inside an &lt;code&gt;sh -c&lt;/code&gt; to print a length, systemd saw &lt;code&gt;${...}&lt;/code&gt;, looked for a variable literally named &lt;code&gt;#SECRET_TOKEN&lt;/code&gt;, and substituted nothing. The output was silently empty. Plain &lt;code&gt;$SECRET_TOKEN&lt;/code&gt; inside the quotes made it through to the shell and worked.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;%&lt;/code&gt; means something to systemd too
&lt;/h3&gt;

&lt;p&gt;Percent signs in unit files are specifiers: &lt;code&gt;%n&lt;/code&gt; is the unit name, &lt;code&gt;%H&lt;/code&gt; the hostname, &lt;code&gt;%u&lt;/code&gt; the user, &lt;code&gt;%T&lt;/code&gt; the temp directory. So this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="py"&gt;ExecStart&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;/bin/sh -c 'echo "daily job ran at $(date +%T)"'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;logs &lt;code&gt;daily job ran at /tmp&lt;/code&gt;, because &lt;code&gt;%T&lt;/code&gt; became &lt;code&gt;/tmp&lt;/code&gt; before &lt;code&gt;date&lt;/code&gt; ever saw it. I did this in my own test unit and stared at it for a minute. Double it, &lt;code&gt;%%T&lt;/code&gt;, and &lt;code&gt;date&lt;/code&gt; gets its &lt;code&gt;%T&lt;/code&gt; back.&lt;/p&gt;

&lt;h3&gt;
  
  
  Set the environment on purpose
&lt;/h3&gt;

&lt;p&gt;The useful knobs, together:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="nn"&gt;[Service]&lt;/span&gt;
&lt;span class="py"&gt;Type&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;oneshot&lt;/span&gt;
&lt;span class="py"&gt;User&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;keith&lt;/span&gt;
&lt;span class="py"&gt;WorkingDirectory&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;/home/keith&lt;/span&gt;
&lt;span class="py"&gt;EnvironmentFile&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;/etc/hello.env&lt;/span&gt;
&lt;span class="py"&gt;ExecStart&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;/bin/sh -c 'echo "user=$(id -un) pwd=$(pwd) token=$SECRET_TOKEN"'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;user=keith pwd=/home/keith token=abc123
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;User=&lt;/code&gt; runs the job as someone other than root, which most jobs should. &lt;code&gt;WorkingDirectory=&lt;/code&gt; matters because the default is &lt;code&gt;/&lt;/code&gt;. &lt;code&gt;EnvironmentFile=&lt;/code&gt; reads &lt;code&gt;KEY=value&lt;/code&gt; lines from a file, which is where a token or a password belongs, in a root owned file with mode 600, and not in the unit file that &lt;code&gt;systemctl cat&lt;/code&gt; will happily print to anyone. The restic post's &lt;code&gt;Environment=HOME=/root&lt;/code&gt; line exists precisely because of the &lt;code&gt;HOME is: unset&lt;/code&gt; result from Section 2, restic wanted a home directory for its cache, and a system service does not get one for free.&lt;/p&gt;

&lt;h2&gt;
  
  
  11. When the job takes longer than the interval
&lt;/h2&gt;

&lt;p&gt;What if the timer fires again while the previous run is still going? I gave a service a hundred second &lt;code&gt;sleep&lt;/code&gt; and a timer that fires every minute:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;2026-09-03T22:55:00+00:00 slow start
2026-09-03T22:56:40+00:00 slow end
2026-09-03T22:56:40+00:00 slow start
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No second copy started at 22:56:00. Starting a service that is already running does nothing, and what my log shows is the 22:56 trigger waiting behind the running job and firing the instant it finished, at 22:56:40. You get at most one instance at a time, which is exactly right for a backup. If you want a fixed gap between runs instead of a fixed clock time, &lt;code&gt;OnUnitActiveSec&lt;/code&gt; from Section 8 counts from the last start, so a slow run simply pushes the next one back.&lt;/p&gt;

&lt;h2&gt;
  
  
  12. Testing without waiting until 03:30
&lt;/h2&gt;

&lt;p&gt;The service is a normal unit, so the way to test a nightly job is to run it now:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;systemctl start backup.service
journalctl &lt;span class="nt"&gt;-u&lt;/span&gt; backup.service &lt;span class="nt"&gt;-n&lt;/span&gt; 20
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That runs the exact command, as the exact user, with the exact environment the timer will use. It is a much better test than running the script from your shell, because your shell has a &lt;code&gt;HOME&lt;/code&gt; and a full &lt;code&gt;PATH&lt;/code&gt; and Section 10 happens.&lt;/p&gt;

&lt;p&gt;For a one off "run this in twenty seconds" or "at 03:30 tonight only" there is &lt;code&gt;systemd-run&lt;/code&gt;, which creates a temporary timer and service without any files:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;systemd-run &lt;span class="nt"&gt;--on-active&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;20s &lt;span class="nt"&gt;--unit&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;oneoff /bin/sh &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'echo "oneoff fired at $(date +%T)"'&lt;/span&gt;
systemctl list-timers oneoff.timer
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Running timer as unit: oneoff.timer
Will run service as unit: oneoff.service
NEXT                        LEFT LAST PASSED UNIT         ACTIVATES
Thu 2026-09-03 22:58:29 UTC  19s -         - oneoff.timer oneoff.service
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;--on-calendar="*-*-* 03:30:00"&lt;/code&gt; gives you the calendar flavour. The transient timer fires, the output lands in &lt;code&gt;journalctl -u oneoff.service&lt;/code&gt;, and then the units disappear on their own. It is &lt;code&gt;at&lt;/code&gt;, without installing &lt;code&gt;at&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  13. Timers for a normal user
&lt;/h2&gt;

&lt;p&gt;Everything so far went in &lt;code&gt;/etc/systemd/system/&lt;/code&gt; as root. A normal user gets their own systemd, with its own units in &lt;code&gt;~/.config/systemd/user/&lt;/code&gt;, driven by &lt;code&gt;systemctl --user&lt;/code&gt;. Same file format, no &lt;code&gt;sudo&lt;/code&gt;, and the jobs run as you. The catch is what happens when you log out.&lt;/p&gt;

&lt;p&gt;I created &lt;code&gt;note.timer&lt;/code&gt; and &lt;code&gt;note.service&lt;/code&gt; as user &lt;code&gt;keith&lt;/code&gt;, logged in over SSH, enabled the timer, and logged out:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;systemctl &lt;span class="nt"&gt;--user&lt;/span&gt; daemon-reload
systemctl &lt;span class="nt"&gt;--user&lt;/span&gt; &lt;span class="nb"&gt;enable&lt;/span&gt; &lt;span class="nt"&gt;--now&lt;/span&gt; note.timer
systemctl &lt;span class="nt"&gt;--user&lt;/span&gt; list-timers note.timer
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Created symlink '/home/keith/.config/systemd/user/timers.target.wants/note.timer' → '/home/keith/.config/systemd/user/note.timer'.
Thu 2026-09-03 23:05:00 UTC 46s - - note.timer note.service
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Seventy five seconds later, from root:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;systemctl is-active user@1000.service
journalctl &lt;span class="nv"&gt;_UID&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1000 &lt;span class="nt"&gt;--since&lt;/span&gt; &lt;span class="s2"&gt;"-2min"&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="s2"&gt;"user timer"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;inactive
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nothing ran. A user's systemd instance is &lt;code&gt;user@&amp;lt;uid&amp;gt;.service&lt;/code&gt;, and by default it starts when the user logs in and stops when their last session ends, taking every user timer down with it. The fix is one command, run once, by root or by the user themselves:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;loginctl enable-linger keith
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Lingering keeps &lt;code&gt;user@1000.service&lt;/code&gt; up with no session at all. Same timer, same enable, log out, and this time:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;2026-09-03T23:06:00+00:00 systemd-timers-post sh[2375]: user timer ran as keith at 23:06:00
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The user reads their own runs with &lt;code&gt;journalctl --user -u note.service&lt;/code&gt;, and root can see them with &lt;code&gt;journalctl _UID=1000&lt;/code&gt;. One trap for anyone administering this from root: &lt;code&gt;sudo -iu keith systemctl --user ...&lt;/code&gt; does not work. It fails with &lt;code&gt;Failed to connect to user scope bus&lt;/code&gt;, because &lt;code&gt;sudo&lt;/code&gt; does not set up the session environment the user manager needs. Log in as the user properly. The error message suggests &lt;code&gt;--machine=keith@.host --user&lt;/code&gt; as a way for root to reach the user's manager directly, which I have not tried.&lt;/p&gt;

&lt;h2&gt;
  
  
  14. Changing one of Ubuntu's own timers
&lt;/h2&gt;

&lt;p&gt;You will eventually want &lt;code&gt;apt-daily.timer&lt;/code&gt; to run at 04:00 instead of somewhere in a twelve hour window, or &lt;code&gt;fstrim&lt;/code&gt; on a Sunday. Do not edit the file under &lt;code&gt;/usr/lib&lt;/code&gt;, the next package update will overwrite it. Add an override:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;systemctl edit apt-daily.timer
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That opens an editor on a new file, &lt;code&gt;/etc/systemd/system/apt-daily.timer.d/override.conf&lt;/code&gt;, which systemd merges over the original. systemd calls that file a drop-in, and it is the word in the warning from Section 3. I tested this from a session with no terminal, where &lt;code&gt;systemctl edit&lt;/code&gt; refuses to run, so I wrote the same file by hand and ran &lt;code&gt;daemon-reload&lt;/code&gt;, which is all &lt;code&gt;edit&lt;/code&gt; does for you. Here is the one that pins the apt check to 04:00:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="nn"&gt;[Timer]&lt;/span&gt;
&lt;span class="py"&gt;OnCalendar&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;
&lt;span class="py"&gt;OnCalendar&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;*-*-* 04:00:00&lt;/span&gt;
&lt;span class="py"&gt;RandomizedDelaySec&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The blank &lt;code&gt;OnCalendar=&lt;/code&gt; line is not a typo and it is the part everyone misses. &lt;code&gt;OnCalendar&lt;/code&gt; is a list setting, and a drop-in that supplies another one &lt;strong&gt;adds&lt;/strong&gt; a time rather than replacing it. I checked: without the blank line, &lt;code&gt;systemctl show apt-daily.timer -p TimersCalendar&lt;/code&gt; still listed the original &lt;code&gt;06,18:00&lt;/code&gt; alongside my &lt;code&gt;04:00&lt;/code&gt;, so apt would have run three times a day. An empty assignment clears the list first.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;systemctl cat apt-daily.timer&lt;/code&gt; now shows both files, original and override, which is how you can always tell what is actually in effect. &lt;code&gt;systemctl revert apt-daily.timer&lt;/code&gt; deletes the override and puts things back.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gotchas worth knowing
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Edits do nothing until &lt;code&gt;daemon-reload&lt;/code&gt;.&lt;/strong&gt; &lt;code&gt;start&lt;/code&gt; prints a warning and runs the old definition anyway. Make the reload part of saving the file.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Enable the timer, not the service.&lt;/strong&gt; A service with no &lt;code&gt;[Install]&lt;/code&gt; section refuses to be enabled, which is the right outcome. A service you gave &lt;code&gt;WantedBy=multi-user.target&lt;/code&gt; runs at every boot on top of its schedule.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It fires up to a minute late by default.&lt;/strong&gt; &lt;code&gt;AccuracySec=1min&lt;/code&gt;. Set &lt;code&gt;AccuracySec=1s&lt;/code&gt; when the minute matters. Measured drift on the default was between four and twenty seven seconds.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Without &lt;code&gt;Persistent=true&lt;/code&gt;, a run missed during downtime is gone.&lt;/strong&gt; With it, the job runs within seconds of the next boot. It does not fire on first enable, only for runs it can prove were missed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;%&lt;/code&gt; and &lt;code&gt;$&lt;/code&gt; belong to systemd.&lt;/strong&gt; &lt;code&gt;%T&lt;/code&gt; became &lt;code&gt;/tmp&lt;/code&gt; in my &lt;code&gt;date&lt;/code&gt; format and &lt;code&gt;${#VAR}&lt;/code&gt; became nothing. Double them, &lt;code&gt;%%&lt;/code&gt; and &lt;code&gt;$$&lt;/code&gt;, to pass them through.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No shell, no &lt;code&gt;HOME&lt;/code&gt;, five entry &lt;code&gt;PATH&lt;/code&gt;, working directory &lt;code&gt;/&lt;/code&gt;.&lt;/strong&gt; Wrap shell syntax in &lt;code&gt;/bin/sh -c&lt;/code&gt;, set &lt;code&gt;Environment=HOME=&lt;/code&gt; if a tool wants one, and set &lt;code&gt;WorkingDirectory=&lt;/code&gt; if the script assumes one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;status=3/NOTIMPLEMENTED&lt;/code&gt; just means exit code 3.&lt;/strong&gt; The word is systemd's name for that code from the LSB table and has nothing to do with your script.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A drop-in &lt;code&gt;OnCalendar=&lt;/code&gt; adds a schedule.&lt;/strong&gt; Put an empty &lt;code&gt;OnCalendar=&lt;/code&gt; above the new one to replace instead.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;User timers die at logout&lt;/strong&gt; unless &lt;code&gt;loginctl enable-linger &amp;lt;user&amp;gt;&lt;/code&gt; has been run once. And &lt;code&gt;sudo -iu user systemctl --user&lt;/code&gt; cannot reach the user manager at all.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A typo in &lt;code&gt;OnCalendar&lt;/code&gt; stops the timer from loading and says so quietly.&lt;/strong&gt; &lt;code&gt;systemd-analyze verify&lt;/code&gt; on the file, or &lt;code&gt;systemd-analyze calendar&lt;/code&gt; on the expression, before you enable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Quick reference
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Task&lt;/th&gt;
&lt;th&gt;Command&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Pick up an edited unit file&lt;/td&gt;
&lt;td&gt;&lt;code&gt;systemctl daemon-reload&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Turn a timer on, now and at boot&lt;/td&gt;
&lt;td&gt;&lt;code&gt;systemctl enable --now job.timer&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;See every timer and when it next fires&lt;/td&gt;
&lt;td&gt;&lt;code&gt;systemctl list-timers --all&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Last run and next run of one timer&lt;/td&gt;
&lt;td&gt;&lt;code&gt;systemctl status job.timer&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Run the job right now, timer or not&lt;/td&gt;
&lt;td&gt;&lt;code&gt;systemctl start job.service&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Read the job's output&lt;/td&gt;
&lt;td&gt;&lt;code&gt;journalctl -u job.service -o short-iso&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Follow it live&lt;/td&gt;
&lt;td&gt;&lt;code&gt;journalctl -u job.service -f&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Since last boot only&lt;/td&gt;
&lt;td&gt;&lt;code&gt;journalctl -u job.service -b&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;What does this schedule mean&lt;/td&gt;
&lt;td&gt;&lt;code&gt;systemd-analyze calendar "Mon..Fri 09:00" --iterations=3&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Check a unit file before enabling&lt;/td&gt;
&lt;td&gt;&lt;code&gt;systemd-analyze verify /etc/systemd/system/job.timer&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Show the file, plus any overrides&lt;/td&gt;
&lt;td&gt;&lt;code&gt;systemctl cat job.timer&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Every unit currently in a failed state&lt;/td&gt;
&lt;td&gt;&lt;code&gt;systemctl list-units --failed&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Clear a failed state&lt;/td&gt;
&lt;td&gt;&lt;code&gt;systemctl reset-failed job.service&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;One off run in twenty seconds&lt;/td&gt;
&lt;td&gt;&lt;code&gt;systemd-run --on-active=20s --unit=oneoff /path/to/cmd&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Override a vendor timer&lt;/td&gt;
&lt;td&gt;&lt;code&gt;systemctl edit apt-daily.timer&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Undo the override&lt;/td&gt;
&lt;td&gt;&lt;code&gt;systemctl revert apt-daily.timer&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;User timers survive logout&lt;/td&gt;
&lt;td&gt;&lt;code&gt;loginctl enable-linger &amp;lt;user&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Where the missed run stamps live&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/var/lib/systemd/timers/stamp-*.timer&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The pair of files I now start every new job from, with everything above baked in:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="c"&gt;# /etc/systemd/system/job.service
&lt;/span&gt;&lt;span class="nn"&gt;[Unit]&lt;/span&gt;
&lt;span class="py"&gt;Description&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;What this job does&lt;/span&gt;

&lt;span class="nn"&gt;[Service]&lt;/span&gt;
&lt;span class="py"&gt;Type&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;oneshot&lt;/span&gt;
&lt;span class="py"&gt;User&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;someone&lt;/span&gt;
&lt;span class="py"&gt;WorkingDirectory&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;/where/it/expects/to/be&lt;/span&gt;
&lt;span class="py"&gt;EnvironmentFile&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;/etc/job.env&lt;/span&gt;
&lt;span class="py"&gt;ExecStart&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;/usr/local/bin/job.sh&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="c"&gt;# /etc/systemd/system/job.timer
&lt;/span&gt;&lt;span class="nn"&gt;[Unit]&lt;/span&gt;
&lt;span class="py"&gt;Description&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;When it runs&lt;/span&gt;

&lt;span class="nn"&gt;[Timer]&lt;/span&gt;
&lt;span class="py"&gt;OnCalendar&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;*-*-* 03:30:00&lt;/span&gt;
&lt;span class="py"&gt;Persistent&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;true&lt;/span&gt;
&lt;span class="py"&gt;RandomizedDelaySec&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;10min&lt;/span&gt;

&lt;span class="nn"&gt;[Install]&lt;/span&gt;
&lt;span class="py"&gt;WantedBy&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;timers.target&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;daemon-reload&lt;/code&gt;, &lt;code&gt;enable --now job.timer&lt;/code&gt;, &lt;code&gt;start job.service&lt;/code&gt; once to prove it works, and then leave it alone. That last step is the whole point. &lt;code&gt;[ timer armed ]&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://peculiarengineer.com/blog/systemd-units-timers-ubuntu-26-04/" rel="noopener noreferrer"&gt;peculiarengineer.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>linux</category>
      <category>ubuntu</category>
      <category>devops</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>A disposable Hetzner lab with OpenTofu: real Ubuntu box, run a script, destroy it</title>
      <dc:creator>Minor Keith</dc:creator>
      <pubDate>Mon, 24 Aug 2026 15:03:03 +0000</pubDate>
      <link>https://dev.to/peculiarengineer/a-disposable-hetzner-lab-with-opentofu-real-ubuntu-box-run-a-script-destroy-it-1522</link>
      <guid>https://dev.to/peculiarengineer/a-disposable-hetzner-lab-with-opentofu-real-ubuntu-box-run-a-script-destroy-it-1522</guid>
      <description>&lt;p&gt;Most of what I write here is Ubuntu, and I write it on a Mac. That gap is a problem. A Docker container is not a machine: it shares my kernel, it has no real systemd to fight with, it cannot reboot, and &lt;code&gt;do-release-upgrade&lt;/code&gt; inside one is meaningless. A local VM is closer but it lives forever, drifts, and quietly becomes a machine I maintain instead of a machine I test on.&lt;/p&gt;

&lt;p&gt;What I actually want is a real Ubuntu server that exists for twenty minutes and then does not. Hetzner bills by the hour and hands you a booted box in about sixty seconds, so the cloud part is easy. The part worth writing down is everything around it: where the state goes when you are creating and destroying the same thing forty times, how you make sure a forgotten VM does not bill you all month, and the three ways this tool could quietly cost me money, open a box to the internet, or leave something behind it no longer knows about. All three were in my own code. I found one by reading it and the other two by trying to build the box for the next post, which failed. All three are fixed now, and each one is in here because the fix is less interesting than the reason it was wrong.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR.&lt;/strong&gt; One &lt;code&gt;hcloud_server&lt;/code&gt; and one &lt;code&gt;hcloud_firewall&lt;/code&gt; in OpenTofu, with the firewall locked to your public IP &lt;code&gt;/32&lt;/code&gt;. Keep state in S3 at &lt;code&gt;labs/&amp;lt;lab_id&amp;gt;/state.tfstate&lt;/code&gt;, one object per lab, so concurrent labs never touch the same file. Wrap it in a Python script that does &lt;code&gt;up&lt;/code&gt;, &lt;code&gt;down&lt;/code&gt;, &lt;code&gt;ssh&lt;/code&gt;, &lt;code&gt;status&lt;/code&gt;, and &lt;code&gt;sweep&lt;/code&gt;. Push the work into a &lt;strong&gt;runbook&lt;/strong&gt;, a plain Python file that gets copied to the box and run as root, so the test is a reproducible artifact instead of shell history. Set a short TTL and sweep from cron, because nothing cloud side enforces it.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The shape of it
&lt;/h2&gt;

&lt;p&gt;The commands you will actually use are &lt;code&gt;up&lt;/code&gt;, &lt;code&gt;down&lt;/code&gt;, &lt;code&gt;ssh&lt;/code&gt;, &lt;code&gt;status&lt;/code&gt;, and &lt;code&gt;sweep&lt;/code&gt;, and the whole thing is a Python file plus five &lt;code&gt;.tofu&lt;/code&gt; files.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;lab up &lt;span class="nt"&gt;--image&lt;/span&gt; ubuntu-26.04 &lt;span class="nt"&gt;--ttl&lt;/span&gt; 1 &lt;span class="nt"&gt;--runbook&lt;/span&gt; runbooks/check.py
lab ssh &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'journalctl -n 50 --no-pager'&lt;/span&gt;
lab down
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;up&lt;/code&gt; creates one server and one firewall, then prints the SSH command and a destroy deadline. Hand it a runbook and it also waits for SSH, copies the script to the box, and runs it as root while streaming the output back. &lt;code&gt;down&lt;/code&gt; destroys it. &lt;code&gt;sweep&lt;/code&gt; destroys anything past its deadline. That is the entire product.&lt;/p&gt;

&lt;p&gt;The design decision that matters is that a lab is &lt;strong&gt;one server&lt;/strong&gt;, not a fleet. The moment you let it grow a second node you need inventory, ordering, and a graph, and you have written a worse Ansible. One box, born and killed, is a small enough problem to keep honest.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prerequisites
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://opentofu.org/docs/intro/install/" rel="noopener noreferrer"&gt;OpenTofu&lt;/a&gt; 1.10 or newer, which is where S3 state locking arrives. I am on 1.12.6. Terraform works too, since this uses nothing OpenTofu specific.&lt;/li&gt;
&lt;li&gt;Python 3.10 or newer and &lt;a href="https://docs.astral.sh/uv/getting-started/installation/" rel="noopener noreferrer"&gt;uv&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;A Hetzner Cloud project and an API token with read and write, from &lt;strong&gt;Security&lt;/strong&gt;, then &lt;strong&gt;API tokens&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Your SSH public key uploaded under &lt;strong&gt;Security&lt;/strong&gt;, then &lt;strong&gt;SSH keys&lt;/strong&gt;. Note the name you gave it.&lt;/li&gt;
&lt;li&gt;An S3 compatible bucket. I use Hetzner Object Storage in the same region, so state and logs sit next to the servers.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  1. The OpenTofu is the small part
&lt;/h2&gt;

&lt;p&gt;Two resources. That is it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="c1"&gt;# main.tofu&lt;/span&gt;
&lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="s2"&gt;"hcloud_ssh_key"&lt;/span&gt; &lt;span class="s2"&gt;"lab"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ssh_key_name&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"hcloud_firewall"&lt;/span&gt; &lt;span class="s2"&gt;"lab"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"${var.lab_id}-fw"&lt;/span&gt;

  &lt;span class="nx"&gt;rule&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;direction&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"in"&lt;/span&gt;
    &lt;span class="nx"&gt;protocol&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"tcp"&lt;/span&gt;
    &lt;span class="nx"&gt;port&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"22"&lt;/span&gt;
    &lt;span class="nx"&gt;source_ips&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;allowed_ssh_cidr&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;rule&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;direction&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"in"&lt;/span&gt;
    &lt;span class="nx"&gt;protocol&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"icmp"&lt;/span&gt;
    &lt;span class="nx"&gt;source_ips&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"0.0.0.0/0"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"::/0"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"hcloud_server"&lt;/span&gt; &lt;span class="s2"&gt;"lab"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;         &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;lab_id&lt;/span&gt;
  &lt;span class="nx"&gt;server_type&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;server_type&lt;/span&gt;
  &lt;span class="nx"&gt;image&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;image&lt;/span&gt;
  &lt;span class="nx"&gt;location&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;location&lt;/span&gt;
  &lt;span class="nx"&gt;ssh_keys&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;hcloud_ssh_key&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;lab&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="nx"&gt;firewall_ids&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;hcloud_firewall&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;lab&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

  &lt;span class="nx"&gt;labels&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;lab&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"true"&lt;/span&gt;
    &lt;span class="nx"&gt;lab_id&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;lab_id&lt;/span&gt;
    &lt;span class="nx"&gt;ttl_hours&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ttl_hours&lt;/span&gt;
    &lt;span class="nx"&gt;created_at&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;created_at&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One more file does quiet work alongside this one. &lt;code&gt;outputs.tofu&lt;/code&gt; exports &lt;code&gt;server_ip&lt;/code&gt;, &lt;code&gt;server_id&lt;/code&gt;, and &lt;code&gt;lab_id&lt;/code&gt;, and the Python wrapper reads the first two back with &lt;code&gt;tofu output -json&lt;/code&gt; right after apply. Copy the resources, skip the outputs, and &lt;code&gt;up&lt;/code&gt; dies immediately after building your server, which is the worst moment for it to die.&lt;/p&gt;

&lt;p&gt;Two things in the resources are load bearing and easy to skip past.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Only port 22 and ICMP are open.&lt;/strong&gt; Nothing else, inbound, ever. If a runbook installs Nginx you will not reach it from your browser, and that is deliberate: a box that exists for twenty minutes with a service exposed to the internet is how you end up in someone's scan results. When I need to see a web service I forward it over the SSH session I already have:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh &lt;span class="nt"&gt;-i&lt;/span&gt; ~/.ssh/id_ed25519 &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nv"&gt;StrictHostKeyChecking&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;no &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nv"&gt;UserKnownHostsFile&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/dev/null &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-N&lt;/span&gt; &lt;span class="nt"&gt;-L&lt;/span&gt; 8080:localhost:80 root@&amp;lt;ip&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The labels are the safety net.&lt;/strong&gt; &lt;code&gt;lab = "true"&lt;/code&gt;, plus the TTL and creation time, live on the server in Hetzner's own API. Local files get deleted, laptops get reimaged, and a &lt;code&gt;.lab/&lt;/code&gt; directory is one &lt;code&gt;rm -rf&lt;/code&gt; away from being gone. The labels stay attached to the running server, so no matter what happens on my machine I can always ask Hetzner "what did I leave on?" and get a real answer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$HCLOUD_TOKEN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s1"&gt;'https://api.hetzner.cloud/v1/servers?label_selector=lab%3Dtrue'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | python3 &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'import json,sys; [print(s["name"], s["public_net"]["ipv4"]["ip"], s["labels"]) for s in json.load(sys.stdin)["servers"]]'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Put that somewhere you will find it. It is the command that answers "am I being billed for something I forgot about," and it does not care whether your local state survived.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. One state object per lab
&lt;/h2&gt;

&lt;p&gt;The backend is declared empty and filled in at &lt;code&gt;init&lt;/code&gt; time:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="c1"&gt;# backend.tofu&lt;/span&gt;
&lt;span class="nx"&gt;terraform&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;backend&lt;/span&gt; &lt;span class="s2"&gt;"s3"&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then every command passes the key for that specific lab:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;tofu init &lt;span class="nt"&gt;-input&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;false&lt;/span&gt; &lt;span class="nt"&gt;-reconfigure&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-backend-config&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"bucket=&lt;/span&gt;&lt;span class="nv"&gt;$S3_BUCKET&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-backend-config&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"key=labs/&lt;/span&gt;&lt;span class="nv"&gt;$LAB_ID&lt;/span&gt;&lt;span class="s2"&gt;/state.tfstate"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-backend-config&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"region=&lt;/span&gt;&lt;span class="nv"&gt;$S3_REGION&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-backend-config&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"endpoint=&lt;/span&gt;&lt;span class="nv"&gt;$S3_ENDPOINT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-backend-config&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"access_key=&lt;/span&gt;&lt;span class="nv"&gt;$AWS_ACCESS_KEY_ID&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-backend-config&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"secret_key=&lt;/span&gt;&lt;span class="nv"&gt;$AWS_SECRET_ACCESS_KEY&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-backend-config&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"skip_credentials_validation=true"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-backend-config&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"skip_metadata_api_check=true"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-backend-config&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"skip_region_validation=true"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-backend-config&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"skip_requesting_account_id=true"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-backend-config&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"use_lockfile=true"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-backend-config&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"use_path_style=true"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The four &lt;code&gt;skip_&lt;/code&gt; flags are not optional and they are the thing that sends people to the search bar. The S3 backend assumes AWS. Point it at Hetzner Object Storage, MinIO, or Backblaze and it will try to validate credentials against AWS endpoints, look for an EC2 metadata service that is not there, reject &lt;code&gt;nbg1&lt;/code&gt; as a region name, and call STS to work out an account ID. Every one of those fails against a non AWS provider. Skip them all.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;use_path_style=true&lt;/code&gt; matters for the same reason. Hetzner Object Storage wants &lt;code&gt;https://nbg1.your-objectstorage.com/bucket/key&lt;/code&gt;, not &lt;code&gt;https://bucket.nbg1.your-objectstorage.com/key&lt;/code&gt;. Get it wrong and you get a DNS failure that looks nothing like a configuration mistake.&lt;/p&gt;

&lt;p&gt;Why one object per lab instead of one workspace or one big state? Because labs are independent and I want to run two at once without them knowing about each other. Separate keys means separate state files, so a 24.04 box and a 26.04 box can be up simultaneously and neither can corrupt the other. It also means a lab's entire existence is one deletable prefix in a bucket.&lt;/p&gt;

&lt;p&gt;The credentials failure here is worth calling out because it does not announce itself. Bad S3 keys surface as an opaque &lt;code&gt;tofu init&lt;/code&gt; error with no mention of S3. So the first thing the tool does when you complain is check them directly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;lab test-s3
&lt;span class="c"&gt;# Endpoint: https://nbg1.your-objectstorage.com&lt;/span&gt;
&lt;span class="c"&gt;# Region:   nbg1&lt;/span&gt;
&lt;span class="c"&gt;# Bucket:   minor-lab-state&lt;/span&gt;
&lt;span class="c"&gt;# Access:   ABC123...&lt;/span&gt;
&lt;span class="c"&gt;#&lt;/span&gt;
&lt;span class="c"&gt;# OK: bucket 'minor-lab-state' is reachable.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If that passes and &lt;code&gt;init&lt;/code&gt; still fails, the problem is genuinely OpenTofu. If it fails, you have your answer in two seconds instead of twenty minutes.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. The wrapper
&lt;/h2&gt;

&lt;p&gt;The Python file is the whole user interface. &lt;code&gt;up&lt;/code&gt;, &lt;code&gt;down&lt;/code&gt;, &lt;code&gt;ssh&lt;/code&gt;, &lt;code&gt;logs&lt;/code&gt;, &lt;code&gt;status&lt;/code&gt;, &lt;code&gt;sweep&lt;/code&gt;, plus &lt;code&gt;test-s3&lt;/code&gt;, &lt;code&gt;test-discord&lt;/code&gt;, and &lt;code&gt;server-types&lt;/code&gt;. It shells out to &lt;code&gt;tofu&lt;/code&gt;, holds the flags so I never type a backend config by hand, and writes a small metadata file per lab.&lt;/p&gt;

&lt;p&gt;Three details in &lt;code&gt;up&lt;/code&gt; earned their place.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It waits for SSH properly, when there is a runbook.&lt;/strong&gt; Hetzner reports the server as &lt;code&gt;running&lt;/code&gt; before cloud-init has finished, so connecting immediately gets you a refused connection. The wrapper polls port 22 every two seconds for up to two minutes, with the progress prints trimmed out of the quote below. A bare &lt;code&gt;lab up&lt;/code&gt; with no runbook skips this entirely. It applies, prints, and exits without ever probing port 22, so the address it hands you may not answer for another half minute.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_wait_for_ssh&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ip&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;120&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;deadline&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nf"&gt;timedelta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;seconds&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="nf"&gt;_now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;deadline&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create_connection&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;ip&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;22&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
                &lt;span class="k"&gt;return&lt;/span&gt;
        &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;OSError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;pass&lt;/span&gt;
        &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;It saves metadata before it does anything risky.&lt;/strong&gt; The IP, the server ID, the TTL, and the destroy deadline are written to disk the moment the server exists, before the runbook runs. If the runbook explodes, or I hit Ctrl C, the box is still tracked and still destroyable. Saving that file after the interesting part would mean any failure leaves an untracked, billed server.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It fails closed.&lt;/strong&gt; If the runbook exits non zero, &lt;code&gt;up&lt;/code&gt; destroys the server and firewall, wipes the local metadata, and still exits with the runbook's code. That is the correct default for a tool that spends money, but it has a consequence you need to internalize: &lt;strong&gt;the evidence dies with the box.&lt;/strong&gt; To debug a failure you have to decide beforehand and pass &lt;code&gt;--keep-on-failure&lt;/code&gt;. It took two fixes to make that heading true, and both are in the third footgun below.&lt;/p&gt;

&lt;p&gt;The log survives a failed runbook, because the upload to S3 happens before the destroy. After an automatic teardown, &lt;code&gt;lab logs&lt;/code&gt; fails because the local directory is gone, but the full output including stderr is sitting at &lt;code&gt;s3://&amp;lt;bucket&amp;gt;/labs/&amp;lt;lab_id&amp;gt;/runbook.log&lt;/code&gt;. Read it from there.&lt;/p&gt;

&lt;p&gt;One hole in that, which I would rather state than have you discover. The upload only gets skipped when the SCP itself fails, because that raises and goes straight to the exception handler, which destroys the box without uploading. A connection that dies partway through a running script is fine: the read loop just ends, &lt;code&gt;up&lt;/code&gt; uploads whatever made it into the log, and you get a truncated but real record. So the log survives a script that fails and a session that drops. It does not survive a runbook that never landed on the box.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Runbooks, not shell history
&lt;/h2&gt;

&lt;p&gt;A runbook is a Python file that gets copied to &lt;code&gt;/tmp/runbook.py&lt;/code&gt; and executed as root. Output is merged, streamed live to my terminal, written locally, and uploaded to S3.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;#!/usr/bin/env python3
&lt;/span&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;subprocess&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sys&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cmd&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;$ &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt; &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cmd&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;subprocess&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cmd&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;check&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;apt-get&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;update&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-qq&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;apt-get&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;install&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-y&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-qq&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;nginx&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;__name__&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;__main__&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Python rather than bash for one reason: exit codes. A bash script without &lt;code&gt;set -euo pipefail&lt;/code&gt; cheerfully reports success after every command in it failed, and I have been burned by a green test that proved nothing. &lt;code&gt;subprocess.run(..., check=True)&lt;/code&gt; raises, the script exits non zero, and the tool tears the lab down and tells me. Failure is loud by default instead of by remembering to configure it.&lt;/p&gt;

&lt;p&gt;The real payoff is that the runbook is a file. When I write an Ubuntu guide, the script that proves it works can ship next to the post. A reader can run the same file on their own box and get the same output rather than trusting that I ran the commands in the order I wrote them.&lt;/p&gt;

&lt;p&gt;One thing bites on the first run. &lt;code&gt;apt-get&lt;/code&gt; fails with &lt;code&gt;Could not get lock /var/lib/dpkg/lock-frontend&lt;/code&gt; because cloud-init is still installing packages when SSH comes up. Waiting for port 22 is not the same as waiting for the machine to be finished. Either wait for cloud-init or retry:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cloud-init&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--wait&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  5. Teardown is the actual feature
&lt;/h2&gt;

&lt;p&gt;Creating servers is easy. Not paying for them is the part that needs design.&lt;/p&gt;

&lt;p&gt;Every lab gets a &lt;code&gt;destroy_after&lt;/code&gt; timestamp from &lt;code&gt;--ttl&lt;/code&gt;, in hours. &lt;code&gt;sweep&lt;/code&gt; walks the labs and destroys anything past its deadline, which goes in cron:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;0 * * * * cd ~/projects/hetzner-terraform-dev-lab &amp;amp;&amp;amp; uv run ./lab sweep &amp;gt;&amp;gt; /tmp/lab-sweep.log 2&amp;gt;&amp;amp;1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;uv run&lt;/code&gt; is not decoration there. The script exits at import time if &lt;code&gt;boto3&lt;/code&gt; and &lt;code&gt;python-dotenv&lt;/code&gt; are missing, and system Python has neither, so a bare &lt;code&gt;./lab sweep&lt;/code&gt; from cron prints a dependency error into the log every hour and sweeps nothing. Silently, in the one place whose entire job is to stop you being billed for a machine you forgot.&lt;/p&gt;

&lt;p&gt;Be honest about what this is: &lt;strong&gt;the TTL is advisory.&lt;/strong&gt; Nothing on Hetzner's side enforces it. If my laptop is asleep, cron does not run, and the box bills merrily. A short TTL plus prompt &lt;code&gt;lab down&lt;/code&gt; is the real story, and sweep is a backstop for the times I forget.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;sweep&lt;/code&gt; also destroys &lt;em&gt;any&lt;/em&gt; expired lab, including one somebody else on the machine created. Mine is a single user Mac so that is theoretical, but it is why the command is not wired into &lt;code&gt;up&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The failure I care most about is the one where teardown itself fails. The script prints &lt;code&gt;Manual cleanup may be required&lt;/code&gt;, then re-raises, so you get a traceback with it. That is the correct behavior, and it is also the exact moment to go run that label selector query from earlier.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three footguns I found in my own tool
&lt;/h2&gt;

&lt;p&gt;Writing this post is what surfaced all three. The first I found by reading the code, and the other two found me, in the middle of trying to spin up the box for the next post.&lt;/p&gt;

&lt;h3&gt;
  
  
  The public IP lookup fails open
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;up&lt;/code&gt; locks SSH to your current public IP by default. It gets that IP from ipify:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_public_ip&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;urllib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;urlopen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://api.ipify.org&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;Exception&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and then:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;ip&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_public_ip&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;allowed_ssh_cidr&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;ip&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/32&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;ip&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0.0.0.0/0&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read that second block again. If ipify is slow, blocked, rate limiting me, or I am on a captive portal, &lt;code&gt;_public_ip()&lt;/code&gt; swallows the exception and returns an empty string, and the default silently becomes &lt;strong&gt;root SSH open to the entire internet.&lt;/strong&gt; No warning, no prompt, nothing in the output that looks different from a normal run. The one line in &lt;code&gt;up&lt;/code&gt;'s output that would tell you is &lt;code&gt;ssh cidr: 0.0.0.0/0&lt;/code&gt;, sitting in a block of five lines that all look like routine startup noise.&lt;/p&gt;

&lt;p&gt;It fails closed now:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;ip&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_public_ip&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;ip&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Could not determine your public IP address.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Refusing to default the SSH rule to 0.0.0.0/0.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Pass --allow-ssh &amp;lt;cidr&amp;gt; explicitly if that is really what you want.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;allowed_ssh_cidr&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;ip&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/32&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And when you do ask for it deliberately, it says so out loud instead of hiding in the startup noise:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;WARNING: SSH will be open to the entire internet (0.0.0.0/0).
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Wide open is a legitimate thing to want sometimes, from a CI runner with no stable egress address for instance. It is not a legitimate thing to arrive at by accident because a third party HTTP request timed out. Make the human type it.&lt;/p&gt;

&lt;p&gt;The same default was sitting in &lt;code&gt;variables.tofu&lt;/code&gt; as &lt;code&gt;default = "0.0.0.0/0"&lt;/code&gt;, which is the belt to match the braces. That default is gone too, so the variable is now required and a missing value is an error rather than an open port.&lt;/p&gt;

&lt;h3&gt;
  
  
  Hetzner will price a server type it will not sell you
&lt;/h3&gt;

&lt;p&gt;I picked &lt;code&gt;cpx12&lt;/code&gt; when I set this up because the name looked like the small one. Later I built a &lt;code&gt;server-types&lt;/code&gt; command so I would stop guessing, ran it, and it told me &lt;code&gt;cpx11&lt;/code&gt; was two cores instead of one and less than half the price. Obvious upgrade. I changed the flag, ran it, and got this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Error: Server Type "cpx11" is unavailable in "nbg1" and can no longer be ordered

  with hcloud_server.lab,
  on main.tofu line 22, in resource "hcloud_server" "lab":
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here is the part worth the post. Hetzner's &lt;code&gt;/v1/server_types&lt;/code&gt; endpoint still returns &lt;code&gt;cpx11&lt;/code&gt; with a current hourly price for &lt;code&gt;nbg1&lt;/code&gt; and &lt;code&gt;deprecation: null&lt;/code&gt;. Nothing in that response suggests you cannot have one. The entire &lt;code&gt;cpx*1&lt;/code&gt; generation is retired, and the only endpoint that admits it is &lt;code&gt;/v1/datacenters&lt;/code&gt;, which carries a &lt;code&gt;server_types.available&lt;/code&gt; list of numeric IDs per datacenter:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$HCLOUD_TOKEN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s1"&gt;'https://api.hetzner.cloud/v1/datacenters'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | python3 &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'import json,sys; [print(d["name"], sorted(d["server_types"]["available"])) for d in json.load(sys.stdin)["datacenters"]]'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Prices and deprecation flags describe the catalog. Only that list describes the shop.&lt;/p&gt;

&lt;p&gt;Which made my own command a liar. &lt;code&gt;lab server-types --location nbg1&lt;/code&gt; printed the header &lt;code&gt;Available server types in nbg1:&lt;/code&gt; and then listed &lt;code&gt;cpx11&lt;/code&gt;, &lt;code&gt;cpx21&lt;/code&gt;, &lt;code&gt;cpx31&lt;/code&gt;, &lt;code&gt;cpx41&lt;/code&gt;, &lt;code&gt;cpx51&lt;/code&gt;, &lt;code&gt;cx43&lt;/code&gt;, and &lt;code&gt;cx53&lt;/code&gt;, none of which can be ordered there. It filtered on whether a price exists for the location, which is not the same question. A command whose whole purpose is to stop me guessing sent me straight into a failed apply.&lt;/p&gt;

&lt;p&gt;It cross references the datacenter list now, and only prints what you can actually have:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Server types you can actually order in nbg1:
NAME          CPU   RAM   DISK  ORDERABLE   PRICE/MO
cax11           2    4GB    40GB        yes       6.99
cpx12           1    2GB    40GB        yes      13.49
cx23            2    4GB    40GB        yes       6.49
...                                                       (17 rows in total)

Pass --all to include types that are priced here but cannot be ordered.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;--all&lt;/code&gt; keeps the rest visible with an honest column, which is where &lt;code&gt;cpx11&lt;/code&gt; now shows up as &lt;code&gt;NO&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Among the types you can actually order in &lt;code&gt;nbg1&lt;/code&gt;, the original default still looks bad:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;th&gt;Cores&lt;/th&gt;
&lt;th&gt;RAM&lt;/th&gt;
&lt;th&gt;Hourly&lt;/th&gt;
&lt;th&gt;Orderable in nbg1&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;cx23&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;4 GB&lt;/td&gt;
&lt;td&gt;€0.0104&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;cax11&lt;/code&gt; (arm)&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;4 GB&lt;/td&gt;
&lt;td&gt;€0.0112&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;&lt;code&gt;cpx12&lt;/code&gt;&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;2 GB&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;€0.0216&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;cpx11&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;2 GB&lt;/td&gt;
&lt;td&gt;€0.0096&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;no&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;cx23&lt;/code&gt; is double the cores and double the RAM of the old default, for less than half the hourly. It is the default now. The arm box is worth a look too, since Hetzner publishes &lt;code&gt;ubuntu-26.04&lt;/code&gt; for arm as well as x86.&lt;/p&gt;

&lt;h3&gt;
  
  
  The teardown handler has two blind spots
&lt;/h3&gt;

&lt;p&gt;The &lt;code&gt;cpx11&lt;/code&gt; failure exposed a third one immediately. When &lt;code&gt;tofu apply&lt;/code&gt; fails, the run leaves behind a &lt;code&gt;lab-upgrade-2604-fw&lt;/code&gt; firewall with nothing attached to it, and &lt;code&gt;lab status&lt;/code&gt; shows an empty table. Local metadata is written after apply succeeds, so from the tool's point of view the lab was never born, and &lt;code&gt;lab down&lt;/code&gt; has nothing to destroy.&lt;/p&gt;

&lt;p&gt;The cause is one line of control flow. In &lt;code&gt;cmd_up&lt;/code&gt;, the apply happens &lt;em&gt;before&lt;/em&gt; the &lt;code&gt;try&lt;/code&gt; block that owns the cleanup:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nf"&gt;_tofu_init&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lab_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;_tofu_apply&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lab_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;allowed_ssh_cidr&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;created_at_label&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;vm_created&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;

&lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="bp"&gt;...&lt;/span&gt;
&lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;Exception&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;# tears down the server and the firewall
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every failure after that point was handled properly. The failure &lt;em&gt;of&lt;/em&gt; that line was not handled at all, and it is the most likely one, because apply is where quota limits, retired server types, unavailable images, and API hiccups all land.&lt;/p&gt;

&lt;p&gt;The apply lives inside the &lt;code&gt;try&lt;/code&gt; now. The subtle part is the flag, which has to be set &lt;em&gt;before&lt;/em&gt; the apply rather than after it, because a failed apply may already have built the firewall. Set it after and you have written the same bug in a new place, which I did on the first attempt:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;resources_touched&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;
&lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nf"&gt;_tofu_init&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lab_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="c1"&gt;# Set before the apply, not after: a failed apply can still have created
&lt;/span&gt;    &lt;span class="c1"&gt;# the firewall, and that is exactly the case that used to leak.
&lt;/span&gt;    &lt;span class="n"&gt;resources_touched&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;
    &lt;span class="nf"&gt;_tofu_apply&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lab_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;allowed_ssh_cidr&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;created_at_label&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Running the same &lt;code&gt;cpx11&lt;/code&gt; failure against the fixed version now ends the way it should:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Error: Server Type "cpx11" is unavailable in "nbg1" and can no longer be ordered

Lab setup failed: Command '['tofu', 'apply', ...]' returned non-zero exit status 1.
Tearing down lab to avoid leaving resources up...
...
Destroy complete! Resources: 1 destroyed.
Lab torn down.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There is a second blind spot in the same handler and it is quieter. &lt;code&gt;_wait_for_ssh&lt;/code&gt; gives up after two minutes by calling &lt;code&gt;sys.exit(1)&lt;/code&gt;. That raises &lt;code&gt;SystemExit&lt;/code&gt;, and &lt;code&gt;SystemExit&lt;/code&gt; does not inherit from &lt;code&gt;Exception&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;issubclass&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;SystemExit&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;Exception&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="bp"&gt;False&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So it went straight past &lt;code&gt;except Exception&lt;/code&gt; and no teardown ran. A box that boots but never opens port 22, which is what a bad image or a firewall mistake looks like, left a running server behind. This one was gentler than the apply case, because metadata was already saved, so &lt;code&gt;lab status&lt;/code&gt; saw the lab and &lt;code&gt;lab down&lt;/code&gt; worked. It still was not the behavior the tool advertised. The handler catches &lt;code&gt;BaseException&lt;/code&gt; now, which is the one place that spelling is worth reaching for.&lt;/p&gt;

&lt;p&gt;A dangling firewall costs nothing, so this is clutter rather than a bill. It matters because it breaks the promise the rest of the tool makes: that anything it created, it can also destroy. Here is the audit, which needs no local state and is the thing to reach for when the tool says it knows nothing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$HCLOUD_TOKEN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s1"&gt;'https://api.hetzner.cloud/v1/firewalls'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | python3 &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'import json,sys; [print(f["id"], f["name"], len(f["applied_to"])) for f in json.load(sys.stdin)["firewalls"]]'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Anything with zero attachments and a lab name is mine, and it is safe to delete.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gotchas worth knowing
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;tofu init&lt;/code&gt; fails with something that never mentions S3.&lt;/strong&gt; Bad object storage credentials surface as an opaque init error. Run &lt;code&gt;lab test-s3&lt;/code&gt; first, always.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;InvalidAccessKeyId&lt;/code&gt; when the keys look right.&lt;/strong&gt; The S3 credentials belong to a different Hetzner project than the bucket. They are scoped per project and the console does not make that obvious.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;SSH stops working mid lab.&lt;/strong&gt; The firewall is pinned to the public IP you had when you ran &lt;code&gt;up&lt;/code&gt;. Move from home WiFi to a phone hotspot and you are locked out of your own box. Recreate it, or widen the CIDR.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;There was no state locking at all, and that is worse than a stuck lock.&lt;/strong&gt; I assumed there was, and I was wrong, which is why I am spelling it out. The S3 backend only locks if you ask it to, and this backend config did not. On OpenTofu 1.10 and later that means adding &lt;code&gt;use_lockfile=true&lt;/code&gt;, and on older setups it meant a DynamoDB table. With neither, two commands against the same lab do not queue and do not hang. They both write, and the loser silently corrupts the state file. Separate labs are genuinely safe because they have separate state objects. The same lab twice at once was not, and nothing warned you. One flag fixes it, and it is in the backend config now:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nt"&gt;-backend-config&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"use_lockfile=true"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Deleting &lt;code&gt;.lab/&lt;/code&gt; orphans a running server.&lt;/strong&gt; Destroy is driven from the local metadata file. Wipe the directory and the tool no longer knows the box exists, while Hetzner still does, and still bills for it. This is why the labels matter.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;lab logs&lt;/code&gt; fails after a failed runbook.&lt;/strong&gt; Expected. The teardown removed the local directory. The log is in S3.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Discord webhooks fail quietly.&lt;/strong&gt; Post to a webhook URL and you get a &lt;code&gt;204&lt;/code&gt; with no body whether or not anything sensible happened. Add &lt;code&gt;?wait=true&lt;/code&gt; and Discord returns the created message or a real error, which turns "did that work?" into a question with an answer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Quick reference
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;lab status                                     &lt;span class="c"&gt;# what exists, * marks current&lt;/span&gt;
lab up &lt;span class="nt"&gt;--ttl&lt;/span&gt; 1                                 &lt;span class="c"&gt;# bare box, eligible for sweep after 1h&lt;/span&gt;
lab up &lt;span class="nt"&gt;--image&lt;/span&gt; ubuntu-26.04 &lt;span class="nt"&gt;--ttl&lt;/span&gt; 2 &lt;span class="nt"&gt;--runbook&lt;/span&gt; runbooks/x.py
lab up &lt;span class="nt"&gt;--ttl&lt;/span&gt; 1 &lt;span class="nt"&gt;--runbook&lt;/span&gt; r.py &lt;span class="nt"&gt;--keep-on-failure&lt;/span&gt;  &lt;span class="c"&gt;# keep the box to debug&lt;/span&gt;
lab ssh                                        &lt;span class="c"&gt;# interactive shell&lt;/span&gt;
lab ssh &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'cloud-init status --wait'&lt;/span&gt;          &lt;span class="c"&gt;# one shot command&lt;/span&gt;
lab logs &lt;span class="nt"&gt;--show&lt;/span&gt;                                &lt;span class="c"&gt;# local runbook log&lt;/span&gt;
lab down                                       &lt;span class="c"&gt;# destroy current lab&lt;/span&gt;
lab down lab-20260821-143000                   &lt;span class="c"&gt;# destroy a specific one&lt;/span&gt;
lab sweep                                      &lt;span class="c"&gt;# destroy everything expired&lt;/span&gt;
lab server-types &lt;span class="nt"&gt;--location&lt;/span&gt; nbg1               &lt;span class="c"&gt;# only what you can actually order&lt;/span&gt;
lab test-s3                                    &lt;span class="c"&gt;# check credentials first&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the one that does not need the tool at all, for when local state is gone and you need the truth:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$HCLOUD_TOKEN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s1"&gt;'https://api.hetzner.cloud/v1/servers?label_selector=lab%3Dtrue'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first thing I pointed this at was a 24.04 box and &lt;code&gt;do-release-upgrade&lt;/code&gt;, which is a test I was never going to run on a machine I cared about. That one is written up in &lt;a href="https://peculiarengineer.com/blog/upgrade-ubuntu-24-04-to-26-04/" rel="noopener noreferrer"&gt;upgrading Ubuntu 24.04 to 26.04&lt;/a&gt;, and two of the three footguns above surfaced while I was building the box for it.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;[ lab down ✓ ]&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://peculiarengineer.com/blog/disposable-hetzner-lab-opentofu/" rel="noopener noreferrer"&gt;peculiarengineer.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>devops</category>
      <category>terraform</category>
      <category>linux</category>
      <category>cloud</category>
    </item>
    <item>
      <title>Backing up a homelab with restic</title>
      <dc:creator>Minor Keith</dc:creator>
      <pubDate>Mon, 24 Aug 2026 13:30:49 +0000</pubDate>
      <link>https://dev.to/peculiarengineer/backing-up-a-homelab-with-restic-ldi</link>
      <guid>https://dev.to/peculiarengineer/backing-up-a-homelab-with-restic-ldi</guid>
      <description>&lt;p&gt;I have written a lot here about running things at home and almost nothing about not losing them, which is a strange gap for someone who keeps telling you to &lt;a href="https://peculiarengineer.com/blog/self-host-forgejo-tailscale-docker-compose/" rel="noopener noreferrer"&gt;self host your own Git server&lt;/a&gt;. So I sat down to work out how backups should actually be done, rather than how I had been vaguely assuming they were done.&lt;/p&gt;

&lt;p&gt;The tool part turned out to be the easy half. The half that surprised me is that the obvious approach, point a backup tool at your data directory and let it run every night, produces a broken restore most of the time if anything in there is a database. I have numbers on that below, and they changed the shape of this post while I was writing it.&lt;/p&gt;

&lt;p&gt;The whole thing is built around one idea. &lt;strong&gt;A backup nobody has restored is a rumor.&lt;/strong&gt; So the restore comes first here, and everything else exists to make that restore possible.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR.&lt;/strong&gt; Use restic, pointed at object storage you already pay for. The repository password is the backup: lose it and the data is gone with no recovery path, so store it somewhere separate from the machine. Never back up live database files. Dump them first, &lt;code&gt;VACUUM INTO&lt;/code&gt; for SQLite and &lt;code&gt;pg_dump&lt;/code&gt; for Postgres, then back up the dumps. Do not use SQLite's &lt;code&gt;.backup&lt;/code&gt; on a busy database: it either fails instantly leaving a zero byte file or spins forever. Run it from a systemd timer, not cron. &lt;code&gt;forget&lt;/code&gt; drops snapshots from the index and reclaims &lt;strong&gt;no space at all&lt;/strong&gt;, &lt;code&gt;prune&lt;/code&gt; is what actually frees it, and you need both. Then test a restore onto a machine that has never seen the data.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Why restic
&lt;/h2&gt;

&lt;p&gt;One static binary, no daemon, no database of its own. It encrypts on your machine before anything leaves it, deduplicates across snapshots so the tenth backup of a 200 GB directory is not 2 TB, and it speaks S3 natively so it can point straight at object storage without a helper.&lt;/p&gt;

&lt;p&gt;The alternatives, briefly, because I did look. &lt;strong&gt;Borg&lt;/strong&gt; is excellent and has been around longer, but it has no native object storage support, so you end up mounting your bucket with rclone and now your backup depends on a FUSE mount behaving. &lt;strong&gt;Kopia&lt;/strong&gt; is genuinely good and slightly nicer to use, just younger with less production mileage. &lt;strong&gt;rsync&lt;/strong&gt; is not a backup, it is a file copy: no history, no integrity checking, and it will happily replicate your corruption over the good copy.&lt;/p&gt;

&lt;p&gt;For a homelab where the destination is a bucket, restic is the boring correct answer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Before you start
&lt;/h2&gt;

&lt;p&gt;Everything here is on Ubuntu 26.04, and everything from here runs as root, which is also how the timer will run it. Drop into a root shell with &lt;code&gt;sudo -i&lt;/code&gt; and the commands below are exactly what you type.&lt;/p&gt;

&lt;p&gt;Install the two tools the post assumes, because neither is on a stock server:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;apt update
apt &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; restic sqlite3
restic version
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;sqlite3&lt;/code&gt; is the command line client, and you need it even if the application that owns the database bundles its own copy. If you are backing up Postgres in a container, &lt;code&gt;pg_dump&lt;/code&gt; runs inside that container so there is nothing extra to install for it.&lt;/p&gt;

&lt;p&gt;Make the directory the dumps land in now, because several commands below write into it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; /srv/dumps
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The password is the backup
&lt;/h2&gt;

&lt;p&gt;Before anything else, because &lt;code&gt;restic init&lt;/code&gt; says it and people scroll past:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Please note that knowledge of your password is required to access
the repository. Losing your password means that your data is
irrecoverably lost.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is not boilerplate. There is no recovery, no support email, no key escrow. Encrypted with a password you do not have is identical to deleted.&lt;/p&gt;

&lt;p&gt;So the password goes somewhere that is not the machine being backed up. A password manager, a piece of paper, another machine. Backing up your password manager's vault into the same restic repository, which I nearly did while setting this up, is a circle with no way in.&lt;/p&gt;

&lt;p&gt;On the box it lives in a file only root can read, so it stays out of your shell history and out of &lt;code&gt;ps&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-m&lt;/span&gt; 600 /dev/null /root/.restic-password
&lt;span class="nb"&gt;read&lt;/span&gt; &lt;span class="nt"&gt;-rs&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="s1"&gt;'passphrase: '&lt;/span&gt; P &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'%s'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$P&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /root/.restic-password &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;unset &lt;/span&gt;P
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;read -rs&lt;/code&gt; rather than putting the passphrase on the command line, because a command line ends up in &lt;code&gt;.bash_history&lt;/code&gt; and in &lt;code&gt;ps&lt;/code&gt; while it runs, which are the two places this file exists to keep it out of.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Create the repository
&lt;/h2&gt;

&lt;p&gt;I am using Hetzner Object Storage because I already have a bucket there. Any S3 compatible storage works the same way.&lt;/p&gt;

&lt;p&gt;Credentials and repository location go in an environment file, root readable only:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-m&lt;/span&gt; 600 /dev/null /etc/restic.env
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# /etc/restic.env&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;AWS_ACCESS_KEY_ID&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"your-access-key"&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;AWS_SECRET_ACCESS_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"your-secret-key"&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;RESTIC_REPOSITORY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"s3:https://nbg1.your-objectstorage.com/your-bucket/homelab"&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;RESTIC_PASSWORD_FILE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"/root/.restic-password"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The repository URL is worth reading carefully. It is &lt;code&gt;s3:&lt;/code&gt; then the &lt;strong&gt;full endpoint URL including https&lt;/strong&gt;, then the bucket, then a prefix. That prefix matters: it lets one bucket hold several unrelated repositories without them colliding.&lt;/p&gt;

&lt;p&gt;Then initialize it once:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-a&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;.&lt;/span&gt; /etc/restic.env&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;set&lt;/span&gt; +a
restic init
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;✅ You should see&lt;/strong&gt; the password warning quoted above, followed by a line naming the repository it made:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;created restic repository 461094b21b at s3:https://nbg1.your-objectstorage.com/your-bucket/homelab
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  2. Decide what to back up
&lt;/h2&gt;

&lt;p&gt;The instinct is to back up everything. Resist it, because a backup you cannot afford to run daily is a backup you will turn off.&lt;/p&gt;

&lt;p&gt;What actually needs to be in there:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Application data.&lt;/strong&gt; Docker volumes and bind mounts, whatever &lt;code&gt;/srv&lt;/code&gt; or &lt;code&gt;/opt&lt;/code&gt; holds for you.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Database dumps&lt;/strong&gt;, which is section 4 and the reason this post exists.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;/etc&lt;/code&gt;.&lt;/strong&gt; Small, and it is every decision you made about this machine. Restoring config from memory at 2 a.m. is how you discover you had forgotten three of them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The list of what was installed.&lt;/strong&gt; &lt;code&gt;dpkg --get-selections&lt;/code&gt; into a file.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What does not need to be in there: package caches, anything under &lt;code&gt;/proc&lt;/code&gt;, &lt;code&gt;/sys&lt;/code&gt; or &lt;code&gt;/tmp&lt;/code&gt;, and the backup repository itself if you keep a local one. Docker &lt;strong&gt;images&lt;/strong&gt; do not need backing up either, because they come back from a registry.&lt;/p&gt;

&lt;p&gt;Docker &lt;strong&gt;volumes&lt;/strong&gt; are a trap here, and worth being precise about. I use bind mounts, so my container data sits under &lt;code&gt;/srv&lt;/code&gt; and gets picked up by the paths above. If you use named volumes instead, which is the more common pattern, that data lives in &lt;code&gt;/var/lib/docker/volumes&lt;/code&gt; and nothing above touches it. Either add that path to the backup or move to bind mounts. Skipping &lt;code&gt;/var/lib/docker&lt;/code&gt; wholesale, which is the usual advice and which I very nearly wrote here, would leave you with a backup containing none of your actual data.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. The part that will quietly break you
&lt;/h2&gt;

&lt;p&gt;Here is the experiment that changed this post.&lt;/p&gt;

&lt;p&gt;I set up a box with two databases being written to continuously: one in WAL mode, one in the older rollback journal mode, which is still SQLite's default. Which mode your applications use is worth checking rather than assuming, with &lt;code&gt;sqlite3 yourapp.db 'PRAGMA journal_mode;'&lt;/code&gt;, because as you will see it changes the odds a lot. Then I backed up their files live with restic, restored them, and asked SQLite whether the restored copy was intact.&lt;/p&gt;

&lt;p&gt;The first run came back clean. &lt;code&gt;integrity_check: ok&lt;/code&gt;. If I had stopped there, this section would have said live backups are fine.&lt;/p&gt;

&lt;p&gt;So I ran it ten more times, and then twice more on different machines with different write patterns. Here is the third run, which is the one I would have least expected:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;run  wal          journal
  1  malformed    malformed
  2  malformed    ok
  3  malformed    malformed
  4  malformed    malformed
  5  malformed    malformed
 ...
 10  malformed    malformed

WAL failures:     10/10
JOURNAL failures: 9/10
TOTAL:            19/20
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;malformed&lt;/code&gt; there is SQLite's own verdict on the restored file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Error: stepping, database disk image is malformed (11)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three runs, three answers:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Run&lt;/th&gt;
&lt;th&gt;Rollback journal&lt;/th&gt;
&lt;th&gt;WAL&lt;/th&gt;
&lt;th&gt;Total&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;First machine, single row inserts&lt;/td&gt;
&lt;td&gt;8 of 10 corrupt&lt;/td&gt;
&lt;td&gt;3 of 10&lt;/td&gt;
&lt;td&gt;12 of 20&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Second machine, moderate load&lt;/td&gt;
&lt;td&gt;9 of 10 corrupt&lt;/td&gt;
&lt;td&gt;0 of 10&lt;/td&gt;
&lt;td&gt;9 of 20&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Third machine, batched transactions&lt;/td&gt;
&lt;td&gt;9 of 10 corrupt&lt;/td&gt;
&lt;td&gt;10 of 10&lt;/td&gt;
&lt;td&gt;19 of 20&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;That spread is the finding, not any one number.&lt;/strong&gt; Rollback journal mode is reliably terrible, around nine times in ten every time I tried. WAL mode ranged from never failing to failing every single time, depending on nothing more than how the writes were shaped. On the run where WAL never broke I could easily have concluded WAL mode is safe, published that, and been wrong.&lt;/p&gt;

&lt;p&gt;So the useful statement is not a percentage. It is that a live file copy of a SQLite database restores to a corrupt file often, unpredictably, and in a way you cannot measure once and trust. It works just enough of the time to convince you.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Postgres behaved differently.&lt;/strong&gt; Five of five live data directory restores came back working, every one crash recovering on startup:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;LOG:  database system was not properly shut down; automatic recovery in progress
LOG:  redo starts at 0/1917A20
LOG:  database system is ready to accept connections
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Which makes sense once you say it out loud: a file level copy of a running Postgres looks exactly like someone pulled the power out, and surviving that is a thing Postgres is specifically built to do. I could not break it.&lt;/p&gt;

&lt;p&gt;I am still going to tell you not to do it, for two reasons that have nothing to do with superstition. Upstream does not sanction copying a running cluster, because a busy one can be captured mid page write in a way a small test database never will be. And the dump of that same database was &lt;strong&gt;19.6 MB against a 225 MB live data directory&lt;/strong&gt;. Eleven times smaller, portable between versions, and blessed by the people who wrote it.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Dump the databases first
&lt;/h2&gt;

&lt;p&gt;The correct approach costs almost nothing, which removes the last excuse.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;SQLite.&lt;/strong&gt; The obvious command is &lt;code&gt;.backup&lt;/code&gt;, SQLite's own online backup, and it is what I recommended here until I tested it properly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;time &lt;/span&gt;sqlite3 /srv/app/data/app.db &lt;span class="s2"&gt;".backup /srv/dumps/app.db"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;real    0m0.089s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Eighty nine milliseconds, passes &lt;code&gt;integrity_check&lt;/code&gt;, lovely. On an &lt;strong&gt;idle&lt;/strong&gt; database.&lt;/p&gt;

&lt;p&gt;Point it at a database that is actually being written to and it stops being lovely, because the SQLite backup API restarts from page zero every time an external writer commits. I hit two different failure modes on two different machines with the same command.&lt;/p&gt;

&lt;p&gt;On one, it gave up instantly and left a file behind:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Error: database is locked
real    0m0.10s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;-rw-r--r-- 1 root root 0 Aug 23 00:02 b1.db
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Zero bytes.&lt;/strong&gt; A script that does not check the exit code backs that up and reports success.&lt;/p&gt;

&lt;p&gt;On the other, it never finished at all. Under systemd with a 45 second cap:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;hang.service: start operation timed out. Terminating.
hang.service: Consumed 44.743s CPU time over 45.016s wall clock time
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Forty five seconds of pegged CPU producing nothing, on a database of a few tens of megabytes. Without that timeout it spins for as long as writes keep arriving, and &lt;code&gt;Type=oneshot&lt;/code&gt; means systemd waits patiently forever while your backups quietly stop happening.&lt;/p&gt;

&lt;p&gt;Use &lt;code&gt;VACUUM INTO&lt;/code&gt; instead. It takes one read transaction and writes a clean copy, without restarting when someone else commits:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;time &lt;/span&gt;sqlite3 /srv/app/data/app.db &lt;span class="s2"&gt;"VACUUM INTO '/srv/dumps/app.db';"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same box, same writer hammering it, same moment:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;real    0m0.41s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;integrity: ok
rows:      60412
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four tenths of a second, and a file that opens. It needs SQLite 3.27 or newer, which means anything from 2019 onwards, so on 26.04 it is simply there. The output file must not already exist, which is why the script in the next section writes to a temporary name and moves it into place.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Postgres.&lt;/strong&gt; Dump in the custom format so you can restore selectively later:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker &lt;span class="nb"&gt;exec &lt;/span&gt;app-pg-1 pg_dump &lt;span class="nt"&gt;-U&lt;/span&gt; postgres &lt;span class="nt"&gt;-d&lt;/span&gt; labdb &lt;span class="nt"&gt;-Fc&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; /tmp/labdb.dump
docker &lt;span class="nb"&gt;cp &lt;/span&gt;app-pg-1:/tmp/labdb.dump /srv/dumps/labdb.dump
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then back up &lt;code&gt;/srv/dumps&lt;/code&gt;, not the live directories.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. The backup script
&lt;/h2&gt;

&lt;p&gt;Everything above, in the order it has to happen: dump first, then back up the dumps.&lt;/p&gt;

&lt;p&gt;Save this as &lt;code&gt;/usr/local/bin/backup.sh&lt;/code&gt; and make it executable, which the next section will fail on if you forget:&lt;/p&gt;

&lt;p&gt;The container name and database name below are mine. Change them, or delete those two lines if you have no Postgres.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/bin/bash&lt;/span&gt;
&lt;span class="c"&gt;# /usr/local/bin/backup.sh&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-uo&lt;/span&gt; pipefail
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-a&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;.&lt;/span&gt; /etc/restic.env&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;set&lt;/span&gt; +a

&lt;span class="nb"&gt;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; /srv/dumps
&lt;span class="nv"&gt;failed&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;0

&lt;span class="c"&gt;# Databases first, while everything is still running.&lt;/span&gt;
&lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; /srv/dumps/app.db.tmp
sqlite3 /srv/app/data/app.db &lt;span class="s2"&gt;"VACUUM INTO '/srv/dumps/app.db.tmp';"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;mv&lt;/span&gt; /srv/dumps/app.db.tmp /srv/dumps/app.db &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nv"&gt;failed&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1

docker &lt;span class="nb"&gt;exec &lt;/span&gt;app-pg-1 pg_dump &lt;span class="nt"&gt;-U&lt;/span&gt; postgres &lt;span class="nt"&gt;-d&lt;/span&gt; labdb &lt;span class="nt"&gt;-Fc&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; /tmp/labdb.dump &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; docker &lt;span class="nb"&gt;cp &lt;/span&gt;app-pg-1:/tmp/labdb.dump /srv/dumps/labdb.dump &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nv"&gt;failed&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1

&lt;span class="c"&gt;# What was installed, so a rebuild is not archaeology.&lt;/span&gt;
dpkg &lt;span class="nt"&gt;--get-selections&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /srv/dumps/packages.txt

restic backup /srv/dumps /srv/app /etc &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--exclude&lt;/span&gt; /srv/app/data &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--tag&lt;/span&gt; nightly &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nv"&gt;failed&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1

restic forget &lt;span class="nt"&gt;--keep-daily&lt;/span&gt; 7 &lt;span class="nt"&gt;--keep-weekly&lt;/span&gt; 4 &lt;span class="nt"&gt;--keep-monthly&lt;/span&gt; 6 &lt;span class="nt"&gt;--prune&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nv"&gt;failed&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1

&lt;span class="nb"&gt;exit&lt;/span&gt; &lt;span class="nv"&gt;$failed&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two decisions in there are deliberate and both came from watching this fail.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No &lt;code&gt;-e&lt;/code&gt;.&lt;/strong&gt; The obvious version starts &lt;code&gt;set -euo pipefail&lt;/code&gt;, and I wrote it that way first. The problem is that &lt;code&gt;set -e&lt;/code&gt; makes a single failed dump abort the entire script, so a stopped Postgres container means restic never runs and &lt;strong&gt;nothing gets backed up that night&lt;/strong&gt;, including &lt;code&gt;/etc&lt;/code&gt; and every file that had nothing to do with Postgres. The version above records the failure, backs up everything it still can, and exits non zero so the timer reports it. A partial backup beats no backup.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The dump goes to a temporary name and gets moved into place.&lt;/strong&gt; If the dump dies halfway, &lt;code&gt;mv&lt;/code&gt; never happens and yesterday's good copy stays where it is, rather than being replaced by a truncated file that restic will faithfully preserve forever. The &lt;code&gt;rm -f&lt;/code&gt; before it matters too: &lt;code&gt;VACUUM INTO&lt;/code&gt; refuses to write to a file that already exists, so a run killed between the vacuum and the move would otherwise fail every night afterwards until somebody deleted the leftover by hand.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Both restic calls are checked.&lt;/strong&gt; This is the one I nearly shipped without. Wrapping only the dumps means a wrong password, an unreachable bucket, or a repository locked by a prune leaves &lt;code&gt;failed&lt;/code&gt; at zero, the unit green, and no backup taken. Restic's exit codes are specific enough to rely on:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;clean run                     exit 0
some files could not be read  exit 0   (snapshot still created)
wrong password                exit 12
repository does not exist     exit 10
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A file vanishing mid backup, which happens constantly with sockets and pid files, does not fail the run. A broken destination does.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;chmod&lt;/span&gt; +x /usr/local/bin/backup.sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;--exclude /srv/app/data&lt;/code&gt; is the point of section 3: the live database directory is deliberately not in the backup, because the good copy is already in &lt;code&gt;/srv/dumps&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Run it from a systemd timer
&lt;/h2&gt;

&lt;p&gt;Cron works. A timer is better here because it gives you &lt;code&gt;systemctl status&lt;/code&gt; on the last run, journal output kept with the unit, and &lt;code&gt;Persistent=true&lt;/code&gt;, which runs a missed job after a machine was off rather than skipping the night.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="c"&gt;# /etc/systemd/system/backup.service
&lt;/span&gt;&lt;span class="nn"&gt;[Unit]&lt;/span&gt;
&lt;span class="py"&gt;Description&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;Nightly restic backup&lt;/span&gt;
&lt;span class="py"&gt;Wants&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;network-online.target&lt;/span&gt;
&lt;span class="py"&gt;After&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;network-online.target docker.service&lt;/span&gt;

&lt;span class="nn"&gt;[Service]&lt;/span&gt;
&lt;span class="py"&gt;Type&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;oneshot&lt;/span&gt;
&lt;span class="py"&gt;TimeoutStartSec&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;30min&lt;/span&gt;
&lt;span class="py"&gt;Environment&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;HOME=/root&lt;/span&gt;
&lt;span class="py"&gt;ExecStart&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;/usr/local/bin/backup.sh&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="c"&gt;# /etc/systemd/system/backup.timer
&lt;/span&gt;&lt;span class="nn"&gt;[Unit]&lt;/span&gt;
&lt;span class="py"&gt;Description&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;Run the restic backup nightly&lt;/span&gt;

&lt;span class="nn"&gt;[Timer]&lt;/span&gt;
&lt;span class="py"&gt;OnCalendar&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;*-*-* 03:30:00&lt;/span&gt;
&lt;span class="py"&gt;RandomizedDelaySec&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;30m&lt;/span&gt;
&lt;span class="py"&gt;Persistent&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;true&lt;/span&gt;

&lt;span class="nn"&gt;[Install]&lt;/span&gt;
&lt;span class="py"&gt;WantedBy&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;timers.target&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;systemctl daemon-reload
systemctl &lt;span class="nb"&gt;enable&lt;/span&gt; &lt;span class="nt"&gt;--now&lt;/span&gt; backup.timer
systemctl list-timers backup.timer
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;RandomizedDelaySec&lt;/code&gt; matters more than it looks. Everyone picks a time on the hour, and object storage providers notice.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;TimeoutStartSec&lt;/code&gt; is the backstop for section 4. A &lt;code&gt;oneshot&lt;/code&gt; service with no timeout will sit in &lt;code&gt;activating&lt;/code&gt; indefinitely if something inside it wedges, and you find out weeks later. With it set, systemd kills the run and marks the unit &lt;code&gt;failed&lt;/code&gt;, which is a state you can actually alert on.&lt;/p&gt;

&lt;p&gt;Run it once by hand before trusting the schedule:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;systemctl start backup.service
journalctl &lt;span class="nt"&gt;-u&lt;/span&gt; backup.service &lt;span class="nt"&gt;-n&lt;/span&gt; 30 &lt;span class="nt"&gt;--no-pager&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  7. Retention, and why forget is not enough
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;restic forget&lt;/code&gt; removes snapshots from the index. &lt;code&gt;restic prune&lt;/code&gt; removes the data those snapshots referenced. They are separate operations, and running only the first is a very popular way to have a retention policy that does nothing.&lt;/p&gt;

&lt;p&gt;Watch what each one does. &lt;code&gt;restic stats --mode raw-data&lt;/code&gt; reports the size of the repository itself, which works against object storage where &lt;code&gt;du&lt;/code&gt; does not:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;restic snapshots &lt;span class="nt"&gt;--compact&lt;/span&gt; | &lt;span class="nb"&gt;tail&lt;/span&gt; &lt;span class="nt"&gt;-1&lt;/span&gt;     &lt;span class="c"&gt;# 16 snapshots&lt;/span&gt;
restic stats &lt;span class="nt"&gt;--mode&lt;/span&gt; raw-data             &lt;span class="c"&gt;# 98 MiB&lt;/span&gt;

restic forget &lt;span class="nt"&gt;--keep-last&lt;/span&gt; 3
restic snapshots &lt;span class="nt"&gt;--compact&lt;/span&gt; | &lt;span class="nb"&gt;tail&lt;/span&gt; &lt;span class="nt"&gt;-1&lt;/span&gt;     &lt;span class="c"&gt;# 3 snapshots&lt;/span&gt;
restic stats &lt;span class="nt"&gt;--mode&lt;/span&gt; raw-data             &lt;span class="c"&gt;# 98 MiB&lt;/span&gt;

restic prune
restic stats &lt;span class="nt"&gt;--mode&lt;/span&gt; raw-data             &lt;span class="c"&gt;# 26 MiB&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Sixteen snapshots became three and &lt;strong&gt;not one byte was freed&lt;/strong&gt;. The space came back only after &lt;code&gt;prune&lt;/code&gt;. So either pass &lt;code&gt;--prune&lt;/code&gt; to &lt;code&gt;forget&lt;/code&gt;, as the script above does, or run prune on its own schedule.&lt;/p&gt;

&lt;p&gt;One thing that confused me the first time: &lt;code&gt;--keep-last 3&lt;/code&gt; keeps three snapshots &lt;em&gt;per group&lt;/em&gt;, and restic groups by host and paths by default. Back up two different path sets and you keep three of each, so the count you land on may be higher than the number you typed.&lt;/p&gt;

&lt;h2&gt;
  
  
  8. The restore, which is the whole point
&lt;/h2&gt;

&lt;p&gt;Everything so far is preparation. This is the only part that proves anything, and it is the part nobody does.&lt;/p&gt;

&lt;p&gt;Do it on a machine that has never seen this data, with nothing but the repository URL and the password. That is the actual disaster scenario: the original box is gone.&lt;/p&gt;

&lt;p&gt;That machine needs &lt;code&gt;restic&lt;/code&gt; installed, plus &lt;code&gt;sqlite3&lt;/code&gt; and Docker if you want to check the dumps the way I do below. A &lt;a href="https://peculiarengineer.com/blog/disposable-hetzner-lab-opentofu/" rel="noopener noreferrer"&gt;throwaway cloud box&lt;/a&gt; is ideal, because you want somewhere with genuinely none of your data on it, and you want to destroy it afterwards.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;AWS_ACCESS_KEY_ID&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"..."&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;AWS_SECRET_ACCESS_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"..."&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;RESTIC_REPOSITORY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"s3:https://nbg1.your-objectstorage.com/your-bucket/homelab"&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;RESTIC_PASSWORD&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"..."&lt;/span&gt;

restic snapshots
restic restore latest &lt;span class="nt"&gt;--target&lt;/span&gt; /bare &lt;span class="nt"&gt;--include&lt;/span&gt; /srv/dumps
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then check what came back, rather than admiring the file listing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;sqlite3 /bare/srv/dumps/app.db &lt;span class="s1"&gt;'PRAGMA integrity_check;'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ok
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and put the Postgres dump into a fresh server to prove it loads:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker run &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;--name&lt;/span&gt; pgr &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="nv"&gt;POSTGRES_PASSWORD&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;x postgres:17

&lt;span class="c"&gt;# Wait for it. `docker run -d` returns long before Postgres accepts connections,&lt;/span&gt;
&lt;span class="c"&gt;# and running createdb immediately gives you a confusing socket error.&lt;/span&gt;
&lt;span class="k"&gt;until &lt;/span&gt;docker &lt;span class="nb"&gt;exec &lt;/span&gt;pgr pg_isready &lt;span class="nt"&gt;-U&lt;/span&gt; postgres &lt;span class="nt"&gt;-q&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do &lt;/span&gt;&lt;span class="nb"&gt;sleep &lt;/span&gt;1&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;done

&lt;/span&gt;docker &lt;span class="nb"&gt;cp&lt;/span&gt; /bare/srv/dumps/labdb.dump pgr:/tmp/d.dump
docker &lt;span class="nb"&gt;exec &lt;/span&gt;pgr createdb &lt;span class="nt"&gt;-U&lt;/span&gt; postgres restored
docker &lt;span class="nb"&gt;exec &lt;/span&gt;pgr pg_restore &lt;span class="nt"&gt;-U&lt;/span&gt; postgres &lt;span class="nt"&gt;-d&lt;/span&gt; restored /tmp/d.dump
docker &lt;span class="nb"&gt;exec &lt;/span&gt;pgr psql &lt;span class="nt"&gt;-U&lt;/span&gt; postgres &lt;span class="nt"&gt;-d&lt;/span&gt; restored &lt;span class="nt"&gt;-tAc&lt;/span&gt; &lt;span class="s1"&gt;'select count(*) from t;'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; 1124000
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Your number will differ, obviously. That it is a number at all is the entire point of the post. Not that the backup ran, that the data came back and could be queried on a machine that had never seen it.&lt;/p&gt;

&lt;p&gt;Pulling a single file out of an older snapshot is the other restore worth practicing, because it is the one you will actually use:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;restic snapshots                     &lt;span class="c"&gt;# copy an ID from the first column&lt;/span&gt;
restic restore &amp;lt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nt"&gt;--target&lt;/span&gt; /tmp/oops &lt;span class="nt"&gt;--include&lt;/span&gt; /etc/fstab
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And confirm the repository itself is readable, which is a different question from whether the last backup succeeded:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;restic check
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;check snapshots, trees and blobs
no errors were found
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Put &lt;code&gt;restic check&lt;/code&gt; on a monthly timer. A repository that cannot be read is not a backup either, and you want to find that out on a Tuesday rather than during a rebuild.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gotchas I hit
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;A single successful restore proves nothing.&lt;/strong&gt; My first live database restore came back clean. Across three machines the same test then failed between nine and nineteen times out of twenty. Test more than once, and test the thing you actually care about.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;forget&lt;/code&gt; frees no space.&lt;/strong&gt; Covered above, and worth repeating because a retention policy that never prunes looks like it is working right up until the storage bill.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Two backups at once do not collide.&lt;/strong&gt; I expected a repository lock error and got two clean snapshots instead, because backups take a non exclusive lock. &lt;code&gt;prune&lt;/code&gt; is the one that takes an exclusive lock, so the collision you can actually hit is a manual backup landing while the timer is pruning.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;--unsafe-allow-remove-all&lt;/code&gt; refuses to run bare.&lt;/strong&gt; Trying to empty a repository gives you &lt;code&gt;Fatal: --unsafe-allow-remove-all is not allowed unless a snapshot filter option is specified&lt;/code&gt;, so it needs something like &lt;code&gt;--host&lt;/code&gt; or &lt;code&gt;--tag&lt;/code&gt; alongside it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Restic reports a warning and still exits zero&lt;/strong&gt; when a file vanishes mid backup, which happens constantly with sockets and pid files. &lt;code&gt;at least one source file could not be read&lt;/code&gt; is worth reading, not worth alerting on.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Under systemd, restic complains about a cache it cannot find.&lt;/strong&gt; &lt;code&gt;unable to open cache: unable to locate cache directory: neither $XDG_CACHE_HOME nor $HOME are defined&lt;/code&gt;, on every run. It is harmless, it just means no local cache and so slower metadata operations. Set &lt;code&gt;Environment=HOME=/root&lt;/code&gt; in the service unit to quiet it and get the cache back.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;backup.service&lt;/code&gt; has no &lt;code&gt;[Install]&lt;/code&gt; section, on purpose.&lt;/strong&gt; It is started by the timer, so &lt;code&gt;systemctl enable backup.service&lt;/code&gt; is not a thing you want. Enable &lt;code&gt;backup.timer&lt;/code&gt; instead. &lt;code&gt;systemctl status backup.service&lt;/code&gt; still works for reading the last run.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;That shrinkage on the first snapshot is compression, not dedup.&lt;/strong&gt; 2.7 MiB of files stored as 874 KiB, with no previous snapshot to deduplicate against. Restic creates version 2 repositories now and compresses by default. Dedup is real too, it just needs a second snapshot before it has anything to work with.&lt;/p&gt;

&lt;h2&gt;
  
  
  Quick reference
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-a&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;.&lt;/span&gt; /etc/restic.env&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;set&lt;/span&gt; +a      &lt;span class="c"&gt;# load credentials into the shell&lt;/span&gt;

restic init                            &lt;span class="c"&gt;# once, at the start&lt;/span&gt;
restic backup /srv/dumps /etc &lt;span class="nt"&gt;--tag&lt;/span&gt; nightly
restic snapshots                       &lt;span class="c"&gt;# what have I got&lt;/span&gt;
restic snapshots &lt;span class="nt"&gt;--compact&lt;/span&gt;             &lt;span class="c"&gt;# the same, readable&lt;/span&gt;

restic restore latest &lt;span class="nt"&gt;--target&lt;/span&gt; /bare                        &lt;span class="c"&gt;# everything&lt;/span&gt;
restic restore &amp;lt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nt"&gt;--target&lt;/span&gt; /tmp/x &lt;span class="nt"&gt;--include&lt;/span&gt; /etc/hosts    &lt;span class="c"&gt;# one file&lt;/span&gt;
restic check                                                &lt;span class="c"&gt;# is the repo sound&lt;/span&gt;

restic forget &lt;span class="nt"&gt;--keep-daily&lt;/span&gt; 7 &lt;span class="nt"&gt;--keep-weekly&lt;/span&gt; 4 &lt;span class="nt"&gt;--keep-monthly&lt;/span&gt; 6 &lt;span class="nt"&gt;--prune&lt;/span&gt;

&lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; /srv/dumps/app.db.tmp                                 &lt;span class="c"&gt;# VACUUM INTO wants a free path&lt;/span&gt;
sqlite3 live.db &lt;span class="s2"&gt;"VACUUM INTO '/srv/dumps/app.db.tmp';"&lt;/span&gt;      &lt;span class="c"&gt;# not cp, and not .backup&lt;/span&gt;
docker &lt;span class="nb"&gt;exec &lt;/span&gt;pg pg_dump &lt;span class="nt"&gt;-U&lt;/span&gt; postgres &lt;span class="nt"&gt;-d&lt;/span&gt; mydb &lt;span class="nt"&gt;-Fc&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; /tmp/d.dump &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; docker &lt;span class="nb"&gt;cp &lt;/span&gt;pg:/tmp/d.dump /srv/dumps/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I went into this assuming the hard part was picking a tool. The hard part was finding out that the obvious way to back up a database works often enough to fool you. Go and restore something.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;[ restored ✓ ]&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://peculiarengineer.com/blog/backup-homelab-restic-object-storage/" rel="noopener noreferrer"&gt;peculiarengineer.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>linux</category>
      <category>devops</category>
      <category>database</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Running Kimi Code on Claude Code</title>
      <dc:creator>Minor Keith</dc:creator>
      <pubDate>Thu, 13 Aug 2026 20:40:23 +0000</pubDate>
      <link>https://dev.to/peculiarengineer/running-kimi-code-on-claude-code-4gb0</link>
      <guid>https://dev.to/peculiarengineer/running-kimi-code-on-claude-code-4gb0</guid>
      <description>&lt;p&gt;I pay for Claude and I pay for Kimi Code, and I want both available from the same terminal without choosing one in the morning. Kimi publishes a guide for pointing Claude Code at their endpoint. It tells you to export a handful of environment variables in your shell profile, which works, and which also routes every Claude Code session on the machine to Kimi from then on. Your Anthropic subscription sits there unused while you wonder why Opus got worse.&lt;/p&gt;

&lt;p&gt;The fix is boring: a second settings file and a shell function that loads it. Ten minutes. The part worth writing down is that the headline model ID in Kimi's guide is rejected by Kimi's own endpoint, and it's rejected with a &lt;code&gt;401&lt;/code&gt; that says your credentials are bad. I lost time auditing a key that was never the problem. Then I found a second version of the same lie living in &lt;code&gt;~/.claude.json&lt;/code&gt;, and that one is better disguised.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR.&lt;/strong&gt; Put the Kimi config in &lt;code&gt;~/.claude/kimi-settings.json&lt;/code&gt; rather than &lt;code&gt;~/.claude/settings.json&lt;/code&gt;, then add a &lt;code&gt;claude-kimi()&lt;/code&gt; function that runs &lt;code&gt;claude --settings "$HOME/.claude/kimi-settings.json" "$@"&lt;/code&gt;. Base URL is &lt;code&gt;https://api.kimi.com/coding/&lt;/code&gt; with the key in &lt;code&gt;ANTHROPIC_API_KEY&lt;/code&gt;. The model is &lt;code&gt;k3&lt;/code&gt;, not &lt;code&gt;k3[1m]&lt;/code&gt;, whatever the docs say. Set &lt;code&gt;CLAUDE_CODE_MAX_CONTEXT_TOKENS&lt;/code&gt; to &lt;code&gt;1048576&lt;/code&gt; because a bare &lt;code&gt;k3&lt;/code&gt; gives Claude Code no window to infer. If interactive mode 401s while &lt;code&gt;curl&lt;/code&gt; and &lt;code&gt;claude -p&lt;/code&gt; both work, your key is in the rejected list in &lt;code&gt;~/.claude.json&lt;/code&gt; and no amount of key rotation will fix it.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Kimi has two API surfaces and only one of them is yours
&lt;/h2&gt;

&lt;p&gt;This is the distinction that decides whether anything else in this post works, so get it straight first.&lt;/p&gt;

&lt;p&gt;A &lt;strong&gt;Kimi Code subscription&lt;/strong&gt; authenticates against &lt;code&gt;https://api.kimi.com/coding/&lt;/code&gt;, using &lt;code&gt;ANTHROPIC_API_KEY&lt;/code&gt;, which is sent as an &lt;code&gt;x-api-key&lt;/code&gt; header. Model IDs are short: &lt;code&gt;k3&lt;/code&gt;, &lt;code&gt;k3-256k&lt;/code&gt;, &lt;code&gt;kimi-for-coding&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;Moonshot platform&lt;/strong&gt; is a different thing. It lives at &lt;code&gt;https://api.moonshot.ai/anthropic&lt;/code&gt;, uses &lt;code&gt;ANTHROPIC_AUTH_TOKEN&lt;/code&gt; as a bearer token, and wants model IDs with a &lt;code&gt;kimi-&lt;/code&gt; prefix. That is the surface you pay for by the token. It's also what most of the search results describe.&lt;/p&gt;

&lt;p&gt;They are different products. Your subscription key will not authenticate against the platform host, and the failure is a flat &lt;code&gt;401&lt;/code&gt; that tells you nothing about why. If you follow the popular guide with a subscription key, this is where you stop.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Get a key
&lt;/h2&gt;

&lt;p&gt;Go to &lt;a href="https://www.kimi.com/code/console" rel="noopener noreferrer"&gt;kimi.com/code/console&lt;/a&gt; and create one.&lt;/p&gt;

&lt;p&gt;Two things worth knowing before you click. You get at most five keys, and each is shown exactly once, at creation. There is no reveal button later, only delete and make a new one. Copy it somewhere before you close the dialog.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Write the settings file
&lt;/h2&gt;

&lt;p&gt;Create it empty and lock it down before the key goes anywhere near it, so it's never briefly readable by anything else on the machine:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;touch&lt;/span&gt; ~/.claude/kimi-settings.json
&lt;span class="nb"&gt;chmod &lt;/span&gt;600 ~/.claude/kimi-settings.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then paste this in, replacing the placeholder:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"env"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"ANTHROPIC_BASE_URL"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://api.kimi.com/coding/"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"ANTHROPIC_API_KEY"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"PASTE_YOUR_KIMI_CODE_KEY_HERE"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;

    &lt;/span&gt;&lt;span class="nl"&gt;"ANTHROPIC_MODEL"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"k3"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"ANTHROPIC_DEFAULT_OPUS_MODEL"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"k3"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"ANTHROPIC_DEFAULT_SONNET_MODEL"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"k3"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"ANTHROPIC_DEFAULT_FABLE_MODEL"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"k3"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"ANTHROPIC_DEFAULT_HAIKU_MODEL"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"kimi-for-coding"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"CLAUDE_CODE_SUBAGENT_MODEL"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"k3"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;

    &lt;/span&gt;&lt;span class="nl"&gt;"ANTHROPIC_DEFAULT_OPUS_MODEL_NAME"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Kimi K3"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"ANTHROPIC_DEFAULT_OPUS_MODEL_DESCRIPTION"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Kimi Code K3 (1M context)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Kimi K2.7 Code"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;

    &lt;/span&gt;&lt;span class="nl"&gt;"CLAUDE_CODE_MAX_CONTEXT_TOKENS"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"1048576"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"CLAUDE_CODE_AUTO_COMPACT_WINDOW"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"950000"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"CLAUDE_CODE_EFFORT_LEVEL"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"high"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Check the permissions again after you save. Some editors write a temp file and rename it into place, which lands at your umask and quietly undoes the &lt;code&gt;chmod&lt;/code&gt; you just ran:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;ls&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt; ~/.claude/kimi-settings.json   &lt;span class="c"&gt;# want -rw-------&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There are six model slots, not one, and that's deliberate. Claude Code uses the haiku slot for background chores like naming your conversation, and pointing those at K3 spends your quota on titles. &lt;code&gt;kimi-for-coding&lt;/code&gt; is ungated on every tier, so it's the safe floor for throwaway work.&lt;/p&gt;

&lt;p&gt;The two window numbers are the part people get wrong. &lt;code&gt;CLAUDE_CODE_AUTO_COMPACT_WINDOW&lt;/code&gt; is a trigger threshold, not a statement of how much context you have, so it needs headroom underneath the real window. Kimi's guide sets it to &lt;code&gt;1048576&lt;/code&gt;, which is out of range anyway: Claude Code accepts &lt;code&gt;100000&lt;/code&gt; to &lt;code&gt;1000000&lt;/code&gt;. It also has to be a plain integer. Write &lt;code&gt;500k&lt;/code&gt; and it parses as &lt;code&gt;500&lt;/code&gt; and clamps to the floor, and you'll be wondering why compaction fires roughly immediately.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. The model ID in the official guide does not work
&lt;/h2&gt;

&lt;p&gt;Kimi's own Claude Code guide tells you to set &lt;code&gt;ANTHROPIC_MODEL&lt;/code&gt; to &lt;code&gt;k3[1m]&lt;/code&gt;. Their endpoint rejects it. I sent each ID at &lt;code&gt;api.kimi.com/coding/&lt;/code&gt; with curl, and here's what came back:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Model ID&lt;/th&gt;
&lt;th&gt;HTTP&lt;/th&gt;
&lt;th&gt;What comes back&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;k3&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;200&lt;/td&gt;
&lt;td&gt;Works. Main conversation model&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;k3-256k&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;200&lt;/td&gt;
&lt;td&gt;Works. Smaller window variant&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;kimi-for-coding&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;200&lt;/td&gt;
&lt;td&gt;Works. Ungated on all tiers&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;k3[1m]&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;401&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Your model id does not exist, recognized as other:k3[1m]. Please set model id as k3.&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Read that &lt;code&gt;401&lt;/code&gt; again. It's an &lt;code&gt;authentication_error&lt;/code&gt;, and the message body is about a model ID. Nothing in the status code suggests you should go looking at your model configuration, so you go and audit your key instead, and your key is fine. It cost me more time than anything else in this setup.&lt;/p&gt;

&lt;p&gt;Use the bare &lt;code&gt;k3&lt;/code&gt;. It is natively a 1M context model and the suffix was never what unlocked that.&lt;/p&gt;

&lt;p&gt;There is a consequence though, and it's why &lt;code&gt;CLAUDE_CODE_MAX_CONTEXT_TOKENS&lt;/code&gt; is in the config above rather than being decorative. With a &lt;code&gt;[1m]&lt;/code&gt; style ID, Claude Code can read the window size out of the name. A bare &lt;code&gt;k3&lt;/code&gt; tells it nothing, so it falls back to a conservative guess and starts compacting your session far earlier than it needs to. Declaring the real number fixes that.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If you're on &lt;code&gt;k3-256k&lt;/code&gt; instead&lt;/strong&gt;, change &lt;code&gt;ANTHROPIC_MODEL&lt;/code&gt;, &lt;code&gt;ANTHROPIC_DEFAULT_OPUS_MODEL&lt;/code&gt;, &lt;code&gt;ANTHROPIC_DEFAULT_SONNET_MODEL&lt;/code&gt;, &lt;code&gt;ANTHROPIC_DEFAULT_FABLE_MODEL&lt;/code&gt; and &lt;code&gt;CLAUDE_CODE_SUBAGENT_MODEL&lt;/code&gt; to &lt;code&gt;k3-256k&lt;/code&gt;, leave the haiku slot alone, and set &lt;code&gt;CLAUDE_CODE_MAX_CONTEXT_TOKENS&lt;/code&gt; to &lt;code&gt;262144&lt;/code&gt; with the compact window at &lt;code&gt;240000&lt;/code&gt;. I'm listing all five by name on purpose. Forget &lt;code&gt;CLAUDE_CODE_SUBAGENT_MODEL&lt;/code&gt; and your subagents keep running on the 1M model while you believe you moved everything down.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Add the launcher
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; ~/.zshrc &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;'

# Claude Code against the Kimi Code subscription.
# Plain `claude` is unaffected and keeps the claude.ai subscription.
claude-kimi() {
  command claude --settings "&lt;/span&gt;&lt;span class="nv"&gt;$HOME&lt;/span&gt;&lt;span class="sh"&gt;/.claude/kimi-settings.json" "&lt;/span&gt;&lt;span class="nv"&gt;$@&lt;/span&gt;&lt;span class="sh"&gt;"
}
&lt;/span&gt;&lt;span class="no"&gt;EOF

&lt;/span&gt;&lt;span class="nb"&gt;exec &lt;/span&gt;zsh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Use &lt;code&gt;$HOME&lt;/code&gt; rather than &lt;code&gt;~&lt;/code&gt; so the path resolves however the function gets called. The &lt;code&gt;--settings&lt;/code&gt; flag layers that file on top of your normal user config, and because &lt;code&gt;~/.claude/settings.json&lt;/code&gt; has no &lt;code&gt;env&lt;/code&gt; block on my machine, there's nothing for it to collide with. Check yours before you assume the same:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python3 &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"import json,os;print('env' in json.load(open(os.path.expanduser('~/.claude/settings.json'))))"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If that prints &lt;code&gt;True&lt;/code&gt;, whatever is in there will fight with the Kimi config, and you should move it out first.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Test the endpoint before you test Claude Code
&lt;/h2&gt;

&lt;p&gt;Confirm the key, host and model at the HTTP layer while there's only one thing that can be wrong. Then any failure in the next step is a Claude Code problem, and you already know it isn't credentials.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;read&lt;/span&gt; &lt;span class="nt"&gt;-rs&lt;/span&gt; &lt;span class="s2"&gt;"KEY?Kimi Code key: "&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;echo

&lt;/span&gt;curl &lt;span class="nt"&gt;-sS&lt;/span&gt; https://api.kimi.com/coding/v1/messages &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"x-api-key: &lt;/span&gt;&lt;span class="nv"&gt;$KEY&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"anthropic-version: 2023-06-01"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"content-type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"model":"k3","max_tokens":16,
       "messages":[{"role":"user","content":"Say OK."}]}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You want JSON with a &lt;code&gt;content&lt;/code&gt; array in it. &lt;code&gt;read -rs&lt;/code&gt; keeps the key out of your shell history, and note the header is &lt;code&gt;x-api-key&lt;/code&gt; rather than a bearer token, which is the practical tell that you're on the subscription surface and not the platform one. Run &lt;code&gt;unset KEY&lt;/code&gt; when you're finished with it.&lt;/p&gt;

&lt;p&gt;Two notes on this. If you get a &lt;code&gt;404&lt;/code&gt; on everything, your base URL includes the full path and Claude Code is appending &lt;code&gt;v1/messages&lt;/code&gt; to something that already ends in it. Set the base, keep the trailing slash, nothing more.&lt;/p&gt;

&lt;p&gt;And &lt;code&gt;k3&lt;/code&gt; needs the Moderato tier or above. If it comes back with a &lt;code&gt;401&lt;/code&gt; naming the model and linking an upgrade page, that's tier gating rather than a bad ID, and you want every slot on &lt;code&gt;kimi-for-coding&lt;/code&gt; instead. Both failures arrive as &lt;code&gt;authentication_error&lt;/code&gt;, so the status code can't tell them apart and the message body is the only thing that can.&lt;/p&gt;

&lt;p&gt;A curl probe here is fine, by the way. Kimi's terms require tools to identify themselves honestly, and curl does. Forging a &lt;code&gt;User-Agent&lt;/code&gt; to make one client look like another is the thing they take issue with.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Confirm both lanes
&lt;/h2&gt;

&lt;p&gt;In one tab:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;claude-kimi
&lt;span class="c"&gt;# then, inside the session:&lt;/span&gt;
/status
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;/status&lt;/code&gt; should show &lt;code&gt;https://api.kimi.com/coding/&lt;/code&gt; and a Kimi model. Send it a real message so you've exercised the round trip rather than just the startup path.&lt;/p&gt;

&lt;p&gt;Then open a &lt;strong&gt;separate&lt;/strong&gt; tab and run plain &lt;code&gt;claude&lt;/code&gt;, and check &lt;code&gt;/status&lt;/code&gt; there too. This is the whole point of the exercise, so actually do it. Verifying that the isolation held is more important after a Claude Code upgrade than it is today: model variable precedence changed in v2.1.195, and the next release that touches settings layering is not going to send you a note.&lt;/p&gt;

&lt;p&gt;Expect one prompt on the first interactive &lt;code&gt;claude-kimi&lt;/code&gt;, asking whether to use the detected API key. &lt;strong&gt;Say yes.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The rejected key list, which is the worst one
&lt;/h2&gt;

&lt;p&gt;Answer no to that prompt, deliberately or with a stray keypress on a dialog you weren't expecting, and Claude Code writes the key's last 20 characters into a rejected list in &lt;code&gt;~/.claude.json&lt;/code&gt;. From then on, interactive sessions refuse it and tell you this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Please run /login · API Error: 401
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here is why this one is genuinely nasty. None of the obvious diagnostics consult that list, so every one of them clears the key. I had &lt;code&gt;curl&lt;/code&gt; returning 200, &lt;code&gt;claude -p "hello"&lt;/code&gt; returning 200, a config file that was correct, an endpoint that was up, and a key that was valid. Only interactive mode failed, and it failed with a message pointing at my credentials.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do not run &lt;code&gt;/login&lt;/code&gt; here.&lt;/strong&gt; The error asks you to, and it's wrong, and running it inside the Kimi lane is the one action in this whole setup that can disturb the Anthropic credential you were trying to protect.&lt;/p&gt;

&lt;p&gt;Check for it instead:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python3 &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"
import json,os
d=json.load(open(os.path.expanduser('~/.claude.json')))
print(json.dumps(d.get('customApiKeyResponses',{}),indent=2))"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If your key's tail is sitting under &lt;code&gt;rejected&lt;/code&gt;, move it across:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python3 - &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="no"&gt;PY&lt;/span&gt;&lt;span class="sh"&gt;'
import json, os, shutil
p = os.path.expanduser('~/.claude.json')
shutil.copy(p, p + '.bak-apikey')
d = json.load(open(p))
key = json.load(open(os.path.expanduser(
    '~/.claude/kimi-settings.json')))['env']['ANTHROPIC_API_KEY']
tail = key[-20:]
r = d.setdefault('customApiKeyResponses', {'approved': [], 'rejected': []})
r['rejected'] = [x for x in r.get('rejected', []) if x != tail]
if tail not in r.setdefault('approved', []):
    r['approved'].append(tail)
json.dump(d, open(p, 'w'), indent=2)
print('approved:', r['approved'])
&lt;/span&gt;&lt;span class="no"&gt;PY
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Close every Claude Code session before you run that, since &lt;code&gt;~/.claude.json&lt;/code&gt; is live state and you don't want two writers. Then start fresh, because the list is read at launch. If the prompt appears again, accept it this time.&lt;/p&gt;

&lt;p&gt;While we're on things read at launch: settings are too. Editing &lt;code&gt;kimi-settings.json&lt;/code&gt; does nothing to a session that's already open, and a session started before your last edit will keep sending the old model ID and keep 401ing on every turn, with an error that once again blames the key. To find stale ones:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ps &lt;span class="nt"&gt;-eo&lt;/span&gt; pid,lstart,command | &lt;span class="nb"&gt;grep &lt;/span&gt;kimi-settings
&lt;span class="nb"&gt;stat&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="s1"&gt;'%Sm'&lt;/span&gt; ~/.claude/kimi-settings.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Compare the two and kill anything older than the file.&lt;/p&gt;

&lt;h2&gt;
  
  
  Living with the request quota
&lt;/h2&gt;

&lt;p&gt;This is the part that changes how you work, and it isn't the setup. Your plan is metered in &lt;strong&gt;requests per rolling five hour window&lt;/strong&gt;, somewhere around 300 to 1,200 depending on tier, with up to 30 running at once. Tokens are not the constraint. Round trips are.&lt;/p&gt;

&lt;p&gt;Claude Code burns through that faster than a chat client does, because one instruction can fan out into a lot of requests. Subagents are the big one, since each agent runs its own request loop and several can run concurrently, so a single "go investigate this" can cost dozens. Workflows and ultracode fan out hard enough to approach the 30 concurrent ceiling on their own, which is reason enough to keep them off this lane. Raising the effort level means more tool calls per turn, and every tool call is a request. Background chores are small but constant, which is exactly why the haiku slot points at &lt;code&gt;kimi-for-coding&lt;/code&gt; rather than K3.&lt;/p&gt;

&lt;p&gt;One quirk on effort. Kimi Code collapses Claude Code's five effort levels onto K3's three, so &lt;code&gt;medium&lt;/code&gt; and &lt;code&gt;xhigh&lt;/code&gt; land in the same place as their neighbors. Tune between &lt;code&gt;low&lt;/code&gt;, &lt;code&gt;high&lt;/code&gt; and &lt;code&gt;max&lt;/code&gt; and don't expect the in between settings to do anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gotchas worth knowing
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Plain &lt;code&gt;claude&lt;/code&gt; also goes to Kimi.&lt;/strong&gt; The variables reached your global environment. Run &lt;code&gt;env | grep ANTHROPIC&lt;/code&gt;, and check you didn't paste the config into &lt;code&gt;~/.claude/settings.json&lt;/code&gt; out of habit.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A warning that claude.ai connectors are disabled.&lt;/strong&gt; Expected, and actually a good sign. An API key outranks your claude.ai login for that session, which is the isolation working. The other tab is unaffected.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;WebFetch says it's temporarily unavailable.&lt;/strong&gt; The endpoint doesn't implement it. Paste the content in, or use an MCP scraping server. WebSearch still works on K3.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;/model&lt;/code&gt; shows unhelpful names.&lt;/strong&gt; That's what the &lt;code&gt;_NAME&lt;/code&gt; and &lt;code&gt;_DESCRIPTION&lt;/code&gt; keys in the config are for. You can also set &lt;code&gt;CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY=1&lt;/code&gt; and let the picker populate itself from the endpoint's model list.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Timestamps in the console request log are UTC+8&lt;/strong&gt;, and a request that failed on a bad model ID logs the model as &lt;code&gt;-&lt;/code&gt;. A row with a dash in the model column is a rejected ID, not a credential fault, whatever the status column says.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;400 invalid thinking: only type=enabled is allowed&lt;/code&gt;.&lt;/strong&gt; The K2.7 Code models used to reject requests that arrived without thinking enabled. I retested on 12 August 2026 and it no longer reproduces, with &lt;code&gt;kimi-for-coding&lt;/code&gt; returning 200 to a request carrying no &lt;code&gt;thinking&lt;/code&gt; block at all. I'm leaving it here because my retest used a minimal raw HTTP body rather than Claude Code's real request shape, and I'd rather you recognize it than rediscover it. If you do hit it, stay on K3.&lt;/p&gt;

&lt;h2&gt;
  
  
  Quick reference
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Variable&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;th&gt;What it does&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ANTHROPIC_BASE_URL&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;https://api.kimi.com/coding/&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Where requests go. Base only, keep the trailing slash&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ANTHROPIC_API_KEY&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;your Kimi Code key&lt;/td&gt;
&lt;td&gt;Sent as &lt;code&gt;x-api-key&lt;/code&gt;. Outranks the claude.ai login for this session&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ANTHROPIC_MODEL&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;k3&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Main conversation. No &lt;code&gt;[1m]&lt;/code&gt; suffix&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ANTHROPIC_DEFAULT_OPUS_MODEL&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;k3&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;What the &lt;code&gt;opus&lt;/code&gt; alias resolves to&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ANTHROPIC_DEFAULT_SONNET_MODEL&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;k3&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;What the &lt;code&gt;sonnet&lt;/code&gt; alias resolves to&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ANTHROPIC_DEFAULT_FABLE_MODEL&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;k3&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;What the &lt;code&gt;fable&lt;/code&gt; alias resolves to&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ANTHROPIC_DEFAULT_HAIKU_MODEL&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;kimi-for-coding&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Background chores. Ungated on every tier&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;CLAUDE_CODE_SUBAGENT_MODEL&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;k3&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Subagents and workflow agents. Your main quota burn&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;CLAUDE_CODE_MAX_CONTEXT_TOKENS&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;1048576&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Required. A bare &lt;code&gt;k3&lt;/code&gt; gives Claude Code nothing to infer from&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;CLAUDE_CODE_AUTO_COMPACT_WINDOW&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;950000&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Compaction trigger. &lt;code&gt;100000&lt;/code&gt; to &lt;code&gt;1000000&lt;/code&gt;, plain integer&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;CLAUDE_CODE_EFFORT_LEVEL&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;high&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Kimi collapses five levels onto three&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Backing it out
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;rm&lt;/span&gt; ~/.claude/kimi-settings.json
&lt;span class="c"&gt;# then delete the claude-kimi() block from ~/.zshrc&lt;/span&gt;
&lt;span class="nb"&gt;exec &lt;/span&gt;zsh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Your claude.ai login was never touched, so &lt;code&gt;claude&lt;/code&gt; keeps working the whole time. To pause Kimi without uninstalling anything, just stop typing &lt;code&gt;claude-kimi&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;One honest caveat on the cleanup. If you accepted that API key prompt, the last 20 characters of your key are still in &lt;code&gt;~/.claude.json&lt;/code&gt; under &lt;code&gt;customApiKeyResponses&lt;/code&gt;, and if you ran the repair script there's a &lt;code&gt;~/.claude.json.bak-apikey&lt;/code&gt; next to it. Neither is dangerous, both are residue, and you'll want to clear them out if you're rotating the key or handing the machine on.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one thing to remember
&lt;/h2&gt;

&lt;p&gt;Every failure in this setup arrives dressed as an authentication error. A rejected model ID, a model above your tier, and a key you once declined all surface as &lt;code&gt;401&lt;/code&gt;, and two of them will send you to the console to rotate a credential that was never broken. Read the message body, not the status code. That's the habit worth keeping.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;[ two lanes, one terminal ]&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://peculiarengineer.com/blog/claude-code-kimi-code-subscription/" rel="noopener noreferrer"&gt;peculiarengineer.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>claude</category>
      <category>kimi</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Kubernetes, Part Three: GitOps with Argo CD, let Git drive your cluster</title>
      <dc:creator>Minor Keith</dc:creator>
      <pubDate>Wed, 05 Aug 2026 02:58:58 +0000</pubDate>
      <link>https://dev.to/peculiarengineer/kubernetes-part-three-gitops-with-argo-cd-let-git-drive-your-cluster-1lfe</link>
      <guid>https://dev.to/peculiarengineer/kubernetes-part-three-gitops-with-argo-cd-let-git-drive-your-cluster-1lfe</guid>
      <description>&lt;p&gt;In &lt;a href="https://peculiarengineer.com/blog/kubernetes-first-app-k3s-single-node/" rel="noopener noreferrer"&gt;Part One&lt;/a&gt; I deployed nginx on a single k3s node and killed a Pod to watch a replacement appear. In &lt;a href="https://peculiarengineer.com/blog/kubernetes-configmaps-secrets-k3s/" rel="noopener noreferrer"&gt;Part Two&lt;/a&gt; I pulled the config and the passwords out of the image. Both parts had the same weak spot, and it's the one nobody mentions when they teach you &lt;code&gt;kubectl&lt;/code&gt;: I was still standing at a terminal typing &lt;code&gt;kubectl apply&lt;/code&gt; at a cluster.&lt;/p&gt;

&lt;p&gt;That works fine until it doesn't. A month later nobody can tell you what's actually running, or who changed it, or what the cluster looked like before someone "just quickly fixed" something at 11pm. The YAML on your laptop and the YAML in the cluster quietly drift apart, and the only way to find out is to go and look.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://argo-cd.readthedocs.io" rel="noopener noreferrer"&gt;Argo CD&lt;/a&gt; fixes that by taking the terminal away from you. You put your manifests in Git, you tell Argo CD where that repo is, and from then on the repo is the truth. Change the cluster by hand and Argo CD changes it back. I ran the whole thing on the same 4GB box that already hosts my &lt;a href="https://peculiarengineer.com/blog/self-host-forgejo-tailscale-docker-compose/" rel="noopener noreferrer"&gt;Forgejo instance&lt;/a&gt;, and I'm writing it down because six separate things bit me and I'd rather not rediscover any of them.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt; Put your manifests in a Git repo &lt;strong&gt;first&lt;/strong&gt;. Install Argo CD with &lt;code&gt;kubectl apply --server-side&lt;/code&gt; (plain &lt;code&gt;apply&lt;/code&gt; fails on one oversized CRD). Create an &lt;code&gt;Application&lt;/code&gt; object that names three things: which repo, which folder, where it goes. Sync it once by hand and watch &lt;code&gt;OutOfSync&lt;/code&gt; become &lt;code&gt;Synced&lt;/code&gt;. Then set &lt;code&gt;syncPolicy.automated&lt;/code&gt; with &lt;code&gt;selfHeal: true&lt;/code&gt; and &lt;code&gt;prune: true&lt;/code&gt;, and try to fight it: scale a Deployment, delete a Service, swap an image. It puts all of them back within fifteen seconds. Deploy by pushing to Git, roll back with &lt;code&gt;git revert&lt;/code&gt;. And remember that &lt;code&gt;Synced&lt;/code&gt; means "matches Git", not "works".&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The one idea worth holding onto
&lt;/h2&gt;

&lt;p&gt;Part One's idea was that you declare the state you want and Kubernetes makes reality match it. Part Three is the same sentence moved one level out:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;You declare the state you want in Git. Argo CD makes the cluster match it.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Kubernetes watches its own objects. Argo CD watches your repo. That's the entire product, and every feature below is a variation on it.&lt;/p&gt;

&lt;p&gt;What you get for free is the part I didn't expect. Because every change to the cluster is now a commit, Git becomes your audit log, your rollback mechanism, and your review gate without you building any of those things. "Who scaled this to five replicas and why" stops being an unanswerable question.&lt;/p&gt;

&lt;h2&gt;
  
  
  Before you start
&lt;/h2&gt;

&lt;p&gt;You need a working cluster and a &lt;code&gt;kubectl&lt;/code&gt; that reaches it without &lt;code&gt;sudo&lt;/code&gt;. That's Part One, Step 0, the &lt;code&gt;KUBECONFIG&lt;/code&gt; fix that catches everyone. You also need a GitHub account, and about forty minutes.&lt;/p&gt;

&lt;p&gt;Mine is a single k3s node, v1.36.2, on a 4GB Hetzner box in Falkenstein that was already running Forgejo in Docker. Argo CD cost me about 500MB across seven Pods, taking the box from 2.3GB free to 1.8GB free. It fits on a small machine comfortably. Nothing got OOM killed.&lt;/p&gt;

&lt;p&gt;One warning that matters if your box has a public IP like mine does. Argo CD ships with an &lt;code&gt;admin&lt;/code&gt; account and a bootstrap password, and Step 6 is where I make sure the web UI is reachable from my devices and from nowhere else. Don't skip it and don't reorder it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: the repo comes first
&lt;/h2&gt;

&lt;p&gt;This ordering is the whole philosophy in miniature, so do it in this order even though it feels backwards: the repo exists before Argo CD does.&lt;/p&gt;

&lt;p&gt;Create a new public repo. Mine is &lt;a href="https://github.com/peculiarengineer-mk/peculiarengineer-gitops" rel="noopener noreferrer"&gt;peculiarengineer-gitops&lt;/a&gt;, and it holds exactly two files under &lt;code&gt;apps/hello/&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Save the first as &lt;code&gt;apps/hello/deployment.yaml&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# A Deployment says "keep N copies of this container running, forever."&lt;/span&gt;
&lt;span class="c1"&gt;# Argo CD's job is to make sure this file and the cluster always agree.&lt;/span&gt;
&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;apps/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Deployment&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;hello&lt;/span&gt;
  &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;hello&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;replicas&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt; &lt;span class="c1"&gt;# change this number, push, and watch Argo CD notice&lt;/span&gt;
  &lt;span class="na"&gt;selector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;matchLabels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;hello&lt;/span&gt; &lt;span class="c1"&gt;# which Pods this Deployment owns&lt;/span&gt;
  &lt;span class="na"&gt;template&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;hello&lt;/span&gt; &lt;span class="c1"&gt;# must match the selector above, or nothing happens&lt;/span&gt;
    &lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;containers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;nginx&lt;/span&gt;
          &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;nginx:1.27&lt;/span&gt;
          &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;containerPort&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;80&lt;/span&gt;
          &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="c1"&gt;# Small box, so be explicit about what this is allowed to eat.&lt;/span&gt;
            &lt;span class="na"&gt;requests&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="na"&gt;cpu&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;10m&lt;/span&gt;
              &lt;span class="na"&gt;memory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;16Mi&lt;/span&gt;
            &lt;span class="na"&gt;limits&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="na"&gt;memory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;64Mi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the second as &lt;code&gt;apps/hello/service.yaml&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# A Service gives the Pods above one stable address inside the cluster.&lt;/span&gt;
&lt;span class="c1"&gt;# ClusterIP means "reachable from inside the cluster only", so nothing is&lt;/span&gt;
&lt;span class="c1"&gt;# published to the internet by this file.&lt;/span&gt;
&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Service&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;hello&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ClusterIP&lt;/span&gt;
  &lt;span class="na"&gt;selector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;hello&lt;/span&gt; &lt;span class="c1"&gt;# sends traffic to any Pod carrying this label&lt;/span&gt;
  &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;80&lt;/span&gt;
      &lt;span class="na"&gt;targetPort&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;80&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nothing new here. This is Part One's Deployment and Service with resource limits added, because a 4GB box deserves them. Commit and push both.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;💡 What happened:&lt;/strong&gt; You wrote down what you want running without running any of it. From here on, editing these files is how you change the cluster. That's the habit the rest of the post builds.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: install Argo CD
&lt;/h2&gt;

&lt;p&gt;Argo CD installs into the cluster as ordinary Kubernetes objects in its own namespace. No operator, no Helm required, no packages on the host.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;▶ Do:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl create namespace argocd
kubectl apply &lt;span class="nt"&gt;-n&lt;/span&gt; argocd &lt;span class="nt"&gt;-f&lt;/span&gt; https://raw.githubusercontent.com/argoproj/argo-cd/stable/manifests/install.yaml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;❌ And that fails.&lt;/strong&gt; Most of it applies, then right at the end:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;The CustomResourceDefinition "applicationsets.argoproj.io" is invalid:
metadata.annotations: Too long: may not be more than 262144 bytes
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This one confused me for a minute because the error blames the CRD, and the CRD is fine. &lt;code&gt;kubectl apply&lt;/code&gt; stashes a copy of the entire manifest it just applied into a &lt;code&gt;last-applied-configuration&lt;/code&gt; annotation so it can compute diffs later. The ApplicationSet CRD is bigger than the 256KB limit Kubernetes puts on annotations, so the copy can't be stored.&lt;/p&gt;

&lt;p&gt;Server side apply doesn't use that annotation at all. It hands the whole document to the API server and lets it track ownership properly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;▶ Do:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl apply &lt;span class="nt"&gt;-n&lt;/span&gt; argocd &lt;span class="nt"&gt;--server-side&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; https://raw.githubusercontent.com/argoproj/argo-cd/stable/manifests/install.yaml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;✅ You should see&lt;/strong&gt; three CRDs land:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;customresourcedefinition.apiextensions.k8s.io/applications.argoproj.io serverside-applied
customresourcedefinition.apiextensions.k8s.io/applicationsets.argoproj.io serverside-applied
customresourcedefinition.apiextensions.k8s.io/appprojects.argoproj.io serverside-applied
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now wait for the Pods. Seven of them come up, and on my box that took about forty seconds:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl &lt;span class="nb"&gt;wait&lt;/span&gt; &lt;span class="nt"&gt;--for&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;condition&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;Ready pods &lt;span class="nt"&gt;--all&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; argocd &lt;span class="nt"&gt;--timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;300s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;💡 What happened:&lt;/strong&gt; Argo CD is now a workload in your cluster like any other, watching for &lt;code&gt;Application&lt;/code&gt; objects that don't exist yet. Worth noticing that it installed by declaring a pile of YAML, which is the same trick it's about to do on your behalf.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: the CLI and the admin password
&lt;/h2&gt;

&lt;p&gt;Grab the CLI on the node. This is the Linux x86 build, so if you'd rather run it from a Mac or an ARM box, take the matching binary from the &lt;a href="https://github.com/argoproj/argo-cd/releases" rel="noopener noreferrer"&gt;releases page&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;▶ Do:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;curl &lt;span class="nt"&gt;-sSL&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /usr/local/bin/argocd https://github.com/argoproj/argo-cd/releases/latest/download/argocd-linux-amd64
&lt;span class="nb"&gt;sudo chmod&lt;/span&gt; +x /usr/local/bin/argocd
argocd version &lt;span class="nt"&gt;--client&lt;/span&gt; &lt;span class="nt"&gt;--short&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;✅ You should see&lt;/strong&gt; a version line and nothing else:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;argocd: v3.4.5+564b949
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The installer generates a random admin password and leaves it in a Secret. Reading it is a callback to Part Two, base64 and all:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl &lt;span class="nt"&gt;-n&lt;/span&gt; argocd get secret argocd-initial-admin-secret &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nv"&gt;jsonpath&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"{.data.password}"&lt;/span&gt; | &lt;span class="nb"&gt;base64&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same reminder as Part Two: base64 is encoding, not encryption. You just decoded a password with a pipe. Step 12 replaces this one properly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: the Application, and the moment it clicks
&lt;/h2&gt;

&lt;p&gt;This is the object the whole product hangs off, and it's smaller than you'd think. An &lt;code&gt;Application&lt;/code&gt; answers three questions and nothing else. Which repo. Which folder inside it. Where the result goes.&lt;/p&gt;

&lt;p&gt;You can create it with &lt;code&gt;argocd app create&lt;/code&gt;, but write the YAML instead. Seeing the object is what makes the idea stick. Save it as &lt;code&gt;hello-app.yaml&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;argoproj.io/v1alpha1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Application&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;hello&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;argocd&lt;/span&gt; &lt;span class="c1"&gt;# Applications live in the argocd namespace&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;project&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;default&lt;/span&gt;
  &lt;span class="na"&gt;source&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;repoURL&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https://github.com/peculiarengineer-mk/peculiarengineer-gitops.git&lt;/span&gt;
    &lt;span class="na"&gt;targetRevision&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;main&lt;/span&gt; &lt;span class="c1"&gt;# which branch to track&lt;/span&gt;
    &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;apps/hello&lt;/span&gt; &lt;span class="c1"&gt;# which folder in the repo to apply&lt;/span&gt;
  &lt;span class="na"&gt;destination&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;server&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https://kubernetes.default.svc&lt;/span&gt; &lt;span class="c1"&gt;# this same cluster&lt;/span&gt;
    &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;default&lt;/span&gt; &lt;span class="c1"&gt;# where the manifests land&lt;/span&gt;
  &lt;span class="c1"&gt;# No syncPolicy yet. This first one syncs only when we tell it to.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Swap in your own repo URL. Leaving &lt;code&gt;syncPolicy&lt;/code&gt; out is deliberate, and it's the best decision in this whole tutorial, because it lets you see the next bit.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;▶ Do:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl apply &lt;span class="nt"&gt;-f&lt;/span&gt; hello-app.yaml
&lt;span class="nb"&gt;sleep &lt;/span&gt;5
kubectl get application hello &lt;span class="nt"&gt;-n&lt;/span&gt; argocd &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-o&lt;/span&gt; custom-columns&lt;span class="o"&gt;=&lt;/span&gt;NAME:.metadata.name,SYNC:.status.sync.status,HEALTH:.status.health.status
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;✅ You should see&lt;/strong&gt; two words that are the entire pitch:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;NAME    SYNC        HEALTH
hello   OutOfSync   Missing
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;💡 What happened, and this is the paragraph I'd underline.&lt;/strong&gt; Nobody asked it to do that. In the five seconds since you created the object, Argo CD cloned your repo, read &lt;code&gt;apps/hello&lt;/code&gt;, compared both files against the live cluster, and reported the gap: &lt;code&gt;OutOfSync&lt;/code&gt; means Git and the cluster disagree, &lt;code&gt;Missing&lt;/code&gt; means those objects don't exist yet. It will keep doing that comparison forever, whether or not you ever sync anything.&lt;/p&gt;

&lt;p&gt;The sync is almost an afterthought. The &lt;strong&gt;diff&lt;/strong&gt; is what you're actually buying.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: the first sync
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;argocd&lt;/code&gt; CLI has two modes and they behave differently, which caused me the second gotcha. &lt;code&gt;--core&lt;/code&gt; talks straight to the Kubernetes API with no Argo CD server in the middle. It's the quickest way to work from the node.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;▶ Do:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;argocd app &lt;span class="nb"&gt;sync &lt;/span&gt;hello &lt;span class="nt"&gt;--core&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;❌ And it fails&lt;/strong&gt; with a message that is actively misleading:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;{"level":"fatal","msg":"configmap \"argocd-cm\" not found"}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Go and look and &lt;code&gt;argocd-cm&lt;/code&gt; is sitting right there in the &lt;code&gt;argocd&lt;/code&gt; namespace. The problem is that core mode has no server session telling it where Argo CD lives, so it reads the namespace from your &lt;strong&gt;kubeconfig context&lt;/strong&gt;. On k3s that context has no namespace set, so it looks in &lt;code&gt;default&lt;/code&gt; and finds nothing.&lt;/p&gt;

&lt;p&gt;Setting &lt;code&gt;ARGOCD_NAMESPACE&lt;/code&gt; does not help. I tried, twice.&lt;/p&gt;

&lt;p&gt;The obvious fix is &lt;code&gt;kubectl config set-context --current --namespace=argocd&lt;/code&gt;, and that's the third gotcha, because it poisons every &lt;code&gt;kubectl&lt;/code&gt; command you run on that box afterwards. Suddenly &lt;code&gt;kubectl get pods&lt;/code&gt; shows you Argo CD's internals instead of your app, forever, and you will not remember why. Use a separate kubeconfig for the &lt;code&gt;argocd&lt;/code&gt; CLI instead.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;▶ Do:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cp&lt;/span&gt; ~/.kube/config ~/.kube/argocd-core.yaml
&lt;span class="nv"&gt;KUBECONFIG&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;~/.kube/argocd-core.yaml kubectl config set-context &lt;span class="nt"&gt;--current&lt;/span&gt; &lt;span class="nt"&gt;--namespace&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;argocd
&lt;span class="nv"&gt;KUBECONFIG&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;~/.kube/argocd-core.yaml argocd app &lt;span class="nb"&gt;sync &lt;/span&gt;hello &lt;span class="nt"&gt;--core&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That copies the kubeconfig Part One had you set up, so you're not going back to the root owned &lt;code&gt;/etc/rancher/k3s/k3s.yaml&lt;/code&gt; that Step 0 told you to stop touching.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;✅ You should see:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Sync Status:        Synced to main (d1395f1)
Phase:              Succeeded
Message:            successfully synced (all tasks run)

GROUP  KIND        NAMESPACE  NAME   STATUS  HEALTH       MESSAGE
       Service     default    hello  Synced  Healthy      service/hello created
apps   Deployment  default    hello  Synced  Progressing  deployment.apps/hello created
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Check what landed:&lt;br&gt;
&lt;/p&gt;

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

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;deployment.apps/hello   2/2   2   2   22s
pod/hello-5d6b7fbfc4-f6kxf   1/1   Running   0   23s
pod/hello-5d6b7fbfc4-p4x64   1/1   Running   0   23s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;KUBECONFIG=&lt;/code&gt; prefix is needed on &lt;strong&gt;every&lt;/strong&gt; &lt;code&gt;argocd&lt;/code&gt; command from here on, and typing it each time gets old fast. Set an alias for the rest of the session:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;alias &lt;/span&gt;&lt;span class="nv"&gt;argocd&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'KUBECONFIG=~/.kube/argocd-core.yaml argocd'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every &lt;code&gt;argocd&lt;/code&gt; command below assumes you did that. If one of them ever comes back with &lt;code&gt;configmap "argocd-cm" not found&lt;/code&gt;, you're in a new shell and the alias is gone.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;💡 What happened:&lt;/strong&gt; You never ran &lt;code&gt;kubectl apply&lt;/code&gt; on those manifests. Argo CD read GitHub and created both objects itself. That's a small thing on two files and a very large thing on two hundred.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 6: reach the UI without publishing it to the internet
&lt;/h2&gt;

&lt;p&gt;The CLI does everything, but the web UI is genuinely good and the resource tree is the bit that makes Kubernetes legible to people who don't live in it.&lt;/p&gt;

&lt;p&gt;Here's the trap. A k3s NodePort binds to &lt;code&gt;0.0.0.0&lt;/code&gt;, which on a box with a public IP means the open internet. Argo CD has a known admin username and a bootstrap password. So the firewall goes on &lt;strong&gt;first&lt;/strong&gt; and the service gets exposed &lt;strong&gt;second&lt;/strong&gt;. If you get that order backwards you spend a few minutes with an unauthenticated door open, and that's plenty.&lt;/p&gt;

&lt;p&gt;My box wasn't on my tailnet at all. Only the Forgejo container was, through its sidecar. So the host joins properly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-fsSL&lt;/span&gt; https://tailscale.com/install.sh | sh
tailscale up &lt;span class="nt"&gt;--hostname&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;k3s-1 &lt;span class="nt"&gt;--accept-dns&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;false&lt;/span&gt; &lt;span class="nt"&gt;--accept-routes&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;false&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both of those flags are deliberate. &lt;code&gt;--accept-dns=false&lt;/code&gt; stops Tailscale rewriting &lt;code&gt;/etc/resolv.conf&lt;/code&gt;, and &lt;code&gt;--accept-routes=false&lt;/code&gt; stops it touching the routing table. On a box already running k3s and Docker I want Tailscale to add an interface and change nothing else.&lt;/p&gt;

&lt;p&gt;Now the firewall, and here's the fourth gotcha, which is the one that can genuinely take your services down. Ubuntu ships this in &lt;code&gt;/etc/default/ufw&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;DEFAULT_FORWARD_POLICY="DROP"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Enable ufw with that set and you break pod to pod networking in k3s &lt;strong&gt;and&lt;/strong&gt; Docker's bridge. On my box that would have taken Forgejo down as collateral. Fix it before you enable anything:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sed&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="s1"&gt;'s/^DEFAULT_FORWARD_POLICY=.*/DEFAULT_FORWARD_POLICY="ACCEPT"/'&lt;/span&gt; /etc/default/ufw
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Before you turn the firewall on, arm a dead man switch. Enabling a firewall over SSH on a box whose only access is SSH is how people lose servers, and a Hetzner rescue console at midnight is a bad time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;▶ Do:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;setsid &lt;span class="nb"&gt;nohup &lt;/span&gt;bash &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"sleep 300; ufw --force disable"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null 2&amp;gt;&amp;amp;1 &amp;lt; /dev/null &amp;amp;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="nv"&gt;$!&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /root/ufw-deadman.pid
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That undoes the firewall in five minutes unless you cancel it. Now the rules. SSH, the tailnet, and the k3s internal networks:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ufw default deny incoming
ufw default allow outgoing
ufw allow 22/tcp
ufw allow &lt;span class="k"&gt;in &lt;/span&gt;on tailscale0
ufw allow 41641/udp        &lt;span class="c"&gt;# tailscale wireguard&lt;/span&gt;
ufw allow &lt;span class="k"&gt;in &lt;/span&gt;on cni0       &lt;span class="c"&gt;# k3s pods&lt;/span&gt;
ufw allow &lt;span class="k"&gt;in &lt;/span&gt;on flannel.1
ufw allow &lt;span class="k"&gt;in &lt;/span&gt;on docker0
ufw allow from 10.42.0.0/16   &lt;span class="c"&gt;# k3s pod cidr&lt;/span&gt;
ufw allow from 10.43.0.0/16   &lt;span class="c"&gt;# k3s service cidr&lt;/span&gt;
ufw &lt;span class="nt"&gt;--force&lt;/span&gt; &lt;span class="nb"&gt;enable&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Open a second SSH session and confirm you can still get in. Check your cluster and anything else on the box is still healthy. Then cancel the timer using the PID file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;kill&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; /root/ufw-deadman.pid&lt;span class="si"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Use the PID file, not &lt;code&gt;pkill -f "sleep 300"&lt;/code&gt;. I tried the &lt;code&gt;pkill&lt;/code&gt; version and it killed my own shell, because the command line running that &lt;code&gt;pkill&lt;/code&gt; contains the string &lt;code&gt;sleep 300&lt;/code&gt; and therefore matches itself. Exit code 255, dropped connection, and a few seconds of wondering whether I'd just locked myself out.&lt;/p&gt;

&lt;p&gt;Now expose the UI:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl &lt;span class="nt"&gt;-n&lt;/span&gt; argocd patch svc argocd-server &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="s1"&gt;'{"spec":{"type":"NodePort","ports":[
  {"name":"http","port":80,"targetPort":8080,"protocol":"TCP"},
  {"name":"https","port":443,"targetPort":8080,"protocol":"TCP","nodePort":30443}]}}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you go looking for a listening socket you won't find one, and that threw me for a second:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ss &lt;span class="nt"&gt;-tlnp&lt;/span&gt; | &lt;span class="nb"&gt;grep &lt;/span&gt;30443    &lt;span class="c"&gt;# returns nothing, and that's correct&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;kube-proxy implements NodePorts with iptables rules, not a bound socket. The test that actually matters is reaching it from two places:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://100.87.x.y:30443  -&amp;gt;  HTTP 200     # over the tailnet
https://203.0.113.88:30443  -&amp;gt;  HTTP 000     # public IP, blocked
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;💡 What happened:&lt;/strong&gt; The UI is reachable from every device on my tailnet, including the iPad, and invisible from everywhere else. The cert is self signed so your browser will complain once. Log in as &lt;code&gt;admin&lt;/code&gt; with the password from Step 3, click into the &lt;code&gt;hello&lt;/code&gt; app, and you get the resource tree: Application, Deployment, ReplicaSet, Pods, each with a health dot. That view is why people keep the UI around.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 7: break the cluster on purpose
&lt;/h2&gt;

&lt;p&gt;Now the interesting part. Do the exact thing GitOps is supposed to prevent.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;▶ Do:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl scale deployment hello &lt;span class="nt"&gt;-n&lt;/span&gt; default &lt;span class="nt"&gt;--replicas&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;5
&lt;span class="nb"&gt;sleep &lt;/span&gt;15
kubectl get application hello &lt;span class="nt"&gt;-n&lt;/span&gt; argocd &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-o&lt;/span&gt; custom-columns&lt;span class="o"&gt;=&lt;/span&gt;SYNC:.status.sync.status,HEALTH:.status.health.status
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;✅ You should see:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;SYNC        HEALTH
OutOfSync   Healthy
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Five Pods running, and Argo CD spotted it within seconds and did &lt;strong&gt;nothing at all&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;That's not a bug, it's the lesson. Detecting drift and correcting drift are separate features, and right now you only have the first one. Seeing Argo CD notice and deliberately sit on its hands is what makes the next step land.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;Healthy&lt;/code&gt; sitting next to &lt;code&gt;OutOfSync&lt;/code&gt; is worth its own thought too. Health and sync are different axes. Health means the five Pods are fine. Sync means this matches Git. An app can be in perfect health and completely wrong.&lt;/p&gt;

&lt;p&gt;Ask what's wrong and it tells you exactly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;argocd app diff hello &lt;span class="nt"&gt;--core&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;===== apps/Deployment default/hello ======
120c120
&amp;lt;   replicas: 5
---
&amp;gt;   replicas: 2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;&amp;lt;&lt;/code&gt; is the live cluster, &lt;code&gt;&amp;gt;&lt;/code&gt; is Git. Four lines that answer "how does reality differ from what I said I wanted". You can run that against anything, at any time, and get a real answer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 8: turn on self-heal and try to win
&lt;/h2&gt;

&lt;p&gt;Three settings. &lt;code&gt;selfHeal&lt;/code&gt; corrects drift, &lt;code&gt;prune&lt;/code&gt; deletes objects whose files vanish from the repo, and &lt;code&gt;automated&lt;/code&gt; means it acts without being asked.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;▶ Do:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl &lt;span class="nt"&gt;-n&lt;/span&gt; argocd patch application hello &lt;span class="nt"&gt;--type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;merge &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="s1"&gt;'{
  "spec": {"syncPolicy": {"automated": {"prune": true, "selfHeal": true}}}
}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then watch, without running any sync command:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; 5s  OutOfSync Healthy   replicas=5  pods=5
10s  Synced Healthy      replicas=2  pods=2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Five Pods became two on their own.&lt;/strong&gt; I changed a policy, not the cluster, and Argo CD dragged reality back to what the repo says. For my money this is the moment the whole idea clicks, more than the first sync was.&lt;/p&gt;

&lt;p&gt;Now try to win. I made three attempts and lost all three inside fifteen seconds:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What I did&lt;/th&gt;
&lt;th&gt;What happened&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;kubectl scale --replicas=4&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;back to 2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;kubectl delete svc hello&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Service recreated&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;kubectl set image nginx=nginx:1.25&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;back to &lt;code&gt;nginx:1.27&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;💡 What happened, plus the fifth gotcha.&lt;/strong&gt; Look closely at that recreated Service and it has a &lt;strong&gt;different ClusterIP&lt;/strong&gt; and an age of fifteen seconds. Argo CD did not restore the object you deleted. It created a new one that matches the file. Anything that cached the old address is now talking to nothing. Self-heal keeps your cluster matching your repo, and that is not the same as a backup. Don't let it talk you out of having real backups.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 9: deploy by pushing to Git
&lt;/h2&gt;

&lt;p&gt;The whole point. Edit the repo, not the cluster.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;▶ Do&lt;/strong&gt; change &lt;code&gt;replicas: 2&lt;/code&gt; to &lt;code&gt;replicas: 3&lt;/code&gt; in &lt;code&gt;apps/hello/deployment.yaml&lt;/code&gt;, then:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git commit &lt;span class="nt"&gt;-am&lt;/span&gt; &lt;span class="s2"&gt;"Scale hello to 3 replicas"&lt;/span&gt;
git push
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now touch nothing and watch:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  0s  revision=d1395f1  replicas=2
...
124s  revision=d1395f1  replicas=2
136s  revision=e6c57cb  replicas=3
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;136 seconds.&lt;/strong&gt; That isn't Argo CD being slow, it's the default polling interval. With no webhook configured it asks GitHub "anything new?" every three minutes. In production you point a repo webhook at it and deployments become instant. While you're writing a tutorial and don't want to wait, force it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;argocd app get hello &lt;span class="nt"&gt;--hard-refresh&lt;/span&gt; &lt;span class="nt"&gt;--core&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;💡 What happened:&lt;/strong&gt; Check the Pod ages afterwards and you get 28m, 28m, 2s. It scaled up. It did not redeploy. Argo CD works out the difference between the repo and the cluster and applies only that, so a one line change doesn't churn your running workload.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 10: delete a file, delete the object
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;prune&lt;/code&gt; is the setting that decides whether removing a file removes the thing it described. I tested it with something disposable.&lt;/p&gt;

&lt;p&gt;Add &lt;code&gt;apps/hello/temp-configmap.yaml&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ConfigMap&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;hello-temp&lt;/span&gt;
&lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;note&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;delete&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;this&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;file&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;from&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;git&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;and&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;prune&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;should&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;remove&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;the&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;object'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Commit, push, hard refresh, and the ConfigMap appears in the cluster. Now &lt;code&gt;git rm&lt;/code&gt; the file, commit, push, hard refresh again:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl get configmap hello-temp &lt;span class="nt"&gt;-n&lt;/span&gt; default
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Error from server (NotFound): configmaps "hello-temp" not found
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;💡 What happened:&lt;/strong&gt; Deleting a file deleted the object. Worth knowing that &lt;code&gt;prune: false&lt;/code&gt; is the default, and with it off Argo CD will happily add and update things but never remove them. That sounds safer and mostly it is, but you end up with orphaned objects in your cluster that no file in any repo describes and nobody remembers creating. Turn prune on early, while your cluster is small enough that a mistake is obvious.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 11: ship something broken, then roll it back
&lt;/h2&gt;

&lt;p&gt;This is the most important section in the post, so I deliberately shipped a release that could not possibly work. In &lt;code&gt;deployment.yaml&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;nginx:9.9.9-doesnotexist&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Commit, push, refresh, wait thirty seconds:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;=== app health ===
Synced   Progressing

=== pods ===
hello-5d6b7fbfc4-f6kxf 1/1 Running       32m
hello-5d6b7fbfc4-p4x64 1/1 Running       32m
hello-5d6b7fbfc4-vzq7x 1/1 Running       6m
hello-6d96c85587-lgxml 0/1 ErrImagePull  31s

=== is the site still up? ===
HTTP 200
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read that first line again. &lt;strong&gt;&lt;code&gt;Synced&lt;/code&gt;, and completely broken.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;Synced&lt;/code&gt; means the cluster matches Git. It does not mean the app works. I put something wrong in the repo, so Argo CD faithfully made the cluster wrong. GitOps guarantees fidelity to your repo, not correctness, and if you remember one sentence from this post make it that one. Your repo is now the thing that needs code review, because it's the thing that ships.&lt;/p&gt;

&lt;p&gt;The site stayed up for a reason worth knowing: Kubernetes' rolling update will not kill a healthy Pod until its replacement reports Ready, so three good Pods kept serving while the fourth failed to pull. It also sits in &lt;code&gt;Progressing&lt;/code&gt; for a full ten minutes before it admits to being &lt;code&gt;Degraded&lt;/code&gt;, which is &lt;code&gt;progressDeadlineSeconds&lt;/code&gt; defaulting to 600 rather than Argo CD dragging its feet. I stared at &lt;code&gt;Progressing&lt;/code&gt; for a while assuming something was stuck.&lt;/p&gt;

&lt;p&gt;Now the fix, and notice what the fix &lt;em&gt;is&lt;/em&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git revert &lt;span class="nt"&gt;--no-edit&lt;/span&gt; &amp;lt;bad-sha&amp;gt;
git push
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Synced   Healthy
image: nginx:1.27
HTTP 200
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pod ages afterwards: 32m, 32m, 6m. &lt;strong&gt;Not one Pod died.&lt;/strong&gt; The bad release never took hold, the old Pods served throughout, and recovery was a normal commit.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;💡 What happened:&lt;/strong&gt; Your rollback procedure is the same command as every other change. No special runbook, no "quick fix" applied straight to prod that nobody writes down. The revert is right there in the log next to the mistake it undoes.&lt;/p&gt;

&lt;p&gt;Small thing that cost me a minute: &lt;code&gt;git revert -q&lt;/code&gt; is not a flag. It quietly prints the usage text and reverts nothing, and if you're not reading closely you'll push an empty change and wonder why nothing recovered.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 12: rotate that admin password
&lt;/h2&gt;

&lt;p&gt;The bootstrap password from Step 3 is meant to be temporary. The documented command is &lt;code&gt;argocd account update-password&lt;/code&gt;, and here's the sixth gotcha:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ argocd account update-password --account admin ... --core
failed to get issue time: unable to extract token claims
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Core mode has no server session, so there's no token to authenticate a password change. You either log in through the API server properly, or you write the hash yourself:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;NEW&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"pick-something-better-than-this"&lt;/span&gt;
&lt;span class="nv"&gt;HASH&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;argocd account bcrypt &lt;span class="nt"&gt;--password&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$NEW&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
kubectl &lt;span class="nt"&gt;-n&lt;/span&gt; argocd patch secret argocd-secret &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="s2"&gt;"{&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;stringData&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;:{&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;admin.password&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="nv"&gt;$HASH&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;,&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;admin.passwordMtime&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%FT%T%Z&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;}}"&lt;/span&gt;
kubectl &lt;span class="nt"&gt;-n&lt;/span&gt; argocd rollout restart deployment argocd-server
kubectl &lt;span class="nt"&gt;-n&lt;/span&gt; argocd delete secret argocd-initial-admin-secret
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Confirm both directions. The old password should be refused:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ argocd login &amp;lt;host&amp;gt;:30443 --username admin --password "&amp;lt;old&amp;gt;" --insecure --grpc-web
Invalid username or password

$ argocd login &amp;lt;host&amp;gt;:30443 --username admin --password "&amp;lt;new&amp;gt;" --insecure --grpc-web
'admin:login' logged in successfully
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Worth understanding that those two CLI modes really are different tools wearing the same name. &lt;code&gt;--core&lt;/code&gt; skips the server and talks to the Kubernetes API, which is fast and needs no login but can't do anything that depends on a session. &lt;code&gt;--server &amp;lt;host&amp;gt; --grpc-web&lt;/code&gt; goes through argocd-server with a real login, which is what you want for anything touching accounts, tokens, or RBAC.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gotchas I hit
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Plain &lt;code&gt;kubectl apply&lt;/code&gt; fails on the install manifest.&lt;/strong&gt; The ApplicationSet CRD exceeds the 256KB annotation limit that &lt;code&gt;apply&lt;/code&gt; needs for its &lt;code&gt;last-applied-configuration&lt;/code&gt; copy. Use &lt;code&gt;--server-side&lt;/code&gt;. The error blames the CRD, which sends you looking in the wrong place.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;argocd --core&lt;/code&gt; reads its namespace from the kubeconfig context.&lt;/strong&gt; Not from &lt;code&gt;ARGOCD_NAMESPACE&lt;/code&gt;, which does nothing. On k3s the context has no namespace, so you get &lt;code&gt;configmap "argocd-cm" not found&lt;/code&gt; while the ConfigMap is plainly sitting in the &lt;code&gt;argocd&lt;/code&gt; namespace.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fixing that by repointing your main kubeconfig poisons everything else.&lt;/strong&gt; &lt;code&gt;kubectl config set-context --current --namespace=argocd&lt;/code&gt; means every later &lt;code&gt;kubectl get pods&lt;/code&gt; on that box shows Argo CD internals. Keep a separate kubeconfig for the &lt;code&gt;argocd&lt;/code&gt; CLI.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;DEFAULT_FORWARD_POLICY="DROP"&lt;/code&gt; breaks k3s and Docker when you enable ufw.&lt;/strong&gt; Set it to &lt;code&gt;ACCEPT&lt;/code&gt; in &lt;code&gt;/etc/default/ufw&lt;/code&gt; first, and allow the pod and service CIDRs. Skip this and you take down pod networking and every container on the box at the same time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Self-heal is not a backup.&lt;/strong&gt; Delete a Service and Argo CD creates a new one matching the file, with a new ClusterIP. It restores the description, not the object. Anything holding the old address is now pointing at nothing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;argocd account update-password&lt;/code&gt; does not work in core mode.&lt;/strong&gt; No session, no token, no password change. Patch the bcrypt hash into &lt;code&gt;argocd-secret&lt;/code&gt; instead.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;Synced&lt;/code&gt; does not mean working.&lt;/strong&gt; It means the cluster matches Git. Put a broken image tag in the repo and you get a proudly &lt;code&gt;Synced&lt;/code&gt; broken app.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't cancel a background timer with &lt;code&gt;pkill -f "sleep 300"&lt;/code&gt;.&lt;/strong&gt; The shell running that command contains the string, matches itself, and kills your session. Use a PID file.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Quick command reference
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Goal&lt;/th&gt;
&lt;th&gt;Command&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Install Argo CD&lt;/td&gt;
&lt;td&gt;&lt;code&gt;kubectl apply -n argocd --server-side -f &amp;lt;install.yaml&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Read bootstrap password&lt;/td&gt;
&lt;td&gt;`kubectl -n argocd get secret argocd-initial-admin-secret -o jsonpath="{.data.password}" \&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;App status&lt;/td&gt;
&lt;td&gt;{% raw %}&lt;code&gt;kubectl get application &amp;lt;app&amp;gt; -n argocd&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;See Git versus cluster&lt;/td&gt;
&lt;td&gt;&lt;code&gt;argocd app diff &amp;lt;app&amp;gt; --core&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sync now&lt;/td&gt;
&lt;td&gt;&lt;code&gt;argocd app sync &amp;lt;app&amp;gt; --core&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Skip the 3 minute poll&lt;/td&gt;
&lt;td&gt;&lt;code&gt;argocd app get &amp;lt;app&amp;gt; --hard-refresh --core&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;List apps through the server&lt;/td&gt;
&lt;td&gt;&lt;code&gt;argocd app list --server &amp;lt;host&amp;gt;:30443 --insecure --grpc-web&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Turn on self-heal and prune&lt;/td&gt;
&lt;td&gt;patch &lt;code&gt;spec.syncPolicy.automated&lt;/code&gt; with &lt;code&gt;selfHeal&lt;/code&gt; and &lt;code&gt;prune&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Roll back&lt;/td&gt;
&lt;td&gt;&lt;code&gt;git revert --no-edit &amp;lt;sha&amp;gt; &amp;amp;&amp;amp; git push&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Where this series goes next
&lt;/h2&gt;

&lt;p&gt;You now have a cluster that argues with you, which is the correct behaviour. Change something by hand and it changes it back. Deploy by pushing a commit. Roll back with &lt;code&gt;git revert&lt;/code&gt; and lose nothing.&lt;/p&gt;

&lt;p&gt;There's one loose thread I left deliberately, and it's the obvious next part. That &lt;code&gt;hello-app.yaml&lt;/code&gt; from Step 4 is still sitting on the node, applied by hand with &lt;code&gt;kubectl&lt;/code&gt;. The thing driving all my GitOps is itself not under GitOps, which is a slightly embarrassing place to stop. The fix has a good name, app of apps, and it answers the question of who deploys the deployer.&lt;/p&gt;

&lt;p&gt;After that: repo webhooks to kill the three minute poll, private repo authentication so this can point at my own Forgejo rather than GitHub, and Helm and Kustomize as sources instead of plain YAML. The &lt;a href="https://peculiarengineer.com/blog/kubernetes-series/" rel="noopener noreferrer"&gt;Kubernetes series hub&lt;/a&gt; tracks the lot, and Labels, Volumes, health probes and Ingress are all still on the list.&lt;/p&gt;

&lt;p&gt;Go and scale something by hand, then watch it change back while you're still looking at it. That's the bit that made it real for me. &lt;code&gt;[ synced ]&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://peculiarengineer.com/blog/gitops-argocd-k3s/" rel="noopener noreferrer"&gt;peculiarengineer.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>gitops</category>
      <category>devops</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Self-host Forgejo on your tailnet with Docker Compose and Tailscale</title>
      <dc:creator>Minor Keith</dc:creator>
      <pubDate>Thu, 30 Jul 2026 02:53:00 +0000</pubDate>
      <link>https://dev.to/peculiarengineer/self-host-forgejo-on-your-tailnet-with-docker-compose-and-tailscale-1f2d</link>
      <guid>https://dev.to/peculiarengineer/self-host-forgejo-on-your-tailnet-with-docker-compose-and-tailscale-1f2d</guid>
      <description>&lt;p&gt;You want your own Git host. Maybe you're getting off GitHub, maybe you just want somewhere private to keep the repos that shouldn't be on someone else's servers. So you go looking, and every guide hands you the same shape: a public DNS record, ports 80 and 443 open to the entire internet, a reverse proxy, a certificate, and a login page that anyone on earth can now knock on.&lt;/p&gt;

&lt;p&gt;For a private code host, that's a strange trade. Nothing about "my repos, for me and three collaborators" requires a public address. If the only people who should reach it are people you already trust, put it on your tailnet and the whole category of internet facing problems stops existing. No open ports, and no login page getting scanned at three in the morning.&lt;/p&gt;

&lt;p&gt;The Compose file for this is short. What makes it worth writing down is the sidecar pattern it uses, which is not obvious the first time, and a handful of settings that decide whether it works at all. I built this from nothing on a fresh Ubuntu 26.04 box to check it, and the settings that went wrong were not the ones I expected, so those get their own section at the end.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR.&lt;/strong&gt; Run Tailscale as its own container and give Forgejo &lt;code&gt;network_mode: service:ts-forgejo&lt;/code&gt; so Forgejo has no published ports and its own tailnet identity. Point &lt;code&gt;TS_SERVE_CONFIG&lt;/code&gt; at a serve JSON file and you get real HTTPS on &lt;code&gt;forgejo.your-tailnet.ts.net&lt;/code&gt; with no port 80 and no HTTP-01 challenge. Persist &lt;code&gt;/var/lib/tailscale&lt;/code&gt; or every restart creates a brand new node. Tag the device so its node key never expires. Do not set &lt;code&gt;START_SSH_SERVER&lt;/code&gt;, because the image already runs sshd on port 22.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Prerequisites
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A box with &lt;a href="https://peculiarengineer.com/blog/install-docker-ubuntu-26-04/" rel="noopener noreferrer"&gt;Docker installed&lt;/a&gt;. Mine runs Ubuntu 26.04, but nothing here is version specific.&lt;/li&gt;
&lt;li&gt;A Tailscale account and a tailnet you can already log into. If you're starting cold, &lt;a href="https://peculiarengineer.com/blog/install-tailscale-ubuntu-26-04/" rel="noopener noreferrer"&gt;install Tailscale on the host first&lt;/a&gt; so you have something to test from.&lt;/li&gt;
&lt;li&gt;Admin access to the Tailscale console, because two of the steps happen there and not on the box.&lt;/li&gt;
&lt;li&gt;MagicDNS turned on. It's on by default for new tailnets, and without it you get an IP instead of a name.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You do not need a domain, a DNS record, a certificate, or a single open port in your firewall. That's the point.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one thing to get straight
&lt;/h2&gt;

&lt;p&gt;Forgejo is not sitting behind a reverse proxy here. Forgejo &lt;em&gt;is&lt;/em&gt; the Tailscale node.&lt;/p&gt;

&lt;p&gt;That distinction is the whole post. In the usual setup you'd run Caddy or Nginx on the host, publish ports, and forward traffic to a container. Here, the Tailscale container owns a network namespace, and Forgejo is placed inside that same namespace with &lt;code&gt;network_mode: service:ts-forgejo&lt;/code&gt;. The two containers share one network stack the way two processes on one machine do.&lt;/p&gt;

&lt;p&gt;Once that clicks, the rest follows on its own:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Forgejo publishes no ports to the host, so &lt;code&gt;docker ps&lt;/code&gt; shows nothing listening and the host firewall has nothing to do.&lt;/li&gt;
&lt;li&gt;The machine appears in your tailnet as its own device, with its own name and its own ACL rules, separate from the host it happens to run on.&lt;/li&gt;
&lt;li&gt;Port 22 inside that namespace belongs to Forgejo, and the host's real sshd is somewhere else entirely as far as the network stack is concerned. The port collision that makes the public version of this setup annoying never happens.&lt;/li&gt;
&lt;li&gt;HTTPS arrives without opening port 80. The certificate still comes from Let's Encrypt, but through a DNS-01 challenge that Tailscale completes for you by publishing the &lt;code&gt;_acme-challenge&lt;/code&gt; TXT record under &lt;code&gt;ts.net&lt;/code&gt; for your node. Nothing has to be reachable from the public internet for validation to pass.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The result is a real, publicly trusted certificate on a machine with no public presence at all. Your browser sees a normal padlock. Nothing is exposed.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Turn on HTTPS for your tailnet
&lt;/h2&gt;

&lt;p&gt;Do this before anything else. Skipping it does not stop the stack coming up, which is exactly why it wastes your time later.&lt;/p&gt;

&lt;p&gt;In the Tailscale admin console, go to &lt;strong&gt;DNS&lt;/strong&gt;, and under HTTPS Certificates click &lt;strong&gt;Enable HTTPS&lt;/strong&gt;. Note the tailnet name it shows you, something like &lt;code&gt;tail1234.ts.net&lt;/code&gt;. Every device in your tailnet gets a name under it, so your Forgejo node will end up at &lt;code&gt;forgejo.tail1234.ts.net&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;If you skip this, the sidecar still starts and still says it's running. It logs a line telling you HTTPS is not enabled and links the docs, which is fair enough, but &lt;code&gt;tailscale serve status&lt;/code&gt; just answers &lt;code&gt;No serve config&lt;/code&gt; with no reason attached. The command that gives you a straight answer is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose &lt;span class="nb"&gt;exec &lt;/span&gt;ts-forgejo tailscale cert forgejo.tail1234.ts.net
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With HTTPS off you get &lt;code&gt;your Tailscale account does not support getting TLS certs&lt;/code&gt;. With it on you get two files written and you can move on. I use that as the check before touching anything else, because every other symptom of this is ambiguous.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Make a tagged auth key
&lt;/h2&gt;

&lt;p&gt;The container needs a key to join the tailnet unattended, and the way expiry works here catches people out.&lt;/p&gt;

&lt;p&gt;Auth keys cap out at 90 days, but that expiry only stops &lt;em&gt;new&lt;/em&gt; devices from joining. A node that already registered keeps working until its own node key expires, and that defaults to 180 days. So a plain reusable key gets you roughly six months before your Git host quietly drops off the tailnet on a day you weren't touching anything.&lt;/p&gt;

&lt;p&gt;The fix is a tag. Key expiry is disabled by default for tagged devices, because they're owned by the tailnet rather than by a user.&lt;/p&gt;

&lt;p&gt;First define the tag in your ACL file, under &lt;strong&gt;Access controls&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"tagOwners"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"tag:container"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"autogroup:admin"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then go to &lt;strong&gt;Settings&lt;/strong&gt;, &lt;strong&gt;Keys&lt;/strong&gt;, and generate an auth key with &lt;strong&gt;Reusable&lt;/strong&gt; on and the tag &lt;code&gt;tag:container&lt;/code&gt; applied. Copy it somewhere safe now, because the console shows it exactly once.&lt;/p&gt;

&lt;p&gt;Put it in a &lt;code&gt;.env&lt;/code&gt; file next to your Compose file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# .env&lt;/span&gt;
&lt;span class="nv"&gt;TS_AUTHKEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;tskey-auth-xxxxxxxxxxxx
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And keep that file out of Git:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;".env"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; .gitignore
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  3. The Compose file
&lt;/h2&gt;

&lt;p&gt;Two services. The first is the network, the second is the application.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;ts-forgejo&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;tailscale/tailscale:latest&lt;/span&gt;
    &lt;span class="na"&gt;container_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ts-forgejo&lt;/span&gt;
    &lt;span class="na"&gt;hostname&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;forgejo&lt;/span&gt;                      &lt;span class="c1"&gt;# becomes forgejo.your-tailnet.ts.net&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;TS_AUTHKEY=${TS_AUTHKEY}&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;TS_EXTRA_ARGS=--advertise-tags=tag:container&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;TS_STATE_DIR=/var/lib/tailscale&lt;/span&gt;    &lt;span class="c1"&gt;# persisted, see the volume below&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;TS_SERVE_CONFIG=/config/serve.json&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;TS_USERSPACE=false&lt;/span&gt;                 &lt;span class="c1"&gt;# use the kernel networking path&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;ts-forgejo-state:/var/lib/tailscale&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./ts-config:/config&lt;/span&gt;
    &lt;span class="na"&gt;devices&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/dev/net/tun:/dev/net/tun&lt;/span&gt;
    &lt;span class="na"&gt;cap_add&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;net_admin&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;sys_module&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;

  &lt;span class="na"&gt;forgejo&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;codeberg.org/forgejo/forgejo:15&lt;/span&gt;
    &lt;span class="na"&gt;container_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;forgejo&lt;/span&gt;
    &lt;span class="na"&gt;network_mode&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;service:ts-forgejo&lt;/span&gt;       &lt;span class="c1"&gt;# the whole trick, one line&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;USER_UID=1000&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;USER_GID=1000&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;FORGEJO__server__ROOT_URL=https://forgejo.tail1234.ts.net/&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;FORGEJO__server__SSH_DOMAIN=forgejo.tail1234.ts.net&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;FORGEJO__server__SSH_PORT=22&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;FORGEJO__service__DISABLE_REGISTRATION=true&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;forgejo-data:/data&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/etc/timezone:/etc/timezone:ro&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/etc/localtime:/etc/localtime:ro&lt;/span&gt;
    &lt;span class="na"&gt;depends_on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;ts-forgejo&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;

&lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;ts-forgejo-state&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;forgejo-data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few of those lines are doing more work than they look like they are.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;network_mode: service:ts-forgejo&lt;/code&gt; is the pattern. Notice what is absent: there is no &lt;code&gt;ports:&lt;/code&gt; block anywhere in this file. Forgejo listens on 3000 and 22 inside the shared namespace, reachable over the tailnet and nowhere else.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;TS_USERSPACE=false&lt;/code&gt; matters more than it looks. The Tailscale image defaults to userspace networking, and in that mode the TUN device and the &lt;code&gt;net_admin&lt;/code&gt; capability sit there unused. Turning it off puts the container on the kernel networking path those lines exist for, which is what you want when something in the namespace needs to answer on a real port.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;hostname: forgejo&lt;/code&gt; is what decides the name. Tailscale registers the container under it, so this is the value that ends up in your URL. Pick it before first boot, because renaming a node later means fixing &lt;code&gt;ROOT_URL&lt;/code&gt; too.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;TS_STATE_DIR&lt;/code&gt; with a real volume behind it is the difference between one node and a hundred. More on that in the gotchas below.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;FORGEJO__server__ROOT_URL&lt;/code&gt; uses Forgejo's double underscore convention, where &lt;code&gt;FORGEJO__section__KEY&lt;/code&gt; maps to a key in &lt;code&gt;app.ini&lt;/code&gt;. Get this value right before you ever start the container. Forgejo bakes it into the clone URLs it shows you, the links in its emails, and its webhook targets. Wrong here means every clone command your users copy points at the wrong place, and it looks fine on screen right up until someone tries it.&lt;/p&gt;

&lt;p&gt;There is also something deliberately missing. You will see &lt;code&gt;START_SSH_SERVER=true&lt;/code&gt; in a lot of Forgejo examples, and it does not belong here. The rootful image runs OpenSSH on port 22 by itself, unconditionally. Setting that variable starts Forgejo's own Go SSH server as well, and now two SSH servers want the same port in the same namespace. Leave it out and the image does the right thing on its own. That variable is for the rootless image, which is a different setup with SSH on 2222.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;DISABLE_REGISTRATION=true&lt;/code&gt; from the first boot means nobody gets to sign up while you're still setting things up. You'll create your admin account through the installer instead.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. The serve config
&lt;/h2&gt;

&lt;p&gt;Create &lt;code&gt;ts-config/serve.json&lt;/code&gt; next to your Compose file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"TCP"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"443"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"HTTPS"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Web"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"${TS_CERT_DOMAIN}:443"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Handlers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"/"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"Proxy"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"http://127.0.0.1:3000"&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"AllowFunnel"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"${TS_CERT_DOMAIN}:443"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;${TS_CERT_DOMAIN}&lt;/code&gt; is substituted by the Tailscale container at startup with the node's real name, so you don't have to hardcode your tailnet name in a second place.&lt;/p&gt;

&lt;p&gt;The proxy target is &lt;code&gt;127.0.0.1:3000&lt;/code&gt;, and that is only correct because of the shared namespace. Forgejo really is on localhost from Tailscale's point of view. If you ever pull these two apart into separate networks, this line breaks first.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;AllowFunnel&lt;/code&gt; is set to &lt;code&gt;false&lt;/code&gt; on purpose. It's the switch that would put this node on the public internet, and having it present and off is better than having it absent, because you can see what the answer currently is.&lt;/p&gt;

&lt;p&gt;Bring it up:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt;
docker compose logs &lt;span class="nt"&gt;-f&lt;/span&gt; ts-forgejo
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Watch for the node registering and the certificate being issued. Then check it from another machine on your tailnet:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;tailscale status | &lt;span class="nb"&gt;grep &lt;/span&gt;forgejo
curl &lt;span class="nt"&gt;-I&lt;/span&gt; https://forgejo.tail1234.ts.net/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A &lt;code&gt;200&lt;/code&gt; and a valid certificate means the hard part is done.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. First boot and locking the installer
&lt;/h2&gt;

&lt;p&gt;Open &lt;code&gt;https://forgejo.tail1234.ts.net/&lt;/code&gt; in a browser on any device in your tailnet and you get Forgejo's setup page.&lt;/p&gt;

&lt;p&gt;Two things matter here. Leave the database as SQLite unless you have a reason not to. For a personal or small team instance it's genuinely fine, it's one less container to run and one less thing to back up, and you can migrate later if the instance grows into something that needs Postgres. And check that the URL fields on the form match the &lt;code&gt;ROOT_URL&lt;/code&gt; you set, because the installer will happily write different values into &lt;code&gt;app.ini&lt;/code&gt; and leave you with two sources of truth.&lt;/p&gt;

&lt;p&gt;Create your admin account on that same form. Do not skip it and do it later.&lt;/p&gt;

&lt;p&gt;The reason for the urgency is smaller here than on a public box, but it still applies: until the installer is completed, whoever reaches that page can complete it and become the administrator. On a tailnet that's limited to devices you've already authorised, which is a much shorter list than "the internet". It's still a list. Finish the form.&lt;/p&gt;

&lt;p&gt;Once you're in, confirm the install lock landed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose &lt;span class="nb"&gt;exec &lt;/span&gt;forgejo &lt;span class="nb"&gt;grep &lt;/span&gt;INSTALL_LOCK /data/gitea/conf/app.ini
&lt;span class="c"&gt;# INSTALL_LOCK = true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  6. Cloning over SSH
&lt;/h2&gt;

&lt;p&gt;This is the part that would have been a whole section of fighting in the public version, and here it's almost nothing.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone git@forgejo.tail1234.ts.net:yourname/yourrepo.git
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Port 22, no custom port, no &lt;code&gt;-p 2222&lt;/code&gt; to remember, no conflict with the host's own sshd. The image's SSH server owns port 22 inside its namespace, and the host's sshd owns port 22 on the host, and they never meet.&lt;/p&gt;

&lt;p&gt;Add your public key in the Forgejo UI under &lt;strong&gt;Settings&lt;/strong&gt;, &lt;strong&gt;SSH / GPG Keys&lt;/strong&gt;, the same as you would anywhere else.&lt;/p&gt;

&lt;p&gt;One thing to check: the clone URL Forgejo displays comes from &lt;code&gt;SSH_DOMAIN&lt;/code&gt; and &lt;code&gt;SSH_PORT&lt;/code&gt;. If those disagree with reality, the button copies a command that fails, and the error the user sees is a connection timeout that tells them nothing about why.&lt;/p&gt;

&lt;h2&gt;
  
  
  Who can actually reach it
&lt;/h2&gt;

&lt;p&gt;Right now, every device in your tailnet can reach it. That's fine for a tailnet of one person and three laptops. It stops being fine the moment you add a contractor's machine or a server that runs someone else's code.&lt;/p&gt;

&lt;p&gt;ACLs fix that. &lt;strong&gt;This is a fragment to merge into your existing policy, not a whole policy file.&lt;/strong&gt; If you paste it over the top of everything, you delete the default rule that lets your tailnet talk to itself, and every other connection you have stops working at the same moment.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"groups"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"group:devs"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"you@example.com"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"collaborator@example.com"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"tagOwners"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"tag:container"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"autogroup:admin"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"acls"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;...&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;your&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;existing&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;rules&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;stay&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;here&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;...&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"action"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"accept"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"src"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"group:devs"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"dst"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"tag:container:443,22"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you already worked through the ACL section of &lt;a href="https://peculiarengineer.com/blog/tailscale-private-networking-workers-to-prod/" rel="noopener noreferrer"&gt;Tailscale for private networking&lt;/a&gt;, this is the same machinery pointed at a container instead of a server.&lt;/p&gt;

&lt;h2&gt;
  
  
  When you want it public after all
&lt;/h2&gt;

&lt;p&gt;Sometimes you need to hand someone a link without adding them to your tailnet. Tailscale Funnel does that, and it's less of a one liner than it looks.&lt;/p&gt;

&lt;p&gt;Two things have to be true first. Your tailnet policy needs the &lt;code&gt;funnel&lt;/code&gt; node attribute, which for a tagged container node you will be adding by hand:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"nodeAttrs"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"target"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"tag:container"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"attr"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"funnel"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then flip the switch in &lt;code&gt;serve.json&lt;/code&gt; and restart, which is the durable way to do it since &lt;code&gt;TS_SERVE_CONFIG&lt;/code&gt; reapplies that file every time the container starts:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nl"&gt;"AllowFunnel"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"${TS_CERT_DOMAIN}:443"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Restart with &lt;code&gt;docker compose restart&lt;/code&gt; and no service name after you edit that file. Restarting only the Tailscale container is what leaves Forgejo answering &lt;code&gt;502&lt;/code&gt; behind a network stack that moved out from under it, which is the gotcha below and the one that wastes the most time.&lt;/p&gt;

&lt;p&gt;Funnel only works on ports 443, 8443, and 10000, so 443 is the one you want anyway.&lt;/p&gt;

&lt;p&gt;The node attribute is not optional, and skipping it is the worst kind of failure. I flipped &lt;code&gt;AllowFunnel&lt;/code&gt; to &lt;code&gt;true&lt;/code&gt; without it and everything told me it had worked. &lt;code&gt;tailscale funnel status&lt;/code&gt; printed &lt;code&gt;Funnel on&lt;/code&gt; with the URL under it, &lt;code&gt;tailscale serve status&lt;/code&gt; agreed, and the logs said nothing at all. The name simply never appeared in public DNS, so from outside the tailnet it did not resolve, let alone serve. The node did not have the capability and no part of the tooling mentioned it.&lt;/p&gt;

&lt;p&gt;If you turn Funnel on, verify it from something that is not on your tailnet. A phone with WiFi off is enough:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dig +short @1.1.1.1 forgejo.tail1234.ts.net
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An empty answer means Funnel is not really on, whatever the CLI told you.&lt;/p&gt;

&lt;p&gt;Once it's on, you have inherited every problem this post was avoiding. Registration lockdown and rate limiting become your concern again. It's a good escape hatch and a bad default. Set it back to &lt;code&gt;false&lt;/code&gt; when you're done.&lt;/p&gt;

&lt;p&gt;The honest limitation: if the people you collaborate with will not join your tailnet, this setup is not for you. Tailnet only means tailnet only. Everyone who touches these repos needs Tailscale on their machine and a place in your ACLs. For a solo developer or a small team that already uses Tailscale, that cost is zero. For an open source project taking drive by contributions, it's a wall.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gotchas I hit
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;No state volume, so every restart mints a new node.&lt;/strong&gt; This is the big one, and I watched it happen. If &lt;code&gt;TS_STATE_DIR&lt;/code&gt; has no volume behind it, the container loses its identity, registers again as a fresh device, and Tailscale appends a suffix to keep the name unique. Mine came back as &lt;code&gt;forgejo-1&lt;/code&gt; on a new address while the old &lt;code&gt;forgejo&lt;/code&gt; sat there marked offline.&lt;/p&gt;

&lt;p&gt;What makes it nasty is how healthy the result looks. The new node got its own certificate within seconds and served Forgejo on 443 without complaint. But &lt;code&gt;ROOT_URL&lt;/code&gt; and &lt;code&gt;SSH_DOMAIN&lt;/code&gt; live in &lt;code&gt;app.ini&lt;/code&gt; inside the data volume, so they still held the old name, and the API cheerfully handed out clone URLs pointing at a node nothing is listening on. The server is fine. Every clone command it gives your users times out. Persist &lt;code&gt;/var/lib/tailscale&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What losing that state does to you depends on your auth key.&lt;/strong&gt; With a reusable key you get the duplicate node above. With a single use key the container cannot register at all, fails with &lt;code&gt;invalid key: API key ... not valid&lt;/code&gt;, and never comes up. Two completely different mornings from the same missing volume.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Auth failures loop instead of stopping.&lt;/strong&gt; Every registration failure I hit, whether a tag the policy did not allow or a spent key, left the container restarting rather than exiting. With &lt;code&gt;restart: unless-stopped&lt;/code&gt; that continues forever. &lt;code&gt;docker compose ps&lt;/code&gt; shows &lt;code&gt;restarting&lt;/code&gt;, not an error, so if you run &lt;code&gt;up -d&lt;/code&gt; and walk away you come back to something that has been failing quietly for however long you were gone. The reason is only ever in &lt;code&gt;docker compose logs&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;An untagged node drops off at six months, not three.&lt;/strong&gt; The auth key expiring at 90 days is the number everyone quotes, but that only blocks new registrations. The node itself runs until its node key hits the 180 day default, which is a much worse way to find out, because by then you've forgotten the setup entirely. Tag the device and key expiry is off.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The tag has to exist before you advertise it.&lt;/strong&gt; &lt;code&gt;tag:container&lt;/code&gt; is my example name, not a default. If it is not in &lt;code&gt;tagOwners&lt;/code&gt; in your policy the node refuses to join with &lt;code&gt;requested tags [tag:container] are invalid or not permitted&lt;/code&gt;, which is at least an honest error. Use whatever tag your tailnet already has if you have one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tailscale SSH swallows Git over SSH.&lt;/strong&gt; If you add &lt;code&gt;--ssh&lt;/code&gt; to &lt;code&gt;TS_EXTRA_ARGS&lt;/code&gt;, tailscaled intercepts inbound tailnet connections to port 22 before they ever reach the SSH server in the container. There's no bind conflict and nothing looks broken in the logs. Your clones just stop working. Leave Tailscale SSH off on this node and use it on your other machines.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Restarting the sidecar alone breaks Forgejo in a way that looks like something else.&lt;/strong&gt; This one cost me the most time, because I did it to myself repeatedly while testing and kept diagnosing the wrong thing. &lt;code&gt;docker compose restart ts-forgejo&lt;/code&gt; gives Forgejo a network stack that has moved out from under it. Forgejo keeps running, &lt;code&gt;docker compose ps&lt;/code&gt; says both are &lt;code&gt;Up&lt;/code&gt;, and the tailnet name still resolves.&lt;/p&gt;

&lt;p&gt;What you get is a broken instance that points nowhere useful. The web side answers &lt;code&gt;502&lt;/code&gt;, because tailscaled terminates TLS perfectly well and then cannot reach &lt;code&gt;127.0.0.1:3000&lt;/code&gt; any more. Git over SSH gives you &lt;code&gt;Connection refused&lt;/code&gt;, or just hangs with no banner. Neither symptom points at the container you actually restarted.&lt;/p&gt;

&lt;p&gt;The fix is &lt;code&gt;docker compose restart&lt;/code&gt; with no service name, so both come back together. I checked this twice: sidecar alone gives 502 and a refused clone, both together gives 200 and a clean clone. Any time you touch the Tailscale container, take Forgejo with it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;ROOT_URL&lt;/code&gt; left over from an earlier attempt.&lt;/strong&gt; If you tried this once with a different hostname, changing the environment variable is not always enough, because the installer wrote the old value into &lt;code&gt;app.ini&lt;/code&gt; and that file lives in the volume. Check &lt;code&gt;/data/gitea/conf/app.ini&lt;/code&gt; and fix it there, or start from a clean volume.&lt;/p&gt;

&lt;h2&gt;
  
  
  Quick reference
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Setting&lt;/th&gt;
&lt;th&gt;What it controls&lt;/th&gt;
&lt;th&gt;What breaks without it&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;network_mode: service:ts-forgejo&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Puts Forgejo in the sidecar's network namespace&lt;/td&gt;
&lt;td&gt;Forgejo is not on the tailnet at all&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;TS_USERSPACE=false&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Kernel networking instead of userspace&lt;/td&gt;
&lt;td&gt;The TUN device and &lt;code&gt;net_admin&lt;/code&gt; do nothing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;hostname&lt;/code&gt; on the sidecar&lt;/td&gt;
&lt;td&gt;The node name, and so the URL&lt;/td&gt;
&lt;td&gt;Random or wrong &lt;code&gt;ts.net&lt;/code&gt; name&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;TS_STATE_DIR&lt;/code&gt; plus a volume&lt;/td&gt;
&lt;td&gt;Node identity across restarts&lt;/td&gt;
&lt;td&gt;New device on every restart, name drifts&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;TS_EXTRA_ARGS=--advertise-tags&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Marks the node as tailnet owned&lt;/td&gt;
&lt;td&gt;Node key expires at 180 days, node drops off&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;TS_SERVE_CONFIG&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;HTTPS and the proxy to port 3000&lt;/td&gt;
&lt;td&gt;No TLS, nothing served on 443&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;FORGEJO__server__ROOT_URL&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Clone URLs, emails, webhooks&lt;/td&gt;
&lt;td&gt;Everything points somewhere wrong&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;FORGEJO__server__SSH_DOMAIN&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The SSH clone URL shown in the UI&lt;/td&gt;
&lt;td&gt;Copy button hands out a command that times out&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;no &lt;code&gt;START_SSH_SERVER&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Lets the image's own sshd own port 22&lt;/td&gt;
&lt;td&gt;Two SSH servers want the same port&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Useful commands:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose logs &lt;span class="nt"&gt;-f&lt;/span&gt; ts-forgejo                      &lt;span class="c"&gt;# node registration and cert issuance&lt;/span&gt;
docker compose &lt;span class="nb"&gt;exec &lt;/span&gt;ts-forgejo tailscale status        &lt;span class="c"&gt;# is it on the tailnet&lt;/span&gt;
docker compose &lt;span class="nb"&gt;exec &lt;/span&gt;ts-forgejo tailscale serve status   &lt;span class="c"&gt;# what is being served on 443&lt;/span&gt;

&lt;span class="c"&gt;# backup, written somewhere writable and then copied out&lt;/span&gt;
docker compose &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; 1000 &lt;span class="nt"&gt;-w&lt;/span&gt; /tmp forgejo &lt;span class="se"&gt;\&lt;/span&gt;
  forgejo dump &lt;span class="nt"&gt;-c&lt;/span&gt; /data/gitea/conf/app.ini

&lt;span class="c"&gt;# the archive is named forgejo-dump-&amp;lt;timestamp&amp;gt;.zip, so read the name back&lt;/span&gt;
&lt;span class="nv"&gt;DUMP&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;docker compose &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-T&lt;/span&gt; forgejo sh &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'ls -1 /tmp/forgejo-dump-*.zip | tail -1'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
docker compose &lt;span class="nb"&gt;cp&lt;/span&gt; &lt;span class="s2"&gt;"forgejo:&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;DUMP&lt;/span&gt;&lt;span class="p"&gt;%&lt;/span&gt;&lt;span class="s1"&gt;$'&lt;/span&gt;&lt;span class="se"&gt;\r&lt;/span&gt;&lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; ./
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That timestamp is the part that bites. &lt;code&gt;forgejo dump&lt;/code&gt; prints the filename it wrote and then you are on your own, so a copy command with a fixed name in it fails every time and you find out when you need the backup.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;[ no open ports · real certs · git@ over the tailnet ]&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://peculiarengineer.com/blog/self-host-forgejo-tailscale-docker-compose/" rel="noopener noreferrer"&gt;peculiarengineer.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>selfhosted</category>
      <category>docker</category>
      <category>git</category>
      <category>tailscale</category>
    </item>
    <item>
      <title>Your MCP server's search is bad: ranking, embeddings, and what each one fixes</title>
      <dc:creator>Minor Keith</dc:creator>
      <pubDate>Tue, 28 Jul 2026 02:34:37 +0000</pubDate>
      <link>https://dev.to/peculiarengineer/your-mcp-servers-search-is-bad-ranking-embeddings-and-what-each-one-fixes-373n</link>
      <guid>https://dev.to/peculiarengineer/your-mcp-servers-search-is-bad-ranking-embeddings-and-what-each-one-fixes-373n</guid>
      <description>&lt;p&gt;At the end of &lt;a href="https://peculiarengineer.com/blog/build-your-first-mcp-server-python/" rel="noopener noreferrer"&gt;Part One&lt;/a&gt; I left the notes server with a search I described as dumb, and promised to come back for it. Here is where it stands today, on the same query:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nf"&gt;search_notes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;firewall&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;build-your-first-mcp-server-python: Build your first MCP server in Python: give Claude your own notes
caddy-reverse-proxy-docker-compose-ubuntu-26-04: Reverse Proxy Your Containers with Caddy and Docker Compose on Ubuntu 26.04
create-sudo-user-ubuntu-26-04: Create a Sudo User on Ubuntu 26.04
enable-ssh-on-ubuntu-desktop: Enable SSH on an Ubuntu desktop
extend-azure-windows-disk-run-command: Extending C: on locked-down Azure Windows VMs without RDP
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It got worse. The top hit is now Part One itself, the post complaining that this search is bad, which mentions "firewall" only because it was quoting this exact output. The blog is up to 51 posts, 17 of them mention firewalls somewhere, and &lt;a href="https://peculiarengineer.com/blog/ufw-firewall-basics-ubuntu/" rel="noopener noreferrer"&gt;UFW Firewall Basics&lt;/a&gt; is still not in the list.&lt;/p&gt;

&lt;p&gt;This post fixes it in two stages, because there are two different problems here and only one of them is the one everybody reaches for. The first is a ranking bug and it costs about twenty lines. The second is a query that no amount of ranking will ever answer, and that one costs an embedding model.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt; Score every hit and sort, instead of taking the first five in alphabetical order. That alone puts the right post on top. Then add local embeddings with &lt;code&gt;ollama pull nomic-embed-text&lt;/code&gt;, chunked on &lt;code&gt;##&lt;/code&gt; headings and cached in an &lt;code&gt;.npz&lt;/code&gt; keyed by path, mtime and model name. Prefix documents with &lt;code&gt;search_document:&lt;/code&gt; and queries with &lt;code&gt;search_query:&lt;/code&gt;, which doubled how often my index put the right post first. Build the index from a command, not on the first search, because 496 chunks took four minutes on CPU. And keep both searches, because they fail in different places: the exact match misses &lt;code&gt;brute force&lt;/code&gt; over a hyphen, and the embeddings miss it because two words are not enough meaning to work with.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Before you start
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;The finished server from &lt;a href="https://peculiarengineer.com/blog/build-your-first-mcp-server-python/" rel="noopener noreferrer"&gt;Part One&lt;/a&gt;. Everything below edits that &lt;code&gt;server.py&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://peculiarengineer.com/blog/install-ollama-ubuntu-26-04-nvidia-gpu/" rel="noopener noreferrer"&gt;Ollama&lt;/a&gt; running locally. That post is about feeding a GPU for chat models, and none of that applies here: embedding models are small and a CPU is fine for this job.&lt;/li&gt;
&lt;li&gt;Two dependencies: &lt;code&gt;uv add ollama numpy&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  1. The bug was ranking, not matching
&lt;/h2&gt;

&lt;p&gt;Part One already named this, and it is worth quoting because the diagnosis is the whole of Stage One:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The substring match did not fail. It found the UFW post fine. The problem is that there is no ranking at all.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Look at what the original loop does:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;hits&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;_posts&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read_text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;encoding&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;utf-8&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
        &lt;span class="n"&gt;hits&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stem&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;_title&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stem&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hits&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;break&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;_posts()&lt;/code&gt; globs the &lt;code&gt;.md&lt;/code&gt; files and the &lt;code&gt;.mdx&lt;/code&gt; files separately and concatenates the two sorted lists, so the order is alphabetical within each extension and any &lt;code&gt;.mdx&lt;/code&gt; file lands at the end no matter what it is called. The loop appends in that order and breaks at five. So &lt;code&gt;search_notes&lt;/code&gt; is not returning the five best matches for "firewall," it is returning the first five files in that order that contain the string anywhere, code blocks and links included. &lt;code&gt;u&lt;/code&gt; sorts after &lt;code&gt;b&lt;/code&gt;, &lt;code&gt;c&lt;/code&gt;, and &lt;code&gt;e&lt;/code&gt;, so the post that is entirely about firewalls loses to four posts that mention them in passing.&lt;/p&gt;

&lt;p&gt;That is worse than returning nothing, because five confident, plausible, wrong results are an answer the model will happily build on.&lt;/p&gt;

&lt;p&gt;The fix is to stop treating a match as a boolean. Collect every hit, score it, sort, then truncate:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_frontmatter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;parts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;---&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;parts&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;parts&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_fm_line&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fm&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;next&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;l&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;l&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;fm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;splitlines&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;l&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startswith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_score&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;low&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;q&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;low&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;

    &lt;span class="n"&gt;fm&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_frontmatter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;low&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;score&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;q&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;_fm_line&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fm&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;title: &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;\&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;):&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="n"&gt;score&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;q&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stem&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
        &lt;span class="n"&gt;score&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;q&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;_fm_line&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fm&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tags:&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;score&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;40&lt;/span&gt;
    &lt;span class="c1"&gt;# Body mentions count for something, but cap them so a 4,000 word post
&lt;/span&gt;    &lt;span class="c1"&gt;# cannot win on length alone.
&lt;/span&gt;    &lt;span class="n"&gt;score&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;low&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;score&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four signals, weighted by how much intent each carries. A title match scores highest because a title is the strongest claim a post makes about its own subject. The slug is close behind, since I hand-write slugs on this blog and they say what the post is about. Tags sit below that, and body count is the tiebreaker, capped at ten so a long post cannot grind out a win on length alone.&lt;/p&gt;

&lt;p&gt;Then pull the ranked lookup into its own function, because Stage Two is going to need it separately from the tool:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_keyword_hits&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;tuple&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;]]:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Every post containing the phrase, best first, as (slug, title).&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;q&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;scored&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;_posts&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
        &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read_text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;encoding&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;utf-8&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;score&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_score&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;score&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;scored&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;score&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stem&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;_title&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stem&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt;

    &lt;span class="n"&gt;scored&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;lambda&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]))&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[(&lt;/span&gt;&lt;span class="n"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;scored&lt;/span&gt;&lt;span class="p"&gt;[:&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;]]&lt;/span&gt;


&lt;span class="nd"&gt;@mcp.tool&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;search_notes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Search my blog posts for a word or phrase.

    Matches the literal phrase anywhere in a post. Results are ranked: a match
    in the title, slug or tags outranks a passing mention in the body. Returns
    the 5 best hits with slug and title. Use get_note with a slug to read a
    full post.
    &lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;hits&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_keyword_hits&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;hits&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;No posts mention &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="si"&gt;!r}&lt;/span&gt;&lt;span class="s"&gt;.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;slug&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;hits&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same query, same corpus:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ufw-firewall-basics-ubuntu: UFW Firewall Basics on Ubuntu
hardening-ubuntu-desktop: Hardening an Ubuntu Desktop
ssh-connection-refused-port-22-ubuntu: Troubleshooting "ssh: connect to host port 22: Connection refused"
build-your-first-mcp-server-python: Build your first MCP server in Python: give Claude your own notes
enable-ssh-on-ubuntu-desktop: Enable SSH on an Ubuntu desktop
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The UFW post scores 230 against 61 for the runner up, because it hits the title, the slug, the tags and the body. Part One drops to fourth where it belongs.&lt;/p&gt;

&lt;p&gt;The docstring changed too. Part One's said "There is no ranking" and told the model to search repeatedly with different wording. That was honest then and is wrong now, and left alone it would keep the model burning three calls to work around a limitation that no longer exists.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If you have fifty markdown files, you could stop reading here.&lt;/strong&gt; Twenty lines, no new dependencies, no model, nothing to keep in sync. That is a real answer and I am not going to pretend otherwise to justify the rest of the post.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. The query ranking cannot touch
&lt;/h2&gt;

&lt;p&gt;Here is the one that sent me looking further. I have a post about stopping repeated SSH login attempts. Ask for it the way a person would ask:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nf"&gt;search_notes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;brute force&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;No posts mention 'brute force'.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Not badly ranked, not buried at position nine. Zero, across 51 posts.&lt;/p&gt;

&lt;p&gt;Two separate failures are stacked on top of each other there. &lt;a href="https://peculiarengineer.com/blog/ufw-firewall-basics-ubuntu/" rel="noopener noreferrer"&gt;UFW Firewall Basics&lt;/a&gt; says "throttle brute-force knocking," so the word is right there in the corpus, and it does not match because I typed a space where the post has a hyphen. Substring matching is not looking for a concept, it is looking for a byte sequence, and those are two different byte sequences.&lt;/p&gt;

&lt;p&gt;The second failure is the one that matters. The post that actually answers this question is the &lt;a href="https://peculiarengineer.com/blog/set-up-fail2ban-ubuntu-26-04/" rel="noopener noreferrer"&gt;Fail2ban post&lt;/a&gt;, and it does not contain the word in any spelling. It never says "brute" or "attack" at all. It opens like this:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A public SSH server starts collecting failed logins almost as soon as it gets an address. Key-only authentication makes those guesses useless, but it does not stop the same addresses filling the journal all day.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;"Failed logins," "guesses," "the same addresses." Every one of those describes the thing better than "brute force" does, and not one of them is the phrase somebody would type into a search box. I wrote a whole post about defending against brute force attacks without ever using the term.&lt;/p&gt;

&lt;p&gt;This is where ranking runs out. Ranking orders the matches you already have. It cannot manufacture one for a word you never wrote.&lt;/p&gt;

&lt;p&gt;And the caller here is not me. It is a model turning somebody's half-remembered question into search terms, and it will not guess my vocabulary. Part One patched around this by telling the model in the docstring to try different wording, which works about as well as it sounds. What the server needs is a search that matches on meaning instead of spelling.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. What an embedding actually is
&lt;/h2&gt;

&lt;p&gt;A model reads a piece of text and hands back a fixed-length list of floats. That is all. &lt;code&gt;nomic-embed-text&lt;/code&gt; gives you 768 of them. The useful property is that texts about similar things land near each other in that 768-dimensional space, so "brute force attack" and "the same addresses filling the journal" end up close together despite sharing no words.&lt;/p&gt;

&lt;p&gt;"Near" is cosine similarity, which for unit-length vectors is just a dot product: multiply the two lists element by element, add up the results, get a number between -1 and 1. Higher is more related. That is the entire retrieval algorithm.&lt;/p&gt;

&lt;p&gt;Here is what you do &lt;strong&gt;not&lt;/strong&gt; need, despite what a search for this will tell you: a vector database. No Chroma, no pgvector, no Pinecone, no index structure of any kind. Fifty-one posts chunked by heading comes to 496 sections, so the entire index is a 496 by 768 array of float32, about 1.5 MB. Comparing a query against every single one of them is one matrix multiply. You need a real vector store somewhere in the tens of thousands of documents, when scanning everything stops being free. Below that it is a file you can delete when it gets weird, which is worth more than it sounds.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Embeddings locally with Ollama
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ollama pull nomic-embed-text
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The current SDK call is &lt;code&gt;embed()&lt;/code&gt;, which takes a list and returns a list:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;ollama&lt;/span&gt;

&lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ollama&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;embed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;nomic-embed-text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;input&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;hello&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;goodbye&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;embeddings&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;embeddings&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two vectors of 768 floats each. The older &lt;code&gt;ollama.embeddings(prompt=...)&lt;/code&gt; is the deprecated single-string version. Use &lt;code&gt;embed()&lt;/code&gt;: batching matters later.&lt;/p&gt;

&lt;p&gt;Two things about this model are not optional, and both fail silently.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Prefix your inputs.&lt;/strong&gt; &lt;code&gt;nomic-embed-text&lt;/code&gt; is trained with task prefixes. Documents go in as &lt;code&gt;search_document: {text}&lt;/code&gt; and queries as &lt;code&gt;search_query: {text}&lt;/code&gt;. Ollama will not add them for you. Leave them off and nothing breaks: you get vectors, plausible similarity scores, and ranked results. There is no error to chase.&lt;/p&gt;

&lt;p&gt;I nearly talked myself out of this one. Comparing raw cosine scores between two documents, prefixed and not, the numbers barely move, which looks like proof that the prefixes are folklore. They are not. Raw cosine is the wrong thing to measure, because a change that shifts every score equally changes no rankings. What matters is where the right post lands.&lt;/p&gt;

&lt;p&gt;So I built the index twice over all 496 chunks and ran eight questions with known answers through both:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Mean reciprocal rank&lt;/th&gt;
&lt;th&gt;Correct post first&lt;/th&gt;
&lt;th&gt;Mean rank&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;No prefixes&lt;/td&gt;
&lt;td&gt;0.480&lt;/td&gt;
&lt;td&gt;2 of 8&lt;/td&gt;
&lt;td&gt;3.6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;With prefixes&lt;/td&gt;
&lt;td&gt;0.682&lt;/td&gt;
&lt;td&gt;4 of 8&lt;/td&gt;
&lt;td&gt;2.4&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Prefixing doubles the number of questions answered correctly on the first result. "Something keeps trying to log into my server over and over" goes from seventh place to first. That is not folklore, and it costs one f-string.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Know where it truncates.&lt;/strong&gt; Ollama serves this model with a 2,048 token context. Feed it a longer chunk and it drops the tail and tells you nothing, which is the second silent failure in a row.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;ollama show&lt;/code&gt; will tell you, if you think to ask:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ ollama show nomic-embed-text
  Model
    architecture        nomic-bert
    parameters          137M
    context length      2048
    embedding length    768
    quantization        F16

  Capabilities
    embedding

  Parameters
    num_ctx    8192
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read those last two numbers together, because between them they are the whole trap. The architecture stops at 2,048, and the model file already asks for 8,192. The setting you were about to reach for is set, and it is not doing anything.&lt;/p&gt;

&lt;p&gt;The obvious move is &lt;code&gt;options={"num_ctx": 8192}&lt;/code&gt;, since nomic's own model card describes an 8,192 window. It does not work, and it is worth showing how I know, because "the flag had no effect" is a hard thing to prove by staring at vectors.&lt;/p&gt;

&lt;p&gt;Take one long document, copy it, and change only the last sentence. If the model reads the whole thing, the two copies must embed slightly differently. If it truncates, the two copies are byte-identical up to the cut and the vectors come back the same:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;filler&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;The firewall configuration is stored in the usual place. &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;400&lt;/span&gt;
&lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;filler&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt; ZEBRA QUASAR MARMALADE.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;filler&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt; The cat sat quietly on the warm mat.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is roughly 5,700 tokens of identical text with two very different endings. On Ollama 0.32.4:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;num_ctx=None  -&amp;gt; cosine(a,b)=1.000000
num_ctx=8192  -&amp;gt; cosine(a,b)=1.000000
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Identical to six decimal places, with and without the flag. Everything past the limit was thrown away in both cases. The clamp is in the Ollama log if you go looking, one WARN line and then the model loads at 2,048 regardless:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;level=WARN source=server.go:114 msg="requested context size too large for model"
  num_ctx=8192 n_ctx_train=2048
llama_context: n_ctx = 2048
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Treat &lt;code&gt;num_ctx&lt;/code&gt; as a request, not a setting, and check it the way I did rather than trusting either the model card or me.&lt;/p&gt;

&lt;p&gt;The better answer is to not need it. Chunking on &lt;code&gt;##&lt;/code&gt; headings does most of the work for you. Across this blog, 496 sections, the longest one in any prose post is 7,825 characters, which at the usual four-characters-per-token rule of thumb lands just under the limit. The only sections that clearly blow through it are the four complete-source dumps at the end of the LÖVE posts, and the largest of those are past 8,192 as well, so raising the window would not have rescued them either. A wall of Lua that size should not be competing for search hits anyway. Split those or skip them.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Wiring it into the server
&lt;/h2&gt;

&lt;p&gt;Four pieces: chunk, embed, cache, search.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Chunking is not optional.&lt;/strong&gt; One vector for a 4,000 word post averages every idea in it into mush. The &lt;a href="https://peculiarengineer.com/blog/hardening-ubuntu-26-04-server/" rel="noopener noreferrer"&gt;server hardening post&lt;/a&gt; covers SSH, the firewall, unattended upgrades, fail2ban and livepatch; embedded whole, it is a vector for "Ubuntu things," equally mediocre at all five. Chunk it and each section gets to be about one thing.&lt;/p&gt;

&lt;p&gt;Every post on this blog uses &lt;code&gt;##&lt;/code&gt; headings, so the chunk boundaries already exist. That also means a hit can report which section matched, which is strictly more useful to the model than a slug.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;numpy&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;ollama&lt;/span&gt;

&lt;span class="n"&gt;MODEL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;nomic-embed-text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;INDEX&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;__file__&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;parent&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;index.npz&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;FLOOR&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.5&lt;/span&gt;          &lt;span class="c1"&gt;# minimum cosine score worth returning, see below
&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_chunks&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;tuple&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;]]:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Split a post into (heading, text) pairs on ## boundaries.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read_text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;encoding&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;utf-8&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;title&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_title&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stem&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;---&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

    &lt;span class="n"&gt;sections&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;heading&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;buf&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[],&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;intro&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;splitlines&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startswith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;## &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;buf&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="n"&gt;sections&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;heading&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;buf&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt;
            &lt;span class="n"&gt;heading&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;buf&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;:].&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
        &lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;buf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;buf&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;sections&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;heading&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;buf&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt;

    &lt;span class="c1"&gt;# Carry the title into every chunk so a section knows what post it is from.
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="s"&gt;## &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;sections&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;]&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_embed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;texts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;prefix&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ndarray&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ollama&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;embed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;MODEL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;input&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;prefix&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;texts&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="nf"&gt;except &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ollama&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ResponseError&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;ConnectionError&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="c1"&gt;# ConnectionError is the daemon not running. stderr, never stdout.
&lt;/span&gt;        &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ollama embed failed: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;file&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stderr&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt;
    &lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;embeddings&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dtype&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;float32&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="c1"&gt;# Normalize once, here, so search is a plain dot product later.
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;linalg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;norm&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;axis&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;keepdims&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now the cache, and this is the part where my first design was wrong.&lt;/p&gt;

&lt;p&gt;Embedding the whole blog takes real time. On my Mac, CPU only, 496 chunks indexed in 239 seconds. That is 482 ms per chunk, and it is four minutes of wall clock before the first search can return anything.&lt;/p&gt;

&lt;p&gt;My instinct was to build the index lazily on the first search, so &lt;code&gt;mcp.run()&lt;/code&gt; starts instantly and the handshake never blocks. That is the right instinct and the wrong conclusion: it moves a four minute wait from startup, where nobody is looking, onto a tool call the model is waiting on. Build the index ahead of time instead, from a command you run yourself, and have the server load a file that already exists. The lazy path stays as the fallback for a cold cache, but it should be the exception, not the plan.&lt;/p&gt;

&lt;p&gt;So: build it once, keep it on disk, and only re-embed what changed.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_load_cache&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;tuple&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ndarray&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;tuple&lt;/span&gt;&lt;span class="p"&gt;]]:&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;INDEX&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exists&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
    &lt;span class="n"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;load&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;INDEX&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;allow_pickle&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;model&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;MODEL&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;      &lt;span class="c1"&gt;# a model swap invalidates every vector
&lt;/span&gt;        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;vecs&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;tuple&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;meta&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]]&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_build_index&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;tuple&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ndarray&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;tuple&lt;/span&gt;&lt;span class="p"&gt;]]:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Load the cached index, re-embedding only posts whose mtime moved.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;cached_vecs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cached_meta&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_load_cache&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;live&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stem&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stat&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="n"&gt;st_mtime_ns&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;_posts&lt;/span&gt;&lt;span class="p"&gt;()}&lt;/span&gt;

    &lt;span class="n"&gt;keep_rows&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;keep_meta&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[],&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;enumerate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cached_meta&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;mtime&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;live&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;mtime&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;      &lt;span class="c1"&gt;# unchanged file, reuse its vectors
&lt;/span&gt;            &lt;span class="n"&gt;keep_rows&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cached_vecs&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
            &lt;span class="n"&gt;keep_meta&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;done&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;keep_meta&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;todo_texts&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;todo_meta&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[],&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;_posts&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stem&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;done&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;
        &lt;span class="n"&gt;title&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_title&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read_text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;encoding&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;utf-8&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stem&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;heading&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;_chunks&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="n"&gt;todo_texts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;todo_meta&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stem&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;live&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stem&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;heading&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;todo_texts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;vecs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;keep_rows&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;keep_meta&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cached_meta&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;   &lt;span class="c1"&gt;# a post was deleted, persist the prune
&lt;/span&gt;            &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;savez&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;INDEX&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;vecs&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;vecs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;meta&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;keep_meta&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dtype&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;object&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;MODEL&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;vecs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;keep_meta&lt;/span&gt;

    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;embedding &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;todo_texts&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; chunks&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;file&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stderr&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;new_vecs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_embed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;todo_texts&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;search_document&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# one batched call
&lt;/span&gt;    &lt;span class="n"&gt;vecs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;vstack&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;keep_rows&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;new_vecs&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;keep_rows&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="n"&gt;new_vecs&lt;/span&gt;
    &lt;span class="n"&gt;meta&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;keep_meta&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;todo_meta&lt;/span&gt;
    &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;savez&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;INDEX&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;vecs&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;vecs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;meta&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;meta&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dtype&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;object&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;MODEL&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;vecs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;meta&lt;/span&gt;


&lt;span class="n"&gt;_INDEX_CACHE&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;tuple&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_index&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Loaded on first search. Warm cache is instant; a cold one costs minutes.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="k"&gt;global&lt;/span&gt; &lt;span class="n"&gt;_INDEX_CACHE&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;_INDEX_CACHE&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;_INDEX_CACHE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_build_index&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;_INDEX_CACHE&lt;/span&gt;


&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;__name__&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;__main__&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;# This replaces Part One's block. Build the index from the shell before
&lt;/span&gt;    &lt;span class="c1"&gt;# starting the server:
&lt;/span&gt;    &lt;span class="c1"&gt;#     uv run --directory /abs/path server.py --reindex
&lt;/span&gt;    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--reindex&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;argv&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;vecs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;meta&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_build_index&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;indexed &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;meta&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; chunks&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;file&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stderr&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nb"&gt;SystemExit&lt;/span&gt;

    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;serving &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;_posts&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; posts from &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;NOTES&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;file&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stderr&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;mcp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;transport&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;stdio&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four things in there matter. The model name goes into the cache file, so swapping models throws the whole index out instead of comparing 768-dimensional rows against a 1024-dimensional query. &lt;code&gt;st_mtime_ns&lt;/code&gt; is the freshness check, so editing one post re-embeds one post and leaves the other fifty alone, which turns a four minute rebuild into a two second one. Deleting a post is the quiet case: its slug never makes it into &lt;code&gt;keep_meta&lt;/code&gt;, and the save on the early return writes the pruned index back to disk, because otherwise the dead vectors sit in &lt;code&gt;index.npz&lt;/code&gt; forever and every run prunes them again. And the &lt;code&gt;--reindex&lt;/code&gt; path means the expensive build happens when you ask for it, not in the middle of somebody's question.&lt;/p&gt;

&lt;p&gt;Search itself is four lines:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_similar&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;tuple&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;]]:&lt;/span&gt;
    &lt;span class="n"&gt;vecs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;meta&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_index&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;q&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_embed&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;search_query&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;scores&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;vecs&lt;/span&gt; &lt;span class="o"&gt;@&lt;/span&gt; &lt;span class="n"&gt;q&lt;/span&gt;                      &lt;span class="c1"&gt;# both sides normalized, so this is cosine
&lt;/span&gt;    &lt;span class="n"&gt;top&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;argsort&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;scores&lt;/span&gt;&lt;span class="p"&gt;)[:&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="c1"&gt;# meta rows are (slug, mtime, heading, title)
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[(&lt;/span&gt;&lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;scores&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]),&lt;/span&gt; &lt;span class="n"&gt;meta&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;meta&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;meta&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;top&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;vecs @ q&lt;/code&gt; is the whole search engine. One matrix multiply against every chunk on the blog.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. What it actually retrieves
&lt;/h2&gt;

&lt;p&gt;Here is where I found out my plan for this post was wrong.&lt;/p&gt;

&lt;p&gt;The whole setup in Stage Two was that &lt;code&gt;brute force&lt;/code&gt; should find the Fail2ban post, and that meaning-based search is the thing that gets you there. So I built the index, 496 chunks across 51 posts, and asked it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;query: "brute force"
  0.6237  ufw-firewall-basics-ubuntu        ## Rate limiting the front door
  0.6048  hardening-ubuntu-desktop          ## First, know what you are defending against
  0.5961  hardening-ubuntu-26-04-server     ## See what is actually listening
  0.5951  dev-handoff-claude-code-skill     ## How it works, section by section
  0.5950  hardening-ubuntu-26-04-desktop    ## Per-app permission prompts
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Fail2ban post is not in the top five. It is eighth. Fourth place is a post about handing work to AI sub-agents, which has nothing to do with any of this.&lt;/p&gt;

&lt;p&gt;That is a better result than the substring match, which returned nothing at all, and the top hit is defensible: the UFW post's rate limiting section really is about throttling repeated connection attempts. But it is not the answer I promised, and the spread across those five is 0.03, which is another way of saying the index has no strong opinion.&lt;/p&gt;

&lt;p&gt;Now ask the same thing as a sentence:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;query: "how do I stop repeated failed SSH login attempts"
  0.7546  set-up-fail2ban-ubuntu-26-04      ## intro
  0.7425  set-up-ssh-keys-ubuntu            ## 5. Turn off password login
  0.7232  hardening-ubuntu-26-04-server     ## Order matters: do not lock yourself out
  0.7185  set-up-fail2ban-ubuntu-26-04      ## Before you install it
  0.7168  create-sudo-user-ubuntu-26-04     ## Now lock down root
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;First place, and second place is the post it tells you to read first. Phrase it more vaguely and it still holds: "something keeps trying to log into my server over and over" also puts the Fail2ban intro on top.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Two words is not enough to embed.&lt;/strong&gt; That is the lesson, and I would not have believed it without the numbers. An embedding is a summary of meaning, and "brute force" on its own carries almost none: no target, no symptom, no context. The model has to guess whether you mean SSH, or a search algorithm, or a way of solving a puzzle. A sentence gives it something to summarize.&lt;/p&gt;

&lt;p&gt;Which is fine, and it is worth understanding why. &lt;strong&gt;The caller is not a human typing two words into a box.&lt;/strong&gt; It is a model turning somebody's question into a tool call, and models write sentences. The query shape this search is worst at is the one it will rarely be handed. But it does mean the docstring should tell it so, and mine now does.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. Keep both
&lt;/h2&gt;

&lt;p&gt;I expected this section to be about embeddings falling over on exact strings. It is not, because they do not. Ask the index for &lt;code&gt;_ctypes&lt;/code&gt; and the top five hits are all sections of the right post, starting at 0.7004. That is the single cleanest result in this whole experiment, and it is the opposite of what I sat down to write.&lt;/p&gt;

&lt;p&gt;So the case for keeping both is not that one is bad at literals. It is that they fail in different places, and you now have both failures on record. The substring match returned nothing for &lt;code&gt;brute force&lt;/code&gt; over a hyphen. The embedding index returned nothing useful for the same two words because two words carry no meaning to summarize. Neither of those is a flaw you can fix from inside the other approach.&lt;/p&gt;

&lt;p&gt;The other reason is cheaper and more practical. When I paste an error string into my own notes, I want to know that a post literally contains it, not that a post is thematically nearby. Exact match is a different kind of answer, not a worse one, and it costs one function call to keep.&lt;/p&gt;

&lt;p&gt;So the answer is both, in one tool:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nd"&gt;@mcp.tool&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;search_notes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Search my blog posts.

    Runs two searches and merges them: an exact-phrase search ranked by where
    the phrase appears, and a meaning-based search that finds related posts
    even when they never use your words. Good for both error strings and
    plain-English questions. Returns up to 5 posts, best first, with the
    section that matched. Use get_note with a slug to read a full post.
    &lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;exact&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_keyword_hits&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="c1"&gt;# Ask for more chunks than we need: several may come from one post and
&lt;/span&gt;    &lt;span class="c1"&gt;# collapse into a single line below.
&lt;/span&gt;    &lt;span class="n"&gt;related&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_similar&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;15&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;seen&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;exact&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;                    &lt;span class="c1"&gt;# literal matches win the top
&lt;/span&gt;        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;slug&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;seen&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;seen&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;slug&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;score&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;heading&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;related&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="c1"&gt;# semantic fills the rest
&lt;/span&gt;        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;slug&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;seen&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;score&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;FLOOR&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;seen&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;slug&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;  (matched section: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;heading&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;[:&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Nothing found for &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="si"&gt;!r}&lt;/span&gt;&lt;span class="s"&gt;.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The merge rule is deliberately blunt: anything the keyword search found literally goes first, because if you typed an exact string you almost certainly meant it, and semantic hits fill whatever is left. Chunk hits collapse to one line per post, keeping the best-scoring section as the label, since the model wants a slug it can pass to &lt;code&gt;get_note&lt;/code&gt; and not four near-duplicate rows from the same file.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;FLOOR&lt;/code&gt; exists so a query with no real answer returns nothing instead of the five least-unrelated posts on the blog, which is the semantic version of the bug in Stage One. I set it to 0.5 by guessing, and the numbers say that was useless: on this corpus every hit that came back for every query I tried scored above 0.59, including the sub-agent post that has nothing to do with brute force attacks. A floor of 0.5 filters nothing at all.&lt;/p&gt;

&lt;p&gt;Then I went looking for a number that would work, and did not find one. Real scores here sit between 0.59 and 0.76, and across five queries neither obvious rule separates the good answers from the bad ones:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Query&lt;/th&gt;
&lt;th&gt;Top score&lt;/th&gt;
&lt;th&gt;Gap to second&lt;/th&gt;
&lt;th&gt;Right answer?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;brute force&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0.6237&lt;/td&gt;
&lt;td&gt;0.0189&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;how do I stop repeated failed SSH login attempts&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0.7546&lt;/td&gt;
&lt;td&gt;0.0121&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;something keeps trying to log into my server over and over&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0.6540&lt;/td&gt;
&lt;td&gt;0.0009&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;_ctypes&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0.7004&lt;/td&gt;
&lt;td&gt;0.0443&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;my server forgets its IP address after reboot&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0.6985&lt;/td&gt;
&lt;td&gt;0.0080&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A floor of 0.70 would have thrown away a correct answer at 0.6540. And the gap between first and second place, which I assumed would be the tell, does not sort them either. The widest gap on that list is a success and the narrowest gap is also a success, a factor of fifty apart, with both of the failures sitting comfortably in between. Both of the tidy heuristics I expected to find are wrong on my own data.&lt;/p&gt;

&lt;p&gt;So &lt;code&gt;FLOOR&lt;/code&gt; stays at 0.5 as insurance, not as a demonstrated filter. It never fired on any query I tried, and I am keeping it for the query that is genuinely about nothing on this blog, which none of my test queries were. The honest position is that I do not have a reliable confidence signal for this yet, and anyone who tells you a fixed cosine threshold means "relevant" has not printed their own numbers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One tool, not two.&lt;/strong&gt; It is tempting to ship &lt;code&gt;search_notes&lt;/code&gt; and &lt;code&gt;search_notes_semantic&lt;/code&gt; and let the model choose. Do not. The model would have to know which search strategy suits a query it has not run yet, which is a harder problem than the one you are solving, and it will get it wrong in the direction of whichever docstring reads more confidently. Every tool you add is another thing it can pick wrong. Give it one door and do the routing yourself.&lt;/p&gt;

&lt;h2&gt;
  
  
  8. Ask it the way a person would
&lt;/h2&gt;

&lt;p&gt;Testing the function at a terminal is not the point. The caller is a model, so restart Claude Code and ask it the way somebody would actually ask.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;how do I stop people repeatedly trying to log into my server?
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;mcp-server-search-ranking-embeddings-ollama: Your MCP server's search is bad: ranking, embeddings, and what each one fixes
set-up-fail2ban-ubuntu-26-04: Set Up Fail2ban on Ubuntu 26.04 Server  (matched section: intro)
hardening-ubuntu-26-04-server: Hardening Ubuntu 26.04 Server (Resolute Raccoon)  (matched section: Order matters: do not lock yourself out)
create-sudo-user-ubuntu-26-04: Create a Sudo User on Ubuntu 26.04  (matched section: Now lock down root)
set-up-ssh-keys-ubuntu: Set up SSH keys for Ubuntu  (matched section: 5. Turn off password login)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Fail2ban post is there in second place, matched on its intro, which is the section that never says "brute" or "attack." That is the result section 6 promised and the whole point of the exercise.&lt;/p&gt;

&lt;p&gt;Now look at what is sitting on top of it.&lt;/p&gt;

&lt;p&gt;That is this post. And look at what the top line is missing: every other row carries a matched section, because every other row came out of the embedding index. The top row does not, which means it came back from the exact-phrase half.&lt;/p&gt;

&lt;p&gt;When I ran this, the sentence I had just typed existed in exactly one file on the blog. It was in this section, inside an HTML comment, in a note to myself that said to ask something like "how do I stop people repeatedly trying to log into my server?" I wrote the question down before I wrote the section. The literal search found my own unfinished to-do list and ranked it above the answer.&lt;/p&gt;

&lt;p&gt;Part One opened with the search returning Part One. Two stages and a rewrite later, it opens with the search returning a comment I had not finished writing.&lt;/p&gt;

&lt;p&gt;There is a real lesson under the joke, and it is about the merge rule in Stage One. Exact matches go first because if you typed a literal string you almost certainly meant it. That reasoning holds for &lt;code&gt;_ctypes&lt;/code&gt;. It does not hold for a twelve word question, because a twelve word question matching a document verbatim does not mean the document answers it. It means the document quotes it. Length should have been part of that decision and it is not.&lt;/p&gt;

&lt;p&gt;The contamination is not only literal, either. Ask something with no phrase in common with anything I have written:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;someone keeps guessing passwords on my ssh server and filling the logs
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;hardening-ubuntu-26-04-server: Hardening Ubuntu 26.04 Server (Resolute Raccoon)  (matched section: Order matters: do not lock yourself out)
set-up-ssh-keys-ubuntu: Set up SSH keys for Ubuntu  (matched section: Gotchas I hit)
ssh-config-file-explained: The ~/.ssh/config file explained: aliases, jump hosts, and a key per host  (matched section: A key per host)
mcp-server-search-ranking-embeddings-ollama: Your MCP server's search is bad: ranking, embeddings, and what each one fixes  (matched section: 2. The query ranking cannot touch)
set-up-fail2ban-ubuntu-26-04: Set Up Fail2ban on Ubuntu 26.04 Server  (matched section: intro)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Fail2ban falls to fifth, and this post is fourth on the strength of section 2, which is three hundred words about failed SSH logins that solve nothing. Every measurement in section 6 was taken on an index of 51 posts. There are 52 now, and the new one is a long post about searching for the answer rather than a post containing it.&lt;/p&gt;

&lt;p&gt;I do not have a clean fix for that and I am not going to invent one. A post about retrieval is a genuinely hard document to keep in a retrieval index, because it is made of other posts' vocabulary. What I have is the knowledge that my corpus now contains a decoy, which is worth more than a confidence score.&lt;/p&gt;

&lt;h2&gt;
  
  
  9. Gotchas I hit
&lt;/h2&gt;

&lt;p&gt;Part One's worst bug was a &lt;code&gt;print()&lt;/code&gt; that worked fine until a buffer filled weeks later, and the first two here have the same shape: no crash, no error, just an index that is quietly worse than you think.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;I built the first index without the task prefixes.&lt;/strong&gt; Nothing told me. Vectors came back, the scores looked reasonable, the results were ranked, and I nearly shipped it. It took building the whole index a second time and scoring both against eight questions to see that the first one was measurably worse. There is no error to chase here, only the version of the index you did not build.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;I reached for &lt;code&gt;num_ctx&lt;/code&gt; before I tested whether it did anything.&lt;/strong&gt; Ollama serves this model at 2,048 tokens, drops the rest without a word, and clamps a larger &lt;code&gt;num_ctx&lt;/code&gt; back down to the trained context. Two documents differing only after the cut embed to cosine 1.000000, which is how I found out. Measure your longest chunk instead of trusting the flag.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A four minute index build in the wrong place.&lt;/strong&gt; 496 chunks took 239 seconds on CPU. Put that on the first search, the way I first designed it, and the model waits four minutes for a tool call. Build ahead of time and let the server load a file.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A model swap poisons a cache keyed only on mtime.&lt;/strong&gt; No file changed, so nothing re-embeds, and now a 768-dimensional index meets a 1024-dimensional query. That one at least fails loudly, though in numpy's terms rather than yours:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  ValueError: matmul: Input operand 1 has a mismatch in its core dimension 0,
  with gufunc signature (n?,k),(k,m?)-&amp;gt;(n?,m?) (size 1024 is different from 768)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nothing in there says "you changed embedding models." Put the model name in the cache file and check it on load, which is what &lt;code&gt;_load_cache()&lt;/code&gt; above is for.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The two halves of the merged tool can disagree about the corpus.&lt;/strong&gt; &lt;code&gt;_INDEX_CACHE&lt;/code&gt; loads once per process, and Part One's rule that config is read at session start applies to it too. Edit a post mid-session and the keyword half sees the change on the next call, because it reads the files live every time, while the embedding half keeps serving the old vectors until the server restarts. One tool, two answers about what the blog says.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ollama being down fails at search time, not startup.&lt;/strong&gt; The index builds lazily, so &lt;code&gt;claude mcp list&lt;/code&gt; reports &lt;code&gt;✔ Connected&lt;/code&gt;, the tools list normally, and the failure waits until someone searches. The &lt;code&gt;try&lt;/code&gt; in &lt;code&gt;_embed&lt;/code&gt; covers both paths that need it, the index build and the query, because &lt;code&gt;_similar&lt;/code&gt; embeds the query on every single search whether the index is warm or not. The daemon being down surfaces as a plain &lt;code&gt;ConnectionError&lt;/code&gt;, and the reason goes to stderr, which Part One established is the only channel that will not corrupt the transport.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The post you are reading poisoned its own index.&lt;/strong&gt; Section 8 has the whole story. A twelve word question matched this file literally, because I had written that question into a to-do comment before I had written the section under it, and the merge rule puts literal matches first. The lesson is not about comments. It is that the corpus is not a fixed thing you index once and reason about afterwards. It contains whatever you are currently writing, including the parts that are not finished, and a post about search is made almost entirely of other posts' vocabulary.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The docstring changed three times in two posts.&lt;/strong&gt; No ranking, then ranking, then two searches at once. Every time it went stale the model kept working around a limitation that no longer existed. It is still the only description of this tool it will ever see.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  10. Quick reference
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Task&lt;/th&gt;
&lt;th&gt;Command / code&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Add the deps&lt;/td&gt;
&lt;td&gt;&lt;code&gt;uv add ollama numpy&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pull the model&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ollama pull nomic-embed-text&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Embed a batch&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ollama.embed(model=MODEL, input=[...])&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Keep chunks under&lt;/td&gt;
&lt;td&gt;2,048 tokens, where Ollama truncates this model&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Check that limit yourself&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ollama show nomic-embed-text&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Prefix documents&lt;/td&gt;
&lt;td&gt;&lt;code&gt;search_document: {text}&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Prefix queries&lt;/td&gt;
&lt;td&gt;&lt;code&gt;search_query: {text}&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Build the index&lt;/td&gt;
&lt;td&gt;&lt;code&gt;uv run --directory /abs/path server.py --reindex&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Normalize&lt;/td&gt;
&lt;td&gt;&lt;code&gt;v / np.linalg.norm(v, axis=1, keepdims=True)&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Search&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;vecs @ q&lt;/code&gt; then &lt;code&gt;np.argsort(-scores)[:k]&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cache key&lt;/td&gt;
&lt;td&gt;path + &lt;code&gt;st_mtime_ns&lt;/code&gt; + model name&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Force a rebuild&lt;/td&gt;
&lt;td&gt;&lt;code&gt;rm index.npz&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Inspect the index&lt;/td&gt;
&lt;td&gt;&lt;code&gt;np.load("index.npz", allow_pickle=True)["meta"]&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Reload the server&lt;/td&gt;
&lt;td&gt;restart Claude Code, config is read at session start&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pre-allow headless&lt;/td&gt;
&lt;td&gt;&lt;code&gt;claude -p "..." --allowedTools "mcp__notes__search_notes"&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Part One's closing line was that the protocol is the easy part and what you put behind the door is your problem. Two stages later that still holds. The ranking fix is twenty lines and should have shipped in Part One. The embeddings are a 768-float vector per section and a matrix multiply.&lt;/p&gt;

&lt;p&gt;Neither is the interesting part. The interesting part is that I wrote a whole post about stopping brute force attacks and never once typed the words. Any search I put in front of these notes has to survive that, because the person asking will not know what I called it.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;[ two searches, one door ]&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://peculiarengineer.com/blog/mcp-server-search-ranking-embeddings-ollama/" rel="noopener noreferrer"&gt;peculiarengineer.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>python</category>
      <category>ai</category>
      <category>mcp</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Reverse Proxy Your Containers with Caddy and Docker Compose on Ubuntu 26.04</title>
      <dc:creator>Minor Keith</dc:creator>
      <pubDate>Fri, 24 Jul 2026 02:15:03 +0000</pubDate>
      <link>https://dev.to/peculiarengineer/reverse-proxy-your-containers-with-caddy-and-docker-compose-on-ubuntu-2604-1n97</link>
      <guid>https://dev.to/peculiarengineer/reverse-proxy-your-containers-with-caddy-and-docker-compose-on-ubuntu-2604-1n97</guid>
      <description>&lt;p&gt;Once you have &lt;a href="https://peculiarengineer.com/blog/install-docker-ubuntu-26-04/" rel="noopener noreferrer"&gt;Docker running on 26.04&lt;/a&gt; and a couple of containers up, you hit the wall everyone hits: your apps live on a pile of random ports. Plex on &lt;code&gt;:32400&lt;/code&gt;, something else on &lt;code&gt;:8080&lt;/code&gt;, a dashboard on &lt;code&gt;:9000&lt;/code&gt;, and none of them speak HTTPS. You want &lt;code&gt;plex.example.com&lt;/code&gt; and &lt;code&gt;books.example.com&lt;/code&gt; to just work, with a real certificate, without hand-rolling Nginx configs or running certbot on a cron job.&lt;/p&gt;

&lt;p&gt;Caddy is the boring, dependable answer to that, and it is short. A hostname in a Caddyfile is all it takes for Caddy to go get a Let's Encrypt certificate, serve it on 443, and renew it forever without you thinking about it again. This is the setup I use to front my own containers, and this post is where I keep the parts that are not obvious the first time.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt; Run Caddy in the same Compose project as your apps, put every container on one shared Docker network, and in the Caddyfile &lt;code&gt;reverse_proxy&lt;/code&gt; to the &lt;em&gt;container name and its internal port&lt;/em&gt;, never &lt;code&gt;localhost&lt;/code&gt;. Caddy gets HTTPS automatically as long as your domain's DNS points at the box and ports 80 and 443 are reachable. While testing, set &lt;code&gt;acme_ca&lt;/code&gt; to the Let's Encrypt staging endpoint so a broken config does not burn your weekly certificate quota.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Prerequisites
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Ubuntu 26.04 with Docker and the Compose plugin. If you are not there yet, start with &lt;a href="https://peculiarengineer.com/blog/install-docker-ubuntu-26-04/" rel="noopener noreferrer"&gt;Install Docker on Ubuntu 26.04&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;A domain you control, with an A record (and AAAA if you have IPv6) pointing at the box's public IP.&lt;/li&gt;
&lt;li&gt;Ports 80 and 443 reachable from the internet: open in &lt;a href="https://peculiarengineer.com/blog/ufw-firewall-basics-ubuntu/" rel="noopener noreferrer"&gt;UFW&lt;/a&gt; and forwarded at your router if you are behind NAT.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The mental model
&lt;/h2&gt;

&lt;p&gt;Two ideas carry this whole setup, and if you hold them straight nothing else is confusing.&lt;/p&gt;

&lt;p&gt;First, &lt;strong&gt;Caddy and your apps have to share a Docker network so Caddy can reach them by name.&lt;/strong&gt; Containers on the same user-defined network get automatic DNS: a service called &lt;code&gt;whoami&lt;/code&gt; is reachable at the hostname &lt;code&gt;whoami&lt;/code&gt; from any other container on that network. Caddy proxies to those names. Your apps then do not need to publish any ports to the host at all, which is the point. Only Caddy is exposed, everything behind it is private.&lt;/p&gt;

&lt;p&gt;Second, &lt;strong&gt;automatic HTTPS is triggered by using a real hostname, not a port.&lt;/strong&gt; When the Caddyfile says &lt;code&gt;books.example.com { ... }&lt;/code&gt;, Caddy registers an ACME account, proves it controls that name over port 80, installs the certificate, and serves 443. When it says &lt;code&gt;:8080 { ... }&lt;/code&gt; it does not, because there is nothing to get a certificate for. The hostname is the switch.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. The Compose file
&lt;/h2&gt;

&lt;p&gt;Here is a complete, working stack: Caddy plus one example backend (&lt;code&gt;whoami&lt;/code&gt;, a tiny container that prints request info, perfect for confirming the proxy works before you point it at anything real).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;caddy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;caddy:2.9&lt;/span&gt;
    &lt;span class="na"&gt;container_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;caddy&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;80:80"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;443:443"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;443:443/udp"&lt;/span&gt;        &lt;span class="c1"&gt;# HTTP/3&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/opt/dockerapp/caddy:/etc/caddy&lt;/span&gt;   &lt;span class="c1"&gt;# mount the dir, not the file&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;caddy_data:/data&lt;/span&gt;                  &lt;span class="c1"&gt;# certificates live here, keep it&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;caddy_config:/config&lt;/span&gt;
    &lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;web&lt;/span&gt;

  &lt;span class="na"&gt;whoami&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;traefik/whoami&lt;/span&gt;
    &lt;span class="na"&gt;container_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;whoami&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
    &lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;web&lt;/span&gt;
    &lt;span class="c1"&gt;# note: no `ports:` at all. Only Caddy is exposed.&lt;/span&gt;

&lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;web&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;

&lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;caddy_data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;caddy_config&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two lines earn their comments. &lt;code&gt;caddy_data&lt;/code&gt; is a named volume holding your certificates and ACME account key; if you delete it you throw away real Let's Encrypt certificates and have to fetch them again, which matters because of the rate limit below. And the config mount is the &lt;em&gt;directory&lt;/em&gt; &lt;code&gt;/opt/dockerapp/caddy&lt;/code&gt;, not the single Caddyfile. Mounting a lone file bites you when an editor replaces it on save: the container keeps pointing at the old inode and your edits appear to do nothing. Mount the folder and that problem disappears.&lt;/p&gt;

&lt;p&gt;Create the config directory before you bring the stack up:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; /opt/dockerapp/caddy
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  2. The Caddyfile
&lt;/h2&gt;

&lt;p&gt;Drop this at &lt;code&gt;/opt/dockerapp/caddy/Caddyfile&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;{
      email you@example.com
}

whoami.example.com {
      reverse_proxy whoami:80
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the entire config for one site. The global block at the top sets the email Let's Encrypt uses for expiry notices. The site block says "for this hostname, hand the request to the &lt;code&gt;whoami&lt;/code&gt; container on port 80." Bring it up:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cd&lt;/span&gt; /opt/dockerapp/caddy
docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Point &lt;code&gt;whoami.example.com&lt;/code&gt; at the box, wait for DNS, and load it over HTTPS. You should see the whoami output with a valid padlock and no certificate warning. Adding a second app is another three lines:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;books.example.com {
      reverse_proxy calibre-web:8083
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The port you write is the container's &lt;em&gt;internal&lt;/em&gt; port, the one the app listens on inside its own container, not any host port you might have published elsewhere. Caddy is talking to it over the Docker network, so the host port mapping is irrelevant here.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. The shared network is the whole trick
&lt;/h2&gt;

&lt;p&gt;This is the part that sends people to the search bar, so it gets its own section. If Caddy and the backend are not on the same Docker network, &lt;code&gt;reverse_proxy whoami:80&lt;/code&gt; fails with a DNS error in the Caddy logs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;dial tcp: lookup whoami on 127.0.0.11:53: no such host
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That message means exactly what it says: Caddy asked Docker's internal DNS for &lt;code&gt;whoami&lt;/code&gt; and got nothing, because from Caddy's network that name does not exist. The fix is always the same: put both services on the same network. In the Compose file above they share &lt;code&gt;web&lt;/code&gt;, so it works.&lt;/p&gt;

&lt;p&gt;The trap shows up when your apps live in a &lt;em&gt;different&lt;/em&gt; Compose file (a common way to organize a homelab, one project per app). Compose names each project's default network after the project, and those networks are isolated. To let Caddy reach a container in another project, declare a shared external network and attach both sides to it. Create it once:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker network create web
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then in every Compose file, Caddy's and each app's, mark that network external:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;web&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;external&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and add &lt;code&gt;- web&lt;/code&gt; to each service's &lt;code&gt;networks:&lt;/code&gt; list. Now they all share one bridge and Caddy can resolve every container by name across projects.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One real exception worth knowing:&lt;/strong&gt; a container running with &lt;code&gt;network_mode: host&lt;/code&gt; is not on any bridge network and has no Docker DNS name. My &lt;a href="https://peculiarengineer.com/blog/plex-sabnzbd-docker-compose-hardware-transcoding/" rel="noopener noreferrer"&gt;Plex container uses host networking&lt;/a&gt; because it needs it for discovery, so Caddy cannot reach it as &lt;code&gt;plex&lt;/code&gt;. For those, proxy to the host's LAN address and the published port instead:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;plex.example.com {
      reverse_proxy 192.168.1.10:32400
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  4. How automatic HTTPS actually happens
&lt;/h2&gt;

&lt;p&gt;Caddy's certificate magic is not magic, and knowing the steps tells you exactly what to fix when it fails. On first request for a hostname, Caddy:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Registers an ACME account with Let's Encrypt (once, stored in &lt;code&gt;caddy_data&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;Requests a certificate for the hostname.&lt;/li&gt;
&lt;li&gt;Proves control by answering an HTTP-01 challenge on &lt;strong&gt;port 80&lt;/strong&gt;, or a TLS-ALPN challenge on 443.&lt;/li&gt;
&lt;li&gt;Installs the certificate and serves HTTPS on 443, redirecting HTTP to HTTPS.&lt;/li&gt;
&lt;li&gt;Renews automatically around 30 days before expiry.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;So the requirements are concrete: the DNS record must resolve to this box, and &lt;strong&gt;port 80 must be reachable from the public internet&lt;/strong&gt; for the challenge. If 80 is closed at the firewall or not forwarded through your router, the challenge fails and you get no certificate, no matter how correct the Caddyfile is. Check the logs with &lt;code&gt;docker compose logs caddy&lt;/code&gt; and you will see the ACME error spelled out.&lt;/p&gt;

&lt;p&gt;If the box is LAN-only and has no public DNS, public certificates are not an option, because Let's Encrypt cannot reach it to run the challenge. Two ways out: use Caddy's built-in local CA with &lt;code&gt;tls internal&lt;/code&gt; (you then trust its root on your devices), or use the DNS-01 challenge, which proves control by writing a TXT record instead of answering on port 80. DNS-01 needs a Caddy image built with your provider's DNS module, so it is a custom build, not the stock &lt;code&gt;caddy:2.9&lt;/code&gt;. For a normal internet-facing box, none of that applies and the default just works.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Reloading without downtime
&lt;/h2&gt;

&lt;p&gt;After editing the Caddyfile, you do not need to restart the container and drop connections. Caddy reloads its config in place:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-w&lt;/span&gt; /etc/caddy caddy caddy reload
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;-w /etc/caddy&lt;/code&gt; runs the command from the config directory so Caddy finds the Caddyfile without a &lt;code&gt;--config&lt;/code&gt; flag. If the new config has a syntax error, the reload is rejected and the old config keeps serving, so a typo does not take your sites down. Validate a change before reloading if you want the check without applying it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-w&lt;/span&gt; /etc/caddy caddy caddy validate
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Gotchas I hit
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The rate limit that burns a real certificate.&lt;/strong&gt; Let's Encrypt allows &lt;a href="https://letsencrypt.org/docs/rate-limits/" rel="noopener noreferrer"&gt;5 certificates per exact hostname per week&lt;/a&gt;. Fight a broken config with the production endpoint and you can exhaust that quota fast, then you are locked out of new certificates for that name for days. While you are still getting things working, point Caddy at the staging CA, which issues certificates your browser will not trust but that never run out:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;{
      email you@example.com
      acme_ca https://acme-staging-v02.api.letsencrypt.org/directory
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Your browser will warn about the staging certificate, that is expected. Once the setup is solid, delete that &lt;code&gt;acme_ca&lt;/code&gt; line, reload, and Caddy fetches a real one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Editing the mounted single file did nothing.&lt;/strong&gt; This is the inode trap from earlier. I mounted &lt;code&gt;./Caddyfile:/etc/caddy/Caddyfile&lt;/code&gt; directly, edited it with an editor that writes a new file and renames it over the old one, and the container went on serving the original because it still held the old inode. Mounting the directory instead of the file fixes it for good.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Port 80 closed after I set up the firewall.&lt;/strong&gt; I locked the box down with UFW, opened 443, and forgot 80. Everything looked fine until the certificate came up for renewal weeks later and silently failed the HTTP-01 challenge. Open both:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;ufw allow 80/tcp
&lt;span class="nb"&gt;sudo &lt;/span&gt;ufw allow 443/tcp
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Port 80 is not just for a redirect, Caddy needs it for the ACME challenge on renewal, so leaving it closed is a time bomb set for 60 days out.&lt;/p&gt;

&lt;h2&gt;
  
  
  Quick reference
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Job&lt;/th&gt;
&lt;th&gt;Command / config&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Proxy to a container&lt;/td&gt;
&lt;td&gt;&lt;code&gt;reverse_proxy servicename:INTERNALPORT&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Proxy to a host-networked app&lt;/td&gt;
&lt;td&gt;&lt;code&gt;reverse_proxy 192.168.1.10:32400&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Shared network across projects&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;docker network create web&lt;/code&gt; + &lt;code&gt;external: true&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Reload config&lt;/td&gt;
&lt;td&gt;&lt;code&gt;docker exec -w /etc/caddy caddy caddy reload&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Validate config&lt;/td&gt;
&lt;td&gt;&lt;code&gt;docker exec -w /etc/caddy caddy caddy validate&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Staging CA while testing&lt;/td&gt;
&lt;td&gt;&lt;code&gt;acme_ca https://acme-staging-v02.api.letsencrypt.org/directory&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Open the ACME ports&lt;/td&gt;
&lt;td&gt;&lt;code&gt;sudo ufw allow 80/tcp &amp;amp;&amp;amp; sudo ufw allow 443/tcp&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Read the logs&lt;/td&gt;
&lt;td&gt;&lt;code&gt;docker compose logs -f caddy&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Put every container on one shared network, proxy to names and not &lt;code&gt;localhost&lt;/code&gt;, keep port 80 open for the challenge, and test against staging so a bad afternoon does not cost you a week of certificates. Do that and Caddy turns a pile of random ports into clean hostnames with real HTTPS that renews itself while you forget it exists. That last part, forgetting it exists, is the whole reason to use it.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;[ 443 up · certs on autopilot ]&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://peculiarengineer.com/blog/caddy-reverse-proxy-docker-compose-ubuntu-26-04/" rel="noopener noreferrer"&gt;peculiarengineer.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>docker</category>
      <category>devops</category>
      <category>linux</category>
      <category>ubuntu</category>
    </item>
    <item>
      <title>Install Docker on Ubuntu 26.04 (the right way, with the docker-group truth)</title>
      <dc:creator>Minor Keith</dc:creator>
      <pubDate>Wed, 22 Jul 2026 21:24:32 +0000</pubDate>
      <link>https://dev.to/peculiarengineer/install-docker-on-ubuntu-2604-the-right-way-with-the-docker-group-truth-1knd</link>
      <guid>https://dev.to/peculiarengineer/install-docker-on-ubuntu-2604-the-right-way-with-the-docker-group-truth-1knd</guid>
      <description>&lt;p&gt;The wrong way to install Docker on Ubuntu is the one that looks easiest: &lt;code&gt;sudo apt install docker.io&lt;/code&gt;. That package exists, it installs, and it runs a container. It is also whatever version happened to be frozen into the archive when 26.04 was cut, it lags the real releases by months, and it ships without the Compose and Buildx plugins you will want by the end of the week. Use Docker's own apt repository instead, and this is the post I keep open so I do not re-derive the repo setup from memory each time.&lt;/p&gt;

&lt;p&gt;This is short on purpose. The steps are the official ones, and the only place worth slowing down is step 5, where adding yourself to the &lt;code&gt;docker&lt;/code&gt; group quietly hands out root. That tradeoff is the part most guides skip, and it is the one thing here actually worth reading twice.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt; Remove any distro &lt;code&gt;docker.io&lt;/code&gt;/&lt;code&gt;containerd&lt;/code&gt; packages, add Docker's GPG key and the deb822 &lt;code&gt;.sources&lt;/code&gt; repo, then &lt;code&gt;sudo apt install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin&lt;/code&gt;. Verify with &lt;code&gt;sudo docker run hello-world&lt;/code&gt;. Add yourself to the &lt;code&gt;docker&lt;/code&gt; group to drop the &lt;code&gt;sudo&lt;/code&gt; (it is root-equivalent, more below).&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Prerequisites
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Ubuntu 26.04 (Resolute Raccoon), server or desktop, on &lt;code&gt;amd64&lt;/code&gt; or &lt;code&gt;arm64&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;A user with sudo. If you are still on root, &lt;a href="https://peculiarengineer.com/blog/create-sudo-user-ubuntu-26-04/" rel="noopener noreferrer"&gt;create a sudo user first&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;Outbound HTTPS to &lt;code&gt;download.docker.com&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  1. Remove the distro Docker packages first
&lt;/h2&gt;

&lt;p&gt;Ubuntu ships its own &lt;code&gt;docker.io&lt;/code&gt;, &lt;code&gt;docker-compose&lt;/code&gt;, and &lt;code&gt;containerd&lt;/code&gt; packages, and any of them will fight the official ones over the same files and the same &lt;code&gt;containerd&lt;/code&gt; socket. Clear them out before you add Docker's repo. This is safe on a fresh box because there is nothing to lose yet; on a box that already ran the distro Docker, it removes the packages but leaves your images and volumes in &lt;code&gt;/var/lib/docker&lt;/code&gt; alone.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;apt remove &lt;span class="si"&gt;$(&lt;/span&gt;dpkg &lt;span class="nt"&gt;--get-selections&lt;/span&gt; docker.io docker-compose docker-compose-v2 docker-doc podman-docker containerd runc | &lt;span class="nb"&gt;cut&lt;/span&gt; &lt;span class="nt"&gt;-f1&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;dpkg --get-selections&lt;/code&gt; wrapper is just so the command does not error out on packages you never had installed. If none of them are present, nothing happens, which is exactly what you want.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Add Docker's apt repository
&lt;/h2&gt;

&lt;p&gt;Two steps: trust Docker's signing key, then point apt at their repo. Docker's current docs use the newer deb822 format (a &lt;code&gt;.sources&lt;/code&gt; file), which is more readable than the old one-line &lt;code&gt;.list&lt;/code&gt; entry and is what 26.04's apt prefers.&lt;/p&gt;

&lt;p&gt;First, the key:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;apt update
&lt;span class="nb"&gt;sudo &lt;/span&gt;apt &lt;span class="nb"&gt;install &lt;/span&gt;ca-certificates curl
&lt;span class="nb"&gt;sudo install&lt;/span&gt; &lt;span class="nt"&gt;-m&lt;/span&gt; 0755 &lt;span class="nt"&gt;-d&lt;/span&gt; /etc/apt/keyrings
&lt;span class="nb"&gt;sudo &lt;/span&gt;curl &lt;span class="nt"&gt;-fsSL&lt;/span&gt; https://download.docker.com/linux/ubuntu/gpg &lt;span class="nt"&gt;-o&lt;/span&gt; /etc/apt/keyrings/docker.asc
&lt;span class="nb"&gt;sudo chmod &lt;/span&gt;a+r /etc/apt/keyrings/docker.asc
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The key lives in &lt;code&gt;/etc/apt/keyrings/&lt;/code&gt;, not the deprecated &lt;code&gt;apt-key&lt;/code&gt; store, and the repo file below points at it with &lt;code&gt;Signed-By&lt;/code&gt;. That pairing is what tells apt "only trust packages from this repo if they are signed by this specific key," which is the whole reason you are not just piping a script into your shell.&lt;/p&gt;

&lt;p&gt;Now the repo itself:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo tee&lt;/span&gt; /etc/apt/sources.list.d/docker.sources &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;
Types: deb
URIs: https://download.docker.com/linux/ubuntu
Suites: &lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;.&lt;/span&gt; /etc/os-release &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;UBUNTU_CODENAME&lt;/span&gt;&lt;span class="k"&gt;:-&lt;/span&gt;&lt;span class="nv"&gt;$VERSION_CODENAME&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="sh"&gt;
Components: stable
Architectures: &lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;dpkg &lt;span class="nt"&gt;--print-architecture&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="sh"&gt;
Signed-By: /etc/apt/keyrings/docker.asc
&lt;/span&gt;&lt;span class="no"&gt;EOF

&lt;/span&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;apt update
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;Suites:&lt;/code&gt; line reads your release codename out of &lt;code&gt;/etc/os-release&lt;/code&gt;, so on 26.04 it resolves to &lt;code&gt;resolute&lt;/code&gt; and on 24.04 it would be &lt;code&gt;noble&lt;/code&gt;. That final &lt;code&gt;apt update&lt;/code&gt; pulls in Docker's package list, and you are ready to install.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Install the engine and plugins
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;apt &lt;span class="nb"&gt;install &lt;/span&gt;docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Five packages, and it is worth knowing what each one is rather than pasting them as a magic incantation:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;docker-ce&lt;/code&gt; is the daemon, the thing that actually runs containers.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;docker-ce-cli&lt;/code&gt; is the &lt;code&gt;docker&lt;/code&gt; command you type.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;containerd.io&lt;/code&gt; is the lower-level runtime the daemon drives.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;docker-buildx-plugin&lt;/code&gt; is the modern builder, so &lt;code&gt;docker build&lt;/code&gt; uses BuildKit.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;docker-compose-plugin&lt;/code&gt; gives you &lt;code&gt;docker compose&lt;/code&gt; as a subcommand.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last one is the part people miss. Compose v2 is a plugin now, so the command is &lt;code&gt;docker compose up&lt;/code&gt; with a space, not the old standalone &lt;code&gt;docker-compose&lt;/code&gt; with a hyphen. If you have muscle memory for the hyphenated one, this is where it stops working, and installing this plugin is how you get the replacement.&lt;/p&gt;

&lt;p&gt;The daemon starts and enables itself on install, so there is nothing to &lt;code&gt;systemctl enable&lt;/code&gt;. Confirm it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;systemctl is-active docker    &lt;span class="c"&gt;# -&amp;gt; active&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  4. Verify it actually runs
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;docker run hello-world
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This pulls a tiny image, runs it, and prints a paragraph confirming the daemon, the runtime, and the network path all work end to end. If you see "Hello from Docker!", the install is done. If it hangs on the pull, that is a network or DNS problem reaching Docker Hub, not a broken install.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Run Docker without sudo (and what that really costs)
&lt;/h2&gt;

&lt;p&gt;Typing &lt;code&gt;sudo&lt;/code&gt; before every &lt;code&gt;docker&lt;/code&gt; command gets old fast. The fix is to add yourself to the &lt;code&gt;docker&lt;/code&gt; group, which owns the daemon's socket:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;groupadd docker            &lt;span class="c"&gt;# usually already exists; harmless if so&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;usermod &lt;span class="nt"&gt;-aG&lt;/span&gt; docker &lt;span class="nv"&gt;$USER&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Group membership is read at login, so it does not apply to your current shell. Log out and back in, or start a fresh session with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;newgrp docker
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then prove it without sudo:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker run hello-world
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here is the honest caveat, because most guides drop you in the &lt;code&gt;docker&lt;/code&gt; group and move on. &lt;strong&gt;Membership in the &lt;code&gt;docker&lt;/code&gt; group is root-equivalent.&lt;/strong&gt; The daemon runs as root, and anyone who can talk to its socket can mount the host filesystem into a container and walk straight out as root. There is no privilege boundary between "in the docker group" and "root," full stop. On your own laptop or a single-admin homelab box, that is a fine trade for convenience. On a shared server, do not hand out &lt;code&gt;docker&lt;/code&gt; group membership as if it were a lesser permission, because it is not. If that trade bothers you, &lt;a href="https://docs.docker.com/engine/security/rootless/" rel="noopener noreferrer"&gt;rootless mode&lt;/a&gt; runs the whole daemon as your user instead, at the cost of a few limitations around ports below 1024 and some networking.&lt;/p&gt;

&lt;h2&gt;
  
  
  Quick reference
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Job&lt;/th&gt;
&lt;th&gt;Command&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Remove distro packages&lt;/td&gt;
&lt;td&gt;`sudo apt remove $(dpkg --get-selections docker.io docker-compose docker-compose-v2 docker-doc podman-docker containerd runc \&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Add GPG key&lt;/td&gt;
&lt;td&gt;{% raw %}&lt;code&gt;sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Install engine + plugins&lt;/td&gt;
&lt;td&gt;&lt;code&gt;sudo apt install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Verify&lt;/td&gt;
&lt;td&gt;&lt;code&gt;sudo docker run hello-world&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Drop the sudo&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;sudo usermod -aG docker $USER&lt;/code&gt; then re-login&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Compose command&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;docker compose up&lt;/code&gt; (space, not &lt;code&gt;docker-compose&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Check the daemon&lt;/td&gt;
&lt;td&gt;&lt;code&gt;systemctl is-active docker&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Use Docker's repo, not the distro package, so you get real versions and the Compose and Buildx plugins. Clear out the old packages first, trust the key, and let apt pull the real thing. Then decide with open eyes whether the &lt;code&gt;docker&lt;/code&gt; group is a trade you want, because it hands out root. With that done, the box is ready for the actual reason you installed Docker: running something. A natural next step is putting &lt;a href="https://peculiarengineer.com/blog/caddy-reverse-proxy-docker-compose-ubuntu-26-04/" rel="noopener noreferrer"&gt;Caddy in front of those containers for automatic HTTPS&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally posted on &lt;a href="https://peculiarengineer.com/blog/install-docker-ubuntu-26-04/" rel="noopener noreferrer"&gt;Peculiar Engineer&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>docker</category>
      <category>ubuntu</category>
      <category>linux</category>
      <category>devops</category>
    </item>
  </channel>
</rss>
