<?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>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>
    <item>
      <title>Kubernetes, Part Three: GitOps with Argo CD, let Git drive your cluster</title>
      <dc:creator>Minor Keith</dc:creator>
      <pubDate>Wed, 05 Aug 2026 02:58:58 +0000</pubDate>
      <link>https://dev.to/peculiarengineer/kubernetes-part-three-gitops-with-argo-cd-let-git-drive-your-cluster-1lfe</link>
      <guid>https://dev.to/peculiarengineer/kubernetes-part-three-gitops-with-argo-cd-let-git-drive-your-cluster-1lfe</guid>
      <description>&lt;p&gt;In &lt;a href="https://peculiarengineer.com/blog/kubernetes-first-app-k3s-single-node/" rel="noopener noreferrer"&gt;Part One&lt;/a&gt; I deployed nginx on a single k3s node and killed a Pod to watch a replacement appear. In &lt;a href="https://peculiarengineer.com/blog/kubernetes-configmaps-secrets-k3s/" rel="noopener noreferrer"&gt;Part Two&lt;/a&gt; I pulled the config and the passwords out of the image. Both parts had the same weak spot, and it's the one nobody mentions when they teach you &lt;code&gt;kubectl&lt;/code&gt;: I was still standing at a terminal typing &lt;code&gt;kubectl apply&lt;/code&gt; at a cluster.&lt;/p&gt;

&lt;p&gt;That works fine until it doesn't. A month later nobody can tell you what's actually running, or who changed it, or what the cluster looked like before someone "just quickly fixed" something at 11pm. The YAML on your laptop and the YAML in the cluster quietly drift apart, and the only way to find out is to go and look.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://argo-cd.readthedocs.io" rel="noopener noreferrer"&gt;Argo CD&lt;/a&gt; fixes that by taking the terminal away from you. You put your manifests in Git, you tell Argo CD where that repo is, and from then on the repo is the truth. Change the cluster by hand and Argo CD changes it back. I ran the whole thing on the same 4GB box that already hosts my &lt;a href="https://peculiarengineer.com/blog/self-host-forgejo-tailscale-docker-compose/" rel="noopener noreferrer"&gt;Forgejo instance&lt;/a&gt;, and I'm writing it down because six separate things bit me and I'd rather not rediscover any of them.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt; Put your manifests in a Git repo &lt;strong&gt;first&lt;/strong&gt;. Install Argo CD with &lt;code&gt;kubectl apply --server-side&lt;/code&gt; (plain &lt;code&gt;apply&lt;/code&gt; fails on one oversized CRD). Create an &lt;code&gt;Application&lt;/code&gt; object that names three things: which repo, which folder, where it goes. Sync it once by hand and watch &lt;code&gt;OutOfSync&lt;/code&gt; become &lt;code&gt;Synced&lt;/code&gt;. Then set &lt;code&gt;syncPolicy.automated&lt;/code&gt; with &lt;code&gt;selfHeal: true&lt;/code&gt; and &lt;code&gt;prune: true&lt;/code&gt;, and try to fight it: scale a Deployment, delete a Service, swap an image. It puts all of them back within fifteen seconds. Deploy by pushing to Git, roll back with &lt;code&gt;git revert&lt;/code&gt;. And remember that &lt;code&gt;Synced&lt;/code&gt; means "matches Git", not "works".&lt;/p&gt;
&lt;/blockquote&gt;

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

&lt;p&gt;Part One's idea was that you declare the state you want and Kubernetes makes reality match it. Part Three is the same sentence moved one level out:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;You declare the state you want in Git. Argo CD makes the cluster match it.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Kubernetes watches its own objects. Argo CD watches your repo. That's the entire product, and every feature below is a variation on it.&lt;/p&gt;

&lt;p&gt;What you get for free is the part I didn't expect. Because every change to the cluster is now a commit, Git becomes your audit log, your rollback mechanism, and your review gate without you building any of those things. "Who scaled this to five replicas and why" stops being an unanswerable question.&lt;/p&gt;

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

&lt;p&gt;You need a working cluster and a &lt;code&gt;kubectl&lt;/code&gt; that reaches it without &lt;code&gt;sudo&lt;/code&gt;. That's Part One, Step 0, the &lt;code&gt;KUBECONFIG&lt;/code&gt; fix that catches everyone. You also need a GitHub account, and about forty minutes.&lt;/p&gt;

&lt;p&gt;Mine is a single k3s node, v1.36.2, on a 4GB Hetzner box in Falkenstein that was already running Forgejo in Docker. Argo CD cost me about 500MB across seven Pods, taking the box from 2.3GB free to 1.8GB free. It fits on a small machine comfortably. Nothing got OOM killed.&lt;/p&gt;

&lt;p&gt;One warning that matters if your box has a public IP like mine does. Argo CD ships with an &lt;code&gt;admin&lt;/code&gt; account and a bootstrap password, and Step 6 is where I make sure the web UI is reachable from my devices and from nowhere else. Don't skip it and don't reorder it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: the repo comes first
&lt;/h2&gt;

&lt;p&gt;This ordering is the whole philosophy in miniature, so do it in this order even though it feels backwards: the repo exists before Argo CD does.&lt;/p&gt;

&lt;p&gt;Create a new public repo. Mine is &lt;a href="https://github.com/peculiarengineer-mk/peculiarengineer-gitops" rel="noopener noreferrer"&gt;peculiarengineer-gitops&lt;/a&gt;, and it holds exactly two files under &lt;code&gt;apps/hello/&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Save the first as &lt;code&gt;apps/hello/deployment.yaml&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# A Deployment says "keep N copies of this container running, forever."&lt;/span&gt;
&lt;span class="c1"&gt;# Argo CD's job is to make sure this file and the cluster always agree.&lt;/span&gt;
&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;apps/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Deployment&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;hello&lt;/span&gt;
  &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;hello&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;replicas&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt; &lt;span class="c1"&gt;# change this number, push, and watch Argo CD notice&lt;/span&gt;
  &lt;span class="na"&gt;selector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;matchLabels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;hello&lt;/span&gt; &lt;span class="c1"&gt;# which Pods this Deployment owns&lt;/span&gt;
  &lt;span class="na"&gt;template&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;hello&lt;/span&gt; &lt;span class="c1"&gt;# must match the selector above, or nothing happens&lt;/span&gt;
    &lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;containers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;nginx&lt;/span&gt;
          &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;nginx:1.27&lt;/span&gt;
          &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;containerPort&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;80&lt;/span&gt;
          &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="c1"&gt;# Small box, so be explicit about what this is allowed to eat.&lt;/span&gt;
            &lt;span class="na"&gt;requests&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="na"&gt;cpu&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;10m&lt;/span&gt;
              &lt;span class="na"&gt;memory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;16Mi&lt;/span&gt;
            &lt;span class="na"&gt;limits&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="na"&gt;memory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;64Mi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the second as &lt;code&gt;apps/hello/service.yaml&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# A Service gives the Pods above one stable address inside the cluster.&lt;/span&gt;
&lt;span class="c1"&gt;# ClusterIP means "reachable from inside the cluster only", so nothing is&lt;/span&gt;
&lt;span class="c1"&gt;# published to the internet by this file.&lt;/span&gt;
&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Service&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;hello&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ClusterIP&lt;/span&gt;
  &lt;span class="na"&gt;selector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;hello&lt;/span&gt; &lt;span class="c1"&gt;# sends traffic to any Pod carrying this label&lt;/span&gt;
  &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;80&lt;/span&gt;
      &lt;span class="na"&gt;targetPort&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;80&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nothing new here. This is Part One's Deployment and Service with resource limits added, because a 4GB box deserves them. Commit and push both.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;💡 What happened:&lt;/strong&gt; You wrote down what you want running without running any of it. From here on, editing these files is how you change the cluster. That's the habit the rest of the post builds.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: install Argo CD
&lt;/h2&gt;

&lt;p&gt;Argo CD installs into the cluster as ordinary Kubernetes objects in its own namespace. No operator, no Helm required, no packages on the host.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;▶ Do:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl create namespace argocd
kubectl apply &lt;span class="nt"&gt;-n&lt;/span&gt; argocd &lt;span class="nt"&gt;-f&lt;/span&gt; https://raw.githubusercontent.com/argoproj/argo-cd/stable/manifests/install.yaml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;❌ And that fails.&lt;/strong&gt; Most of it applies, then right at the end:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;The CustomResourceDefinition "applicationsets.argoproj.io" is invalid:
metadata.annotations: Too long: may not be more than 262144 bytes
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This one confused me for a minute because the error blames the CRD, and the CRD is fine. &lt;code&gt;kubectl apply&lt;/code&gt; stashes a copy of the entire manifest it just applied into a &lt;code&gt;last-applied-configuration&lt;/code&gt; annotation so it can compute diffs later. The ApplicationSet CRD is bigger than the 256KB limit Kubernetes puts on annotations, so the copy can't be stored.&lt;/p&gt;

