<?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>Install Tailscale on Ubuntu 26.10</title>
      <dc:creator>Minor Keith</dc:creator>
      <pubDate>Fri, 16 Oct 2026 19:42:24 +0000</pubDate>
      <link>https://dev.to/peculiarengineer/install-tailscale-on-ubuntu-2610-130b</link>
      <guid>https://dev.to/peculiarengineer/install-tailscale-on-ubuntu-2610-130b</guid>
      <description>&lt;p&gt;Ubuntu 26.10 came out recently, and I wanted Tailscale on a fresh 26.10 box. I use Tailscale for development, so I can test apps and get to the box from anywhere. On 26.10 the usual one liner doesn't work. Tailscale builds its apt repo per Ubuntu codename, and there's no &lt;code&gt;stonking&lt;/code&gt; repo yet:&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;-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; https://pkgs.tailscale.com/stable/ubuntu/dists/stonking/Release
&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;404
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same URL with &lt;code&gt;resolute&lt;/code&gt; (26.04) returns &lt;code&gt;200&lt;/code&gt;. Tailscale's &lt;a href="https://pkgs.tailscale.com/stable/" rel="noopener noreferrer"&gt;package page&lt;/a&gt; starts at 26.04, then 25.10, 25.04 and older. There's no 26.10.&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%2Fm4rg1gvr4yikmfl4vaqx.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%2Fm4rg1gvr4yikmfl4vaqx.png" alt="on Ubuntu 26.10, the stonking Release URL on pkgs.tailscale.com returns 404 and the resolute one returns 200" width="800" height="248"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The screenshots come from a final clean run on a fresh 26.10 server, as a user called &lt;code&gt;example&lt;/code&gt;; the text blocks come from the first test box, so times and IDs differ.&lt;/p&gt;

