<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>devncode</title><description>Notes from running a homelab like production infrastructure, plus AWS and programming. By Marco Cotrufo.</description><link>https://devncode.it/</link><item><title>What the ZFS ARC does with your RAM, and what a cap costs</title><link>https://devncode.it/zfs-arc-max-homelab/</link><guid isPermaLink="true">https://devncode.it/zfs-arc-max-homelab/</guid><description>I ignored the ZFS ARC for three years. It was allowed 97% of my RAM. What it is, why it matters next to containers, and what capping it cost, measured.</description><pubDate>Tue, 13 Oct 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;For three and a half years I ran ZFS on my homelab server without ever asking what its cache was allowed to do. When I finally looked, the answer was: almost all of the RAM. This post is what I learned about the ARC since then, with ten days of data from before and after I capped it.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Achtung&lt;/strong&gt;
If you never set &lt;code&gt;zfs_arc_max&lt;/code&gt;, current OpenZFS on Linux lets the ARC grow to the larger of &quot;all RAM minus 1 GiB&quot; and &quot;5/8 of RAM&quot;. On a box that also runs containers, pick that number yourself.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;The machine is small: an i5-6500T with 4 cores, 31 GiB of RAM, and one Samsung 850 EVO that holds the root filesystem, the ZFS pool and the swap. Around seventy containers run on it, each with its own memory limit.&lt;/p&gt;
&lt;p&gt;What made me look was an outage. On September 21 Traefik went from 85 to 371 MB in less than two minutes and hit its container limit of 384 MB, and with the proxy dead every web service was unreachable. That kill came from Traefik&apos;s own cgroup limit; the ARC had nothing to do with it. But it was the afternoon I went through the memory of the whole box, and the ARC was the biggest surprise.&lt;/p&gt;
&lt;h2&gt;What the ARC is&lt;/h2&gt;
&lt;p&gt;The ARC, Adaptive Replacement Cache, is the read cache ZFS keeps in RAM. &quot;Adaptive&quot; because it tracks two kinds of blocks, the ones read recently and the ones read often, and keeps moving the boundary between them depending on which kind pays off.&lt;/p&gt;
&lt;p&gt;Three things about it I did not know:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;It lives outside the kernel&apos;s page cache.&lt;/strong&gt; ZFS manages that memory itself. The kernel can ask for it back under memory pressure, and the ARC then shrinks its target in small steps: by default each step takes off about 0.8% of its size.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Its default limit is huge.&lt;/strong&gt; With &lt;code&gt;zfs_arc_max&lt;/code&gt; left at 0, OpenZFS uses the larger of RAM minus 1 GiB and 5/8 of RAM. On my 31 GiB that was an &lt;code&gt;arc_c_max&lt;/code&gt; of 30.24 GiB, 97% of the RAM.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Lowering the limit at runtime does not shrink it.&lt;/strong&gt; The man page says it plainly: reducing &lt;code&gt;zfs_arc_max&lt;/code&gt; below the current size &quot;will not cause the ARC to shrink without memory pressure&quot;.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Why it matters on this box&lt;/h2&gt;
&lt;p&gt;Seventy containers share 31 GiB, and each has a memory limit that covers its own processes. The ARC is not inside any of those limits. When several containers need memory at the same time, the kernel first has to win it back from a cache that returns it slowly, and until then it swaps, on the same SSD that holds everything else, or kills something.&lt;/p&gt;
&lt;p&gt;Most of the time the ARC was harmless. Its median size was 8.4 GiB. But its peaks reached 15 GiB, and peaks are exactly when the containers need memory too.&lt;/p&gt;
&lt;h2&gt;The cap&lt;/h2&gt;
&lt;p&gt;The limit is a module option, so it survives a reboot. Because changing it at runtime does not shrink the cache, the Ansible role also writes the new value to &lt;code&gt;/sys/module/zfs/parameters/zfs_arc_max&lt;/code&gt;, and the cache gives memory back the next time the kernel asks:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;options zfs zfs_arc_max={{ zfs_arc_max_bytes }}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;zfs_arc_max_bytes&lt;/code&gt; is 8589934592: 8 GiB, just under the old median and about a quarter of the RAM.&lt;/p&gt;
&lt;h2&gt;What the cap changed&lt;/h2&gt;
&lt;p&gt;I compared September 11 to 20 with September 22 to October 1, skipping the day of the change. The numbers come from node-exporter&apos;s ZFS metrics, exported from Prometheus before its 30-day retention dropped them:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;ARC&lt;/th&gt;
&lt;th&gt;Before the cap&lt;/th&gt;
&lt;th&gt;After the cap&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Median size&lt;/td&gt;
&lt;td&gt;8.44 GiB&lt;/td&gt;
&lt;td&gt;8.81 GiB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;99th percentile&lt;/td&gt;
&lt;td&gt;13.86 GiB&lt;/td&gt;
&lt;td&gt;10.18 GiB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Maximum&lt;/td&gt;
&lt;td&gt;14.99 GiB&lt;/td&gt;
&lt;td&gt;10.96 GiB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hits per second&lt;/td&gt;
&lt;td&gt;21,464&lt;/td&gt;
&lt;td&gt;22,062&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Misses per second&lt;/td&gt;
&lt;td&gt;19.6&lt;/td&gt;
&lt;td&gt;298&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hit ratio&lt;/td&gt;
&lt;td&gt;99.91%&lt;/td&gt;
&lt;td&gt;98.67%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The cap did what I wanted: the peaks dropped by about 4 GiB, and the typical size did not move.&lt;/p&gt;
&lt;p&gt;It also has a price, and I did not expect it to be this visible. The workload read the same amount, about 22,000 hits a second, but misses went from 20 to almost 300 a second. A hit ratio of 98.67% sounds fine; it means fifteen times more reads going to disk. Reads do not wear an SSD the way writes do, and I did not notice any difference in daily use, so for now I keep the cap. It is a trade, not a free fix.&lt;/p&gt;
&lt;h2&gt;The ARC still goes over its limit&lt;/h2&gt;
&lt;p&gt;This one I have not solved. After the cap, the ARC&apos;s target (&lt;code&gt;arc_c&lt;/code&gt;) stays between 6.3 and 8 GiB, as it should. Its real size does not: it sits above 8 GiB in almost every sample, and peaked at 11 GiB.&lt;/p&gt;
&lt;p&gt;According to the man page the ARC starts evicting as soon as it is 0.2% over its target, and blocks new allocations at 0.6%. Sitting above 8 GiB for days, up to 37% over, is something else. My guess is memory the ARC cannot evict at that moment, such as metadata for files that are open. I have not checked it yet: the next step is a breakdown with &lt;code&gt;arc_summary&lt;/code&gt;, and I would rather report a guess as a guess than dress it up.&lt;/p&gt;
&lt;h2&gt;How I measured&lt;/h2&gt;
&lt;p&gt;Prometheus keeps 30 days, so three weeks after the change I exported every series I needed through its HTTP API (&lt;code&gt;/api/v1/query_range&lt;/code&gt;, one sample every five minutes) into gzipped JSON. A small Python script compares the two windows. Gauges get an average and percentiles. Counters, like hits and misses, get an average rate, and a drop between two samples counts as a counter reset:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def counter_rate(points, period):
    &quot;&quot;&quot;Average per-second increase, skipping counter resets.&quot;&quot;&quot;
    pts = window(points, period)
    total = sum(max(b - a, 0) for (_, a), (_, b) in zip(pts, pts[1:]))
    span = pts[-1][0] - pts[0][0] if len(pts) &amp;gt; 1 else float(&quot;nan&quot;)
    return total / span
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;The rest of that afternoon&lt;/h2&gt;
&lt;p&gt;The ARC was one of ten changes I made on the same day. That was a mistake for the measurement: I wanted an aggressive plan, and I got data that cannot separate most of them. Briefly:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;TRIM&lt;/strong&gt; clearly works. &lt;code&gt;fstrim&lt;/code&gt; never reached the pool, because ZFS does not implement the &lt;code&gt;FITRIM&lt;/code&gt; ioctl it uses, and the SSD had gone untrimmed for three and a half years. With &lt;code&gt;autotrim=on&lt;/code&gt; and a monthly &lt;code&gt;zpool trim&lt;/code&gt;, discards went from 5.5 to 140 GB a day.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The &lt;code&gt;performance&lt;/code&gt; governor&lt;/strong&gt; raised the average clock from 2.30 to 2.84 GHz. I did not feel it.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;I/O stalls&lt;/strong&gt; dropped from 5.3% to 3.0% of the time. The scheduler, TRIM, &lt;code&gt;atime&lt;/code&gt; and &lt;code&gt;recordsize&lt;/code&gt; all touch the same disk, so I cannot say which one did it.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Writes to the SSD&lt;/strong&gt; stayed at about 280 GB a day: turning off &lt;code&gt;atime&lt;/code&gt; and capping the journal did nothing visible. What writes that much is a question for another post.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Load and swap went up in the same days, for a reason unrelated to this tuning. That is another post too.&lt;/p&gt;
&lt;h2&gt;What I would do differently&lt;/h2&gt;
&lt;p&gt;Next time I change one thing, wait for a few days of data, check it, and only then move to the next. And I will read the defaults of anything that holds on to memory before I run it for three years.&lt;/p&gt;
</content:encoded></item><item><title>Time Machine backups on a Samba share in Docker</title><link>https://devncode.it/time-machine-samba-docker/</link><guid isPermaLink="true">https://devncode.it/time-machine-samba-docker/</guid><description>Samba in Docker as a Time Machine target: getting the Mac to find it, the cap and stalled verification that broke it, and the alerts I run now.</description><pubDate>Mon, 12 Oct 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;I found out my Time Machine backups were broken right around my upgrade to macOS 27: every attempt was failing. That one is on me. I set the backup up in April, saw the first one complete, and never tested it again.&lt;/p&gt;
&lt;p&gt;My homelab server is also my NAS, so the Mac backs up to it over the LAN. Samba runs in Docker on that server and exports a Time Machine share that lives on a USB disk. This post covers the share, what it took for the Mac to find it, the two faults that stopped it in September, and the alerts I run now.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Achtung&lt;/strong&gt;
&lt;code&gt;fruit:time machine max size&lt;/code&gt; must leave room for a full backup, not only for the next incremental. Mine did not, and the day macOS decided it needed a full copy, the copy never fit.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;The share&lt;/h2&gt;
&lt;p&gt;The image is &lt;code&gt;ghcr.io/servercontainers/samba&lt;/code&gt;. It runs Samba, Avahi and wsdd2 in one container and builds &lt;code&gt;smb.conf&lt;/code&gt; from environment variables. A Time Machine share needs the &lt;code&gt;fruit&lt;/code&gt; VFS module, which speaks Apple&apos;s SMB extensions, stacked with &lt;code&gt;streams_xattr&lt;/code&gt;, plus &lt;code&gt;fruit:time machine = yes&lt;/code&gt; to advertise it as a backup target.&lt;/p&gt;
&lt;p&gt;These are the lines of the compose file that matter for Time Machine:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;services:
  samba:
    image: ghcr.io/servercontainers/samba:latest
    environment:
      MODEL: &quot;TimeCapsule&quot;
      SAMBA_GLOBAL_CONFIG_interfaces: &quot;wan0&quot;
      SAMBA_GLOBAL_CONFIG_bind_SPACE_interfaces_SPACE_only: &quot;yes&quot;
      SAMBA_VOLUME_CONFIG_timemachine: |
        [TimeMachine]
          path = /shares/timemachine
          read only = no
          valid users = {{ samba_user }}
          vfs objects = fruit streams_xattr
          fruit:time machine = yes
          fruit:time machine max size = {{ samba_timemachine_max_size }}
    cap_add:
      - NET_ADMIN
      - NET_RAW
    volumes:
      - &quot;{{ samba_timemachine_path }}:/shares/timemachine&quot;
      - &quot;{{ samba_config_dir }}/avahi-daemon.conf:/etc/avahi/avahi-daemon.conf:ro&quot;
    network_mode: host
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Two details of the image cost me a fix each. Option names with a space are written with &lt;code&gt;_SPACE_&lt;/code&gt;: my first version used double underscores, and the day after I corrected them. And &lt;code&gt;network_mode: host&lt;/code&gt; is not optional: Avahi and wsdd2 announce the share with multicast on the LAN, and from a bridged container nobody hears them. &lt;code&gt;MODEL&lt;/code&gt; is only the model Avahi announces, which decides the icon Finder shows.&lt;/p&gt;
&lt;h2&gt;Making the Mac find it&lt;/h2&gt;
&lt;p&gt;Samba was up on March 30, but the Mac listed the share only on April 18. Three problems, in the order I hit them:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Avahi was off.&lt;/strong&gt; The image disables it when &lt;code&gt;AVAHI_DISABLE&lt;/code&gt; is set, whatever the value. I had the variable in the compose file, so mDNS never started, and without mDNS the share never shows up in Time Machine&apos;s list of disks.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The dbus socket.&lt;/strong&gt; To get Avahi running I bind-mounted the host&apos;s &lt;code&gt;/var/run/dbus&lt;/code&gt; into the container. Avahi started, the Mac found the share on April 18, and the first full backup ran that same day. Five days later something else on the host broke: &lt;code&gt;hostnamectl&lt;/code&gt; failed, and so did the Ansible tasks that go through dbus. Sharing that directory had let the container delete the host&apos;s system bus socket.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Docker interfaces.&lt;/strong&gt; With host networking Avahi sees every veth interface Docker creates and removes. Every new container brought a new interface, Avahi reconfigured itself each time, and the announcement on the LAN never settled.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The fix for the last two is a small &lt;code&gt;avahi-daemon.conf&lt;/code&gt;, mounted over the image&apos;s own:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;[server]
use-ipv4=yes
use-ipv6=yes
allow-interfaces={{ router_lan_nic | default(&apos;wan0&apos;) }}
enable-dbus=no
ratelimit-interval-usec=1000000
ratelimit-burst=1000