&lt;p&gt;Server side apply doesn't use that annotation at all. It hands the whole document to the API server and lets it track ownership properly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;▶ Do:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl apply &lt;span class="nt"&gt;-n&lt;/span&gt; argocd &lt;span class="nt"&gt;--server-side&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; https://raw.githubusercontent.com/argoproj/argo-cd/stable/manifests/install.yaml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;✅ You should see&lt;/strong&gt; three CRDs land:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;customresourcedefinition.apiextensions.k8s.io/applications.argoproj.io serverside-applied
customresourcedefinition.apiextensions.k8s.io/applicationsets.argoproj.io serverside-applied
customresourcedefinition.apiextensions.k8s.io/appprojects.argoproj.io serverside-applied
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now wait for the Pods. Seven of them come up, and on my box that took about forty seconds:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl &lt;span class="nb"&gt;wait&lt;/span&gt; &lt;span class="nt"&gt;--for&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;condition&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;Ready pods &lt;span class="nt"&gt;--all&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; argocd &lt;span class="nt"&gt;--timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;300s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;💡 What happened:&lt;/strong&gt; Argo CD is now a workload in your cluster like any other, watching for &lt;code&gt;Application&lt;/code&gt; objects that don't exist yet. Worth noticing that it installed by declaring a pile of YAML, which is the same trick it's about to do on your behalf.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: the CLI and the admin password
&lt;/h2&gt;

&lt;p&gt;Grab the CLI on the node. This is the Linux x86 build, so if you'd rather run it from a Mac or an ARM box, take the matching binary from the &lt;a href="https://github.com/argoproj/argo-cd/releases" rel="noopener noreferrer"&gt;releases page&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;▶ Do:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;curl &lt;span class="nt"&gt;-sSL&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /usr/local/bin/argocd https://github.com/argoproj/argo-cd/releases/latest/download/argocd-linux-amd64
&lt;span class="nb"&gt;sudo chmod&lt;/span&gt; +x /usr/local/bin/argocd
argocd version &lt;span class="nt"&gt;--client&lt;/span&gt; &lt;span class="nt"&gt;--short&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;✅ You should see&lt;/strong&gt; a version line and nothing else:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;argocd: v3.4.5+564b949
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The installer generates a random admin password and leaves it in a Secret. Reading it is a callback to Part Two, base64 and all:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl &lt;span class="nt"&gt;-n&lt;/span&gt; argocd get secret argocd-initial-admin-secret &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nv"&gt;jsonpath&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"{.data.password}"&lt;/span&gt; | &lt;span class="nb"&gt;base64&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same reminder as Part Two: base64 is encoding, not encryption. You just decoded a password with a pipe. Step 12 replaces this one properly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: the Application, and the moment it clicks
&lt;/h2&gt;

&lt;p&gt;This is the object the whole product hangs off, and it's smaller than you'd think. An &lt;code&gt;Application&lt;/code&gt; answers three questions and nothing else. Which repo. Which folder inside it. Where the result goes.&lt;/p&gt;

&lt;p&gt;You can create it with &lt;code&gt;argocd app create&lt;/code&gt;, but write the YAML instead. Seeing the object is what makes the idea stick. Save it as &lt;code&gt;hello-app.yaml&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;argoproj.io/v1alpha1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Application&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;hello&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;argocd&lt;/span&gt; &lt;span class="c1"&gt;# Applications live in the argocd namespace&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;project&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;default&lt;/span&gt;
  &lt;span class="na"&gt;source&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;repoURL&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https://github.com/peculiarengineer-mk/peculiarengineer-gitops.git&lt;/span&gt;
    &lt;span class="na"&gt;targetRevision&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;main&lt;/span&gt; &lt;span class="c1"&gt;# which branch to track&lt;/span&gt;
    &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;apps/hello&lt;/span&gt; &lt;span class="c1"&gt;# which folder in the repo to apply&lt;/span&gt;
  &lt;span class="na"&gt;destination&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;server&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https://kubernetes.default.svc&lt;/span&gt; &lt;span class="c1"&gt;# this same cluster&lt;/span&gt;
    &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;default&lt;/span&gt; &lt;span class="c1"&gt;# where the manifests land&lt;/span&gt;
  &lt;span class="c1"&gt;# No syncPolicy yet. This first one syncs only when we tell it to.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Swap in your own repo URL. Leaving &lt;code&gt;syncPolicy&lt;/code&gt; out is deliberate, and it's the best decision in this whole tutorial, because it lets you see the next bit.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;▶ Do:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl apply &lt;span class="nt"&gt;-f&lt;/span&gt; hello-app.yaml
&lt;span class="nb"&gt;sleep &lt;/span&gt;5
kubectl get application hello &lt;span class="nt"&gt;-n&lt;/span&gt; argocd &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-o&lt;/span&gt; custom-columns&lt;span class="o"&gt;=&lt;/span&gt;NAME:.metadata.name,SYNC:.status.sync.status,HEALTH:.status.health.status
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;✅ You should see&lt;/strong&gt; two words that are the entire pitch:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;NAME    SYNC        HEALTH
hello   OutOfSync   Missing
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;💡 What happened, and this is the paragraph I'd underline.&lt;/strong&gt; Nobody asked it to do that. In the five seconds since you created the object, Argo CD cloned your repo, read &lt;code&gt;apps/hello&lt;/code&gt;, compared both files against the live cluster, and reported the gap: &lt;code&gt;OutOfSync&lt;/code&gt; means Git and the cluster disagree, &lt;code&gt;Missing&lt;/code&gt; means those objects don't exist yet. It will keep doing that comparison forever, whether or not you ever sync anything.&lt;/p&gt;

&lt;p&gt;The sync is almost an afterthought. The &lt;strong&gt;diff&lt;/strong&gt; is what you're actually buying.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: the first sync
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;argocd&lt;/code&gt; CLI has two modes and they behave differently, which caused me the second gotcha. &lt;code&gt;--core&lt;/code&gt; talks straight to the Kubernetes API with no Argo CD server in the middle. It's the quickest way to work from the node.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;▶ Do:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;argocd app &lt;span class="nb"&gt;sync &lt;/span&gt;hello &lt;span class="nt"&gt;--core&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;❌ And it fails&lt;/strong&gt; with a message that is actively misleading:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;{"level":"fatal","msg":"configmap \"argocd-cm\" not found"}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Go and look and &lt;code&gt;argocd-cm&lt;/code&gt; is sitting right there in the &lt;code&gt;argocd&lt;/code&gt; namespace. The problem is that core mode has no server session telling it where Argo CD lives, so it reads the namespace from your &lt;strong&gt;kubeconfig context&lt;/strong&gt;. On k3s that context has no namespace set, so it looks in &lt;code&gt;default&lt;/code&gt; and finds nothing.&lt;/p&gt;

&lt;p&gt;Setting &lt;code&gt;ARGOCD_NAMESPACE&lt;/code&gt; does not help. I tried, twice.&lt;/p&gt;

&lt;p&gt;The obvious fix is &lt;code&gt;kubectl config set-context --current --namespace=argocd&lt;/code&gt;, and that's the third gotcha, because it poisons every &lt;code&gt;kubectl&lt;/code&gt; command you run on that box afterwards. Suddenly &lt;code&gt;kubectl get pods&lt;/code&gt; shows you Argo CD's internals instead of your app, forever, and you will not remember why. Use a separate kubeconfig for the &lt;code&gt;argocd&lt;/code&gt; CLI instead.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;▶ Do:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cp&lt;/span&gt; ~/.kube/config ~/.kube/argocd-core.yaml
&lt;span class="nv"&gt;KUBECONFIG&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;~/.kube/argocd-core.yaml kubectl config set-context &lt;span class="nt"&gt;--current&lt;/span&gt; &lt;span class="nt"&gt;--namespace&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;argocd
&lt;span class="nv"&gt;KUBECONFIG&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;~/.kube/argocd-core.yaml argocd app &lt;span class="nb"&gt;sync &lt;/span&gt;hello &lt;span class="nt"&gt;--core&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That copies the kubeconfig Part One had you set up, so you're not going back to the root owned &lt;code&gt;/etc/rancher/k3s/k3s.yaml&lt;/code&gt; that Step 0 told you to stop touching.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;✅ You should see:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Sync Status:        Synced to main (d1395f1)
Phase:              Succeeded
Message:            successfully synced (all tasks run)

GROUP  KIND        NAMESPACE  NAME   STATUS  HEALTH       MESSAGE
       Service     default    hello  Synced  Healthy      service/hello created