&lt;p&gt;So this post is the error text you'll hit, what each one means, and two fixes I tested on a fresh 26.10 server. For everything after the install (auth keys on a headless box, MagicDNS, exit nodes), my &lt;a href="https://peculiarengineer.com/blog/install-tailscale-ubuntu-26-04/" rel="noopener noreferrer"&gt;26.04 Tailscale guide&lt;/a&gt; uses the same commands, so I won't repeat it here (I couldn't test joining a tailnet from the lab; see section 6).&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR.&lt;/strong&gt; Use Tailscale's 26.04 repo until a 26.10 one exists: fetch &lt;code&gt;resolute.noarmor.gpg&lt;/code&gt; and &lt;code&gt;resolute.tailscale-keyring.list&lt;/code&gt; instead of the &lt;code&gt;stonking&lt;/code&gt; ones, then &lt;code&gt;sudo apt-get install tailscale&lt;/code&gt;. It installed Tailscale 1.104.1 on 26.10 and &lt;code&gt;tailscaled&lt;/code&gt; came up enabled and running. If you'd rather keep apt out of it, the static binaries plus the systemd unit in the tarball work too, and &lt;code&gt;tailscale update&lt;/code&gt; keeps them current. When &lt;code&gt;https://pkgs.tailscale.com/stable/ubuntu/stonking/installer-supported&lt;/code&gt; says &lt;code&gt;OK&lt;/code&gt;, swap &lt;code&gt;resolute&lt;/code&gt; for &lt;code&gt;stonking&lt;/code&gt; in &lt;code&gt;/etc/apt/sources.list.d/tailscale.list&lt;/code&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  What breaks on 26.10
&lt;/h2&gt;

&lt;p&gt;I tested on a fresh Ubuntu 26.10 server (kernel 7.3.0-9-generic) as a sudo user called &lt;code&gt;example&lt;/code&gt;, on 16 October 2026, the day after release.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What you run&lt;/th&gt;
&lt;th&gt;What happens&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;`curl -fsSL &lt;a href="https://tailscale.com/install.sh" rel="noopener noreferrer"&gt;https://tailscale.com/install.sh&lt;/a&gt; \&lt;/td&gt;
&lt;td&gt;sh`&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tailscale's manual steps with &lt;code&gt;stonking&lt;/code&gt; in the URLs&lt;/td&gt;
&lt;td&gt;both downloads 404, &lt;code&gt;tee&lt;/code&gt; writes two empty files, and &lt;code&gt;apt-get install&lt;/code&gt; says &lt;code&gt;Unable to locate package tailscale&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A &lt;code&gt;deb ... stonking main&lt;/code&gt; line written by hand&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;apt update&lt;/code&gt; fails with &lt;code&gt;does not have a Release file&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The same steps with &lt;code&gt;resolute&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;installs Tailscale 1.104.1, &lt;code&gt;tailscaled&lt;/code&gt; active and enabled&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The one thing to get straight: nothing is wrong with Tailscale on 26.10. The package from the 26.04 repo runs fine. What's missing is a directory on Tailscale's package server, and every official install path looks up the directory by your codename.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. "ubuntu stonking isn't supported by this script yet."
&lt;/h2&gt;

&lt;p&gt;The install script reads the codename from &lt;code&gt;/etc/os-release&lt;/code&gt; (&lt;code&gt;UBUNTU_CODENAME=stonking&lt;/code&gt; on 26.10) and asks the package server whether that codename is supported before it touches apt. For &lt;code&gt;stonking&lt;/code&gt; the server answers with a 404, so the script bails:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ curl -fsSL https://tailscale.com/install.sh | sh
ubuntu stonking isn't supported by this script yet.

If you'd like us to support your system better, please email support@tailscale.com
and tell us what OS you're running.

Please include the following information we gathered from your system:

OS=ubuntu
VERSION=stonking
PACKAGETYPE=apt
UNAME=Linux pe-ts2610-1 7.3.0-9-generic #9-Ubuntu SMP PREEMPT_DYNAMIC Mon Oct  5 10:38:02 UTC 2026 x86_64 GNU/Linux
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It then prints your whole &lt;code&gt;/etc/os-release&lt;/code&gt; and exits with status 1. This is the friendly failure: it stops before writing anything, and &lt;code&gt;/etc/apt/sources.list.d/&lt;/code&gt; still held only &lt;code&gt;ubuntu.sources&lt;/code&gt; afterwards. There's no flag to make the script use another codename (it takes &lt;code&gt;TRACK&lt;/code&gt; and &lt;code&gt;TAILSCALE_VERSION&lt;/code&gt;, neither of which helps), so skip it and add the repo yourself.&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%2Fagx1fgdixbbjtc5hybnw.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%2Fagx1fgdixbbjtc5hybnw.png" alt="the install script prints ubuntu stonking isn't supported by this script yet, and sources.list.d still holds only ubuntu.sources" width="800" height="251"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  2. "The repository '&lt;a href="https://pkgs.tailscale.com/stable/ubuntu" rel="noopener noreferrer"&gt;https://pkgs.tailscale.com/stable/ubuntu&lt;/a&gt; stonking Release' does not have a Release file."
&lt;/h2&gt;

&lt;p&gt;This is what you get if your apt source says &lt;code&gt;stonking&lt;/code&gt;, for example because you wrote the &lt;code&gt;deb&lt;/code&gt; line yourself with the codename, or a config management template fills it in from the OS facts:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ sudo apt update
Ign:1 https://pkgs.tailscale.com/stable/ubuntu stonking InRelease
Hit:2 http://security.ubuntu.com/ubuntu stonking-security InRelease
Hit:3 http://archive.ubuntu.com/ubuntu stonking InRelease
Err:4 https://pkgs.tailscale.com/stable/ubuntu stonking Release
  404  Not Found [IP: 2600:9000:2250:ac00:19:e3cb:f900:93a1 443]
Hit:5 http://archive.ubuntu.com/ubuntu stonking-updates InRelease
Hit:6 http://archive.ubuntu.com/ubuntu stonking-backports InRelease
Reading package lists...
Error: The repository 'https://pkgs.tailscale.com/stable/ubuntu stonking Release' does not have a Release file.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;apt-get update&lt;/code&gt; prints the same line starting &lt;code&gt;E:&lt;/code&gt; instead of &lt;code&gt;Error:&lt;/code&gt;. Both exit with status 100, which matters in a script or a cloud-init run: one missing vendor repo fails the whole update, not just the Tailscale part.&lt;/p&gt;

&lt;p&gt;"Does not have a Release file" sounds like a broken repo. It isn't. The &lt;code&gt;404 Not Found&lt;/code&gt; on the &lt;code&gt;Err:&lt;/code&gt; line is the real message: that suite doesn't exist on the server. It's the same thing I hit with Docker's and HashiCorp's repos in the &lt;a href="https://peculiarengineer.com/blog/upgrade-ubuntu-26-04-to-26-10-beta/" rel="noopener noreferrer"&gt;26.04 to 26.10 upgrade&lt;/a&gt;, and the fix is the same too.&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%2Fv3e1c5xqxsafrhjfrsyx.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%2Fv3e1c5xqxsafrhjfrsyx.png" alt="with a stonking deb line, apt update shows Err for the stonking Release with 404 Not Found, then Error: The repository ... stonking Release does not have a Release file, exit 100" width="800" height="276"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  3. The quiet one: "Unable to locate package tailscale"
&lt;/h2&gt;

&lt;p&gt;Tailscale's own manual instructions are two &lt;code&gt;curl | sudo tee&lt;/code&gt; lines with the codename in the URL. Swap in &lt;code&gt;stonking&lt;/code&gt; and both URLs 404, but look at what happens:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ curl -fsSL https://pkgs.tailscale.com/stable/ubuntu/stonking.noarmor.gpg | sudo tee /usr/share/keyrings/tailscale-archive-keyring.gpg &amp;gt;/dev/null; echo "pipe exit=$?"
curl: (22) The requested URL returned error: 404
pipe exit=0
$ curl -fsSL https://pkgs.tailscale.com/stable/ubuntu/stonking.tailscale-keyring.list | sudo tee /etc/apt/sources.list.d/tailscale.list; echo "pipe exit=$?"
curl: (22) The requested URL returned error: 404
pipe exit=0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The pipeline's exit status is &lt;code&gt;tee&lt;/code&gt;'s, and &lt;code&gt;tee&lt;/code&gt; happily wrote what it was given, which was nothing. You now have a 0 byte keyring and a 0 byte list file:&lt;br&gt;
&lt;/p&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 Oct 16 17:58 /etc/apt/sources.list.d/tailscale.list
-rw-r--r-- 1 root root 0 Oct 16 17:58 /usr/share/keyrings/tailscale-archive-keyring.gpg
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An empty list file has no sources in it, so &lt;code&gt;sudo apt-get update&lt;/code&gt; succeeds without a word about Tailscale, and the failure turns up one command later, looking like a typo:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ sudo apt-get install -y tailscale
Reading package lists...
Building dependency tree...
Reading state information...
E: Unable to locate package tailscale
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you see that, &lt;code&gt;cat /etc/apt/sources.list.d/tailscale.list&lt;/code&gt;. Empty means the download failed. The next section overwrites both files, so there's nothing to clean up 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%2Ffy7rigqfcb67pdpiqgxz.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%2Ffy7rigqfcb67pdpiqgxz.png" alt="both stonking curl downloads fail with error 404 while the pipe exits 0, tailscale.list and the keyring are 0 bytes, and apt-get install says E: Unable to locate package tailscale" width="800" height="362"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  4. The fix: use the resolute repo
&lt;/h2&gt;

&lt;p&gt;Same steps as Tailscale's 26.04 instructions, with &lt;code&gt;resolute&lt;/code&gt; where you'd expect &lt;code&gt;stonking&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;-fsSL&lt;/span&gt; https://pkgs.tailscale.com/stable/ubuntu/resolute.noarmor.gpg &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="nb"&gt;sudo tee&lt;/span&gt; /usr/share/keyrings/tailscale-archive-keyring.gpg &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null
curl &lt;span class="nt"&gt;-fsSL&lt;/span&gt; https://pkgs.tailscale.com/stable/ubuntu/resolute.tailscale-keyring.list &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="nb"&gt;sudo tee&lt;/span&gt; /etc/apt/sources.list.d/tailscale.list
&lt;span class="nb"&gt;sudo &lt;/span&gt;apt-get update
&lt;span class="nb"&gt;sudo &lt;/span&gt;apt-get &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; tailscale
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The second &lt;code&gt;tee&lt;/code&gt; echoes the file, which is how you know it isn't empty 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;# Tailscale packages for ubuntu resolute
deb [signed-by=/usr/share/keyrings/tailscale-archive-keyring.gpg] https://pkgs.tailscale.com/stable/ubuntu resolute main
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;apt-get update&lt;/code&gt; fetched &lt;code&gt;resolute InRelease&lt;/code&gt; and the package lists, and the install pulled in &lt;code&gt;tailscale-archive-keyring&lt;/code&gt; alongside it (a recommended package that owns &lt;code&gt;/usr/share/keyrings/tailscale-archive-keyring.gpg&lt;/code&gt; from then on):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Setting up tailscale-archive-keyring (1.35.181)…
Setting up tailscale (1.104.1)…
Created symlink '/etc/systemd/system/multi-user.target.wants/tailscaled.service' → '/usr/lib/systemd/system/tailscaled.service'.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;tailscale version
systemctl is-active tailscaled
systemctl is-enabled tailscaled
&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;1.104.1
  tailscale commit: 7f4efe814ff14b064eb080344389ceb9ea2d37ac
  long version: 1.104.1-t7f4efe814-g8da26756c
  other commit: 8da26756c9a2af700dfd05fa38d21b1eef2bcaa2
  go version: go1.27.1 (tailscale/go 24ee2fd061)
active
enabled
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F30ugr1ip4qtsvol2s0c5.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%2F30ugr1ip4qtsvol2s0c5.png" alt="the resolute keyring and list download cleanly, apt-get install sets up tailscale-archive-keyring 1.35.181 and tailscale 1.104.1, and tailscaled is active and enabled" width="800" height="424"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Is it safe to run a 26.04 package on 26.10? For Tailscale, in practice yes. The signing key is the same file for every suite (the &lt;code&gt;resolute&lt;/code&gt;, &lt;code&gt;questing&lt;/code&gt; and &lt;code&gt;noble&lt;/code&gt; keys all had the same sha256 when I checked), and 1.104.1 is the same version the static tarball ships. The only cost is one line in a list file to change once Tailscale publishes &lt;code&gt;stonking&lt;/code&gt;, which section 7 covers.&lt;/p&gt;

&lt;p&gt;Updates come through apt as normal. I downgraded to 1.102.5 to check, and &lt;code&gt;apt list --upgradable&lt;/code&gt; offered &lt;code&gt;tailscale 1.104.1&lt;/code&gt;, which &lt;code&gt;sudo apt-get install --only-upgrade tailscale&lt;/code&gt; put back with &lt;code&gt;tailscaled&lt;/code&gt; still active. One thing worth knowing: Ubuntu's &lt;code&gt;unattended-upgrades&lt;/code&gt; only allows Ubuntu's own origins by default, so it won't update Tailscale on its own. Run &lt;code&gt;sudo apt upgrade&lt;/code&gt; now and then. (&lt;code&gt;sudo tailscale update --dry-run&lt;/code&gt; also recognised the apt install, but I only ran a real &lt;code&gt;tailscale update&lt;/code&gt; on the static install below.)&lt;/p&gt;

&lt;p&gt;I'd just use the &lt;code&gt;resolute&lt;/code&gt; repo for the time being.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Or run the static binaries
&lt;/h2&gt;

&lt;p&gt;If you'd rather not point apt at another release's repo, Tailscale publishes &lt;a href="https://tailscale.com/kb/1053/install-static-binaries" rel="noopener noreferrer"&gt;static binaries&lt;/a&gt; for Linux. The tarball has &lt;code&gt;tailscale&lt;/code&gt;, &lt;code&gt;tailscaled&lt;/code&gt; and a &lt;code&gt;systemd/&lt;/code&gt; folder with the unit and its defaults file. Tailscale's page shows starting &lt;code&gt;tailscaled&lt;/code&gt; by hand and points at the unit in the tarball. To install it as a service the way the package does, put the files where the unit expects them (it runs &lt;code&gt;/usr/sbin/tailscaled&lt;/code&gt; and reads &lt;code&gt;/etc/default/tailscaled&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;-fsSLO&lt;/span&gt; https://pkgs.tailscale.com/stable/tailscale_1.104.1_amd64.tgz
&lt;span class="nb"&gt;tar &lt;/span&gt;xzf tailscale_1.104.1_amd64.tgz
&lt;span class="nb"&gt;cd &lt;/span&gt;tailscale_1.104.1_amd64
&lt;span class="nb"&gt;sudo install&lt;/span&gt; &lt;span class="nt"&gt;-m&lt;/span&gt; 755 tailscale /usr/bin/tailscale
&lt;span class="nb"&gt;sudo install&lt;/span&gt; &lt;span class="nt"&gt;-m&lt;/span&gt; 755 tailscaled /usr/sbin/tailscaled
&lt;span class="nb"&gt;sudo install&lt;/span&gt; &lt;span class="nt"&gt;-m&lt;/span&gt; 644 systemd/tailscaled.service /etc/systemd/system/tailscaled.service
&lt;span class="nb"&gt;sudo install&lt;/span&gt; &lt;span class="nt"&gt;-m&lt;/span&gt; 644 systemd/tailscaled.defaults /etc/default/tailscaled
&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; tailscaled
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;1.104.1 was the current stable version when I tested; check the &lt;a href="https://pkgs.tailscale.com/stable/#static" rel="noopener noreferrer"&gt;package page&lt;/a&gt; for the latest, and use &lt;code&gt;arm64&lt;/code&gt; instead of &lt;code&gt;amd64&lt;/code&gt; on an ARM box (I only tested &lt;code&gt;amd64&lt;/code&gt;). I did this on the same box after &lt;code&gt;sudo apt-get purge tailscale tailscale-archive-keyring&lt;/code&gt; and removing the list and keyring files, so nothing from section 4 was left over.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Created symlink '/etc/systemd/system/multi-user.target.wants/tailscaled.service' → '/etc/systemd/system/tailscaled.service'.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;tailscale version&lt;/code&gt; printed the same 1.104.1 block, and &lt;code&gt;systemctl is-active tailscaled&lt;/code&gt; and &lt;code&gt;is-enabled&lt;/code&gt; said &lt;code&gt;active&lt;/code&gt; and &lt;code&gt;enabled&lt;/code&gt;. After a reboot it came back &lt;code&gt;active&lt;/code&gt; on its own.&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%2Fidiy09qdd4tm8oh40qjz.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%2Fidiy09qdd4tm8oh40qjz.png" alt="the static 1.104.1 binaries, unit and defaults file are installed, enable --now creates the symlink to /etc/systemd/system/tailscaled.service, and tailscaled is active and enabled" width="800" height="355"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Without apt, updates are up to you, and &lt;code&gt;tailscale update&lt;/code&gt; handles them. I put 1.102.5 binaries in place and ran it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ sudo tailscale update --yes
Updating Tailscale from 1.102.5 to 1.104.1; --yes given, continuing without prompts.
Downloading "https://pkgs.tailscale.com/stable/tailscale_1.104.1_amd64.tgz"
Download size: 41130123
Downloading "https://pkgs.tailscale.com/stable/tailscale_1.104.1_amd64.tgz.sig"
Signature OK
Extracting "/root/.cache/tailscale-update/tailscale_1.104.1_amd64.tgz"
Updated /usr/bin/tailscale
Updated /usr/sbin/tailscaled
Success
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It checks the signature, replaces both binaries, and restarted the service: &lt;code&gt;tailscale version --daemon&lt;/code&gt; showed the daemon at 1.104.1 straight after, and the service's start time had moved. Don't pipe it into &lt;code&gt;head&lt;/code&gt; to keep the output short, though. I did, &lt;code&gt;head&lt;/code&gt; closed the pipe, and the update died partway through the download with the old binaries still in place.&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%2Frwbnkfwu313rew2o4oyi.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%2Frwbnkfwu313rew2o4oyi.png" alt="with 1.102.5 in place, tailscale update --yes downloads 1.104.1, checks the signature, updates both binaries, and the client and daemon then report 1.104.1" width="799" height="527"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;So my pick is the &lt;code&gt;resolute&lt;/code&gt; repo. It's one file to change later, and apt keeps track of what it installed. The static route is for when you don't want another release's repo on the box at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Join the tailnet
&lt;/h2&gt;

&lt;p&gt;Either way, joining is the same as on any other release:&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;tailscale up
&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;To authenticate, visit:

    https://login.tailscale.com/a/5021...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Open that URL in a browser logged into your tailnet and approve the machine. I tested on a throwaway box with no auth key, so I stopped here: I haven't shown it joining a tailnet on 26.10, only that the daemon runs and hands out a login URL (with both installs). Before that, &lt;code&gt;tailscale status&lt;/code&gt; just says &lt;code&gt;Logged out.&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%2Flozrahf69j1m5rtajtqg.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%2Flozrahf69j1m5rtajtqg.png" alt="tailscale up prints To authenticate, visit and a login URL, and tailscale status says Logged out with a Log in at URL" width="800" height="462"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;For a headless server, use an auth key with &lt;code&gt;sudo tailscale up --authkey ...&lt;/code&gt;; the &lt;a href="https://peculiarengineer.com/blog/install-tailscale-ubuntu-26-04/#on-a-headless-server" rel="noopener noreferrer"&gt;26.04 guide&lt;/a&gt; covers it, along with checking the connection with &lt;code&gt;tailscale ip -4&lt;/code&gt; and &lt;code&gt;tailscale ping&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Once it's on the tailnet, the first thing I do is SSH in over Tailscale, usually to test it before I start throwing apps on it.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. Move to stonking when it ships
&lt;/h2&gt;

&lt;p&gt;Tailscale will publish a &lt;code&gt;stonking&lt;/code&gt; repo; I don't know when. The install script's own check is the easiest thing to watch:&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;-fsS&lt;/span&gt; https://pkgs.tailscale.com/stable/ubuntu/stonking/installer-supported
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Today that fails with &lt;code&gt;curl: (22) The requested URL returned error: 404&lt;/code&gt;. For &lt;code&gt;resolute&lt;/code&gt; it prints &lt;code&gt;OK&lt;/code&gt;. When &lt;code&gt;stonking&lt;/code&gt; prints &lt;code&gt;OK&lt;/code&gt;, change the suite in your list file and update:&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 sed&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="s1"&gt;'s/ resolute / stonking /'&lt;/span&gt; /etc/apt/sources.list.d/tailscale.list
&lt;span class="nb"&gt;sudo &lt;/span&gt;apt-get update
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The keyring stays as it is, since it's the same key. I ran that &lt;code&gt;sed&lt;/code&gt; on the lab box to check it edits the right word, and today &lt;code&gt;apt-get update&lt;/code&gt; then fails with the &lt;code&gt;does not have a Release file&lt;/code&gt; error from section 2, as you'd expect; I put &lt;code&gt;resolute&lt;/code&gt; back with the reverse &lt;code&gt;sed&lt;/code&gt; and it fetched cleanly again. What I couldn't test is the real &lt;code&gt;stonking&lt;/code&gt; repo, because it doesn't exist yet. The comment line in the file will still say &lt;code&gt;resolute&lt;/code&gt;; apt ignores 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%2Fijyxzpup5aouw759chdb.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%2Fijyxzpup5aouw759chdb.png" alt="the stonking installer-supported URL fails with 404 and the resolute one prints OK" width="800" height="192"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you used the static binaries, there's nothing to switch: &lt;code&gt;tailscale update&lt;/code&gt; keeps working. If you want to move to the apt package later, I'd remove the four files from section 5 first (I didn't test installing the package over them).&lt;/p&gt;

&lt;p&gt;As for going back to an LTS: I'll stay with 26.10.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;The install script refuses 26.10 outright and leaves nothing behind, which is the good failure.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;curl -fsSL ... | sudo tee&lt;/code&gt; exits 0 when the download 404s, and leaves an empty keyring and an empty list file. The error shows up later as &lt;code&gt;Unable to locate package tailscale&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;does not have a Release file&lt;/code&gt; is a 404, not a broken repo, and it fails the whole &lt;code&gt;apt update&lt;/code&gt; with status 100.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;unattended-upgrades&lt;/code&gt; won't touch Tailscale's repo by default; update it yourself.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;tailscale update --yes | head&lt;/code&gt; kills the update partway through.&lt;/li&gt;
&lt;li&gt;I couldn't join a tailnet from the lab (no auth key), so everything here stops at the login URL.&lt;/li&gt;
&lt;/ul&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;Is there a 26.10 repo yet?&lt;/td&gt;
&lt;td&gt;&lt;code&gt;curl -fsS https://pkgs.tailscale.com/stable/ubuntu/stonking/installer-supported&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Install from the 26.04 repo&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;resolute.noarmor.gpg&lt;/code&gt; and &lt;code&gt;resolute.tailscale-keyring.list&lt;/code&gt;, then &lt;code&gt;sudo apt-get install -y tailscale&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Check for the empty list file&lt;/td&gt;
&lt;td&gt;&lt;code&gt;cat /etc/apt/sources.list.d/tailscale.list&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Static binaries&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;tailscale&lt;/code&gt; to &lt;code&gt;/usr/bin&lt;/code&gt;, &lt;code&gt;tailscaled&lt;/code&gt; to &lt;code&gt;/usr/sbin&lt;/code&gt;, unit to &lt;code&gt;/etc/systemd/system&lt;/code&gt;, defaults to &lt;code&gt;/etc/default/tailscaled&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Check the install&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;tailscale version&lt;/code&gt;, &lt;code&gt;systemctl is-active tailscaled&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Update&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;sudo apt upgrade&lt;/code&gt; (repo), &lt;code&gt;sudo tailscale update&lt;/code&gt; (static)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Join&lt;/td&gt;
&lt;td&gt;&lt;code&gt;sudo tailscale up&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Move to stonking later&lt;/td&gt;
&lt;td&gt;&lt;code&gt;sudo sed -i 's/ resolute / stonking /' /etc/apt/sources.list.d/tailscale.list&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Same Tailscale, borrowed from 26.04 for now, and one word to change when &lt;code&gt;stonking&lt;/code&gt; shows up.&lt;/p&gt;

</description>
      <category>tailscale</category>
      <category>ubuntu</category>
      <category>linux</category>
      <category>networking</category>
    </item>
    <item>
      <title>Claude Code using a local LLM</title>
      <dc:creator>Minor Keith</dc:creator>
      <pubDate>Fri, 16 Oct 2026 19:39:13 +0000</pubDate>
      <link>https://dev.to/peculiarengineer/claude-code-using-a-local-llm-28nb</link>
      <guid>https://dev.to/peculiarengineer/claude-code-using-a-local-llm-28nb</guid>
      <description>&lt;p&gt;I wanted a local model for the privacy it gives me. And if I do have to use a frontier model as the master control node, the problem is cost: frontier models are expensive, and the subscription usage limits seem keep getting smaller and smaller. &lt;/p&gt;

&lt;p&gt;So the question is whether the expensive model can do the thinking while the cheap one does the typing, and whether anything worth having comes out the other end. (For this test the "local" model ran on a rented RunPod card, which is not private from RunPod. It's the same 16.8 GB file that runs on any 24 GB card you own.)&lt;/p&gt;

&lt;p&gt;There are already plenty of posts on wiring Claude Code to Ollama or a local endpoint. What I couldn't find was anyone measuring whether the split actually beats just giving the local model the whole job. So I had Claude set up a small project with hidden tests, ran it both ways on the same card, and graded it with the same script. This is what came out, including the parts that don't flatter the idea.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR.&lt;/strong&gt; Orchestrated (Claude writes five small task prompts, reviews every result against the spec, sends defects back with a reproduction): &lt;strong&gt;78/78 and 77/78&lt;/strong&gt; on the full test suite, 58 of those tests hidden from the worker. One big prompt to the same model: &lt;strong&gt;76, 74 and 71 out of 78&lt;/strong&gt;, and the 71 was the run with the most turns. Every one shot run shipped the same bug (replaying the log wrote it out again), and both orchestrated runs had it too, until review caught it. The worker was Qwen3.8 27B Q4_K_M on llama.cpp, one RTX PRO 4000 Blackwell at $0.57/hr, 32K context, and the whole session cost about forty cents of GPU. The review is where the defects died. This setup can't say how much the splitting alone buys.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;Two roles. "Claude Code with a local model" gets used for two different setups, so here is which is which.&lt;/p&gt;

&lt;p&gt;The first setup points Claude Code itself at a local endpoint, so the local model becomes the whole agent. That's not this. Here Claude stays Claude. It plans, writes the task prompts, reads the code that comes back, runs probes against it, and decides what goes back for rework. The local model is a worker it calls through a small harness, one stateless conversation per task, with four tools and a workspace folder.&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;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Orchestrator&lt;/td&gt;
&lt;td&gt;Claude, in a Claude Code session on my Mac&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Worker&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;Qwen3.8-27B-Uncensored-Q4_K_M.gguf&lt;/code&gt; (16.8 GB), llama.cpp &lt;code&gt;llama-server&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GPU&lt;/td&gt;
&lt;td&gt;1x RTX PRO 4000 Blackwell, 24 GB, RunPod Secure, $0.57/hr&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Context&lt;/td&gt;
&lt;td&gt;32,768 tokens, &lt;code&gt;--jinja&lt;/code&gt;, flash attention on, thinking off, temperature 0.2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Reach&lt;/td&gt;
&lt;td&gt;SSH tunnel from the Mac to the pod's port 8080&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;I wanted the RTX A5000 at $0.27/hr, which is what the first round of this experiment ran on, but RunPod had none in stock when I asked. The PRO 4000 was next on the list. The card changes speed. The model file and settings are identical, but the harness changed between the rounds, so the two aren't a card comparison.&lt;/p&gt;

&lt;p&gt;The server on the pod is one line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;llama-server &lt;span class="nt"&gt;-m&lt;/span&gt; /workspace/gguf/Qwen3.8-27B-Uncensored-Q4_K_M.gguf &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--host&lt;/span&gt; 127.0.0.1 &lt;span class="nt"&gt;--port&lt;/span&gt; 8080 &lt;span class="nt"&gt;-c&lt;/span&gt; 32768 &lt;span class="nt"&gt;-ngl&lt;/span&gt; 99 &lt;span class="nt"&gt;--jinja&lt;/span&gt; &lt;span class="nt"&gt;-fa&lt;/span&gt; on &lt;span class="nt"&gt;--alias&lt;/span&gt; &lt;span class="nb"&gt;local&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It listens on localhost only, and I reach it through a tunnel instead of RunPod's public proxy:&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;-f&lt;/span&gt; &lt;span class="nt"&gt;-N&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; &amp;lt;pod-ssh-port&amp;gt; &lt;span class="nt"&gt;-i&lt;/span&gt; ~/.runpod/ssh/runpodctl-ssh-key &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nv"&gt;ExitOnForwardFailure&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;yes&lt;/span&gt; &lt;span class="nt"&gt;-L&lt;/span&gt; 8091:127.0.0.1:8080 root@&amp;lt;pod-ip&amp;gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; http://127.0.0.1:8091/health
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The prebuilt llama.cpp comes from my own Cloudflare R2 bucket. The model came from Hugging Face for the measured runs and is now mirrored to the same bucket. For the screenshot run below, a fresh pod pulled both from R2 and was serving in 379 seconds, a little over six minutes, five of them the 15.7 GiB model download. If you're starting from scratch, &lt;a href="https://peculiarengineer.com/blog/self-host-qwen-runpod-vllm/" rel="noopener noreferrer"&gt;the vLLM post&lt;/a&gt; covers renting the card and what RunPod bills you for.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzdc32ijdur9vbvg7na4b.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%2Fzdc32ijdur9vbvg7na4b.png" alt="On the pod: nvidia-smi shows the RTX PRO 4000 Blackwell with 24467 MiB total and 18178 MiB used, the 16810714496 byte GGUF sits in /workspace/gguf, and llama-server runs on 127.0.0.1:8080 with -c 32768" width="799" height="281"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The pod screenshots come from a fresh pod after the runs above, so the times and token counts in them differ from the tables.&lt;/p&gt;

&lt;h2&gt;
  
  
  The test project
&lt;/h2&gt;

&lt;p&gt;A toy project is only useful if it has corners. This one is &lt;code&gt;minikv&lt;/code&gt;, a key value store in plain Python with five modules that depend on each other:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Module&lt;/th&gt;
&lt;th&gt;What it does&lt;/th&gt;
&lt;th&gt;The corner that bites&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;store.py&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;keys, values, expiry&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;ttl&lt;/code&gt; rounding, expired keys still in the dict&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;txn.py&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;nested transactions on top of the store&lt;/td&gt;
&lt;td&gt;a key deleted in a transaction must read as missing, even if the store still has it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;protocol.py&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;parse and format text commands&lt;/td&gt;
&lt;td&gt;quoting rules, error order, &lt;code&gt;KEYS&lt;/code&gt; takes no arguments&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;aof.py&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;append only log file&lt;/td&gt;
&lt;td&gt;a torn last line is ignored, a blank line is kept&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;session.py&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;ties it together, replays the log at startup&lt;/td&gt;
&lt;td&gt;replayed lines must not be logged again; transactions buffer their lines&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The spec is one page. There are 78 &lt;code&gt;unittest&lt;/code&gt; tests: 20 the worker can see (one file per module) and 58 it never sees, covering edge cases and cross module scenarios. A reference solution written from the spec alone passes 78/78, so the tests are satisfiable. The grader takes the denominator from the test files, never from the submission, and only copies the five spec modules into its sandbox, so a stray file in the workspace can't shadow anything. It also runs Python with &lt;code&gt;-I&lt;/code&gt; and counts results from the runner itself, so a fake &lt;code&gt;unittest.py&lt;/code&gt; or a doctored summary gains nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The worker harness
&lt;/h2&gt;

&lt;p&gt;The worker is a Python script, &lt;code&gt;worker.py&lt;/code&gt;, that holds one conversation with the model through the OpenAI style &lt;code&gt;/v1/chat/completions&lt;/code&gt; endpoint and gives it four tools:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;list_files&lt;/code&gt; and &lt;code&gt;read_file&lt;/code&gt;, confined to the workspace folder&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;write_file&lt;/code&gt;, confined the same way (absolute paths, &lt;code&gt;..&lt;/code&gt; and symlinks out are refused)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;run_command&lt;/code&gt;, limited to &lt;code&gt;python3&lt;/code&gt; scripts, &lt;code&gt;python3 -c&lt;/code&gt;, &lt;code&gt;python3 -m unittest&lt;/code&gt;, &lt;code&gt;ls&lt;/code&gt;, &lt;code&gt;cat&lt;/code&gt; and &lt;code&gt;pwd&lt;/code&gt;, relative paths only&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It also has a repeat guard: when the same sequence of one to three turns, tool calls and results included, repeats four times in a row, it gets a warning, and if it carries on, the run stops with &lt;code&gt;repeat-stop&lt;/code&gt;. Every call prints one JSON line (trimmed here; a &lt;code&gt;done&lt;/code&gt; run also carries the model's summary):&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="nl"&gt;"turns"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"calls"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"bad_calls"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"completion_tokens"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;693&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"max_prompt_tokens"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2060&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"total_prompt_tokens"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;9455&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"repeat_warnings"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"finish"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"done"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"secs"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;55.1&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;finish&lt;/code&gt; is the field that matters. &lt;code&gt;done&lt;/code&gt; means the model said it was finished. &lt;code&gt;max-turns&lt;/code&gt; means it ran out of turns, and in my runs that usually meant it was going in circles.&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%2Fa3d225ak5ag0lzj94fus.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%2Fa3d225ak5ag0lzj94fus.png" alt="On the Mac: the health check returns status ok, store-ws starts with clock.py and test_store.py, one worker call ends done after 6 turns and 36.6 seconds with a summary saying all 4 tests pass, store.py appears, and the grader gives 4/4 visible and 8/8 hidden for the store" width="799" height="492"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How the orchestrated run works
&lt;/h2&gt;

&lt;p&gt;Five tasks, one per module, in dependency order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Stage A, in parallel:&lt;/strong&gt; &lt;code&gt;store.py&lt;/code&gt;, &lt;code&gt;protocol.py&lt;/code&gt;, &lt;code&gt;aof.py&lt;/code&gt;. Nothing depends on anything yet. Three tasks against one &lt;code&gt;llama-server&lt;/code&gt;, and the slowest of them finished in 87 seconds.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Review.&lt;/strong&gt; Claude reads each file against the spec and probes it with &lt;code&gt;python3 -c&lt;/code&gt; one liners: the edge cases the spec names, error messages, round trips.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rework&lt;/strong&gt; for anything wrong, while the next module starts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stage B:&lt;/strong&gt; &lt;code&gt;txn.py&lt;/code&gt; (needs the store), then &lt;code&gt;session.py&lt;/code&gt; (needs everything).&lt;/li&gt;
&lt;li&gt;At most two rework rounds per task. After that, whatever is there gets graded.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Each task prompt is the spec text for that module, the interfaces of the modules it uses, the test command, and the line doing the most work: "the final grading uses extra tests you cannot see, so follow the requirements exactly". Here is the store prompt, trimmed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Write store.py (test file: test_store).
The workspace contains clock.py and a visible test file for your module. Plain Python 3 standard
library only. Run the test file with `python3 -m unittest &amp;lt;testfile without .py&amp;gt;` and fix failures.
The final grading uses extra tests you cannot see, so follow the requirements exactly, including
edge cases and error messages. Keep to the file named below; do not edit other files or the tests.

class Store(clock); attribute `clock` holds the clock. ...
- ttl(key) -&amp;gt; -2 if missing or expired, -1 if the key has no expiry, otherwise the remaining
  seconds rounded UP to an int (math.ceil(expire_at - now)).
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A rework prompt states the defect, gives a short reproduction, and quotes the spec. No hint at the cause. This one, trimmed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Review of the current txn.py against the spec found that, inside a transaction, a key that was
deleted or has expired is still treated as present by some methods.

    t.delete("a")      # True, correct
    t.delete("a")      # returns True, required False (the key is no longer visible to this transaction)
    t.ttl("a")         # returns -1, required -2 (missing)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The one shot condition got the whole spec in a single prompt, all five modules at once, with the same tools, the same visible tests and a budget of 90 turns.&lt;/p&gt;

&lt;h2&gt;
  
  
  Results
&lt;/h2&gt;

&lt;p&gt;All runs on the same pod, same model, same harness, same grader.&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;Score (58 hidden + 20 visible)&lt;/th&gt;
&lt;th&gt;Worker turns&lt;/th&gt;
&lt;th&gt;Completion tokens&lt;/th&gt;
&lt;th&gt;Worker time&lt;/th&gt;
&lt;th&gt;Largest prompt&lt;/th&gt;
&lt;th&gt;How it ended&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Orchestrated, run 1&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;78/78&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;84&lt;/td&gt;
&lt;td&gt;12,594&lt;/td&gt;
&lt;td&gt;672 s&lt;/td&gt;
&lt;td&gt;7,832&lt;/td&gt;
&lt;td&gt;all tasks done&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Orchestrated, run 2&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;77/78&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;69&lt;/td&gt;
&lt;td&gt;11,852&lt;/td&gt;
&lt;td&gt;673 s&lt;/td&gt;
&lt;td&gt;6,014&lt;/td&gt;
&lt;td&gt;one rework ran out of turns&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;One shot, run 1&lt;/td&gt;
&lt;td&gt;74/78&lt;/td&gt;
&lt;td&gt;23&lt;/td&gt;
&lt;td&gt;5,395&lt;/td&gt;
&lt;td&gt;204 s&lt;/td&gt;
&lt;td&gt;10,469&lt;/td&gt;
&lt;td&gt;repeat guard stopped it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;One shot, run 2&lt;/td&gt;
&lt;td&gt;76/78&lt;/td&gt;
&lt;td&gt;11&lt;/td&gt;
&lt;td&gt;4,497&lt;/td&gt;
&lt;td&gt;221 s&lt;/td&gt;
&lt;td&gt;8,568&lt;/td&gt;
&lt;td&gt;said it was done&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;One shot, guard off, 90 turns&lt;/td&gt;
&lt;td&gt;71/78&lt;/td&gt;
&lt;td&gt;90&lt;/td&gt;
&lt;td&gt;22,442&lt;/td&gt;
&lt;td&gt;935 s&lt;/td&gt;
&lt;td&gt;32,001&lt;/td&gt;
&lt;td&gt;out of turns, context nearly full&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fokywahmegqqnpmt4dcgn.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%2Fokywahmegqqnpmt4dcgn.png" alt="grade.py on the second one shot workspace: every visible test passes, test_integration.py is 6/7 and test_session_h.py is 18/19 with an extra 'SET a 1' line in the log, TOTAL 76/78" width="800" height="434"&gt;&lt;/a&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%2Fpw1c98geb827a0amtqpn.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%2Fpw1c98geb827a0amtqpn.png" alt="grade.py on the first orchestrated workspace: every visible and hidden file passes, TOTAL 78/78" width="800" height="558"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Both graded again for the screenshots, from the workspaces those runs left behind.&lt;/p&gt;

&lt;p&gt;Worker time for the orchestrated runs is summed across tasks. Three of them ran in parallel, so the wall clock was shorter.&lt;/p&gt;

&lt;p&gt;The headline is the gap, 77 to 78 against 71 to 76, but the more useful number is in the Largest prompt column. No orchestrated prompt got past 8K tokens. The one shot runs went to 10K and 8.5K and the long one ran to within 800 tokens of the 32K limit. The small tasks keep the model in the part of its context where it still pays attention.&lt;/p&gt;

&lt;p&gt;The cost of that is turns. Orchestration took three to eight times as many worker turns as the one shot runs that stopped on their own. On a card that bills by the hour, that's a few extra minutes: the whole session, both conditions and all five runs, was 42 minutes of pod time, about $0.40.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the review caught
&lt;/h2&gt;

&lt;p&gt;Every defect below passed the visible tests. That's the point of having hidden ones.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Module&lt;/th&gt;
&lt;th&gt;Defect&lt;/th&gt;
&lt;th&gt;Run 1&lt;/th&gt;
&lt;th&gt;Run 2&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;protocol.py&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;KEYS&lt;/code&gt; not implemented at all; &lt;code&gt;parse("KEYS")&lt;/code&gt; raised "unknown command"&lt;/td&gt;
&lt;td&gt;fixed in one rework&lt;/td&gt;
&lt;td&gt;fixed in one rework&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;txn.py&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;a deleted or expired key still counted as found: second &lt;code&gt;delete&lt;/code&gt; returned True, &lt;code&gt;ttl&lt;/code&gt; gave -1, an expired key stayed in &lt;code&gt;keys()&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;fixed in one rework&lt;/td&gt;
&lt;td&gt;fixed in one rework&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;txn.py&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;a key deleted in an outer transaction was still visible inside a nested one&lt;/td&gt;
&lt;td&gt;not present&lt;/td&gt;
&lt;td&gt;still there when the rework rounds ran out&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;session.py&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;replaying the log at startup wrote every line out again, so the file doubled on each restart&lt;/td&gt;
&lt;td&gt;fixed in round two&lt;/td&gt;
&lt;td&gt;fixed in one rework&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;session.py&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;an inner &lt;code&gt;ROLLBACK&lt;/code&gt; kept that transaction's lines, so a rolled back &lt;code&gt;SET&lt;/code&gt; reached the log&lt;/td&gt;
&lt;td&gt;fixed in round two&lt;/td&gt;
&lt;td&gt;not present&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The replay bug is the one I'd frame. Every one shot run shipped it: all three fail the hidden test &lt;code&gt;test_replay_does_not_relog&lt;/code&gt;. The orchestrated runs wrote it too, both times. The only difference is that Claude read &lt;code&gt;__init__&lt;/code&gt;, saw the replay loop call &lt;code&gt;execute()&lt;/code&gt;, and knew &lt;code&gt;execute()&lt;/code&gt; logs. It's an easy bug to write and an easy bug to see, as long as somebody is looking.&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%2F0jxlpy9g2kczvfksh2jv.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%2F0jxlpy9g2kczvfksh2jv.png" alt="replay_check.py writes one SET, then restarts the session three times: on the one shot workspace the log grows to 2, 4 and 8 lines, on the orchestrated one it stays at 1" width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The same goes for &lt;code&gt;txn.py&lt;/code&gt;. The model wrote the same "deleted means found" bug in both orchestrated runs, and the first round of this experiment, the day before, hit it too. It's a stable blind spot.&lt;/p&gt;

&lt;p&gt;I wouldn't trust it on its own, like any model. I would need to review its output. It will tell you it's done with the replay bug still in there, so the shape that works is well specified modules going out and something else doing the reading.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the review missed
&lt;/h2&gt;

&lt;p&gt;The second orchestrated run lost one test, and it's a fair hit. The spec says to quote an argument when it is empty or contains whitespace or a double quote. The worker also quoted values with a backslash in them, so &lt;code&gt;format_command&lt;/code&gt; produced &lt;code&gt;SET k "a\\b"&lt;/code&gt; where the hidden test wanted &lt;code&gt;SET k a\b&lt;/code&gt;. Both forms parse back to the same command, so a round trip check passes either way. Catching it means checking what &lt;code&gt;format_command&lt;/code&gt; writes, character by character, and the review didn't.&lt;/p&gt;

&lt;p&gt;And the nested transaction bug in run 2 was still there when its rework rounds ran out (only the second one was about it), but none of the 58 hidden tests exercise that case, so it cost nothing on the scoreboard. The review found a real defect the tests can't see, and the tests found one the review missed. Neither is a complete check on its own.&lt;/p&gt;

&lt;h2&gt;
  
  
  More turns made one shot worse
&lt;/h2&gt;

&lt;p&gt;The obvious objection to the first round of this experiment was budget: the orchestrated run used 88 turns and the one shot run only 40. So this time one shot got 90 turns, and one run had the repeat guard switched off so it could use all of them.&lt;/p&gt;

&lt;p&gt;It used all of them and scored the worst of the five, 71/78. By the end its prompt was 32,001 tokens, within 800 of the 32,768 limit, because every turn appends the tool results to the conversation. The shorter runs failed two and four hidden tests. This one failed seven, including the transaction logging tests the shorter runs passed. More rope, same model, worse code. The runs with the guard on stopped themselves at 23 and 11 turns and did better.&lt;/p&gt;

&lt;p&gt;So the gap isn't that orchestration gets more turns. It's that each turn happens in a short, focused conversation, with a reviewer deciding what the next one is about.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;The command filter is not a sandbox.&lt;/strong&gt; In one session rework the worker spent twelve of its twenty turns running the same check script with a new file name each time: &lt;code&gt;/tmp/aof1.txt&lt;/code&gt;, &lt;code&gt;/tmp/aof2.txt&lt;/code&gt;, up to &lt;code&gt;/tmp/aof12.txt&lt;/code&gt;. Two problems in one. The absolute path was inside a &lt;code&gt;python3 -c&lt;/code&gt; string, which the filter doesn't parse, so it wrote eleven files into &lt;code&gt;/tmp&lt;/code&gt; on my Mac. And because each turn used a different file name, no two turns were identical and the repeat guard never fired. The worker's code runs as you, on your machine. Give it a workspace with nothing in it that matters.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;finish: done&lt;/code&gt; is the model's opinion.&lt;/strong&gt; Every task that ended &lt;code&gt;done&lt;/code&gt; with a defect still in it came back with a cheerful summary saying all tests pass or everything is correct. They did pass. The visible ones.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;max-turns&lt;/code&gt; means read the code.&lt;/strong&gt; Three worker calls ran out of turns across the orchestrated runs. One was the original session task, which left code that passed its visible tests with two defects in it. The other two were reworks that didn't fix what they were sent to fix. The status tells you it stopped, not what it left behind.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A second rework needs to say where.&lt;/strong&gt; The first session rework (defect plus reproduction plus spec) changed nothing at all. The second named the method and described the fix, and it was done in seven turns. That's the orchestrator doing more of the work, and it counts against the "Claude only reviews" story. It's in the honest limits below.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Budget for the review.&lt;/strong&gt; The worker's numbers are in the table. Claude's aren't: reading five modules, writing probes and writing rework prompts is real orchestrator time and tokens, and it isn't counted anywhere above.&lt;/p&gt;

&lt;h2&gt;
  
  
  Honest limits
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Two runs per condition is still small.&lt;/strong&gt; The model isn't deterministic: the one shot runs scored 76, 74 and 71 with the same prompt. The gap held across every pair, but I wouldn't put a decimal place on it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Run 2's review wasn't fully blind.&lt;/strong&gt; Claude reviewed run 1 without having seen any hidden test. Before run 2, grading the one shot runs had shown it short failure notes from the hidden tests. The run 2 review was held to the same probes and spec reading as run 1, but a note can't be unseen.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The spec is precise, and the review leans on it.&lt;/strong&gt; A reviewer reading a vaguer spec would catch less. Part of the win is a precise spec, which is also true when the worker is a person.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Some rework prompts went beyond the spec.&lt;/strong&gt; The second session rework in run 1 described the fix (a replay flag, one buffer per transaction), not just the defect.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Context was never the binding constraint for orchestration.&lt;/strong&gt; Its largest prompt was under 8K. This project doesn't show whether decomposition rescues a codebase that doesn't fit in 32K, only that it keeps the model well clear of the limit.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Orchestrator cost isn't counted.&lt;/strong&gt; Claude's tokens are the expensive ones, and the comparison above is worker cost only.&lt;/li&gt;
&lt;/ul&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;Thing&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Model&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;Qwen3.8-27B-Uncensored-Q4_K_M.gguf&lt;/code&gt;, 16,810,714,496 bytes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Card&lt;/td&gt;
&lt;td&gt;24 GB is enough at 32K context (RTX PRO 4000 Blackwell here, RTX A5000 in the first round)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Server&lt;/td&gt;
&lt;td&gt;&lt;code&gt;llama-server -c 32768 -ngl 99 --jinja -fa on --alias local&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Worker settings&lt;/td&gt;
&lt;td&gt;temperature 0.2, &lt;code&gt;enable_thinking: false&lt;/code&gt;, &lt;code&gt;max_tokens&lt;/code&gt; 4096&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Task size that worked&lt;/td&gt;
&lt;td&gt;one module, 20 to 150 lines, its spec text, its interfaces, its test command&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rework&lt;/td&gt;
&lt;td&gt;defect, reproduction, spec text; at most two rounds&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Trust&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;finish: done&lt;/code&gt; and passing visible tests mean nothing until someone reads the code&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The cheap model is good enough for small, well specified tasks like this one. Would I trust it to touch my database? Probably not. But I can use it for automation tasks like Python or Bash scripts. Like with any model, check the output. Don't just run whatever it gives you.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>claude</category>
      <category>llm</category>
      <category>python</category>
    </item>
    <item>
      <title>Self-host NetBird on Ubuntu 26.04 using a DuckDNS name</title>
      <dc:creator>Minor Keith</dc:creator>
      <pubDate>Thu, 24 Sep 2026 13:28:20 +0000</pubDate>
      <link>https://dev.to/peculiarengineer/self-host-netbird-on-ubuntu-2604-using-a-duckdns-name-22j9</link>
      <guid>https://dev.to/peculiarengineer/self-host-netbird-on-ubuntu-2604-using-a-duckdns-name-22j9</guid>
      <description>&lt;p&gt;I wanted one thing at home reachable from an ordinary browser, over HTTPS, without opening a port on the router and without the traffic going through someone else's edge. Cloudflare Tunnel and Tailscale Funnel both do the first part, and both route you through their network to do it. NetBird can do it on a VPS you own: the server that runs your WireGuard mesh now has a reverse proxy built in, so the VPS takes the HTTPS request and hands it down the tunnel to the machine at home.&lt;/p&gt;

&lt;p&gt;NetBird's docs start with "point your domain and a wildcard record at the server", and that is the part that stops people who never bought a domain. This post does it with a free DuckDNS name, and I ran it on two fresh Ubuntu 26.04 servers: one as the NetBird server, and one standing in for the machine at home, with nothing open but SSH.&lt;/p&gt;

&lt;p&gt;The one thing to get straight: the VPS takes every public connection, and the machine at home only ever connects outward. There are three pieces. The &lt;strong&gt;NetBird server&lt;/strong&gt; on the VPS is the control plane (users, peers, keys) and the relay. The &lt;strong&gt;peer&lt;/strong&gt; at home dials out to it, so nothing listens at home. The &lt;strong&gt;NetBird Proxy&lt;/strong&gt;, also on the VPS, answers &lt;code&gt;https://app.&amp;lt;your name&amp;gt;&lt;/code&gt; with its own certificate for that name and forwards the request over WireGuard to the peer. Traefik sits in front of both and passes TLS for the proxy's names straight through, which is why NetBird's docs say Traefik is the only front proxy that works for this.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR.&lt;/strong&gt; Get a DuckDNS name and point it at the VPS. Open TCP 80 and 443 and UDP 3478 on the VPS. Install Docker and &lt;code&gt;jq&lt;/code&gt;, then run NetBird's getting started script with &lt;code&gt;NETBIRD_ENABLE_PROXY=true&lt;/code&gt;. Create the admin at &lt;code&gt;https://&amp;lt;name&amp;gt;.duckdns.org/setup&lt;/code&gt;. On the machine at home, install the NetBird client and &lt;code&gt;netbird up --management-url https://&amp;lt;name&amp;gt;.duckdns.org --setup-key &amp;lt;key&amp;gt;&lt;/code&gt;. Bind the app to &lt;code&gt;0.0.0.0&lt;/code&gt; or the NetBird address (not &lt;code&gt;127.0.0.1&lt;/code&gt;), add a reverse proxy service for &lt;code&gt;app.&amp;lt;name&amp;gt;.duckdns.org&lt;/code&gt; pointing at that peer and port, and open it. DuckDNS answers for every name under yours, which is the wildcard record the docs ask for.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  What I tested on
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Two Hetzner cx23 servers (2 vCPU, 4GB) with Ubuntu 26.04, on 24 September 2026. The cloud firewall on the "home" box only let SSH in.&lt;/li&gt;
&lt;li&gt;Docker Engine 29.8.1 and Compose v5.5.1 on the NetBird server, installed from Docker's repo as in &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;NetBird's server images as the script pulled them on the day (&lt;code&gt;netbirdio/netbird-server:latest&lt;/code&gt;, &lt;code&gt;netbirdio/dashboard:latest&lt;/code&gt;, &lt;code&gt;netbirdio/reverse-proxy:latest&lt;/code&gt;, and &lt;code&gt;traefik:v3.6&lt;/code&gt;); 0.79.0, released on 18 September, was the latest NetBird release, and the client on the peer reported 0.79.0.&lt;/li&gt;
&lt;li&gt;The DuckDNS name &lt;code&gt;pe-netbird.duckdns.org&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;NetBird's docs label the reverse proxy as beta. Everything below worked on this version; I have not tested how a service survives a NetBird upgrade.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. A free name that covers every subdomain
&lt;/h2&gt;

&lt;p&gt;The proxy gives every service its own hostname under a base name (&lt;code&gt;app.pe-netbird.duckdns.org&lt;/code&gt;), so the base name needs every subdomain under it to resolve to the VPS. With a real domain that is a wildcard DNS record. With DuckDNS it is already there: after I pointed the name at the server, every name under it answered with the same address.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ dig +short pe-netbird.duckdns.org
135.181.38.45
$ dig +short app.pe-netbird.duckdns.org @ns1.duckdns.org
135.181.38.45
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(Before that, &lt;code&gt;x.y.pe-netbird.duckdns.org&lt;/code&gt;, two levels down, resolved too.)&lt;/p&gt;

&lt;p&gt;The other reason DuckDNS works here: &lt;code&gt;duckdns.org&lt;/code&gt; is on the Public Suffix List (I checked the list), so per Let's Encrypt's rate limit docs its limits count against your name, not against every DuckDNS user put together. That matters, because this setup asks for new certificates (two, as it turns out) for every service name.&lt;/p&gt;

&lt;p&gt;Sign in at duckdns.org with one of the accounts it offers, create a name, and copy the token it shows you. Keep the token in a file rather than in your shell history:&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; ~/.config/duckdns
&lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-m&lt;/span&gt; 600 /dev/null ~/.config/duckdns/token
nano ~/.config/duckdns/token          &lt;span class="c"&gt;# paste the token, save&lt;/span&gt;
curl &lt;span class="s2"&gt;"https://www.duckdns.org/update?domains=pe-netbird&amp;amp;token=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;tr&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'\n'&lt;/span&gt; &amp;lt; ~/.config/duckdns/token&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;&amp;amp;ip=203.0.113.10"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Use your own name and your VPS address. The update URL answers &lt;code&gt;OK&lt;/code&gt;, and the new address showed up in &lt;code&gt;dig&lt;/code&gt; within seconds.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Open the ports on the VPS
&lt;/h2&gt;

&lt;p&gt;The script's closing message lists what it needs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Open ports:
  - 443/tcp   (HTTPS - all NetBird services)
  - 80/tcp    (HTTP - redirects to HTTPS)
  - 3478/udp   (STUN - required for NAT traversal)
  - 51820/udp (WIREGUARD - (optional) for P2P proxy connections)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I opened TCP 80 and 443 and UDP 3478 in the provider's firewall and left 51820 closed, which matters later (section 5). The machine at home needs nothing opened at all: every connection it makes is outbound.&lt;/p&gt;

&lt;p&gt;If the VPS also runs ufw, remember that Docker publishes ports underneath it (the &lt;a href="https://peculiarengineer.com/blog/ufw-firewall-basics-ubuntu/" rel="noopener noreferrer"&gt;ufw post&lt;/a&gt; calls this "Docker punches through"). The compose file the script writes publishes 51820/udp from the proxy container, so on a VPS with only ufw that port is open whatever ufw says. Here the provider's firewall is what kept it closed.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Install the server with the proxy switched on
&lt;/h2&gt;

&lt;p&gt;The script needs Docker with the Compose plugin, &lt;code&gt;curl&lt;/code&gt; and &lt;code&gt;jq&lt;/code&gt; already installed. A fresh 26.04 image has &lt;code&gt;curl&lt;/code&gt; but not &lt;code&gt;jq&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

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

&lt;/div&gt;



&lt;p&gt;NetBird's docs give &lt;code&gt;curl ... | bash&lt;/code&gt;. I downloaded it first so I could read it (it is about 1,800 lines), and I ran it unattended, because every prompt has an environment variable:&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/netbird &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;cd&lt;/span&gt; /opt/netbird
&lt;span class="nb"&gt;sudo &lt;/span&gt;curl &lt;span class="nt"&gt;-fsSL&lt;/span&gt; https://github.com/netbirdio/netbird/releases/latest/download/getting-started.sh &lt;span class="nt"&gt;-o&lt;/span&gt; getting-started.sh
&lt;span class="nb"&gt;sudo &lt;/span&gt;&lt;span class="nv"&gt;NETBIRD_DOMAIN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;pe-netbird.duckdns.org &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nv"&gt;NETBIRD_LETSENCRYPT_EMAIL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;you@example.com &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nv"&gt;NETBIRD_ENABLE_PROXY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;true&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nv"&gt;NETBIRD_ENABLE_CROWDSEC&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;false&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nv"&gt;NETBIRD_NON_INTERACTIVE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;true&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  bash getting-started.sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run interactively, it asks the same things in turn: the domain, which reverse proxy (0 is the built in Traefik), an email for Let's Encrypt (required; unattended without it, the script stops with &lt;code&gt;NETBIRD_LETSENCRYPT_EMAIL is required for a non-interactive install.&lt;/code&gt;), then &lt;code&gt;Enable proxy? [y/N]&lt;/code&gt; and &lt;code&gt;Enable CrowdSec? [y/N]&lt;/code&gt;. It finished with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;NETBIRD SETUP COMPLETE
...
NetBird Proxy:
  The proxy service is enabled and running.
  Any domain NOT matching pe-netbird.duckdns.org will be passed through to the proxy.
  The proxy handles its own TLS certificates via ACME TLS-ALPN-01 challenge.
  Point your proxy domain to this server's domain address like in the examples below:

  *.pe-netbird.duckdns.org    CNAME    pe-netbird.duckdns.org
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That last line is the wildcard record DuckDNS already gives you. Four containers were running: &lt;code&gt;netbird-server&lt;/code&gt;, &lt;code&gt;netbird-dashboard&lt;/code&gt;, &lt;code&gt;netbird-proxy&lt;/code&gt; and &lt;code&gt;netbird-traefik&lt;/code&gt;. The configuration is in &lt;code&gt;/opt/netbird&lt;/code&gt; (&lt;code&gt;docker-compose.yml&lt;/code&gt;, &lt;code&gt;config.yaml&lt;/code&gt;, &lt;code&gt;dashboard.env&lt;/code&gt;, &lt;code&gt;proxy.env&lt;/code&gt;, &lt;code&gt;traefik-dynamic.yaml&lt;/code&gt;), so back that folder up with the Docker volumes it names.&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%2Fxg0vaqc0dm5imaxunohs.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%2Fxg0vaqc0dm5imaxunohs.png" alt="on the server, jq is not installed, the getting started script run with the proxy enabled prints the NetBird Proxy block with the wildcard CNAME line, and docker ps lists netbird-proxy, netbird-dashboard, netbird-traefik and netbird-server" width="799" height="465"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The screenshots come from a clean run on two fresh boxes after the post was written, so addresses, names and timings differ a little from the text. The &lt;code&gt;apt install jq&lt;/code&gt; step is left out of this one; it only printed apt's restart notice.&lt;/p&gt;

&lt;p&gt;The dashboard certificate was a real one on the first try, &lt;code&gt;CN=pe-netbird.duckdns.org&lt;/code&gt; from Let's Encrypt. And it is small: &lt;code&gt;docker stats&lt;/code&gt; put the four containers at 70MiB, 22MiB, 21MiB and 20MiB. The docs ask for 1 CPU and 2GB, and that is generous.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Create the admin
&lt;/h2&gt;

&lt;p&gt;Open &lt;code&gt;https://pe-netbird.duckdns.org/setup&lt;/code&gt; and create the first user. That page only works while no users exist, which you can check: &lt;code&gt;curl https://pe-netbird.duckdns.org/api/instance&lt;/code&gt; said &lt;code&gt;{"setup_required":true}&lt;/code&gt; before and &lt;code&gt;{"setup_required":false}&lt;/code&gt; after. Logins are handled by an embedded Dex server, so there is nothing else to set up for users.&lt;/p&gt;

&lt;p&gt;I had no browser on the lab, so I did this with the call the setup page makes, &lt;code&gt;POST /api/setup&lt;/code&gt; with an email, password and name, and logged in afterwards with a script. In a browser it is one form. Once in, you need a &lt;strong&gt;setup key&lt;/strong&gt; for the machine at home: create a reusable one in the dashboard (I used the API, &lt;code&gt;POST /api/setup-keys&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%2F0v5gr7weorfjedx1mqwq.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%2F0v5gr7weorfjedx1mqwq.png" alt="api/instance says setup_required true, the POST to api/setup with the details in a file returns 200, api/instance then says setup_required false, and the dashboard certificate is CN=pe-netbird.duckdns.org from Let's Encrypt" width="800" height="348"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Join the machine at home
&lt;/h2&gt;

&lt;p&gt;On the machine that runs the service, install the client and point it at your server:&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://pkgs.netbird.io/install.sh | sh
&lt;span class="nb"&gt;sudo &lt;/span&gt;netbird up &lt;span class="nt"&gt;--management-url&lt;/span&gt; https://pe-netbird.duckdns.org &lt;span class="nt"&gt;--setup-key&lt;/span&gt; &amp;lt;your setup key&amp;gt;
netbird 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;Management: Connected
Signal: Connected
Relays: 2/2 Available
FQDN: nb-peer.netbird.selfhosted
NetBird IP: 100.66.194.33/16
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It got the address &lt;code&gt;100.66.194.33&lt;/code&gt; on a new &lt;code&gt;wt0&lt;/code&gt; interface. On the peer, the only TCP port listening on a public address was still SSH. NetBird does open its own UDP sockets (WireGuard on 51820 among them), but the peer's cloud firewall let nothing but SSH in and everything below still worked, because the connections that matter are the ones the peer makes outward.&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%2F9lfjf4aazt3ejg959p66.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%2F9lfjf4aazt3ejg959p66.png" alt="on the home peer, netbird up prints Connected, status shows daemon 0.79.0, management and signal connected, relays 2/2 available and a 100.79 NetBird IP, and ss shows TCP listening only for sshd on 22 and NetBird's DNS on its own 100.x address" width="800" height="448"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Once a service exists (next section), the proxy shows up on the peer as another NetBird peer, and &lt;code&gt;netbird status -d&lt;/code&gt; says how they are connected:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;proxy-...netbird.selfhosted:
  NetBird IP: 100.66.196.27
  Status: Connected
  Connection type: Relayed
  Relay server address: rels://pe-netbird.duckdns.org:443
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Relayed&lt;/strong&gt; is because I left UDP 51820 closed on the server, so the two could not talk WireGuard directly and went through the server's relay over 443 instead. It worked and responses came back in about half a second. Open 51820 if you want direct connections; I did not test that.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Publish one service
&lt;/h2&gt;

&lt;p&gt;The app has to be reachable on the peer's NetBird address, because that is where the proxy connects. I used a plain web server with one page, bound to that address (use the &lt;code&gt;NetBird IP&lt;/code&gt; your &lt;code&gt;netbird status&lt;/code&gt; shows, without the &lt;code&gt;/16&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;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; ~/app
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"hello from nb-peer, a box with only port 22 open"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; ~/app/index.html
python3 &lt;span class="nt"&gt;-m&lt;/span&gt; http.server 8080 &lt;span class="nt"&gt;--bind&lt;/span&gt; 100.66.194.33 &lt;span class="nt"&gt;--directory&lt;/span&gt; ~/app
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Binding to &lt;code&gt;0.0.0.0&lt;/code&gt; works too. Binding to &lt;code&gt;127.0.0.1&lt;/code&gt; does not (section 9).&lt;/p&gt;

&lt;p&gt;Then add the service: in the dashboard it lives under the reverse proxy section (per the docs), and I used the API, &lt;code&gt;POST /api/reverse-proxies/services&lt;/code&gt;, with the domain &lt;code&gt;app.pe-netbird.duckdns.org&lt;/code&gt;, the peer as the target, protocol &lt;code&gt;http&lt;/code&gt; and port &lt;code&gt;8080&lt;/code&gt;. The server filled in the peer's NetBird address as the target host by itself.&lt;/p&gt;

&lt;p&gt;The service came back &lt;code&gt;pending&lt;/code&gt;, and its status recorded the certificate as issued six seconds after I created it. From my laptop:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ curl -v https://app.pe-netbird.duckdns.org/
*  subject: CN=app.pe-netbird.duckdns.org
*  issuer: C=US; O=Let's Encrypt; CN=YE1
&amp;lt; HTTP/2 200
hello from nb-peer, a box with only port 22 open
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A real certificate for that exact name, served from a box whose cloud firewall only allows SSH in. My first request loop saw the first &lt;code&gt;200&lt;/code&gt; about a minute after creating the service, so give it a moment before assuming something is wrong.&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%2Fmdidtofksddjcgwdurc2.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%2Fmdidtofksddjcgwdurc2.png" alt="from outside, curl to home.pe-netbird.duckdns.org shows a certificate for that name from Let's Encrypt, HTTP/2 200, and the page from the peer" width="800" height="242"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;On the clean run the service is called &lt;code&gt;home.&lt;/code&gt; rather than &lt;code&gt;app.&lt;/code&gt;, for the reason in the next paragraph.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rebuilding? Mind the certificate limit.&lt;/strong&gt; The proxy asks Let's Encrypt for two certificates per name, an ECDSA one and an RSA one (the certificate volume held &lt;code&gt;app.pe-netbird.duckdns.org&lt;/code&gt; and &lt;code&gt;app.pe-netbird.duckdns.org+rsa&lt;/code&gt;). Let's Encrypt allows five certificates for the exact same name per week, so the third time I built this setup with &lt;code&gt;app.&lt;/code&gt; in one week, only the RSA one came back. The proxy logged the refusal, and my laptop, which asks for ECDSA, got a TLS error (&lt;code&gt;tlsv1 alert internal error&lt;/code&gt;, with &lt;code&gt;acme/autocert: missing certificate&lt;/code&gt; in the proxy log) while a client limited to RSA still got a &lt;code&gt;200&lt;/code&gt;. A new name (&lt;code&gt;home.&lt;/code&gt;) worked straight away. If you are testing, keep the Docker volumes between rebuilds, or change the name.&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%2F8k7s3r1i7385ttqnfn8m.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%2F8k7s3r1i7385ttqnfn8m.png" alt="the proxy log shows 429 rateLimited: too many certificates (5) already issued for this exact set of identifiers in the last 168h, retry after 2026-09-25 13:49:45 UTC, and the certificate volume holds only the RSA certificate for app. but both certificates for home." width="800" height="291"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The proxy passes the visitor's address through: its log showed my own public IP as &lt;code&gt;client_ip&lt;/code&gt;, not Traefik's.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. Put a password or PIN in front of it
&lt;/h2&gt;

&lt;p&gt;A service with no authentication is open to the internet, which is the point, but most homelab dashboards should not be. The proxy can put a password, a PIN, SSO through your NetBird login, and a few other options in front (the docs list them). I tested the password and the PIN.&lt;/p&gt;

&lt;p&gt;I set a password on the service (the &lt;code&gt;auth.password_auth&lt;/code&gt; field when you update it through the API; the dashboard has the same options, per the docs). After that, an anonymous request got a &lt;code&gt;401&lt;/code&gt; and a small "NetBird Service" login page. The page posts the password back to the same URL:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ curl -s -o /dev/null -w '%{http_code}\n' -F password=wrong https://app.pe-netbird.duckdns.org/
401
$ curl -s -D - -o /dev/null -F password=&amp;lt;the right one&amp;gt; https://app.pe-netbird.duckdns.org/
HTTP/2 303
location: /
set-cookie: nb_session=...; Path=/; Max-Age=86400; HttpOnly; Secure; SameSite=Lax
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With that cookie the app answered. &lt;code&gt;Max-Age=86400&lt;/code&gt; means a login lasts a day. The password is checked by the proxy on the VPS, before anything reaches the machine at home, which is the reason to use it rather than a login in the app itself.&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%2Fssmscrkig1c5m38v7l5b.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%2Fssmscrkig1c5m38v7l5b.png" alt="from outside, no password gets 401, a wrong password 401, the right one a 303 to / with an nb_session cookie that lasts 86400 seconds, and with the cookie the page loads" width="799" height="337"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  8. netbird expose, for something quick
&lt;/h2&gt;

&lt;p&gt;For a quick share there is a command on the peer itself:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;netbird expose &lt;span class="nt"&gt;--with-pin&lt;/span&gt; 123456 8080
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first time, it failed with &lt;code&gt;peer expose is not enabled for this account&lt;/code&gt;. Turning it on is an account setting (Peer Expose, per the docs), and the server refused it until I also chose a group allowed to use it: &lt;code&gt;peer expose requires at least one group&lt;/code&gt;. I picked &lt;code&gt;All&lt;/code&gt;. After that:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Service exposed successfully!
  Name:     908osflzfrae
  URL:      https://908osflzfrae.pe-netbird.duckdns.org
  ...
Press Ctrl+C to stop exposing.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6wnv1lslutqd8zdqaept.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%2F6wnv1lslutqd8zdqaept.png" alt="on the peer, netbird expose first fails with peer expose is not enabled for this account, and after enabling it prints Service exposed successfully with a random name under pe-netbird.duckdns.org" width="800" height="418"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Without the PIN that URL gave &lt;code&gt;401&lt;/code&gt;, with it a &lt;code&gt;303&lt;/code&gt; and then the page. When the command stopped, the name stopped answering. The name is random each time (&lt;code&gt;--with-name-prefix&lt;/code&gt; sets the start of it), so every run asks Let's Encrypt for new certificates. On a DuckDNS name those count against Let's Encrypt's limits for your name (the weekly limit on new certificates per registered name is in their docs; I did not hit that one), which is fine for the odd share. Run it in a loop and you use up that limit, and new share names stop getting certificates until the week rolls over.&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%2Fdja2xycnswc813wfc8x1.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%2Fdja2xycnswc813wfc8x1.png" alt="from outside, the random name gives 401 without the PIN, 303 with PIN 123456, and then the page" width="800" height="260"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  9. What 502, 401 and a timeout mean
&lt;/h2&gt;

&lt;p&gt;I broke it on purpose, from the outside with &lt;code&gt;curl&lt;/code&gt;, with the password from section 7 taken off again (with it still on, every row below is just a &lt;code&gt;401&lt;/code&gt; until you log in):&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 on the peer&lt;/th&gt;
&lt;th&gt;What the browser gets&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;stopped the app&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;502&lt;/code&gt;, and the proxy log says &lt;code&gt;status=502 title="Service Unavailable"&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ran the app on &lt;code&gt;127.0.0.1:8080&lt;/code&gt; only&lt;/td&gt;
&lt;td&gt;the same &lt;code&gt;502&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ran it on &lt;code&gt;0.0.0.0:8080&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;200&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;netbird down&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;no answer at all: &lt;code&gt;curl&lt;/code&gt; gave up after 20 seconds&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;netbird up&lt;/code&gt; again&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;200&lt;/code&gt;, but the first request took 14 seconds&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;In these tests, a stopped app and an app listening only on loopback both gave &lt;code&gt;502&lt;/code&gt;, a disconnected peer gave a timeout, and &lt;code&gt;401&lt;/code&gt; was only ever my own password or PIN doing its job. So on a &lt;code&gt;502&lt;/code&gt;, check where the app is listening; on a timeout, check &lt;code&gt;netbird status&lt;/code&gt; on the peer. The loopback case is the one that catches people, because the app works fine from the machine itself.&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%2F81stxb7wt6noin1a6ib5.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%2F81stxb7wt6noin1a6ib5.png" alt="from outside with each peer side change run over ssh: 200 at first, 502 with the app stopped, 502 with it on 127.0.0.1, 200 on 0.0.0.0, a 20 second timeout after netbird down, and 200 in 13.8 seconds after netbird up" width="800" height="508"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  10. Next to Cloudflare Tunnel and Tailscale Funnel
&lt;/h2&gt;

&lt;p&gt;I have not tested either of those for this post, so this is only the difference in shape, from their docs. Cloudflare Tunnel ends your visitors' HTTPS on Cloudflare's network. Tailscale Funnel relays the encrypted connection through Tailscale's servers and ends TLS on your own machine, with a certificate for its &lt;code&gt;ts.net&lt;/code&gt; name. Both need an account with the vendor, and the public name is theirs or on their DNS. NetBird's proxy ends TLS on your VPS, and the only third parties are DuckDNS for the name and Let's Encrypt for the certificates. The price is that you run and patch the VPS, and the feature is still marked beta. If you already use &lt;a href="https://peculiarengineer.com/blog/install-tailscale-ubuntu-26-04/" rel="noopener noreferrer"&gt;Tailscale&lt;/a&gt; for your own access, you do not need any of this for yourself; this is for letting other people in.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;A fresh Ubuntu 26.04 image has no &lt;code&gt;jq&lt;/code&gt;. Install it before the getting started script, which needs it.&lt;/li&gt;
&lt;li&gt;Unattended mode does not make the Let's Encrypt email optional: set &lt;code&gt;NETBIRD_LETSENCRYPT_EMAIL&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Creating the admin needs the &lt;code&gt;/setup&lt;/code&gt; page in a browser (or the &lt;code&gt;POST /api/setup&lt;/code&gt; it calls). It only works while there are no users.&lt;/li&gt;
&lt;li&gt;An app bound to &lt;code&gt;127.0.0.1&lt;/code&gt; gives a &lt;code&gt;502&lt;/code&gt;. Bind to &lt;code&gt;0.0.0.0&lt;/code&gt; or the NetBird address.&lt;/li&gt;
&lt;li&gt;With UDP 51820 closed, the proxy reached the peer through the relay on 443. It still worked.&lt;/li&gt;
&lt;li&gt;The first request after the peer reconnects can take many seconds.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;netbird expose&lt;/code&gt; is off until you enable Peer Expose and give it a group, and every run makes a new random hostname and new certificates.&lt;/li&gt;
&lt;li&gt;Each service name uses two certificates, ECDSA and RSA. Build with the same name three times in a week and one of them is refused (&lt;code&gt;429 rateLimited ... (5) already issued for this exact set of identifiers&lt;/code&gt;); for me it was ECDSA, which showed up as a TLS error on my laptop.&lt;/li&gt;
&lt;/ul&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 or setting&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Point DuckDNS at the VPS&lt;/td&gt;
&lt;td&gt;&lt;code&gt;curl "https://www.duckdns.org/update?domains=&amp;lt;name&amp;gt;&amp;amp;token=$(tr -d '\n' &amp;lt; ~/.config/duckdns/token)&amp;amp;ip=&amp;lt;vps ip&amp;gt;"&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;VPS ports&lt;/td&gt;
&lt;td&gt;TCP 80, 443; UDP 3478 (51820 optional)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Prerequisites&lt;/td&gt;
&lt;td&gt;Docker with Compose, &lt;code&gt;curl&lt;/code&gt;, &lt;code&gt;sudo apt install jq&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Install with the proxy&lt;/td&gt;
&lt;td&gt;&lt;code&gt;NETBIRD_DOMAIN=&amp;lt;name&amp;gt;.duckdns.org NETBIRD_LETSENCRYPT_EMAIL=&amp;lt;email&amp;gt; NETBIRD_ENABLE_PROXY=true NETBIRD_NON_INTERACTIVE=true bash getting-started.sh&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;First user&lt;/td&gt;
&lt;td&gt;&lt;code&gt;https://&amp;lt;name&amp;gt;.duckdns.org/setup&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Join a peer&lt;/td&gt;
&lt;td&gt;`curl -fsSL &lt;a href="https://pkgs.netbird.io/install.sh" rel="noopener noreferrer"&gt;https://pkgs.netbird.io/install.sh&lt;/a&gt; \&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Check it&lt;/td&gt;
&lt;td&gt;{% raw %}&lt;code&gt;netbird status&lt;/code&gt;, &lt;code&gt;netbird status -d&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;App binding&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;0.0.0.0&lt;/code&gt; or the NetBird IP, not &lt;code&gt;127.0.0.1&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Quick share&lt;/td&gt;
&lt;td&gt;&lt;code&gt;netbird expose --with-pin &amp;lt;6 digits&amp;gt; &amp;lt;port&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If the public URL gives a &lt;code&gt;502&lt;/code&gt;, look at where the app is listening before you look at NetBird: an app that answers on &lt;code&gt;localhost&lt;/code&gt; can still be unreachable through the tunnel.&lt;/p&gt;

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

</description>
      <category>networking</category>
      <category>docker</category>
      <category>linux</category>
      <category>ubuntu</category>
    </item>
    <item>
      <title>Install Portainer CE on Ubuntu 26.04 (without exposing my Docker host)</title>
      <dc:creator>Minor Keith</dc:creator>
      <pubDate>Thu, 24 Sep 2026 13:25:55 +0000</pubDate>
      <link>https://dev.to/peculiarengineer/install-portainer-ce-on-ubuntu-2604-without-exposing-my-docker-host-1fic</link>
      <guid>https://dev.to/peculiarengineer/install-portainer-ce-on-ubuntu-2604-without-exposing-my-docker-host-1fic</guid>
      <description>&lt;p&gt;The install command in Portainer's docs is one line, and it works. It also publishes the Portainer UI on port 9443 on every address the server has, and Docker writes its own firewall rules for that, so ufw never gets a say. On a box where ufw said only SSH was allowed in, a client standing in for the outside world could still load the Portainer login page. That page is the front door to a container that holds the Docker socket, which means whoever gets through it as an admin is root on the host.&lt;/p&gt;

&lt;p&gt;So this is the Portainer install I would actually put on a server: Compose instead of a long &lt;code&gt;docker run&lt;/code&gt;, the UI bound to &lt;code&gt;127.0.0.1&lt;/code&gt;, and an SSH tunnel to reach it. It also covers the parts the older guides predate or skip. Since 2.43, Portainer will not let you create the admin account until you give it a token from its logs, and it still locks itself after five minutes if you do not. Stack files end up somewhere &lt;code&gt;docker compose ls&lt;/code&gt; points at but you cannot open. And updating, backing up and removing it each have one small trap.&lt;/p&gt;

&lt;p&gt;The one thing to get straight: Portainer is not a dashboard sitting beside Docker. It is a web login in front of &lt;code&gt;/var/run/docker.sock&lt;/code&gt;. I proved that on the lab box by asking Portainer's API, and nothing else, to start an Alpine container with the host's &lt;code&gt;/&lt;/code&gt; mounted and print the first line of &lt;code&gt;/etc/shadow&lt;/code&gt;. It printed &lt;code&gt;root:!*:17478:0:99999:7:::&lt;/code&gt;. Treat the Portainer login like an SSH login, and keep it off the open internet the same way.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR.&lt;/strong&gt; Install Docker from Docker's repo. &lt;code&gt;docker volume create portainer_data&lt;/code&gt;, then run Portainer from a Compose file that publishes only &lt;code&gt;127.0.0.1:9443:9443&lt;/code&gt; and mounts &lt;code&gt;portainer_data&lt;/code&gt; as an &lt;code&gt;external&lt;/code&gt; volume. Reach it with &lt;code&gt;ssh -N -L 9443:127.0.0.1:9443 you@server&lt;/code&gt; and open &lt;code&gt;https://localhost:9443&lt;/code&gt;. Within five minutes, create the admin with the &lt;code&gt;setup_token&lt;/code&gt; from &lt;code&gt;docker logs portainer&lt;/code&gt;; if it times out, &lt;code&gt;docker restart portainer&lt;/code&gt; and use the new token. Update with &lt;code&gt;docker compose pull &amp;amp;&amp;amp; docker compose up -d&lt;/code&gt;. Back up by stopping Portainer and tarring the volume.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  What I tested on
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A Hetzner cx23 running Ubuntu 26.04.1 LTS, kernel 7.0.0-30. The cloud firewall only let port 22 in.&lt;/li&gt;
&lt;li&gt;Docker Engine 29.8.1 and Docker Compose v5.5.1 from Docker's apt repo.&lt;/li&gt;
&lt;li&gt;Portainer CE 2.45.1 LTS, which is what the &lt;code&gt;lts&lt;/code&gt; tag pulled on 24 September 2026, plus 2.45.0 for the update test.&lt;/li&gt;
&lt;li&gt;ufw 0.36.2, enabled with only OpenSSH allowed.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I did every step that normally happens in the browser (creating the admin, adding the environment, deploying a stack, the backup download) through Portainer's HTTP API with &lt;code&gt;curl&lt;/code&gt;, because the box had no browser on it. So what I tested is the server's responses; I have not seen the browser flow in 2.45.1, and where a step names a menu, that is from Portainer's docs.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Install Docker first
&lt;/h2&gt;

&lt;p&gt;Portainer runs as a container, so Docker comes first. Use Docker's own apt repo, not Ubuntu's &lt;code&gt;docker.io&lt;/code&gt;; &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; has the exact steps and is what I ran on this box. The commands below assume your user is in the &lt;code&gt;docker&lt;/code&gt; group (or put &lt;code&gt;sudo&lt;/code&gt; in front). Check it answers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker version
docker compose version
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  2. What 9443, 8000 and 9000 are
&lt;/h2&gt;

&lt;p&gt;The docs command publishes two ports and the image exposes three. The container logs say what each one is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;starting HTTPS server | bind_address=:9443
starting HTTP server | bind_address=:9000
server: Reverse tunnelling enabled
server: Listening on http://0.0.0.0:8000
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;9443&lt;/strong&gt; is the web UI and the API over HTTPS, with a certificate Portainer generates for itself on first start. This is the only one you need.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;8000&lt;/strong&gt; is a tunnel server for Portainer's Edge agents, which per Portainer's docs is how it manages remote Docker hosts that dial in to it. If Portainer is only managing the machine it runs on, nothing ever connects to 8000. A plain &lt;code&gt;curl&lt;/code&gt; to it returns &lt;code&gt;404&lt;/code&gt;. Leave it unpublished.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;9000&lt;/strong&gt; is the same UI over plain HTTP. It is listening inside the container in 2.45.1 (a &lt;code&gt;curl&lt;/code&gt; to the container's address on 9000 returned the status JSON with a &lt;code&gt;200&lt;/code&gt;), and without &lt;code&gt;-p 9000:9000&lt;/code&gt; it is reachable from the host only on the container's bridge address (the section 3 install's &lt;code&gt;172.17.0.2:9000&lt;/code&gt;), not from other machines. Do not publish it. Your password would cross the network in the clear.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  3. Why the stock command gets past ufw
&lt;/h2&gt;

&lt;p&gt;This is the command from Portainer's docs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker volume create portainer_data
docker run &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; 8000:8000 &lt;span class="nt"&gt;-p&lt;/span&gt; 9443:9443 &lt;span class="nt"&gt;--name&lt;/span&gt; portainer &lt;span class="nt"&gt;--restart&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;always &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-v&lt;/span&gt; /var/run/docker.sock:/var/run/docker.sock &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-v&lt;/span&gt; portainer_data:/data portainer/portainer-ce:lts
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;-p 9443:9443&lt;/code&gt; with no address means every address. &lt;code&gt;docker ps&lt;/code&gt; shows it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;0.0.0.0:8000-&amp;gt;8000/tcp, [::]:8000-&amp;gt;8000/tcp, 0.0.0.0:9443-&amp;gt;9443/tcp, [::]:9443-&amp;gt;9443/tcp, 9000/tcp
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With ufw on and only SSH allowed, I tested from a separate network namespace wired to the host, which stands in for a machine outside (the real internet could not reach the box past Hetzner's firewall). A plain Python web server on port 8081 on the host timed out, which is ufw doing its job. Portainer on 9443 answered &lt;code&gt;200&lt;/code&gt;. The reason is in iptables (the first rule is in the nat table, &lt;code&gt;sudo iptables -t nat -S DOCKER&lt;/code&gt;; the second in the filter table, &lt;code&gt;sudo iptables -S DOCKER&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;-A DOCKER ! -i docker0 -p tcp -m tcp --dport 9443 -j DNAT --to-destination 172.17.0.2:9443
-A DOCKER -d 172.17.0.2/32 ! -i docker0 -o docker0 -p tcp -m tcp --dport 9443 -j ACCEPT
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Published ports are rewritten to the container's address before ufw's input rules ever look at them, and Docker adds its own accept rule on the forwarding path. The &lt;a href="https://peculiarengineer.com/blog/ufw-firewall-basics-ubuntu/" rel="noopener noreferrer"&gt;ufw post&lt;/a&gt; calls this out as "Docker punches through", and Portainer is the worst container to learn it with.&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%2Fm2qfqsuyjp0oqyewi8w7.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%2Fm2qfqsuyjp0oqyewi8w7.png" alt="ufw status shows only OpenSSH allowed, docker ps shows portainer published on 0.0.0.0 and [::] for 8000 and 9443, a curl from the stand in outside client gets 200 on 9443, and the same client times out on a plain host listener on 8081 with exit 28" width="800" height="452"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The screenshots come from a clean run on a fresh box after the post was written, so tokens, IDs, timestamps and sizes differ from the text. The &lt;code&gt;docker volume create&lt;/code&gt; and the stock &lt;code&gt;docker run&lt;/code&gt; are left out of this one because the image download fills the screen.&lt;/p&gt;

&lt;p&gt;In fairness to Portainer, a stranger who finds a fresh install can no longer just create the admin account (section 6 covers the token). But the login page is still there for anyone to try passwords against, on an app that is root on your box.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Install Portainer with Compose on 127.0.0.1
&lt;/h2&gt;

&lt;p&gt;Put the address in front of the port and Docker only listens there. I keep it in a Compose file so the flags are written down and the update in section 8 is two commands.&lt;/p&gt;

&lt;p&gt;If you ran the docs command from section 3, take that container away first. The volume stays, and Compose cannot start a second container called &lt;code&gt;portainer&lt;/code&gt; while the first one exists (it fails with &lt;code&gt;Conflict. The container name "/portainer" is already in use&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;docker stop portainer
docker &lt;span class="nb"&gt;rm &lt;/span&gt;portainer
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then create the volume by hand (it already exists if you ran section 3, which is fine), so its name is exactly &lt;code&gt;portainer_data&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;docker volume create portainer_data
&lt;span class="nb"&gt;sudo mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; /opt/portainer
&lt;span class="nb"&gt;sudo &lt;/span&gt;nano /opt/portainer/compose.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;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;portainer&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;portainer/portainer-ce:lts&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;portainer&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;always&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;127.0.0.1:9443:9443"&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;/var/run/docker.sock:/var/run/docker.sock&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;portainer_data:/data&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;portainer_data&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;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/portainer
docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details in there are deliberate. Only 9443 is published, and only on loopback. And &lt;code&gt;external: true&lt;/code&gt; tells Compose to use the existing &lt;code&gt;portainer_data&lt;/code&gt; volume instead of making its own. Leave that out and Compose creates &lt;code&gt;portainer_portainer_data&lt;/code&gt;, named after the project folder. I did exactly that on the lab box while switching from the &lt;code&gt;docker run&lt;/code&gt; install: &lt;code&gt;docker volume ls&lt;/code&gt; showed both volumes, Portainer came up empty and printed a fresh setup token, and my admin password got &lt;code&gt;Invalid credentials&lt;/code&gt;. The old data was untouched, just ignored. With &lt;code&gt;external: true&lt;/code&gt; the same file brought back the admin and the stack.&lt;/p&gt;

&lt;p&gt;I checked the listening side on the equivalent &lt;code&gt;docker run -p 127.0.0.1:9443:9443&lt;/code&gt; install on the same volume, which publishes exactly what this file does:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ docker ps --format '{{.Names}} {{.Ports}}'
portainer 8000/tcp, 9000/tcp, 127.0.0.1:9443-&amp;gt;9443/tcp
$ sudo ss -tlnp | grep 9443
LISTEN 0      4096       127.0.0.1:9443      0.0.0.0:*    users:(("docker-proxy",pid=4028,fd=8))
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;From the outside namespace, 9443 now timed out. From the host itself, &lt;code&gt;curl -k https://127.0.0.1:9443/&lt;/code&gt; returned &lt;code&gt;200&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%2Fqq90ietoc7q2rzz89mk9.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%2Fqq90ietoc7q2rzz89mk9.png" alt="docker stop and rm of the stock container, docker compose up creating portainer_default and starting portainer, docker ps showing only 127.0.0.1:9443 published, ss listening on 127.0.0.1:9443, the outside client timing out with exit 28, and a local curl returning 200" width="800" height="568"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you prefer &lt;code&gt;docker run&lt;/code&gt;, the same thing is &lt;code&gt;-p 127.0.0.1:9443:9443&lt;/code&gt; and no &lt;code&gt;-p 8000:8000&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Reach it over an SSH tunnel
&lt;/h2&gt;

&lt;p&gt;From your own machine:&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;-N&lt;/span&gt; &lt;span class="nt"&gt;-L&lt;/span&gt; 9443:127.0.0.1:9443 you@your-server
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Leave that running and open &lt;code&gt;https://localhost:9443&lt;/code&gt;. SSH carries the connection to the server and hands it to &lt;code&gt;127.0.0.1:9443&lt;/code&gt; there, so Portainer is exactly as reachable as your SSH is. From my Mac, through the tunnel:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ curl -sk https://localhost:9443/api/system/status
{"Version":"2.45.1","InstanceID":"1b75100e-d5f7-4444-8c93-0117a4534e8b"}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Expect a certificate warning in the browser (I only reached it with &lt;code&gt;curl -k&lt;/code&gt;). It is one Portainer made for itself on first start, with an empty subject, valid for five years, and it lives in the volume under &lt;code&gt;/data/certs&lt;/code&gt; (it survived the container being recreated). If you want a real certificate, put Portainer behind a reverse proxy such as &lt;a href="https://peculiarengineer.com/blog/caddy-reverse-proxy-docker-compose-ubuntu-26-04/" rel="noopener noreferrer"&gt;Caddy&lt;/a&gt;, but keep that proxy private. If it accepts connections from the internet, the Portainer login is back on the internet, even though Portainer itself still listens only on loopback.&lt;/p&gt;

&lt;p&gt;For everyday access I would use &lt;a href="https://peculiarengineer.com/blog/install-tailscale-ubuntu-26-04/" rel="noopener noreferrer"&gt;Tailscale&lt;/a&gt; so Portainer is reachable from your tailnet and nowhere else. I did not test that route for this post, only the SSH tunnel.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. First login: the setup token and the five minute window
&lt;/h2&gt;

&lt;p&gt;Older guides say: open the page and create an admin. On 2.43 and later there is a step before that. The startup logs print a token. This is from &lt;code&gt;docker logs portainer&lt;/code&gt; on the first start:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;setup_token=7416bc9610a1a2fc79c6021969798e9660fd8e0ed67dd5b836e391ea8fdc2614

Paste it into the setup screen, or send it in the X-Setup-Token header.
Start with --no-setup-token to disable.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To pull out just the newest token (which matters after a restart, below):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker logs portainer 2&amp;gt;&amp;amp;1 | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="s2"&gt;"setup_token=[0-9a-f]*"&lt;/span&gt; | &lt;span class="nb"&gt;tail&lt;/span&gt; &lt;span class="nt"&gt;-1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without it, creating the admin fails. Over the API that is a &lt;code&gt;403&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;{"message":"Invalid or missing setup token. Provide the X-Setup-Token header with the token printed in the server logs at startup.","details":"Invalid or missing setup token"}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The release notes for 2.43.0 describe it as a one time token that is required to create the first administrator or to restore a backup on a new instance. It exists for exactly the problem in section 3: someone finding your fresh install before you do. I have not tried &lt;code&gt;--no-setup-token&lt;/code&gt; and see no reason to.&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%2F4oleiilh2rtnkeik1acl.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%2F4oleiilh2rtnkeik1acl.png" alt="the newest setup_token pulled from docker logs, and an admin init without the X-Setup-Token header returning Invalid or missing setup token" width="800" height="210"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;With the token, the admin was created. Two more things from that step:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The password has a minimum length. &lt;code&gt;short&lt;/code&gt; came back &lt;code&gt;Password does not meet the requirements&lt;/code&gt;. A 16 character one was accepted.&lt;/li&gt;
&lt;li&gt;After creating the admin through the API, there is no environment yet: &lt;code&gt;GET /api/endpoints&lt;/code&gt; returned &lt;code&gt;[]&lt;/code&gt;, and I added the local Docker socket as an environment called &lt;code&gt;local&lt;/code&gt; myself. Per Portainer's docs, the web setup flow does that step for you (Get Started). I did not see it in a browser.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;The five minute window is still real.&lt;/strong&gt; I started Portainer at 02:10:54, did nothing, and at 02:15 the log said:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;the Portainer instance timed out for security purposes, to re-enable your Portainer instance, you will need to restart Portainer
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The container does not stop. &lt;code&gt;docker ps&lt;/code&gt; still said &lt;code&gt;Up 5 minutes&lt;/code&gt;, which is what makes this confusing. The front page redirects to &lt;code&gt;/timeout.html&lt;/code&gt;, and the API says &lt;code&gt;Administrator initialization timeout&lt;/code&gt;. The fix 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 restart portainer
docker logs portainer 2&amp;gt;&amp;amp;1 | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="s2"&gt;"setup_token=[0-9a-f]*"&lt;/span&gt; | &lt;span class="nb"&gt;tail&lt;/span&gt; &lt;span class="nt"&gt;-1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After the restart the log had a second, different token, and the new one is the one I used. Get the tunnel open before you start Portainer, then take the newest token from the logs, not the one you copied earlier.&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%2Ftcp1tbxogcwiedwl5xj7.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%2Ftcp1tbxogcwiedwl5xj7.png" alt="the log says the Portainer instance timed out for security purposes, docker ps still shows Up 5 minutes, the front page returns 307 to /timeout.html, and after docker restart the log has a second setup token and the admin init with the newest token returns 200" width="800" height="436"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Once an admin exists, restarts and recreated containers do not print a token.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. Stacks, and where the Compose file actually lives
&lt;/h2&gt;

&lt;p&gt;A stack in Portainer is a Compose project that Portainer deployed. In the UI it is Stacks, then Add stack, then the web editor; I deployed this one through the API:&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;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:v1.11&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;127.0.0.1:8080:80"&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It is a real Compose project, so the host sees it. Here it is next to one I started by hand from &lt;code&gt;/opt/hello&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;$ docker compose ls
NAME                STATUS              CONFIG FILES
hello               running(1)          /opt/hello/compose.yaml
whoami              running(1)          /data/compose/1/docker-compose.yml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;/data/compose/1/docker-compose.yml&lt;/code&gt; is a path inside the Portainer container. On the host, &lt;code&gt;ls /data/compose/1&lt;/code&gt; says &lt;code&gt;No such file or directory&lt;/code&gt;. The file is really here:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/var/lib/docker/volumes/portainer_data/_data/compose/1/docker-compose.yml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;docker compose -p whoami ps&lt;/code&gt; still works from the host, because Compose finds projects by label. But the file to edit is the copy in Portainer, and if you change the stack from the command line Portainer's copy stops describing what is running.&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%2F4eadqjynzq5xa2egfa8g.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%2F4eadqjynzq5xa2egfa8g.png" alt="docker compose ls lists hello from /opt/hello, portainer from /opt/portainer and whoami from /data/compose/1/docker-compose.yml; ls /data/compose/1 on the host fails, and the file is under /var/lib/docker/volumes/portainer_data/_data/compose/1/" width="800" height="355"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;On the clean run Portainer itself was already a Compose project, so it shows up in that list too.&lt;/p&gt;

&lt;p&gt;The other direction: Portainer's stack list only holds stacks Portainer created. The &lt;code&gt;hello&lt;/code&gt; project did not appear in &lt;code&gt;/api/stacks&lt;/code&gt;, although its container did show up with its &lt;code&gt;hello&lt;/code&gt; project label. I did not check how the web UI presents a project like that.&lt;/p&gt;

&lt;p&gt;My rule of thumb from this: pick one owner per project. Things I deploy from a Git repo or a folder on the server stay plain Compose files, and Portainer is for looking at them, reading logs and restarting. If a stack was born in Portainer, edit it in Portainer.&lt;/p&gt;

&lt;h2&gt;
  
  
  8. Update Portainer without losing anything
&lt;/h2&gt;

&lt;p&gt;Everything Portainer knows lives in the volume: the database with users, environments and settings, the stack files, the certificate, and the Edge tunnel key. The container is disposable. Updating means a new container on the same volume.&lt;/p&gt;

&lt;p&gt;With the Compose file from section 4 and the &lt;code&gt;lts&lt;/code&gt; tag:&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/portainer
docker compose pull
docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To see a real version change, I ran a second instance pinned to &lt;code&gt;2.45.0&lt;/code&gt; on its own volume, added a second user and a stack, then changed the tag to &lt;code&gt;2.45.1&lt;/code&gt; and ran the same two commands. The log showed the migration:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;migrating database from version 2.45.0 to 2.45.1
db migrated to 2.45.1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The status endpoint reported &lt;code&gt;2.45.1&lt;/code&gt; with the same instance ID, the second user could log in, the stack was still listed, and the stack's container went from &lt;code&gt;Up 11 seconds&lt;/code&gt; to &lt;code&gt;Up 19 seconds&lt;/code&gt;, so updating Portainer did not restart the things it manages. The old image stays on disk, 201MB each here, until you remove it. On a pinned tag, remove it by name (&lt;code&gt;docker image rm portainer/portainer-ce:2.45.0&lt;/code&gt;); a plain &lt;code&gt;docker image prune&lt;/code&gt; reclaimed &lt;code&gt;0B&lt;/code&gt; here, because an image that still has a tag is not dangling. On &lt;code&gt;lts&lt;/code&gt;, the pull moves the tag to the new image, so the old one is left untagged and &lt;code&gt;docker image prune&lt;/code&gt; is the command that removes it (Docker's rule for dangling images; &lt;code&gt;lts&lt;/code&gt; did not move during my test).&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%2Ffve2vsh1h25miwjr9hzg.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%2Ffve2vsh1h25miwjr9hzg.png" alt="a second instance reports 2.45.0; after it is started again on lts, the log says migrating database from version 2.45.0 to 2.45.1 and db migrated to 2.45.1, the status shows 2.45.1 with the same instance ID, and both 201MB images are still on disk" width="800" height="344"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;On the clean run the test instance was a plain &lt;code&gt;docker run&lt;/code&gt; container: I removed it and started it again on &lt;code&gt;lts&lt;/code&gt; rather than editing a Compose file, and the migration went the same way.&lt;/p&gt;

&lt;p&gt;Portainer also copied its database to &lt;code&gt;/data/backups/portainer.db.bak&lt;/code&gt; before migrating. I saw that when I tried going back from 2.45.1 to 2.45.0, which worked for that one patch release. One patch release going back cleanly does not prove the next update can be undone. Take the full volume backup in section 9 first, so if changing the image back fails you can restore Portainer's state; Portainer's docs say the same.&lt;/p&gt;

&lt;p&gt;If you installed with the docs &lt;code&gt;docker run&lt;/code&gt; command, the update is &lt;code&gt;docker stop portainer&lt;/code&gt;, &lt;code&gt;docker rm portainer&lt;/code&gt;, &lt;code&gt;docker pull portainer/portainer-ce:lts&lt;/code&gt;, and the same &lt;code&gt;docker run&lt;/code&gt; again. Removing the container does not touch the volume.&lt;/p&gt;

&lt;p&gt;If Portainer comes up after a Docker update and says &lt;code&gt;The environment named local is unreachable&lt;/code&gt;, look at your Docker version before you blame Portainer. On a separate lab box, Docker 29.0.0, 29.1.5 and 29.2.0 reported a minimum API version of 1.44, which is the version floor the GitHub reports of that message point at. I did not see the message on a Portainer screen myself. From 29.3.0 through 29.8.1 the minimum was back to 1.40, and Portainer 2.33.3 worked on 29.8.1. This box ran Docker 29.8.1, which reports &lt;code&gt;minimum version 1.40&lt;/code&gt;, and 2.45.1 saw the local environment fine. So if &lt;code&gt;docker version&lt;/code&gt; shows a 29.0 to 29.2 engine, updating Docker is the first thing to try.&lt;/p&gt;

&lt;h2&gt;
  
  
  9. Back up and restore portainer_data
&lt;/h2&gt;

&lt;p&gt;The dependable backup is the volume itself, taken while Portainer is stopped so the database is not mid write:&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; /root/backups
docker stop portainer
docker run &lt;span class="nt"&gt;--rm&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; portainer_data:/data:ro &lt;span class="nt"&gt;-v&lt;/span&gt; /root/backups:/backup alpine:3.22 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nb"&gt;tar &lt;/span&gt;czf /backup/portainer_data-&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%F&lt;span class="si"&gt;)&lt;/span&gt;.tar.gz &lt;span class="nt"&gt;-C&lt;/span&gt; /data &lt;span class="nb"&gt;.&lt;/span&gt;
docker start portainer
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That produced a file of 9 to 16KB across my runs, holding &lt;code&gt;portainer.db&lt;/code&gt;, &lt;code&gt;compose/1/docker-compose.yml&lt;/code&gt;, &lt;code&gt;certs/&lt;/code&gt;, &lt;code&gt;chisel/private-key.pem&lt;/code&gt; and the &lt;code&gt;portainer.key&lt;/code&gt; pair. The stack containers kept running while Portainer was stopped. Anyone logged in gets logged out, though: the session token from before the stop came back &lt;code&gt;Invalid JWT token&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%2Fxj4kkgximuyk0n242yuq.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%2Fxj4kkgximuyk0n242yuq.png" alt="docker stop portainer, the alpine tar of the volume into /root/backups (with alpine's first download), docker start portainer, and a 9502 byte portainer_data-2026-09-24.tar.gz in /root/backups" width="800" height="522"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;To prove it restores, I removed the container and deleted the volume, then ran this (use your own backup's file name; the screenshot run did it the same day with &lt;code&gt;$(date +%F)&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;docker volume create portainer_data
docker run &lt;span class="nt"&gt;--rm&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; portainer_data:/data &lt;span class="nt"&gt;-v&lt;/span&gt; /root/backups:/backup alpine:3.22 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nb"&gt;tar &lt;/span&gt;xzf /backup/portainer_data-2026-09-24.tar.gz &lt;span class="nt"&gt;-C&lt;/span&gt; /data
&lt;span class="nb"&gt;cd&lt;/span&gt; /opt/portainer
docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Use the Compose file to start it again, not a &lt;code&gt;docker run&lt;/code&gt; line: a container started by &lt;code&gt;docker run&lt;/code&gt; is not part of the Compose project, so a later &lt;code&gt;docker compose down&lt;/code&gt; would leave it running and &lt;code&gt;docker volume rm&lt;/code&gt; would fail with &lt;code&gt;volume is in use&lt;/code&gt;. No setup token in the logs, the admin logged in, the environment and the stack were back, and the stack's Compose file came back identical.&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%2Fvza9qpxzizvlww812b36.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%2Fvza9qpxzizvlww812b36.png" alt="a new portainer_data volume, the tar extracted into it, docker compose up starting portainer, zero setup tokens in the logs, and the stacks API listing whoami with status 1 after logging in as the old admin" width="800" height="398"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Portainer also has its own backup under Settings, then Back up Portainer, which downloads a &lt;code&gt;.tar.gz&lt;/code&gt; (the API call takes an optional password for it). Through the API that file held the same things plus a JSON export of the database. Per the docs you restore it on a brand new Portainer from the setup screen. I did it through the API, and that restore needs the setup token as well: without it I got the same &lt;code&gt;403&lt;/code&gt;, with it the restore returned &lt;code&gt;200&lt;/code&gt; and the user and stack were there. That should put a restore on the same five minute clock, though I did not time it.&lt;/p&gt;

&lt;p&gt;Neither backup includes the data your containers keep in their own volumes. The Portainer backup is Portainer's memory, not your apps. Back those up separately; &lt;a href="https://peculiarengineer.com/blog/backup-homelab-restic-object-storage/" rel="noopener noreferrer"&gt;restic to object storage&lt;/a&gt; is how I would do it.&lt;/p&gt;

&lt;h2&gt;
  
  
  10. Remove Portainer, and what stays behind
&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;cd&lt;/span&gt; /opt/portainer
docker compose down
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That removed the Portainer container and its network. Everything else stayed:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;every stack Portainer deployed, still running;&lt;/li&gt;
&lt;li&gt;the &lt;code&gt;portainer_data&lt;/code&gt; volume;&lt;/li&gt;
&lt;li&gt;the Portainer images;&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;docker compose ls&lt;/code&gt; still listing each stack with a &lt;code&gt;/data/compose/...&lt;/code&gt; path that now exists nowhere you can reach.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Removing Portainer does not remove what it deployed, which is usually what you want. To turn a stack into a plain Compose project, copy its file out of the volume before you delete the volume, take it down by project name, and bring it back up from the copy:&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 cp&lt;/span&gt; /var/lib/docker/volumes/portainer_data/_data/compose/1/docker-compose.yml ~/whoami-compose.yml
&lt;span class="nb"&gt;sudo chown&lt;/span&gt; &lt;span class="nv"&gt;$USER&lt;/span&gt;: ~/whoami-compose.yml
&lt;span class="nb"&gt;cat&lt;/span&gt; ~/whoami-compose.yml
docker compose &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="nb"&gt;whoami &lt;/span&gt;down
docker compose &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="nb"&gt;whoami&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; ~/whoami-compose.yml up &lt;span class="nt"&gt;-d&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;chown&lt;/code&gt; and the copy into your home matter: if your user cannot read the copied file (a copy under &lt;code&gt;/root&lt;/code&gt; was unreadable in one walkthrough), the &lt;code&gt;down&lt;/code&gt; works and the &lt;code&gt;up&lt;/code&gt; fails with &lt;code&gt;permission denied&lt;/code&gt;, leaving the stack stopped. After that, &lt;code&gt;docker compose ls&lt;/code&gt; showed the project running from the copied file, an ordinary Compose project with a file you can open. Put the copy wherever you keep Compose files. The directory numbers are Portainer's stack IDs, so check the file is the one you think it is before you copy 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%2Fefu65mg8uhtebnmjtsql.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%2Fefu65mg8uhtebnmjtsql.png" alt="docker compose down removes the portainer network, compose ls still lists hello and whoami with whoami at /data/compose/1, then the file is copied and chowned, whoami is taken down and brought up from the copy, and compose ls shows whoami running from /root/whoami-compose.yml" width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That run was as root, so &lt;code&gt;~&lt;/code&gt; is &lt;code&gt;/root&lt;/code&gt; there, and I skipped the &lt;code&gt;cat&lt;/code&gt; check because I had just deployed that stack myself.&lt;/p&gt;

&lt;p&gt;To remove everything, take the stacks down first (&lt;code&gt;docker compose -p &amp;lt;name&amp;gt; down&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;docker volume &lt;span class="nb"&gt;rm &lt;/span&gt;portainer_data
docker image &lt;span class="nb"&gt;rm &lt;/span&gt;portainer/portainer-ce:lts
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;ul&gt;
&lt;li&gt;The docs command is reachable past ufw: &lt;code&gt;-p 9443:9443&lt;/code&gt; and &lt;code&gt;-p 8000:8000&lt;/code&gt; listen on every address and Docker's rules skip ufw. Bind to &lt;code&gt;127.0.0.1&lt;/code&gt;, and do not publish 8000 unless you use Edge agents.&lt;/li&gt;
&lt;li&gt;An admin, or any user given access to the local environment, can start a container with &lt;code&gt;/&lt;/code&gt; mounted, so Portainer is root on the host. A plain user with no environment access got &lt;code&gt;403 Permission denied to access environment&lt;/code&gt;. Guard the admin login like SSH.&lt;/li&gt;
&lt;li&gt;On 2.43 and later the first admin needs the &lt;code&gt;setup_token&lt;/code&gt; from &lt;code&gt;docker logs portainer&lt;/code&gt;. Guides written before that do not mention it.&lt;/li&gt;
&lt;li&gt;The five minute lockout does not stop the container. &lt;code&gt;docker ps&lt;/code&gt; says it is up while every page redirects to &lt;code&gt;/timeout.html&lt;/code&gt;; &lt;code&gt;docker restart portainer&lt;/code&gt; fixes it and prints a new token.&lt;/li&gt;
&lt;li&gt;A plain &lt;code&gt;volumes: portainer_data:&lt;/code&gt; in Compose becomes &lt;code&gt;portainer_portainer_data&lt;/code&gt;, and Portainer looks freshly installed. Use &lt;code&gt;external: true&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Stopping Portainer for a backup invalidated my session. Expect to log in again after any restart.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;docker compose ls&lt;/code&gt; shows stack files at &lt;code&gt;/data/compose/N/...&lt;/code&gt;, a path inside the container; the real file is under &lt;code&gt;/var/lib/docker/volumes/portainer_data/_data/compose/N/&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Both backup methods cover Portainer's own state, not your apps. Their volumes need their own backup.&lt;/li&gt;
&lt;/ul&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;Create the volume&lt;/td&gt;
&lt;td&gt;&lt;code&gt;docker volume create portainer_data&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Start (Compose)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;cd /opt/portainer &amp;amp;&amp;amp; docker compose up -d&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Port line to use&lt;/td&gt;
&lt;td&gt;&lt;code&gt;"127.0.0.1:9443:9443"&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tunnel from your machine&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;ssh -N -L 9443:127.0.0.1:9443 you@server&lt;/code&gt;, then &lt;code&gt;https://localhost:9443&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Setup token&lt;/td&gt;
&lt;td&gt;`docker logs portainer 2&amp;gt;&amp;amp;1 \&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Timed out before setup&lt;/td&gt;
&lt;td&gt;{% raw %}&lt;code&gt;docker restart portainer&lt;/code&gt;, then the new token&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Version&lt;/td&gt;
&lt;td&gt;&lt;code&gt;curl -sk https://127.0.0.1:9443/api/system/status&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Update&lt;/td&gt;
&lt;td&gt;&lt;code&gt;docker compose pull &amp;amp;&amp;amp; docker compose up -d&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Back up&lt;/td&gt;
&lt;td&gt;stop, &lt;code&gt;tar czf&lt;/code&gt; the volume through an &lt;code&gt;alpine&lt;/code&gt; container, start&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Stack files on the host&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/var/lib/docker/volumes/portainer_data/_data/compose/&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Remove&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;docker compose down&lt;/code&gt;, then stacks, then &lt;code&gt;docker volume rm portainer_data&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Portainer is a login in front of the Docker socket. Keep it on loopback, reach it through a tunnel, and back up the volume before you touch it.&lt;/p&gt;

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

</description>
      <category>docker</category>
      <category>security</category>
      <category>linux</category>
      <category>ubuntu</category>
    </item>
    <item>
      <title>Rocky Linux 10 for Ubuntu people</title>
      <dc:creator>Minor Keith</dc:creator>
      <pubDate>Wed, 23 Sep 2026 20:13:14 +0000</pubDate>
      <link>https://dev.to/peculiarengineer/rocky-linux-10-for-ubuntu-people-3d3l</link>
      <guid>https://dev.to/peculiarengineer/rocky-linux-10-for-ubuntu-people-3d3l</guid>
      <description>&lt;p&gt;Most of the boxes I look after run Ubuntu. The RHEL family (Red Hat, and its free rebuilds Rocky and Alma) is what most of the enterprise world runs, and sooner or later someone hands you one. The &lt;a href="https://peculiarengineer.com/blog/debian-13-for-ubuntu-people/" rel="noopener noreferrer"&gt;Debian post&lt;/a&gt; was the easy version of this comparison, because Debian and Ubuntu share apt and most of &lt;code&gt;/etc&lt;/code&gt;. This is the hard version. Sit down at a Rocky Linux 10 prompt with Ubuntu habits and &lt;code&gt;apt&lt;/code&gt;, &lt;code&gt;dpkg&lt;/code&gt;, &lt;code&gt;ufw&lt;/code&gt;, &lt;code&gt;netplan&lt;/code&gt; and &lt;code&gt;add-apt-repository&lt;/code&gt; are all &lt;code&gt;command not found&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;I built it the same way. One fresh Rocky Linux 10 server, one fresh AlmaLinux 10 server and one fresh Ubuntu 26.04 server from the same provider on the same day, the same script run on all three, then a second round of hands on checks on the Rocky box: a user, a firewall, a port change, a reverse proxy, Docker, and a reboot. Rocky and Alma came back near identical, so the text says "Rocky" and calls out the one functional place Alma differs. Where something is a property of the cloud image rather than the distribution, I say so, and this time that includes the biggest finding in the post.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR.&lt;/strong&gt; systemd, &lt;code&gt;journalctl&lt;/code&gt;, &lt;code&gt;sshd_config.d&lt;/code&gt;, cloud-init, chrony and rsyslog all carry over. Packaging is the same ideas under different names: &lt;code&gt;dnf&lt;/code&gt; instead of &lt;code&gt;apt&lt;/code&gt;, &lt;code&gt;.repo&lt;/code&gt; files instead of &lt;code&gt;.sources&lt;/code&gt;, EPEL instead of PPAs, &lt;code&gt;wheel&lt;/code&gt; instead of &lt;code&gt;sudo&lt;/code&gt;, and no &lt;code&gt;EXTERNALLY-MANAGED&lt;/code&gt; marker, so &lt;code&gt;pip install&lt;/code&gt; as root just works. Networking is NetworkManager and &lt;code&gt;nmcli&lt;/code&gt;, not netplan. The firewall is &lt;code&gt;firewalld&lt;/code&gt;, and it is not on this cloud image. The trap is SELinux: this image ships it &lt;strong&gt;permissive&lt;/strong&gt;, so an SSH port change and an nginx &lt;code&gt;proxy_pass&lt;/code&gt; both work, then fail on the first restart or request after you set enforcing, with &lt;code&gt;Bind to port 2222 on 0.0.0.0 failed: Permission denied&lt;/code&gt; and &lt;code&gt;(13: Permission denied) while connecting to upstream&lt;/code&gt;. The fixes are &lt;code&gt;semanage port&lt;/code&gt; and &lt;code&gt;setsebool&lt;/code&gt;, and both are below.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  What is the same
&lt;/h2&gt;

&lt;p&gt;Start here, because it is more of the list than the reputation suggests. On both boxes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;systemd 25x, &lt;code&gt;systemctl&lt;/code&gt;, &lt;code&gt;journalctl&lt;/code&gt;, &lt;code&gt;hostnamectl&lt;/code&gt;, &lt;code&gt;timedatectl&lt;/code&gt;. Every systemd and journal command I ran for this post behaved the same on both, so the &lt;a href="https://peculiarengineer.com/blog/systemd-units-timers-ubuntu-26-04/" rel="noopener noreferrer"&gt;systemd units and timers post&lt;/a&gt; and the &lt;a href="https://peculiarengineer.com/blog/reading-logs-journalctl-ubuntu-26-04/" rel="noopener noreferrer"&gt;journalctl post&lt;/a&gt; should carry over; the one path difference is under logs below.&lt;/li&gt;
&lt;li&gt;cloud-init, which on both images creates only &lt;code&gt;root&lt;/code&gt;, locks root's password, leaves the admin group empty (&lt;code&gt;wheel&lt;/code&gt; on Rocky, &lt;code&gt;sudo&lt;/code&gt; on Ubuntu) and drops &lt;code&gt;root ALL=(ALL) NOPASSWD:ALL&lt;/code&gt; in &lt;code&gt;/etc/sudoers.d/90-cloud-init-users&lt;/code&gt;. That is the image, not the distribution.&lt;/li&gt;
&lt;li&gt;sshd with &lt;code&gt;Include /etc/ssh/sshd_config.d/*.conf&lt;/code&gt;, root allowed by key only, and your own settings going in a numbered drop-in. Rocky ships three drop-ins already (&lt;code&gt;40-redhat-crypto-policies.conf&lt;/code&gt;, &lt;code&gt;50-redhat.conf&lt;/code&gt;, &lt;code&gt;50-cloud-init.conf&lt;/code&gt;); Ubuntu's directory is empty.&lt;/li&gt;
&lt;li&gt;chrony for time, on both. &lt;code&gt;chronyc tracking&lt;/code&gt; works and &lt;code&gt;timedatectl timesync-status&lt;/code&gt; errors out on both, because neither runs timesyncd. Rocky synced to the provider's NTP server, Ubuntu's chrony to Canonical's pool.&lt;/li&gt;
&lt;li&gt;rsyslog, running on both, so both have text logs beside the journal. The file names differ, covered below.&lt;/li&gt;
&lt;li&gt;bash 5 as the login shell, vim and nano installed, &lt;code&gt;ss&lt;/code&gt;, &lt;code&gt;lvm&lt;/code&gt;, &lt;code&gt;parted&lt;/code&gt;, &lt;code&gt;growpart&lt;/code&gt;, &lt;code&gt;mkfs.xfs&lt;/code&gt;, &lt;code&gt;mkfs.ext4&lt;/code&gt;, &lt;code&gt;mdadm&lt;/code&gt;, &lt;code&gt;cryptsetup&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;su -&lt;/code&gt; to root from a normal user fails on both cloud images with &lt;code&gt;Authentication failure&lt;/code&gt;, because root's password is locked. I tried it on Rocky here and on Ubuntu in the Debian post's lab, on the same image.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If your day is &lt;code&gt;systemctl&lt;/code&gt;, &lt;code&gt;journalctl&lt;/code&gt; and editing files under &lt;code&gt;/etc&lt;/code&gt;, you will not notice which one you are on. The rest of this post is the remainder: packaging, networking, the firewall and SELinux, which is to say everything Ubuntu made easy.&lt;/p&gt;

&lt;h2&gt;
  
  
  The versions
&lt;/h2&gt;

&lt;p&gt;RHEL 10 shipped in May 2025 and its clones followed within weeks. Ubuntu 26.04 is eleven months younger and, like Debian 13, the older release shows it:&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;Rocky Linux 10.2 (Red Quartz)&lt;/th&gt;
&lt;th&gt;AlmaLinux 10.2 (Lavender Lion)&lt;/th&gt;
&lt;th&gt;Ubuntu 26.04.1 (resolute)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Kernel&lt;/td&gt;
&lt;td&gt;6.12 (&lt;code&gt;el10_2&lt;/code&gt; build)&lt;/td&gt;
&lt;td&gt;same&lt;/td&gt;
&lt;td&gt;7.0 (&lt;code&gt;-generic&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;systemd&lt;/td&gt;
&lt;td&gt;257&lt;/td&gt;
&lt;td&gt;257&lt;/td&gt;
&lt;td&gt;259&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Python&lt;/td&gt;
&lt;td&gt;3.12.14&lt;/td&gt;
&lt;td&gt;3.12.14&lt;/td&gt;
&lt;td&gt;3.14.4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Package tool&lt;/td&gt;
&lt;td&gt;dnf 4.20.0&lt;/td&gt;
&lt;td&gt;dnf 4.20.0&lt;/td&gt;
&lt;td&gt;apt 3.2.0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;bash&lt;/td&gt;
&lt;td&gt;5.2&lt;/td&gt;
&lt;td&gt;5.2&lt;/td&gt;
&lt;td&gt;5.3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;OpenSSH&lt;/td&gt;
&lt;td&gt;9.9p1&lt;/td&gt;
&lt;td&gt;9.9p1&lt;/td&gt;
&lt;td&gt;10.2p1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;sudo&lt;/td&gt;
&lt;td&gt;original sudo 1.9.17p2&lt;/td&gt;
&lt;td&gt;same&lt;/td&gt;
&lt;td&gt;sudo-rs 0.2.13&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;coreutils&lt;/td&gt;
&lt;td&gt;GNU 9.5&lt;/td&gt;
&lt;td&gt;GNU 9.5&lt;/td&gt;
&lt;td&gt;uutils 0.8.0 (&lt;code&gt;df&lt;/code&gt; still GNU 9.7)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Packages installed, fresh image&lt;/td&gt;
&lt;td&gt;443&lt;/td&gt;
&lt;td&gt;443&lt;/td&gt;
&lt;td&gt;585&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;First release (10.0 / 26.04)&lt;/td&gt;
&lt;td&gt;11 June 2025&lt;/td&gt;
&lt;td&gt;27 May 2025&lt;/td&gt;
&lt;td&gt;April 2026&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Support until&lt;/td&gt;
&lt;td&gt;May 2030 active, May 2035 security&lt;/td&gt;
&lt;td&gt;same&lt;/td&gt;
&lt;td&gt;May 2031, then Ubuntu Pro to 2036&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmb3y4sj18317kp5e26mi.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%2Fmb3y4sj18317kp5e26mi.png" alt="Terminal, both boxes: Rocky prints Rocky Linux 10.2 (Red Quartz), kernel 6.12.0-211.56.1.el10_2.0.1, systemd 257, Python 3.12.14, Sudo version 1.9.17p2 and GNU coreutils 9.5; Ubuntu prints 26.04.1 LTS, kernel 7.0.0-30-generic, systemd 259, Python 3.14.4, sudo-rs 0.2.13, uutils coreutils 0.8.0 and 585 packages" width="800" height="1071"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The screenshots in this post are from a second pair of fresh boxes run the same night, and both of the provider's images had moved in between: the second Rocky box booted as 10.1 and was brought to 10.2 with &lt;code&gt;dnf upgrade&lt;/code&gt; before this shot (that story is under updates below), and the second Ubuntu box has kernel build &lt;code&gt;-30&lt;/code&gt; where the first had &lt;code&gt;-31&lt;/code&gt;. Build suffixes in the shots differ from the table for that reason; the versions do not.&lt;/p&gt;

&lt;p&gt;The release and support dates are from the projects' own pages, not the lab. Everything else in the table is what the boxes printed. Two rows matter more than they look. The kernel: RHEL keeps one kernel base for a whole major release and backports fixes into it, so Rocky 10 will still say 6.12 in 2030 (that is Red Hat's stated policy, not something the box can show). And the support row: ten years, with the last five security only, against Ubuntu's five plus a paid tail. That is what the family is for, and nothing in this post argues with it.&lt;/p&gt;

&lt;p&gt;One row that is not in the table because it is about your CPU, not the box: RHEL 10 and Rocky 10 require an x86-64-v3 processor, roughly Intel Haswell (2013) or newer, per their release notes. AlmaLinux 10 also builds for v2, which is the one reason to pick Alma over Rocky on a specific box. The lab CPUs (AMD EPYC Rome) are v3, so I could not test the refusal.&lt;/p&gt;

&lt;h2&gt;
  
  
  Users: wheel, not sudo
&lt;/h2&gt;

&lt;p&gt;The admin group is &lt;code&gt;wheel&lt;/code&gt;, and &lt;code&gt;/etc/sudoers&lt;/code&gt; on Rocky already has &lt;code&gt;%wheel ALL=(ALL) ALL&lt;/code&gt;. So a new admin user is:&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;useradd &lt;span class="nt"&gt;-m&lt;/span&gt; &lt;span class="nt"&gt;-G&lt;/span&gt; wheel keith
&lt;span class="nb"&gt;sudo &lt;/span&gt;passwd keith
&lt;span class="nb"&gt;sudo mkdir&lt;/span&gt; &lt;span class="nt"&gt;-m&lt;/span&gt; 700 /home/keith/.ssh
&lt;span class="nb"&gt;sudo cp&lt;/span&gt; /root/.ssh/authorized_keys /home/keith/.ssh/
&lt;span class="nb"&gt;sudo chown&lt;/span&gt; &lt;span class="nt"&gt;-R&lt;/span&gt; keith:keith /home/keith/.ssh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and an existing user joins with &lt;code&gt;sudo usermod -aG wheel keith&lt;/code&gt;. That is the &lt;a href="https://peculiarengineer.com/blog/create-sudo-user-ubuntu-26-04/" rel="noopener noreferrer"&gt;create a sudo user post&lt;/a&gt; with &lt;code&gt;sudo&lt;/code&gt; changed to &lt;code&gt;wheel&lt;/code&gt;, plus the key copy, which matters more here than on Ubuntu: this image sets &lt;code&gt;PasswordAuthentication no&lt;/code&gt;, so a user with only a password cannot log in over SSH at all. I logged in as &lt;code&gt;keith&lt;/code&gt; with the copied key to check. Two details. Rocky's &lt;code&gt;login.defs&lt;/code&gt; has &lt;code&gt;CREATE_HOME yes&lt;/code&gt;, so &lt;code&gt;-m&lt;/code&gt; is not strictly needed, but it is harmless and I keep typing it. And the &lt;code&gt;%wheel&lt;/code&gt; line asks for a password; for passwordless sudo, drop a file in &lt;code&gt;/etc/sudoers.d/&lt;/code&gt; as you would on Ubuntu, which I did for &lt;code&gt;keith&lt;/code&gt; to run the checks below.&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%2Fx8l0dpfa99hwc6vegflp.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%2Fx8l0dpfa99hwc6vegflp.png" alt="Terminal, Rocky: useradd -m -G wheel keith creates uid 1000 in group wheel, usermod -aG sudo keith fails with group sudo does not exist, sudoers has %wheel ALL=(ALL) ALL, the key is copied into /home/keith/.ssh, su from keith fails with Authentication failure, and ssh keith@ the box from my Mac returns id with the wheel group" width="800" height="369"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Ubuntu's group is &lt;code&gt;sudo&lt;/code&gt; with &lt;code&gt;%sudo ALL=(ALL:ALL) ALL&lt;/code&gt;. Same shape, different name, and neither box has the other's group: &lt;code&gt;usermod -aG sudo keith&lt;/code&gt; on Rocky fails with &lt;code&gt;group 'sudo' does not exist&lt;/code&gt;, and there is no &lt;code&gt;wheel&lt;/code&gt; group on Ubuntu.&lt;/p&gt;

&lt;h2&gt;
  
  
  Packaging: dnf, repos, EPEL and history
&lt;/h2&gt;

&lt;p&gt;Here is the translation. Everything with output quoted below was typed on the box.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The commands.&lt;/strong&gt; &lt;code&gt;dnf install&lt;/code&gt;, &lt;code&gt;dnf remove&lt;/code&gt;, &lt;code&gt;dnf upgrade&lt;/code&gt;, &lt;code&gt;dnf search&lt;/code&gt;, &lt;code&gt;dnf info&lt;/code&gt;. &lt;code&gt;dnf check-update&lt;/code&gt; lists pending updates and exits 100 when there are any (36 on the day), which is useful in scripts and alarming the first time. &lt;code&gt;apt&lt;/code&gt; is not found, &lt;code&gt;dpkg&lt;/code&gt; is not found. &lt;code&gt;yum&lt;/code&gt; and &lt;code&gt;dnf&lt;/code&gt; are both symlinks to the same &lt;code&gt;dnf-3&lt;/code&gt;, so old guides still work.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The repos.&lt;/strong&gt; &lt;code&gt;/etc/yum.repos.d/*.repo&lt;/code&gt;, INI style, with one or more repos per file. On the day, Rocky enables &lt;code&gt;baseos&lt;/code&gt;, &lt;code&gt;appstream&lt;/code&gt; and &lt;code&gt;extras&lt;/code&gt;. &lt;code&gt;crb&lt;/code&gt; (CodeReady Builder, headers and build dependencies) is disabled on Rocky and enabled on Alma, which is the one functional difference I found between the two. Turn it on with &lt;code&gt;dnf config-manager --set-enabled crb&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;EPEL is the PPA replacement, sort of.&lt;/strong&gt; Half the things an Ubuntu person installs by reflex are not in Rocky's repos at all. &lt;code&gt;htop&lt;/code&gt; and &lt;code&gt;fail2ban&lt;/code&gt; both answered &lt;code&gt;No matching Packages to list&lt;/code&gt; on the fresh box, and &lt;code&gt;pipx&lt;/code&gt;, &lt;code&gt;uv&lt;/code&gt;, &lt;code&gt;python3.13&lt;/code&gt; and plain &lt;code&gt;netcat&lt;/code&gt; turned out to live in the same place, which you reach with:&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;dnf &lt;span class="nb"&gt;install &lt;/span&gt;epel-release
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;epel-release&lt;/code&gt; comes from the &lt;code&gt;extras&lt;/code&gt; repo (&lt;code&gt;dnf info epel-release&lt;/code&gt; says so), so that one command works on a fresh box, and after it &lt;code&gt;dnf repolist&lt;/code&gt; shows a fourth repo, &lt;code&gt;epel&lt;/code&gt; (a fifth if you enabled &lt;code&gt;crb&lt;/code&gt; above). It is one repo run by the Fedora project, not a thousand personal ones. There is no &lt;code&gt;add-apt-repository&lt;/code&gt;. For a vendor's own repo you download a &lt;code&gt;.repo&lt;/code&gt; file into &lt;code&gt;/etc/yum.repos.d/&lt;/code&gt; or use &lt;code&gt;dnf config-manager --add-repo &amp;lt;url&amp;gt;&lt;/code&gt;, which is what the Docker step below does.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Package names.&lt;/strong&gt; The names you know are the Debian names, and dnf does not translate:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;You type&lt;/th&gt;
&lt;th&gt;Rocky has&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;apache2&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;httpd&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;dnsutils&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;bind-utils&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;netcat&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;nmap-ncat&lt;/code&gt; (or &lt;code&gt;netcat&lt;/code&gt; from EPEL)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;build-essential&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;dnf group install "Development Tools"&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;python3-venv&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;nothing to install, &lt;code&gt;python3 -m venv&lt;/code&gt; works out of the box&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;gdisk&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;gdisk&lt;/code&gt;, but not on the image, so &lt;code&gt;sgdisk&lt;/code&gt; is missing until you install it&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;When you do not know the name, &lt;code&gt;dnf provides "*/dig"&lt;/code&gt; finds the package that owns a file, which is &lt;code&gt;apt-file&lt;/code&gt; without the separate database.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;History and undo.&lt;/strong&gt; Here Rocky is closer to Ubuntu 26.04 than Debian is. &lt;code&gt;dnf history list&lt;/code&gt; shows every transaction, &lt;code&gt;dnf history info last&lt;/code&gt; shows what it changed, and &lt;code&gt;dnf history undo last&lt;/code&gt; reverses it. I installed &lt;code&gt;htop&lt;/code&gt; from EPEL, undid it, and &lt;code&gt;htop&lt;/code&gt; was gone. This is the feature the &lt;a href="https://peculiarengineer.com/blog/apt-history-undo-rollback-ubuntu-26-04/" rel="noopener noreferrer"&gt;apt history post&lt;/a&gt; is about, and dnf had it 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%2Fhge42puosqf0zb5g2yku.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%2Fhge42puosqf0zb5g2yku.png" alt="Terminal, Rocky: dnf install htop fails with Unable to find a match, dnf install epel-release completes, dnf repolist now lists epel as a fourth repo, htop installs, dnf history list shows the two transactions, dnf history undo last removes htop and command -v htop finds nothing" width="800" height="633"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Two settings in &lt;code&gt;/etc/dnf/dnf.conf&lt;/code&gt; worth knowing about.&lt;/strong&gt; &lt;code&gt;best=True&lt;/code&gt; is the setting that makes dnf refuse an upgrade rather than quietly pick a lower version when the newest one has a dependency problem, where apt's habit is to hold the package back and carry on. &lt;code&gt;installonly_limit=3&lt;/code&gt; is the setting that keeps three kernels installed and removes the oldest when a fourth arrives, which is the kernel half of apt's &lt;code&gt;autoremove&lt;/code&gt;, done for you. I read both from the file; a fresh box has one kernel and nothing to hold back, so neither behaviour was exercised.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Missing commands are just missing.&lt;/strong&gt; Type &lt;code&gt;tree&lt;/code&gt; on a fresh Rocky box and bash says &lt;code&gt;command not found&lt;/code&gt; and nothing else. Ubuntu's &lt;code&gt;command-not-found&lt;/code&gt; hook, which in an interactive shell tells you which package to install (the Debian post shows it), has no equivalent on this image.&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%2F70mx7qmdq9k348wop3de.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%2F70mx7qmdq9k348wop3de.png" alt="Terminal, both boxes: a loop over apt, ufw, netplan, firewall-cmd, semanage, add-apt-repository, snap and sgdisk reports every one not found on Rocky, only the wheel group exists there and tree is command not found; on Ubuntu everything except firewall-cmd and semanage is present and only the sudo group exists" width="800" height="680"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Networking: NetworkManager and nmcli
&lt;/h2&gt;

&lt;p&gt;No &lt;code&gt;/etc/netplan&lt;/code&gt;, no &lt;code&gt;netplan&lt;/code&gt; command, no &lt;code&gt;/etc/network/interfaces.d&lt;/code&gt;, and on Rocky 10 no &lt;code&gt;/etc/sysconfig/network-scripts&lt;/code&gt; either, which is where older RHEL guides will send you. The stack is:&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;Rocky 10 (this image)&lt;/th&gt;
&lt;th&gt;Ubuntu 26.04&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Config&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;/etc/NetworkManager/system-connections/*.nmconnection&lt;/code&gt; (keyfiles)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/etc/netplan/*.yaml&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Brought up by&lt;/td&gt;
&lt;td&gt;&lt;code&gt;NetworkManager&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;systemd-networkd&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tool&lt;/td&gt;
&lt;td&gt;&lt;code&gt;nmcli&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;netplan&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DNS&lt;/td&gt;
&lt;td&gt;NetworkManager writes &lt;code&gt;/etc/resolv.conf&lt;/code&gt; directly&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;systemd-resolved&lt;/code&gt;, stub at &lt;code&gt;127.0.0.53&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;resolvectl&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;binary present, answers &lt;code&gt;The name is not activatable&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;works&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;cloud-init wrote one connection, &lt;code&gt;cloud-init eth0&lt;/code&gt;, and &lt;code&gt;nmcli con show&lt;/code&gt; lists it. A static address is two commands. The values below are placeholders; put in the address, prefix and gateway the box has now (&lt;code&gt;nmcli dev show eth0&lt;/code&gt; prints them), because &lt;code&gt;con up&lt;/code&gt; applies the change immediately and the wrong gateway ends your SSH 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;sudo &lt;/span&gt;nmcli con mod &lt;span class="s2"&gt;"cloud-init eth0"&lt;/span&gt; ipv4.method manual &lt;span class="se"&gt;\&lt;/span&gt;
  ipv4.addresses 10.0.0.5/24 ipv4.gateway 10.0.0.1 ipv4.dns &lt;span class="s2"&gt;"1.1.1.1 1.0.0.1"&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;nmcli con up &lt;span class="s2"&gt;"cloud-init eth0"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I ran it on the Alma box with that box's current address and gateway, over SSH, and kept the session. &lt;code&gt;nmcli con mod&lt;/code&gt; edits the keyfile (an &lt;code&gt;[ipv4]&lt;/code&gt; block with &lt;code&gt;address1=&lt;/code&gt;, &lt;code&gt;gateway=&lt;/code&gt; and &lt;code&gt;dns=&lt;/code&gt;) and &lt;code&gt;con up&lt;/code&gt; applies it, so together they are &lt;code&gt;netplan apply&lt;/code&gt;. &lt;code&gt;/etc/resolv.conf&lt;/code&gt; was rewritten with the new servers on the way. The muscle memory that fails first is &lt;code&gt;resolvectl status&lt;/code&gt; and &lt;code&gt;ls /etc/netplan&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%2Fkxzdp90qi3s2urblttf0.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%2Fkxzdp90qi3s2urblttf0.png" alt="Terminal, both boxes: on Rocky there is no /etc/netplan, system-connections holds cloud-init-eth0.nmconnection, nmcli lists cloud-init eth0 and lo, resolvectl status fails with The name is not activatable and resolv.conf says Generated by NetworkManager; on Ubuntu netplan holds 50-cloud-init.yaml, there is no system-connections directory, resolvectl answers and resolv.conf is the resolved stub" width="800" height="700"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Firewall: firewalld, once you install it
&lt;/h2&gt;

&lt;p&gt;Neither image starts with any firewall rules. Both have &lt;code&gt;nft&lt;/code&gt; and &lt;code&gt;iptables&lt;/code&gt; installed and an empty ruleset. From there the Ubuntu image has &lt;code&gt;ufw&lt;/code&gt; installed (&lt;code&gt;ufw status&lt;/code&gt; says &lt;code&gt;inactive&lt;/code&gt; until you &lt;code&gt;ufw enable&lt;/code&gt;), and the Rocky image has no front end at all: &lt;code&gt;firewall-cmd&lt;/code&gt; not found, &lt;code&gt;firewalld&lt;/code&gt; not installed. That surprised me, because firewalld is the RHEL firewall and I have never seen an installer build without it. This cloud image does not ship it, and &lt;code&gt;dnf history&lt;/code&gt; says why: transaction 2 in the image's own build log is &lt;code&gt;remove firewalld&lt;/code&gt;, run by whoever built the image. Somebody took it out on purpose.&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;dnf &lt;span class="nb"&gt;install &lt;/span&gt;firewalld
&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; firewalld
&lt;span class="nb"&gt;sudo &lt;/span&gt;firewall-cmd &lt;span class="nt"&gt;--list-all&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Out of the box the default zone is &lt;code&gt;public&lt;/code&gt;, &lt;code&gt;eth0&lt;/code&gt; lands in it, and it allows &lt;code&gt;ssh&lt;/code&gt;, &lt;code&gt;dhcpv6-client&lt;/code&gt; and &lt;code&gt;cockpit&lt;/code&gt; (port 9090, whether or not cockpit is installed). Opening things is &lt;code&gt;--add-service&lt;/code&gt; or &lt;code&gt;--add-port&lt;/code&gt;, and the flag every Ubuntu person forgets is &lt;code&gt;--permanent&lt;/code&gt;, because a rule added without it lives in the running config only and goes away on the next reload or reboot (that is what the flag is for; I only tested the permanent path):&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;firewall-cmd &lt;span class="nt"&gt;--add-service&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;http &lt;span class="nt"&gt;--permanent&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;firewall-cmd &lt;span class="nt"&gt;--add-port&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;2222/tcp &lt;span class="nt"&gt;--permanent&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;firewall-cmd &lt;span class="nt"&gt;--reload&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Underneath it is nftables, &lt;code&gt;table inet firewalld&lt;/code&gt;, 292 non-blank lines of ruleset for that one zone. &lt;code&gt;ufw allow 22&lt;/code&gt; and &lt;code&gt;firewall-cmd --add-service=ssh --permanent&lt;/code&gt; both end up in the kernel's nftables, by different routes and in different tables. The &lt;a href="https://peculiarengineer.com/blog/ufw-firewall-basics-ubuntu/" rel="noopener noreferrer"&gt;ufw post&lt;/a&gt; has no direct equivalent here, and I have not written the firewalld one yet.&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%2Foyhki27fwhrga708ufai.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%2Foyhki27fwhrga708ufai.png" alt="Terminal, Rocky: getenforce prints Permissive and the config file says SELINUX=permissive, firewalld is not-found, dnf install firewalld completes, after enable the default zone is public with services cockpit dhcpv6-client ssh, and adding 2222/tcp with --permanent and a reload lists 2222/tcp" width="800" height="448"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  SELinux: permissive on this image, and why that is a trap
&lt;/h2&gt;

&lt;p&gt;Ubuntu has AppArmor, loaded with 182 profiles on this image, and most server setups never trip over it. Rocky has SELinux, and most do. Except that on this cloud image you will not, for a while, and that is the finding the post is really about.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ getenforce
Permissive
$ grep ^SELINUX= /etc/selinux/config
SELINUX=permissive
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Permissive means SELinux is loaded, files and processes carry their labels, denials are written to the audit log, and nothing is enforced. Both the Rocky 10 and the AlmaLinux 10 image on Hetzner Cloud shipped that way on the day I ran this (22 September 2026). The installer's default is enforcing; I did not run an installer or Red Hat's own cloud image for this post, so check &lt;code&gt;getenforce&lt;/code&gt; on yours rather than assuming either way. A surprising amount of "Rocky is different" on this box is really "this image turned the different part off."&lt;/p&gt;

&lt;p&gt;Why it is a trap: everything you set up works. You change the SSH port, it listens. You put nginx in front of an app, it proxies. You get comfortable, or a hardening guide tells you to set &lt;code&gt;SELINUX=enforcing&lt;/code&gt; and reboot, and two services you tested are now broken, with no change to their config. The next two sections reproduce both, and the switch between the two states is one command, which I ran between the permissive and enforcing halves 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;&lt;span class="nb"&gt;sudo &lt;/span&gt;setenforce 1     &lt;span class="c"&gt;# enforcing until reboot; getenforce confirms&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To make it stick, set &lt;code&gt;SELINUX=enforcing&lt;/code&gt; in &lt;code&gt;/etc/selinux/config&lt;/code&gt;, which I did at the end, and rebooted to check that both fixes below survived it.&lt;/p&gt;

&lt;p&gt;The tools you need, and their packages: &lt;code&gt;getenforce&lt;/code&gt;, &lt;code&gt;setenforce&lt;/code&gt;, &lt;code&gt;getsebool&lt;/code&gt;, &lt;code&gt;setsebool&lt;/code&gt; and &lt;code&gt;restorecon&lt;/code&gt; are on the image. &lt;code&gt;semanage&lt;/code&gt;, the one you need for ports and file contexts, is not; it lives in &lt;code&gt;policycoreutils-python-utils&lt;/code&gt;. &lt;code&gt;ausearch -m avc -ts recent&lt;/code&gt; reads the denials. That command is worth running on a permissive box before you switch, because every operation you have already exercised that would be blocked is in there; anything you have not exercised yet is not.&lt;/p&gt;

&lt;h2&gt;
  
  
  SSH: Bind to port 2222 failed: Permission denied
&lt;/h2&gt;

&lt;p&gt;The Debian post found that changing the SSH port on Ubuntu needs &lt;code&gt;daemon-reload&lt;/code&gt; and a restart of &lt;code&gt;ssh.socket&lt;/code&gt;, because sshd is socket activated there. Rocky runs &lt;code&gt;sshd.service&lt;/code&gt; the old way (&lt;code&gt;sshd.socket&lt;/code&gt; exists and is disabled), so that trap is gone. SELinux supplies a new one: the label trap replaces the socket trap.&lt;/p&gt;

&lt;p&gt;I added &lt;code&gt;/etc/ssh/sshd_config.d/10-port.conf&lt;/code&gt; with &lt;code&gt;Port 22&lt;/code&gt; and &lt;code&gt;Port 2222&lt;/code&gt;, checked it with &lt;code&gt;sshd -t&lt;/code&gt;, and restarted:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Permissive:  0.0.0.0:22 and 0.0.0.0:2222 both listening after `systemctl restart sshd`
Enforcing:   only 0.0.0.0:22 listening, and in the journal:
             sshd: error: Bind to port 2222 on 0.0.0.0 failed: Permission denied.
             (and a second line for ::, one per address family)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Even in permissive mode the audit log already had the denial: &lt;code&gt;comm="sshd" src=2222 ... tcontext=system_u:object_r:unreserved_port_t:s0&lt;/code&gt;. Port 2222 is an unreserved port and only ports labelled &lt;code&gt;ssh_port_t&lt;/code&gt; are ones sshd is allowed to bind. The fix is one label:&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;dnf &lt;span class="nb"&gt;install &lt;/span&gt;policycoreutils-python-utils
&lt;span class="nb"&gt;sudo &lt;/span&gt;semanage port &lt;span class="nt"&gt;-a&lt;/span&gt; &lt;span class="nt"&gt;-t&lt;/span&gt; ssh_port_t &lt;span class="nt"&gt;-p&lt;/span&gt; tcp 2222
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl restart sshd
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;semanage port -l | grep ssh_port_t&lt;/code&gt; then shows &lt;code&gt;tcp 2222, 22&lt;/code&gt;, and sshd binds both under enforcing. Add the firewalld rule from the previous section, and test the new port from a second session before you drop &lt;code&gt;Port 22&lt;/code&gt;. Those are the three steps the RHEL port change guides give, and now you know which layer each one is for: &lt;code&gt;semanage&lt;/code&gt; for SELinux, the drop-in for sshd, &lt;code&gt;firewall-cmd&lt;/code&gt; for the packet filter.&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%2Fjt3oa7wn3xj1sy6vlaep.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%2Fjt3oa7wn3xj1sy6vlaep.png" alt="Terminal, Rocky: under Permissive the Port drop-in and restart leave sshd listening on 22 and 2222 while ausearch already shows the sshd denial against unreserved_port_t; after setenforce 1 a restart leaves only 22, the journal says Bind to port 2222 failed Permission denied, policycoreutils-python-utils installs, semanage port adds 2222 to ssh_port_t, and the next restart listens on both again" width="800" height="693"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;One more sshd difference: this cloud image sets &lt;code&gt;PasswordAuthentication no&lt;/code&gt; in &lt;code&gt;50-cloud-init.conf&lt;/code&gt;. The Ubuntu image leaves it &lt;code&gt;yes&lt;/code&gt;. If you create a user on Rocky and cannot log in with a password, that is why, and it is the right default.&lt;/p&gt;

&lt;h2&gt;
  
  
  nginx: (13: Permission denied) while connecting to upstream
&lt;/h2&gt;

&lt;p&gt;The second thing enforcing breaks is the most common thing people put on a server. nginx from AppStream, a stand in app on port 3000, and the smallest reverse proxy there is:&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;setenforce 0     &lt;span class="c"&gt;# the SSH section left the box enforcing; start this one permissive&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;dnf &lt;span class="nb"&gt;install &lt;/span&gt;nginx
&lt;span class="nb"&gt;sudo mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; /srv/app &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;echo &lt;/span&gt;hello-from-upstream | &lt;span class="nb"&gt;sudo tee&lt;/span&gt; /srv/app/index.html
&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;cd&lt;/span&gt; /srv/app &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;sudo &lt;/span&gt;setsid python3 &lt;span class="nt"&gt;-m&lt;/span&gt; http.server 3000 &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null 2&amp;gt;&amp;amp;1 &amp;amp;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'server { listen 8080; location / { proxy_pass http://127.0.0.1:3000; } }'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="nb"&gt;sudo tee&lt;/span&gt; /etc/nginx/conf.d/app.conf
&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 &lt;span class="nb"&gt;enable&lt;/span&gt; &lt;span class="nt"&gt;--now&lt;/span&gt; nginx
curl 127.0.0.1:8080
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Under permissive that last line prints &lt;code&gt;hello-from-upstream&lt;/code&gt;. Under enforcing it returns &lt;strong&gt;502&lt;/strong&gt;. The error log says why, and it is not what it looks like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;connect() to 127.0.0.1:3000 failed (13: Permission denied) while connecting to upstream
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is not a file permission and no &lt;code&gt;chmod&lt;/code&gt; will touch it. nginx runs in the &lt;code&gt;httpd_t&lt;/code&gt; SELinux domain, and the policy does not let that domain connect out to port 3000 unless a boolean says so. The audit log has it as &lt;code&gt;denied { name_connect } ... comm="nginx" dest=3000 scontext=system_u:system_r:httpd_t:s0&lt;/code&gt;. The switch is a boolean, and &lt;code&gt;-P&lt;/code&gt; makes it survive a reboot:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;getsebool httpd_can_network_connect          &lt;span class="c"&gt;# --&amp;gt; off&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;setsebool &lt;span class="nt"&gt;-P&lt;/span&gt; httpd_can_network_connect on
curl 127.0.0.1:8080                          &lt;span class="c"&gt;# hello-from-upstream&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On Ubuntu this configuration works first time, which is where the &lt;a href="https://peculiarengineer.com/blog/nginx-reverse-proxy-ubuntu-26-04/" rel="noopener noreferrer"&gt;nginx reverse proxy post&lt;/a&gt; was run. On a permissive Rocky box it also works first time; I set it up under permissive, got &lt;code&gt;hello-from-upstream&lt;/code&gt;, ran &lt;code&gt;setenforce 1&lt;/code&gt;, and the next request was the 502. That post's recipe needs one extra step here that is invisible until enforcing is on, and if you only remember one boolean from SELinux, this is the 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%2F1fajawkoac7x4ergbx60.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%2F1fajawkoac7x4ergbx60.png" alt="Terminal, Rocky: under Permissive nginx installs, the python app answers on 3000, the proxy config passes nginx -t and curl on 8080 returns hello-from-upstream; under Enforcing the same curl returns 502, the error log shows 13 Permission denied while connecting to upstream, ausearch shows two name_connect denials from nginx, httpd_can_network_connect is off, setsebool -P turns it on and curl returns hello-from-upstream again" width="799" height="707"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Logs, updates and time
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Logs.&lt;/strong&gt; rsyslog runs on both, but writes different files. Rocky has &lt;code&gt;/var/log/messages&lt;/code&gt; and &lt;code&gt;/var/log/secure&lt;/code&gt;; Ubuntu has &lt;code&gt;/var/log/syslog&lt;/code&gt; and &lt;code&gt;/var/log/auth.log&lt;/code&gt;. The &lt;code&gt;grep sshd /var/log/auth.log&lt;/code&gt; reflex becomes &lt;code&gt;grep sshd /var/log/secure&lt;/code&gt;, or &lt;code&gt;journalctl -u sshd&lt;/code&gt; (the unit is &lt;code&gt;sshd&lt;/code&gt;, not &lt;code&gt;ssh&lt;/code&gt;). The journal was 16M on Rocky after boot against 8M on Ubuntu, and there is no &lt;code&gt;/etc/systemd/journald.conf&lt;/code&gt; on Rocky at all: the stock file lives at &lt;code&gt;/usr/lib/systemd/journald.conf&lt;/code&gt;, and your overrides go in &lt;code&gt;/etc/systemd/journald.conf.d/&lt;/code&gt;, which is exactly what the &lt;a href="https://peculiarengineer.com/blog/journald-disk-usage-retention-ubuntu-26-04/" rel="noopener noreferrer"&gt;journald retention post&lt;/a&gt; tells you to do anyway.&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%2Fht15onntw7xslzylzrf5.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%2Fht15onntw7xslzylzrf5.png" alt="Terminal, both boxes: on Rocky /var/log/messages and /var/log/secure exist and syslog and auth.log do not, rsyslog and chronyd are active and /etc/systemd/journald.conf does not exist; on Ubuntu the reverse, with rsyslog and chrony active and the journald.conf file present" width="800" height="734"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Updates.&lt;/strong&gt; Ubuntu ships &lt;code&gt;unattended-upgrades&lt;/code&gt; switched on. Rocky ships nothing. The equivalent is &lt;code&gt;dnf-automatic&lt;/code&gt;, and it is two decisions, not 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="nb"&gt;sudo &lt;/span&gt;dnf &lt;span class="nb"&gt;install &lt;/span&gt;dnf-automatic
&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; dnf-automatic.timer
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The package ships four timers. &lt;code&gt;dnf-automatic.timer&lt;/code&gt; reads &lt;code&gt;/etc/dnf/automatic.conf&lt;/code&gt;, where the defaults are &lt;code&gt;download_updates = yes&lt;/code&gt; and &lt;code&gt;apply_updates = no&lt;/code&gt;, so out of the box it downloads updates and installs nothing. Either set &lt;code&gt;apply_updates = yes&lt;/code&gt; in that file, or enable &lt;code&gt;dnf-automatic-install.timer&lt;/code&gt; instead, whose service runs &lt;code&gt;dnf-automatic --installupdates&lt;/code&gt; and installs regardless of the setting. There is no security only mode by default; &lt;code&gt;upgrade_type = security&lt;/code&gt; in the same file gives you one. After updates, &lt;code&gt;dnf needs-restarting -r&lt;/code&gt; says whether a reboot is due and &lt;code&gt;dnf needs-restarting&lt;/code&gt; lists processes running old libraries (empty on a fresh box, which is what I got). There is no &lt;code&gt;needrestart&lt;/code&gt;, so nothing prompts you after an upgrade the way Ubuntu's does.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Point releases.&lt;/strong&gt; The second Rocky box in the screenshot run booted as 10.1, because the provider's image had not been rebuilt since January, and &lt;code&gt;dnf upgrade&lt;/code&gt; took it to 10.2 in one go: 258 packages, a new kernel, reboot. That is the whole point release process, and it is the same command as a Tuesday's patching. It is also why that screenshot shows 10.1 before the upgrade.&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%2Fi2i33b16rtd2p0zvbzzo.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%2Fi2i33b16rtd2p0zvbzzo.png" alt="Terminal, Rocky and Ubuntu: dnf-automatic.timer is not-found until installed and apply_updates is no; rocky-release says 10.1, dnf check-update counts 258 packages, dnf upgrade completes, rocky-release says 10.2, and after a reboot uname shows the el10_2 kernel with getenforce Enforcing; on Ubuntu unattended-upgrades is enabled" width="800" height="911"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Time.&lt;/strong&gt; chrony on both, as noted. The only difference is which servers, and that is the image.&lt;/p&gt;

&lt;h2&gt;
  
  
  Python, Docker and disks: do the other posts hold
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Python.&lt;/strong&gt; Rocky 10 has Python 3.12, no pip, and no &lt;code&gt;EXTERNALLY-MANAGED&lt;/code&gt; marker. That last part changes the &lt;a href="https://peculiarengineer.com/blog/pip-externally-managed-environment-ubuntu-26-04/" rel="noopener noreferrer"&gt;pip post&lt;/a&gt; completely: after &lt;code&gt;dnf install python3-pip&lt;/code&gt;, &lt;code&gt;python3 -m pip install cowsay&lt;/code&gt; as root installed into &lt;code&gt;/usr/local/lib/python3.12/site-packages&lt;/code&gt; with only the usual "running as root" warning. (Red Hat patches pip to keep root installs out of &lt;code&gt;/usr/lib&lt;/code&gt;, where rpm's files live, which is a gentler fence than Debian's marker.) The lockout that post is about does not exist here. Use a venv anyway; &lt;code&gt;python3 -m venv&lt;/code&gt; works with no extra package and the venv has pip inside it, where the same command on the Ubuntu box fails until &lt;code&gt;python3.14-venv&lt;/code&gt; is installed. &lt;code&gt;pipx&lt;/code&gt;, &lt;code&gt;uv&lt;/code&gt; and &lt;code&gt;python3.13&lt;/code&gt; are all in EPEL.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Docker.&lt;/strong&gt; Not in Rocky's repos; &lt;code&gt;podman&lt;/code&gt; is, and is the distribution's answer. If you want Docker, Docker's own RHEL repo works on Rocky 10 unchanged:&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;dnf config-manager &lt;span class="nt"&gt;--add-repo&lt;/span&gt; https://download.docker.com/linux/rhel/docker-ce.repo
&lt;span class="nb"&gt;sudo &lt;/span&gt;dnf &lt;span class="nb"&gt;install &lt;/span&gt;docker-ce docker-ce-cli containerd.io
&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; docker
&lt;span class="nb"&gt;sudo &lt;/span&gt;docker run &lt;span class="nt"&gt;--rm&lt;/span&gt; hello-world
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That gave Docker 29.8.1 and &lt;code&gt;Hello from Docker!&lt;/code&gt;, with SELinux enforcing and firewalld running. Those four commands are the &lt;a href="https://peculiarengineer.com/blog/install-docker-ubuntu-26-04/" rel="noopener noreferrer"&gt;Ubuntu Docker post&lt;/a&gt;'s install steps with the repo URL and &lt;code&gt;dnf&lt;/code&gt; swapped in; I did not rerun the rest of that post here. &lt;code&gt;podman&lt;/code&gt; was not installed on the image either, despite being the house container tool.&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%2Fpidfa1awrqd8zykb2xth.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%2Fpidfa1awrqd8zykb2xth.png" alt="Terminal, both boxes: on Rocky neither EXTERNALLY-MANAGED path exists, python3-pip installs, python3 -m pip install cowsay reports Successfully installed cowsay-6.1, the Docker repo is added, docker-ce installs and hello-world prints Hello from Docker; on Ubuntu the EXTERNALLY-MANAGED file exists and aa-status reports 182 profiles loaded" width="800" height="602"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Disks.&lt;/strong&gt; &lt;code&gt;parted&lt;/code&gt;, &lt;code&gt;partprobe&lt;/code&gt;, &lt;code&gt;growpart&lt;/code&gt;, LVM, xfsprogs and e2fsprogs are all present. &lt;code&gt;sgdisk&lt;/code&gt; is not, so the &lt;a href="https://peculiarengineer.com/blog/add-disk-fstab-lvm-ubuntu-26-04/" rel="noopener noreferrer"&gt;add a disk post&lt;/a&gt; needs &lt;code&gt;dnf install gdisk&lt;/code&gt; first, or &lt;code&gt;parted&lt;/code&gt; for the partitioning step. One image note: the root filesystem on this Rocky image is ext4, where the Rocky installer's default (not tested here) is XFS. If you are reading a RHEL guide that assumes &lt;code&gt;xfs_growfs&lt;/code&gt;, check &lt;code&gt;findmnt -no FSTYPE /&lt;/code&gt; first.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rocky or Alma
&lt;/h2&gt;

&lt;p&gt;The two boxes differed in the &lt;code&gt;.repo&lt;/code&gt; file names, the vendor string in some package versions (&lt;code&gt;.rocky.0.1&lt;/code&gt; against &lt;code&gt;.alma.1&lt;/code&gt; on systemd and cloud-init, identical on the kernel), CRB enabled by default on Alma and disabled on Rocky, and nothing else my script could find. Same kernel build, same systemd, same package count, same cloud-init behaviour, same permissive SELinux. By their own descriptions, Rocky aims to be bug for bug identical to RHEL, and Alma aims for ABI compatibility and allows itself fixes and additions, of which the x86-64-v2 build is the visible one. For a homelab or a small fleet, pick by which community you would rather ask questions in. For an older CPU, Alma.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which one I would pick
&lt;/h2&gt;

&lt;p&gt;For a box that has to behave like RHEL, because the software vendor tests against it or the rest of the estate runs it, Rocky (or RHEL itself, if a checklist says the word), and I would set SELinux to enforcing on day one, before anything is installed, so every denial shows up while I am still watching. Ten years of patches is a real thing. Starting from a 6.12 kernel is the price.&lt;/p&gt;

&lt;p&gt;For everything else I run, Ubuntu or Debian, because the guides assume it and I would rather have this year's kernel. The one habit that genuinely needs relearning on Rocky is not dnf, which you will pick up in an afternoon. It is treating SELinux as part of every service you deploy, and this image lets you skip that until it hurts.&lt;/p&gt;

&lt;p&gt;The box at the end of the run, for the record: enforcing written to the config file, a reboot, and then sshd on both ports, firewalld running, and the proxy answering once the stand in app was started again, which the screenshot does not show. A bare &lt;code&gt;setsid&lt;/code&gt; process is not a unit, so nothing brings it back after a reboot.&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%2Fmmodrevmzykavtqhukj0.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%2Fmmodrevmzykavtqhukj0.png" alt="Terminal, Rocky: SELINUX=enforcing written to /etc/selinux/config, then after a reboot getenforce prints Enforcing, ss shows sshd on 22 and 2222, curl on 8080 returns hello-from-upstream and firewall-cmd --state is running" width="799" height="472"&gt;&lt;/a&gt;&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;SELinux is permissive on this cloud image&lt;/strong&gt;, on Rocky and Alma both. Everything works until you set enforcing, then the SSH port and the reverse proxy break with no config change. Run &lt;code&gt;ausearch -m avc -ts recent&lt;/code&gt; before you switch to see what will.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;Bind to port 2222 on 0.0.0.0 failed: Permission denied&lt;/code&gt;&lt;/strong&gt; from sshd, with an &lt;code&gt;unreserved_port_t&lt;/code&gt; denial in &lt;code&gt;ausearch&lt;/code&gt;, means the port has no &lt;code&gt;ssh_port_t&lt;/code&gt; label. &lt;code&gt;semanage port -a -t ssh_port_t -p tcp 2222&lt;/code&gt;, and &lt;code&gt;semanage&lt;/code&gt; needs &lt;code&gt;policycoreutils-python-utils&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;(13: Permission denied) while connecting to upstream&lt;/code&gt;&lt;/strong&gt; from nginx is not a file permission. Check &lt;code&gt;ausearch&lt;/code&gt; for a &lt;code&gt;name_connect&lt;/code&gt; denial from &lt;code&gt;comm="nginx"&lt;/code&gt;; if it is there, it is &lt;code&gt;httpd_can_network_connect&lt;/code&gt;, off by default. &lt;code&gt;setsebool -P httpd_can_network_connect on&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;firewalld is not on the cloud image.&lt;/strong&gt; No rules are active either. &lt;code&gt;dnf install firewalld&lt;/code&gt;, &lt;code&gt;systemctl enable --now firewalld&lt;/code&gt;, and remember &lt;code&gt;--permanent&lt;/code&gt; on every rule.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The pip lockout does not exist.&lt;/strong&gt; No &lt;code&gt;EXTERNALLY-MANAGED&lt;/code&gt; marker on Rocky 10, so &lt;code&gt;pip install&lt;/code&gt; as root writes into the system site-packages. Nothing stops you. Use a venv.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;dnf install htop&lt;/code&gt; fails on a fresh box.&lt;/strong&gt; So does fail2ban, and pipx, uv and python3.13 are in the same repo. &lt;code&gt;dnf install epel-release&lt;/code&gt; first.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The admin group is &lt;code&gt;wheel&lt;/code&gt;.&lt;/strong&gt; &lt;code&gt;usermod -aG sudo keith&lt;/code&gt; fails on Rocky with &lt;code&gt;group 'sudo' does not exist&lt;/code&gt;. &lt;code&gt;-G wheel&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;dnf check-update&lt;/code&gt; exits 100&lt;/strong&gt; when updates are available. Not an error.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Password logins are off&lt;/strong&gt; on this image (&lt;code&gt;50-cloud-init.conf&lt;/code&gt;). Create the user, then add a key.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;dnf-automatic&lt;/code&gt; installs nothing by default.&lt;/strong&gt; &lt;code&gt;apply_updates = no&lt;/code&gt;. Set it, or enable the &lt;code&gt;-install&lt;/code&gt; timer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No &lt;code&gt;/etc/systemd/journald.conf&lt;/code&gt;&lt;/strong&gt; on Rocky. Drop-ins under &lt;code&gt;journald.conf.d/&lt;/code&gt; still work.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;sgdisk&lt;/code&gt; is not installed.&lt;/strong&gt; &lt;code&gt;dnf install gdisk&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Docker is not in the repos, and podman is not on the image.&lt;/strong&gt; Docker's RHEL repo works on Rocky 10.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Quick reference: Ubuntu habit to Rocky equivalent
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Ubuntu habit&lt;/th&gt;
&lt;th&gt;On this Rocky Linux 10 image&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;sudo apt install x&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;sudo dnf install x&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;sudo apt update &amp;amp;&amp;amp; sudo apt upgrade&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;sudo dnf upgrade&lt;/code&gt; (dnf refreshes its own metadata when it is stale)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;apt search x&lt;/code&gt; / &lt;code&gt;apt show x&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;dnf search x&lt;/code&gt; / &lt;code&gt;dnf info x&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;apt-file search /bin/dig&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;dnf provides "*/dig"&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;sudo apt history-undo &amp;lt;ID&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;sudo dnf history undo &amp;lt;ID&amp;gt;&lt;/code&gt;, IDs from &lt;code&gt;dnf history list&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;sudo add-apt-repository ppa:x/y&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;sudo dnf install epel-release&lt;/code&gt;, or a &lt;code&gt;.repo&lt;/code&gt; file in &lt;code&gt;/etc/yum.repos.d/&lt;/code&gt;, or &lt;code&gt;dnf config-manager --add-repo &amp;lt;url&amp;gt;&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;apt install build-essential&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;dnf group install "Development Tools"&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;sudo usermod -aG sudo keith&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;sudo usermod -aG wheel keith&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;sudo netplan apply&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;nmcli con mod "&amp;lt;name&amp;gt;" ...&lt;/code&gt; then &lt;code&gt;nmcli con up "&amp;lt;name&amp;gt;"&lt;/code&gt;; keyfiles in &lt;code&gt;/etc/NetworkManager/system-connections/&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;resolvectl status&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;cat /etc/resolv.conf&lt;/code&gt;, NetworkManager writes it; &lt;code&gt;nmcli -g IP4.DNS dev show eth0&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;sudo ufw allow 22&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;sudo dnf install firewalld &amp;amp;&amp;amp; sudo systemctl enable --now firewalld&lt;/code&gt; first, then &lt;code&gt;sudo firewall-cmd --add-service=ssh --permanent &amp;amp;&amp;amp; sudo firewall-cmd --reload&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;sudo ufw status&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;sudo firewall-cmd --list-all&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;aa-status&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;getenforce&lt;/code&gt;, &lt;code&gt;sestatus&lt;/code&gt;; denials in &lt;code&gt;ausearch -m avc -ts recent&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Change &lt;code&gt;Port&lt;/code&gt; then &lt;code&gt;systemctl restart ssh&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;semanage port -a -t ssh_port_t -p tcp &amp;lt;port&amp;gt;&lt;/code&gt; first (&lt;code&gt;semanage&lt;/code&gt; is in &lt;code&gt;policycoreutils-python-utils&lt;/code&gt;), open the port in firewalld, then &lt;code&gt;systemctl restart sshd&lt;/code&gt; (no socket to restart)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;grep sshd /var/log/auth.log&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;grep sshd /var/log/secure&lt;/code&gt; or &lt;code&gt;journalctl -u sshd&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;tail -f /var/log/syslog&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;tail -f /var/log/messages&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;unattended-upgrades&lt;/code&gt; (on by default)&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;dnf install dnf-automatic&lt;/code&gt;, set &lt;code&gt;apply_updates = yes&lt;/code&gt;, &lt;code&gt;systemctl enable --now dnf-automatic.timer&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;sudo needrestart -r l&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;dnf needs-restarting -r&lt;/code&gt;, and &lt;code&gt;dnf needs-restarting&lt;/code&gt; for the service list&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;apt install python3-venv python3-pip&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;dnf install python3-pip&lt;/code&gt;; venv already works; no &lt;code&gt;EXTERNALLY-MANAGED&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;apt install docker.io&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;dnf install podman&lt;/code&gt;, or Docker's RHEL repo for &lt;code&gt;docker-ce&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;sudo sgdisk ...&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;dnf install gdisk&lt;/code&gt; first, or use &lt;code&gt;parted&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;snap install x&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;No &lt;code&gt;snap&lt;/code&gt; command on this image. EPEL, the project's repo, or a container.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;sudo do-release-upgrade&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;No equivalent command. Point releases (10.2 to 10.3) arrive through &lt;code&gt;dnf upgrade&lt;/code&gt;; a new major version is a reinstall, or a migration tool such as Alma's ELevate, following that release's notes. Not tested here.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;[ same systemd, same journal, and getenforce before you trust anything you just set up ]&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://peculiarengineer.com/blog/rocky-linux-10-for-ubuntu-people/" rel="noopener noreferrer"&gt;peculiarengineer.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>rockylinux</category>
      <category>linux</category>
      <category>ubuntu</category>
      <category>selinux</category>
    </item>
    <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>
  </channel>
</rss>