[publish]
publish-hinfo=no
publish-workstation=no
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;allow-interfaces&lt;/code&gt; pins Avahi to the LAN interface, &lt;code&gt;enable-dbus=no&lt;/code&gt; lets it run without the host&apos;s dbus. The two capabilities in the compose file belong to wsdd2, the Windows discovery daemon: without &lt;code&gt;NET_ADMIN&lt;/code&gt; and &lt;code&gt;NET_RAW&lt;/code&gt; it cannot set up its multicast sockets.&lt;/p&gt;
&lt;h2&gt;What broke in September&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Date&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;2026-04-18&lt;/td&gt;
&lt;td&gt;First full backup. macOS verifies the new bundle.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;April to September&lt;/td&gt;
&lt;td&gt;Hourly incrementals succeed.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2026-09-14, 04:03 UTC&lt;/td&gt;
&lt;td&gt;Last successful backup.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2026-09-15&lt;/td&gt;
&lt;td&gt;I notice that every backup fails.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;code&gt;backupd&lt;/code&gt; stayed in &lt;code&gt;PreparingSourceVolumes&lt;/code&gt;: no CPU use, nothing in the logs. Two separate problems were behind it.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The verification came due.&lt;/strong&gt; macOS re-verifies network backups now and then. The last verification of my bundle dated from April 18, almost five months earlier, and the bundle held 720G of history. Running that check over SMB, on a USB disk with shingled (SMR) recording, never finished, and no backup could start until it did.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The full copy did not fit.&lt;/strong&gt; The bundle held 720 GiB and the cap was 1 TiB, which left the Mac about 326 GB of free space (macOS counts in decimal units). The full copy &lt;code&gt;backupd&lt;/code&gt; had planned was 413 GB. Instead of copying, it kept deleting old snapshots to make room, at about 700 KB/s on that disk, and the copy never started.&lt;/p&gt;
&lt;p&gt;One wrong turn is worth telling. When I wrote the monitoring, I first read &lt;code&gt;VerificationState = 1&lt;/code&gt; in the bundle&apos;s &lt;code&gt;MachineID.plist&lt;/code&gt; as &quot;this bundle is damaged&quot; and planned to alert on it. Then the collector ran on the real share, and the bundle created that morning reported &lt;code&gt;1&lt;/code&gt; too. What separates a healthy bundle from mine is the date of the last verification, not the flag. The check on the empty replacement bundle finished in 25 seconds. So the old bundle was intact. At 720G, verifying it over SMB on this disk never finished.&lt;/p&gt;
&lt;h2&gt;Raising the cap&lt;/h2&gt;
&lt;p&gt;The cap does not live in the bundle. Samba uses it to report a smaller disk to the Mac (the &lt;code&gt;vfs_fruit&lt;/code&gt; man page says it &quot;limits the reported disksize&quot;), so changing it needs no work on the bundle: the Mac reads the new size the next time it mounts the share.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;samba_timemachine_max_size: 1T
samba_timemachine_max_size: 2T
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;I stopped at 2T because of the disk: it had 1.9T free with the bundle already taking 720G, so 2T is a cap the disk can actually honour. The man page also warns that the cap is an estimate built from the bundle&apos;s band files only. Anything else stored on the same disk is invisible to it, so keep the cap below the space the disk really has.&lt;/p&gt;
&lt;p&gt;I deleted the old bundle and let the Mac start a new one.&lt;/p&gt;
&lt;h2&gt;Alerts that would have caught it&lt;/h2&gt;
&lt;p&gt;Everything else on the server had monitoring. The Time Machine share had none. Now a systemd timer runs a Python collector every five minutes:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;[Unit]
Description=Refresh Time Machine backup metrics every 5 minutes