apps   Deployment  default    hello  Synced  Progressing  deployment.apps/hello created
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Check what landed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl get deploy,pods &lt;span class="nt"&gt;-l&lt;/span&gt; &lt;span class="nv"&gt;app&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;hello &lt;span class="nt"&gt;-n&lt;/span&gt; default
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;deployment.apps/hello   2/2   2   2   22s
pod/hello-5d6b7fbfc4-f6kxf   1/1   Running   0   23s
pod/hello-5d6b7fbfc4-p4x64   1/1   Running   0   23s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;KUBECONFIG=&lt;/code&gt; prefix is needed on &lt;strong&gt;every&lt;/strong&gt; &lt;code&gt;argocd&lt;/code&gt; command from here on, and typing it each time gets old fast. Set an alias for the rest of the session:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;alias &lt;/span&gt;&lt;span class="nv"&gt;argocd&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'KUBECONFIG=~/.kube/argocd-core.yaml argocd'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every &lt;code&gt;argocd&lt;/code&gt; command below assumes you did that. If one of them ever comes back with &lt;code&gt;configmap "argocd-cm" not found&lt;/code&gt;, you're in a new shell and the alias is gone.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;💡 What happened:&lt;/strong&gt; You never ran &lt;code&gt;kubectl apply&lt;/code&gt; on those manifests. Argo CD read GitHub and created both objects itself. That's a small thing on two files and a very large thing on two hundred.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 6: reach the UI without publishing it to the internet
&lt;/h2&gt;

&lt;p&gt;The CLI does everything, but the web UI is genuinely good and the resource tree is the bit that makes Kubernetes legible to people who don't live in it.&lt;/p&gt;

&lt;p&gt;Here's the trap. A k3s NodePort binds to &lt;code&gt;0.0.0.0&lt;/code&gt;, which on a box with a public IP means the open internet. Argo CD has a known admin username and a bootstrap password. So the firewall goes on &lt;strong&gt;first&lt;/strong&gt; and the service gets exposed &lt;strong&gt;second&lt;/strong&gt;. If you get that order backwards you spend a few minutes with an unauthenticated door open, and that's plenty.&lt;/p&gt;

&lt;p&gt;My box wasn't on my tailnet at all. Only the Forgejo container was, through its sidecar. So the host joins properly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-fsSL&lt;/span&gt; https://tailscale.com/install.sh | sh
tailscale up &lt;span class="nt"&gt;--hostname&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;k3s-1 &lt;span class="nt"&gt;--accept-dns&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;false&lt;/span&gt; &lt;span class="nt"&gt;--accept-routes&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;false&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both of those flags are deliberate. &lt;code&gt;--accept-dns=false&lt;/code&gt; stops Tailscale rewriting &lt;code&gt;/etc/resolv.conf&lt;/code&gt;, and &lt;code&gt;--accept-routes=false&lt;/code&gt; stops it touching the routing table. On a box already running k3s and Docker I want Tailscale to add an interface and change nothing else.&lt;/p&gt;

&lt;p&gt;Now the firewall, and here's the fourth gotcha, which is the one that can genuinely take your services down. Ubuntu ships this in &lt;code&gt;/etc/default/ufw&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

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

&lt;/div&gt;



&lt;p&gt;Enable ufw with that set and you break pod to pod networking in k3s &lt;strong&gt;and&lt;/strong&gt; Docker's bridge. On my box that would have taken Forgejo down as collateral. Fix it before you enable anything:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sed&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="s1"&gt;'s/^DEFAULT_FORWARD_POLICY=.*/DEFAULT_FORWARD_POLICY="ACCEPT"/'&lt;/span&gt; /etc/default/ufw
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Before you turn the firewall on, arm a dead man switch. Enabling a firewall over SSH on a box whose only access is SSH is how people lose servers, and a Hetzner rescue console at midnight is a bad time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;▶ Do:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;setsid &lt;span class="nb"&gt;nohup &lt;/span&gt;bash &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"sleep 300; ufw --force disable"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null 2&amp;gt;&amp;amp;1 &amp;lt; /dev/null &amp;amp;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="nv"&gt;$!&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /root/ufw-deadman.pid
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That undoes the firewall in five minutes unless you cancel it. Now the rules. SSH, the tailnet, and the k3s internal networks:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ufw default deny incoming
ufw default allow outgoing
ufw allow 22/tcp
ufw allow &lt;span class="k"&gt;in &lt;/span&gt;on tailscale0
ufw allow 41641/udp        &lt;span class="c"&gt;# tailscale wireguard&lt;/span&gt;
ufw allow &lt;span class="k"&gt;in &lt;/span&gt;on cni0       &lt;span class="c"&gt;# k3s pods&lt;/span&gt;
ufw allow &lt;span class="k"&gt;in &lt;/span&gt;on flannel.1
ufw allow &lt;span class="k"&gt;in &lt;/span&gt;on docker0
ufw allow from 10.42.0.0/16   &lt;span class="c"&gt;# k3s pod cidr&lt;/span&gt;
ufw allow from 10.43.0.0/16   &lt;span class="c"&gt;# k3s service cidr&lt;/span&gt;
ufw &lt;span class="nt"&gt;--force&lt;/span&gt; &lt;span class="nb"&gt;enable&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Open a second SSH session and confirm you can still get in. Check your cluster and anything else on the box is still healthy. Then cancel the timer using the PID file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;kill&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; /root/ufw-deadman.pid&lt;span class="si"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Use the PID file, not &lt;code&gt;pkill -f "sleep 300"&lt;/code&gt;. I tried the &lt;code&gt;pkill&lt;/code&gt; version and it killed my own shell, because the command line running that &lt;code&gt;pkill&lt;/code&gt; contains the string &lt;code&gt;sleep 300&lt;/code&gt; and therefore matches itself. Exit code 255, dropped connection, and a few seconds of wondering whether I'd just locked myself out.&lt;/p&gt;

&lt;p&gt;Now expose the UI:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl &lt;span class="nt"&gt;-n&lt;/span&gt; argocd patch svc argocd-server &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="s1"&gt;'{"spec":{"type":"NodePort","ports":[
  {"name":"http","port":80,"targetPort":8080,"protocol":"TCP"},
  {"name":"https","port":443,"targetPort":8080,"protocol":"TCP","nodePort":30443}]}}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you go looking for a listening socket you won't find one, and that threw me for a second:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ss &lt;span class="nt"&gt;-tlnp&lt;/span&gt; | &lt;span class="nb"&gt;grep &lt;/span&gt;30443    &lt;span class="c"&gt;# returns nothing, and that's correct&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;kube-proxy implements NodePorts with iptables rules, not a bound socket. The test that actually matters is reaching it from two places:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://100.87.x.y:30443  -&amp;gt;  HTTP 200     # over the tailnet
https://203.0.113.88:30443  -&amp;gt;  HTTP 000     # public IP, blocked
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;💡 What happened:&lt;/strong&gt; The UI is reachable from every device on my tailnet, including the iPad, and invisible from everywhere else. The cert is self signed so your browser will complain once. Log in as &lt;code&gt;admin&lt;/code&gt; with the password from Step 3, click into the &lt;code&gt;hello&lt;/code&gt; app, and you get the resource tree: Application, Deployment, ReplicaSet, Pods, each with a health dot. That view is why people keep the UI around.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 7: break the cluster on purpose
&lt;/h2&gt;

&lt;p&gt;Now the interesting part. Do the exact thing GitOps is supposed to prevent.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;▶ Do:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl scale deployment hello &lt;span class="nt"&gt;-n&lt;/span&gt; default &lt;span class="nt"&gt;--replicas&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;5
&lt;span class="nb"&gt;sleep &lt;/span&gt;15
kubectl get application hello &lt;span class="nt"&gt;-n&lt;/span&gt; argocd &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-o&lt;/span&gt; custom-columns&lt;span class="o"&gt;=&lt;/span&gt;SYNC:.status.sync.status,HEALTH:.status.health.status
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;✅ You should see:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;SYNC        HEALTH
OutOfSync   Healthy
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Five Pods running, and Argo CD spotted it within seconds and did &lt;strong&gt;nothing at all&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;That's not a bug, it's the lesson. Detecting drift and correcting drift are separate features, and right now you only have the first one. Seeing Argo CD notice and deliberately sit on its hands is what makes the next step land.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;Healthy&lt;/code&gt; sitting next to &lt;code&gt;OutOfSync&lt;/code&gt; is worth its own thought too. Health and sync are different axes. Health means the five Pods are fine. Sync means this matches Git. An app can be in perfect health and completely wrong.&lt;/p&gt;

&lt;p&gt;Ask what's wrong and it tells you exactly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;argocd app diff hello &lt;span class="nt"&gt;--core&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;===== apps/Deployment default/hello ======
120c120
&amp;lt;   replicas: 5
---
&amp;gt;   replicas: 2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;&amp;lt;&lt;/code&gt; is the live cluster, &lt;code&gt;&amp;gt;&lt;/code&gt; is Git. Four lines that answer "how does reality differ from what I said I wanted". You can run that against anything, at any time, and get a real answer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 8: turn on self-heal and try to win
&lt;/h2&gt;

&lt;p&gt;Three settings. &lt;code&gt;selfHeal&lt;/code&gt; corrects drift, &lt;code&gt;prune&lt;/code&gt; deletes objects whose files vanish from the repo, and &lt;code&gt;automated&lt;/code&gt; means it acts without being asked.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;▶ Do:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl &lt;span class="nt"&gt;-n&lt;/span&gt; argocd patch application hello &lt;span class="nt"&gt;--type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;merge &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="s1"&gt;'{
  "spec": {"syncPolicy": {"automated": {"prune": true, "selfHeal": true}}}
}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then watch, without running any sync command:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; 5s  OutOfSync Healthy   replicas=5  pods=5
10s  Synced Healthy      replicas=2  pods=2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Five Pods became two on their own.&lt;/strong&gt; I changed a policy, not the cluster, and Argo CD dragged reality back to what the repo says. For my money this is the moment the whole idea clicks, more than the first sync was.&lt;/p&gt;

&lt;p&gt;Now try to win. I made three attempts and lost all three inside fifteen seconds:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What I did&lt;/th&gt;
&lt;th&gt;What happened&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;kubectl scale --replicas=4&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;back to 2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;kubectl delete svc hello&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Service recreated&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;kubectl set image nginx=nginx:1.25&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;back to &lt;code&gt;nginx:1.27&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;💡 What happened, plus the fifth gotcha.&lt;/strong&gt; Look closely at that recreated Service and it has a &lt;strong&gt;different ClusterIP&lt;/strong&gt; and an age of fifteen seconds. Argo CD did not restore the object you deleted. It created a new one that matches the file. Anything that cached the old address is now talking to nothing. Self-heal keeps your cluster matching your repo, and that is not the same as a backup. Don't let it talk you out of having real backups.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 9: deploy by pushing to Git
&lt;/h2&gt;

&lt;p&gt;The whole point. Edit the repo, not the cluster.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;▶ Do&lt;/strong&gt; change &lt;code&gt;replicas: 2&lt;/code&gt; to &lt;code&gt;replicas: 3&lt;/code&gt; in &lt;code&gt;apps/hello/deployment.yaml&lt;/code&gt;, then:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git commit &lt;span class="nt"&gt;-am&lt;/span&gt; &lt;span class="s2"&gt;"Scale hello to 3 replicas"&lt;/span&gt;
git push
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now touch nothing and watch:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  0s  revision=d1395f1  replicas=2
...
124s  revision=d1395f1  replicas=2
136s  revision=e6c57cb  replicas=3
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;136 seconds.&lt;/strong&gt; That isn't Argo CD being slow, it's the default polling interval. With no webhook configured it asks GitHub "anything new?" every three minutes. In production you point a repo webhook at it and deployments become instant. While you're writing a tutorial and don't want to wait, force it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;argocd app get hello &lt;span class="nt"&gt;--hard-refresh&lt;/span&gt; &lt;span class="nt"&gt;--core&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;💡 What happened:&lt;/strong&gt; Check the Pod ages afterwards and you get 28m, 28m, 2s. It scaled up. It did not redeploy. Argo CD works out the difference between the repo and the cluster and applies only that, so a one line change doesn't churn your running workload.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 10: delete a file, delete the object
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;prune&lt;/code&gt; is the setting that decides whether removing a file removes the thing it described. I tested it with something disposable.&lt;/p&gt;

&lt;p&gt;Add &lt;code&gt;apps/hello/temp-configmap.yaml&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ConfigMap&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;hello-temp&lt;/span&gt;
&lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;note&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;delete&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;this&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;file&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;from&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;git&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;and&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;prune&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;should&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;remove&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;the&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;object'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Commit, push, hard refresh, and the ConfigMap appears in the cluster. Now &lt;code&gt;git rm&lt;/code&gt; the file, commit, push, hard refresh again:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl get configmap hello-temp &lt;span class="nt"&gt;-n&lt;/span&gt; default
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Error from server (NotFound): configmaps "hello-temp" not found
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;💡 What happened:&lt;/strong&gt; Deleting a file deleted the object. Worth knowing that &lt;code&gt;prune: false&lt;/code&gt; is the default, and with it off Argo CD will happily add and update things but never remove them. That sounds safer and mostly it is, but you end up with orphaned objects in your cluster that no file in any repo describes and nobody remembers creating. Turn prune on early, while your cluster is small enough that a mistake is obvious.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 11: ship something broken, then roll it back
&lt;/h2&gt;

&lt;p&gt;This is the most important section in the post, so I deliberately shipped a release that could not possibly work. In &lt;code&gt;deployment.yaml&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;nginx:9.9.9-doesnotexist&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Commit, push, refresh, wait thirty seconds:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;=== app health ===
Synced   Progressing

=== pods ===
hello-5d6b7fbfc4-f6kxf 1/1 Running       32m
hello-5d6b7fbfc4-p4x64 1/1 Running       32m
hello-5d6b7fbfc4-vzq7x 1/1 Running       6m
hello-6d96c85587-lgxml 0/1 ErrImagePull  31s

=== is the site still up? ===
HTTP 200
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read that first line again. &lt;strong&gt;&lt;code&gt;Synced&lt;/code&gt;, and completely broken.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;Synced&lt;/code&gt; means the cluster matches Git. It does not mean the app works. I put something wrong in the repo, so Argo CD faithfully made the cluster wrong. GitOps guarantees fidelity to your repo, not correctness, and if you remember one sentence from this post make it that one. Your repo is now the thing that needs code review, because it's the thing that ships.&lt;/p&gt;

&lt;p&gt;The site stayed up for a reason worth knowing: Kubernetes' rolling update will not kill a healthy Pod until its replacement reports Ready, so three good Pods kept serving while the fourth failed to pull. It also sits in &lt;code&gt;Progressing&lt;/code&gt; for a full ten minutes before it admits to being &lt;code&gt;Degraded&lt;/code&gt;, which is &lt;code&gt;progressDeadlineSeconds&lt;/code&gt; defaulting to 600 rather than Argo CD dragging its feet. I stared at &lt;code&gt;Progressing&lt;/code&gt; for a while assuming something was stuck.&lt;/p&gt;

&lt;p&gt;Now the fix, and notice what the fix &lt;em&gt;is&lt;/em&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git revert &lt;span class="nt"&gt;--no-edit&lt;/span&gt; &amp;lt;bad-sha&amp;gt;
git push
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Synced   Healthy
image: nginx:1.27
HTTP 200
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pod ages afterwards: 32m, 32m, 6m. &lt;strong&gt;Not one Pod died.&lt;/strong&gt; The bad release never took hold, the old Pods served throughout, and recovery was a normal commit.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;💡 What happened:&lt;/strong&gt; Your rollback procedure is the same command as every other change. No special runbook, no "quick fix" applied straight to prod that nobody writes down. The revert is right there in the log next to the mistake it undoes.&lt;/p&gt;

&lt;p&gt;Small thing that cost me a minute: &lt;code&gt;git revert -q&lt;/code&gt; is not a flag. It quietly prints the usage text and reverts nothing, and if you're not reading closely you'll push an empty change and wonder why nothing recovered.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 12: rotate that admin password
&lt;/h2&gt;

&lt;p&gt;The bootstrap password from Step 3 is meant to be temporary. The documented command is &lt;code&gt;argocd account update-password&lt;/code&gt;, and here's the sixth gotcha:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ argocd account update-password --account admin ... --core
failed to get issue time: unable to extract token claims
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Core mode has no server session, so there's no token to authenticate a password change. You either log in through the API server properly, or you write the hash yourself:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;NEW&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"pick-something-better-than-this"&lt;/span&gt;
&lt;span class="nv"&gt;HASH&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;argocd account bcrypt &lt;span class="nt"&gt;--password&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$NEW&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
kubectl &lt;span class="nt"&gt;-n&lt;/span&gt; argocd patch secret argocd-secret &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="s2"&gt;"{&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;stringData&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;:{&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;admin.password&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="nv"&gt;$HASH&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;,&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;admin.passwordMtime&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%FT%T%Z&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;}}"&lt;/span&gt;
kubectl &lt;span class="nt"&gt;-n&lt;/span&gt; argocd rollout restart deployment argocd-server
kubectl &lt;span class="nt"&gt;-n&lt;/span&gt; argocd delete secret argocd-initial-admin-secret
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Confirm both directions. The old password should be refused:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ argocd login &amp;lt;host&amp;gt;:30443 --username admin --password "&amp;lt;old&amp;gt;" --insecure --grpc-web
Invalid username or password