[Timer]
OnBootSec=2min
OnUnitActiveSec=5min
AccuracySec=30s

[Install]
WantedBy=timers.target
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The collector reads the plists inside each sparsebundle and writes a file for node-exporter&apos;s textfile collector. The completion dates of the snapshots sit at different key paths depending on the macOS release, so it walks the whole plist instead of trusting one path:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def collect_dates(node, out):
    if isinstance(node, dict):
        for key, value in node.items():
            if key.endswith(&quot;SnapshotCompletionDate&quot;) and hasattr(value, &quot;timestamp&quot;):
                out.append(value.timestamp())
            else:
                collect_dates(value, out)
    elif isinstance(node, list):
        for item in node:
            collect_dates(item, out)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The bundle size comes from &lt;code&gt;st_blocks&lt;/code&gt;, not &lt;code&gt;st_size&lt;/code&gt;: band files are sparse, and their size would overcount.&lt;/p&gt;
&lt;p&gt;One trap in this script. Ansible renders it through Jinja before copying it to the server, to fill in the share path and the cap. Two opening braces in a row, anywhere in the file and even in a comment, start a Jinja expression and break the deploy, and doubled braces are what &lt;code&gt;str.format&lt;/code&gt; uses to print a literal brace. The script sticks to &lt;code&gt;%&lt;/code&gt;-formatting, and a test rejects any &lt;code&gt;{{&lt;/code&gt; other than the variables Ansible is meant to fill.&lt;/p&gt;
&lt;p&gt;Prometheus turns the metrics into six alerts, each aimed at one of the ways this went wrong:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Alert&lt;/th&gt;
&lt;th&gt;Fires when&lt;/th&gt;
&lt;th&gt;What it would have caught&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;TimeMachineBackupMissed&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;no completed snapshot for 48 hours&lt;/td&gt;
&lt;td&gt;the September outage, without me finding it by hand&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;TimeMachineNeverCompleted&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;a bundle holds no snapshot for 24 hours&lt;/td&gt;
&lt;td&gt;a first backup that never finishes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;TimeMachineVerificationOverdue&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;the last verification is older than 90 days&lt;/td&gt;
&lt;td&gt;the real cause, about two months before it stalled anything&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;TimeMachineBundleNearCap&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;the bundle passes 70% of the cap&lt;/td&gt;
&lt;td&gt;the 720G-of-1T situation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;TimeMachineNoBundle&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;the share holds no bundle at all&lt;/td&gt;
&lt;td&gt;an empty share&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;TimeMachineMetricsStale&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;the collector&apos;s last run is over 30 minutes old, for another 30 minutes&lt;/td&gt;
&lt;td&gt;a collector that stopped, leaving numbers that look fine forever&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The last one matters more than it looks. The collector replaces its file atomically, and node-exporter keeps serving the last file it finds, so a stopped timer makes nothing disappear: the numbers freeze. The rules built on &lt;code&gt;time()&lt;/code&gt; still fire on a frozen timestamp, but a frozen size or snapshot count looks healthy forever. The freshness check catches that case, and &lt;code&gt;absent()&lt;/code&gt; covers a file that was never written:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;- alert: TimeMachineVerificationOverdue
  expr: time() - timemachine_verification_timestamp_seconds &amp;gt; 7776000
  for: 12h
  labels:
    severity: warning

- alert: TimeMachineMetricsStale
  expr: absent(timemachine_collector_last_run_timestamp_seconds) or time() - timemachine_collector_last_run_timestamp_seconds &amp;gt; 1800
  for: 30m
  labels:
    severity: warning
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;The test I skipped in April&lt;/h2&gt;
&lt;p&gt;I recently formatted the Mac and restored it from this share, and it went smoothly. That restore is the test I should have run in April, right after the first backup completed. The alerts tell me when backups stop. Only a restore tells me they are usable.&lt;/p&gt;
</content:encoded></item><item><title>Structuring an Ansible repo for a one-server homelab</title><link>https://devncode.it/ansible-homelab-repo-structure/</link><guid isPermaLink="true">https://devncode.it/ansible-homelab-repo-structure/</guid><description>One role per service for atomic deploys, every value in two files, resource classes for CPU and memory, and a precedence trap that overrode production.</description><pubDate>Sun, 11 Oct 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;The server from &lt;a href=&quot;https://devncode.it/run-homelab-like-production/&quot;&gt;part 1&lt;/a&gt; runs about a hundred containers, and one Ansible repo describes all of them. I picked Ansible because I have used it for years and I have always been happy with it. This part is about the shape of that repo: where a service lives, where its values live, and how one bad loading order overrode production for months without anyone noticing.&lt;/p&gt;
&lt;h2&gt;One role per service&lt;/h2&gt;
&lt;p&gt;Every service is a role, and every role has its own tag in the playbook. That is the whole reason for the layout: a deploy can touch one service and nothing else. A role is small and always looks the same:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;roles/&amp;lt;service&amp;gt;/
  tasks/main.yml           create the directories, render the files, compose up
  templates/
    docker-compose.yml.j2  the container, with limits from the resource classes
    &amp;lt;config files&amp;gt;.j2      whatever the service reads at start
  handlers/main.yml        restart the container when a rendered file changes
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The playbook runs the roles in order: first the base system (common, router, VPN, ZFS, Docker), then the services. A full deploy goes through all of them. A normal one does not. When I moved the temp directory of Plex into RAM, it was one variable, one line in a template and &lt;code&gt;make deploy-tags TAGS=plex&lt;/code&gt;, and only Plex restarted.&lt;/p&gt;
&lt;p&gt;Almost no role has a &lt;code&gt;defaults/&lt;/code&gt; directory, and none has &lt;code&gt;vars/&lt;/code&gt;. A role holds the logic, not the values.&lt;/p&gt;
&lt;h2&gt;Every value in two files&lt;/h2&gt;
&lt;p&gt;The values live in two files. &lt;code&gt;inventory/group_vars/all/common.yml&lt;/code&gt; has everything shared: datasets, ports, hostnames, resource limits. &lt;code&gt;inventory/production/group_vars/all/vars.yml&lt;/code&gt; has what belongs to the real server: addresses, interfaces, and references to the secrets, which are in an encrypted &lt;code&gt;vault.yml&lt;/code&gt; next to it.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;vault.yml.example&lt;/code&gt; lists the keys of the vault with fake values, so I can read what exists without decrypting anything. &lt;code&gt;make vault-check&lt;/code&gt; compares the two lists and fails when a key is missing from the example or left there after removal.&lt;/p&gt;
&lt;h2&gt;Resource classes instead of numbers&lt;/h2&gt;
&lt;p&gt;This server was born for Plex and the *arr stack, and nothing else. Everything that came after shares the same 4 cores and 32 GB, and the CPU is the tight part. Giving every service the right resources is critical, and Docker gives three knobs per container:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;cpu_shares&lt;/code&gt; is a weight, not a cap. It only matters when the CPU is busy, and Docker&apos;s default is 1024.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;mem_limit&lt;/code&gt; is a hard cap. A container that goes over it gets a process killed by the kernel, inside that container, and the rest of the server is not involved.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;oom_score_adj&lt;/code&gt;, from -1000 to 1000, decides who the kernel kills first when the whole host runs out of memory.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Three numbers for each of a hundred services is a lot of numbers to keep consistent. So the numbers live in a few named classes, and a service picks one:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;resource_class:
  critical:
    cpu_shares: 4096
    mem_limit: &quot;0&quot;
    oom_score_adj: -500
  high:
    cpu_shares: 768
    mem_limit: 768m
    oom_score_adj: -100
  high_light:
    cpu_shares: 768
    mem_limit: 384m
    oom_score_adj: -100
  medium:
    cpu_shares: 384
    mem_limit: 768m
    oom_score_adj: 0
  low_light:
    cpu_shares: 192
    mem_limit: 384m
    oom_score_adj: 100
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The name says the priority, the suffix says the memory: &lt;code&gt;high&lt;/code&gt; and &lt;code&gt;high_light&lt;/code&gt; get the same CPU weight and the same place in the kill order, and differ only in the cap. &lt;code&gt;critical&lt;/code&gt; has no memory cap, four times Docker&apos;s default weight, and is the last thing the kernel kills. Only two services are in it: Vaultwarden, and Plex, the reason the server exists. A few classes exist for one kind of workload, like the containers that drive a real Chrome: a single page of results costs about 400 MB, so they get 2 GB.&lt;/p&gt;
&lt;p&gt;The mapping is one line per service:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;container_resources:
  plex: &quot;{{ resource_class.critical }}&quot;
  traefik: &quot;{{ resource_class.high }}&quot;
  adguard: &quot;{{ resource_class.high_light }}&quot;
  sonarr: &quot;{{ resource_class.medium }}&quot;
  transmission: &quot;{{ resource_class.high }}&quot;
  vaultwarden: &quot;{{ resource_class.critical }}&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Each compose template reads its three values from there, so no number is ever written in a role:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;    cpu_shares: {{ container_resources.plex.cpu_shares }}
    mem_limit: {{ container_resources.plex.mem_limit }}
    oom_score_adj: {{ container_resources.plex.oom_score_adj }}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The classes change after incidents. A service that needs more moves to another class, and the comment next to its line says why. Traefik was &lt;code&gt;high_light&lt;/code&gt;, until one afternoon it went from 85 to 371 MB in under two minutes: the kernel killed it at its 384 MB cap, and every site behind the proxy went down with it. Now it is &lt;code&gt;high&lt;/code&gt;, with the same weight and twice the memory. Transmission was &lt;code&gt;medium&lt;/code&gt;, until two Plex transcodes at once left it so little CPU that 41% of the calls to its web interface took more than 5 seconds. Now it is &lt;code&gt;high&lt;/code&gt;, with the same memory and twice the weight. Both fixes were a one-word change.&lt;/p&gt;
&lt;h2&gt;The loading order that overrode production&lt;/h2&gt;
&lt;p&gt;For months the playbook loaded &lt;code&gt;common.yml&lt;/code&gt; with &lt;code&gt;vars_files&lt;/code&gt;. That is the wrong place for shared defaults. In Ansible&apos;s &lt;a href=&quot;https://docs.ansible.com/projects/ansible/latest/playbook_guide/playbooks_variables.html#understanding-variable-precedence&quot;&gt;variable precedence&lt;/a&gt;, play &lt;code&gt;vars_files&lt;/code&gt; rank above inventory &lt;code&gt;group_vars&lt;/code&gt;. So when a key was in both files, the shared default won over production.&lt;/p&gt;
&lt;p&gt;It happened with the DHCP reservations. &lt;code&gt;common.yml&lt;/code&gt; had an empty &lt;code&gt;router_dhcp_static_leases: []&lt;/code&gt; as a default, &lt;code&gt;vars.yml&lt;/code&gt; had the real list with the access point in it, and dnsmasq got the empty one. The access point had no reserved address, and I never noticed. It was not about that key either: every key present in both files lost to the default, including the ones nobody had added yet.&lt;/p&gt;
&lt;p&gt;Now &lt;code&gt;common.yml&lt;/code&gt; is part of the inventory. The environment&apos;s &lt;code&gt;group_vars/all/&lt;/code&gt; has a symlink to it, next to &lt;code&gt;vars.yml&lt;/code&gt;, and Ansible reads the files of a &lt;a href=&quot;https://docs.ansible.com/projects/ansible/latest/inventory_guide/intro_inventory.html&quot;&gt;group_vars directory&lt;/a&gt; in lexicographical order: &lt;code&gt;c&lt;/code&gt; before &lt;code&gt;v&lt;/code&gt;, shared before production.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;group_vars/all/
  common.yml              the shared file, the only copy
production/
  hosts.yml
  group_vars/all/
    common.yml -&amp;gt; ../../../group_vars/all/common.yml
    vars.yml              read after common.yml, so it wins
    vault.yml
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Achtung&lt;/strong&gt;
The order is alphabetical. Rename &lt;code&gt;vars.yml&lt;/code&gt; to something that sorts before &lt;code&gt;common.yml&lt;/code&gt;, and the shared defaults win again, without a warning.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Something this easy to break gets a test. &lt;code&gt;tests/test_var_precedence.sh&lt;/code&gt; has two halves. The first reads the repo: no &lt;code&gt;vars_files&lt;/code&gt; in the playbook, a symlink that resolves to the one shared file, &lt;code&gt;common.yml&lt;/code&gt; sorting before &lt;code&gt;vars.yml&lt;/code&gt;. The second asks Ansible for the values with &lt;code&gt;ansible -m debug -c local&lt;/code&gt;, which renders them without touching the server, and expects production to win: the access point is in the lease list, and every key that differs between the two files has the value from &lt;code&gt;vars.yml&lt;/code&gt;. CI has no vault, so there only the first half runs. The second runs on my machine.&lt;/p&gt;
&lt;h2&gt;Docs that point to the code&lt;/h2&gt;
&lt;p&gt;The last rule is about the &lt;code&gt;docs/&lt;/code&gt; folder. Docs explain why. They never list services, datasets, networks, databases, tags or versions: those live in &lt;code&gt;common.yml&lt;/code&gt; and &lt;code&gt;site.yml&lt;/code&gt;, and the docs link there. I made it a rule because static lists were impossible to keep up to date. When I applied it, some docs no longer matched the code at all: one still described HTTP-01 certificates, while Traefik was using the DNS-01 challenge with Route53.&lt;/p&gt;
&lt;p&gt;Lint is a rule too: yamllint and ansible-lint, with the &lt;code&gt;production&lt;/code&gt; profile, on every push, and the result must be zero warnings. Exceptions go in the linter config, where they are visible. It is worth it, but it is a detail next to the rest.&lt;/p&gt;
&lt;h2&gt;Next&lt;/h2&gt;
&lt;p&gt;&lt;a href=&quot;https://devncode.it/series/homelab-like-production/&quot;&gt;Part 3&lt;/a&gt; follows one change from the commit to the running container: the CI, the Semaphore webhook, and the checks after a deploy.&lt;/p&gt;
</content:encoded></item><item><title>Running a one-server homelab like production</title><link>https://devncode.it/run-homelab-like-production/</link><guid isPermaLink="true">https://devncode.it/run-homelab-like-production/</guid><description>One Debian box is the router, the NAS and the media server for my family. Why it runs from an Ansible repo with CI, alerts and tested backups.</description><pubDate>Sat, 10 Oct 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Everything my family uses at home runs on one machine: an i5-6500T with 4 cores and 32 GB of RAM, Debian 13, a 250 GB SSD with the system and a ZFS pool, and two USB disks, 6 TB for media and 4 TB for backups. About a hundred containers run on it. It is also the router, so when it goes down, the house goes offline.&lt;/p&gt;
&lt;p&gt;This series is about how I keep that machine boring. This first part is the why, and a map of the pieces. The other parts go into each piece.&lt;/p&gt;
&lt;h2&gt;Kubernetes was too much&lt;/h2&gt;
&lt;p&gt;Before the current setup I had a Kubernetes cluster with Argo CD. Exploring GitOps was fun, but for us it was overkill: we have one node and no shared filesystem.&lt;/p&gt;
&lt;p&gt;In March 2026 I started again from an empty repo, with Ansible. Its README states the rule: the repo is the source of truth, and the server is disposable. Everything that matters lives on ZFS datasets that are backed up every night, and a fresh Debian install plus one playbook rebuilds the rest. There is a runbook for that rebuild in the repo.&lt;/p&gt;
&lt;h2&gt;Why the server became the router&lt;/h2&gt;
&lt;p&gt;Until April the router was a TP-Link AX18. A NAT router keeps a connection tracking table: one entry for every connection between a device inside and a host outside, so it knows where to send the replies. Every entry costs memory, and the AX18 has 256 MB of RAM for everything it does. When the table is full, new connections are dropped. With the devices of a whole family and a torrent client, ours was full, and the symptom was the upload: it went to almost zero.&lt;/p&gt;
&lt;p&gt;First I tried to make the problem smaller: I lowered Transmission&apos;s peer limit from 200 to 80, and started pinging three targets every 15 seconds, the gateway and two public DNS servers, so the next outage would show whether the router or the provider was at fault. Two days later I moved NAT, DHCP and the firewall to the server, and the AX18 became a Wi-Fi access point.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;AX18 doing NAT&lt;/th&gt;
&lt;th&gt;Server doing NAT&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Upload&lt;/td&gt;
&lt;td&gt;almost 0 Mbps&lt;/td&gt;
&lt;td&gt;887 Mbps&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Download&lt;/td&gt;
&lt;td&gt;355 Mbps&lt;/td&gt;
&lt;td&gt;941 Mbps&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Connection tracking&lt;/td&gt;
&lt;td&gt;256 MB of RAM for the whole router&lt;/td&gt;
&lt;td&gt;room for 262,144 entries, about 2,800 in use as I write&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Probably a good router would have been enough. But we like to make our lives complicated, and this way we got two things for one. The server now hands out the addresses on the network, and it gives itself as the DNS server, so every device goes through AdGuard and the ad and tracker filters live in one place.&lt;/p&gt;
&lt;p&gt;The price is that the server is the single point of failure for the whole family. It is not ideal. One day we will fix it.&lt;/p&gt;
&lt;h2&gt;How the pieces fit&lt;/h2&gt;
&lt;p&gt;The diagram is the whole series in one picture. The laptop and Semaphore change the server; Prometheus, Loki and restic watch it and copy it. Forgejo and Semaphore run on the same server, which is less circular than it looks: a deploy from the laptop needs neither of them.&lt;/p&gt;
&lt;p&gt;&amp;lt;figure style=&quot;margin:2rem 0&quot;&amp;gt;
&amp;lt;svg viewBox=&quot;0 0 440 520&quot; role=&quot;img&quot; aria-labelledby=&quot;hlp-title hlp-desc&quot; style=&quot;display:block;width:100%;max-width:440px;height:auto;margin:0 auto;font-family:var(--font-sans)&quot;&amp;gt;
&amp;lt;title id=&quot;hlp-title&quot;&amp;gt;How the homelab is built, deployed and watched&amp;lt;/title&amp;gt;
&amp;lt;desc id=&quot;hlp-desc&quot;&amp;gt;A laptop pushes to Forgejo, which lints and tests every push. A release triggers Semaphore. The laptop or Semaphore applies Ansible roles to the server. The server sends metrics and logs to Prometheus, Loki and Grafana, which alert on Telegram, and restic copies its data to S3 every night.&amp;lt;/desc&amp;gt;
&amp;lt;defs&amp;gt;&amp;lt;marker id=&quot;hlp-arrow&quot; viewBox=&quot;0 0 8 8&quot; refX=&quot;7&quot; refY=&quot;4&quot; markerWidth=&quot;7&quot; markerHeight=&quot;7&quot; orient=&quot;auto&quot;&amp;gt;&amp;lt;path d=&quot;M0,0 L8,4 L0,8 z&quot; style=&quot;fill:var(--muted)&quot;/&amp;gt;&amp;lt;/marker&amp;gt;&amp;lt;/defs&amp;gt;
&amp;lt;g style=&quot;fill:var(--code-bg);stroke:var(--rule);stroke-width:1&quot;&amp;gt;
&amp;lt;rect x=&quot;20&quot; y=&quot;20&quot; width=&quot;190&quot; height=&quot;80&quot; rx=&quot;6&quot;/&amp;gt;
&amp;lt;rect x=&quot;230&quot; y=&quot;20&quot; width=&quot;190&quot; height=&quot;80&quot; rx=&quot;6&quot;/&amp;gt;
&amp;lt;rect x=&quot;230&quot; y=&quot;150&quot; width=&quot;190&quot; height=&quot;80&quot; rx=&quot;6&quot;/&amp;gt;
&amp;lt;rect x=&quot;20&quot; y=&quot;410&quot; width=&quot;190&quot; height=&quot;96&quot; rx=&quot;6&quot;/&amp;gt;
&amp;lt;rect x=&quot;230&quot; y=&quot;410&quot; width=&quot;190&quot; height=&quot;96&quot; rx=&quot;6&quot;/&amp;gt;
&amp;lt;/g&amp;gt;
&amp;lt;rect x=&quot;20&quot; y=&quot;280&quot; width=&quot;400&quot; height=&quot;80&quot; rx=&quot;6&quot; style=&quot;fill:var(--code-bg);stroke:var(--accent);stroke-width:1.5&quot;/&amp;gt;
&amp;lt;g style=&quot;stroke:var(--muted);stroke-width:1.5;fill:none&quot; marker-end=&quot;url(#hlp-arrow)&quot;&amp;gt;
&amp;lt;line x1=&quot;210&quot; y1=&quot;60&quot; x2=&quot;228&quot; y2=&quot;60&quot; marker-end=&quot;url(#hlp-arrow)&quot;/&amp;gt;
&amp;lt;line x1=&quot;325&quot; y1=&quot;100&quot; x2=&quot;325&quot; y2=&quot;148&quot; marker-end=&quot;url(#hlp-arrow)&quot;/&amp;gt;
&amp;lt;line x1=&quot;115&quot; y1=&quot;100&quot; x2=&quot;115&quot; y2=&quot;278&quot; marker-end=&quot;url(#hlp-arrow)&quot;/&amp;gt;
&amp;lt;line x1=&quot;325&quot; y1=&quot;230&quot; x2=&quot;325&quot; y2=&quot;278&quot; marker-end=&quot;url(#hlp-arrow)&quot;/&amp;gt;
&amp;lt;line x1=&quot;115&quot; y1=&quot;360&quot; x2=&quot;115&quot; y2=&quot;408&quot; marker-end=&quot;url(#hlp-arrow)&quot;/&amp;gt;
&amp;lt;line x1=&quot;325&quot; y1=&quot;360&quot; x2=&quot;325&quot; y2=&quot;408&quot; marker-end=&quot;url(#hlp-arrow)&quot;/&amp;gt;
&amp;lt;/g&amp;gt;
&amp;lt;g text-anchor=&quot;middle&quot; style=&quot;fill:var(--fg);font-size:15px;font-weight:600&quot;&amp;gt;
&amp;lt;text x=&quot;115&quot; y=&quot;46&quot;&amp;gt;Laptop&amp;lt;/text&amp;gt;
&amp;lt;text x=&quot;325&quot; y=&quot;46&quot;&amp;gt;Forgejo&amp;lt;/text&amp;gt;
&amp;lt;text x=&quot;325&quot; y=&quot;176&quot;&amp;gt;Semaphore&amp;lt;/text&amp;gt;
&amp;lt;text x=&quot;220&quot; y=&quot;306&quot;&amp;gt;Server&amp;lt;/text&amp;gt;
&amp;lt;text x=&quot;115&quot; y=&quot;436&quot;&amp;gt;Grafana&amp;lt;/text&amp;gt;
&amp;lt;text x=&quot;325&quot; y=&quot;436&quot;&amp;gt;restic → S3&amp;lt;/text&amp;gt;
&amp;lt;/g&amp;gt;
&amp;lt;g text-anchor=&quot;middle&quot; style=&quot;fill:var(--muted);font-size:13px&quot;&amp;gt;
&amp;lt;text x=&quot;115&quot; y=&quot;68&quot;&amp;gt;git push&amp;lt;/text&amp;gt;
&amp;lt;text x=&quot;115&quot; y=&quot;88&quot;&amp;gt;make deploy-tags&amp;lt;/text&amp;gt;
&amp;lt;text x=&quot;325&quot; y=&quot;68&quot;&amp;gt;git repos&amp;lt;/text&amp;gt;
&amp;lt;text x=&quot;325&quot; y=&quot;88&quot;&amp;gt;Actions: lint, tests&amp;lt;/text&amp;gt;
&amp;lt;text x=&quot;325&quot; y=&quot;198&quot;&amp;gt;webhook on release&amp;lt;/text&amp;gt;
&amp;lt;text x=&quot;325&quot; y=&quot;218&quot;&amp;gt;runs one role&amp;lt;/text&amp;gt;
&amp;lt;text x=&quot;220&quot; y=&quot;328&quot;&amp;gt;Debian 13 · ZFS · ~100 containers&amp;lt;/text&amp;gt;
&amp;lt;text x=&quot;220&quot; y=&quot;348&quot;&amp;gt;one Ansible role per service&amp;lt;/text&amp;gt;
&amp;lt;text x=&quot;115&quot; y=&quot;458&quot;&amp;gt;Prometheus, Loki&amp;lt;/text&amp;gt;
&amp;lt;text x=&quot;115&quot; y=&quot;478&quot;&amp;gt;a dashboard per service&amp;lt;/text&amp;gt;
&amp;lt;text x=&quot;115&quot; y=&quot;498&quot;&amp;gt;alerts on Telegram&amp;lt;/text&amp;gt;
&amp;lt;text x=&quot;325&quot; y=&quot;458&quot;&amp;gt;every night&amp;lt;/text&amp;gt;
&amp;lt;text x=&quot;325&quot; y=&quot;478&quot;&amp;gt;checked every week&amp;lt;/text&amp;gt;
&amp;lt;text x=&quot;325&quot; y=&quot;498&quot;&amp;gt;restored every month&amp;lt;/text&amp;gt;
&amp;lt;/g&amp;gt;
&amp;lt;g style=&quot;fill:var(--muted);font-size:12px&quot;&amp;gt;
&amp;lt;text x=&quot;123&quot; y=&quot;194&quot;&amp;gt;deploy&amp;lt;/text&amp;gt;
&amp;lt;text x=&quot;333&quot; y=&quot;128&quot;&amp;gt;release&amp;lt;/text&amp;gt;
&amp;lt;text x=&quot;333&quot; y=&quot;258&quot;&amp;gt;apply&amp;lt;/text&amp;gt;
&amp;lt;text x=&quot;123&quot; y=&quot;390&quot;&amp;gt;metrics, logs&amp;lt;/text&amp;gt;
&amp;lt;text x=&quot;333&quot; y=&quot;390&quot;&amp;gt;data&amp;lt;/text&amp;gt;
&amp;lt;/g&amp;gt;
&amp;lt;/svg&amp;gt;
&amp;lt;/figure&amp;gt;&lt;/p&gt;
&lt;h3&gt;One repo, one playbook&lt;/h3&gt;
&lt;p&gt;One Ansible role per service, all run in order by one playbook. Two variable files hold every setting: one shared, and one for production with the real addresses and the references to the vault, where the secrets are. In broad lines the repo looks like this:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;inventory/
  group_vars/all/common.yml     shared settings: datasets, limits, hostnames
  production/
    hosts.yml                   the one server
    group_vars/all/
      vars.yml                  production values, pointers into the vault
      vault.yml                 secrets, encrypted with ansible-vault
playbooks/
  site.yml                      one play, every role in order
roles/
  &amp;lt;service&amp;gt;/
    tasks/main.yml              directories, config files, compose up
    templates/                  docker-compose.yml.j2 and the config files
    handlers/main.yml           restart when the config changes
  grafana/files/dashboards/     one dashboard per service
scripts/
  deploy-verify.sh              checks after a deploy
docs/                           the why: architecture, runbooks
Makefile                        lint, test, deploy-check, deploy-tags
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The playbook starts by refusing to run on anything that is not Debian 13, then goes through the roles from the base system up:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;  pre_tasks:
    - name: Verify Debian 13 (Trixie)
      ansible.builtin.assert:
        that:
          - ansible_facts[&apos;distribution&apos;] == &quot;Debian&quot;
          - ansible_facts[&apos;distribution_major_version&apos;] == &quot;13&quot;
        fail_msg: &quot;This playbook requires Debian 13 (Trixie)&quot;

  roles:
    - role: common
      tags: [common, security]

    - role: router
      tags: [router, network]
      when: router_enabled | default(false)

    - role: wireguard
      tags: [wireguard, vpn, network]
      when: wireguard_port is defined

    - role: zfs
      tags: [zfs, storage]

    - role: docker
      tags: [docker]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;a href=&quot;https://devncode.it/ansible-homelab-repo-structure/&quot;&gt;Part 2&lt;/a&gt; is about how the repo is organized and why the docs never list services.&lt;/p&gt;
&lt;h3&gt;Deploy one role at a time&lt;/h3&gt;
&lt;p&gt;I don&apos;t change containers on the server by hand. A change is a commit, and a deploy usually touches one role, with a dry run first when the change is risky:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;deploy-check: galaxy ## Dry-run deployment on production
	uv run ansible-playbook $(PLAYBOOK) -i $(INVENTORY) --check --diff

deploy: galaxy ## Deploy to production
	uv run ansible-playbook $(PLAYBOOK) -i $(INVENTORY)

deploy-tags: galaxy ## Deploy specific tags to production (usage: make deploy-tags TAGS=router)
	uv run ansible-playbook $(PLAYBOOK) -i $(INVENTORY) --tags $(TAGS)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;After the deploy, a script checks the container status, the HTTP answer over the real domain and the last minutes of logs. Every push runs yamllint, ansible-lint and the offline tests on Forgejo Actions, and lint has to finish with zero warnings. The apps I write myself have their own pipeline: a tagged release runs test, publish, release and deploy, and the last step calls a Semaphore webhook that applies the app&apos;s role. Part 3 is the deploy path, step by step.&lt;/p&gt;
&lt;h3&gt;Alerts with the threshold on the graph&lt;/h3&gt;
&lt;p&gt;Every service gets a Grafana dashboard, and its alert rules live in the repo with everything else. They arrive on Telegram. The rule is that a panel with an alert draws the alert&apos;s threshold as a line, so the number on the graph and the number that sends the message are the same number. Part 4 is about how I decide what deserves an alert.&lt;/p&gt;
&lt;h3&gt;Backups that get restored&lt;/h3&gt;
&lt;p&gt;Restic copies the container data to S3 every night and keeps 30 days. Once a week a job runs &lt;code&gt;restic check&lt;/code&gt; and reads the data back. Once a month a drill restores every database into a throwaway copy. I learned the hard way that a backup has to be tested: &lt;a href=&quot;https://devncode.it/time-machine-samba-docker/&quot;&gt;part 5&lt;/a&gt; is that story.&lt;/p&gt;
&lt;h2&gt;What it caught, and what it didn&apos;t&lt;/h2&gt;
&lt;p&gt;The discipline doesn&apos;t mean that the monitoring finds everything. Three examples from the last months:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Time Machine.&lt;/strong&gt; The verification of the backup bundle had not run for five months, and no alert said it. I found it by hand, and the alert on the age of the last verification came after (part 5).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The ZFS ARC.&lt;/strong&gt; It was allowed 97% of the RAM, next to all those containers. I found it while going through the memory of the server after an outage (&lt;a href=&quot;https://devncode.it/zfs-arc-max-homelab/&quot;&gt;part 6&lt;/a&gt;).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;authentik.&lt;/strong&gt; One day the Docker daemon restarted. A minute before, the authentik worker had died: it had started before DNS was up and could not find its database. The &lt;code&gt;unless-stopped&lt;/code&gt; policy does not bring back a container that was already stopped when the daemon starts again, so the worker stayed down, and half of authentik with it. No alert fired.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;None of the three was caught by an alert the first time. Each one ended up in the repo: an alert for Time Machine, a cap in the ZFS role, and &lt;code&gt;restart: always&lt;/code&gt; for the critical containers.&lt;/p&gt;
&lt;h2&gt;What it costs, and the weak spot&lt;/h2&gt;
&lt;p&gt;Day to day, it is low effort. Most changes are one role and one &lt;code&gt;make deploy-tags&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;The weak spot is the storage. Two USB disks are amateur stuff: they are hard to expand, and the backup disk is six years old. The next step is probably a real DAS.&lt;/p&gt;
&lt;p&gt;The rest of the series, in the order it makes sense to read it, is on the &lt;a href=&quot;https://devncode.it/series/homelab-like-production/&quot;&gt;series page&lt;/a&gt;.&lt;/p&gt;
</content:encoded></item></channel></rss>