$ argocd login &amp;lt;host&amp;gt;:30443 --username admin --password "&amp;lt;new&amp;gt;" --insecure --grpc-web
'admin:login' logged in successfully
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Worth understanding that those two CLI modes really are different tools wearing the same name. &lt;code&gt;--core&lt;/code&gt; skips the server and talks to the Kubernetes API, which is fast and needs no login but can't do anything that depends on a session. &lt;code&gt;--server &amp;lt;host&amp;gt; --grpc-web&lt;/code&gt; goes through argocd-server with a real login, which is what you want for anything touching accounts, tokens, or RBAC.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Plain &lt;code&gt;kubectl apply&lt;/code&gt; fails on the install manifest.&lt;/strong&gt; The ApplicationSet CRD exceeds the 256KB annotation limit that &lt;code&gt;apply&lt;/code&gt; needs for its &lt;code&gt;last-applied-configuration&lt;/code&gt; copy. Use &lt;code&gt;--server-side&lt;/code&gt;. The error blames the CRD, which sends you looking in the wrong place.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;argocd --core&lt;/code&gt; reads its namespace from the kubeconfig context.&lt;/strong&gt; Not from &lt;code&gt;ARGOCD_NAMESPACE&lt;/code&gt;, which does nothing. On k3s the context has no namespace, so you get &lt;code&gt;configmap "argocd-cm" not found&lt;/code&gt; while the ConfigMap is plainly sitting in the &lt;code&gt;argocd&lt;/code&gt; namespace.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fixing that by repointing your main kubeconfig poisons everything else.&lt;/strong&gt; &lt;code&gt;kubectl config set-context --current --namespace=argocd&lt;/code&gt; means every later &lt;code&gt;kubectl get pods&lt;/code&gt; on that box shows Argo CD internals. Keep a separate kubeconfig for the &lt;code&gt;argocd&lt;/code&gt; CLI.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;DEFAULT_FORWARD_POLICY="DROP"&lt;/code&gt; breaks k3s and Docker when you enable ufw.&lt;/strong&gt; Set it to &lt;code&gt;ACCEPT&lt;/code&gt; in &lt;code&gt;/etc/default/ufw&lt;/code&gt; first, and allow the pod and service CIDRs. Skip this and you take down pod networking and every container on the box at the same time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Self-heal is not a backup.&lt;/strong&gt; Delete a Service and Argo CD creates a new one matching the file, with a new ClusterIP. It restores the description, not the object. Anything holding the old address is now pointing at nothing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;argocd account update-password&lt;/code&gt; does not work in core mode.&lt;/strong&gt; No session, no token, no password change. Patch the bcrypt hash into &lt;code&gt;argocd-secret&lt;/code&gt; instead.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;Synced&lt;/code&gt; does not mean working.&lt;/strong&gt; It means the cluster matches Git. Put a broken image tag in the repo and you get a proudly &lt;code&gt;Synced&lt;/code&gt; broken app.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't cancel a background timer with &lt;code&gt;pkill -f "sleep 300"&lt;/code&gt;.&lt;/strong&gt; The shell running that command contains the string, matches itself, and kills your session. Use a PID file.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Goal&lt;/th&gt;
&lt;th&gt;Command&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Install Argo CD&lt;/td&gt;
&lt;td&gt;&lt;code&gt;kubectl apply -n argocd --server-side -f &amp;lt;install.yaml&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Read bootstrap password&lt;/td&gt;
&lt;td&gt;`kubectl -n argocd get secret argocd-initial-admin-secret -o jsonpath="{.data.password}" \&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;App status&lt;/td&gt;
&lt;td&gt;{% raw %}&lt;code&gt;kubectl get application &amp;lt;app&amp;gt; -n argocd&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;See Git versus cluster&lt;/td&gt;
&lt;td&gt;&lt;code&gt;argocd app diff &amp;lt;app&amp;gt; --core&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sync now&lt;/td&gt;
&lt;td&gt;&lt;code&gt;argocd app sync &amp;lt;app&amp;gt; --core&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Skip the 3 minute poll&lt;/td&gt;
&lt;td&gt;&lt;code&gt;argocd app get &amp;lt;app&amp;gt; --hard-refresh --core&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;List apps through the server&lt;/td&gt;
&lt;td&gt;&lt;code&gt;argocd app list --server &amp;lt;host&amp;gt;:30443 --insecure --grpc-web&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Turn on self-heal and prune&lt;/td&gt;
&lt;td&gt;patch &lt;code&gt;spec.syncPolicy.automated&lt;/code&gt; with &lt;code&gt;selfHeal&lt;/code&gt; and &lt;code&gt;prune&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Roll back&lt;/td&gt;
&lt;td&gt;&lt;code&gt;git revert --no-edit &amp;lt;sha&amp;gt; &amp;amp;&amp;amp; git push&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Where this series goes next
&lt;/h2&gt;

&lt;p&gt;You now have a cluster that argues with you, which is the correct behaviour. Change something by hand and it changes it back. Deploy by pushing a commit. Roll back with &lt;code&gt;git revert&lt;/code&gt; and lose nothing.&lt;/p&gt;

&lt;p&gt;There's one loose thread I left deliberately, and it's the obvious next part. That &lt;code&gt;hello-app.yaml&lt;/code&gt; from Step 4 is still sitting on the node, applied by hand with &lt;code&gt;kubectl&lt;/code&gt;. The thing driving all my GitOps is itself not under GitOps, which is a slightly embarrassing place to stop. The fix has a good name, app of apps, and it answers the question of who deploys the deployer.&lt;/p&gt;

&lt;p&gt;After that: repo webhooks to kill the three minute poll, private repo authentication so this can point at my own Forgejo rather than GitHub, and Helm and Kustomize as sources instead of plain YAML. The &lt;a href="https://peculiarengineer.com/blog/kubernetes-series/" rel="noopener noreferrer"&gt;Kubernetes series hub&lt;/a&gt; tracks the lot, and Labels, Volumes, health probes and Ingress are all still on the list.&lt;/p&gt;

&lt;p&gt;Go and scale something by hand, then watch it change back while you're still looking at it. That's the bit that made it real for me. &lt;code&gt;[ synced ]&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://peculiarengineer.com/blog/gitops-argocd-k3s/" rel="noopener noreferrer"&gt;peculiarengineer.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>gitops</category>
      <category>devops</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Self-host Forgejo on your tailnet with Docker Compose and Tailscale</title>
      <dc:creator>Minor Keith</dc:creator>
      <pubDate>Thu, 30 Jul 2026 02:53:00 +0000</pubDate>
      <link>https://dev.to/peculiarengineer/self-host-forgejo-on-your-tailnet-with-docker-compose-and-tailscale-1f2d</link>
      <guid>https://dev.to/peculiarengineer/self-host-forgejo-on-your-tailnet-with-docker-compose-and-tailscale-1f2d</guid>
      <description>&lt;p&gt;You want your own Git host. Maybe you're getting off GitHub, maybe you just want somewhere private to keep the repos that shouldn't be on someone else's servers. So you go looking, and every guide hands you the same shape: a public DNS record, ports 80 and 443 open to the entire internet, a reverse proxy, a certificate, and a login page that anyone on earth can now knock on.&lt;/p&gt;

&lt;p&gt;For a private code host, that's a strange trade. Nothing about "my repos, for me and three collaborators" requires a public address. If the only people who should reach it are people you already trust, put it on your tailnet and the whole category of internet facing problems stops existing. No open ports, and no login page getting scanned at three in the morning.&lt;/p&gt;

&lt;p&gt;The Compose file for this is short. What makes it worth writing down is the sidecar pattern it uses, which is not obvious the first time, and a handful of settings that decide whether it works at all. I built this from nothing on a fresh Ubuntu 26.04 box to check it, and the settings that went wrong were not the ones I expected, so those get their own section at the end.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR.&lt;/strong&gt; Run Tailscale as its own container and give Forgejo &lt;code&gt;network_mode: service:ts-forgejo&lt;/code&gt; so Forgejo has no published ports and its own tailnet identity. Point &lt;code&gt;TS_SERVE_CONFIG&lt;/code&gt; at a serve JSON file and you get real HTTPS on &lt;code&gt;forgejo.your-tailnet.ts.net&lt;/code&gt; with no port 80 and no HTTP-01 challenge. Persist &lt;code&gt;/var/lib/tailscale&lt;/code&gt; or every restart creates a brand new node. Tag the device so its node key never expires. Do not set &lt;code&gt;START_SSH_SERVER&lt;/code&gt;, because the image already runs sshd on port 22.&lt;/p&gt;
&lt;/blockquote&gt;

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

&lt;ul&gt;
&lt;li&gt;A box with &lt;a href="https://peculiarengineer.com/blog/install-docker-ubuntu-26-04/" rel="noopener noreferrer"&gt;Docker installed&lt;/a&gt;. Mine runs Ubuntu 26.04, but nothing here is version specific.&lt;/li&gt;
&lt;li&gt;A Tailscale account and a tailnet you can already log into. If you're starting cold, &lt;a href="https://peculiarengineer.com/blog/install-tailscale-ubuntu-26-04/" rel="noopener noreferrer"&gt;install Tailscale on the host first&lt;/a&gt; so you have something to test from.&lt;/li&gt;
&lt;li&gt;Admin access to the Tailscale console, because two of the steps happen there and not on the box.&lt;/li&gt;
&lt;li&gt;MagicDNS turned on. It's on by default for new tailnets, and without it you get an IP instead of a name.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You do not need a domain, a DNS record, a certificate, or a single open port in your firewall. That's the point.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one thing to get straight
&lt;/h2&gt;

&lt;p&gt;Forgejo is not sitting behind a reverse proxy here. Forgejo &lt;em&gt;is&lt;/em&gt; the Tailscale node.&lt;/p&gt;

&lt;p&gt;That distinction is the whole post. In the usual setup you'd run Caddy or Nginx on the host, publish ports, and forward traffic to a container. Here, the Tailscale container owns a network namespace, and Forgejo is placed inside that same namespace with &lt;code&gt;network_mode: service:ts-forgejo&lt;/code&gt;. The two containers share one network stack the way two processes on one machine do.&lt;/p&gt;

&lt;p&gt;Once that clicks, the rest follows on its own:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Forgejo publishes no ports to the host, so &lt;code&gt;docker ps&lt;/code&gt; shows nothing listening and the host firewall has nothing to do.&lt;/li&gt;
&lt;li&gt;The machine appears in your tailnet as its own device, with its own name and its own ACL rules, separate from the host it happens to run on.&lt;/li&gt;
&lt;li&gt;Port 22 inside that namespace belongs to Forgejo, and the host's real sshd is somewhere else entirely as far as the network stack is concerned. The port collision that makes the public version of this setup annoying never happens.&lt;/li&gt;
&lt;li&gt;HTTPS arrives without opening port 80. The certificate still comes from Let's Encrypt, but through a DNS-01 challenge that Tailscale completes for you by publishing the &lt;code&gt;_acme-challenge&lt;/code&gt; TXT record under &lt;code&gt;ts.net&lt;/code&gt; for your node. Nothing has to be reachable from the public internet for validation to pass.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The result is a real, publicly trusted certificate on a machine with no public presence at all. Your browser sees a normal padlock. Nothing is exposed.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Turn on HTTPS for your tailnet
&lt;/h2&gt;

&lt;p&gt;Do this before anything else. Skipping it does not stop the stack coming up, which is exactly why it wastes your time later.&lt;/p&gt;

&lt;p&gt;In the Tailscale admin console, go to &lt;strong&gt;DNS&lt;/strong&gt;, and under HTTPS Certificates click &lt;strong&gt;Enable HTTPS&lt;/strong&gt;. Note the tailnet name it shows you, something like &lt;code&gt;tail1234.ts.net&lt;/code&gt;. Every device in your tailnet gets a name under it, so your Forgejo node will end up at &lt;code&gt;forgejo.tail1234.ts.net&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;If you skip this, the sidecar still starts and still says it's running. It logs a line telling you HTTPS is not enabled and links the docs, which is fair enough, but &lt;code&gt;tailscale serve status&lt;/code&gt; just answers &lt;code&gt;No serve config&lt;/code&gt; with no reason attached. The command that gives you a straight answer is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose &lt;span class="nb"&gt;exec &lt;/span&gt;ts-forgejo tailscale cert forgejo.tail1234.ts.net
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With HTTPS off you get &lt;code&gt;your Tailscale account does not support getting TLS certs&lt;/code&gt;. With it on you get two files written and you can move on. I use that as the check before touching anything else, because every other symptom of this is ambiguous.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Make a tagged auth key
&lt;/h2&gt;

&lt;p&gt;The container needs a key to join the tailnet unattended, and the way expiry works here catches people out.&lt;/p&gt;

&lt;p&gt;Auth keys cap out at 90 days, but that expiry only stops &lt;em&gt;new&lt;/em&gt; devices from joining. A node that already registered keeps working until its own node key expires, and that defaults to 180 days. So a plain reusable key gets you roughly six months before your Git host quietly drops off the tailnet on a day you weren't touching anything.&lt;/p&gt;

&lt;p&gt;The fix is a tag. Key expiry is disabled by default for tagged devices, because they're owned by the tailnet rather than by a user.&lt;/p&gt;

&lt;p&gt;First define the tag in your ACL file, under &lt;strong&gt;Access controls&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"tagOwners"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"tag:container"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"autogroup:admin"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then go to &lt;strong&gt;Settings&lt;/strong&gt;, &lt;strong&gt;Keys&lt;/strong&gt;, and generate an auth key with &lt;strong&gt;Reusable&lt;/strong&gt; on and the tag &lt;code&gt;tag:container&lt;/code&gt; applied. Copy it somewhere safe now, because the console shows it exactly once.&lt;/p&gt;

&lt;p&gt;Put it in a &lt;code&gt;.env&lt;/code&gt; file next to your Compose file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# .env&lt;/span&gt;
&lt;span class="nv"&gt;TS_AUTHKEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;tskey-auth-xxxxxxxxxxxx
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And keep that file out of Git:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;".env"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; .gitignore
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  3. The Compose file
&lt;/h2&gt;

&lt;p&gt;Two services. The first is the network, the second is the application.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;ts-forgejo&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;tailscale/tailscale:latest&lt;/span&gt;
    &lt;span class="na"&gt;container_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ts-forgejo&lt;/span&gt;
    &lt;span class="na"&gt;hostname&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;forgejo&lt;/span&gt;                      &lt;span class="c1"&gt;# becomes forgejo.your-tailnet.ts.net&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;TS_AUTHKEY=${TS_AUTHKEY}&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;TS_EXTRA_ARGS=--advertise-tags=tag:container&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;TS_STATE_DIR=/var/lib/tailscale&lt;/span&gt;    &lt;span class="c1"&gt;# persisted, see the volume below&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;TS_SERVE_CONFIG=/config/serve.json&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;TS_USERSPACE=false&lt;/span&gt;                 &lt;span class="c1"&gt;# use the kernel networking path&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;ts-forgejo-state:/var/lib/tailscale&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./ts-config:/config&lt;/span&gt;
    &lt;span class="na"&gt;devices&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/dev/net/tun:/dev/net/tun&lt;/span&gt;
    &lt;span class="na"&gt;cap_add&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;net_admin&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;sys_module&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;

  &lt;span class="na"&gt;forgejo&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;codeberg.org/forgejo/forgejo:15&lt;/span&gt;
    &lt;span class="na"&gt;container_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;forgejo&lt;/span&gt;
    &lt;span class="na"&gt;network_mode&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;service:ts-forgejo&lt;/span&gt;       &lt;span class="c1"&gt;# the whole trick, one line&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;USER_UID=1000&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;USER_GID=1000&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;FORGEJO__server__ROOT_URL=https://forgejo.tail1234.ts.net/&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;FORGEJO__server__SSH_DOMAIN=forgejo.tail1234.ts.net&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;FORGEJO__server__SSH_PORT=22&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;FORGEJO__service__DISABLE_REGISTRATION=true&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;forgejo-data:/data&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/etc/timezone:/etc/timezone:ro&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/etc/localtime:/etc/localtime:ro&lt;/span&gt;
    &lt;span class="na"&gt;depends_on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;ts-forgejo&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;

&lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;ts-forgejo-state&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;forgejo-data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few of those lines are doing more work than they look like they are.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;network_mode: service:ts-forgejo&lt;/code&gt; is the pattern. Notice what is absent: there is no &lt;code&gt;ports:&lt;/code&gt; block anywhere in this file. Forgejo listens on 3000 and 22 inside the shared namespace, reachable over the tailnet and nowhere else.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;TS_USERSPACE=false&lt;/code&gt; matters more than it looks. The Tailscale image defaults to userspace networking, and in that mode the TUN device and the &lt;code&gt;net_admin&lt;/code&gt; capability sit there unused. Turning it off puts the container on the kernel networking path those lines exist for, which is what you want when something in the namespace needs to answer on a real port.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;hostname: forgejo&lt;/code&gt; is what decides the name. Tailscale registers the container under it, so this is the value that ends up in your URL. Pick it before first boot, because renaming a node later means fixing &lt;code&gt;ROOT_URL&lt;/code&gt; too.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;TS_STATE_DIR&lt;/code&gt; with a real volume behind it is the difference between one node and a hundred. More on that in the gotchas below.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;FORGEJO__server__ROOT_URL&lt;/code&gt; uses Forgejo's double underscore convention, where &lt;code&gt;FORGEJO__section__KEY&lt;/code&gt; maps to a key in &lt;code&gt;app.ini&lt;/code&gt;. Get this value right before you ever start the container. Forgejo bakes it into the clone URLs it shows you, the links in its emails, and its webhook targets. Wrong here means every clone command your users copy points at the wrong place, and it looks fine on screen right up until someone tries it.&lt;/p&gt;

&lt;p&gt;There is also something deliberately missing. You will see &lt;code&gt;START_SSH_SERVER=true&lt;/code&gt; in a lot of Forgejo examples, and it does not belong here. The rootful image runs OpenSSH on port 22 by itself, unconditionally. Setting that variable starts Forgejo's own Go SSH server as well, and now two SSH servers want the same port in the same namespace. Leave it out and the image does the right thing on its own. That variable is for the rootless image, which is a different setup with SSH on 2222.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;DISABLE_REGISTRATION=true&lt;/code&gt; from the first boot means nobody gets to sign up while you're still setting things up. You'll create your admin account through the installer instead.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. The serve config
&lt;/h2&gt;

&lt;p&gt;Create &lt;code&gt;ts-config/serve.json&lt;/code&gt; next to your Compose file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"TCP"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"443"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"HTTPS"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Web"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"${TS_CERT_DOMAIN}:443"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Handlers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"/"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"Proxy"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"http://127.0.0.1:3000"&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"AllowFunnel"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"${TS_CERT_DOMAIN}:443"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;${TS_CERT_DOMAIN}&lt;/code&gt; is substituted by the Tailscale container at startup with the node's real name, so you don't have to hardcode your tailnet name in a second place.&lt;/p&gt;

&lt;p&gt;The proxy target is &lt;code&gt;127.0.0.1:3000&lt;/code&gt;, and that is only correct because of the shared namespace. Forgejo really is on localhost from Tailscale's point of view. If you ever pull these two apart into separate networks, this line breaks first.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;AllowFunnel&lt;/code&gt; is set to &lt;code&gt;false&lt;/code&gt; on purpose. It's the switch that would put this node on the public internet, and having it present and off is better than having it absent, because you can see what the answer currently is.&lt;/p&gt;

&lt;p&gt;Bring it up:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt;
docker compose logs &lt;span class="nt"&gt;-f&lt;/span&gt; ts-forgejo
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Watch for the node registering and the certificate being issued. Then check it from another machine on your tailnet:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;tailscale status | &lt;span class="nb"&gt;grep &lt;/span&gt;forgejo
curl &lt;span class="nt"&gt;-I&lt;/span&gt; https://forgejo.tail1234.ts.net/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A &lt;code&gt;200&lt;/code&gt; and a valid certificate means the hard part is done.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. First boot and locking the installer
&lt;/h2&gt;

&lt;p&gt;Open &lt;code&gt;https://forgejo.tail1234.ts.net/&lt;/code&gt; in a browser on any device in your tailnet and you get Forgejo's setup page.&lt;/p&gt;

&lt;p&gt;Two things matter here. Leave the database as SQLite unless you have a reason not to. For a personal or small team instance it's genuinely fine, it's one less container to run and one less thing to back up, and you can migrate later if the instance grows into something that needs Postgres. And check that the URL fields on the form match the &lt;code&gt;ROOT_URL&lt;/code&gt; you set, because the installer will happily write different values into &lt;code&gt;app.ini&lt;/code&gt; and leave you with two sources of truth.&lt;/p&gt;

&lt;p&gt;Create your admin account on that same form. Do not skip it and do it later.&lt;/p&gt;

&lt;p&gt;The reason for the urgency is smaller here than on a public box, but it still applies: until the installer is completed, whoever reaches that page can complete it and become the administrator. On a tailnet that's limited to devices you've already authorised, which is a much shorter list than "the internet". It's still a list. Finish the form.&lt;/p&gt;

&lt;p&gt;Once you're in, confirm the install lock landed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose &lt;span class="nb"&gt;exec &lt;/span&gt;forgejo &lt;span class="nb"&gt;grep &lt;/span&gt;INSTALL_LOCK /data/gitea/conf/app.ini
&lt;span class="c"&gt;# INSTALL_LOCK = true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  6. Cloning over SSH
&lt;/h2&gt;

&lt;p&gt;This is the part that would have been a whole section of fighting in the public version, and here it's almost nothing.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone git@forgejo.tail1234.ts.net:yourname/yourrepo.git
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Port 22, no custom port, no &lt;code&gt;-p 2222&lt;/code&gt; to remember, no conflict with the host's own sshd. The image's SSH server owns port 22 inside its namespace, and the host's sshd owns port 22 on the host, and they never meet.&lt;/p&gt;

&lt;p&gt;Add your public key in the Forgejo UI under &lt;strong&gt;Settings&lt;/strong&gt;, &lt;strong&gt;SSH / GPG Keys&lt;/strong&gt;, the same as you would anywhere else.&lt;/p&gt;

&lt;p&gt;One thing to check: the clone URL Forgejo displays comes from &lt;code&gt;SSH_DOMAIN&lt;/code&gt; and &lt;code&gt;SSH_PORT&lt;/code&gt;. If those disagree with reality, the button copies a command that fails, and the error the user sees is a connection timeout that tells them nothing about why.&lt;/p&gt;

&lt;h2&gt;
  
  
  Who can actually reach it
&lt;/h2&gt;

&lt;p&gt;Right now, every device in your tailnet can reach it. That's fine for a tailnet of one person and three laptops. It stops being fine the moment you add a contractor's machine or a server that runs someone else's code.&lt;/p&gt;

&lt;p&gt;ACLs fix that. &lt;strong&gt;This is a fragment to merge into your existing policy, not a whole policy file.&lt;/strong&gt; If you paste it over the top of everything, you delete the default rule that lets your tailnet talk to itself, and every other connection you have stops working at the same moment.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"groups"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"group:devs"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"you@example.com"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"collaborator@example.com"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"tagOwners"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"tag:container"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"autogroup:admin"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"acls"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;...&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;your&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;existing&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;rules&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;stay&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;here&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;...&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"action"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"accept"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"src"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"group:devs"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"dst"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"tag:container:443,22"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you already worked through the ACL section of &lt;a href="https://peculiarengineer.com/blog/tailscale-private-networking-workers-to-prod/" rel="noopener noreferrer"&gt;Tailscale for private networking&lt;/a&gt;, this is the same machinery pointed at a container instead of a server.&lt;/p&gt;

&lt;h2&gt;
  
  
  When you want it public after all
&lt;/h2&gt;

&lt;p&gt;Sometimes you need to hand someone a link without adding them to your tailnet. Tailscale Funnel does that, and it's less of a one liner than it looks.&lt;/p&gt;

&lt;p&gt;Two things have to be true first. Your tailnet policy needs the &lt;code&gt;funnel&lt;/code&gt; node attribute, which for a tagged container node you will be adding by hand:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"nodeAttrs"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"target"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"tag:container"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"attr"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"funnel"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then flip the switch in &lt;code&gt;serve.json&lt;/code&gt; and restart, which is the durable way to do it since &lt;code&gt;TS_SERVE_CONFIG&lt;/code&gt; reapplies that file every time the container starts:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nl"&gt;"AllowFunnel"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"${TS_CERT_DOMAIN}:443"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Restart with &lt;code&gt;docker compose restart&lt;/code&gt; and no service name after you edit that file. Restarting only the Tailscale container is what leaves Forgejo answering &lt;code&gt;502&lt;/code&gt; behind a network stack that moved out from under it, which is the gotcha below and the one that wastes the most time.&lt;/p&gt;

&lt;p&gt;Funnel only works on ports 443, 8443, and 10000, so 443 is the one you want anyway.&lt;/p&gt;

&lt;p&gt;The node attribute is not optional, and skipping it is the worst kind of failure. I flipped &lt;code&gt;AllowFunnel&lt;/code&gt; to &lt;code&gt;true&lt;/code&gt; without it and everything told me it had worked. &lt;code&gt;tailscale funnel status&lt;/code&gt; printed &lt;code&gt;Funnel on&lt;/code&gt; with the URL under it, &lt;code&gt;tailscale serve status&lt;/code&gt; agreed, and the logs said nothing at all. The name simply never appeared in public DNS, so from outside the tailnet it did not resolve, let alone serve. The node did not have the capability and no part of the tooling mentioned it.&lt;/p&gt;

&lt;p&gt;If you turn Funnel on, verify it from something that is not on your tailnet. A phone with WiFi off is enough:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dig +short @1.1.1.1 forgejo.tail1234.ts.net
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An empty answer means Funnel is not really on, whatever the CLI told you.&lt;/p&gt;

&lt;p&gt;Once it's on, you have inherited every problem this post was avoiding. Registration lockdown and rate limiting become your concern again. It's a good escape hatch and a bad default. Set it back to &lt;code&gt;false&lt;/code&gt; when you're done.&lt;/p&gt;

&lt;p&gt;The honest limitation: if the people you collaborate with will not join your tailnet, this setup is not for you. Tailnet only means tailnet only. Everyone who touches these repos needs Tailscale on their machine and a place in your ACLs. For a solo developer or a small team that already uses Tailscale, that cost is zero. For an open source project taking drive by contributions, it's a wall.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;No state volume, so every restart mints a new node.&lt;/strong&gt; This is the big one, and I watched it happen. If &lt;code&gt;TS_STATE_DIR&lt;/code&gt; has no volume behind it, the container loses its identity, registers again as a fresh device, and Tailscale appends a suffix to keep the name unique. Mine came back as &lt;code&gt;forgejo-1&lt;/code&gt; on a new address while the old &lt;code&gt;forgejo&lt;/code&gt; sat there marked offline.&lt;/p&gt;

&lt;p&gt;What makes it nasty is how healthy the result looks. The new node got its own certificate within seconds and served Forgejo on 443 without complaint. But &lt;code&gt;ROOT_URL&lt;/code&gt; and &lt;code&gt;SSH_DOMAIN&lt;/code&gt; live in &lt;code&gt;app.ini&lt;/code&gt; inside the data volume, so they still held the old name, and the API cheerfully handed out clone URLs pointing at a node nothing is listening on. The server is fine. Every clone command it gives your users times out. Persist &lt;code&gt;/var/lib/tailscale&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What losing that state does to you depends on your auth key.&lt;/strong&gt; With a reusable key you get the duplicate node above. With a single use key the container cannot register at all, fails with &lt;code&gt;invalid key: API key ... not valid&lt;/code&gt;, and never comes up. Two completely different mornings from the same missing volume.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Auth failures loop instead of stopping.&lt;/strong&gt; Every registration failure I hit, whether a tag the policy did not allow or a spent key, left the container restarting rather than exiting. With &lt;code&gt;restart: unless-stopped&lt;/code&gt; that continues forever. &lt;code&gt;docker compose ps&lt;/code&gt; shows &lt;code&gt;restarting&lt;/code&gt;, not an error, so if you run &lt;code&gt;up -d&lt;/code&gt; and walk away you come back to something that has been failing quietly for however long you were gone. The reason is only ever in &lt;code&gt;docker compose logs&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;An untagged node drops off at six months, not three.&lt;/strong&gt; The auth key expiring at 90 days is the number everyone quotes, but that only blocks new registrations. The node itself runs until its node key hits the 180 day default, which is a much worse way to find out, because by then you've forgotten the setup entirely. Tag the device and key expiry is off.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The tag has to exist before you advertise it.&lt;/strong&gt; &lt;code&gt;tag:container&lt;/code&gt; is my example name, not a default. If it is not in &lt;code&gt;tagOwners&lt;/code&gt; in your policy the node refuses to join with &lt;code&gt;requested tags [tag:container] are invalid or not permitted&lt;/code&gt;, which is at least an honest error. Use whatever tag your tailnet already has if you have one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tailscale SSH swallows Git over SSH.&lt;/strong&gt; If you add &lt;code&gt;--ssh&lt;/code&gt; to &lt;code&gt;TS_EXTRA_ARGS&lt;/code&gt;, tailscaled intercepts inbound tailnet connections to port 22 before they ever reach the SSH server in the container. There's no bind conflict and nothing looks broken in the logs. Your clones just stop working. Leave Tailscale SSH off on this node and use it on your other machines.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Restarting the sidecar alone breaks Forgejo in a way that looks like something else.&lt;/strong&gt; This one cost me the most time, because I did it to myself repeatedly while testing and kept diagnosing the wrong thing. &lt;code&gt;docker compose restart ts-forgejo&lt;/code&gt; gives Forgejo a network stack that has moved out from under it. Forgejo keeps running, &lt;code&gt;docker compose ps&lt;/code&gt; says both are &lt;code&gt;Up&lt;/code&gt;, and the tailnet name still resolves.&lt;/p&gt;

&lt;p&gt;What you get is a broken instance that points nowhere useful. The web side answers &lt;code&gt;502&lt;/code&gt;, because tailscaled terminates TLS perfectly well and then cannot reach &lt;code&gt;127.0.0.1:3000&lt;/code&gt; any more. Git over SSH gives you &lt;code&gt;Connection refused&lt;/code&gt;, or just hangs with no banner. Neither symptom points at the container you actually restarted.&lt;/p&gt;

&lt;p&gt;The fix is &lt;code&gt;docker compose restart&lt;/code&gt; with no service name, so both come back together. I checked this twice: sidecar alone gives 502 and a refused clone, both together gives 200 and a clean clone. Any time you touch the Tailscale container, take Forgejo with it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;ROOT_URL&lt;/code&gt; left over from an earlier attempt.&lt;/strong&gt; If you tried this once with a different hostname, changing the environment variable is not always enough, because the installer wrote the old value into &lt;code&gt;app.ini&lt;/code&gt; and that file lives in the volume. Check &lt;code&gt;/data/gitea/conf/app.ini&lt;/code&gt; and fix it there, or start from a clean volume.&lt;/p&gt;

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

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Setting&lt;/th&gt;
&lt;th&gt;What it controls&lt;/th&gt;
&lt;th&gt;What breaks without it&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;network_mode: service:ts-forgejo&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Puts Forgejo in the sidecar's network namespace&lt;/td&gt;
&lt;td&gt;Forgejo is not on the tailnet at all&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;TS_USERSPACE=false&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Kernel networking instead of userspace&lt;/td&gt;
&lt;td&gt;The TUN device and &lt;code&gt;net_admin&lt;/code&gt; do nothing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;hostname&lt;/code&gt; on the sidecar&lt;/td&gt;
&lt;td&gt;The node name, and so the URL&lt;/td&gt;
&lt;td&gt;Random or wrong &lt;code&gt;ts.net&lt;/code&gt; name&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;TS_STATE_DIR&lt;/code&gt; plus a volume&lt;/td&gt;
&lt;td&gt;Node identity across restarts&lt;/td&gt;
&lt;td&gt;New device on every restart, name drifts&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;TS_EXTRA_ARGS=--advertise-tags&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Marks the node as tailnet owned&lt;/td&gt;
&lt;td&gt;Node key expires at 180 days, node drops off&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;TS_SERVE_CONFIG&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;HTTPS and the proxy to port 3000&lt;/td&gt;
&lt;td&gt;No TLS, nothing served on 443&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;FORGEJO__server__ROOT_URL&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Clone URLs, emails, webhooks&lt;/td&gt;
&lt;td&gt;Everything points somewhere wrong&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;FORGEJO__server__SSH_DOMAIN&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The SSH clone URL shown in the UI&lt;/td&gt;
&lt;td&gt;Copy button hands out a command that times out&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;no &lt;code&gt;START_SSH_SERVER&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Lets the image's own sshd own port 22&lt;/td&gt;
&lt;td&gt;Two SSH servers want the same port&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Useful commands:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose logs &lt;span class="nt"&gt;-f&lt;/span&gt; ts-forgejo                      &lt;span class="c"&gt;# node registration and cert issuance&lt;/span&gt;
docker compose &lt;span class="nb"&gt;exec &lt;/span&gt;ts-forgejo tailscale status        &lt;span class="c"&gt;# is it on the tailnet&lt;/span&gt;
docker compose &lt;span class="nb"&gt;exec &lt;/span&gt;ts-forgejo tailscale serve status   &lt;span class="c"&gt;# what is being served on 443&lt;/span&gt;

&lt;span class="c"&gt;# backup, written somewhere writable and then copied out&lt;/span&gt;
docker compose &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; 1000 &lt;span class="nt"&gt;-w&lt;/span&gt; /tmp forgejo &lt;span class="se"&gt;\&lt;/span&gt;
  forgejo dump &lt;span class="nt"&gt;-c&lt;/span&gt; /data/gitea/conf/app.ini

&lt;span class="c"&gt;# the archive is named forgejo-dump-&amp;lt;timestamp&amp;gt;.zip, so read the name back&lt;/span&gt;
&lt;span class="nv"&gt;DUMP&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;docker compose &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-T&lt;/span&gt; forgejo sh &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'ls -1 /tmp/forgejo-dump-*.zip | tail -1'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
docker compose &lt;span class="nb"&gt;cp&lt;/span&gt; &lt;span class="s2"&gt;"forgejo:&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;DUMP&lt;/span&gt;&lt;span class="p"&gt;%&lt;/span&gt;&lt;span class="s1"&gt;$'&lt;/span&gt;&lt;span class="se"&gt;\r&lt;/span&gt;&lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; ./
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That timestamp is the part that bites. &lt;code&gt;forgejo dump&lt;/code&gt; prints the filename it wrote and then you are on your own, so a copy command with a fixed name in it fails every time and you find out when you need the backup.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;[ no open ports · real certs · git@ over the tailnet ]&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://peculiarengineer.com/blog/self-host-forgejo-tailscale-docker-compose/" rel="noopener noreferrer"&gt;peculiarengineer.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>selfhosted</category>
      <category>docker</category>
      <category>git</category>
      <category>tailscale</category>
    </item>
  </channel>
</rss>
